Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/feature-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Flagship feature. C++ services + `dao://dao-agent` WebUI + vendor runtime.
| ☐ | MCP exact-target eligibility and terminal lifecycle | `automation/dao_browser_target_policy.{h,cc}`, `mcp/dao_mcp_session_lifecycle_monitor.{h,cc}`, `dao_mcp_end_to_end_browsertest.cc`, `dao_mcp_service.{h,cc}` | 🔴 | Run `DaoMcpEndToEndBrowserTest.*` and the lifecycle filters in `DaoMcpServiceBrowserTest.*`; verify HTTP/HTTPS/literal blank/web-hosted PDF allow, popup/OTR/Guest/internal/extension/DevTools/Agent WebUI/file/data/custom rejection for execution without blocking catalog discovery, exact-owner `TARGET_GONE`, no tab fallback, pre-mutation forbidden switch rejection, Browser/Profile/target/navigation terminal cleanup, no Ready/Disabled status during enabled logical closing, and replacement admission only after the accepted socket disconnects |
| ☐ | MCP startup, packaging, protocol, UI, and rebinding regression sweep | `browser_prefs_mcp.cc.patch`, `chrome_browser_main_extra_parts_profiles*.patch`, `chrome/BUILD_mcp_helper.gn.patch`, `mcp/`, `dao_mcp_approval_dialog.*`, `dao_mcp_control_banner_view.*`, Settings Dao page patches | 🔴 | After Chromium upgrades, verify Local State registration and clean startup/shutdown, owner-only Unix socket/metadata plus helper executable packaging, protocol framing/version/8 MiB and ingress/write bounds, DevTools attach/cancel/detach, approval and banner BrowserView layout, Settings switch/status/enabled-only quick setup/Copy/Stop, stable tab identity across reorder/restore/WebContents replacement, and complete target rebinding/cleanup |
| ☐ | Agent page, selection, element-context, element-screenshot, and PDF-text attachments | `src/dao/.../agent/dao_chat_view.ts`, `dao_page_capture.ts`, `dao_agent_ui.cc` | — | Composer can attach current page, selected text, picked element DOM context, picked element screenshot, and PDF text without losing existing chips |
| ☐ | Agent message actions and code-block insertion | `dao_chat_view.ts`, `dao_share_image.ts`, `dao_page_capture.ts` | — | Copy/share image/regenerate/edit/rewind work on the intended message; code-block insert appears only with a focused page input and inserts at cursor |
| ☐ | Agent message actions, error retry, and code-block insertion | `dao_chat_view.ts`, `dao_share_image.ts`, `dao_page_capture.ts` | — | Copy/share image/regenerate/edit/rewind work on the intended message; the latest provider error can retry the original submission without duplicating its user message or changing earlier timeline entries, while cancelled and historical errors cannot retry; code-block insert appears only with a focused page input and inserts at cursor |
| ☐ | SQLite `Statement::ColumnName()` accessor (agent memory DB) | `sql/statement.{cc,h}.patch` | 🟢 | `//sql` compiles; agent memory store links |
| ☐ | Agent memory histogram variant | `tools/metrics/histograms/metadata/sql/histograms.xml.patch` | 🟢 | `validate_format.py` passes |
| ☐ | Agent long-term memory store, memory context, and memory inspector | `src/dao/.../agent/dao_agent_memory_*`, `dao_memory_context.ts`, `dao_memory_app.ts`, `dao_memory_table.ts`, `dao_settings_view.ts` | — | Memory settings toggles persist; conversation/page context is saved and retrieved; `dao://memory` runs read-only SQL and clear/usage controls work |
Expand Down
4 changes: 4 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ The stack includes: **LLM tool calling**, **long-term memory** (SQLite + FTS5),

**Chat surface**
- `dao_chat_view.ts` — Main conversation view (session resume, skill picker, dynamic chips, composer height tracking, cost stats / usage)
- **Latest Agent error retry** — A terminal provider error exposes a retry
action that reuses the original user submission, replaces only its failed
assistant/tool branch, and preserves every earlier timeline entry; cancelled
and historical errors cannot truncate the conversation
- `dao_chat_history_panel.ts` — History panel
- `dao_compact.ts` — Conversation compaction for context management
- `dao_page_capture.ts` — Convert current page to markdown and insert into the message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,28 @@ describe('dao-chat-view message metadata helpers', () => {
expect(iface.requestUpdate).toHaveBeenCalled();
});

it('does not auto-compact a latest provider error that can be retried',
async () => {
vi.mocked(estimateMessagesTokens).mockReturnValue(900);
const messages = [
{role: 'user', content: 'retry this prompt', dao: {id: 'u1'}},
{
role: 'assistant',
content: [{type: 'text', text: 'Partial response'}],
stopReason: 'error',
errorMessage: 'Provider unavailable',
dao: {id: 'e1'},
},
];
const view = viewWithMessages(messages);
view.agent_.state.model = {contextWindow: 1000};

await view._daoTestMaybeAutoCompactAfterTurn();

expect(compactAgentMessages).not.toHaveBeenCalled();
expect(view.agent_.state.messages).toEqual(messages);
});

it('stays silent when background auto-compaction fails', async () => {
vi.mocked(estimateMessagesTokens).mockReturnValue(810);
vi.mocked(compactAgentMessages).mockRejectedValue(
Expand Down Expand Up @@ -973,6 +995,106 @@ describe('dao-chat-view message metadata helpers', () => {
expect(panel.querySelector('.dao-debug-context-menu-item')).toBeNull();
});

it('shows retry only for the latest terminal agent error', () => {
const latestErrorView = viewWithMessages([
{role: 'user', content: 'keep this request', dao: {id: 'u1'}},
{
role: 'assistant',
content: [{type: 'text', text: 'Partial response before failure'}],
stopReason: 'error',
errorMessage: 'Network unavailable',
dao: {id: 'e1'},
},
]);
const {panel: latestErrorPanel} = attachMessageHosts(latestErrorView);

latestErrorView._daoTestRefreshAssistantActions();

const retry = latestErrorPanel.querySelector(
'.dao-error-retry-btn') as HTMLButtonElement|null;
expect(retry).toBeTruthy();
expect(retry?.title).toBe('chat.message_actions.retry_error_tooltip');

const abortedView = viewWithMessages([
{role: 'user', content: 'cancel this request', dao: {id: 'u2'}},
{
role: 'assistant',
content: [{type: 'text', text: 'Partial response before cancel'}],
stopReason: 'aborted',
errorMessage: 'Request was aborted',
dao: {id: 'e2'},
},
]);
const {panel: abortedPanel} = attachMessageHosts(abortedView);
abortedView._daoTestRefreshAssistantActions();
expect(abortedPanel.querySelector(
'.dao-assistant-actions[data-dao-message-id="e2"] .dao-retry-btn'))
.toBeNull();

const historicalErrorView = viewWithMessages([
{role: 'user', content: 'old request', dao: {id: 'u3'}},
{
role: 'assistant',
content: [{type: 'text', text: 'Partial old response'}],
stopReason: 'error',
errorMessage: 'Old failure',
dao: {id: 'e3'},
},
{role: 'user', content: 'new request', dao: {id: 'u4'}},
{role: 'assistant', content: 'new response', dao: {id: 'a4'}},
]);
const {panel: historicalPanel} = attachMessageHosts(historicalErrorView);
historicalErrorView._daoTestRefreshAssistantActions();
expect(historicalPanel.querySelector(
'.dao-assistant-actions[data-dao-message-id="e3"] .dao-retry-btn'))
.toBeNull();
});

it('retries the failed submission without changing its earlier timeline',
async () => {
const view = viewWithMessages([
{role: 'user', content: 'earlier request', dao: {id: 'u0'}},
{role: 'assistant', content: 'earlier response', dao: {id: 'a0'}},
{
role: 'user-with-attachments',
content: 'retry this request',
attachments: [{id: 'page-1', extractedText: 'original context'}],
dao: {id: 'u1'},
},
{role: 'toolResult', content: 'partial tool output', dao: {id: 't1'}},
{
role: 'assistant',
content: [{type: 'text', text: ''}],
stopReason: 'error',
errorMessage: 'Provider failed',
dao: {id: 'e1'},
},
]);
const {panel} = attachMessageHosts(view);
view._daoTestRefreshAssistantActions();

const retry = panel.querySelector(
'.dao-error-retry-btn') as HTMLButtonElement|null;
retry?.click();

await vi.waitFor(() => {
expect(view.agent_.continue).toHaveBeenCalledTimes(1);
});
expect(view.agent_.state.messages).toEqual([
{role: 'user', content: 'earlier request', dao: {id: 'u0'}},
{role: 'assistant', content: 'earlier response', dao: {id: 'a0'}},
{
role: 'user-with-attachments',
content: 'retry this request',
attachments: [{id: 'page-1', extractedText: 'original context'}],
dao: {id: 'u1'},
},
]);
expect(view.agent_.state.messages.filter(
msg => msg.role === 'user' || msg.role === 'user-with-attachments'))
.toHaveLength(2);
});

it('regenerates from the user paired with the selected assistant', async () => {
const messages = selectedAssistantHistory();
const view = viewWithMessages(messages);
Expand Down
59 changes: 58 additions & 1 deletion src/dao/browser/ui/webui/resources/agent/dao_chat_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ type DaoChatMessage = {
model?: string;
usage?: {input: number; output: number; cacheRead: number; cacheWrite: number};
stopReason?: string;
errorMessage?: string;
};

interface DaoAssistantPair {
Expand Down Expand Up @@ -1851,6 +1852,29 @@ export class DaoChatView extends CrLitElement {
return row;
}

private buildErrorActionRow_(
msg: DaoChatMessage, disabled: boolean): HTMLElement {
const row = document.createElement('div');
row.className =
'dao-message-actions dao-assistant-actions dao-error-actions';
row.dataset['daoMessageId'] = msg.dao?.id || '';
const retrySvg =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
' stroke-width="2" stroke-linecap="round" stroke-linejoin="round"' +
' aria-hidden="true">' +
'<path d="M3 12a9 9 0 1 0 3-6.7"></path>' +
'<path d="M3 4v5h5"></path>' +
'</svg>';
const id = msg.dao?.id || '';
const retry = this.buildActionButton_(
'dao-retry-btn dao-error-retry-btn',
'chat.message_actions.retry_error_tooltip', retrySvg,
() => void this.retryErrorById_(id));
retry.disabled = disabled;
row.appendChild(retry);
return row;
}

private buildUserActionRow_(
msg: DaoChatMessage, disabled: boolean): HTMLElement {
const row = document.createElement('div');
Expand Down Expand Up @@ -2226,7 +2250,13 @@ export class DaoChatView extends CrLitElement {
}
} else if (role === 'assistant') {
const el = assistantEls[assistantCursor++] as HTMLElement | undefined;
if (el && msg.dao?.id && this.isAssistantMessage_(msg)) {
if (el && msg.dao?.id && msg === msgs[msgs.length - 1] &&
this.isRetryableAssistantError_(msg)) {
el.insertAdjacentElement(
'afterend', this.buildErrorActionRow_(msg, disabled));
} else if (el && msg.dao?.id &&
!this.isTerminalAssistantFailure_(msg) &&
this.isAssistantMessage_(msg)) {
const idx = msgs.indexOf(msg);
const canRewind = idx >= 0 && idx !== latestAssistantIdx;
el.insertAdjacentElement(
Expand Down Expand Up @@ -2435,6 +2465,17 @@ export class DaoChatView extends CrLitElement {
await this.retryFromUserIndex_(userIdx);
}

private async retryErrorById_(errorId: string): Promise<void> {
const errorIdx = this.findMessageIndexByDaoId_(errorId);
const messages = this.currentMessages_();
if (errorIdx !== messages.length - 1 ||
!this.isRetryableAssistantError_(messages[errorIdx])) {
return;
}
await this.retryFromUserIndex_(
this.findUserIndexForAssistantIndex_(errorIdx));
}
Comment on lines +2468 to +2477

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Prevent concurrent retry execution.

Line 2475 calls retryFromUserIndex_, which checks agent.state.isStreaming before it awaits clearProactiveSuggestionForManualSend_(). Two quick activations can both pass that check and call agent.continue().

This can start duplicate provider requests for one failed submission. Add a synchronous in-flight guard around the full retry transaction. Add a double-activation test.

Proposed fix
+  private retryInFlight_ = false;
+
   private async retryFromUserIndex_(userIdx: number): Promise<void> {
     const agent = this.agent_;
-    if (!agent || agent.state.isStreaming) return;
+    if (!agent || agent.state.isStreaming || this.retryInFlight_) return;
     const messages = agent.state.messages;
     if (userIdx < 0 || userIdx >= messages.length ||
         !this.isUserMessage_(messages[userIdx])) {
       return;
     }
+    this.retryInFlight_ = true;
+    try {
       agent.state.messages = messages.slice(0, userIdx + 1);
       // ...
       await this.clearProactiveSuggestionForManualSend_();
-    try {
       await agent.continue();
     } catch (e) {
       console.warn('[dao] retry failed', e);
       this.scheduleSaveSession_();
+    } finally {
+      this.retryInFlight_ = false;
     }
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dao/browser/ui/webui/resources/agent/dao_chat_view.ts` around lines 2468
- 2477, Update retryErrorById_ to use a synchronous in-flight guard covering the
entire retry transaction, preventing concurrent activations from reaching
retryFromUserIndex_ and agent.continue(); set the guard before any await and
clear it reliably after completion, while preserving the existing retryability
and final-message checks. Add a test that triggers two retry activations and
verifies only one retry request executes.


private async rewindToAssistantById_(assistantId: string): Promise<void> {
const agent = this.agent_;
if (!agent || agent.state.isStreaming || this.isStreaming_) return;
Expand Down Expand Up @@ -3484,6 +3525,18 @@ export class DaoChatView extends CrLitElement {
!!this.extractVisibleText_(msg as DaoChatMessage);
}

private isRetryableAssistantError_(msg: unknown): msg is DaoChatMessage {
return isRecord(msg) && msg['role'] === 'assistant' &&
msg['stopReason'] === 'error' &&
typeof msg['errorMessage'] === 'string' &&
!!msg['errorMessage'].trim();
}

private isTerminalAssistantFailure_(msg: unknown): boolean {
return isRecord(msg) && msg['role'] === 'assistant' &&
(msg['stopReason'] === 'error' || msg['stopReason'] === 'aborted');
}

private extractVisibleText_(msg: DaoChatMessage): string {
return this.extractAssistantText_(msg);
}
Expand Down Expand Up @@ -3616,6 +3669,10 @@ export class DaoChatView extends CrLitElement {
const msgs = this.currentMessages_().filter(
msg => !this.isDaoLocalMessage_(msg));
if (msgs.length < 2) return;
// Preserve the original submission and its terminal error until the user
// can retry it. Compaction may summarize that user message and appends a
// local notice after the error, either of which would invalidate retry.
if (this.isRetryableAssistantError_(msgs[msgs.length - 1])) return;
const tokens = estimateMessagesTokens(
msgs as unknown as Parameters<typeof estimateMessagesTokens>[0]);
const ratio = tokens / ctx;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const dict: Dictionary = {
'chat.message_actions.copy_tooltip': 'Copy answer text',
'chat.message_actions.share_tooltip': 'Copy as image',
'chat.message_actions.regenerate_tooltip': 'Regenerate response',
'chat.message_actions.retry_error_tooltip': 'Retry request',
'chat.message_actions.rewind_tooltip': 'Rewind to this response',
'chat.message_actions.more_tooltip': 'More actions',
'chat.message_actions.edit': 'Edit',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const dict: Dictionary = {
'chat.message_actions.copy_tooltip': '复制回答文本',
'chat.message_actions.share_tooltip': '复制为图片',
'chat.message_actions.regenerate_tooltip': '重新生成回答',
'chat.message_actions.retry_error_tooltip': '重试请求',
'chat.message_actions.rewind_tooltip': '回到这条回答',
'chat.message_actions.more_tooltip': '更多操作',
'chat.message_actions.edit': '编辑',
Expand Down
Loading