2867e1f4e3
- 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.
161 lines
5.4 KiB
TypeScript
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();
|
|
});
|
|
});
|