fix(coding-agent/task): plugged wall-clock timer holes and propagated context stats

- Added defensive abort re-check after registering the abortSignal listener plus a checkAbort() immediately before await session.prompt(...), so a wall-clock timer that fires during pre-prompt setup is no longer lost between listener registration and the prompt call.
- Late yield events arriving after a wall-clock timeout no longer flip the result to success: a runtimeLimitExceeded flag derived from the internal abortReason forces wasAborted=true and exitCode=1 regardless of hasYield, while yield payloads remain captured in extractedToolData.
- Async task progress now copies contextTokens and contextWindow from the completed SingleResult onto AgentProgress, so backgrounded tasks still surface their context gauge to the UI.
This commit is contained in:
can1357
2026-05-15 17:49:00 +02:00
parent dad6149393
commit c4672f11af
4 changed files with 186 additions and 5 deletions
@@ -157,4 +157,162 @@ describe("runSubprocess wall clock (task.maxRuntimeMs)", () => {
expect(result.aborted).toBe(false);
expect(result.abortReason).toBeUndefined();
});
it("aborts before prompting when the timer fires during session setup", async () => {
// Delay createAgentSession longer than maxRuntimeMs so the wall-clock
// timer fires while the executor is still doing async setup, well before
// it ever calls session.prompt(). The fix must observe abortSignal
// immediately before prompting and return the runtime-limit result.
const settings = Settings.isolated({ "task.maxRuntimeMs": 30 });
const handle = createHangingSession();
let promptCalls = 0;
const originalPrompt = handle.session.prompt;
handle.session.prompt = async (text, options) => {
promptCalls += 1;
return originalPrompt.call(handle.session, text, options);
};
vi.spyOn(sdkModule, "createAgentSession").mockImplementation(async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return {
session: handle.session,
extensionsResult: {} as unknown as LoadExtensionsResult,
setToolUIContext: () => {},
eventBus: new EventBus(),
} satisfies CreateAgentSessionResult;
});
const result = await runSubprocess({
...baseOptions,
id: "subagent-setup-timeout",
settings,
});
expect(result.aborted).toBe(true);
expect(result.exitCode).toBe(1);
expect(result.abortReason).toContain("runtime limit exceeded");
expect(result.abortReason).toContain("task.maxRuntimeMs=30");
// The whole point: we never reached session.prompt(), because the abort
// was observed before issuing the model call.
expect(promptCalls).toBe(0);
});
it("a late successful yield does not flip a timed-out run to success", async () => {
// A hung subagent emits a successful `yield` event during teardown (after
// the timer has already aborted). Without the fix, `hasYield=true` would
// make finalizeSubprocessOutput zero the exit code and `wasAborted`
// would resolve to false — silently masking the runtime-limit breach.
const settings = Settings.isolated({ "task.maxRuntimeMs": 30 });
const { promise: hang, resolve: releaseHang } = Promise.withResolvers<void>();
let listenerRef: ((event: AgentSessionEvent) => void) | undefined;
let abortCount = 0;
const session: Partial<AgentSession> = {
state: { messages: [] } as never,
agent: { state: { systemPrompt: ["test"] } } as never,
extensionRunner: undefined as never,
sessionManager: { appendSessionInit: () => {} } as never,
getActiveToolNames: () => ["read", "yield"],
setActiveToolsByName: async () => {},
subscribe: (listener: (event: AgentSessionEvent) => void) => {
listenerRef = listener;
return () => {};
},
prompt: async (_text: string, _options?: PromptOptions) => {
await hang;
},
waitForIdle: async () => {
await hang;
},
getLastAssistantMessage: () => undefined,
abort: async () => {
abortCount += 1;
// Simulate a late yield arriving while the executor is tearing
// the session down in response to the wall-clock abort.
listenerRef?.({
type: "tool_execution_end",
toolCallId: "tool-late-yield",
toolName: "yield",
result: {
content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { lateButLanded: true } },
},
isError: false,
} as AgentSessionEvent);
releaseHang();
},
dispose: async () => {},
};
mockCreateAgentSession(session as AgentSession);
const result = await runSubprocess({
...baseOptions,
id: "subagent-late-yield",
settings,
});
expect(abortCount).toBeGreaterThanOrEqual(1);
expect(result.aborted).toBe(true);
expect(result.exitCode).toBe(1);
expect(result.abortReason).toContain("runtime limit exceeded");
// Yield data is preserved for inspection — the regression was only in
// the exit status / abort flag, not in the captured payload.
expect(result.extractedToolData?.yield).toBeDefined();
});
it("propagates per-turn context tokens onto the SingleResult", async () => {
// Async task consumers (index.ts) copy `singleResult.contextTokens` and
// `singleResult.contextWindow` onto AgentProgress. This test pins the
// upstream contract: when an assistant message_end carries totalTokens,
// executor must surface it on SingleResult.contextTokens.
const settings = Settings.isolated({ "task.maxRuntimeMs": 0 });
const fastSession: Partial<AgentSession> = {
state: { messages: [] } as never,
agent: { state: { systemPrompt: ["test"] } } as never,
extensionRunner: undefined as never,
sessionManager: { appendSessionInit: () => {} } as never,
getActiveToolNames: () => ["read", "yield"],
setActiveToolsByName: async () => {},
subscribe: (listener: (event: AgentSessionEvent) => void) => {
queueMicrotask(() => {
listener({
type: "message_end",
message: {
role: "assistant",
content: [{ type: "text", text: "ok" }],
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 12345 },
},
} as unknown as AgentSessionEvent);
listener({
type: "tool_execution_end",
toolCallId: "tool-ok",
toolName: "yield",
result: {
content: [{ type: "text", text: "Result submitted." }],
details: { status: "success", data: { ok: true } },
},
isError: false,
} as AgentSessionEvent);
});
return () => {};
},
prompt: async () => {},
waitForIdle: async () => {},
getLastAssistantMessage: () => undefined,
abort: async () => {},
dispose: async () => {},
};
mockCreateAgentSession(fastSession as AgentSession);
const result = await runSubprocess({
...baseOptions,
id: "subagent-context-tokens",
settings,
});
expect(result.aborted).toBe(false);
expect(result.contextTokens).toBe(12345);
// contextWindow is only populated when the model registry resolves one;
// here we mock createAgentSession so it stays undefined. The async-task
// consumer's assignment is a straight copy, so undefined is acceptable.
expect(result.contextWindow).toBeUndefined();
});
});