From a88dd79f9c5a7687828f9bcd563441ecc0b20dee Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 3 Mar 2026 04:13:47 +0100 Subject: [PATCH] feat(task): surface explicit abort reasons for subagent results - Add a dedicated abortReason field to SingleResult and thread it through task execution paths so aborted subagents show actionable context instead of a generic badge. - Populate abortReason for signal cancellation, pre-start cancellation, submit_result aborted status, and missing submit_result after reminders Fixes #248 --- packages/coding-agent/src/task/executor.ts | 40 ++++++++++++++-- packages/coding-agent/src/task/index.ts | 3 +- packages/coding-agent/src/task/render.ts | 7 ++- packages/coding-agent/src/task/types.ts | 1 + .../task/executor-subagent-reminders.test.ts | 46 +++++++++++++++++++ .../test/task/render-report-finding.test.ts | 40 ++++++++++++++++ 6 files changed, 132 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index d6396b184..a34af06da 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -499,12 +499,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise { + const reason = signal?.reason; + if (reason instanceof Error) { + const message = reason.message.trim(); + if (message.length > 0) return message; + } else if (typeof reason === "string") { + const message = reason.trim(); + if (message.length > 0) return message; + } + return "Cancelled by caller"; + }; const PROGRESS_COALESCE_MS = 150; let lastProgressEmitMs = 0; let progressTimeoutId: NodeJS.Timeout | null = null; @@ -898,16 +911,20 @@ export async function runSubprocess(options: ExecutorOptions): Promise => { const sessionAbortController = new AbortController(); let exitCode = 0; let error: string | undefined; let aborted = false; - + let abortReasonText: string | undefined; const checkAbort = () => { if (abortSignal.aborted) { aborted = abortReason === "signal" || abortReason === undefined; + if (aborted) { + abortReasonText ??= resolveSignalAbortReason(); + } exitCode = 1; throw new ToolAbortError(); } @@ -1096,6 +1113,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise { durationMs: 0, tokens: 0, modelOverride, - error: "Skipped", + error: "Cancelled before start", aborted: true, + abortReason: "Cancelled before start", }; }); diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index b97d6dc99..afebafa34 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -783,6 +783,11 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool lines.push(...renderTaskSection(result.task, continuePrefix, expanded, theme)); + if (aborted && result.abortReason) { + lines.push( + `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", truncateToWidth(replaceTabs(result.abortReason), 80))}`, + ); + } // Check for review result (submit_result with review schema + report_finding) const completeData = result.extractedToolData?.submit_result as Array<{ data: unknown }> | undefined; const reportFindingData = normalizeReportFindings(result.extractedToolData?.report_finding); @@ -873,7 +878,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool } // Error message - if (result.error && (!success || mergeFailed)) { + if (result.error && (!success || mergeFailed) && (!aborted || result.error !== result.abortReason)) { lines.push(`${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", truncateToWidth(result.error, 70))}`); } diff --git a/packages/coding-agent/src/task/types.ts b/packages/coding-agent/src/task/types.ts index 309c4e047..d0a42a492 100644 --- a/packages/coding-agent/src/task/types.ts +++ b/packages/coding-agent/src/task/types.ts @@ -169,6 +169,7 @@ export interface SingleResult { modelOverride?: string | string[]; error?: string; aborted?: boolean; + abortReason?: string; /** Aggregated usage from the subprocess, accumulated incrementally from message_end events. */ usage?: Usage; /** Output path for the task result */ diff --git a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts index 3a062672f..7532133f6 100644 --- a/packages/coding-agent/test/task/executor-subagent-reminders.test.ts +++ b/packages/coding-agent/test/task/executor-subagent-reminders.test.ts @@ -235,5 +235,51 @@ describe("runSubprocess submit_result reminders", () => { expect(result.exitCode).toBe(1); expect(result.aborted).toBe(true); expect(result.stderr).toBe(SUBAGENT_WARNING_MISSING_SUBMIT_RESULT); + expect(result.abortReason).toBe(SUBAGENT_WARNING_MISSING_SUBMIT_RESULT); + }); + + it("surfaces abort reason when submit_result reports aborted status", async () => { + const session = createMockSession(({ promptIndex, emit, state }) => { + if (promptIndex === 1) { + const assistant = createAssistantStopMessage("cannot proceed"); + state.messages.push(assistant); + emit({ type: "message_end", message: assistant }); + } + emit({ + type: "tool_execution_end", + toolCallId: "tool-abort", + toolName: "submit_result", + result: { + content: [{ type: "text", text: "Task aborted: blocked by permissions" }], + details: { status: "aborted", error: "blocked by permissions" }, + }, + isError: false, + }); + }); + + (sdkModule.createAgentSession as unknown as { mockResolvedValue: (value: unknown) => void }).mockResolvedValue({ + session, + extensionsResult: {} as unknown as LoadExtensionsResult, + setToolUIContext: () => {}, + }); + + const result = await runSubprocess({ ...baseOptions, id: "subagent-aborted-submit-result" }); + expect(result.aborted).toBe(true); + expect(result.abortReason).toBe("blocked by permissions"); + }); + + it("marks pre-aborted subprocess with a concrete reason", async () => { + const abortController = new AbortController(); + abortController.abort("caller cancelled task"); + + const result = await runSubprocess({ + ...baseOptions, + id: "subagent-cancelled-before-start", + signal: abortController.signal, + }); + + expect(result.aborted).toBe(true); + expect(result.abortReason).toBe("Cancelled before start"); + expect(result.stderr).toBe("Cancelled before start"); }); }); diff --git a/packages/coding-agent/test/task/render-report-finding.test.ts b/packages/coding-agent/test/task/render-report-finding.test.ts index 245d5d66e..1145bd946 100644 --- a/packages/coding-agent/test/task/render-report-finding.test.ts +++ b/packages/coding-agent/test/task/render-report-finding.test.ts @@ -44,4 +44,44 @@ describe("taskToolRenderer report_finding safety", () => { expect(() => rendered.render(120)).not.toThrow(); }); + + it("renders abort reason inline for aborted subagent results", async () => { + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + const uiTheme = theme!; + + const details: TaskToolDetails = { + projectAgentsDir: null, + results: [ + { + index: 0, + id: "1-Reviewer", + agent: "reviewer", + agentSource: "bundled", + task: "Review patch", + exitCode: 1, + output: "", + stderr: "", + truncated: false, + durationMs: 42, + tokens: 0, + aborted: true, + abortReason: "blocked by permissions", + }, + ], + totalDurationMs: 42, + }; + + const rendered = taskToolRenderer.renderResult( + { + content: [{ type: "text", text: "" }], + details, + }, + { expanded: false, isPartial: false }, + uiTheme, + ); + + const lines = rendered.render(120); + expect(lines.join("\n")).toContain("blocked by permissions"); + }); });