Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/components/chat/AgentTaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { useTranslation } from 'react-i18next';
import {
deriveAgentTaskStatus,
isSubagentResultError,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
type AgentTaskTerminalStatus,
} from '@cindy/maker-shared/agent-task';

Expand Down Expand Up @@ -235,6 +236,7 @@ export function AgentTaskCard({
resultIsLaunchReceipt:
subagentSpawnReceiptName(toolCall?.toolName, toolCall?.toolInput, result) !== undefined
|| subagentSpawnResultIndicatesRunning(toolCall?.toolName, result),
resultIsError: update?.provider === 'claude-code' && isSubagentResultError(result),
Comment thread
Battleplus marked this conversation as resolved.
Outdated
Comment thread
Battleplus marked this conversation as resolved.
Outdated
});
const StatusIcon = statusIcon(status);
const statusIconClassName = cn(
Expand Down
62 changes: 62 additions & 0 deletions packages/maker-shared/src/__tests__/agentTask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
deriveAgentTaskStatus,
findAgentTaskUpdate,
isAgentTaskToolName,
isSubagentResultError,
isSubagentSpawnToolName,
mergeAgentTaskUpdate,
PI_SUBAGENT_TOOL_NAME,
Expand Down Expand Up @@ -113,6 +114,31 @@ describe('deriveAgentTaskStatus', () => {
persistedStatus: 'cancelled' as never,
})).toBe('completed');
});

it('returns failed when resultIsError is true and result is non-empty', () => {
expect(deriveAgentTaskStatus(undefined, '<tool_use_error>Auth failed</tool_use_error>', {
resultIsError: true,
})).toBe('failed');
});

it('returns failed even when updateStatus is running when resultIsError is true', () => {
expect(deriveAgentTaskStatus('running', '<tool_use_error>timeout</tool_use_error>', {
resultIsError: true,
})).toBe('failed');
});

it('persisted status wins over resultIsError', () => {
expect(deriveAgentTaskStatus(undefined, '<tool_use_error>fail</tool_use_error>', {
resultIsError: true,
persistedStatus: 'completed',
})).toBe('completed');
});

it('resultIsError is ignored when result is empty', () => {
expect(deriveAgentTaskStatus(undefined, '', {
resultIsError: true,
})).toBe('running');
});
});

describe('mergeAgentTaskUpdate', () => {
Expand Down Expand Up @@ -525,3 +551,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 <tool_use_error>', () => {
expect(isSubagentResultError('<tool_use_error>Authentication failed</tool_use_error>')).toBe(true);
});

it('returns false for generic <error> prefix (too generic for work product)', () => {
// A subagent returning <error>校验报告</error> as valid output must not be
// misclassified as failure. Only <tool_use_error> is a reliable protocol marker.
expect(isSubagentResultError('<error>Transport failure</error>')).toBe(false);
expect(isSubagentResultError('<error>校验报告</error>')).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);
});
});
29 changes: 29 additions & 0 deletions packages/maker-shared/src/agentTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,44 @@ 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 的依据)。判定为“是”的
* 形态:结构化 JSON 错误记录(ok:false / success:false / status ∈ error|failed|failure
* / 非空 error|errors|exception|stderr 字段)、`<tool_use_error>` 标记、或以
* Error/失败句式开头的短结果。与 messagePresentation.isErrorRecord 语义对齐
* (该函数不可达:agentTask 是叶子模块),但只读 result 字符串,不依赖工具元数据。
*/
export function isSubagentResultError(result: string | undefined): boolean {
Comment thread
Battleplus marked this conversation as resolved.
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 <tool_use_error> -- emitted by the SDK when a tool call fails
// 2. Persisted structured terminal status (agentTaskStatus) -- written by
// messagePersistBroadcaster on terminal observations
//
// Note: <error> prefix removed -- too generic. A subagent returning
// `<error>校验报告</error>` as work output would be misclassified as failure.
// Only <tool_use_error> is a reliable protocol-owned error marker.
return text.startsWith('<tool_use_error>');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 将协议错误判定接入 Mobile 共用卡片模型

当 Mobile 重连后只有配对的 <tool_use_error> 结果、但没有 live update 或持久化终态(例如旧记录或终态写回前退出)时,这个新增 helper 仍不会生效:Mobile 的 MessageRenderer.AgentTaskCard 调用 buildAgentTaskCardModel,而该模型在当前提交第 479–484 行调用 deriveAgentTaskStatus 时没有传入 resultIsError,所以结果会恢复成 completed。此前线程称共享历史推导已经接入;当前提交重新新增 helper 却只接了两个 Desktop 消费者,这是该问题再次出现的新证据。应让共享模型按 Claude TaskAgent 上下文使用该判定,才能覆盖声明的 Mobile 重连场景。 docs/dev-rules/remote-and-mobile-adaptation.mdL7-L15

Useful? React with 👍 / 👎.

}

/**
* Tool names that spawn a sub-agent task: Claude `Task`/`Agent`, Codex collab agents,
* PI `subagent`(Cindy 自有扩展注册的工具名,与 pi 社区惯例一致)。
Expand Down
Loading