Files
oh-my-pi/packages/coding-agent/test/task/executor-wall-clock.test.ts
T
can1357 2867e1f4e3 feat(deps): added pi.zod exports and removed TypeBox package exports
- Added canonical `pi.zod` schema API exports and removed TypeBox package exports/imports.
- Migrated Tool schema typing from TypeBox to shared `TSchema`/Zod flow with legacy TypeBox compatibility.
- Updated AI provider adapters and MCP/agent builders to convert tool params through `toolWireSchema()`.
- Reworked schema validation from AJV to Zod-safe parsing with `fromTypeBox`, `toolWireSchema`, and meta schema checks.
2026-05-15 14:46:54 +02:00

161 lines
5.4 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();
});
});