diff --git a/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx b/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx index 3bb8ec638b..066e3907ac 100644 --- a/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx @@ -14,6 +14,7 @@ import { import { useTranslation } from 'react-i18next'; import { deriveAgentTaskStatus, + isSubagentResultError, type AgentTaskTerminalStatus, } from '@cindy/maker-shared/agent-task'; @@ -235,6 +236,7 @@ export function AgentTaskCard({ resultIsLaunchReceipt: subagentSpawnReceiptName(toolCall?.toolName, toolCall?.toolInput, result) !== undefined || subagentSpawnResultIndicatesRunning(toolCall?.toolName, result), + resultIsError: isSubagentResultError(result), }); const StatusIcon = statusIcon(status); const statusIconClassName = cn( diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts b/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts index 43c77eb495..1acf42334c 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/background-tasks/listSessionTasks.ts @@ -19,6 +19,7 @@ import { deriveAgentTaskStatus, isAgentTaskToolName, + isSubagentResultError, subagentSpawnReceiptName, subagentSpawnResultIndicatesRunning, } from '@cindy/maker-shared/agent-task'; @@ -341,8 +342,11 @@ export function listSessionTasks(input: { resultIsLaunchReceipt: subagentSpawnReceiptName(toolName, toolInput, resultText) !== undefined || subagentSpawnResultIndicatesRunning(toolName, resultText), + resultIsError: isSubagentResultError(resultText), }) - : (settled ? 'completed' : isSessionStreaming ? 'running' : 'stopped'); + : (settled + ? (isSubagentResultError(resultText) ? 'failed' : 'completed') + : isSessionStreaming ? 'running' : 'stopped'); const provider: SessionTaskItem['provider'] = update?.provider ?? (toolName.startsWith('collab:') ? 'codex' : 'claude-code'); diff --git a/apps/mobile/src/session/MessageRenderer.tsx b/apps/mobile/src/session/MessageRenderer.tsx index df51a0384b..6c6a6e9284 100644 --- a/apps/mobile/src/session/MessageRenderer.tsx +++ b/apps/mobile/src/session/MessageRenderer.tsx @@ -3081,8 +3081,7 @@ function AgentTaskCard({ toolName: item.toolCall?.label, toolInput: readAgentTaskToolInput(item.toolCall), update: item.update, - // 重连后 live update 为空:结构化终态优先,存量历史再由配对结果兜底 completed。 - // summary 仍来自 secondaryBody,与 desktop 对齐。 + // 重连后 live update 为空:协议级子任务错误结果必须恢复为 failed。 result: item.toolCall?.secondaryBody, persistedStatus: item.toolCall?.agentTaskStatus, }), diff --git a/packages/maker-shared/src/__tests__/agentTask.test.ts b/packages/maker-shared/src/__tests__/agentTask.test.ts index 789900a32a..d8d6bc0570 100644 --- a/packages/maker-shared/src/__tests__/agentTask.test.ts +++ b/packages/maker-shared/src/__tests__/agentTask.test.ts @@ -5,6 +5,7 @@ import { deriveAgentTaskStatus, findAgentTaskUpdate, isAgentTaskToolName, + isSubagentResultError, isSubagentSpawnToolName, mergeAgentTaskUpdate, PI_SUBAGENT_TOOL_NAME, @@ -113,6 +114,38 @@ describe('deriveAgentTaskStatus', () => { persistedStatus: 'cancelled' as never, })).toBe('completed'); }); + + it('returns failed when resultIsError is true and result is non-empty', () => { + expect(deriveAgentTaskStatus(undefined, 'Auth failed', { + resultIsError: true, + })).toBe('failed'); + }); + + it('returns failed even when updateStatus is running when resultIsError is true', () => { + expect(deriveAgentTaskStatus('running', 'timeout', { + resultIsError: true, + })).toBe('failed'); + }); + + it('shared card model projects protocol error results as failed', () => { + expect(buildAgentTaskCardModel({ + toolName: 'Task', + result: 'launch failed', + }).status).toBe('failed'); + }); + + it('persisted status wins over resultIsError', () => { + expect(deriveAgentTaskStatus(undefined, 'fail', { + resultIsError: true, + persistedStatus: 'completed', + })).toBe('completed'); + }); + + it('resultIsError is ignored when result is empty', () => { + expect(deriveAgentTaskStatus(undefined, '', { + resultIsError: true, + })).toBe('running'); + }); }); describe('mergeAgentTaskUpdate', () => { @@ -525,3 +558,39 @@ describe('buildAgentTaskCardModel', () => { expect(model.summary).toBe('thread-2: done'); }); }); + +describe('isSubagentResultError', () => { + it('returns false for empty/undefined/null', () => { + expect(isSubagentResultError(undefined)).toBe(false); + expect(isSubagentResultError('')).toBe(false); + expect(isSubagentResultError(' ')).toBe(false); + }); + + it('returns true for Claude protocol ', () => { + expect(isSubagentResultError('Authentication failed')).toBe(true); + }); + + it('returns false for generic prefix (too generic for work product)', () => { + // A subagent returning 校验报告 as valid output must not be + // misclassified as failure. Only is a reliable protocol marker. + expect(isSubagentResultError('Transport failure')).toBe(false); + expect(isSubagentResultError('校验报告')).toBe(false); + }); + + it('returns false for JSON with error-looking fields (authority boundary)', () => { + // Subagent result content is arbitrary user work product. + // Fields like "errors", "status", "stderr" are data, not execution signals. + expect(isSubagentResultError('{"errors":["not found"]}')).toBe(false); + expect(isSubagentResultError('{"status":"failed"}')).toBe(false); + expect(isSubagentResultError('{"stderr":"something went wrong"}')).toBe(false); + expect(isSubagentResultError('{"error":"custom message"}')).toBe(false); + expect(isSubagentResultError('{"success":false}')).toBe(false); + expect(isSubagentResultError('{"ok":false}')).toBe(false); + }); + + it('returns false for natural language error phrases', () => { + expect(isSubagentResultError('failed to launch')).toBe(false); + expect(isSubagentResultError('unable to start the service')).toBe(false); + expect(isSubagentResultError('Error: something happened')).toBe(false); + }); +}); diff --git a/packages/maker-shared/src/agentTask.ts b/packages/maker-shared/src/agentTask.ts index 48035708f0..211a53e14c 100644 --- a/packages/maker-shared/src/agentTask.ts +++ b/packages/maker-shared/src/agentTask.ts @@ -174,15 +174,50 @@ export function deriveAgentTaskStatus( options?: { resultIsLaunchReceipt?: boolean; persistedStatus?: AgentTaskTerminalStatus; + resultIsError?: boolean; }, ): AgentTaskStatus { const persistedStatus = normalizeAgentTaskTerminalStatus(options?.persistedStatus); if (persistedStatus) return persistedStatus; const hasResult = typeof result === 'string' && result.trim().length > 0; + if (options?.resultIsError && hasResult) return 'failed'; if (updateStatus === 'running' && hasResult && !options?.resultIsLaunchReceipt) return 'completed'; return updateStatus ?? (hasResult ? 'completed' : 'running'); } +/** + * 判断子任务工具结果是否以协议级错误收尾(历史回放恢复 failed 的依据)。 + * + * 仅识别 Claude SDK 协议标记 `` — 这是 SDK 在 tool call 失败时 + * 发出的结构化错误格式。不解析任意 JSON 字段或自然语言错误短语,因为子任务结果 + * 内容是用户工作产物,其中 "errors"/"status"/"stderr" 等字段是数据而非执行信号。 + * + * 调用方约束:此函数仅应在已确认为子任务上下文的调用点使用 + * (AgentTaskCard / listSessionTasks)。普通工具结果包含 `` 时 + * 不应传入此函数,否则会将非子任务结果误判为失败。 + * + * Authority: Claude protocol `` — SDK 在 tool call 失败时发出。 + */ +export function isSubagentResultError(result: string | undefined): boolean { + const text = typeof result === 'string' ? result.trim() : ''; + if (text.length === 0) return false; + // Only trust protocol-level error markers. Subagent result content is arbitrary + // user work product -- fields like "errors", "status", "stderr" in JSON output + // are data, not execution failure signals. Parsing arbitrary body for + // error-looking fields creates false positives that mark successful tasks as + // failed after Desktop reload / Mobile reconnect. + // + // Authority sources: + // 1. Claude protocol -- emitted by the SDK when a tool call fails + // 2. Persisted structured terminal status (agentTaskStatus) -- written by + // messagePersistBroadcaster on terminal observations + // + // Note: prefix removed -- too generic. A subagent returning + // `校验报告` as work output would be misclassified as failure. + // Only is a reliable protocol-owned error marker. + return text.startsWith(''); +} + /** * Tool names that spawn a sub-agent task: Claude `Task`/`Agent`, Codex collab agents, * PI `subagent`(Cindy 自有扩展注册的工具名,与 pi 社区惯例一致)。 @@ -442,10 +477,10 @@ export function buildAgentTaskCardModel(input: { }): AgentTaskCardModel { const { toolName, toolInput, update, result, persistedStatus } = input; const status = deriveAgentTaskStatus(update?.status, result, { - persistedStatus, - resultIsLaunchReceipt: + persistedStatus, resultIsLaunchReceipt: subagentSpawnReceiptName(toolName, toolInput, result) !== undefined || subagentSpawnResultIndicatesRunning(toolName, result), + resultIsError: isSubagentResultError(result), }); const provider: 'claude-code' | 'codex' | 'pi' = update?.provider