c4672f11af
- 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.
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
import type { ModelRegistry } from "../../src/config/model-registry";
|
|
import { Settings } from "../../src/config/settings";
|
|
import type { LoadExtensionsResult } from "../../src/extensibility/extensions/types";
|
|
import type { CreateAgentSessionResult } from "../../src/sdk";
|
|
import * as sdkModule from "../../src/sdk";
|
|
import type { AgentSession, AgentSessionEvent, PromptOptions } from "../../src/session/agent-session";
|
|
import { runSubprocess } from "../../src/task/executor";
|
|
import type { AgentDefinition } from "../../src/task/types";
|
|
import { EventBus } from "../../src/utils/event-bus";
|
|
|
|
/**
|
|
* Contract: when `task.maxRuntimeMs` is set, a subagent whose inference call
|
|
* never resolves (provider stream hang the watchdog couldn't catch) MUST be
|
|
* aborted within ~maxRuntimeMs and surface a clear "runtime limit exceeded"
|
|
* reason — not a generic "Cancelled by caller" — so on-call engineers don't
|
|
* mistake it for a user cancellation.
|
|
*
|
|
* Without this defense, the executor's `await session.waitForIdle()` waits
|
|
* indefinitely (see session 019e2b4d-fa25-7000-a725-955278e9b293, subagent 7,
|
|
* which stayed silent for ~2 hours).
|
|
*/
|
|
|
|
interface HangingSessionHandle {
|
|
session: AgentSession;
|
|
abortCalls: () => number;
|
|
}
|
|
|
|
function createHangingSession(): HangingSessionHandle {
|
|
let abortCount = 0;
|
|
const { promise: hang, resolve: releaseHang } = Promise.withResolvers<void>();
|
|
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 (_names: string[]) => {},
|
|
subscribe: (_listener: (event: AgentSessionEvent) => void) => () => {},
|
|
prompt: async (_text: string, _options?: PromptOptions) => {
|
|
await hang;
|
|
},
|
|
waitForIdle: async () => {
|
|
await hang;
|
|
},
|
|
getLastAssistantMessage: () => undefined,
|
|
abort: async () => {
|
|
abortCount += 1;
|
|
releaseHang();
|
|
},
|
|
dispose: async () => {},
|
|
};
|
|
return {
|
|
session: session as AgentSession,
|
|
abortCalls: () => abortCount,
|
|
};
|
|
}
|
|
|
|
function mockCreateAgentSession(session: AgentSession) {
|
|
return vi.spyOn(sdkModule, "createAgentSession").mockResolvedValue({
|
|
session,
|
|
extensionsResult: {} as unknown as LoadExtensionsResult,
|
|
setToolUIContext: () => {},
|
|
eventBus: new EventBus(),
|
|
} satisfies CreateAgentSessionResult);
|
|
}
|
|
|
|
describe("runSubprocess wall clock (task.maxRuntimeMs)", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const baseAgent: AgentDefinition = {
|
|
name: "task",
|
|
description: "test",
|
|
systemPrompt: "test",
|
|
source: "bundled",
|
|
};
|
|
|
|
const baseOptions = {
|
|
cwd: "/tmp",
|
|
agent: baseAgent,
|
|
task: "do work",
|
|
index: 0,
|
|
id: "subagent-walltime",
|
|
modelRegistry: { refresh: async () => {} } as unknown as ModelRegistry,
|
|
enableLsp: false,
|
|
};
|
|
|
|
it("aborts a stalled subagent and surfaces a runtime-limit reason", async () => {
|
|
const settings = Settings.isolated({ "task.maxRuntimeMs": 50 });
|
|
const handle = createHangingSession();
|
|
mockCreateAgentSession(handle.session);
|
|
|
|
const startedAt = Date.now();
|
|
const result = await runSubprocess({
|
|
...baseOptions,
|
|
id: "subagent-timeout",
|
|
settings,
|
|
});
|
|
const elapsedMs = Date.now() - startedAt;
|
|
|
|
expect(result.aborted).toBe(true);
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.abortReason).toContain("runtime limit exceeded");
|
|
expect(result.abortReason).toContain("task.maxRuntimeMs=50");
|
|
expect(handle.abortCalls()).toBeGreaterThanOrEqual(1);
|
|
// Sanity: must finish in roughly the configured window (allow generous slack
|
|
// for CI; the contract is "doesn't hang for hours", not "exactly 50 ms").
|
|
expect(elapsedMs).toBeLessThan(10_000);
|
|
});
|
|
|
|
it("does not abort early when the runtime budget is unlimited", async () => {
|
|
// Stub session resolves immediately to a no-op yield so we don't actually
|
|
// hang; we only need to assert that NO timeout fires when maxRuntimeMs=0.
|
|
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) => {
|
|
// Fire a synthetic yield on the next tick to drive runSubprocess to
|
|
// completion without depending on the real agent loop.
|
|
queueMicrotask(() => {
|
|
listener({
|
|
type: "tool_execution_end",
|
|
toolCallId: "tool-fast",
|
|
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-no-limit",
|
|
settings,
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|