From 5c77eab2ce2b8453e82b2057a9e0452224f58a16 Mon Sep 17 00:00:00 2001 From: moonrailgun Date: Fri, 7 Aug 2026 23:19:37 +0800 Subject: [PATCH] fix(agent): retry latest provider error without duplicating submission --- docs/feature-checklist.md | 2 +- docs/features.md | 4 + .../agent/__tests__/dao_chat_view.test.ts | 122 ++++++++++++++++++ .../ui/webui/resources/agent/dao_chat_view.ts | 59 ++++++++- .../webui/resources/agent/i18n/locales/en.ts | 1 + .../resources/agent/i18n/locales/zh-CN.ts | 1 + 6 files changed, 187 insertions(+), 2 deletions(-) diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index 7d2cb5fb..f508bd40 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -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 | diff --git a/docs/features.md b/docs/features.md index 7d9e3bfb..432a61dd 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 diff --git a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_chat_view.test.ts b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_chat_view.test.ts index 4ff4c7b9..ee3f25c9 100644 --- a/src/dao/browser/ui/webui/resources/agent/__tests__/dao_chat_view.test.ts +++ b/src/dao/browser/ui/webui/resources/agent/__tests__/dao_chat_view.test.ts @@ -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( @@ -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); diff --git a/src/dao/browser/ui/webui/resources/agent/dao_chat_view.ts b/src/dao/browser/ui/webui/resources/agent/dao_chat_view.ts index 19f4019f..02ca66f4 100644 --- a/src/dao/browser/ui/webui/resources/agent/dao_chat_view.ts +++ b/src/dao/browser/ui/webui/resources/agent/dao_chat_view.ts @@ -94,6 +94,7 @@ type DaoChatMessage = { model?: string; usage?: {input: number; output: number; cacheRead: number; cacheWrite: number}; stopReason?: string; + errorMessage?: string; }; interface DaoAssistantPair { @@ -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 = + ''; + 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'); @@ -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( @@ -2435,6 +2465,17 @@ export class DaoChatView extends CrLitElement { await this.retryFromUserIndex_(userIdx); } + private async retryErrorById_(errorId: string): Promise { + 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)); + } + private async rewindToAssistantById_(assistantId: string): Promise { const agent = this.agent_; if (!agent || agent.state.isStreaming || this.isStreaming_) return; @@ -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); } @@ -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[0]); const ratio = tokens / ctx; diff --git a/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts b/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts index c5011b6f..0dee69e6 100644 --- a/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts +++ b/src/dao/browser/ui/webui/resources/agent/i18n/locales/en.ts @@ -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', diff --git a/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts b/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts index cf4dd7f0..597e8757 100644 --- a/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts +++ b/src/dao/browser/ui/webui/resources/agent/i18n/locales/zh-CN.ts @@ -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': 'ηΌ–θΎ‘',