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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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))}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user