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
This commit is contained in:
can1357
2026-03-03 04:14:04 +01:00
parent 801553b118
commit a88dd79f9c
6 changed files with 132 additions and 5 deletions
+37 -3
View File
@@ -499,12 +499,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
description: options.description,
exitCode: 1,
output: "",
stderr: "Aborted before start",
stderr: "Cancelled before start",
truncated: false,
durationMs: 0,
tokens: 0,
modelOverride,
error: "Aborted",
error: "Cancelled before start",
aborted: true,
abortReason: "Cancelled before start",
};
}
@@ -612,6 +614,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
signal.addEventListener("abort", onAbort, { once: true, signal: listenerSignal });
}
const resolveSignalAbortReason = (): string => {
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<SingleRes
exitCode: number;
error?: string;
aborted?: boolean;
abortReason?: string;
durationMs: number;
}> => {
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<SingleRes
if (!submitResultCalled && !abortSignal.aborted) {
aborted = true;
exitCode = 1;
abortReasonText ??= SUBAGENT_WARNING_MISSING_SUBMIT_RESULT;
error ??= SUBAGENT_WARNING_MISSING_SUBMIT_RESULT;
}
@@ -1103,6 +1121,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
if (lastAssistant) {
if (lastAssistant.stopReason === "aborted") {
aborted = abortReason === "signal" || abortReason === undefined;
if (aborted) {
abortReasonText ??= resolveSignalAbortReason();
}
exitCode = 1;
} else if (lastAssistant.stopReason === "error") {
exitCode = 1;
@@ -1117,6 +1138,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
} finally {
if (abortSignal.aborted) {
aborted = abortReason === "signal" || abortReason === undefined;
if (aborted) {
abortReasonText ??= resolveSignalAbortReason();
}
if (exitCode === 0) exitCode = 1;
}
sessionAbortController.abort();
@@ -1143,6 +1167,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
exitCode,
error,
aborted,
abortReason: aborted ? abortReasonText : undefined,
durationMs: Date.now() - startTime,
};
};
@@ -1178,6 +1203,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
rawOutput = finalized.rawOutput;
exitCode = finalized.exitCode;
stderr = finalized.stderr;
const lastSubmitResult = submitResultItems?.[submitResultItems.length - 1];
const submitResultAbortReason =
lastSubmitResult?.status === "aborted" ? lastSubmitResult.error || "Subagent aborted task" : undefined;
const { abortedViaSubmitResult, hasSubmitResult } = finalized;
const { content: truncatedOutput, truncated } = truncateTail(rawOutput, {
maxBytes: MAX_OUTPUT_BYTES,
@@ -1203,6 +1231,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
// Update final progress
const wasAborted = abortedViaSubmitResult || (!hasSubmitResult && (done.aborted || signal?.aborted || false));
const finalAbortReason = wasAborted
? abortedViaSubmitResult
? submitResultAbortReason
: (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : "Subagent aborted task"))
: undefined;
progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
scheduleProgress(true);
@@ -1223,6 +1256,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
modelOverride,
error: exitCode !== 0 && stderr ? stderr : undefined,
aborted: wasAborted,
abortReason: finalAbortReason,
usage: hasUsage ? accumulatedUsage : undefined,
outputPath,
extractedToolData: progress.extractedToolData,
+2 -1
View File
@@ -938,8 +938,9 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
durationMs: 0,
tokens: 0,
modelOverride,
error: "Skipped",
error: "Cancelled before start",
aborted: true,
abortReason: "Cancelled before start",
};
});
+6 -1
View File
@@ -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))}`);
}
+1
View File
@@ -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 */
@@ -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");
});
});
@@ -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");
});
});