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': 'ηΌθΎ',