Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
41 changes: 41 additions & 0 deletions packages/ai/test/idle-iterator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,47 @@ describe("iterateWithIdleTimeout transport facts", () => {
expect(transportFailureFacts(error)).toBeUndefined();
});

it("keeps a reasoning stream alive past the shared 120-second default until the xAI window expires", async () => {
vi.useFakeTimers();
let yielded = false;
let iteratorClosed = false;
const source: AsyncIterable<{ type: "thinking" }> = {
[Symbol.asyncIterator]() {
return {
async next() {
if (!yielded) {
yielded = true;
return { done: false as const, value: { type: "thinking" as const } };
}
return await new Promise<never>(() => {});
},
async return() {
iteratorClosed = true;
return { done: true as const, value: undefined };
},
};
},
};
const iterator = iterateWithIdleTimeout(source, {
firstItemTimeoutMs: 300_000,
idleTimeoutMs: 300_000,
errorMessage: "stream idle",
});

expect((await iterator.next()).value).toEqual({ type: "thinking" });
const pending = iterator.next();
await waitForTimerRegistration();
vi.advanceTimersByTime(120_001);
await Promise.resolve();
expect(iteratorClosed).toBe(false);

vi.advanceTimersByTime(179_999);
const error = await pending.catch(error => error);
expect(error).toBeInstanceOf(Error);
expect(error).not.toBeInstanceOf(FirstEventTimeoutError);
expect(iteratorClosed).toBe(true);
});

it("stamps first-item expiry as FirstEventTimeoutError with transport facts", async () => {
vi.useFakeTimers();
const source = (async function* () {
Expand Down
15 changes: 12 additions & 3 deletions packages/coding-agent/src/task/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@ interface FinalizeSubprocessOutputArgs {
rawOutput: string;
exitCode: number;
stderr: string;
terminalFailure?: boolean;
doneAborted: boolean;
signalAborted: boolean;
yieldItems?: YieldItem[];
Expand Down Expand Up @@ -619,7 +620,7 @@ function buildPlaceholderYieldOutcome(

export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
let { rawOutput, exitCode, stderr } = args;
const { yieldItems, doneAborted, signalAborted, outputSchema } = args;
const { yieldItems, terminalFailure = false, doneAborted, signalAborted, outputSchema } = args;
let abortedViaYield = false;
const hasYield = Array.isArray(yieldItems) && yieldItems.length > 0;

Expand Down Expand Up @@ -673,8 +674,15 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
const errorMessage = err instanceof Error ? err.message : String(err);
rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
}
exitCode = 0;
stderr = "";
// A valid yield can preserve policy-safe public review output after a
// terminal provider failure, but it cannot convert that failed run
// into a successful subagent result. A normal yield starts with a
// non-zero provisional exit code, so use the explicit terminal fact
// rather than the provisional code to distinguish the two cases.
if (!terminalFailure) {
exitCode = 0;
stderr = "";
}
}
}
}
Expand Down Expand Up @@ -2183,6 +2191,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
rawOutput,
exitCode,
stderr,
terminalFailure: done.error !== undefined || Boolean(done.aborted),
doneAborted: Boolean(done.aborted),
signalAborted: Boolean(signal?.aborted),
yieldItems,
Expand Down
9 changes: 6 additions & 3 deletions packages/coding-agent/src/tools/subagent-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export const subagentToolRenderer = {
}

const runningCount = subagents.filter(s => s.status === "running").length;
const failedCount = subagents.filter(s => s.status === "failed" || s.status === "not_found").length;
const interrupted = result.details?.interrupted === true;

// Each snapshot's rendered-state signature is constant for this component
Expand All @@ -280,14 +281,16 @@ export const subagentToolRenderer = {
// it is never gated by the heavy body cache.
const header = renderStatusLine(
{
icon: interrupted ? "warning" : runningCount > 0 ? "info" : "success",
icon: interrupted ? "warning" : runningCount > 0 ? "info" : failedCount > 0 ? "error" : "success",
spinnerFrame: !interrupted && runningCount > 0 ? options.spinnerFrame : undefined,
title: interrupted ? "Subagent await interrupted" : "Subagent",
title: interrupted ? "Subagent await interrupted" : failedCount > 0 ? "Subagent failed" : "Subagent",
description: interrupted
? "child subagents continue"
: runningCount > 0
? `awaiting ${runningCount} of ${subagents.length}`
: `${subagents.length} ${subagents.length === 1 ? "subagent" : "subagents"}`,
: failedCount > 0
? `${failedCount} ${failedCount === 1 ? "subagent" : "subagents"} failed`
: `${subagents.length} ${subagents.length === 1 ? "subagent" : "subagents"}`,
},
theme,
);
Expand Down
10 changes: 5 additions & 5 deletions packages/coding-agent/src/tools/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,11 +767,11 @@ export class SubagentTool implements AgentTool<typeof subagentSchema, SubagentTo
if (!attachLiveProgress) return {};
const liveProgressAvailable = manager.hasLiveSubagent(record.subagentId);
if (!liveProgressAvailable) return { liveProgressAvailable: false };
const progress = manager.getSubagentProgress(record.subagentId);
return {
liveProgressAvailable: true,
...(progress ? { progress } : {}),
};
// AgentProgress includes model-generated deltas, tool arguments, nested
// task details, and arbitrary tool output. None is an approved public
// subagent payload, so await receipts expose only liveness. Terminal public
// output continues through the bounded result/error receipt and agent://.
return { liveProgressAvailable: true };
}

#recordSnapshot(
Expand Down
18 changes: 18 additions & 0 deletions packages/coding-agent/test/task/executor-warnings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,24 @@ describe("subagent warning injection", () => {
expect(result.rawOutput.includes("SYSTEM WARNING")).toBe(false);
});

it("preserves a public review payload but not success after a terminal stream failure", () => {
const result = finalizeSubprocessOutput({
rawOutput: "private reasoning must not be promoted",
exitCode: 1,
stderr: "OpenAI completions stream stalled while waiting for the next event",
terminalFailure: true,
doneAborted: false,
signalAborted: false,
yieldItems: [{ status: "success", data: { overall_correctness: "incorrect", summary: "Public review" } }],
outputSchema: undefined,
});

expect(JSON.parse(result.rawOutput)).toEqual({ overall_correctness: "incorrect", summary: "Public review" });
expect(result.rawOutput).not.toContain("private reasoning");
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe("OpenAI completions stream stalled while waiting for the next event");
});

it("validates strict reviewer output without synthesizing findings", () => {
const result = finalizeSubprocessOutput({
rawOutput: "ignored",
Expand Down
8 changes: 8 additions & 0 deletions packages/coding-agent/test/tools/subagent-render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ describe("subagentToolRenderer", () => {
expect(out).not.toContain("ctrl+s");
});

it("renders a failed aggregate await as an error", () => {
const out = render({
subagents: [snapshot({ id: "0-Fail", status: "failed", errorText: "stream stalled" })],
});
expect(out).toContain("Subagent failed");
expect(out).toContain("1 subagent failed");
});

it("caps the result preview at one line collapsed and at four lines expanded (AC2)", () => {
const details: SubagentToolDetails = {
subagents: [
Expand Down
77 changes: 76 additions & 1 deletion packages/coding-agent/test/tools/subagent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { AgentSession, PromptOptions } from "../../src/session/agent-sessio
import { subagentRunOutcomeFromSingleResult } from "../../src/task";
import { runSubprocess } from "../../src/task/executor";
import { buildTaskReceipt } from "../../src/task/receipt";
import type { AgentDefinition } from "../../src/task/types";
import type { AgentDefinition, AgentProgress } from "../../src/task/types";
import { createSetupFailureSummary, type SingleResult } from "../../src/task/types";
import type { ToolSession } from "../../src/tools";
import { capCodePointsAndBytes, SubagentTool } from "../../src/tools/implementations";
Expand Down Expand Up @@ -91,6 +91,81 @@ describe("SubagentTool", () => {
AsyncJobManager.resetForTests();
});

it("keeps a failed subagent's public structured output visible to inspect and await", async () => {
const manager = createManager();
const tool = new SubagentTool(createSession());
const publicReview = '{"overall_correctness":"incorrect","summary":"Public review"}';
const jobId = manager.register("task", "stalled review", async () => ({ kind: "failed", text: publicReview }), {
id: "job-stalled-review",
ownerId: "0-Main",
metadata: {
subagent: {
id: "0-StalledReview",
agent: "architect",
agentSource: "bundled",
assignment: "Review the change.",
},
},
});
await manager.getJob(jobId)?.promise;

for (const action of ["inspect", "await"] as const) {
const result = await tool.execute(`subagent-${action}-failed-review`, {
action,
ids: ["0-StalledReview"],
verbosity: "full",
});
const snapshot = result.details?.subagents[0];
expect(snapshot?.status).toBe("failed");
expect(snapshot?.errorText).toBe(publicReview);
expect(getText(result)).toContain("Error preview:");
expect(getText(result)).toContain(publicReview);
}
});

it("does not expose live provider thinking through await progress", async () => {
const manager = createManager();
const tool = new SubagentTool(createSession());
const gate = Promise.withResolvers<string>();
manager.register("task", "live subagent", async () => gate.promise, {
id: "job-private-progress",
ownerId: "0-Main",
metadata: {
subagent: { id: "0-PrivateProgress", agent: "executor", agentSource: "bundled", assignment: "Work." },
},
});
const progress: AgentProgress = {
index: 0,
id: "0-PrivateProgress",
agent: "executor",
agentSource: "bundled",
status: "running",
task: "Work.",
recentTools: [],
recentOutput: ["PRIVATE_THINKING_MUST_NOT_ESCAPE"],
toolCount: 0,
tokens: 0,
cost: 0,
durationMs: 0,
};
manager.recordSubagentProgress("0-PrivateProgress", progress);
manager.registerLiveHandle("0-PrivateProgress", {
requestPause: () => {},
injectMessage: async () => {},
});

const result = await tool.execute("subagent-await-private-progress", {
action: "await",
ids: ["0-PrivateProgress"],
timeout_ms: 1,
});
const snapshot = result.details?.subagents[0];
expect(snapshot?.progress).toBeUndefined();
expect(getText(result)).not.toContain("PRIVATE_THINKING_MUST_NOT_ESCAPE");
gate.resolve("done");
await manager.getJob("job-private-progress")?.promise;
});

it("lists only visible task jobs with subagent metadata", async () => {
const manager = createManager();
const tool = new SubagentTool(createSession("0-Main"));
Expand Down
Loading