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