diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 50490edac0..51b23b7387 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -715,7 +715,7 @@ "react": 1 }, "importSpecifiers": 11, - "nonTriviaTokens": 1399 + "nonTriviaTokens": 1395 }, "src/renderer/app-shell.tsx": { "importDeclarations": 79, diff --git a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts index 59c568c674..f9dec247ed 100644 --- a/apps/desktop/src/main/__tests__/session-error-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-error-presentation.test.ts @@ -21,21 +21,13 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { describeSessionErrorReason } from '../../renderer/session-error-presentation.js'; -import { sessionEventErrorMessage } from '../../renderer/model-connection-errors.js'; import { describeTurnErrorClass } from '../../renderer/session-status-presentation.js'; describe('provider capacity presentation', () => { it('uses capacity-specific copy instead of the unknown error fallback', () => { assert.match(describeSessionErrorReason('provider_capacity', 'zh-CN') ?? '', /满载/); assert.match(describeSessionErrorReason('provider_capacity', 'en') ?? '', /at capacity/); - assert.match(describeTurnErrorClass('provider_capacity', 'zh-CN'), /满载/); - assert.match(describeTurnErrorClass('provider_capacity', 'en'), /at capacity/); - }); - - it('does not recommend an immediate direct retry', () => { - const label = describeTurnErrorClass('provider_capacity', 'zh-CN'); - assert.match(label, /等几分钟|换一个模型/); - assert.doesNotMatch(label, /直接重试/); + assert.equal(describeTurnErrorClass('provider_capacity', 'zh-CN'), '模型服务暂时满载。'); + assert.equal(describeTurnErrorClass('provider_capacity', 'en'), 'The model service is temporarily at capacity.'); }); }); - diff --git a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts index 111c5e707f..0119fb04c6 100644 --- a/apps/desktop/src/main/__tests__/session-status-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/session-status-presentation.test.ts @@ -19,6 +19,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import type { TurnViewModel } from '@maka/ui'; +import { deriveAppShellTurnPresentation } from '../../renderer/app-shell-turn-view-model.js'; import { describeFailedTurnExecutionState, describeTurnErrorClass, @@ -39,13 +41,20 @@ describe('failed turn presentation', () => { assert.equal(describeTurnErrorClass('ECONNRESET', 'en'), describeTurnErrorClass('network', 'en')); }); - it('states what to do without promising a resume the UI cannot offer', () => { - for (const errorClass of ['rate_limit', 'network', 'timeout']) { - assert.match(describeTurnErrorClass(errorClass, 'zh-CN'), /重新发消息|再发消息|发消息/); - } - assert.doesNotMatch(describeTurnErrorClass('unknown_failure', 'zh-CN'), /重试|重发/); - assert.match(describeTurnErrorClass('stream_truncated', 'zh-CN'), /中途断开/); - assert.match(describeFailedTurnExecutionState({ ...NOTHING_RAN, retry: { decision: 'declined', because: 'side_effects' } }, 'zh-CN')!, /未自动重试/); + it('shows the failure cause alongside the recorded retry refusal', () => { + const turn: TurnViewModel = { + turnId: 't1', status: 'failed', errorClass: 'network', + retry: { decision: 'declined', because: 'side_effects' }, + tools: [], timeline: [], notes: [], startedAt: 1, + }; + const presentation = deriveAppShellTurnPresentation([turn], { + activeId: 'session-1', pendingTurnActions: new Set(), uiLocale: 'zh-CN', + }); + assert.equal(presentation.failedReasonLabels.t1, '网络连接失败,请检查网络。'); + assert.equal(presentation.failedExecutionStateLabels.t1, + '本次已有工具活动,为避免重复操作,未自动重试。请先检查工具结果。'); + assert.equal(describeTurnErrorClass('rate_limit', 'zh-CN'), '模型请求太频繁被限流了。'); + assert.equal(describeTurnErrorClass('timeout', 'zh-CN'), '模型请求超时。'); }); it('grades continuable outcomes below outcomes the user must act on', () => { @@ -93,3 +102,10 @@ describe('failed turn execution state', () => { }); }); + +it('does not hide a terminal diagnostic behind a sandbox tool failure or promote a tool failure to a failed turn', () => { + const turn: TurnViewModel = { turnId: 't1', status: 'failed', errorClass: 'unknown', failureMessage: 'Provider request failed after the tool result', tools: [{ toolUseId: 'tool-1', toolName: 'Bash', status: 'errored', args: {}, result: { kind: 'text', text: 'Operation not permitted', sandboxDenial: { likely: true } } }], timeline: [], notes: [], startedAt: 1 }; + const context = { activeId: 'session-1', pendingTurnActions: new Set(), uiLocale: 'en' as const }; + assert.ok(deriveAppShellTurnPresentation([turn], context).failedReasonLabels.t1); + assert.equal(deriveAppShellTurnPresentation([{ ...turn, status: 'completed' }], context).failedReasonLabels.t1, undefined); +}); diff --git a/apps/desktop/src/renderer/app-shell-turn-view-model.ts b/apps/desktop/src/renderer/app-shell-turn-view-model.ts index 570de2bc48..7c8f920576 100644 --- a/apps/desktop/src/renderer/app-shell-turn-view-model.ts +++ b/apps/desktop/src/renderer/app-shell-turn-view-model.ts @@ -66,14 +66,7 @@ function isSandboxOnlyToolFailure(turn: TurnViewModel): boolean { const erroredTools = turn.tools.filter((tool) => tool.status === 'errored'); if (erroredTools.length === 0 || !erroredTools.every(isSandboxDeniedTool)) return false; - const errorClass = turn.errorClass?.toLowerCase(); - return ( - errorClass === undefined - || errorClass === 'unknown' - || errorClass === 'tool_failed' - || errorClass === 'sandbox_denial' - || errorClass === 'sandbox_denied' - ); + return [undefined, 'unknown', 'tool_failed', 'sandbox_denial', 'sandbox_denied'].includes(turn.errorClass?.toLowerCase()); } /** @@ -219,7 +212,7 @@ function deriveTurnPresentationEntry(input: { const entry: TurnPresentationEntry = { footerActions }; - if (turn.status === 'failed' && !isSandboxOnlyToolFailure(turn)) { + if (turn.status === 'failed' && (turn.failureMessage || !isSandboxOnlyToolFailure(turn))) { entry.failedReasonLabel = describeTurnErrorClass(turn.errorClass, uiLocale); entry.failedSeverity = deriveFailedTurnSeverity(turn.errorClass); entry.failedExecutionStateLabel = describeFailedTurnExecutionState({ diff --git a/apps/desktop/src/renderer/locales/conversation-copy.ts b/apps/desktop/src/renderer/locales/conversation-copy.ts index b538c0ea67..2c92b47f64 100644 --- a/apps/desktop/src/renderer/locales/conversation-copy.ts +++ b/apps/desktop/src/renderer/locales/conversation-copy.ts @@ -683,7 +683,7 @@ const COPY = { reauth: { label: '上次连接测试鉴权失败', tooltip: '最近一次连接测试返回鉴权失败(401 / 403),密钥可能已过期或被吊销。这不会拦截发送,但若发送失败请到 设置 · 模型 重新登录。' }, testError: { label: '上次连接测试失败', tooltip: '最近一次连接测试因网络 / 超时 / 5xx 失败。这不会拦截发送,但若问题持续请到 设置 · 模型 检查 Base URL / 代理。' }, }, - turnError: { streamTruncated: '响应中途断开。', requestRejected: '模型服务拒绝了请求,请检查模型与请求配置。', retryExhausted: '已达到自动重试次数上限。', retryDeclined: { side_effects: '本次已有工具活动,为避免重复操作,未自动重试。请先检查工具结果。', observable_output: '本次已有部分输出,未自动重试。请先检查已保留的内容。', policy: '按当前重试规则,本次未自动重试。', budget: '本次执行预算已用尽,未自动重试。' }, unknown: '出错了,暂时无法确定原因。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时,重新发消息重试。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载,等几分钟重试,或换一个模型。', rateLimit: '模型请求太频繁被限流了,等一会儿再发消息重试。', network: '网络连接失败,检查网络后重新发消息。', provider: '模型服务返回错误,稍后重试或换一个模型。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。' } }, + turnError: { streamTruncated: '响应中途断开。', requestRejected: '模型服务拒绝了请求,请检查模型与请求配置。', retryExhausted: '已达到自动重试次数上限。', retryDeclined: { side_effects: '本次已有工具活动,为避免重复操作,未自动重试。请先检查工具结果。', observable_output: '本次已有部分输出,未自动重试。请先检查已保留的内容。', policy: '按当前重试规则,本次未自动重试。', budget: '本次执行预算已用尽,未自动重试。' }, unknown: '出错了,暂时无法确定原因。', contextOverflow: '上下文超出模型窗口限制,减少附件或开启新任务。', timeout: '模型请求超时。', auth: '模型鉴权失败,请到设置里重新连接或登录。', providerBilling: '模型服务计费受限,请检查账号余额或订阅状态。', providerCapacity: '模型服务暂时满载。', rateLimit: '模型请求太频繁被限流了。', network: '网络连接失败,请检查网络。', provider: '模型服务返回错误。', stepCap: '达到工具调用步数上限,任务可能没做完。发消息让它继续。', tool: '工具调用失败,看一下上面的工具结果再决定要不要重试。', permission: '这一轮在等权限确认时结束了,重新发消息会再问一次。', restarted: '本地应用重启,上一轮没有完成', sandboxBoundaryClosed: '本地应用重启时,等待确认的「允许访问工作区以外的内容」请求已按拒绝关闭。重新发消息可以再决定一次。', executionState: { erroredTool: '这一轮有工具执行出错,先看它的结果,再决定要不要重发。', toolRan: '这一轮已经执行过工具,可能已经产生实际改动,重发前先看工具结果。' } }, }, 'zh-TW': { actions: { stopFailedTitle: '停止失敗', stopFailedFallback: '任務操作失敗,請稍後重試。', refreshSessionsFailedTitle: '重新整理任務列表失敗', refreshSessionsFailedFallback: '重新整理任務列表失敗,請稍後重試。', conversationErrorTitle: '任務出錯', conversationErrorFallback: '任務執行失敗,請稍後重試。', regenerateStartedTitle: '已發起重新生成', regenerateStartedDescription: '正在生成新的一輪迴答', branchCreatedTitle: '已建立分支', branchCreatedDescription: (name) => `新任務 ${name}`, revisionStartedTitle: '已建立修改版草稿', revisionStartedDescription: '原任務仍會保留;修改後傳送將在新版本中繼續', revisionReadyTitle: '可以修改並重發了', revisionReadyDescription: '已回到該訊息之前;編輯後傳送即可', revisionUnavailableTitle: '暫時無法編輯這條訊息', revisionAttachmentsUnsupported: '包含附件的歷史訊息暫不支援編輯並重發,請複製文字後建立訊息。', revisionTransformedTextUnsupported: '透過顯式技能傳送的歷史訊息暫不支援編輯並重發,請複製文字後重新選擇技能。', revisionDraftAttachmentConflict: 'Composer 中已有待發送附件,請先發送或移除附件,再編輯歷史訊息。', revisionCommandUnsupported: '修改訊息時不能執行 /compact、/side 或編排命令,請取消修改後再試。', revisionAlreadyActive: '已有一條訊息正在修改,請先發送或取消目前修改。', revisionCancelLabel: '取消', revisionBannerTitle: '正在修改已傳送訊息', revisionBannerDetail: '· 傳送後建立新版本', revisionUnchanged: '內容沒有變化。如需重新回答,請使用“重新生成”。', operationFailedTitle: '操作失敗', operationFailedFallback: '任務操作失敗,請稍後重試。', attachmentFailedTitle: '新增附件失敗', imageAttachmentNotDirectTitle: '圖片已作為附件新增', imageAttachmentNotDirectDescription: '目前模型不會直接接收圖片。圖片已作為附件提供給模型。', tryAgain: '請稍後重試。', modelReboundTitle: '已切換到可用模型', modelReboundDescription: (modelId) => `原任務使用的連線已不可用${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: '讀取任務失敗', scrollMainToBottom: '滾動主對話到底部' }, @@ -914,7 +914,7 @@ const COPY = { reauth: { label: '上次連線測試鑑權失敗', tooltip: '最近一次連線測試回傳鑑權失敗(401 / 403),金鑰可能已過期或被吊銷。這不會攔截發送,但若傳送失敗請到 設定 · 模型 重新登入。' }, testError: { label: '上次連線測試失敗', tooltip: '最近一次連線測試因網路 / 超時 / 5xx 失敗。這不會攔截發送,但若問題持續請到 設定 · 模型 檢查 Base URL / 代理。' }, }, - turnError: { streamTruncated: '回應中途斷開。', requestRejected: '模型服務拒絕了請求,請檢查模型與請求設定。', retryExhausted: '已達到自動重試次數上限。', retryDeclined: { side_effects: '本次已有工具活動,為避免重複操作,未自動重試。請先檢查工具結果。', observable_output: '本次已有部分輸出,未自動重試。請先檢查已保留的內容。', policy: '依目前重試規則,本次未自動重試。', budget: '本次執行預算已用盡,未自動重試。' }, unknown: '出錯了,暫時無法確定原因。', contextOverflow: '上下文超出模型視窗限制,減少附件或開啟新任務。', timeout: '模型請求逾時,重新傳送訊息重試。', auth: '模型鑑權失敗,請到設定裡重新連線或登入。', providerBilling: '模型服務計費受限,請檢查帳號餘額或訂閱狀態。', providerCapacity: '模型服務暫時滿載,請等待幾分鐘或切換模型。', rateLimit: '模型請求太頻繁而受到速率限制,請稍候再傳送訊息重試。', network: '網路連線失敗,檢查網路後重新傳送訊息。', provider: '模型服務回傳錯誤,稍後重試或切換模型。', stepCap: '達到工具呼叫步數上限,任務可能尚未完成。傳送訊息讓它繼續。', tool: '工具呼叫失敗,先看上面的工具結果再決定是否重試。', permission: '這一輪在等待權限確認時結束,重新傳送訊息會再詢問一次。', restarted: '本機應用程式重啟,上一輪沒有完成', sandboxBoundaryClosed: '本機應用程式重啟時,等待確認的「允許存取工作區以外的內容」請求已按拒絕關閉。重新傳送訊息可以再次決定。', executionState: { erroredTool: '這一輪有工具執行出錯,先看它的結果,再決定是否重發。', toolRan: '這一輪已經執行過工具,可能已經產生實際變更,重發前先看工具結果。' } }, + turnError: { streamTruncated: '回應中途斷開。', requestRejected: '模型服務拒絕了請求,請檢查模型與請求設定。', retryExhausted: '已達到自動重試次數上限。', retryDeclined: { side_effects: '本次已有工具活動,為避免重複操作,未自動重試。請先檢查工具結果。', observable_output: '本次已有部分輸出,未自動重試。請先檢查已保留的內容。', policy: '依目前重試規則,本次未自動重試。', budget: '本次執行預算已用盡,未自動重試。' }, unknown: '出錯了,暫時無法確定原因。', contextOverflow: '上下文超出模型視窗限制,減少附件或開啟新任務。', timeout: '模型請求逾時。', auth: '模型鑑權失敗,請到設定裡重新連線或登入。', providerBilling: '模型服務計費受限,請檢查帳號餘額或訂閱狀態。', providerCapacity: '模型服務暫時滿載。', rateLimit: '模型請求太頻繁而受到速率限制。', network: '網路連線失敗,請檢查網路。', provider: '模型服務回傳錯誤。', stepCap: '達到工具呼叫步數上限,任務可能尚未完成。傳送訊息讓它繼續。', tool: '工具呼叫失敗,先看上面的工具結果再決定是否重試。', permission: '這一輪在等待權限確認時結束,重新傳送訊息會再詢問一次。', restarted: '本機應用程式重啟,上一輪沒有完成', sandboxBoundaryClosed: '本機應用程式重啟時,等待確認的「允許存取工作區以外的內容」請求已按拒絕關閉。重新傳送訊息可以再次決定。', executionState: { erroredTool: '這一輪有工具執行出錯,先看它的結果,再決定是否重發。', toolRan: '這一輪已經執行過工具,可能已經產生實際變更,重發前先看工具結果。' } }, }, en: { actions: { stopFailedTitle: 'Failed to stop', stopFailedFallback: 'The task action failed. Try again later.', refreshSessionsFailedTitle: 'Failed to refresh tasks', refreshSessionsFailedFallback: 'The task list could not be refreshed. Try again later.', conversationErrorTitle: 'Task error', conversationErrorFallback: 'The task run failed. Try again later.', regenerateStartedTitle: 'Regeneration started', regenerateStartedDescription: 'Generating a new response', branchCreatedTitle: 'Branch created', branchCreatedDescription: (name) => `New task: ${name}`, revisionStartedTitle: 'Edit draft ready', revisionStartedDescription: 'The original task is kept; sending creates a new version', revisionReadyTitle: 'Ready to edit and resend', revisionReadyDescription: 'Rewound to before that message; edit and send when ready', revisionUnavailableTitle: 'This message cannot be edited yet', revisionAttachmentsUnsupported: 'Edit & resend does not yet support historical attachments. Copy the text into a new message instead.', revisionTransformedTextUnsupported: 'Edit & resend does not yet support messages sent with an explicit skill. Copy the text and select the skill again instead.', revisionDraftAttachmentConflict: 'The composer already has pending attachments. Send or remove them before editing a sent message.', revisionCommandUnsupported: 'You cannot run /compact, /side, or orchestration commands while editing a sent message. Cancel the edit first.', revisionAlreadyActive: 'Another message is already being edited. Send or cancel that edit first.', revisionCancelLabel: 'Cancel', revisionBannerTitle: 'Editing sent message', revisionBannerDetail: '· New version on send', revisionUnchanged: 'Nothing changed. Use Regenerate if you only want a new answer.', operationFailedTitle: 'Action failed', operationFailedFallback: 'The task action failed. Try again later.', attachmentFailedTitle: 'Failed to add attachment', imageAttachmentNotDirectTitle: 'Image added as an attachment', imageAttachmentNotDirectDescription: 'The current model does not receive images directly. The image has been provided as an attachment.', tryAgain: 'Try again later.', modelReboundTitle: 'Switched to an available model', modelReboundDescription: (modelId) => `The previous connection is unavailable${modelId ? ` · ${modelId}` : ''}`, messageReadFailedTitle: 'Failed to load task', scrollMainToBottom: 'Scroll main conversation to bottom' }, @@ -1161,7 +1161,7 @@ const COPY = { reauth: { label: 'Last connection test failed authentication', tooltip: 'The latest test returned 401 / 403. Sending is not blocked, but sign in again under Settings · Models if it fails.' }, testError: { label: 'Last connection test failed', tooltip: 'The latest test failed because of a network, timeout, or 5xx error. Sending is not blocked; check Base URL or proxy settings if it persists.' }, }, - turnError: { streamTruncated: 'The response stream ended before completion.', requestRejected: 'The model service rejected the request. Check the model and request configuration.', retryExhausted: 'The automatic retry limit was reached.', retryDeclined: { side_effects: 'Tool activity already occurred in this attempt. Automatic retry was declined to avoid repeating operations. Check the tool results first.', observable_output: 'This attempt already produced output, so it was not retried automatically. Check the retained content first.', policy: 'This attempt was not retried under the current retry policy.', budget: 'The execution budget was exhausted, so this attempt was not retried automatically.' }, unknown: 'Something went wrong; the cause is unknown.', contextOverflow: 'Context exceeded the model window. Reduce attachments or start a new task.', timeout: 'The model request timed out. Send a message to retry.', auth: 'Model authentication failed. Reconnect or sign in again from Settings.', providerBilling: 'Model billing is restricted. Check the account balance or subscription.', providerCapacity: 'The model service is temporarily at capacity. Wait a few minutes, or switch models.', rateLimit: 'Requests were rate-limited. Wait a moment, then send a message to retry.', network: 'The network connection failed. Check the network, then send a message again.', provider: 'The model service returned an error. Retry later, or switch models.', stepCap: 'The tool-step limit was reached, so the task may be incomplete. Send a message to continue.', tool: 'A tool call failed. Check the tool result above before deciding whether to retry.', permission: 'This turn ended while waiting for permission. Send a message and it will ask again.', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied. Send a message to decide again.', executionState: { erroredTool: 'A tool errored during this turn. Read its result before deciding whether to send another message.', toolRan: 'Tools already ran during this turn and may have made real changes. Read their results before sending another message.' } }, + turnError: { streamTruncated: 'The response stream ended before completion.', requestRejected: 'The model service rejected the request. Check the model and request configuration.', retryExhausted: 'The automatic retry limit was reached.', retryDeclined: { side_effects: 'Tool activity already occurred in this attempt. Automatic retry was declined to avoid repeating operations. Check the tool results first.', observable_output: 'This attempt already produced output, so it was not retried automatically. Check the retained content first.', policy: 'This attempt was not retried under the current retry policy.', budget: 'The execution budget was exhausted, so this attempt was not retried automatically.' }, unknown: 'Something went wrong; the cause is unknown.', contextOverflow: 'Context exceeded the model window. Reduce attachments or start a new task.', timeout: 'The model request timed out.', auth: 'Model authentication failed. Reconnect or sign in again from Settings.', providerBilling: 'Model billing is restricted. Check the account balance or subscription.', providerCapacity: 'The model service is temporarily at capacity.', rateLimit: 'Requests were rate-limited.', network: 'The network connection failed. Check the network.', provider: 'The model service returned an error.', stepCap: 'The tool-step limit was reached, so the task may be incomplete. Send a message to continue.', tool: 'A tool call failed. Check the tool result above before deciding whether to retry.', permission: 'This turn ended while waiting for permission. Send a message and it will ask again.', restarted: 'The app restarted before the previous turn completed', sandboxBoundaryClosed: 'The app restarted, so the pending request to reach outside the workspace was closed as denied. Send a message to decide again.', executionState: { erroredTool: 'A tool errored during this turn. Read its result before deciding whether to send another message.', toolRan: 'Tools already ran during this turn and may have made real changes. Read their results before sending another message.' } }, }, } satisfies UiCatalog; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 629a81c71c..428e72dd4d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -799,7 +799,7 @@ export const ProviderStreamTruncated: Story = { chat={{ messages: [ user('msg-st-1', 'turn-st', 4, '检查项目的构建结果。'), { type: 'assistant', id: 'msg-st-answer', turnId: 'turn-st', ts: NOW - 199_000, text: '构建已完成,我继续检查输出。', modelId: 'claude-sonnet-4-5' }, - { type: 'turn_state', id: 'state-st-failed', turnId: 'turn-st', ts: NOW - 198_000, status: 'failed', errorClass: 'stream_truncated', retry: { decision: 'declined', because: 'side_effects' } }, + { type: 'turn_state', id: 'state-st-failed', turnId: 'turn-st', ts: NOW - 198_000, status: 'failed', errorClass: 'stream_truncated', failureMessage: 'Response stream ended without a finish reason. (status=502, requestId=req-stream-4502)', retry: { decision: 'declined', because: 'side_effects' } }, ] }} /> ), @@ -820,7 +820,7 @@ export const ProviderRateLimited: Story = { messages: [ user('msg-r-1', 'turn-r', 4, '再生成三个对照方案,越详细越好。'), { type: 'turn_state', id: 'state-r-running', turnId: 'turn-r', ts: NOW - 200_000, status: 'running' }, - { type: 'turn_state', id: 'state-r-failed', turnId: 'turn-r', ts: NOW - 198_000, status: 'failed', errorClass: 'rate_limit' }, + { type: 'turn_state', id: 'state-r-failed', turnId: 'turn-r', ts: NOW - 198_000, status: 'failed', errorClass: 'rate_limit', failureMessage: 'Quota exceeded for this account. Check the provider quota before submitting another request. (code=insufficient_quota, status=429, requestId=req-4502)' }, ], }} /> @@ -834,6 +834,43 @@ export const ProviderRateLimited: Story = { }, }; +// The same turn moves from running to its durable terminal contribution. +function FailureArrival() { + const [failed, fail] = useReducer(() => true, false); + useEffect(() => { + window.addEventListener('storybook:turn-failed', fail); + return () => window.removeEventListener('storybook:turn-failed', fail); + }, []); + return ; +} + +export const FailureArrivesLive: Story = { + render: () => , + play: async ({ canvasElement }) => { + expect(canvasElement.querySelector('.maka-turn-failed-banner')).toBeNull(); + window.dispatchEvent(new Event('storybook:turn-failed')); + await waitFor(() => expect(canvasElement.querySelector('.maka-turn-failed-banner')?.textContent).toContain('本次已有部分输出')); + const toggle = canvasElement.querySelector('.maka-turn-failed-banner button[aria-expanded]')!; + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + toggle.focus(); + toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await waitFor(() => expect(toggle.getAttribute('aria-expanded')).toBe('true')); + expect(document.activeElement).toBe(toggle); + expect(canvasElement.querySelector('.maka-turn-failure-detail')?.textContent).toBe('Response stream ended without a finish reason.'); + }, +}; + +export const LongFailureDiagnostic: Story = { + render: () => , +}; + // Real path: the provider throttles a live request and Runtime schedules a // retry. The running turn swaps its working phrase for the retry Banner // (`ModelProviderRetryIndicator`) — the "retrying" state no story reached. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index c4a5285eac..b495caed0d 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -17,7 +17,11 @@ * under the License. */ -import { isModelRetryDecision, type ModelRetryDecision } from './model-failure.js'; +import { + MODEL_FAILURE_MESSAGE_MAX_BYTES, + isModelRetryDecision, + type ModelRetryDecision, +} from './model-failure.js'; import { decodeMessageContent, @@ -912,6 +916,7 @@ export interface TurnStateMessage { /** Diagnostic source for user/renderer-triggered aborts, e.g. renderer.stop_button. */ abortSource?: string; errorClass?: string; + failureMessage?: string; retry?: ModelRetryDecision; } @@ -1121,6 +1126,7 @@ export interface TurnRecord { abortedAt?: number; abortSource?: string; errorClass?: string; + failureMessage?: string; retry?: ModelRetryDecision; } @@ -1261,6 +1267,7 @@ const TURN_STATE_MESSAGE_SHAPE = defineObjectShape()( 'abortedAt', 'abortSource', 'errorClass', + 'failureMessage', 'retry', ], ['partialOutputRetained'], @@ -1542,6 +1549,10 @@ function decodeMessage( (message.abortedAt === undefined || isFiniteNumber(message.abortedAt)) && isOptionalString(message.abortSource) && isOptionalString(message.errorClass) && + (message.failureMessage === undefined || + (typeof message.failureMessage === 'string' && + new TextEncoder().encode(message.failureMessage).byteLength <= + MODEL_FAILURE_MESSAGE_MAX_BYTES)) && (message.retry === undefined || isModelRetryDecision(message.retry)) ) return pickShape(message as unknown as TurnStateMessage, TURN_STATE_MESSAGE_SHAPE); @@ -1831,6 +1842,7 @@ export function deriveTurnRecords(messages: readonly StoredMessage[]): TurnRecor ...(latestState.abortedAt !== undefined ? { abortedAt: latestState.abortedAt } : {}), ...(latestState.abortSource ? { abortSource: latestState.abortSource } : {}), ...(latestState.errorClass ? { errorClass: latestState.errorClass } : {}), + ...(latestState.failureMessage ? { failureMessage: latestState.failureMessage } : {}), ...(latestState.retry ? { retry: latestState.retry } : {}), }; } diff --git a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts index 23410e23f6..00fc141529 100644 --- a/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts +++ b/packages/runtime-host/src/__tests__/session-transcript-pager.test.ts @@ -88,6 +88,7 @@ test('preserves the canonical retry decision in shared bootstrap and later pages status: 'failed', errorClass: 'stream_truncated', retry: { decision: 'declined', because: 'side_effects' }, + failureMessage: 'Private provider diagnostic', }, ]; const reader = transcriptReader(durable); @@ -101,6 +102,7 @@ test('preserves the canonical retry decision in shared bootstrap and later pages maxBytes: 1024, projection: 'shared', }); + assert.equal(decodeBootstrap(bootstrap.durable)[0]?.failureMessage, undefined); assert.deepEqual(decodeBootstrap(bootstrap.durable)[0]?.retry, { decision: 'declined', because: 'side_effects', @@ -113,6 +115,7 @@ test('preserves the canonical retry decision in shared bootstrap and later pages status: 'failed', errorClass: 'stream_truncated', retry: { decision: 'exhausted', attempts: 2 }, + failureMessage: 'Another private diagnostic', }); updateSubscriberTranscriptHighWater(state, 1); const page = await readSessionTranscriptPage({ @@ -128,6 +131,7 @@ test('preserves the canonical retry decision in shared bootstrap and later pages maxBytes: 1024, }, }); + assert.equal(decodeBootstrap(page)[0]?.failureMessage, undefined); assert.deepEqual(decodeBootstrap(page)[0]?.retry, { decision: 'exhausted', attempts: 2 }); }); diff --git a/packages/runtime-host/src/__tests__/session-turns.test.ts b/packages/runtime-host/src/__tests__/session-turns.test.ts index 5e89f7a914..d311a68ed9 100644 --- a/packages/runtime-host/src/__tests__/session-turns.test.ts +++ b/packages/runtime-host/src/__tests__/session-turns.test.ts @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import test from 'node:test'; import { decodeSessionTurnsQueryResult, @@ -100,6 +101,7 @@ test('bounds turn diagnostics before publishing a contribution', () => { ts: 1, status: 'failed', errorClass: '失败'.repeat(100_000), + failureMessage: '失败'.repeat(100_000), retry: { decision: 'declined', because: 'side_effects' }, }, }, @@ -119,7 +121,11 @@ test('bounds turn diagnostics before publishing a contribution', () => { }), ); const turn = projectSessionTurnContribution(contribution); - assert.deepEqual(turn?.retry, { decision: 'declined', because: 'side_effects' }); + assert.ok(turn); + assert.ok(turn.failureMessage); + assert.ok(Buffer.byteLength(turn.failureMessage) <= MODEL_FAILURE_MESSAGE_MAX_BYTES); + assert.equal(turn.failureMessage, contribution.latestState!.message.failureMessage); + assert.deepEqual(turn.retry, { decision: 'declined', because: 'side_effects' }); }); test('rejects invalid turn-state references before publishing a contribution', () => { diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 4d3e75ccca..20fe84108d 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 129 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 130 as const; +// 130: Turn contributions carry the optional bounded `failureMessage` diagnostic. +// Epoch-129 peers reject this added field on the strict contribution shape. // 129: Turn states and Turn records drop `partialOutputRetained`. The fact was // derived twice — once from the Turn's output rows, once off the state message // — and read by nothing; older peers require the field on both. diff --git a/packages/runtime-host/src/protocol/session-turns.ts b/packages/runtime-host/src/protocol/session-turns.ts index 0a3f682381..136f7883f4 100644 --- a/packages/runtime-host/src/protocol/session-turns.ts +++ b/packages/runtime-host/src/protocol/session-turns.ts @@ -17,6 +17,7 @@ * under the License. */ +import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import { decodeCanonicalMessage, type TurnRecord, type TurnStateMessage } from '@maka/core/session'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { @@ -164,6 +165,15 @@ function projectTurnStateMessageForWire(message: TurnStateMessage): TurnStateMes ...(message.errorClass ? { errorClass: truncateUtf8(message.errorClass, SESSION_TURN_DIAGNOSTIC_MAX_BYTES) } : {}), + ...(message.failureMessage + ? { + failureMessage: truncateUtf8( + message.failureMessage, + MODEL_FAILURE_MESSAGE_MAX_BYTES, + '…', + ), + } + : {}), ...(message.retry ? { retry: message.retry } : {}), }; } @@ -197,6 +207,7 @@ export function projectSessionTurnContribution( ...(state.abortedAt !== undefined ? { abortedAt: state.abortedAt } : {}), ...(state.abortSource ? { abortSource: state.abortSource } : {}), ...(state.errorClass ? { errorClass: state.errorClass } : {}), + ...(state.failureMessage ? { failureMessage: state.failureMessage } : {}), ...(state.retry ? { retry: state.retry } : {}), }; } diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 06a04e4b93..83a86c1c6e 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -7280,7 +7280,7 @@ describe('AiSdkBackend model history', () => { }); describe('AiSdkBackend error surfaces', () => { - test('generalizes model setup errors before emitting renderer events', async () => { + test('preserves model setup diagnostics in renderer events', async () => { const backend = createBackend({ connection: connection(), apiKey: 'sk-live-secret-token-value', @@ -7300,8 +7300,7 @@ describe('AiSdkBackend error surfaces', () => { const error = events.find( (event): event is Extract => event.type === 'error', ); - assert.equal(error?.message, '401 Authorization: Bearer [redacted]'); - assert.equal(JSON.stringify(events).includes('sk-live-secret-token-value'), false); + assert.equal(error?.message, '401 Authorization: Bearer sk-live-secret-token-value'); }); test('stops after a T1 rejection only after sibling tool calls settle', async () => { diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index fe1814a860..a56a940153 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -917,13 +917,13 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(event.message, 'fetch failed'); }); - test('retains a safe bounded summary from an unknown structured provider error', () => { + test('retains an unredacted bounded summary from an unknown structured provider error', () => { const adapter = newAdapter(); const failure = adapter.normalizeFailure({ type: 'error', error: { code: 'provider_error', - message: `provider exploded api_key=sk-live-secret-token-value ${'x'.repeat(4_000)}`, + message: `provider exploded api_key=sk-test-diagnostic-value ${'x'.repeat(4_000)}`, }, request_id: 'req-123', }); @@ -931,10 +931,9 @@ describe('ModelAdapter stream and error normalization', () => { assert.equal(event.reason, 'unknown'); assert.equal(event.code, 'provider_error'); - assert.match(event.message, /^provider exploded api_key=\[redacted\]/); + assert.ok(event.message.startsWith('provider exploded api_key=sk-test-diagnostic-value ')); assert.match(event.message, /… \(code=provider_error, requestId=req-123\)$/); assert.equal(Buffer.byteLength(event.message, 'utf8') <= 2 * 1024, true); - assert.equal(event.message.includes('sk-live-secret-token-value'), false); }); test('normalizes cache and reasoning usage variants in the adapter module', () => { diff --git a/packages/runtime/src/__tests__/provider-error-classification.test.ts b/packages/runtime/src/__tests__/provider-error-classification.test.ts index b3f8dd6ad6..40a0f44cc8 100644 --- a/packages/runtime/src/__tests__/provider-error-classification.test.ts +++ b/packages/runtime/src/__tests__/provider-error-classification.test.ts @@ -210,7 +210,7 @@ describe('Provider error classification', () => { test('extracts allowlisted fields from JSON string failures without copying the payload', () => { const summary = providerModelFailure( JSON.stringify({ - error: { message: 'provider rejected request', code: 'bad_request' }, + error: { message: 'Invalid api_key=sk-test-diagnostic-value', code: 'bad_request' }, request_id: 'req-123', prompt: 'private customer text', headers: { 'x-debug': 'internal' }, @@ -218,7 +218,7 @@ describe('Provider error classification', () => { ); assert.partialDeepStrictEqual(summary, { - message: 'provider rejected request (code=bad_request, requestId=req-123)', + message: 'Invalid api_key=sk-test-diagnostic-value (code=bad_request, requestId=req-123)', code: 'bad_request', }); assert.equal(JSON.stringify(summary).includes('private customer text'), false); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 880ffdefd3..aea8ca2ef5 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -18,13 +18,14 @@ */ import assert from 'node:assert/strict'; +import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import { describe, test } from 'node:test'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { CreateSessionInput, SessionListFilter } from '@maka/core/runtime-inputs'; import type { RuntimeEvent, RuntimeEventActions } from '@maka/core/runtime-event'; import { runtimeEventHasModelVisibleContent } from '@maka/core/runtime-event'; import type { SessionHeader, SessionSummary, StoredMessage, TurnRecord } from '@maka/core/session'; -import { deriveTurnRecords } from '@maka/core/session'; +import { deriveTurnRecords, decodeCanonicalMessage } from '@maka/core/session'; import { isHardRuntimeEventReadModelDiagnostic, isUnclaimedRuntimeEventDiagnostic, @@ -1579,6 +1580,53 @@ describe('projectRuntimeEventsToStoredMessages', () => { assert.deepStrictEqual(out.diagnostics, []); }); + test('failure diagnostics survive terminal projection and serialization round-trip', () => { + const message = 'Quota exceeded for api_key=sk-test-diagnostic-value (status=429)'; + const out = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'provider-failed', + status: 'failed', + content: { + kind: 'error', + message, + retry: { decision: 'declined', because: 'side_effects' }, + }, + actions: { endInvocation: true, stateDelta: { failureClass: 'rate_limit' } }, + }), + ], + { invocations: [endedAs('failed', 'rate_limit')] }, + ); + const live = deriveTurnRecords(out.messages)[0]; + const roundTripped = deriveTurnRecords( + JSON.parse(JSON.stringify(out.messages)).map(decodeCanonicalMessage), + )[0]; + assert.equal(live.failureMessage, message); + assert.deepEqual(roundTripped, live); + assert.equal(live.errorClass, 'rate_limit'); + assert.throws(() => + decodeCanonicalMessage({ ...out.messages[0], failureMessage: '界'.repeat(2048) }), + ); + assert.deepEqual(live.retry, { decision: 'declined', because: 'side_effects' }); + }); + + test('bounds terminal diagnostics before publishing decodable turn states', () => { + const out = projectRuntimeEventsToStoredMessages( + [ + ev({ + status: 'failed', + content: { kind: 'error', message: '界'.repeat(2048) }, + actions: { endInvocation: true, stateDelta: { failureClass: 'unknown' } }, + }), + ], + { invocations: [endedAs('failed', 'unknown')] }, + ); + const turn = deriveTurnRecords(out.messages)[0]; + assert.ok(turn.failureMessage?.startsWith('界')); + assert.ok(Buffer.byteLength(turn.failureMessage!) <= MODEL_FAILURE_MESSAGE_MAX_BYTES); + assert.doesNotThrow(() => out.messages.map(decodeCanonicalMessage)); + }); + test('a session written with the retired context_budget_exhausted reads back as context_overflow', () => { // The runtime no longer decides locally that a request cannot be made to // fit, so that outcome is gone from the live contract. Sessions persisted diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index 9fc4afd975..46729664d0 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -20,6 +20,7 @@ import { deferred, nextId } from '@maka/core/test-only/async-primitives'; import { describe, test } from 'node:test'; import assert from 'node:assert/strict'; +import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import { setTimeout as timerDelay } from 'node:timers/promises'; import { deriveTurnRecords } from '@maka/core/session'; import { DurableStoreWriteError, RunSealedError } from '@maka/core/runtime-event-store'; @@ -221,6 +222,7 @@ describe('SessionManager terminal ledger invariants', () => { }); test('error streams persist a failed terminal fact without non-terminal error ledger rows', async () => { + const diagnostic = 'Provider failed with api_key=sk-test-diagnostic-value'; const store = new TinySessionStore(); const { manager, runStore, session } = await makeHarness( [ @@ -228,7 +230,7 @@ describe('SessionManager terminal ledger invariants', () => { type: 'error', recoverable: false, reason: 'stream_truncated', - message: 'Response stream ended without a finish reason.', + message: diagnostic, retry: { decision: 'declined', because: 'side_effects' }, }, { type: 'complete', stopReason: 'end_turn' }, @@ -281,13 +283,14 @@ describe('SessionManager terminal ledger invariants', () => { const terminal = restored.find(isTerminalRuntimeEvent)!; assert.equal( terminal.content?.kind === 'error' ? terminal.content.message : undefined, - 'Response stream ended without a finish reason.', + diagnostic, ); assert.equal(runtimeEventHasModelVisibleContent(terminal), false); assert.ok(Buffer.byteLength(JSON.stringify(terminal)) < 4096); const cold = projectRuntimeEventsToStoredMessages(restored, { invocations: [run] }); assert.deepEqual(cold.diagnostics, []); const coldTurn = deriveTurnRecords(cold.messages).find((turn) => turn.turnId === 'turn-1')!; + assert.equal(coldTurn.failureMessage, diagnostic); assert.equal(coldTurn.errorClass, turnState.errorClass); assert.deepEqual(coldTurn.retry, turnState.retry); }); @@ -1228,6 +1231,33 @@ describe('SessionManager terminal ledger invariants', () => { ); }); + test('caught failures keep diagnostic text within the terminal byte budget', async () => { + const store = new TinySessionStore(); + const runStore = new TinyAgentRunStore(); + const session = await store.create(makeInput()); + const run = new AgentRun({ + sessionId: session.id, + header: session, + userInput: { turnId: 'turn-1', text: 'hello' }, + runStore, + runtimeEventStore: runStore, + newId: nextId(), + now: nextNow(40_000), + hooks: inertAgentRunHooks(store), + }); + const prefix = 'api_key=sk-test-diagnostic-value '; + await run.recordFailure(new Error(prefix + '界'.repeat(2048))); + await run.finalize(); + const terminal = (await runStore.readRuntimeEvents(session.id, run.runId)).find( + isTerminalRuntimeEvent, + ); + const content = terminal?.content; + assert.equal(content?.kind, 'error'); + if (content?.kind !== 'error') throw new Error('missing failure diagnostic'); + assert.ok(content.message.startsWith(prefix)); + assert.ok(Buffer.byteLength(content.message) <= MODEL_FAILURE_MESSAGE_MAX_BYTES); + }); + test('direct AgentRun finalize synthesizes a failed terminal fact when no terminal event was recorded', async () => { const store = new TinySessionStore(); const runStore = new TinyAgentRunStore(); diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 012352af12..124d79ff28 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -8170,6 +8170,7 @@ describe('SessionManager permission mode updates', () => { ts: 103, status: 'failed', errorClass: 'tool_failed', + failureMessage: 'tool failed', }); assert.strictEqual(runtimeEvents.filter((event) => event.status === 'failed').length, 1); }); diff --git a/packages/runtime/src/__tests__/subscription-model-fetch.test.ts b/packages/runtime/src/__tests__/subscription-model-fetch.test.ts index 66e6492f40..fc0152d6ca 100644 --- a/packages/runtime/src/__tests__/subscription-model-fetch.test.ts +++ b/packages/runtime/src/__tests__/subscription-model-fetch.test.ts @@ -260,7 +260,12 @@ describe('subscription model fetch', () => { fetchFn: async () => { attempts += 1; return Response.json( - { error: { message: 'account is not authorized', code: 'account_not_authorized' } }, + { + error: { + message: 'Invalid api_key=sk-test-diagnostic-value', + code: 'account_not_authorized', + }, + }, { status: 403, headers: { 'x-request-id': 'req-codex-403' } }, ); }, @@ -275,6 +280,7 @@ describe('subscription model fetch', () => { (error) => { assert.ok(error instanceof Error); assert.match(error.message, /Codex OAuth request failed: HTTP 403/); + assert.ok(error.message.includes('api_key=sk-test-diagnostic-value')); assert.equal((error as { statusCode?: unknown }).statusCode, 403); assert.deepEqual((error as { data?: unknown }).data, { error: { code: 'account_not_authorized' }, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 5eb3564da0..28e4c86550 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -53,6 +53,8 @@ import type { ModelCallCommit } from '@maka/core/agent-run'; import { Buffer } from 'node:buffer'; import { isDeepStrictEqual } from 'node:util'; import { redactSecrets } from '@maka/core/redaction'; +import { truncateUtf8 } from '@maka/core/diagnostic-log'; +import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import { MODEL_CALL_ATTEMPT_EVENT_TYPE, type ModelCallAttempt, @@ -1211,7 +1213,10 @@ export class AgentRun { return; } this.finalStatus = { status: 'blocked', blockedReason: 'unknown' }; - this.markRunFailed(error instanceof Error ? error.name : 'unknown', errorMessage(error)); + this.markRunFailed( + error instanceof Error ? error.name : 'unknown', + error instanceof Error ? error.message : String(error), + ); } async finalize(): Promise { @@ -1454,12 +1459,13 @@ export class AgentRun { /** * Remember why this run is going to fail. * + * Preserve diagnostic text without trace redaction, within the byte budget. * Nothing is written here: the terminal RuntimeEvent carries the failure, and * it is committed once, at the end, by `commitTerminalRun`. */ private markRunFailed(failureClass: string, message: string): void { this.failureClass = failureClass; - this.failureMessage = redactTraceString(message); + this.failureMessage = truncateUtf8(message, MODEL_FAILURE_MESSAGE_MAX_BYTES, '…'); } /** diff --git a/packages/runtime/src/provider-error-classification.ts b/packages/runtime/src/provider-error-classification.ts index 370c5725ad..762c6b1c27 100644 --- a/packages/runtime/src/provider-error-classification.ts +++ b/packages/runtime/src/provider-error-classification.ts @@ -20,7 +20,7 @@ import { RetryError } from 'ai'; import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; -import { isAuthenticationErrorText, redactSecrets } from '@maka/core/redaction'; +import { isAuthenticationErrorText } from '@maka/core/redaction'; import type { ModelFailure, ModelFailureKind } from './model-protocol.js'; /** @@ -383,7 +383,7 @@ function failureSummaryFromFacts(facts: ProviderErrorFacts): ProviderFailureSumm MODEL_FAILURE_MESSAGE_MAX_BYTES - Buffer.byteLength(suffix, 'utf8'), ); const summary = `${truncateUtf8( - redactSecrets(message ?? 'Provider request failed'), + message ?? 'Provider request failed', messageBudget, '…', )}${suffix}`; @@ -396,7 +396,7 @@ function failureSummaryFromFacts(facts: ProviderErrorFacts): ProviderFailureSumm /** * Projects provider errors into a small durable fingerprint. Unlike the * presentation summary, this intentionally excludes provider messages and - * response bodies: even redacted free text can echo prompts or credentials. + * response bodies: free text can echo prompts or credentials. */ export function providerFailureDiagnostic(error: unknown): ProviderFailureDiagnostic { const facts = extractProviderErrorFacts(error); @@ -528,7 +528,7 @@ function boundedProviderField(value: unknown): string | undefined { if (typeof value !== 'string' && typeof value !== 'number') return undefined; const normalized = String(value).trim(); if (!normalized) return undefined; - return truncateUtf8(redactSecrets(normalized), PROVIDER_FAILURE_FIELD_MAX_BYTES, '…'); + return truncateUtf8(normalized, PROVIDER_FAILURE_FIELD_MAX_BYTES, '…'); } function boundedProviderMessage(value: unknown, parseJson = true): string | undefined { @@ -553,7 +553,7 @@ function boundedProviderMessage(value: unknown, parseJson = true): string | unde } } if (!normalized) return undefined; - return truncateUtf8(redactSecrets(normalized), MODEL_FAILURE_MESSAGE_MAX_BYTES, '…'); + return truncateUtf8(normalized, MODEL_FAILURE_MESSAGE_MAX_BYTES, '…'); } /** diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index fd01aecfec..61e79973dd 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -17,6 +17,8 @@ * under the License. */ +import { MODEL_FAILURE_MESSAGE_MAX_BYTES } from '@maka/core/model-failure'; +import { truncateUtf8 } from '@maka/core/diagnostic-log'; import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation'; import type { AssistantStepContentKind, StoredMessage, TurnStatus } from '@maka/core/session'; import type { RuntimeEvent, RuntimeEventStatus } from '@maka/core/runtime-event'; @@ -1239,6 +1241,11 @@ function projectTerminalTurnState( ...(status === 'aborted' ? { abortedAt: event.ts } : {}), ...(abortSource ? { abortSource } : {}), ...(status === 'failed' ? { errorClass: failureClass ?? 'unknown' } : {}), + ...(status === 'failed' && event.content?.kind === 'error' && event.content.message + ? { + failureMessage: truncateUtf8(event.content.message, MODEL_FAILURE_MESSAGE_MAX_BYTES, '…'), + } + : {}), ...(status === 'failed' && event.content?.kind === 'error' && event.content.retry ? { retry: event.content.retry } : {}), diff --git a/packages/runtime/src/subscription-model-fetch.ts b/packages/runtime/src/subscription-model-fetch.ts index 26b2a8cfce..348ba19876 100644 --- a/packages/runtime/src/subscription-model-fetch.ts +++ b/packages/runtime/src/subscription-model-fetch.ts @@ -17,7 +17,6 @@ * under the License. */ -import { redactSecrets } from '@maka/core/redaction'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { GITHUB_COPILOT_API_VERSION, @@ -381,7 +380,7 @@ function codexInstructionsFromBody(body: Record): string { } function formatOpenAiCodexHttpError(statusCode: number, detail: string): string { - const compact = redactSecrets(detail).replace(/\s+/g, ' ').trim().slice(0, 240); + const compact = detail.replace(/\s+/g, ' ').trim().slice(0, 240); return compact ? `Codex OAuth request failed: HTTP ${statusCode} ${compact}` : `Codex OAuth request failed: HTTP ${statusCode}`; @@ -396,7 +395,7 @@ function openAiCodexHttpError( ? 'openai_codex_edge_rejection' : openAiCodexProviderCode(detail); const rawRequestId = response.headers.get('x-request-id')?.trim(); - const requestId = rawRequestId ? redactSecrets(rawRequestId).slice(0, 256) : undefined; + const requestId = rawRequestId ? rawRequestId.slice(0, 256) : undefined; return Object.assign(new Error(formatOpenAiCodexHttpError(response.status, detail)), { name: exhaustedEdgeRejection ? 'OpenAiCodexEdgeRejectionError' : 'OpenAiCodexHttpError', ...(exhaustedEdgeRejection ? { code: 'openai_codex_edge_rejection' } : {}), @@ -417,7 +416,7 @@ function openAiCodexProviderCode(detail: string): string | undefined { : root; const value = error.code ?? error.type; if (typeof value !== 'string' && typeof value !== 'number') return undefined; - const normalized = redactSecrets(String(value).trim()).slice(0, 256); + const normalized = String(value).trim().slice(0, 256); return normalized || undefined; } catch { return undefined; diff --git a/packages/ui/src/__tests__/transcript-projection.test.ts b/packages/ui/src/__tests__/transcript-projection.test.ts index 1f154ed1c3..af975aeb8b 100644 --- a/packages/ui/src/__tests__/transcript-projection.test.ts +++ b/packages/ui/src/__tests__/transcript-projection.test.ts @@ -456,6 +456,11 @@ describe('turn identity moves across structural change classes', () => { { type: 'turn_state', id: 's1', turnId: 'turn-1', ts: 5, status: 'failed' }, ], }, + { + field: 'failureMessage', + from: [...base.slice(0, 2), { type: 'turn_state', id: 's1', turnId: 'turn-1', ts: 5, status: 'failed', errorClass: 'rate_limit' }], + refresh: [...base.slice(0, 2), { type: 'turn_state', id: 's1', turnId: 'turn-1', ts: 5, status: 'failed', errorClass: 'rate_limit', failureMessage: 'Quota exceeded (status=429, requestId=req-4502)' }], + }, { field: 'assistant', refresh: [ diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 153503f4f5..58d9523d6a 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -695,12 +695,12 @@ export const TurnView = memo(function TurnView(props: { container="section" className="maka-turn-failed-banner" title={props.failedReasonLabel} - {...(props.safeResumeAction?.detail ?? props.failedExecutionStateLabel - ? { - description: - props.safeResumeAction?.detail ?? props.failedExecutionStateLabel, - } - : {})} + description={ + <> + {props.safeResumeAction?.detail ?? props.failedExecutionStateLabel} + {!turn.failureMessage && {copy.failureDetailsUnavailable}} + + } {...(props.safeResumeAction ? { endContent: ( @@ -718,7 +718,11 @@ export const TurnView = memo(function TurnView(props: { ), } : {})} - /> + > + {turn.failureMessage && ( +
{turn.failureMessage}
+ )} + )} {ownsTurnChrome && props.liveStreaming && ( <> diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 9fe705f1ff..78579d4a3d 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -281,6 +281,7 @@ export interface ConversationCopy { providerRetryStarted: (attempt: number, maxAttempts: number) => string; providerRetryWaiting: (attempt: number, maxAttempts: number) => string; providerRetryReason: Record; + failureDetailsUnavailable: string; safeResumePending: string; safeResume: string; thinking: string; @@ -547,7 +548,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `选择项目:${label},当前分支 ${branch}` : `选择项目:${label}`, }, messages: { - you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', awaitingModelOutput: '等待模型输出…', providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小时', minute: '分', second: '秒' })}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重试(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '响应中途断开', network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, safeResumePending: '正在检查…', safeResume: '继续这一轮', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '包含已展开上下文的历史消息暂不支持编辑并重发', + you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', awaitingModelOutput: '等待模型输出…', providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小时', minute: '分', second: '秒' })}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重试(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '响应中途断开', network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, failureDetailsUnavailable: '无可用诊断详情。', safeResumePending: '正在检查…', safeResume: '继续这一轮', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '包含已展开上下文的历史消息暂不支持编辑并重发', editMessageDisabledDirectoryReferences: '包含文件夹引用的历史消息暂不支持编辑并重发', userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '已中断', abortedByStop: '已中断 · 由停止按钮触发', @@ -706,7 +707,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `選擇專案:${label},目前分支 ${branch}` : `選擇專案:${label}`, }, messages: { - you: '你', assistant: 'Maka', processing: '正在處理…', continuing: '繼續中…', awaitingModelOutput: '等待模型輸出…', providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小時', minute: '分', second: '秒' })}後重試(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重試(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重試(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '回應中途斷開', network: '網路中斷', provider_capacity: '模型服務暫時滿載', provider_unavailable: '模型服務暫時不可用', rate_limit: '觸發模型速率限制', timeout: '請求超時', unknown: '模型請求失敗' }, safeResumePending: '正在檢查…', safeResume: '繼續這一輪', thinking: '深度思考', truncated: '已截斷', copied: '已複製', copying: '複製中', copyFailed: '複製失敗', copy: '複製', editMessage: '編輯並重發', editMessageDisabledRunning: '目前回答仍在進行中,結束後再編輯', editMessageDisabledAttachments: '包含附件的歷史訊息暫不支援編輯並重發', editMessageDisabledQuotes: '包含引用的歷史訊息暫不支援編輯並重發', editMessageDisabledTransformedText: '包含已展開上下文的歷史訊息暫不支援編輯並重發', + you: '你', assistant: 'Maka', processing: '正在處理…', continuing: '繼續中…', awaitingModelOutput: '等待模型輸出…', providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, { day: '天', hour: '小時', minute: '分', second: '秒' })}後重試(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重試(${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `等待重試(${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: '回應中途斷開', network: '網路中斷', provider_capacity: '模型服務暫時滿載', provider_unavailable: '模型服務暫時不可用', rate_limit: '觸發模型速率限制', timeout: '請求超時', unknown: '模型請求失敗' }, failureDetailsUnavailable: '無可用診斷詳情。', safeResumePending: '正在檢查…', safeResume: '繼續這一輪', thinking: '深度思考', truncated: '已截斷', copied: '已複製', copying: '複製中', copyFailed: '複製失敗', copy: '複製', editMessage: '編輯並重發', editMessageDisabledRunning: '目前回答仍在進行中,結束後再編輯', editMessageDisabledAttachments: '包含附件的歷史訊息暫不支援編輯並重發', editMessageDisabledQuotes: '包含引用的歷史訊息暫不支援編輯並重發', editMessageDisabledTransformedText: '包含已展開上下文的歷史訊息暫不支援編輯並重發', editMessageDisabledDirectoryReferences: '包含資料夾引用的歷史訊息暫不支援編輯並重發', userAriaLabel: '你傳送的訊息', systemAriaLabel: '系統訊息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}訊息${context ? `:${context}` : ''}`, sourceAriaLabel: '本輪迴答的來源', derivativesAriaLabel: '本輪迴答的衍生', scheduledTaskTriggered: '定時任務觸發', scheduledTaskTitle: (id) => `由定時任務觸發 · ${id}`, legacyAutomationTriggered: '舊版自動化(僅歷史)', legacyAutomationTitle: (id) => `由舊版自動化觸發 · ${id} · 僅保留歷史,不會再次執行`, goalContinued: 'Goal 自動繼續', goalTitle: (id) => `由 Goal 繼續執行 · ${id}`, agentGraphTriggered: 'Agent Graph 自動繼續', agentGraphTitle: (graphId) => `由 Agent Graph 排程器觸發 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截斷;顯示的是最近的內容', outputTruncatedTitle: '助手輸出已超過單次回合上限,超出部分未渲染。如需完整內容請重新生成或檢視持久化的任務記錄。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展開引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '(已中斷)', abortedByStop: '(已中斷 · 由停止按鈕觸發)', @@ -891,7 +892,7 @@ const CONVERSATION_COPY = { chooseAriaLabel: (label, branch) => branch ? `Choose project: ${label}, current branch ${branch}` : `Choose project: ${label}`, }, messages: { - you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', awaitingModelOutput: 'Waiting for model output…', providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${formatRetryDelay(seconds, { day: 'd', hour: 'h', minute: 'm', second: 's' })} (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `Waiting to retry (${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: 'Response stream ended before completion', network: 'Network interrupted', provider_capacity: 'The model service is temporarily at capacity', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, safeResumePending: 'Checking…', safeResume: 'Continue this turn', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages with expanded context', + you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', awaitingModelOutput: 'Waiting for model output…', providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${formatRetryDelay(seconds, { day: 'd', hour: 'h', minute: 'm', second: 's' })} (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryWaiting: (attempt, maxAttempts) => `Waiting to retry (${attempt}/${maxAttempts})`, providerRetryReason: { stream_truncated: 'Response stream ended before completion', network: 'Network interrupted', provider_capacity: 'The model service is temporarily at capacity', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, failureDetailsUnavailable: 'No diagnostic details are available.', safeResumePending: 'Checking…', safeResume: 'Continue this turn', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages with expanded context', editMessageDisabledDirectoryReferences: 'Edit & resend does not yet support messages with folder references', userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`, thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: 'Interrupted', abortedByStop: 'Interrupted · Stop button', diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 3cacfa985c..e07e5a294f 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -415,6 +415,7 @@ export interface TurnViewModel { abortedAt?: number; abortSource?: string; errorClass?: string; + failureMessage?: string; retry?: import('@maka/core/model-failure').ModelRetryDecision; user?: ChatItem; tools: ToolActivityItem[]; @@ -784,6 +785,7 @@ export function materializeTurns( ? { abortedAt: record.abortedAt } : {}), ...(record?.abortSource ? { abortSource: record.abortSource } : {}), + ...(record?.failureMessage ? { failureMessage: record.failureMessage } : {}), ...(record?.errorClass ? { errorClass: record.errorClass } : {}), ...(record?.retry ? { retry: record.retry } : {}), tools: [], diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index ecbdf18e9b..078609bed0 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -813,6 +813,26 @@ /* Astryx `Banner` paints the failed-turn surface; this only sets the block rhythm between it and the timeline entry above it. */ +.maka-turn-failure-unavailable { display: block; } +/* Keep Banner's immediate height change and built-in chevron rotation. + Animating height repeatedly lays out the transcript and triggers its + ResizeObserver-driven scroll following; that cost grows with history. + Only fade the newly mounted detail, without delaying layout or removal. */ +.maka-turn-failure-detail { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: inherit; + color: var(--foreground); + animation: maka-turn-failure-enter var(--duration-fast) var(--ease-standard); +} +@keyframes maka-turn-failure-enter { + from { opacity: 0; } + to { opacity: 1; } +} +@media (prefers-reduced-motion: reduce) { + .maka-turn-failure-detail { animation: none; } +} .maka-turn-failed-banner { margin-block: var(--space-1) var(--space-0-5); } .maka-turn-lineage-row,