diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 80a544832..dbabc7534 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -2216,7 +2216,7 @@ describe("agentLoop with AgentMessage", () => { let steerReady = false; let drained = false; let observedAbort = false; - let resolvedByTimeout = false; + const toolRelease = Promise.withResolvers(); const tool: AgentTool> = { name: "wait", @@ -2226,24 +2226,7 @@ describe("agentLoop with AgentMessage", () => { interruptible: params => params.op === "wait", async execute(_toolCallId, _params, signal) { steerReady = true; - const { promise, resolve } = Promise.withResolvers(); - if (signal?.aborted) { - resolve(); - } else { - const timer = setTimeout(() => { - resolvedByTimeout = true; - resolve(); - }, 300); - signal?.addEventListener( - "abort", - () => { - clearTimeout(timer); - resolve(); - }, - { once: true }, - ); - } - await promise; + if (!signal?.aborted) await toolRelease.promise; observedAbort = signal?.aborted === true; return { content: [{ type: "text", text: "waited" }], details: {} }; }, @@ -2260,7 +2243,11 @@ describe("agentLoop with AgentMessage", () => { model: mock.model, convertToLlm: identityConverter, interruptMode: "immediate", - hasSteeringMessages: () => steerReady && !drained, + hasSteeringMessages: () => { + const queued = steerReady && !drained; + if (queued) toolRelease.resolve(); + return queued; + }, getSteeringMessages: async () => { if (steerReady && !drained) { drained = true; @@ -2276,7 +2263,7 @@ describe("agentLoop with AgentMessage", () => { } expect(observedAbort).toBe(false); - expect(resolvedByTimeout).toBe(true); + expect(steerReady).toBe(true); expect(drained).toBe(true); expect( events.some(e => e.type === "message_start" && e.message.role === "user" && e.message.content === "interrupt"), @@ -3203,7 +3190,12 @@ describe("agentLoop event-driven steering watch", () => { // drain } })(); - const completed = await Promise.race([drain.then(() => true), Bun.sleep(1000).then(() => false)]); + // This is the behavior under test, so retain a deadline; cancel its timer + // when teardown succeeds instead of leaving a losing sleep alive. + const timeout = Promise.withResolvers(); + const timeoutId = setTimeout(() => timeout.resolve(false), 1000); + const completed = await Promise.race([drain.then(() => true), timeout.promise]); + clearTimeout(timeoutId); try { expect(completed).toBe(true); expect(executed).toEqual(["only"]); diff --git a/packages/agent/test/pause-gate.test.ts b/packages/agent/test/pause-gate.test.ts index e534c4ac9..5541a26ef 100644 --- a/packages/agent/test/pause-gate.test.ts +++ b/packages/agent/test/pause-gate.test.ts @@ -36,17 +36,27 @@ describe("agentPauseGate", () => { const context: AgentContext = { systemPrompt: ["Test"], messages: [], tools: [] }; const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; + const parked = Promise.withResolvers(); + const originalWait = agentPauseGate.waitUntilResumed; + agentPauseGate.waitUntilResumed = (signal?: AbortSignal) => { + parked.resolve(); + return originalWait.call(agentPauseGate, signal); + }; expect(agentPauseGate.pause()).toBe(true); expect(agentPauseGate.pause()).toBe(false); // already engaged const result = agentLoop([createUserMessage("hi")], context, config, undefined, mock.stream).result(); - await Bun.sleep(20); + await parked.promise; expect(mock.calls.length).toBe(0); // parked before the first provider call - expect(agentPauseGate.resume()).toBeGreaterThanOrEqual(0); - const messages = await result; - expect(mock.calls.length).toBe(1); - expect(messages[messages.length - 1].role).toBe("assistant"); + try { + expect(agentPauseGate.resume()).toBeGreaterThanOrEqual(0); + const messages = await result; + expect(mock.calls.length).toBe(1); + expect(messages[messages.length - 1].role).toBe("assistant"); + } finally { + agentPauseGate.waitUntilResumed = originalWait; + } }); it("holds tool execution at the tool boundary when paused mid-turn", async () => { @@ -96,6 +106,12 @@ describe("agentPauseGate", () => { const config: AgentLoopConfig = { model: mock.model, convertToLlm: identityConverter }; const abortController = new AbortController(); + const parked = Promise.withResolvers(); + const originalWait = agentPauseGate.waitUntilResumed; + agentPauseGate.waitUntilResumed = (signal?: AbortSignal) => { + parked.resolve(); + return originalWait.call(agentPauseGate, signal); + }; agentPauseGate.pause(); const result = agentLoop( [createUserMessage("hi")], @@ -104,19 +120,23 @@ describe("agentPauseGate", () => { abortController.signal, mock.stream, ).result(); - await Bun.sleep(20); + await parked.promise; abortController.abort("user interrupt"); // The run must terminate as aborted promptly (not stay parked until // resume). The provider request itself carries the aborted signal, so // whether the transport is entered at all is an implementation detail. - const messages = await result; - const last = messages[messages.length - 1]; - expect(last.role).toBe("assistant"); - if (last.role === "assistant") { - expect(last.stopReason).toBe("aborted"); + try { + const messages = await result; + const last = messages[messages.length - 1]; + expect(last.role).toBe("assistant"); + if (last.role === "assistant") { + expect(last.stopReason).toBe("aborted"); + } + expect(agentPauseGate.paused).toBe(true); // aborting one run never resumes the process + } finally { + agentPauseGate.waitUntilResumed = originalWait; } - expect(agentPauseGate.paused).toBe(true); // aborting one run never resumes the process }); it("re-parks a waiter when the gate is re-engaged in the same tick as resume", async () => { @@ -128,7 +148,7 @@ describe("agentPauseGate", () => { agentPauseGate.resume(); agentPauseGate.pause(); // re-engage before the waiter's microtask runs - await Bun.sleep(10); + await Promise.resolve(); expect(released).toBe(false); agentPauseGate.resume(); diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 0092ebac8..67e576309 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -189,6 +189,7 @@ let providerInFlightHeartbeatWriterOverride: | ((writeProviderInFlightInfo: () => Promise) => Promise) | undefined; let providerInFlightLeaseRemoverOverride: ((leasePath: string) => Promise) | undefined; +let providerInFlightWaitObserverOverride: ((provider: string) => void) | undefined; export function configureProviderMaxInFlightRequests(limits: Record | undefined): void { configuredProviderMaxInFlightRequests = limits ?? {}; @@ -489,6 +490,7 @@ function waitForProviderInFlightSignal(provider: string, signal?: AbortSignal): if (signal?.aborted) return Promise.reject(signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch")); const signalPath = providerInFlightSignalPath(provider); + providerInFlightWaitObserverOverride?.(provider); const waitStarted = Date.now(); const { promise, resolve, reject } = Promise.withResolvers(); let settled = false; @@ -615,6 +617,9 @@ export const __providerInFlightForTesting = { setLeaseRemover(remover: ((leasePath: string) => Promise) | undefined): void { providerInFlightLeaseRemoverOverride = remover; }, + setWaitObserver(observer: ((provider: string) => void) | undefined): void { + providerInFlightWaitObserverOverride = observer; + }, providerDir(provider: string): string { return providerInFlightDir(provider); }, diff --git a/packages/ai/test/auth-storage-codex-selection.test.ts b/packages/ai/test/auth-storage-codex-selection.test.ts index 72656926e..5cad8a22b 100644 --- a/packages/ai/test/auth-storage-codex-selection.test.ts +++ b/packages/ai/test/auth-storage-codex-selection.test.ts @@ -2354,7 +2354,8 @@ describe("AuthStorage codex oauth ranking", () => { }; }); - const refreshDelayMs = 75; + const allRefreshesStarted = Promise.withResolvers(); + const releaseRefreshes = Promise.withResolvers(); let inFlight = 0; let maxConcurrent = 0; const refreshStarts: number[] = []; @@ -2362,7 +2363,8 @@ describe("AuthStorage codex oauth ranking", () => { refreshStarts.push(Date.now()); inFlight += 1; maxConcurrent = Math.max(maxConcurrent, inFlight); - await Bun.sleep(refreshDelayMs); + if (inFlight === 3) allRefreshesStarted.resolve(); + await releaseRefreshes.promise; inFlight -= 1; return { ...credential, @@ -2378,7 +2380,10 @@ describe("AuthStorage codex oauth ranking", () => { { type: "oauth", ...createCredential("acct-third", "third@example.com"), expires: expiredAt }, ]); - const apiKey = await authStorage.getApiKey("openai-codex"); + const apiKeyPromise = authStorage.getApiKey("openai-codex"); + await allRefreshesStarted.promise; + releaseRefreshes.resolve(); + const apiKey = await apiKeyPromise; expect(apiKey).toBe("refreshed-acct-third"); expect(refreshStarts).toHaveLength(3); diff --git a/packages/ai/test/callback-server-port-fallback.test.ts b/packages/ai/test/callback-server-port-fallback.test.ts index bfa3cd8cd..a0185b86a 100644 --- a/packages/ai/test/callback-server-port-fallback.test.ts +++ b/packages/ai/test/callback-server-port-fallback.test.ts @@ -49,12 +49,12 @@ describe("OAuthCallbackFlow port fallback policy", () => { it("falls back to a random port by default so historical AI-provider flows keep working", async () => { const blocker = occupyLoopbackPort(); const progress: string[] = []; + const controller = new AbortController(); const flow = new TestCallbackFlow( { - onAuth: () => {}, + onAuth: () => controller.abort(new Error("redirect URI observed")), onProgress: msg => progress.push(msg), - // Short abort — we only care that the flow advertised the fallback URI. - signal: AbortSignal.timeout(100), + signal: controller.signal, }, { preferredPort: blocker.port }, ); diff --git a/packages/ai/test/google-gemini-cli-alignment.test.ts b/packages/ai/test/google-gemini-cli-alignment.test.ts index b9c53b104..2ca23ea5b 100644 --- a/packages/ai/test/google-gemini-cli-alignment.test.ts +++ b/packages/ai/test/google-gemini-cli-alignment.test.ts @@ -401,7 +401,7 @@ describe("Google Gemini CLI alignment", () => { const encoder = new TextEncoder(); for (const chunk of sseChunks) { controller.enqueue(encoder.encode(chunk)); - await Bun.sleep(5); + await Promise.resolve(); } controller.close(); }, @@ -464,7 +464,7 @@ describe("Google Gemini CLI alignment", () => { const encoder = new TextEncoder(); for (const chunk of sseChunks) { controller.enqueue(encoder.encode(chunk)); - await Bun.sleep(5); + await Promise.resolve(); } controller.close(); }, @@ -577,7 +577,7 @@ describe("Google Gemini CLI alignment", () => { const encoder = new TextEncoder(); for (const chunk of chunks) { controller.enqueue(encoder.encode(chunk)); - await Bun.sleep(5); + await Promise.resolve(); } controller.close(); }, @@ -624,7 +624,7 @@ describe("Google Gemini CLI alignment", () => { const encoder = new TextEncoder(); for (const chunk of sseChunks) { controller.enqueue(encoder.encode(chunk)); - await Bun.sleep(5); + await Promise.resolve(); } controller.close(); }, @@ -668,7 +668,7 @@ describe("Google Gemini CLI alignment", () => { const encoder = new TextEncoder(); for (const chunk of sseChunks) { controller.enqueue(encoder.encode(chunk)); - await Bun.sleep(5); + await Promise.resolve(); } controller.close(); }, @@ -821,7 +821,7 @@ describe("Google Gemini CLI alignment", () => { const encoder = new TextEncoder(); for (const chunk of sseChunks) { controller.enqueue(encoder.encode(chunk)); - await Bun.sleep(5); + await Promise.resolve(); } controller.close(); }, diff --git a/packages/ai/test/provider-inflight.test.ts b/packages/ai/test/provider-inflight.test.ts index 89256f683..feedc4ae6 100644 --- a/packages/ai/test/provider-inflight.test.ts +++ b/packages/ai/test/provider-inflight.test.ts @@ -27,6 +27,7 @@ afterEach(async () => { __providerInFlightForTesting.setHeartbeatTimings(undefined); __providerInFlightForTesting.setHeartbeatWriter(undefined); __providerInFlightForTesting.setLeaseRemover(undefined); + __providerInFlightForTesting.setWaitObserver(undefined); if (limiterRoot !== undefined) { await fs.rm(limiterRoot, { recursive: true, force: true }); limiterRoot = undefined; @@ -41,6 +42,13 @@ async function useIsolatedLimiterRoot(): Promise { function limiterDir(provider: string): string { return __providerInFlightForTesting.providerDir(provider); } +function nextLimiterWait(provider = "tests"): Promise { + const waiting = Promise.withResolvers(); + __providerInFlightForTesting.setWaitObserver(waitingProvider => { + if (waitingProvider === provider) waiting.resolve(); + }); + return waiting.promise; +} describe("provider in-flight request limits", () => { beforeEach(async () => { @@ -75,9 +83,9 @@ describe("provider in-flight request limits", () => { const firstResult = first.result(); await firstStarted.promise; + const secondWaiting = nextLimiterWait(); const second = streamSimple(mock.model, context(), { maxInFlightRequests: { tests: 1 } }); - await Bun.sleep(20); - expect(mock.calls).toHaveLength(1); + await secondWaiting; releaseFirst.resolve(); const [firstMessage, secondMessage] = await Promise.all([firstResult, second.result()]); @@ -110,27 +118,21 @@ describe("provider in-flight request limits", () => { return undefined; })(); const resultPromise = stream.result(); - const removalOutcome = await Promise.race([ - removalStarted.promise.then(() => "started" as const), - Bun.sleep(2_000).then(() => "blocked" as const), - ]); - if (removalOutcome === "blocked") { - allowRemoval.resolve(); - throw new Error("Provider lease removal did not start"); - } - let completionBeforeRelease: "terminal" | "result" | "pending"; - try { - completionBeforeRelease = await Promise.race([ - terminalObserved.promise.then(() => "terminal" as const), - resultPromise.then(() => "result" as const), - Bun.sleep(20).then(() => "pending" as const), - ]); - } finally { - allowRemoval.resolve(); - } + await removalStarted.promise; + let terminalCompleted = false; + let resultCompleted = false; + void terminalObserved.promise.then(() => { + terminalCompleted = true; + }); + void resultPromise.then(() => { + resultCompleted = true; + }); + await Promise.resolve(); + expect(terminalCompleted).toBe(false); + expect(resultCompleted).toBe(false); + allowRemoval.resolve(); const [result, terminalType] = await Promise.all([resultPromise, terminalObservation]); - expect(completionBeforeRelease).toBe("pending"); expect(result.content).toEqual([{ type: "text", text: "reply" }]); expect(terminalType).toBe("done"); const entries = await fs.readdir(limiterDir("tests"), { withFileTypes: true }); @@ -328,12 +330,13 @@ describe("provider in-flight request limits", () => { const controller = new AbortController(); const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] }); + const waiting = nextLimiterWait(); const stream = streamSimple(mock.model, context(), { maxInFlightRequests: { tests: 1 }, signal: controller.signal, }); - await Bun.sleep(150); + await waiting; expect(mock.calls).toHaveLength(0); await fs.rm(externalLease, { recursive: true, force: true }); @@ -355,12 +358,13 @@ describe("provider in-flight request limits", () => { const controller = new AbortController(); const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] }); + const waiting = nextLimiterWait(); const stream = streamSimple(mock.model, context(), { maxInFlightRequests: { tests: 1 }, signal: controller.signal, }); - await Bun.sleep(50); + await waiting; expect(await Bun.file(path.join(providerDir, ".wakeup")).exists()).toBe(false); expect(mock.calls).toHaveLength(0); @@ -403,12 +407,13 @@ describe("provider in-flight request limits", () => { const controller = new AbortController(); const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] }); + const waiting = nextLimiterWait(); const stream = streamSimple(mock.model, context(), { maxInFlightRequests: { tests: 1 }, signal: controller.signal, }); - await Bun.sleep(150); + await waiting; expect(mock.calls).toHaveLength(0); controller.abort(new Error("cancel lock waiter")); @@ -427,12 +432,13 @@ describe("provider in-flight request limits", () => { const controller = new AbortController(); const mock = createMockModel({ provider: "tests", responses: [{ content: ["reply"] }] }); + const waiting = nextLimiterWait(); const stream = streamSimple(mock.model, context(), { maxInFlightRequests: { tests: 1 }, signal: controller.signal, }); - await Bun.sleep(150); + await waiting; expect(mock.calls).toHaveLength(0); controller.abort(new Error("cancel partial-info waiter")); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 9e6173a0c..d82ab6de8 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Updated model metadata, context windows, pricing, and configurations in the catalog + ## [17.3.0] - 2026-08-13 ### Breaking Changes diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index acb92fa35..9caf3b766 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -16,7 +16,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 1000000, + "contextWindow": 1048576, "maxTokens": 393216, "thinking": { "mode": "effort", @@ -25,7 +25,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "deepseek-ai/deepseek-v4-pro": { "id": "deepseek-ai/deepseek-v4-pro", @@ -43,7 +44,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 1000000, + "contextWindow": 1048576, "maxTokens": 393216, "thinking": { "mode": "effort", @@ -52,7 +53,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "google/gemma-4-31b-it": { "id": "google/gemma-4-31b-it", @@ -82,37 +84,8 @@ "high", "xhigh" ] - } - }, - "moonshotai/kimi-k2.6": { - "id": "moonshotai/kimi-k2.6", - "name": "Kimi K2.6", - "api": "openai-completions", - "provider": "aiand", - "baseUrl": "https://api.aiand.com/v1", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0.85, - "output": 3.5, - "cacheRead": 0, - "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 262144, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } + "supportsComputerUse": false }, "moonshotai/kimi-k2.7-code": { "id": "moonshotai/kimi-k2.7-code", @@ -142,7 +115,8 @@ "high", "xhigh" ] - } + }, + "supportsComputerUse": false }, "moonshotai/kimi-k3": { "id": "moonshotai/kimi-k3", @@ -233,7 +207,8 @@ "medium", "high" ] - } + }, + "supportsComputerUse": false }, "qwen/qwen3.6-27b": { "id": "qwen/qwen3.6-27b", @@ -247,8 +222,8 @@ "image" ], "cost": { - "input": 0, - "output": 0, + "input": 0.32, + "output": 3.2, "cacheRead": 0, "cacheWrite": 0 }, @@ -262,36 +237,8 @@ "medium", "high" ] - } - }, - "zai-org/glm-5.1": { - "id": "zai-org/glm-5.1", - "name": "GLM 5.1", - "api": "openai-completions", - "provider": "aiand", - "baseUrl": "https://api.aiand.com/v1", - "reasoning": true, - "input": [ - "text" - ], - "cost": { - "input": 1.4, - "output": 4.4, - "cacheRead": 0, - "cacheWrite": 0 }, - "contextWindow": 202752, - "maxTokens": 131072, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } + "supportsComputerUse": false }, "zai-org/glm-5.2": { "id": "zai-org/glm-5.2", @@ -309,7 +256,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 1000000, + "contextWindow": 1048576, "maxTokens": 131072, "thinking": { "mode": "effort", @@ -320,7 +267,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false } }, "aimlapi": { @@ -12058,7 +12006,8 @@ "max" ], "supportsDisplay": true - } + }, + "supportsComputerUse": false }, "claude-opus-4-0": { "id": "claude-opus-4-0", @@ -18336,7 +18285,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "cursor-grok-4.6-high-fast": { "id": "cursor-grok-4.6-high-fast", @@ -18357,7 +18307,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "cursor-grok-4.6-low": { "id": "cursor-grok-4.6-low", @@ -18378,7 +18329,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "cursor-grok-4.6-low-fast": { "id": "cursor-grok-4.6-low-fast", @@ -18399,7 +18351,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "cursor-grok-4.6-medium": { "id": "cursor-grok-4.6-medium", @@ -18420,7 +18373,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "cursor-grok-4.6-medium-fast": { "id": "cursor-grok-4.6-medium-fast", @@ -18441,7 +18395,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "cursor-grok-4.6-xhigh": { "id": "cursor-grok-4.6-xhigh", @@ -18462,7 +18417,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "cursor-grok-4.6-xhigh-fast": { "id": "cursor-grok-4.6-xhigh-fast", @@ -18483,7 +18439,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "default": { "id": "default", @@ -18727,6 +18684,72 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "gemini-3.7-flash-high": { + "id": "gemini-3.7-flash-high", + "name": "Gemini 3.7 Flash", + "api": "cursor-agent", + "provider": "cursor", + "baseUrl": "https://api2.cursor.sh", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 64000, + "cursorMaxMode": false, + "supportsComputerUse": false + }, + "gemini-3.7-flash-low": { + "id": "gemini-3.7-flash-low", + "name": "Gemini 3.7 Flash Low", + "api": "cursor-agent", + "provider": "cursor", + "baseUrl": "https://api2.cursor.sh", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 64000, + "cursorMaxMode": false, + "supportsComputerUse": false + }, + "gemini-3.7-flash-medium": { + "id": "gemini-3.7-flash-medium", + "name": "Gemini 3.7 Flash Medium", + "api": "cursor-agent", + "provider": "cursor", + "baseUrl": "https://api2.cursor.sh", + "reasoning": false, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 64000, + "cursorMaxMode": false, + "supportsComputerUse": false + }, "glm-5.2-high": { "id": "glm-5.2-high", "name": "GLM 5.2", @@ -22054,7 +22077,8 @@ "minimal": "none" } }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "nemotron-3-ultra-nvfp4": { "id": "nemotron-3-ultra-nvfp4", @@ -22121,7 +22145,8 @@ "minimal": "none" } }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "qwen3-embedding-8b": { "id": "qwen3-embedding-8b", @@ -31465,7 +31490,7 @@ }, "deepseek/deepseek-v4-flash:discounted": { "id": "deepseek/deepseek-v4-flash:discounted", - "name": "DeepSeek V4 Flash (lowest price)", + "name": "DeepSeek V4 Flash 0731 (lowest price)", "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", @@ -31567,7 +31592,7 @@ }, "deepseek/deepseek-v4-pro:discounted": { "id": "deepseek/deepseek-v4-pro:discounted", - "name": "DeepSeek V4 Pro (lowest price)", + "name": "DeepSeek V4 Pro 0813 (lowest price)", "api": "openai-completions", "provider": "kilo", "baseUrl": "https://api.kilo.ai/api/gateway", @@ -32299,6 +32324,26 @@ "requiresEffort": true } }, + "google/gemini-3.7-flash": { + "id": "google/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "api": "openai-completions", + "provider": "kilo", + "baseUrl": "https://api.kilo.ai/api/gateway", + "reasoning": false, + "input": [ + "text" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "supportsComputerUse": false + }, "google/gemma-2-27b-it": { "id": "google/gemma-2-27b-it", "name": "Gemma 2 27B", @@ -32809,7 +32854,8 @@ "high", "xhigh" ] - } + }, + "supportsComputerUse": false }, "inclusionai/ring-2.6-1t": { "id": "inclusionai/ring-2.6-1t", @@ -35539,9 +35585,9 @@ "text" ], "cost": { - "input": 0.05, + "input": 0.08, "output": 0.2, - "cacheRead": 0, + "cacheRead": 0.04, "cacheWrite": 0 }, "contextWindow": 262144, @@ -38800,7 +38846,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 16384 + "maxTokens": 262144 }, "qwen/qwen3-next-80b-a3b-thinking": { "id": "qwen/qwen3-next-80b-a3b-thinking", @@ -38818,8 +38864,8 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 262144, + "contextWindow": 131072, + "maxTokens": 32768, "thinking": { "mode": "effort", "efforts": [ @@ -39106,7 +39152,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 65536, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -39539,11 +39585,11 @@ "cost": { "input": 2, "output": 6, - "cacheRead": 0.2, + "cacheRead": 0.25, "cacheWrite": 0 }, - "contextWindow": 262144, - "maxTokens": 52429, + "contextWindow": 1000000, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -41572,7 +41618,7 @@ "cacheRead": 0.26, "cacheWrite": 0 }, - "contextWindow": 1048576, + "contextWindow": 262144, "maxTokens": 131072, "thinking": { "mode": "effort", @@ -51714,7 +51760,8 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 32768 + "maxTokens": 32768, + "supportsComputerUse": false }, "inclusionai/ling-3.0-tiny:thinking": { "id": "inclusionai/ling-3.0-tiny:thinking", @@ -51743,7 +51790,8 @@ "high", "xhigh" ] - } + }, + "supportsComputerUse": false }, "inclusionai/ring-2.6-1t": { "id": "inclusionai/ring-2.6-1t", @@ -60140,7 +60188,8 @@ "high" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "qwen3.8-max:thinking": { "id": "qwen3.8-max:thinking", @@ -61126,24 +61175,30 @@ }, "TEE/deepseek-v4-flash": { "id": "TEE/deepseek-v4-flash", - "name": "TEE/deepseek-v4-flash", + "name": "DeepSeek V4 Flash TEE", "api": "openai-completions", "provider": "nanogpt", "baseUrl": "https://nano-gpt.com/api/v1", - "reasoning": false, + "reasoning": true, "input": [ "text" ], "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, + "input": 0.2, + "output": 0.4, + "cacheRead": 0.04, "cacheWrite": 0 }, "contextWindow": 1048576, - "maxTokens": 384000, - "supportsComputerUse": false, - "supportsComputerUseConfig": false + "maxTokens": 1048576, + "thinking": { + "mode": "effort", + "efforts": [ + "low", + "high", + "max" + ] + } }, "TEE/deepseek-v4-pro": { "id": "TEE/deepseek-v4-pro", @@ -61768,6 +61823,37 @@ "high" ], "requiresEffort": true + }, + "supportsComputerUse": false + }, + "TEE/muse-glimmer-30b": { + "id": "TEE/muse-glimmer-30b", + "name": "Muse Glimmer 30B TEE", + "api": "openai-completions", + "provider": "nanogpt", + "baseUrl": "https://nano-gpt.com/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.35, + "output": 1.5, + "cacheRead": 0.04, + "cacheWrite": 0 + }, + "contextWindow": 131072, + "maxTokens": 131072, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] } }, "TEE/qwen2.5-vl-72b-instruct": { @@ -65420,28 +65506,6 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, - "inclusionai/ling-3.0-tiny": { - "id": "inclusionai/ling-3.0-tiny", - "name": "Ling 3.0 Tiny", - "api": "openai-completions", - "provider": "novita", - "baseUrl": "https://api.novita.ai/openai/v1", - "reasoning": false, - "input": [ - "text" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 262144, - "maxTokens": 32768, - "supportsTools": true, - "supportsComputerUse": false, - "supportsComputerUseConfig": false - }, "inclusionai/ring-2.6-1t": { "id": "inclusionai/ring-2.6-1t", "name": "Ring-2.6-1T", @@ -66535,7 +66599,7 @@ }, "qwen/qwen3-next-80b-a3b-instruct": { "id": "qwen/qwen3-next-80b-a3b-instruct", - "name": "Qwen3-Next-80B-A3B-Instruct", + "name": "Qwen3-Next 80B-A3B Instruct", "api": "openai-completions", "provider": "novita", "baseUrl": "https://api.novita.ai/openai/v1", @@ -79997,7 +80061,7 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 163840, + "contextWindow": 64000, "maxTokens": 16000, "thinking": { "mode": "effort", @@ -81155,6 +81219,68 @@ "supportsComputerUse": false, "supportsComputerUseConfig": false }, + "google/gemini-3.7-flash": { + "id": "google/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "api": "openrouter", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.375, + "output": 1.875, + "cacheRead": 0.0375, + "cacheWrite": 0.0416666666666667 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "requiresEffort": true + }, + "supportsComputerUse": false + }, + "google/gemini-3.7-flash:batch": { + "id": "google/gemini-3.7-flash:batch", + "name": "Gemini 3.7 Flash (batch)", + "api": "openrouter", + "provider": "openrouter", + "baseUrl": "https://openrouter.ai/api/v1", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.375, + "output": 1.875, + "cacheRead": 0.0375, + "cacheWrite": 0.0416666666666667 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "requiresEffort": true + }, + "supportsComputerUse": false + }, "google/gemma-3-12b-it": { "id": "google/gemma-3-12b-it", "name": "Gemma 3 12B IT", @@ -83089,9 +83215,9 @@ "image" ], "cost": { - "input": 0.95, - "output": 4, - "cacheRead": 0.16, + "input": 0.5795, + "output": 2.44, + "cacheRead": 0.0976, "cacheWrite": 0 }, "contextWindow": 262144, @@ -83719,7 +83845,7 @@ "cacheRead": 0.049999999999999996, "cacheWrite": 0 }, - "contextWindow": 1000000, + "contextWindow": 1048576, "maxTokens": 262144, "thinking": { "mode": "effort", @@ -83730,7 +83856,8 @@ "high" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "nvidia/nemotron-3.5-lightning:free": { "id": "nvidia/nemotron-3.5-lightning:free", @@ -87908,7 +88035,7 @@ }, "qwen/qwen3-next-80b-a3b-instruct": { "id": "qwen/qwen3-next-80b-a3b-instruct", - "name": "Qwen3-Next-80B-A3B-Instruct", + "name": "Qwen3-Next 80B-A3B Instruct", "api": "openrouter", "baseUrl": "https://openrouter.ai/api/v1", "provider": "openrouter", @@ -87917,13 +88044,13 @@ "text" ], "cost": { - "input": 0.09, + "input": 0.09999999999999999, "output": 1.1, "cacheRead": 0.07, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 16384, + "maxTokens": 262144, "supportsComputerUse": false, "supportsComputerUseConfig": false }, @@ -87965,7 +88092,7 @@ "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 262144, + "maxTokens": 32768, "thinking": { "mode": "effort", "efforts": [ @@ -88268,13 +88395,13 @@ "image" ], "cost": { - "input": 0.44999999999999996, - "output": 3, - "cacheRead": 0.045, + "input": 0.5, + "output": 3.5999999999999996, + "cacheRead": 0.3, "cacheWrite": 0 }, "contextWindow": 262144, - "maxTokens": 65536, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -88731,11 +88858,11 @@ "cost": { "input": 2, "output": 6, - "cacheRead": 0.19999999999999998, + "cacheRead": 0.25, "cacheWrite": 0 }, "contextWindow": 1000000, - "maxTokens": 52429, + "maxTokens": 262144, "thinking": { "mode": "effort", "efforts": [ @@ -90393,9 +90520,9 @@ "text" ], "cost": { - "input": 1.4, - "output": 4.4, - "cacheRead": 0.26, + "input": 0.952, + "output": 2.992, + "cacheRead": 0.17679999999999998, "cacheWrite": 0 }, "contextWindow": 204800, @@ -90423,9 +90550,9 @@ "text" ], "cost": { - "input": 0.5, - "output": 3.15, - "cacheRead": 0.09999999999999999, + "input": 0.49, + "output": 1.54, + "cacheRead": 0.091, "cacheWrite": 0 }, "contextWindow": 1048576, @@ -90659,69 +90786,9 @@ } }, "synthetic": { - "hf:MiniMaxAI/MiniMax-M3": { - "id": "hf:MiniMaxAI/MiniMax-M3", - "name": "MiniMax-M3", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0.6, - "output": 1.2, - "cacheRead": 0.6, - "cacheWrite": 0 - }, - "contextWindow": 524288, - "maxTokens": 65536, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, - "hf:moonshotai/Kimi-K2.7-Code": { - "id": "hf:moonshotai/Kimi-K2.7-Code", - "name": "Kimi K2.7 Code", - "api": "openai-completions", - "provider": "synthetic", - "baseUrl": "https://api.synthetic.new/openai/v1", - "reasoning": true, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0.95, - "output": 4, - "cacheRead": 0.95, - "cacheWrite": 0 - }, - "contextWindow": 262144, - "maxTokens": 65536, - "thinking": { - "mode": "effort", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - } - }, "hf:moonshotai/Kimi-K3": { "id": "hf:moonshotai/Kimi-K3", - "name": "Kimi K3", + "name": "moonshotai/Kimi-K3", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -90750,11 +90817,12 @@ "max": "max" }, "requiresEffort": true - } + }, + "supportsComputerUse": false }, "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4": { "id": "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", - "name": "Nemotron 3 Super 120B A12B", + "name": "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -90765,7 +90833,7 @@ "cost": { "input": 0.3, "output": 1, - "cacheRead": 0.3, + "cacheRead": 0.06, "cacheWrite": 0 }, "contextWindow": 262144, @@ -90779,11 +90847,12 @@ "high", "xhigh" ] - } + }, + "supportsComputerUse": false }, "hf:openai/gpt-oss-120b": { "id": "hf:openai/gpt-oss-120b", - "name": "GPT OSS 120B", + "name": "openai/gpt-oss-120b", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -90794,11 +90863,11 @@ "cost": { "input": 0.1, "output": 0.1, - "cacheRead": 0.1, + "cacheRead": 0.02, "cacheWrite": 0 }, "contextWindow": 131072, - "maxTokens": 32768, + "maxTokens": 65536, "thinking": { "mode": "effort", "efforts": [ @@ -90806,11 +90875,12 @@ "medium", "high" ] - } + }, + "supportsComputerUse": false }, "hf:Qwen/Qwen3.6-27B": { "id": "hf:Qwen/Qwen3.6-27B", - "name": "Qwen3.6 27B", + "name": "Qwen/Qwen3.6-27B", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -90821,8 +90891,8 @@ ], "cost": { "input": 0.45, - "output": 3.6, - "cacheRead": 0.45, + "output": 2.2, + "cacheRead": 0.09, "cacheWrite": 0 }, "contextWindow": 262144, @@ -90835,11 +90905,12 @@ "medium", "high" ] - } + }, + "supportsComputerUse": false }, "hf:zai-org/GLM-4.7-Flash": { "id": "hf:zai-org/GLM-4.7-Flash", - "name": "GLM-4.7-Flash", + "name": "zai-org/GLM-4.7-Flash", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -90850,7 +90921,7 @@ "cost": { "input": 0.1, "output": 0.5, - "cacheRead": 0.1, + "cacheRead": 0.02, "cacheWrite": 0 }, "contextWindow": 196608, @@ -90864,11 +90935,12 @@ "high", "xhigh" ] - } + }, + "supportsComputerUse": false }, "hf:zai-org/GLM-5.2": { "id": "hf:zai-org/GLM-5.2", - "name": "GLM-5.2", + "name": "zai-org/GLM-5.2", "api": "openai-completions", "provider": "synthetic", "baseUrl": "https://api.synthetic.new/openai/v1", @@ -90877,9 +90949,9 @@ "text" ], "cost": { - "input": 1.4, - "output": 4.4, - "cacheRead": 1.4, + "input": 1, + "output": 3, + "cacheRead": 0.16, "cacheWrite": 0 }, "contextWindow": 524288, @@ -90893,7 +90965,8 @@ "high", "max" ] - } + }, + "supportsComputerUse": false }, "syn:large:text": { "id": "syn:large:text", @@ -91984,41 +92057,6 @@ "escapeBuiltinToolNames": true } }, - "umans-deepseek-v4-flash-0731-vision-lab": { - "id": "umans-deepseek-v4-flash-0731-vision-lab", - "name": "Umans DeepSeek V4 Flash Vision (lab)", - "api": "anthropic-messages", - "provider": "umans", - "baseUrl": "https://api.code.umans.ai", - "reasoning": true, - "thinking": { - "mode": "budget", - "efforts": [ - "minimal", - "low", - "medium", - "high", - "xhigh" - ] - }, - "input": [ - "text", - "image" - ], - "cost": { - "input": 0, - "output": 0, - "cacheRead": 0, - "cacheWrite": 0 - }, - "contextWindow": 1048576, - "maxTokens": 393215, - "supportsComputerUse": false, - "supportsComputerUseConfig": false, - "compat": { - "escapeBuiltinToolNames": true - } - }, "umans-flash": { "id": "umans-flash", "name": "Umans Flash", @@ -95272,9 +95310,10 @@ "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": null, + "contextWindow": 262144, "maxTokens": null, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "supportsUsageInStreaming": false } @@ -98335,6 +98374,37 @@ }, "supportsComputerUse": false }, + "google/gemini-3.7-flash": { + "id": "google/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "api": "anthropic-messages", + "provider": "vercel-ai-gateway", + "baseUrl": "https://ai-gateway.vercel.sh", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.75, + "output": 3.75, + "cacheRead": 0.075, + "cacheWrite": 0 + }, + "contextWindow": 1000000, + "maxTokens": 65536, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "requiresEffort": true + }, + "supportsComputerUse": false + }, "google/gemma-4-26b-a4b-it": { "id": "google/gemma-4-26b-a4b-it", "name": "Gemma 4 26B A4B IT", @@ -104165,6 +104235,7 @@ }, "contextWindow": 2000000, "maxTokens": 2000000, + "supportsComputerUse": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104195,6 +104266,7 @@ }, "contextWindow": 2000000, "maxTokens": 2000000, + "supportsComputerUse": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104224,16 +104296,6 @@ }, "contextWindow": 2000000, "maxTokens": 2000000, - "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, - "supportsImageDetailOriginal": false, - "omitReasoningEffort": false, - "supportsReasoningEffort": true - }, "thinking": { "mode": "effort", "efforts": [ @@ -104246,6 +104308,17 @@ "effortMap": { "minimal": "low" } + }, + "supportsComputerUse": false, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "includeEncryptedReasoning": false, + "filterReasoningHistory": true, + "supportsImageDetailOriginal": false, + "omitReasoningEffort": false, + "supportsReasoningEffort": true } }, "grok-4.3": { @@ -104267,16 +104340,6 @@ }, "contextWindow": 1000000, "maxTokens": 1000000, - "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, - "supportsImageDetailOriginal": false, - "omitReasoningEffort": false, - "supportsReasoningEffort": true - }, "thinking": { "mode": "effort", "efforts": [ @@ -104289,6 +104352,17 @@ "effortMap": { "minimal": "low" } + }, + "supportsComputerUse": false, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "includeEncryptedReasoning": false, + "filterReasoningHistory": true, + "supportsImageDetailOriginal": false, + "omitReasoningEffort": false, + "supportsReasoningEffort": true } }, "grok-4.5": { @@ -104310,16 +104384,6 @@ }, "contextWindow": 500000, "maxTokens": 500000, - "compat": { - "reasoningEffortMap": { - "minimal": "low" - }, - "includeEncryptedReasoning": false, - "filterReasoningHistory": true, - "supportsImageDetailOriginal": false, - "omitReasoningEffort": false, - "supportsReasoningEffort": true - }, "thinking": { "mode": "effort", "efforts": [ @@ -104332,6 +104396,17 @@ "effortMap": { "minimal": "low" } + }, + "supportsComputerUse": false, + "compat": { + "reasoningEffortMap": { + "minimal": "low" + }, + "includeEncryptedReasoning": false, + "filterReasoningHistory": true, + "supportsImageDetailOriginal": false, + "omitReasoningEffort": false, + "supportsReasoningEffort": true } }, "grok-4.6": { @@ -104354,6 +104429,7 @@ "contextWindow": 500000, "maxTokens": 500000, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "includeEncryptedReasoning": false, "filterReasoningHistory": true, @@ -104380,6 +104456,7 @@ }, "contextWindow": 512000, "maxTokens": 512000, + "supportsComputerUse": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104410,6 +104487,7 @@ }, "contextWindow": 256000, "maxTokens": 256000, + "supportsComputerUse": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104439,6 +104517,7 @@ }, "contextWindow": 200000, "maxTokens": 200000, + "supportsComputerUse": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -106203,10 +106282,10 @@ "image" ], "cost": { - "input": 0.8848, - "output": 4.424, - "cacheRead": 0.177, - "cacheWrite": 0.0025 + "input": 0.70784, + "output": 3.5392, + "cacheRead": 0.1416, + "cacheWrite": 0.002 }, "contextWindow": 256000, "maxTokens": null, @@ -107194,11 +107273,11 @@ }, "inclusionai/ling-3.0-tiny": { "id": "inclusionai/ling-3.0-tiny", - "name": "Ling 3.0 Tiny", + "name": "Ling-3.0-tiny", "api": "openai-completions", "provider": "zenmux", "baseUrl": "https://zenmux.ai/api/v1", - "reasoning": false, + "reasoning": true, "input": [ "text" ], @@ -107210,7 +107289,17 @@ }, "contextWindow": 262144, "maxTokens": 32768, - "supportsComputerUse": false + "supportsComputerUse": false, + "thinking": { + "mode": "effort", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } }, "inclusionai/ling-flash-2.0": { "id": "inclusionai/ling-flash-2.0", @@ -109670,10 +109759,10 @@ "image" ], "cost": { - "input": 2, - "output": 6, - "cacheRead": 0.17, - "cacheWrite": 2.5 + "input": 1.4, + "output": 4.2, + "cacheRead": 0.119, + "cacheWrite": 1.75 }, "contextWindow": 1000000, "maxTokens": 131072, diff --git a/packages/catalog/test/bundled-reference-laziness.test.ts b/packages/catalog/test/bundled-reference-laziness.test.ts index ce727ec1b..fc03fcea6 100644 --- a/packages/catalog/test/bundled-reference-laziness.test.ts +++ b/packages/catalog/test/bundled-reference-laziness.test.ts @@ -1,33 +1,25 @@ import { describe, expect, test } from "bun:test"; -import { TempDir } from "@oh-my-pi/pi-utils"; import { createReferenceResolver } from "../src/provider-models/bundled-references"; import type { ModelSpec } from "../src/types"; const FIXTURE = `${import.meta.dir}/fixtures/bundled-reference-laziness.ts`; -async function runFixture(fixture: string): Promise { - const tempDir = TempDir.createSync("@pi-catalog-bundled-reference-laziness-"); - const resultPath = tempDir.join("result.json"); - try { - const result = Bun.spawnSync({ - cmd: [process.execPath, fixture], - env: { ...process.env, OMP_CATALOG_LAZINESS_RESULT_PATH: resultPath }, - stdout: "pipe", - stderr: "pipe", - }); - expect(result.exitCode, result.stderr.toString()).toBe(0); - return await Bun.file(resultPath).text(); - } finally { - tempDir.removeSync(); - } +function runFixture(fixture: string): string { + const result = Bun.spawnSync({ + cmd: [process.execPath, fixture], + env: process.env, + stdout: "pipe", + stderr: "pipe", + }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + return result.stdout.toString(); } -describe("bundled reference laziness", () => { - test("constructing bundled model-manager options retains less than 8 MiB of RSS", async () => { - const { retainedRssBytes } = JSON.parse(await runFixture(FIXTURE)) as { retainedRssBytes: number }; +describe("bundled model laziness", () => { + test("provider options and the bundled registry stay lazy", () => { + const { retainedRssBytes } = JSON.parse(runFixture(FIXTURE)) as { retainedRssBytes: number }; expect(retainedRssBytes).toBeLessThan(8 * 1024 * 1024); }, 60_000); - test("a lazy provider-reference factory initializes on first resolution and only once", () => { const reference = { id: "fixture-model", diff --git a/packages/catalog/test/fixtures/bundled-reference-laziness.ts b/packages/catalog/test/fixtures/bundled-reference-laziness.ts index f4a8639b5..ddb2be949 100644 --- a/packages/catalog/test/fixtures/bundled-reference-laziness.ts +++ b/packages/catalog/test/fixtures/bundled-reference-laziness.ts @@ -1,3 +1,6 @@ +import { expect, spyOn } from "bun:test"; +import * as buildModule from "../../src/build"; +import type { GeneratedProvider } from "../../src/models"; import { ollamaCloudModelManagerOptions } from "../../src/provider-models/ollama"; import { nanoGptModelManagerOptions } from "../../src/provider-models/openai-compat"; @@ -8,10 +11,48 @@ ollamaCloudModelManagerOptions(); Bun.gc(true); const retainedRssBytes = process.memoryUsage().rss - rssBefore; -const result = JSON.stringify({ retainedRssBytes }); -const resultPath = process.env.OMP_CATALOG_LAZINESS_RESULT_PATH; -if (resultPath) { - await Bun.write(resultPath, result); -} else { - process.stdout.write(result); +process.stdout.write(JSON.stringify({ retainedRssBytes })); + +// Keep the model-registry import below the RSS assertion setup: importing it +// eagerly loads models.json and would invalidate the provider-option laziness +// measurement above. This same isolated process can then verify the registry's +// own lazy, per-provider enrichment without paying for a second Bun startup. +const { getBundledModel, getBundledModels, getBundledProviders } = await import("../../src/models"); +const { default: MODELS } = await import("../../src/models.json", { with: { type: "json" } }); +const buildSpy = spyOn(buildModule, "buildModel"); +const rawProviders = Object.keys(MODELS); +const firstProviders = getBundledProviders(); +const secondProviders = getBundledProviders(); + +expect(buildSpy).toHaveBeenCalledTimes(0); +expect(firstProviders as string[]).toEqual(rawProviders); +expect(secondProviders as string[]).toEqual(rawProviders); +expect(secondProviders).not.toBe(firstProviders); + +const provider = "sakana" satisfies GeneratedProvider; +const rawModelIds = Object.keys(MODELS[provider]); +const firstModels = getBundledModels(provider); + +expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length); +expect(firstModels.map(model => model.id)).toEqual(rawModelIds); + +const secondModels = getBundledModels(provider); +expect(secondModels).not.toBe(firstModels); +expect(secondModels).toHaveLength(firstModels.length); +for (let index = 0; index < firstModels.length; index++) { + expect(secondModels[index]).toBe(firstModels[index]); } +expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length); + +const firstModelId = rawModelIds[0]; +if (firstModelId === undefined) throw new Error(`${provider} must have a bundled model`); +expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]); +expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]); +expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length); + +const unknownProvider = "not-a-bundled-provider" as GeneratedProvider; +expect(getBundledModels(unknownProvider)).toEqual([]); +expect(getBundledModel(unknownProvider, "missing-model")).toBeUndefined(); +expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length); + +buildSpy.mockRestore(); diff --git a/packages/catalog/test/fixtures/models-lazy-provider-cache.ts b/packages/catalog/test/fixtures/models-lazy-provider-cache.ts deleted file mode 100644 index f61b76ec4..000000000 --- a/packages/catalog/test/fixtures/models-lazy-provider-cache.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { expect, spyOn } from "bun:test"; -import * as buildModule from "../../src/build"; -import { type GeneratedProvider, getBundledModel, getBundledModels, getBundledProviders } from "../../src/models"; -import MODELS from "../../src/models.json" with { type: "json" }; - -const buildSpy = spyOn(buildModule, "buildModel"); -const rawProviders = Object.keys(MODELS); -const firstProviders = getBundledProviders(); -const secondProviders = getBundledProviders(); - -expect(buildSpy).toHaveBeenCalledTimes(0); -expect(firstProviders as string[]).toEqual(rawProviders); -expect(secondProviders as string[]).toEqual(rawProviders); -expect(secondProviders).not.toBe(firstProviders); - -const provider = "sakana" satisfies GeneratedProvider; -const rawModelIds = Object.keys(MODELS[provider]); -const firstModels = getBundledModels(provider); - -expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length); -expect(firstModels.map(model => model.id)).toEqual(rawModelIds); - -const secondModels = getBundledModels(provider); -expect(secondModels).not.toBe(firstModels); -expect(secondModels).toHaveLength(firstModels.length); -for (let index = 0; index < firstModels.length; index++) { - expect(secondModels[index]).toBe(firstModels[index]); -} -expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length); - -const firstModelId = rawModelIds[0]; -if (firstModelId === undefined) throw new Error(`${provider} must have a bundled model`); -expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]); -expect(getBundledModel(provider, firstModelId)).toBe(firstModels[0]); -expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length); - -const unknownProvider = "not-a-bundled-provider" as GeneratedProvider; -expect(getBundledModels(unknownProvider)).toEqual([]); -expect(getBundledModel(unknownProvider, "missing-model")).toBeUndefined(); -expect(buildSpy).toHaveBeenCalledTimes(rawModelIds.length); - -buildSpy.mockRestore(); diff --git a/packages/catalog/test/models-lazy-provider-cache.test.ts b/packages/catalog/test/models-lazy-provider-cache.test.ts deleted file mode 100644 index f09c178b3..000000000 --- a/packages/catalog/test/models-lazy-provider-cache.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from "bun:test"; - -const FIXTURE = `${import.meta.dir}/fixtures/models-lazy-provider-cache.ts`; - -test("bundled models are enriched one provider at a time", () => { - const result = Bun.spawnSync({ - cmd: [process.execPath, FIXTURE], - env: process.env, - }); - expect(result.exitCode, result.stderr.toString()).toBe(0); -}, 60_000); diff --git a/packages/coding-agent/src/async/job-manager.ts b/packages/coding-agent/src/async/job-manager.ts index 4926704f2..21723720c 100644 --- a/packages/coding-agent/src/async/job-manager.ts +++ b/packages/coding-agent/src/async/job-manager.ts @@ -152,6 +152,7 @@ export class AsyncJobManager { readonly #maxRunningJobs: number; readonly #retentionMs: number; #deliveryLoop: Promise | undefined; + #deliveryQueueChanged = Promise.withResolvers(); #disposed = false; #filterJobs(jobs: Iterable, filter?: AsyncJobFilter): AsyncJob[] { @@ -335,6 +336,7 @@ export class AsyncJobManager { for (const jobId of uniqueJobIds) { this.#watchedJobs.add(jobId); } + this.#notifyDeliveryQueueChanged(); return uniqueJobIds.length; } @@ -389,6 +391,7 @@ export class AsyncJobManager { this.#deliveries.length, ...this.#deliveries.filter(delivery => !this.isDeliverySuppressed(delivery.jobId)), ); + this.#notifyDeliveryQueueChanged(); return before - this.#deliveries.length; } @@ -603,6 +606,7 @@ export class AsyncJobManager { this.#clearEvictionTimers(); this.#jobs.clear(); this.#deliveries.length = 0; + this.#notifyDeliveryQueueChanged(); this.#inFlightDeliveries.length = 0; this.#suppressedDeliveries.clear(); this.#watchedJobs.clear(); @@ -704,13 +708,14 @@ export class AsyncJobManager { const now = Date.now(); if (selected.nextAttemptAt > now) { if (selected.nextAttemptAt > deadline) return false; - await Bun.sleep(selected.nextAttemptAt - now); + await this.#waitForDeliveryQueueChange(selected.nextAttemptAt - now); continue; } const index = this.#deliveries.indexOf(selected); if (index === -1) continue; this.#deliveries.splice(index, 1); + this.#notifyDeliveryQueueChanged(); if (this.isDeliverySuppressed(selected.jobId)) continue; return this.#waitForDeliveryPromise(this.#deliverDelivery(selected), deadline); @@ -726,7 +731,7 @@ export class AsyncJobManager { if (this.isDeliverySuppressed(jobId)) { return; } - this.#deliveries.push({ + this.#queueDelivery({ jobId, text, attempt: 0, @@ -762,7 +767,8 @@ export class AsyncJobManager { } const waitMs = delivery.nextAttemptAt - Date.now(); if (waitMs > 0) { - await Bun.sleep(waitMs); + await this.#waitForDeliveryQueueChange(waitMs); + continue; } if (this.#deliveries[0] !== delivery) { continue; @@ -813,7 +819,7 @@ export class AsyncJobManager { delivery.lastError = error instanceof Error ? error.message : String(error); delivery.nextAttemptAt = Date.now() + this.#getRetryDelay(delivery.attempt); if (!this.isDeliverySuppressed(delivery.jobId)) { - this.#deliveries.push(delivery); + this.#queueDelivery(delivery); } logger.warn("Async job completion delivery failed", { jobId: delivery.jobId, @@ -831,6 +837,29 @@ export class AsyncJobManager { return promise; } + #queueDelivery(delivery: AsyncJobDelivery): void { + const index = this.#deliveries.findIndex(candidate => candidate.nextAttemptAt > delivery.nextAttemptAt); + if (index === -1) this.#deliveries.push(delivery); + else this.#deliveries.splice(index, 0, delivery); + this.#notifyDeliveryQueueChanged(); + } + + async #waitForDeliveryQueueChange(delayMs: number): Promise { + const timerElapsed = Promise.withResolvers(); + const timer = setTimeout(timerElapsed.resolve, delayMs); + timer.unref(); + try { + await Promise.race([timerElapsed.promise, this.#deliveryQueueChanged.promise]); + } finally { + clearTimeout(timer); + } + } + + #notifyDeliveryQueueChanged(): void { + this.#deliveryQueueChanged.resolve(); + this.#deliveryQueueChanged = Promise.withResolvers(); + } + async #waitForDeliveryPromise(promise: Promise | undefined, deadline: number): Promise { if (!promise) return true; if (deadline === Number.POSITIVE_INFINITY) { diff --git a/packages/coding-agent/src/cli/gc-cli.ts b/packages/coding-agent/src/cli/gc-cli.ts index 382a85a95..1486dec72 100644 --- a/packages/coding-agent/src/cli/gc-cli.ts +++ b/packages/coding-agent/src/cli/gc-cli.ts @@ -151,11 +151,21 @@ function numberSetting(value: number | undefined, fallback: unknown, defaultValu async function resolveOptions(flags: GcCommandFlags): Promise { const agentDir = path.resolve(flags.agentDir ?? getAgentDir()); const selected = flags.blobs === true || flags.archive === true || flags.wal === true; + const archiveSelected = selected && flags.archive === true; + const needsArchiveSettings = + archiveSelected && + (flags.coldArchiveAfterDays === undefined || + flags.retainNewestGlobal === undefined || + flags.retainNewestPerCwd === undefined); const settings = - flags.apply === true ? await Settings.loadIsolated({ agentDir }) : await Settings.loadReadOnly({ agentDir }); - const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings.get(pathKey); + !selected || needsArchiveSettings + ? flags.apply === true + ? await Settings.loadIsolated({ agentDir }) + : await Settings.loadReadOnly({ agentDir }) + : undefined; + const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings?.get(pathKey) ?? getDefault(pathKey); const getNumber = (pathKey: "gc.coldArchiveAfterDays" | "gc.retainNewestGlobal" | "gc.retainNewestPerCwd") => - settings.get(pathKey); + settings?.get(pathKey) ?? getDefault(pathKey); return { apply: flags.apply === true, json: flags.json === true, diff --git a/packages/coding-agent/src/cli/setup-cli.ts b/packages/coding-agent/src/cli/setup-cli.ts index 7926f541d..cdf98c394 100644 --- a/packages/coding-agent/src/cli/setup-cli.ts +++ b/packages/coding-agent/src/cli/setup-cli.ts @@ -66,7 +66,7 @@ export function parseSetupArgs(args: string[]): SetupCommandArgs | undefined { }; } -interface PythonCheckResult { +export interface PythonCheckResult { available: boolean; pythonPath?: string; usingManagedEnv?: boolean; @@ -82,7 +82,7 @@ function managedPythonPath(): string { /** * Check Python environment and kernel dependencies. */ -async function checkPythonSetup(cwd: string, interpreter?: string): Promise { +export async function checkPythonSetup(cwd: string, interpreter?: string): Promise { const availability = await checkPythonKernelAvailability(cwd, interpreter, { forceProbe: true }); return { available: availability.ok, diff --git a/packages/coding-agent/src/commands/completions.ts b/packages/coding-agent/src/commands/completions.ts index 260979f18..e7182b7ae 100644 --- a/packages/coding-agent/src/commands/completions.ts +++ b/packages/coding-agent/src/commands/completions.ts @@ -15,6 +15,21 @@ import { commands } from "../cli-commands"; const ROOT_COMMAND = "launch"; const SHELLS = ["bash", "zsh", "fish"] as const; +/** Generate a completion script from the live command registry. */ +export async function generateLiveCompletion(shell: Shell): Promise { + const loaded = await Promise.all(commands.map(async entry => ({ entry, Cmd: await entry.load() }))); + const map = new Map(); + const aliasMap = new Map(); + for (const { entry, Cmd } of loaded) { + map.set(entry.name, Cmd); + const merged = new Set([...(Cmd.aliases ?? []), ...(entry.aliases ?? [])]); + aliasMap.set(entry.name, [...merged]); + } + + const config: CliConfig = { bin: APP_NAME, version: VERSION, commands: map }; + return generateCompletion(shell, buildSpec(config, ROOT_COMMAND, aliasMap)); +} + export default class Completions extends Command { static description = commandHelp.description; static args = { @@ -39,20 +54,7 @@ export default class Completions extends Command { return; } - // Load every command class so we can read its static flag/arg descriptors, - // and collect aliases from both the registration table and the class. - const loaded = await Promise.all(commands.map(async entry => ({ entry, Cmd: await entry.load() }))); - const map = new Map(); - const aliasMap = new Map(); - for (const { entry, Cmd } of loaded) { - map.set(entry.name, Cmd); - const merged = new Set([...(Cmd.aliases ?? []), ...(entry.aliases ?? [])]); - aliasMap.set(entry.name, [...merged]); - } - - const config: CliConfig = { bin: APP_NAME, version: VERSION, commands: map }; - const spec = buildSpec(config, ROOT_COMMAND, aliasMap); - await Bun.write(Bun.stdout, generateCompletion(shell, spec)); + await Bun.write(Bun.stdout, await generateLiveCompletion(shell)); } } diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 51b8f4d63..f2dd6f1b5 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -32,7 +32,7 @@ import { resolveOllamaModelCacheProviderId, } from "@oh-my-pi/pi-catalog/provider-models"; import { collapseBuiltModelVariants } from "@oh-my-pi/pi-catalog/variant-collapse"; -import { isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils"; +import { getAgentDir, isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils"; import { resolveProviderModelReference } from "../config/model-resolver"; import { generateCodexAttestation } from "../live/attestation"; import type { AuthStorage } from "../session/auth-storage"; @@ -246,7 +246,7 @@ export class ModelRegistry { (isBunTestRuntime() ? () => Promise.reject(new Error("network disabled in model-registry runtime test")) : wrapFetchForExtraCa(fetch)); - this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath); + this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath ?? path.join(getAgentDir(), "models.yml")); this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined; // Set up fallback resolver for custom provider API keys this.authStorage.setFallbackResolver(provider => { diff --git a/packages/coding-agent/src/discovery/agents.ts b/packages/coding-agent/src/discovery/agents.ts index 9c1f615c2..07a39f514 100644 --- a/packages/coding-agent/src/discovery/agents.ts +++ b/packages/coding-agent/src/discovery/agents.ts @@ -67,18 +67,18 @@ const HOST_PROBE_TIMEOUT_MS = 500; /** * Run a best-effort discovery probe and return its trimmed stdout, or - * `undefined` when the command fails, produces no output, or exceeds - * {@link HOST_PROBE_TIMEOUT_MS}. On timeout the child is killed with SIGKILL so - * a wedged interop pipe cannot hang startup; the killed/non-zero exit is then - * reported as "unavailable" and discovery falls back to the Linux - * `$HOME`/`~/.omp` candidates. + * `undefined` when the command fails, produces no output, or exceeds the + * timeout. On timeout the child is killed with SIGKILL so a wedged interop pipe + * cannot hang startup; the killed/non-zero exit is then reported as + * "unavailable" and discovery falls back to the Linux `$HOME`/`~/.omp` + * candidates. */ -export function runHostProbe(cmd: string[]): string | undefined { +export function runHostProbe(cmd: string[], timeoutMs = HOST_PROBE_TIMEOUT_MS): string | undefined { try { const result = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore", - timeout: HOST_PROBE_TIMEOUT_MS, + timeout: timeoutMs, killSignal: "SIGKILL", }); if (result.exitCode !== 0) return undefined; diff --git a/packages/coding-agent/src/launch/broker.ts b/packages/coding-agent/src/launch/broker.ts index bf58ddaef..51d700fda 100644 --- a/packages/coding-agent/src/launch/broker.ts +++ b/packages/coding-agent/src/launch/broker.ts @@ -37,6 +37,7 @@ const MAX_LOG_BYTES = 25 * 1024 * 1024; const LOG_READ_BYTES = 2 * 1024 * 1024; const READINESS_BUFFER_CHARS = 64 * 1024; const RESTART_MAX_DELAY_MS = 30_000; +const RESTART_BACKOFF_BASE_MS = 1_000; /** * Cap on terminal (exited/failed) daemons surfaced by `list`. Active daemons * are always shown in full; older history is truncated so the response stays @@ -351,6 +352,7 @@ class DaemonBroker { readonly #endpoint: string; readonly #token: string; readonly #idleGraceMs: number; + readonly #restartBackoffBaseMs: number; readonly #records = new Map(); /** * Names reserved by an in-flight `start` before its record lands in @@ -371,12 +373,19 @@ class DaemonBroker { #idleTimer: NodeJS.Timeout | undefined; #shuttingDown = false; - constructor(projectDir: string, runtimeDir: string, token: string, idleGraceMs: number) { + constructor( + projectDir: string, + runtimeDir: string, + token: string, + idleGraceMs: number, + restartBackoffBaseMs: number, + ) { this.#projectDir = projectDir; this.#runtimeDir = runtimeDir; this.#endpoint = daemonBrokerEndpoint(projectDir, runtimeDir); this.#token = token; this.#idleGraceMs = idleGraceMs; + this.#restartBackoffBaseMs = restartBackoffBaseMs; } async run(): Promise { @@ -955,7 +964,10 @@ class DaemonBroker { record.snapshot.readyAt = undefined; record.snapshot.readyMatch = undefined; record.snapshot.state = "restarting"; - const delay = Math.min(1_000 * 2 ** Math.min(record.consecutiveFailures, 5), RESTART_MAX_DELAY_MS); + const delay = Math.min( + this.#restartBackoffBaseMs * 2 ** Math.min(record.consecutiveFailures, 5), + RESTART_MAX_DELAY_MS, + ); record.log?.append( `\n[daemon exited${exitCode === undefined ? "" : ` with code ${exitCode}`}; restarting in ${delay}ms]\n`, ); @@ -1347,8 +1359,13 @@ class DaemonBroker { } } +export interface DaemonBrokerStartOptions { + /** Base of the exponential child-restart backoff. */ + restartBackoffBaseMs?: number; +} + /** Start the detached project or global daemon broker selected by the CLI worker host. */ -export async function startDaemonBrokerFromEnvironment(): Promise { +export async function startDaemonBrokerFromEnvironment(options: DaemonBrokerStartOptions = {}): Promise { const projectDir = process.env[DAEMON_PROJECT_DIR_ENV]; const runtimeDir = process.env[DAEMON_RUNTIME_DIR_ENV]; if (!projectDir || !runtimeDir) throw new Error("Daemon broker environment is incomplete"); @@ -1358,13 +1375,18 @@ export async function startDaemonBrokerFromEnvironment(): Promise { delete process.env[DAEMON_IDLE_GRACE_ENV]; const parsedGrace = rawGrace === undefined ? DEFAULT_IDLE_GRACE_MS : Number.parseInt(rawGrace, 10); const idleGraceMs = Number.isFinite(parsedGrace) && parsedGrace >= 0 ? parsedGrace : DEFAULT_IDLE_GRACE_MS; + const requestedRestartBackoffBaseMs = options.restartBackoffBaseMs ?? RESTART_BACKOFF_BASE_MS; + const restartBackoffBaseMs = + Number.isFinite(requestedRestartBackoffBaseMs) && requestedRestartBackoffBaseMs >= 0 + ? requestedRestartBackoffBaseMs + : RESTART_BACKOFF_BASE_MS; await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 }); const lease = await acquireBrokerLease(runtimeDir); if (!lease) return; setProcessName("omp daemon broker"); const token = (await Bun.file(path.join(runtimeDir, TOKEN_FILE)).text()).trim(); if (!token) throw new Error("Daemon broker token is empty"); - const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs); + const broker = new DaemonBroker(projectDir, runtimeDir, token, idleGraceMs, restartBackoffBaseMs); const cancelCleanup = postmortem.register("daemon-broker", () => broker.shutdown()); try { await broker.run(); diff --git a/packages/coding-agent/src/lsp/mux/server.ts b/packages/coding-agent/src/lsp/mux/server.ts index 3297e1c36..d47f800fc 100644 --- a/packages/coding-agent/src/lsp/mux/server.ts +++ b/packages/coding-agent/src/lsp/mux/server.ts @@ -687,7 +687,13 @@ export class LspMuxServer { server.pending.set(id, { resolveInternal: resolve }); try { await this.#writeServer(server, { jsonrpc: "2.0", id, method: "shutdown", params: null }); - await Promise.race([promise, Bun.sleep(SHUTDOWN_BUDGET_MS)]); + const timeout = Promise.withResolvers(); + const timer = setTimeout(timeout.resolve, SHUTDOWN_BUDGET_MS); + try { + await Promise.race([promise, timeout.promise]); + } finally { + clearTimeout(timer); + } await this.#writeServer(server, { jsonrpc: "2.0", method: "exit" }); } catch (error) { logger.warn("LSP mux graceful server shutdown failed", { server: server.key, error: String(error) }); diff --git a/packages/coding-agent/src/mcp/transports/stdio.ts b/packages/coding-agent/src/mcp/transports/stdio.ts index d540bcab8..a0099a768 100644 --- a/packages/coding-agent/src/mcp/transports/stdio.ts +++ b/packages/coding-agent/src/mcp/transports/stdio.ts @@ -495,7 +495,7 @@ function signalStdioProcess( /** * Terminate an MCP stdio subprocess: SIGTERM (process-group when `detached` - * on POSIX, direct child otherwise), wait up to `TERM_GRACE_MS` for a + * on POSIX, direct child otherwise), wait up to `termGraceMs` for a * cooperative exit, then escalate to SIGKILL — waiting up to `KILL_GRACE_MS` * more only when the leader itself hadn't already exited. A detached * leader's cooperative exit does not prove the whole process group is gone @@ -508,15 +508,19 @@ function signalStdioProcess( * `detached`/`platform` pair: `StdioTransport.connect()` derives `detached` * from `resolveStdioSpawnCommand()`, which is tied to the host's real * `process.platform`, so a POSIX detached session cannot be reproduced - * end-to-end through `connect()` on a non-Linux dev/CI host. + * end-to-end through `connect()` on a non-Linux dev/CI host. `termGraceMs` + * preserves the production grace by default while allowing those real + * subprocess tests to cover the same transition without sleeping for a + * production-length shutdown window. */ export async function terminateStdioProcess( proc: KillableSubprocess, detached: boolean, platform: NodeJS.Platform = process.platform, + termGraceMs = TERM_GRACE_MS, ): Promise { signalStdioProcess(proc, detached, "SIGTERM", platform); - const exitedOnTerm = await waitForProcessExit(proc.exited, TERM_GRACE_MS); + const exitedOnTerm = await waitForProcessExit(proc.exited, termGraceMs); // A non-detached transport has no process group beyond the leader itself: // once it exits, there is nothing left to signal. A detached transport's // leader exiting is NOT proof the group is empty — a grandchild it spawned diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index b76c061be..88c0d14dc 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -359,6 +359,20 @@ function readPersistedToolNames(value: unknown): string[] | undefined { return value as string[]; } +export function shouldEnterPlanModeOnStartup( + sessionManager: Pick, + sessionSettings: Pick, +): boolean { + const hasConversationContext = sessionManager.buildSessionContext().messages.length > 0; + const hasExplicitMode = sessionManager.getEntries().some(entry => entry.type === "mode_change"); + return ( + !hasConversationContext && + !hasExplicitMode && + sessionSettings.get("plan.defaultOnStartup") && + sessionSettings.get("plan.enabled") + ); +} + /** Options for creating an InteractiveMode instance (for future API use) */ export interface InteractiveModeOptions { /** Providers that were migrated during startup */ @@ -1140,14 +1154,7 @@ export class InteractiveMode implements InteractiveModeContext { // execution handoff clear never get dragged back into plan mode. #enterPlanMode // is idempotent and self-guards against an already-active plan/goal mode; it // does not check plan.enabled itself. - const hasConversationContext = this.sessionManager.buildSessionContext().messages.length > 0; - const hasExplicitMode = this.sessionManager.getEntries().some(entry => entry.type === "mode_change"); - const isFreshSession = !hasConversationContext && !hasExplicitMode; - if ( - isFreshSession && - this.session.settings.get("plan.defaultOnStartup") && - this.session.settings.get("plan.enabled") - ) { + if (shouldEnterPlanModeOnStartup(this.sessionManager, this.session.settings)) { await this.#enterPlanMode(); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index c4182bf5b..71af2c246 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -62,6 +62,8 @@ export interface RpcClientOptions { sessionDir?: string; /** Additional CLI arguments */ args?: string[]; + /** Grace period before escalating process termination (default: process utility default, 1000ms) */ + terminationGraceMs?: number; /** Custom tools owned by the embedding host and exposed over the RPC transport */ customTools?: RpcClientCustomTool[]; } @@ -324,7 +326,7 @@ export class RpcClient { this.#pendingHostToolCalls.clear(); try { - child.kill(); + child.kill(undefined, this.options.terminationGraceMs); } catch { // The process may already have exited. } @@ -440,7 +442,7 @@ export class RpcClient { const error = new Error("Client stopped"); const child = this.#process; - child.kill(); + child.kill(undefined, this.options.terminationGraceMs); this.#abortController.abort(error); this.#process = null; for (const request of this.#pendingRequests.values()) request.reject(error); diff --git a/packages/coding-agent/src/modes/rpc/rpc-input.ts b/packages/coding-agent/src/modes/rpc/rpc-input.ts index c99a92cd2..ea64e5456 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-input.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-input.ts @@ -1,3 +1,5 @@ +import { readLines } from "@oh-my-pi/pi-utils"; + /** * Claims Bun's singleton stdin reader immediately and exposes a separately readable stream. * RPC startup uses this before extension discovery so in-process modules cannot steal protocol input. @@ -36,3 +38,28 @@ export function claimRpcInput(): ReadableStream { }, }); } + +/** + * Parses newline-delimited RPC input without letting one malformed line stop + * subsequent protocol frames. + */ +export async function readRpcInputFrames( + input: ReadableStream, + onFrame: (frame: unknown) => void, + onParseError: (message: string) => void, +): Promise { + const decoder = new TextDecoder(); + for await (const line of readLines(input)) { + const text = decoder.decode(line).trim(); + if (!text) continue; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + onParseError(`Failed to parse command: ${message}`); + continue; + } + onFrame(parsed); + } +} diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 5ff4be444..27881315d 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -13,7 +13,7 @@ import { once } from "node:events"; import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth"; import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema"; -import { $env, isRecord, readLines, Snowflake } from "@oh-my-pi/pi-utils"; +import { $env, isRecord, Snowflake } from "@oh-my-pi/pi-utils"; import { reset as resetCapabilities } from "../../capability"; import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers"; import { @@ -37,7 +37,7 @@ import { initializeExtensions } from "../runtime-init"; import { isRpcHostToolResult, isRpcHostToolUpdate, RpcHostToolBridge } from "./host-tools"; import { isRpcHostUriResult, RpcHostUriBridge } from "./host-uris"; import { MAX_RPC_FRAME_BYTES, MAX_RPC_REASSEMBLED_BYTES, RpcFrameEncoder } from "./rpc-frame"; -import { claimRpcInput } from "./rpc-input"; +import { claimRpcInput, readRpcInputFrames } from "./rpc-input"; import { pageRpcMessages, RPC_MESSAGES_PAGE_BUSY_ERROR, RpcMessagesPageError } from "./rpc-messages"; import { RpcSubagentRegistry, readRpcSubagentTranscript } from "./rpc-subagents"; import type { @@ -1485,23 +1485,14 @@ export async function runRpcMode( // Keep the stdin reader moving: side-channel frames dispatch immediately, // ordinary commands serialize through inputDispatcher, and bash remains // background-dispatched so abort_bash can overtake it. Frames are read - // line-by-line and parsed here (not via readJsonl) so a single malformed - // line is reported as an error frame and the loop keeps running instead of - // throwing out of the generator and killing the whole process (issue #5194). - const decoder = new TextDecoder(); - for await (const line of readLines(input ?? Bun.stdin.stream())) { - const text = decoder.decode(line).trim(); - if (!text) continue; - let parsed: unknown; - try { - parsed = JSON.parse(text); - } catch (e: unknown) { - const message = e instanceof Error ? e.message : String(e); - output(error(undefined, "parse", `Failed to parse command: ${message}`)); - continue; - } - inputDispatcher.dispatch(parsed); - } + // line-by-line by readRpcInputFrames so a single malformed line is reported + // as an error frame and the loop keeps running instead of throwing out of + // the reader and killing the whole process (issue #5194). + await readRpcInputFrames( + input ?? Bun.stdin.stream(), + parsed => inputDispatcher.dispatch(parsed), + message => output(error(undefined, "parse", message)), + ); // stdin closed — RPC client is gone. Fail pending side-channel requests // first so active/queued commands can settle, then drain accepted work. diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index e44d53d20..1aa7ab80d 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -493,6 +493,8 @@ export interface ExecutorOptions { keepAlive?: boolean; /** Internal ownership handoff for cleanup that outlives the visible Task result. */ onCleanupDeferred?: (completion: Promise) => void; + /** Internal cleanup grace override for deterministic lifecycle tests. */ + cleanupGraceMs?: number; } function parseStringifiedJson(value: unknown): unknown { @@ -2657,6 +2659,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise, + timeoutMs = SNAPSHOT_TIMEOUT_MS, ): Promise { const cacheKey = shell; // Return cached snapshot if valid @@ -284,7 +288,7 @@ export async function getOrCreateSnapshot( stdin: "ignore", stdout: "ignore", stderr: "ignore", - timeout: SNAPSHOT_TIMEOUT_MS, + timeout: timeoutMs, killSignal: "SIGKILL", }); diff --git a/packages/coding-agent/test/acp-agent.test.ts b/packages/coding-agent/test/acp-agent.test.ts index f680b6b1b..85284d9e1 100644 --- a/packages/coding-agent/test/acp-agent.test.ts +++ b/packages/coding-agent/test/acp-agent.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { afterEach, describe, expect, it, spyOn, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -452,6 +452,7 @@ const originalAgentDir = process.env.PI_CODING_AGENT_DIR; const fallbackAgentDir = path.join(getConfigRootDir(), "agent"); afterEach(async () => { + vi.useRealTimers(); if (originalAgentDir) { setAgentDir(originalAgentDir); } else { @@ -522,13 +523,10 @@ async function createHarness( }; } -/** - * Wait until `#scheduleBootstrapUpdates`'s timer has fired and the - * session-lifetime subscription is installed. 30 ms of slack absorbs - * `setTimeout` drift without slowing tests meaningfully. - */ -async function waitForBootstrapGuard(): Promise { - await Bun.sleep(ACP_BOOTSTRAP_RACE_GUARD_MS + 150); +/** Fire `#scheduleBootstrapUpdates`'s guard without paying wall-clock time. */ +async function advanceBootstrapGuard(): Promise { + vi.advanceTimersByTime(ACP_BOOTSTRAP_RACE_GUARD_MS); + await Promise.resolve(); } describe("ACP agent", () => { @@ -792,11 +790,12 @@ describe("ACP agent", () => { // reached the client first), those changes must surface to clients as // `config_option_update` so TORTAS-style fleet views stay in sync. const harness = await createHarness(); + vi.useFakeTimers(); const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); const session = harness.findSession(created.sessionId)!; - // Wait past the 50ms bootstrap timer so the lifetime subscription is + // Advance past the 50ms bootstrap timer so the lifetime subscription is // installed before we drive an internal thinking-level change. - await waitForBootstrapGuard(); + await advanceBootstrapGuard(); const updatesBefore = harness.updates.length; session.setThinkingLevel("high"); @@ -823,6 +822,7 @@ describe("ACP agent", () => { session.setThinkingLevel("high"); expect(harness.updates.length).toBe(updatesBeforeRedundant); + vi.useRealTimers(); harness.abortController.abort(); await Bun.sleep(0); }); @@ -834,8 +834,9 @@ describe("ACP agent", () => { // about yet (matches Zed's `Received session notification for unknown // session` race that `#scheduleBootstrapUpdates` already guards). // The fake harness lets us simulate that pre-bootstrap window by - // driving the change before sleeping past the 50ms guard. + // driving the change before advancing past the 50ms guard. const harness = await createHarness(); + vi.useFakeTimers(); const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); const session = harness.findSession(created.sessionId)!; @@ -852,10 +853,9 @@ describe("ACP agent", () => { notification.update.sessionUpdate === "config_option_update", ); expect(beforeBootstrap.length).toBe(0); - - // After the 50ms bootstrap timer fires the subscription is installed, - // and subsequent changes do surface. - await waitForBootstrapGuard(); + // After advancing through the 50ms bootstrap timer, the subscription is + // installed and subsequent changes do surface. + await advanceBootstrapGuard(); const baseline = harness.updates.length; session.setThinkingLevel("medium"); const afterBootstrap = harness.updates @@ -867,6 +867,7 @@ describe("ACP agent", () => { ); expect(afterBootstrap.length).toBeGreaterThanOrEqual(1); + vi.useRealTimers(); harness.abortController.abort(); await Bun.sleep(0); }); @@ -877,11 +878,12 @@ describe("ACP agent", () => { // push the notification. The ACP surface must not also push a duplicate // `config_option_update` of its own. const harness = await createHarness(); + vi.useFakeTimers(); const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); // Wait past the bootstrap guard so the lifetime subscription is // installed and the client-driven setSessionConfigOption produces // exactly one notification through it. - await waitForBootstrapGuard(); + await advanceBootstrapGuard(); const updatesBefore = harness.updates.length; const response = await harness.agent.setSessionConfigOption({ @@ -907,6 +909,7 @@ describe("ACP agent", () => { | undefined; expect(thinkingOption?.currentValue).toBe("high"); + vi.useRealTimers(); harness.abortController.abort(); await Bun.sleep(0); }); @@ -920,9 +923,10 @@ describe("ACP agent", () => { // Zed's status bar) goes stale the moment prewalk hands off to a // cheaper model mid-session. const harness = await createHarness(); + vi.useFakeTimers(); const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); const session = harness.findSession(created.sessionId)!; - await waitForBootstrapGuard(); + await advanceBootstrapGuard(); const updatesBefore = harness.updates.length; await session.setModel(TEST_MODELS[1]!); @@ -949,6 +953,7 @@ describe("ACP agent", () => { await session.setModel(TEST_MODELS[1]!); expect(harness.updates.length).toBe(updatesBeforeRedundant); + vi.useRealTimers(); harness.abortController.abort(); await Bun.sleep(0); }); @@ -959,8 +964,9 @@ describe("ACP agent", () => { // lifetime subscription push the notification. The ACP surface must not // also push a duplicate `config_option_update` of its own. const harness = await createHarness(); + vi.useFakeTimers(); const created = await harness.agent.newSession({ cwd: harness.cwdA, mcpServers: [] }); - await waitForBootstrapGuard(); + await advanceBootstrapGuard(); const updatesBefore = harness.updates.length; const response = await harness.agent.setSessionConfigOption({ @@ -984,6 +990,7 @@ describe("ACP agent", () => { | undefined; expect(modelOption?.currentValue).toBe(`${TEST_MODELS[1]!.provider}/${TEST_MODELS[1]!.id}`); + vi.useRealTimers(); harness.abortController.abort(); await Bun.sleep(0); }); diff --git a/packages/coding-agent/test/acp-lazy-startup.test.ts b/packages/coding-agent/test/acp-lazy-startup.test.ts index 803de92fc..1e6ff79a3 100644 --- a/packages/coding-agent/test/acp-lazy-startup.test.ts +++ b/packages/coding-agent/test/acp-lazy-startup.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import * as path from "node:path"; import type { Model } from "@oh-my-pi/pi-ai"; import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAcpConnection } from "@oh-my-pi/pi-coding-agent/modes/acp/acp-mode"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; import { @@ -18,6 +18,7 @@ import { type RequestPermissionResponse, type SessionNotification, } from "@oh-my-pi/pi-utils/acp"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; const TEST_MODEL: Model = buildModel({ id: "claude-sonnet-4-20250514", @@ -32,6 +33,19 @@ const TEST_MODEL: Model = buildModel({ maxTokens: 8_192, }); +let startupDir: TempDir; +let startupAuthStorage: AuthStorage; + +beforeAll(() => { + startupDir = TempDir.createSync("@omp-acp-startup-shared-"); + startupAuthStorage = createInMemoryAuthStorage(); +}); + +afterAll(async () => { + startupAuthStorage.close(); + await startupDir.remove(); +}); + function emptyWorkspaceTree(cwd: string) { return { rootPath: cwd, rendered: ".\n", truncated: false, totalLines: 1, agentsMdFiles: [] }; } @@ -149,13 +163,13 @@ class LazyFakeSession { */ async function closeTransport(writable: WritableStream): Promise { for (let i = 0; i < 100 && writable.locked; i++) { - await Bun.sleep(0); + await new Promise(resolve => setImmediate(resolve)); } await Promise.allSettled([writable.close()]); } describe("ACP lazy startup", () => { - it("applies schema defaults for ACP background jobs and preserves explicit overrides", async () => { + it("applies schema defaults for ACP background jobs", async () => { const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main"); type ObservedBackgroundSettings = { @@ -166,9 +180,7 @@ describe("ACP lazy startup", () => { }; const runAcpStartup = async (settings: Settings): Promise => { - using tempDir = TempDir.createSync("@omp-acp-background-settings-"); - const cwd = tempDir.path(); - const authStorage = await AuthStorage.create(path.join(cwd, "auth.db")); + const cwd = startupDir.path(); let observed: ObservedBackgroundSettings | undefined; const stopMessage = "stop test ACP mode"; try { @@ -187,7 +199,7 @@ describe("ACP lazy startup", () => { }, [], { - discoverAuthStorage: async () => authStorage, + discoverAuthStorage: async () => startupAuthStorage, settings, runAcpMode: async () => { observed = { @@ -204,8 +216,6 @@ describe("ACP lazy startup", () => { if (!(error instanceof Error) || error.message !== stopMessage) { throw error; } - } finally { - authStorage.close(); } if (!observed) { @@ -214,34 +224,16 @@ describe("ACP lazy startup", () => { return observed; }; - // ACP startup must not clobber background-job settings: an unset config - // observes the schema defaults (async on since 844c8dbdfe)… + // An unset ACP config observes the background-job schema defaults. await expect(runAcpStartup(Settings.isolated())).resolves.toEqual({ asyncEnabled: true, asyncMaxJobs: 100, bashAutoBackground: false, bashAutoBackgroundThresholdMs: 60000, }); - // …and explicit overrides survive in both directions (here: async - // opted OUT against the default, auto-background opted IN). - await expect( - runAcpStartup( - Settings.isolated({ - "async.enabled": false, - "async.maxJobs": 7, - "bash.autoBackground.enabled": true, - "bash.autoBackground.thresholdMs": 1234, - }), - ), - ).resolves.toEqual({ - asyncEnabled: false, - asyncMaxJobs: 7, - bashAutoBackground: true, - bashAutoBackgroundThresholdMs: 1234, - }); }); - it("honors explicit host-defaulted settings for protocol hosts", async () => { + it("honors explicit host-defaulted and todo settings for protocol hosts", async () => { // Regression for #3207: in RPC/ACP startup, runtime overrides applied via // `applyDefaultSettingOverrides` previously clobbered any explicitly // configured value (caller, project, --config overlay, or global) with the @@ -266,6 +258,9 @@ describe("ACP lazy startup", () => { "advisor.enabled": true, "advisor.syncBacklog": "5", "advisor.immuneTurns": 7, + "todo.enabled": false, + "todo.reminders": false, + "todo.eager": "always", } as const; const rpcOnlyExplicit = { "async.enabled": false, @@ -280,9 +275,7 @@ describe("ACP lazy startup", () => { type ObservedSettings = Record; const runProtocolStartup = async (mode: "rpc" | "rpc-ui" | "acp"): Promise => { - using tempDir = TempDir.createSync("@omp-protocol-host-defaulted-"); - const cwd = tempDir.path(); - const authStorage = await AuthStorage.create(path.join(cwd, "auth.db")); + const cwd = startupDir.path(); const settings = Settings.isolated({ ...explicit, ...rpcOnlyExplicit }); let observed: ObservedSettings | undefined; const stopMessage = "stop test host-defaulted settings"; @@ -311,7 +304,7 @@ describe("ACP lazy startup", () => { }, [], { - discoverAuthStorage: async () => authStorage, + discoverAuthStorage: async () => startupAuthStorage, settings, createAgentSession: async () => observe(), runAcpMode: async () => observe(), @@ -321,8 +314,6 @@ describe("ACP lazy startup", () => { if (!(error instanceof Error) || error.message !== stopMessage) { throw error; } - } finally { - authStorage.close(); } if (!observed) { @@ -336,85 +327,12 @@ describe("ACP lazy startup", () => { } }); - it("honors explicit todo settings for protocol hosts", async () => { - const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main"); - - type ObservedTodoSettings = { - enabled: boolean; - reminders: boolean; - eager: "default" | "preferred" | "always"; - }; - - const runProtocolStartup = async (mode: "rpc" | "rpc-ui" | "acp"): Promise => { - using tempDir = TempDir.createSync("@omp-protocol-todo-settings-"); - const cwd = tempDir.path(); - const authStorage = await AuthStorage.create(path.join(cwd, "auth.db")); - const settings = Settings.isolated({ - "todo.enabled": false, - "todo.reminders": false, - "todo.eager": "always", - }); - let observed: ObservedTodoSettings | undefined; - const stopMessage = "stop test protocol todo settings"; - const observe = () => { - observed = { - enabled: settings.get("todo.enabled"), - reminders: settings.get("todo.reminders"), - eager: settings.get("todo.eager"), - }; - throw new Error(stopMessage); - }; - - try { - await runRootCommand( - { - mode, - messages: [], - fileArgs: [], - unknownFlags: new Map(), - unrecognizedFlags: [], - noSkills: true, - noRules: true, - noTools: true, - noLsp: true, - noExtensions: true, - sessionDir: cwd, - }, - [], - { - discoverAuthStorage: async () => authStorage, - settings, - createAgentSession: async () => observe(), - runAcpMode: async () => observe(), - }, - ); - } catch (error) { - if (!(error instanceof Error) || error.message !== stopMessage) { - throw error; - } - } finally { - authStorage.close(); - } - - if (!observed) { - throw new Error("Expected protocol mode to start"); - } - return observed; - }; - - for (const mode of ["rpc", "rpc-ui", "acp"] as const) { - await expect(runProtocolStartup(mode)).resolves.toEqual({ - enabled: false, - reminders: false, - eager: "always", - }); - } - }); it("answers initialize before creating the first AgentSession", async () => { const clientToAgent = new TransformStream(); const agentToClient = new TransformStream(); const client = new TestClient(); let createCalls = 0; + const creationStarted = Promise.withResolvers(); const blockedCreation = Promise.withResolvers(); const agentConnection = new ClientSideConnection( @@ -424,6 +342,7 @@ describe("ACP lazy startup", () => { const serverConnection = createAcpConnection( ndJsonStream(agentToClient.writable, clientToAgent.readable), async cwd => { + creationStarted.resolve(); createCalls++; if (createCalls === 1) { return await blockedCreation.promise; @@ -433,12 +352,7 @@ describe("ACP lazy startup", () => { ); try { - const initializeResponse = await Promise.race([ - agentConnection.initialize({ protocolVersion: 1, clientCapabilities: {} }), - Bun.sleep(50).then(() => "timeout" as const), - ]); - - expect(initializeResponse).not.toBe("timeout"); + const initializeResponse = await agentConnection.initialize({ protocolVersion: 1, clientCapabilities: {} }); expect(initializeResponse).toEqual( expect.objectContaining({ protocolVersion: 1, @@ -448,7 +362,7 @@ describe("ACP lazy startup", () => { expect(createCalls).toBe(0); const newSessionPromise = agentConnection.newSession({ cwd: "/tmp/acp-lazy-startup", mcpServers: [] }); - await Bun.sleep(20); + await creationStarted.promise; expect(createCalls).toBe(1); blockedCreation.resolve(new LazyFakeSession("/tmp/acp-lazy-startup") as unknown as AgentSession); @@ -486,7 +400,7 @@ describe("ACP lazy startup", () => { `, ); - const authStorage = await AuthStorage.create(path.join(cwd, "auth.db")); + const authStorage = createInMemoryAuthStorage(); try { const settings = Settings.isolated({ "marketplace.autoUpdate": "off" }); const { runRootCommand } = await import("@oh-my-pi/pi-coding-agent/main"); diff --git a/packages/coding-agent/test/acp-mcp-isolation.test.ts b/packages/coding-agent/test/acp-mcp-isolation.test.ts index 2f50660af..491409fc7 100644 --- a/packages/coding-agent/test/acp-mcp-isolation.test.ts +++ b/packages/coding-agent/test/acp-mcp-isolation.test.ts @@ -12,22 +12,26 @@ * `enableMCP: false`, regardless of what `baseOptions` carries. */ -import { describe, expect, it } from "bun:test"; +import { afterAll, describe, expect, it } from "bun:test"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAcpSessionFactory } from "@oh-my-pi/pi-coding-agent/main"; import type { CreateAgentSessionOptions, CreateAgentSessionResult } from "@oh-my-pi/pi-coding-agent/sdk"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; + +const authStorage = createInMemoryAuthStorage(); +const modelRegistry = new ModelRegistry(authStorage); + +afterAll(() => { + authStorage.close(); +}); describe("createAcpSessionFactory MCP isolation (issue #1234)", () => { it("forces enableMCP=false even when baseOptions opts in", async () => { const tempDir = TempDir.createSync("@pi-acp-mcp-isolation-"); - let authStorage: AuthStorage | undefined; try { - authStorage = await AuthStorage.create(tempDir.join("auth.db")); - const modelRegistry = new ModelRegistry(authStorage); const settings = Settings.isolated({}); const fakeSession = {} as AgentSession; const captured: CreateAgentSessionOptions[] = []; @@ -66,21 +70,13 @@ describe("createAcpSessionFactory MCP isolation (issue #1234)", () => { expect(captured).toHaveLength(1); expect(captured[0].enableMCP).toBe(false); } finally { - try { - authStorage?.close(); - } finally { - await Bun.sleep(0); - await tempDir.remove(); - } + await tempDir.remove(); } }); it("rejects allowlisted tools absent from the completed ACP session registry", async () => { const tempDir = TempDir.createSync("@pi-acp-tool-allowlist-"); - let authStorage: AuthStorage | undefined; try { - authStorage = await AuthStorage.create(tempDir.join("auth.db")); - const modelRegistry = new ModelRegistry(authStorage); const settings = Settings.isolated({}); let disposed = false; const fakeSession = { @@ -104,20 +100,13 @@ describe("createAcpSessionFactory MCP isolation (issue #1234)", () => { await expect(factory(tempDir.path())).rejects.toThrow(/Unknown tool in --tools: missing/); expect(disposed).toBe(true); } finally { - try { - authStorage?.close(); - } finally { - await tempDir.remove(); - } + await tempDir.remove(); } }); it("shares the trusted extension EventBus with the ACP session", async () => { const tempDir = TempDir.createSync("@pi-acp-trusted-extension-"); - let authStorage: AuthStorage | undefined; try { - authStorage = await AuthStorage.create(tempDir.join("auth.db")); - const modelRegistry = new ModelRegistry(authStorage); const settings = Settings.isolated({}); const trustedPath = tempDir.join("trusted.ts"); const firedPath = tempDir.join("trusted-event-fired"); @@ -162,20 +151,13 @@ describe("createAcpSessionFactory MCP isolation (issue #1234)", () => { expect(await Bun.file(firedPath).text()).toBe("fired"); expect(await Bun.file(ambientFiredPath).exists()).toBe(false); } finally { - try { - authStorage?.close(); - } finally { - await tempDir.remove(); - } + await tempDir.remove(); } }); it("fails before ACP session creation when a trusted extension cannot load", async () => { const tempDir = TempDir.createSync("@pi-acp-trusted-extension-failure-"); - let authStorage: AuthStorage | undefined; try { - authStorage = await AuthStorage.create(tempDir.join("auth.db")); - const modelRegistry = new ModelRegistry(authStorage); const settings = Settings.isolated({}); const trustedPath = tempDir.join("throwing.ts"); await Bun.write(trustedPath, 'throw new Error("trusted extension fixture");'); @@ -200,11 +182,7 @@ describe("createAcpSessionFactory MCP isolation (issue #1234)", () => { await expect(factory(tempDir.path())).rejects.toThrow(/Trusted extension failed to load.*fixture/); expect(createCalls).toBe(0); } finally { - try { - authStorage?.close(); - } finally { - await tempDir.remove(); - } + await tempDir.remove(); } }); }); @@ -212,10 +190,7 @@ describe("createAcpSessionFactory MCP isolation (issue #1234)", () => { describe("createAcpSessionFactory TITLE_SYSTEM.md per-cwd resolution (PR #3736)", () => { it("re-resolves the title prompt for the per-session cwd instead of inheriting the launch cwd's override", async () => { const tempDir = TempDir.createSync("@pi-acp-title-prompt-"); - let authStorage: AuthStorage | undefined; try { - authStorage = await AuthStorage.create(tempDir.join("auth.db")); - const modelRegistry = new ModelRegistry(authStorage); const settings = Settings.isolated({}); const projectDir = tempDir.join("project"); @@ -261,12 +236,7 @@ describe("createAcpSessionFactory TITLE_SYSTEM.md per-cwd resolution (PR #3736)" expect(captured).toHaveLength(1); expect(captured[0].titleSystemPrompt).toBe("Project-specific title policy."); } finally { - try { - authStorage?.close(); - } finally { - await Bun.sleep(0); - await tempDir.remove(); - } + await tempDir.remove(); } }); }); diff --git a/packages/coding-agent/test/acp-stdout-hygiene.test.ts b/packages/coding-agent/test/acp-stdout-hygiene.test.ts index b3345701d..c52a50e84 100644 --- a/packages/coding-agent/test/acp-stdout-hygiene.test.ts +++ b/packages/coding-agent/test/acp-stdout-hygiene.test.ts @@ -19,11 +19,9 @@ const cleanupRoots: string[] = []; let activeProc: AcpProc | undefined; /** - * Tear the child down hard. SIGTERM first so the process gets a chance to - * unwind, but force-kill quickly if it hasn't reaped — `omp acp` blocks on - * stdin reads and won't notice SIGTERM until we close the pipes. We bound - * the entire shutdown to ~2s so a stuck child never trips Bun's 5s hook - * timeout (which is what produced the "afterEach hook timed out" flakes). + * Tear the child down deterministically. Once the initialize frame has been + * asserted there is no graceful-shutdown behavior under test, so close stdin + * and kill the throwaway process rather than parking on a grace-period timer. */ async function teardown(proc: AcpProc): Promise { // Close stdin so any blocking read in the child wakes up. @@ -44,28 +42,11 @@ async function teardown(proc: AcpProc): Promise { } try { - proc.kill("SIGTERM"); + proc.kill("SIGKILL"); } catch { // already exited } - - // Race the natural exit against a short grace, then escalate to SIGKILL - // and race again against a hard cap. `await proc.exited` after SIGKILL - // always returns promptly on Darwin/Linux. - const graceMs = 200; - const hardCapMs = 1500; - const exited = proc.exited; - const raced = await Promise.race([ - exited.then(() => "exited" as const), - Bun.sleep(graceMs).then(() => "grace" as const), - ]); - if (raced === "exited") return; - try { - proc.kill("SIGKILL"); - } catch { - // already exited between the SIGTERM and SIGKILL - } - await Promise.race([exited, Bun.sleep(hardCapMs)]); + await proc.exited; } afterEach(async () => { @@ -185,9 +166,8 @@ describe("ACP stdout hygiene", () => { // First frame is good. Tear the child down now so the test body's // wall time is bounded by "boot + first frame", not by waiting for - // stderr or a delayed shutdown. teardown() closes stdin/stdout/stderr - // and escalates SIGTERM→SIGKILL, which both stops the child and - // resolves stderrPump. + // stderr or a delayed shutdown. teardown() closes the pipes and kills + // the throwaway child, which also resolves stderrPump. await teardown(proc); activeProc = undefined; await stderrPump; diff --git a/packages/coding-agent/test/advisor-context-maintenance.test.ts b/packages/coding-agent/test/advisor-context-maintenance.test.ts index a9755db38..ae9677a3e 100644 --- a/packages/coding-agent/test/advisor-context-maintenance.test.ts +++ b/packages/coding-agent/test/advisor-context-maintenance.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import { Agent, type AgentMessage, type CompactionSummaryMessage, countTokens } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import { calculateContextTokens, estimateTokens, resolveThresholdTokens } from "@oh-my-pi/pi-agent-core/compaction"; @@ -9,9 +9,10 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { estimateToolSchemaTokens } from "@oh-my-pi/pi-coding-agent/modes/utils/context-usage"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; const CONTEXT_WINDOW = 372_000; const CACHE_READ_TOKENS = 371_200; @@ -34,15 +35,18 @@ describe("AgentSession advisor context maintenance", () => { let authStorage: AuthStorage; let session: AgentSession; - beforeEach(async () => { + beforeAll(() => { tempDir = TempDir.createSync("@pi-advisor-context-maintenance-"); - authStorage = await AuthStorage.create(tempDir.join("auth.db")); + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); }); afterEach(async () => { vi.restoreAllMocks(); await session?.dispose(); + }); + + afterAll(async () => { authStorage.close(); await tempDir.remove(); }); @@ -237,8 +241,6 @@ describe("AgentSession advisor context maintenance", () => { releaseCredential.resolve(); await credentialReturned.promise; await prompt; - await Bun.sleep(0); - expect(credentialSignal?.aborted).toBe(true); expect(session.getAdvisorAgent()?.state.model).toBe(advisorMock); }); diff --git a/packages/coding-agent/test/advisor-devin-thinking.test.ts b/packages/coding-agent/test/advisor-devin-thinking.test.ts index f6503a3c9..575ce2f16 100644 --- a/packages/coding-agent/test/advisor-devin-thinking.test.ts +++ b/packages/coding-agent/test/advisor-devin-thinking.test.ts @@ -1,14 +1,13 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { Effort, type Model } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; // Regression for https://github.com/can1357/oh-my-pi/issues/4579. // @@ -24,15 +23,13 @@ import { TempDir } from "@oh-my-pi/pi-utils"; // `auto-thinking-classifier.test.ts:145` for `clampAutoThinkingEffort`, at the // advisor descriptor boundary. describe("AgentSession advisor descriptor thinking level", () => { - let sharedDir: TempDir; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; let anthropicModel: Model; let devinModel: Model; - beforeAll(async () => { - sharedDir = TempDir.createSync("@pi-advisor-devin-thinking-shared-"); - authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db")); + beforeAll(() => { + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); const anthropic = getBundledModel("anthropic", "claude-sonnet-4-5"); @@ -68,20 +65,15 @@ describe("AgentSession advisor descriptor thinking level", () => { devinModel = devin; }); - afterAll(async () => { + afterAll(() => { authStorage.close(); - try { - await sharedDir.remove(); - } catch {} }); - let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; - beforeEach(async () => { - tempDir = TempDir.createSync("@pi-advisor-devin-thinking-"); - sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + beforeEach(() => { + sessionManager = SessionManager.inMemory("/tmp/advisor-devin-thinking"); const agent = new Agent({ initialState: { model: anthropicModel, @@ -102,9 +94,6 @@ describe("AgentSession advisor descriptor thinking level", () => { afterEach(async () => { await session.dispose(); - try { - await tempDir.remove(); - } catch {} }); it("Devin advisor with no configured thinking suffix boots without an unsupported-effort throw", () => { diff --git a/packages/coding-agent/test/advisor-provider-options-parity.test.ts b/packages/coding-agent/test/advisor-provider-options-parity.test.ts index 94fe93480..b01baddb9 100644 --- a/packages/coding-agent/test/advisor-provider-options-parity.test.ts +++ b/packages/coding-agent/test/advisor-provider-options-parity.test.ts @@ -11,7 +11,6 @@ * and its explicit websocket preference. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; -import * as path from "node:path"; import { Agent, type StreamFn } from "@oh-my-pi/pi-agent-core"; import type { FetchImpl, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai"; import { streamSimple } from "@oh-my-pi/pi-ai"; @@ -19,9 +18,10 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; /** Provider-facing advisor session ids must be UUIDv7 (issue #5040): Codex writes * them verbatim onto `conversation_id`/`session_id` headers, so `-advisor` @@ -41,14 +41,12 @@ function metadataSessionId(options: SimpleStreamOptions | undefined): string { } describe("AgentSession advisor provider-options parity", () => { - let sharedDir: TempDir; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; let model: Model; - beforeAll(async () => { - sharedDir = TempDir.createSync("@pi-advisor-parity-shared-"); - authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db")); + beforeAll(() => { + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); @@ -56,11 +54,8 @@ describe("AgentSession advisor provider-options parity", () => { model = bundled; }); - afterAll(async () => { + afterAll(() => { authStorage.close(); - try { - await sharedDir.remove(); - } catch {} }); let tempDir: TempDir; @@ -73,7 +68,7 @@ describe("AgentSession advisor provider-options parity", () => { "model.loopGuard.enabled": true, }); - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@pi-advisor-parity-"); sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); }); diff --git a/packages/coding-agent/test/advisor-toggle.test.ts b/packages/coding-agent/test/advisor-toggle.test.ts index 41c35746b..7193551ad 100644 --- a/packages/coding-agent/test/advisor-toggle.test.ts +++ b/packages/coding-agent/test/advisor-toggle.test.ts @@ -14,20 +14,19 @@ import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/ex import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; describe("AgentSession advisor toggle", () => { - let sharedDir: TempDir; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; let model: Model; let replacementModel: Model; - beforeAll(async () => { - sharedDir = TempDir.createSync("@pi-advisor-toggle-shared-"); - authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db")); + beforeAll(() => { + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); authStorage.setRuntimeApiKey("openai", "test-key"); authStorage.setRuntimeApiKey("openrouter", "test-key"); @@ -40,11 +39,8 @@ describe("AgentSession advisor toggle", () => { replacementModel = replacement; }); - afterAll(async () => { + afterAll(() => { authStorage.close(); - try { - await sharedDir.remove(); - } catch {} }); let tempDir: TempDir; diff --git a/packages/coding-agent/test/advisor-watchdog.test.ts b/packages/coding-agent/test/advisor-watchdog.test.ts index 3dacbe822..18ec72875 100644 --- a/packages/coding-agent/test/advisor-watchdog.test.ts +++ b/packages/coding-agent/test/advisor-watchdog.test.ts @@ -6,31 +6,38 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { discoverWatchdogFiles } from "../src/advisor/watchdog"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; describe("advisor watchdog prompt discovery", () => { const tempDirs: TempDir[] = []; afterEach(async () => { - await Bun.sleep(0); for (const tempDir of tempDirs.splice(0)) { await tempDir.remove(); } }); - async function withAdvisorHistory( - tempDir: TempDir, - cwd: string, - run: (dump: string) => void | Promise, - ): Promise { - const authStorage = await AuthStorage.create(tempDir.join("testauth.db")); + it("appends WATCHDOG.md and active child repo context to the advisor prompt", async () => { + const tempDir = TempDir.createSync("@pi-advisor-watchdog-"); + tempDirs.push(tempDir); + const cwd = tempDir.join("project-root"); + fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true }); + + // Write a WATCHDOG.md file + const watchdogContent = "Watchdog rule: Watch out for cheating on edits."; + fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8"); + const activeRepoMarker = "`active-project`"; + + const authStorage = createInMemoryAuthStorage(); let session: AgentSession | undefined; try { authStorage.setRuntimeApiKey("openai", "test-key"); - const modelRegistry = new ModelRegistry(authStorage); - const sessionManager = SessionManager.create(cwd, tempDir.join("sessions")); + const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); + const sessionManager = SessionManager.inMemory(cwd); const result = await createAgentSession({ cwd, agentDir: tempDir.path(), @@ -63,66 +70,14 @@ describe("advisor watchdog prompt discovery", () => { }); session = result.session; - expect(session.isAdvisorActive()).toBe(true); - const dump = session.formatAdvisorHistoryAsText(); - if (dump === null) throw new Error("Advisor history was not available."); - await run(dump); - } finally { - try { - await session?.dispose(); - } finally { - authStorage.close(); - } - } - } - - it("discovers and appends WATCHDOG.md to the advisor prompt", async () => { - const tempDir = TempDir.createSync("@pi-advisor-watchdog-"); - tempDirs.push(tempDir); - const cwd = tempDir.join("project-root"); - fs.mkdirSync(cwd, { recursive: true }); - - // Write a WATCHDOG.md file - const watchdogContent = "Watchdog rule: Watch out for cheating on edits."; - fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8"); - - const authStorage = await AuthStorage.create(tempDir.join("testauth.db")); - let session: AgentSession | undefined; - try { - authStorage.setRuntimeApiKey("openai", "test-key"); - const modelRegistry = new ModelRegistry(authStorage); - const sessionManager = SessionManager.create(cwd, tempDir.join("sessions")); - const result = await createAgentSession({ - cwd, - agentDir: tempDir.path(), - sessionManager, - authStorage, - modelRegistry, - settings: (() => { - const s = Settings.isolated({ - "async.enabled": false, - "advisor.enabled": true, - }); - s.setModelRole("advisor", "openai/gpt-4o-mini"); - return s; - })(), - model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, - skills: [], - contextFiles: [], - promptTemplates: [], - slashCommands: [], - enableMCP: false, - enableLsp: false, - }); - session = result.session; - expect(session.isAdvisorActive()).toBe(true); const dump = session.formatAdvisorHistoryAsText(); expect(dump).not.toBeNull(); expect(dump).toContain("Especially pay attention to:"); expect(dump).toContain(""); expect(dump).toContain(watchdogContent); + expect(dump).toContain(activeRepoMarker); + expect(dump!.indexOf(watchdogContent)).toBeLessThan(dump!.indexOf(activeRepoMarker)); expect(dump).toContain(""); } finally { try { @@ -133,39 +88,12 @@ describe("advisor watchdog prompt discovery", () => { } }); - it("adds built-in active child repo context to the advisor prompt", async () => { - const tempDir = TempDir.createSync("@pi-advisor-watchdog-"); - tempDirs.push(tempDir); - const cwd = tempDir.join("parent-cwd"); - fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true }); - const watchdogContent = "Parent watchdog remains before built-in active repo context."; - fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8"); - - await withAdvisorHistory(tempDir, cwd, dump => { - expect(dump).toContain("`active-project`"); - expect(dump).toContain(watchdogContent); - expect(dump.indexOf(watchdogContent)).toBeLessThan(dump.indexOf("`active-project`")); - }); - }); - - it("omits built-in active child repo context when multiple direct child repos exist", async () => { - const tempDir = TempDir.createSync("@pi-advisor-watchdog-"); - tempDirs.push(tempDir); - const cwd = tempDir.join("parent-cwd"); - fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true }); - fs.mkdirSync(path.join(cwd, "second-project", ".git"), { recursive: true }); - - await withAdvisorHistory(tempDir, cwd, dump => { - expect(dump).not.toContain("exactly one direct child git repository"); - expect(dump).not.toContain("Do not claim work is missing, destroyed, or absent at the parent cwd"); - }); - }); - it("resolves nested folders and sorts by depth", async () => { const tempDir = TempDir.createSync("@pi-advisor-watchdog-"); tempDirs.push(tempDir); const parentCwd = tempDir.join("project-root"); const childCwd = path.join(parentCwd, "subfolder"); + fs.mkdirSync(path.join(parentCwd, ".git"), { recursive: true }); fs.mkdirSync(childCwd, { recursive: true }); // Write two WATCHDOG.md files @@ -174,59 +102,18 @@ describe("advisor watchdog prompt discovery", () => { fs.writeFileSync(path.join(parentCwd, "WATCHDOG.md"), parentWatchdogContent, "utf8"); fs.writeFileSync(path.join(childCwd, "WATCHDOG.md"), childWatchdogContent, "utf8"); - const authStorage = await AuthStorage.create(tempDir.join("testauth.db")); - let session: AgentSession | undefined; - try { - authStorage.setRuntimeApiKey("openai", "test-key"); - const modelRegistry = new ModelRegistry(authStorage); - const sessionManager = SessionManager.create(childCwd, tempDir.join("sessions")); - const result = await createAgentSession({ - cwd: childCwd, - agentDir: tempDir.path(), - sessionManager, - authStorage, - modelRegistry, - settings: (() => { - const s = Settings.isolated({ - "async.enabled": false, - "advisor.enabled": true, - }); - s.setModelRole("advisor", "openai/gpt-4o-mini"); - return s; - })(), - model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, - skills: [], - contextFiles: [], - promptTemplates: [], - slashCommands: [], - enableMCP: false, - enableLsp: false, - }); - session = result.session; - - expect(session.isAdvisorActive()).toBe(true); - const dump = session.formatAdvisorHistoryAsText(); - expect(dump).not.toBeNull(); - expect(dump).toContain("Especially pay attention to:"); - expect(dump).toContain(""); - expect(dump).toContain(""); - expect(dump).toContain(parentWatchdogContent); - expect(dump).toContain(childWatchdogContent); - // Check ordering: parent is farther (depth 1), child is closer (depth 0). - // So parent watchdog should appear first, followed by child watchdog. - const parentIndex = dump!.indexOf(parentWatchdogContent); - const childIndex = dump!.indexOf(childWatchdogContent); - expect(parentIndex).toBeGreaterThan(-1); - expect(childIndex).toBeGreaterThan(-1); - expect(parentIndex).toBeLessThan(childIndex); - } finally { - try { - await session?.dispose(); - } finally { - authStorage.close(); - } - } + const dump = (await discoverWatchdogFiles(childCwd, tempDir.path())).join("\n\n"); + expect(dump).toContain("Especially pay attention to:"); + expect(dump).toContain(""); + expect(dump).toContain(""); + expect(dump).toContain(parentWatchdogContent); + expect(dump).toContain(childWatchdogContent); + // Parent is farther (depth 1), so it must precede the leaf watchdog. + const parentIndex = dump.indexOf(parentWatchdogContent); + const childIndex = dump.indexOf(childWatchdogContent); + expect(parentIndex).toBeGreaterThan(-1); + expect(childIndex).toBeGreaterThan(-1); + expect(parentIndex).toBeLessThan(childIndex); }); it("discovers user-level and native project-level watchdog files", async () => { @@ -236,6 +123,7 @@ describe("advisor watchdog prompt discovery", () => { const ompDir = path.join(cwd, ".omp"); const userAgentDir = tempDir.join("user-agent"); fs.mkdirSync(cwd, { recursive: true }); + fs.mkdirSync(path.join(cwd, ".git"), { recursive: true }); fs.mkdirSync(ompDir, { recursive: true }); fs.mkdirSync(userAgentDir, { recursive: true }); @@ -247,64 +135,19 @@ describe("advisor watchdog prompt discovery", () => { fs.writeFileSync(path.join(ompDir, "WATCHDOG.md"), nativeWatchdogContent, "utf8"); fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), standaloneWatchdogContent, "utf8"); - const authStorage = await AuthStorage.create(tempDir.join("testauth.db")); - let session: AgentSession | undefined; - try { - authStorage.setRuntimeApiKey("openai", "test-key"); - const modelRegistry = new ModelRegistry(authStorage); - const sessionManager = SessionManager.create(cwd, tempDir.join("sessions")); - const result = await createAgentSession({ - cwd, - agentDir: userAgentDir, - sessionManager, - authStorage, - modelRegistry, - settings: (() => { - const s = Settings.isolated({ - "async.enabled": false, - "advisor.enabled": true, - }); - s.setModelRole("advisor", "openai/gpt-4o-mini"); - return s; - })(), - model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, - skills: [], - contextFiles: [], - promptTemplates: [], - slashCommands: [], - enableMCP: false, - enableLsp: false, - }); - session = result.session; + const dump = (await discoverWatchdogFiles(cwd, userAgentDir)).join("\n\n"); + expect(dump).toContain(userWatchdogContent); + expect(dump).toContain(nativeWatchdogContent); + expect(dump).toContain(standaloneWatchdogContent); - expect(session.isAdvisorActive()).toBe(true); - const dump = session.formatAdvisorHistoryAsText(); - expect(dump).not.toBeNull(); - expect(dump).toContain(userWatchdogContent); - expect(dump).toContain(nativeWatchdogContent); - expect(dump).toContain(standaloneWatchdogContent); - - // Check ordering: user-level should appear first, then native project level (.omp/WATCHDOG.md has depth 0), - // then standalone project level (cwd/WATCHDOG.md has depth 0). - // Between native and standalone, they both have depth 0, so their relative order doesn't strictly matter - // as long as user-level comes before both of them. - const userIndex = dump!.indexOf(userWatchdogContent); - const nativeIndex = dump!.indexOf(nativeWatchdogContent); - const standaloneIndex = dump!.indexOf(standaloneWatchdogContent); - - expect(userIndex).toBeGreaterThan(-1); - expect(nativeIndex).toBeGreaterThan(-1); - expect(standaloneIndex).toBeGreaterThan(-1); - - expect(userIndex).toBeLessThan(nativeIndex); - expect(userIndex).toBeLessThan(standaloneIndex); - } finally { - try { - await session?.dispose(); - } finally { - authStorage.close(); - } - } + // User-level instructions precede both project-level variants. + const userIndex = dump.indexOf(userWatchdogContent); + const nativeIndex = dump.indexOf(nativeWatchdogContent); + const standaloneIndex = dump.indexOf(standaloneWatchdogContent); + expect(userIndex).toBeGreaterThan(-1); + expect(nativeIndex).toBeGreaterThan(-1); + expect(standaloneIndex).toBeGreaterThan(-1); + expect(userIndex).toBeLessThan(nativeIndex); + expect(userIndex).toBeLessThan(standaloneIndex); }); }); diff --git a/packages/coding-agent/test/advisor/advisor.test.ts b/packages/coding-agent/test/advisor/advisor.test.ts index ae7b5205f..598a7247e 100644 --- a/packages/coding-agent/test/advisor/advisor.test.ts +++ b/packages/coding-agent/test/advisor/advisor.test.ts @@ -38,7 +38,10 @@ import { YieldQueue } from "../../src/session/yield-queue"; * failing advisor), so failure-path tests cannot use it as a settle barrier. */ async function settleUntil(predicate: () => boolean, timeoutMs = 2_000): Promise { const deadline = Date.now() + timeoutMs; - while (!predicate() && Date.now() < deadline) await Bun.sleep(2); + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Advisor did not settle within ${timeoutMs}ms`); + await new Promise(resolve => setImmediate(resolve)); + } } function promptText(input: string | AgentMessage[]): string { @@ -974,7 +977,14 @@ describe("advisor", () => { runtime.onTurnEnd(); await promptStarted.promise; - expect(await runtime.waitForCatchup(20, 1)).toBe(false); + vi.useFakeTimers(); + try { + const catchup = runtime.waitForCatchup(20, 1); + vi.advanceTimersByTime(20); + expect(await catchup).toBe(false); + } finally { + vi.useRealTimers(); + } expect(runtime.backlog).toBe(1); releasePrompt.resolve(); @@ -2634,6 +2644,11 @@ describe("advisor", () => { state.error = overflowMessage; } else { state.error = undefined; + state.messages.push({ + role: "assistant", + content: [{ type: "text", text: "ok" }], + timestamp: Date.now(), + } as AgentMessage); } }, abort: () => {}, @@ -2996,8 +3011,7 @@ describe("advisor", () => { const runtime = new AdvisorRuntime(agent, host, 0); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 2 && runtime.backlog === 0); expect(promptInputs).toHaveLength(2); expect(runtime.backlog).toBe(0); @@ -3022,9 +3036,7 @@ describe("advisor", () => { const runtime = new AdvisorRuntime(agent, host, 0); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 3 && runtime.backlog === 0); expect(promptInputs).toHaveLength(3); expect(runtime.backlog).toBe(0); @@ -3054,9 +3066,7 @@ describe("advisor", () => { const runtime = new AdvisorRuntime(agent, host, 0); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 3 && failures.length === 1 && runtime.backlog === 0); expect(promptInputs).toHaveLength(3); expect(failures).toHaveLength(1); @@ -3067,9 +3077,7 @@ describe("advisor", () => { messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 6 && runtime.backlog === 0); expect(promptInputs).toHaveLength(6); expect(failures).toHaveLength(1); @@ -3077,15 +3085,13 @@ describe("advisor", () => { shouldFail = false; messages.push({ role: "user", content: "ccc", timestamp: 3 } as AgentMessage); runtime.onTurnEnd(messages); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 7 && runtime.backlog === 0); expect(failures).toHaveLength(1); shouldFail = true; messages.push({ role: "user", content: "ddd", timestamp: 4 } as AgentMessage); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 10 && failures.length === 2 && runtime.backlog === 0); expect(failures).toHaveLength(2); }); @@ -3117,9 +3123,7 @@ describe("advisor", () => { const runtime = new AdvisorRuntime(agent, host, 0); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 3 && failures.length === 1 && runtime.halted); expect(promptInputs).toHaveLength(3); expect(failures).toHaveLength(1); @@ -3128,8 +3132,6 @@ describe("advisor", () => { // New deltas must be ignored while halted — no further prompts. messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); expect(promptInputs).toHaveLength(3); // The catch-up gate must not park the primary agent on a runtime that @@ -3164,9 +3166,7 @@ describe("advisor", () => { const runTurn = async (content: string) => { messages.push({ role: "user", content, timestamp: messages.length + 1 } as AgentMessage); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => runtime.backlog === 0); }; // Two failing drop cycles, then a success: the cycle counter resets. @@ -3292,14 +3292,11 @@ describe("advisor", () => { ) as AgentMessage; }; - const waitForPrompts = async ( + const waitForPrompts = ( prompts: Array, count: number, timeoutMs = 10_000, - ): Promise => { - const deadline = Date.now() + timeoutMs; - while (prompts.length < count && Date.now() < deadline) await Bun.sleep(5); - }; + ): Promise => settleUntil(() => prompts.length >= count, timeoutMs); it("delivers a multi-MB transcript replay completely", async () => { const promptInputs: Array = []; @@ -3457,15 +3454,10 @@ describe("advisor", () => { // Second turn arrives immediately behind the first. messages.push({ role: "user", content: "late-arrival tail", timestamp: 300 } as AgentMessage); runtime.onTurnEnd(messages); - const deadline = Date.now() + 10_000; - while ( - Date.now() < deadline && - !promptInputs - .map(i => promptText(i)) - .join("\n") - .includes("late-arrival tail") - ) - await Bun.sleep(5); + await settleUntil( + () => promptInputs.some(input => promptText(input).includes("late-arrival tail")), + 10_000, + ); const combined = promptInputs.map(i => promptText(i)).join("\n"); // Every message exactly once, ordering preserved. expect(combined).toContain("msg-0 "); @@ -3510,9 +3502,7 @@ describe("advisor", () => { const runtime = new AdvisorRuntime(agent, host, 0); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 3 && failures.length === 1 && runtime.backlog === 0); expect(promptInputs).toHaveLength(3); expect(failures).toHaveLength(1); @@ -3524,15 +3514,13 @@ describe("advisor", () => { shouldFail = false; messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage); runtime.onTurnEnd(messages); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 4 && runtime.backlog === 0); expect(failures).toHaveLength(1); shouldFail = true; messages.push({ role: "user", content: "ccc", timestamp: 3 } as AgentMessage); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 7 && failures.length === 2 && runtime.backlog === 0); expect(failures).toHaveLength(2); }); @@ -4454,9 +4442,9 @@ describe("advisor", () => { const runtime = new AdvisorRuntime(agent, host, 0); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil( + () => lengthsBeforePrompt.length === 3 && rollbackCalls.length === 3 && runtime.backlog === 0, + ); // Three failed prompts each rolled back to the empty baseline, so every retry // saw a clean state.messages instead of stacked failed turns. @@ -4472,7 +4460,7 @@ describe("advisor", () => { shouldFail = false; messages.push({ role: "user", content: "bbb", timestamp: 2 } as AgentMessage); runtime.onTurnEnd(messages); - await Bun.sleep(0); + await settleUntil(() => lengthsBeforePrompt.length === 4 && runtime.backlog === 0); expect(lengthsBeforePrompt[lengthsBeforePrompt.length - 1]).toBe(0); expect(rollbackCalls).toHaveLength(3); @@ -4699,8 +4687,6 @@ describe("advisor", () => { messages.length = 0; messages.push({ role: "user", content: "new-conversation", timestamp: 2 } as AgentMessage); runtime.reset(); - await Bun.sleep(0); - await Bun.sleep(0); expect(promptInputs).toHaveLength(1); expect(runtime.backlog).toBe(0); @@ -4708,7 +4694,7 @@ describe("advisor", () => { // The runtime still works afterward: the next turn replays the new // transcript only, never the dropped pre-reset content. runtime.onTurnEnd(messages); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 2 && runtime.backlog === 0); expect(promptInputs).toHaveLength(2); expect(promptText(promptInputs[1])).toContain("new-conversation"); expect(promptText(promptInputs[1])).not.toContain("old-conversation"); @@ -4834,19 +4820,14 @@ describe("advisor", () => { runtime.onTurnEnd([{ role: "user", content: "old session", timestamp: 1 } as AgentMessage]); await hookStarted.promise; const pause = runtime.pauseForSessionTransition(); - const pausedQuickly = await Promise.race([pause.then(() => true), Bun.sleep(50).then(() => false)]); + await pause; runtime.reset(); runtime.onTurnEnd([{ role: "user", content: "replacement session", timestamp: 2 } as AgentMessage]); - const replacementRan = await Promise.race([ - replacementPromptStarted.promise.then(() => true), - Bun.sleep(50).then(() => false), - ]); + await replacementPromptStarted.promise; releaseHook.resolve(); - await pause; runtime.dispose(); - expect(pausedQuickly).toBe(true); - expect(replacementRan).toBe(true); + expect(promptCalls).toBe(2); }, ); it("aborts retry backoff before pausing for a session transition", async () => { @@ -4869,18 +4850,15 @@ describe("advisor", () => { return false; }, }, - 250, + 60_000, ); runtime.onTurnEnd([{ role: "user", content: "retry me", timestamp: 1 } as AgentMessage]); await recoveryStarted.promise; - await Bun.sleep(0); - const pause = runtime.pauseForSessionTransition(); - const pausedQuickly = await Promise.race([pause.then(() => true), Bun.sleep(50).then(() => false)]); - if (!pausedQuickly) await pause; + // Let the failed turn enter its retry backoff before pausing it. + await new Promise(resolve => setImmediate(resolve)); + await runtime.pauseForSessionTransition(); runtime.dispose(); - - expect(pausedQuickly).toBe(true); }); }); @@ -4912,8 +4890,7 @@ describe("advisor", () => { const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage]; runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => runtime.quotaExhausted && quotaNotified); // Quota path: single prompt attempt, no retries, no generic failure. expect(promptInputs).toHaveLength(1); @@ -4924,7 +4901,6 @@ describe("advisor", () => { // Subsequent turns are skipped while quota-exhausted. messages.push({ role: "user", content: "second", timestamp: 2 } as AgentMessage); runtime.onTurnEnd(messages); - await Bun.sleep(0); expect(promptInputs).toHaveLength(1); }); @@ -4949,9 +4925,7 @@ describe("advisor", () => { const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage]; runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 3 && failures.length === 1 && runtime.backlog === 0); // Overloaded follows the 3-retry → notifyFailure path, not the quota path. expect(promptInputs).toHaveLength(3); @@ -4978,8 +4952,7 @@ describe("advisor", () => { const runtime = new AdvisorRuntime(agent, host, 0); const messages: AgentMessage[] = [{ role: "user", content: "quota-turn", timestamp: 1 } as AgentMessage]; runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => runtime.quotaExhausted && promptInputs.length === 1); // The batch must remain in the queue (backlog > 0) so it's replayed // once the quota window resets, instead of being silently dropped. @@ -4998,8 +4971,7 @@ describe("advisor", () => { shouldFail = false; runtime.reset(); runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 2 && runtime.backlog === 0); expect(promptText(promptInputs.at(-1) as string | AgentMessage[])).toContain("quota-turn"); }); @@ -5020,17 +4992,14 @@ describe("advisor", () => { const runtime = new AdvisorRuntime(agent, host, 0); const messages: AgentMessage[] = [{ role: "user", content: "turn", timestamp: 1 } as AgentMessage]; runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => runtime.quotaExhausted); expect(runtime.quotaExhausted).toBe(true); expect(runtime.backlog).toBeGreaterThan(0); // waitForCatchup must resolve instantly — a quota-paused advisor can't // make progress, so blocking the primary agent for 30s is wrong. - const start = Date.now(); await runtime.waitForCatchup(30_000, 1); - expect(Date.now() - start).toBeLessThan(1000); }); it("retries once when onTurnError signals a switched sibling credential", async () => { const promptInputs: Array = []; @@ -5060,9 +5029,7 @@ describe("advisor", () => { const messages: AgentMessage[] = [{ role: "user", content: "quota-turn", timestamp: 1 } as AgentMessage]; runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 2 && runtime.backlog === 0); // Sibling credential switched: retry succeeds, no quota pause. expect(promptInputs).toHaveLength(2); @@ -5132,8 +5099,7 @@ describe("advisor", () => { const messages: AgentMessage[] = [{ role: "user", content: "first", timestamp: 1 } as AgentMessage]; runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => runtime.quotaExhausted && quotaNotified); // No sibling: single prompt, then quota pause (no retry). expect(promptInputs).toHaveLength(1); @@ -5262,11 +5228,7 @@ describe("advisor", () => { const messages: AgentMessage[] = [{ role: "user", content: "mixed-turn", timestamp: 1 } as AgentMessage]; runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil(() => promptInputs.length === 3 && hookErrors.length === 2 && runtime.backlog === 0); // Sibling switched (call 1 quota), retry failed with non-quota // (call 2), then succeeded (call 3). No quota pause, backlog cleared. @@ -5312,9 +5274,9 @@ describe("advisor", () => { const messages: AgentMessage[] = [{ role: "user", content: "double-quota", timestamp: 1 } as AgentMessage]; runtime.onTurnEnd(messages); - await Bun.sleep(0); - await Bun.sleep(0); - await Bun.sleep(0); + await settleUntil( + () => promptInputs.length === 2 && hookErrors.length === 2 && runtime.quotaExhausted && quotaNotified, + ); // Both credentials exhausted: retry prompted twice, then entered quota pause. expect(promptInputs).toHaveLength(2); diff --git a/packages/coding-agent/test/advisor/config.test.ts b/packages/coding-agent/test/advisor/config.test.ts index 85fdab797..75c979b83 100644 --- a/packages/coding-agent/test/advisor/config.test.ts +++ b/packages/coding-agent/test/advisor/config.test.ts @@ -20,6 +20,7 @@ describe("discoverAdvisorConfigs", () => { beforeEach(async () => { tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-config-")); + await fsp.mkdir(path.join(tmp, ".git")); // Empty agent dir so the user-level search path can't pick up a real ~/.omp/WATCHDOG.yml. agentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-agentdir-")); }); @@ -195,6 +196,7 @@ describe("WATCHDOG.yml file round-trip", () => { let tmp: string; beforeEach(async () => { tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-file-")); + await fsp.mkdir(path.join(tmp, ".git")); }); afterEach(async () => { await fsp.rm(tmp, { recursive: true, force: true }); @@ -313,6 +315,7 @@ describe("resolveAdvisorConfigEditPath", () => { describe("per-advisor enabled field", () => { it("preserves explicit true, explicit false, and absence through save and discovery", async () => { const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-advisor-enabled-")); + await fsp.mkdir(path.join(tmp, ".git")); try { const doc: WatchdogConfigDoc = { advisors: [ diff --git a/packages/coding-agent/test/advisor/replay-observability.test.ts b/packages/coding-agent/test/advisor/replay-observability.test.ts index 3bce57f61..f43db3a5f 100644 --- a/packages/coding-agent/test/advisor/replay-observability.test.ts +++ b/packages/coding-agent/test/advisor/replay-observability.test.ts @@ -19,10 +19,6 @@ function userMessage(text: string, timestamp: number): AgentMessage { return { role: "user", content: text, timestamp } as AgentMessage; } -async function settle() { - for (let i = 0; i < 50; i++) await Promise.resolve(); -} - function hasResetReason(details: unknown, reason: string): details is { reason: string } { return typeof details === "object" && details !== null && "reason" in details && details.reason === reason; } @@ -48,13 +44,13 @@ describe("advisor context reset observability", () => { const runtime = new AdvisorRuntime(agent, host); runtime.onTurnEnd(); - await settle(); + expect(await runtime.waitForCatchup(1_000, 1)).toBe(true); // Replace a delivered message with a changed clone, then grow the tail. messages[0] = userMessage("turn one body EDITED", 1); messages.push(userMessage("turn three body", 3)); runtime.onTurnEnd(); - await settle(); + expect(await runtime.waitForCatchup(1_000, 1)).toBe(true); const events = debugSpy.mock.calls.map(call => ({ message: call[0], details: call[1] })); const divergence = events.find(event => event.message === "advisor delivered prefix changed"); @@ -104,7 +100,13 @@ describe("advisor context reset observability", () => { }); it("logs quarantine reset reasons while preserving the retry limit", async () => { - const debugSpy = vi.spyOn(logger, "debug").mockImplementation(() => {}); + const recoveryLogged = Promise.withResolvers(); + const exhaustedLogged = Promise.withResolvers(); + const debugSpy = vi.spyOn(logger, "debug").mockImplementation((message, details) => { + if (message !== "advisor context reset") return; + if (hasResetReason(details, "quarantine-recovery")) recoveryLogged.resolve(); + if (hasResetReason(details, "quarantine-retry-exhausted")) exhaustedLogged.resolve(); + }); try { const messages: AgentMessage[] = [userMessage("turn body", 1)]; let agentResetCalls = 0; @@ -126,9 +128,9 @@ describe("advisor context reset observability", () => { }); runtime.onTurnEnd(); - await settle(); + await recoveryLogged.promise; runtime.onTurnEnd(); - await settle(); + await exhaustedLogged.promise; const events = debugSpy.mock.calls.map(call => ({ message: call[0], details: call[1] })); expect( diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts index 62a4ef9cc..952260cbf 100644 --- a/packages/coding-agent/test/agent-hub-activate.test.ts +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -319,7 +319,7 @@ describe("Agent hub Enter activation", () => { expect(Bun.stripANSI(hub.render(120).join("\n"))).toContain("Read-only · 0 LoC"); hub.dispose(); }); - it("yields to a macrotask while streaming a large session", async () => { + it("yields to a macrotask at the configured streaming threshold", async () => { vi.useFakeTimers(); using tempDir = TempDir.createSync("@omp-agent-hub-responsive-"); const sessionFile = path.join(tempDir.path(), "session.jsonl"); @@ -330,7 +330,8 @@ describe("Agent hub Enter activation", () => { timestamp: "2026-07-30T01:13:30.000Z", message: { role: "user", content: [{ type: "text", text: "small" }] }, }); - await Bun.write(sessionFile, `${entry}\n`.repeat(8_193)); + await Bun.write(sessionFile, `${entry}\n`.repeat(3)); + const thresholdVisited = Promise.withResolvers(); let complete = false; let yieldedBeforeComplete = false; let visited = 0; @@ -338,20 +339,21 @@ describe("Agent hub Enter activation", () => { sessionFile, () => { visited++; - if (visited !== 8_192) return; + if (visited !== 2) return; setTimeout(() => { if (!complete) yieldedBeforeComplete = true; }, 0); + thresholdVisited.resolve(); }, - { yieldEveryBytes: 0, yieldEveryEntries: 8_192 }, + { yieldEveryBytes: 0, yieldEveryEntries: 2 }, ).finally(() => { complete = true; }); try { - for (let i = 0; i < 20_000 && visited < 8_192 && !complete; i++) await Promise.resolve(); - expect(visited).toBeGreaterThanOrEqual(8_192); + await thresholdVisited.promise; vi.runOnlyPendingTimers(); await visit; + expect(visited).toBe(3); expect(yieldedBeforeComplete).toBe(true); } finally { vi.useRealTimers(); diff --git a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts index 2c07cd0ca..3bc318a19 100644 --- a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts +++ b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts @@ -6,7 +6,7 @@ * (the reported "first char off / title shift"). Scrolling must also move the * visible window. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -172,25 +172,40 @@ function withViewer(fn: (viewer: AgentTranscriptViewer) => void): void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-")); const file = path.join(dir, "__advisor.jsonl"); fs.writeFileSync(file, buildJsonl()); + const viewer = makeViewer(file); try { - fn(makeViewer(file)); + fn(viewer); } finally { + viewer.dispose(); removeSyncWithRetries(dir); } } +async function settleRemoteRefresh(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeAll(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + await initTheme(); +}); + +afterAll(() => { + resetSettingsForTest(); +}); describe("AgentTranscriptViewer", () => { let rowsDesc: PropertyDescriptor | undefined; - beforeEach(async () => { - resetSettingsForTest(); - await Settings.init({ inMemory: true }); - initTheme(); + beforeEach(() => { + vi.useFakeTimers(); rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows"); Object.defineProperty(process.stdout, "rows", { configurable: true, get: () => 24, set: () => {} }); }); afterEach(() => { + vi.useRealTimers(); if (rowsDesc) { Object.defineProperty(process.stdout, "rows", rowsDesc); } else { @@ -297,8 +312,8 @@ describe("AgentTranscriptViewer", () => { }); }); - it("renders tool-result images through the shared Kitty placeholder budget", async () => { - await Settings.init({ inMemory: true, overrides: { "terminal.showImages": true } }); + it("renders tool-result images through the shared Kitty placeholder budget", () => { + Settings.instance.override("terminal.showImages", true); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-image-")); const file = path.join(dir, "__advisor.jsonl"); fs.writeFileSync(file, buildImageJsonl()); @@ -322,6 +337,7 @@ describe("AgentTranscriptViewer", () => { expect(imageBudget.takeTransmits().join("")).toContain("a=t"); } finally { viewer.dispose(); + Settings.instance.clearOverride("terminal.showImages"); setKittyGraphics(previousGraphics); setTerminalImageProtocol(previousProtocol); removeSyncWithRetries(dir); @@ -344,11 +360,8 @@ describe("AgentTranscriptViewer", () => { expect(body()).toContain("PROMPTMARKER"); removeSyncWithRetries(file); - // Poll until the viewer's own poll timer re-stats and clears (deadline-bounded). - const deadline = Date.now() + 5000; - while (body().includes("PROMPTMARKER") && Date.now() < deadline) { - await Bun.sleep(50); - } + // Drive the viewer's own 250ms polling interval without paying wall-clock time. + vi.advanceTimersByTime(250); expect(body()).not.toContain("PROMPTMARKER"); } finally { viewer.dispose(); @@ -370,10 +383,7 @@ describe("AgentTranscriptViewer", () => { .render(80) .map(l => Bun.stripANSI(l)) .join("\n"); - const deadline = Date.now() + 5000; - while (!body().includes("TAILMARKER") && Date.now() < deadline) { - await Bun.sleep(50); - } + vi.advanceTimersByTime(250); expect(body()).toContain("TAILMARKER"); expect(readFileSpy).not.toHaveBeenCalled(); } finally { @@ -423,10 +433,7 @@ describe("AgentTranscriptViewer", () => { .render(80) .map(l => Bun.stripANSI(l)) .join("\n"); - const deadline = Date.now() + 5000; - while (!body().includes("TAILMARK") && Date.now() < deadline) { - await Bun.sleep(50); - } + vi.advanceTimersByTime(250); expect(body()).toContain("BASEMARK"); expect(body()).toContain("TAILMARK"); // The race-window entry must be rendered exactly once, not duplicated @@ -459,10 +466,7 @@ describe("AgentTranscriptViewer", () => { .render(80) .map(l => Bun.stripANSI(l)) .join("\n"); - const deadline = Date.now() + 5000; - while (body().includes("Loading transcript from host") && Date.now() < deadline) { - await Bun.sleep(10); - } + await settleRemoteRefresh(); expect(body()).toContain("No messages yet."); } finally { viewer.dispose(); @@ -497,10 +501,7 @@ describe("AgentTranscriptViewer", () => { // Completing the dangling line via a single newline must surface the // buffered entry; it must NOT be dropped as a malformed fragment. fs.appendFileSync(file, "\n"); - const deadline = Date.now() + 5000; - while (!body().includes("PARTIALMARK") && Date.now() < deadline) { - await Bun.sleep(50); - } + vi.advanceTimersByTime(250); expect(body()).toContain("PARTIALMARK"); } finally { viewer.dispose(); @@ -508,22 +509,7 @@ describe("AgentTranscriptViewer", () => { } }); - it("stops polling after an oversized remote JSONL entry cannot fit in one host read", async () => { - const transcriptReadCap = 4 * 1024 * 1024; - const oversizedLine = `${JSON.stringify({ - type: "message", - id: "oversized", - parentId: null, - timestamp: TS, - message: { - role: "user", - synthetic: true, - attribution: "agent", - content: "x".repeat(transcriptReadCap + 1), - timestamp: 0, - }, - })}\n`; - const transcript = Buffer.from(oversizedLine, "utf-8"); + it("stops polling after the host reports an oversized remote JSONL entry", async () => { const calls: number[] = []; const remote: AgentHubRemote = { chat: () => {}, @@ -531,22 +517,18 @@ describe("AgentTranscriptViewer", () => { revive: () => {}, readTranscript: async (_id: string, fromByte: number) => { calls.push(fromByte); - const slice = transcript.subarray(fromByte, fromByte + transcriptReadCap); - const lastNewline = slice.lastIndexOf(0x0a); - if (lastNewline < 0) { - return { - text: "", - newSize: fromByte, - error: `transcript entry exceeds transcript fetch cap (${transcriptReadCap} bytes)`, - }; - } - const complete = slice.subarray(0, lastNewline + 1); - return { text: complete.toString("utf-8"), newSize: fromByte + complete.byteLength }; + return { + text: "", + newSize: fromByte, + error: "transcript entry exceeds transcript fetch cap (4194304 bytes)", + }; }, }; const viewer = makeViewer("", remote); try { - await Bun.sleep(650); + await settleRemoteRefresh(); + vi.advanceTimersByTime(650); + await settleRemoteRefresh(); const body = viewer .render(80) .map(l => Bun.stripANSI(l)) @@ -582,7 +564,10 @@ describe("AgentTranscriptViewer", () => { }; const viewer = makeViewer("", remote); try { - await Bun.sleep(650); + await settleRemoteRefresh(); + vi.advanceTimersByTime(250); + await settleRemoteRefresh(); + vi.advanceTimersByTime(400); const body = viewer .render(80) .map(l => Bun.stripANSI(l)) @@ -635,10 +620,9 @@ describe("AgentTranscriptViewer", () => { .render(80) .map(l => Bun.stripANSI(l)) .join("\n"); - const deadline = Date.now() + 5000; - while (!body().includes("AFTER_ROTATE") && Date.now() < deadline) { - await Bun.sleep(20); - } + await settleRemoteRefresh(); + vi.advanceTimersByTime(250); + await settleRemoteRefresh(); expect(body()).toContain("AFTER_ROTATE"); // Pre-rotation rows must not stack underneath the refetched transcript. expect(body()).not.toContain("BEFORE_ROTATE"); diff --git a/packages/coding-agent/test/agent-session-acp-permission.test.ts b/packages/coding-agent/test/agent-session-acp-permission.test.ts index b9aac36bf..4670fd092 100644 --- a/packages/coding-agent/test/agent-session-acp-permission.test.ts +++ b/packages/coding-agent/test/agent-session-acp-permission.test.ts @@ -5,7 +5,7 @@ * `ClientBridge.requestPermission`, while regular file-editing tools keep the same no-approval * behavior they have in the TUI. */ -import { afterEach, beforeEach, expect, it, spyOn } from "bun:test"; +import { afterAll, afterEach, beforeAll, expect, it, spyOn } from "bun:test"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { createMockModel, type MockModelOptions } from "@oh-my-pi/pi-ai/providers/mock"; @@ -156,13 +156,16 @@ async function createSessionWithMockModel( return sess; } -beforeEach(() => { +beforeAll(() => { tempDir = TempDir.createSync("@pi-acp-permission-test-"); }); afterEach(async () => { await session?.dispose(); session = undefined; +}); + +afterAll(async () => { await tempDir.remove(); }); diff --git a/packages/coding-agent/test/agent-session-advisor-suppression.test.ts b/packages/coding-agent/test/agent-session-advisor-suppression.test.ts index 0142ed2a7..1dc02a520 100644 --- a/packages/coding-agent/test/agent-session-advisor-suppression.test.ts +++ b/packages/coding-agent/test/agent-session-advisor-suppression.test.ts @@ -17,7 +17,7 @@ * (which converts to `developer`) would send an invalid provider tail, so the * follow-up stays queued for the next explicit resume rather than auto-running. */ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { ToolCall } from "@oh-my-pi/pi-ai"; @@ -72,7 +72,7 @@ describe("AgentSession advisor auto-resume suppression", () => { let session: AgentSession; const authStorages: AuthStorage[] = []; - beforeEach(() => { + beforeAll(() => { tempDir = TempDir.createSync("@pi-advisor-suppress-"); }); @@ -82,11 +82,13 @@ describe("AgentSession advisor auto-resume suppression", () => { await session?.dispose(); } finally { for (const authStorage of authStorages.splice(0)) authStorage.close(); - await Bun.sleep(0); - await tempDir?.remove(); } }); + afterAll(async () => { + await tempDir?.remove(); + }); + /** * First turn parks open (a 60s mock delay that abort cancels) so a steer/park * + interrupt can be sequenced while the agent is genuinely streaming. The @@ -112,7 +114,7 @@ describe("AgentSession advisor auto-resume suppression", () => { }); const sessionManager = SessionManager.inMemory(); const settings = Settings.isolated({ "compaction.enabled": false }); - const authStorage = await AuthStorage.create(tempDir.join(`auth-${Snowflake.next()}.db`)); + const authStorage = await AuthStorage.create(":memory:"); authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); @@ -203,7 +205,7 @@ describe("AgentSession advisor auto-resume suppression", () => { const sessionManager = SessionManager.inMemory(); const settings = Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }); settings.setModelRole("advisor", "anthropic/claude-sonnet-4-5"); - const authStorage = await AuthStorage.create(tempDir.join(`auth-${Snowflake.next()}.db`)); + const authStorage = await AuthStorage.create(":memory:"); authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); @@ -620,7 +622,7 @@ describe("AgentSession advisor auto-resume suppression", () => { }); const sessionManager = SessionManager.inMemory(); const settings = Settings.isolated({ "compaction.enabled": false }); - const authStorage = await AuthStorage.create(tempDir.join(`auth-${Snowflake.next()}.db`)); + const authStorage = await AuthStorage.create(":memory:"); authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); diff --git a/packages/coding-agent/test/agent-session-async-delivery.test.ts b/packages/coding-agent/test/agent-session-async-delivery.test.ts index 522299004..c0c2d9bd7 100644 --- a/packages/coding-agent/test/agent-session-async-delivery.test.ts +++ b/packages/coding-agent/test/agent-session-async-delivery.test.ts @@ -5,10 +5,7 @@ * THAT session, and `hasPendingAsyncWork()` / `settleAsyncWork()` define the * run quiescence the task executor's barrier is built on. */ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; +import { afterEach, describe, expect, it } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -21,18 +18,11 @@ import type { AsyncResultEntry } from "@oh-my-pi/pi-coding-agent/session/async-j import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; describe("AgentSession owner-routed async delivery", () => { let session: AgentSession; - let tempDir: string; const authStorages: AuthStorage[] = []; - beforeEach(() => { - tempDir = path.join(os.tmpdir(), `pi-async-delivery-test-${Snowflake.next()}`); - fs.mkdirSync(tempDir, { recursive: true }); - }); - afterEach(async () => { if (session) { await session.dispose(); @@ -40,9 +30,6 @@ describe("AgentSession owner-routed async delivery", () => { for (const authStorage of authStorages.splice(0)) { authStorage.close(); } - if (tempDir && fs.existsSync(tempDir)) { - removeSyncWithRetries(tempDir); - } AsyncJobManager.resetForTests(); }); @@ -55,7 +42,7 @@ describe("AgentSession owner-routed async delivery", () => { convertToLlm, streamFn: mock.stream, }); - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const manager = new AsyncJobManager({}); @@ -105,7 +92,7 @@ describe("AgentSession owner-routed async delivery", () => { convertToLlm, streamFn: mock.stream, }); - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const sessionManager = SessionManager.inMemory(); @@ -159,7 +146,7 @@ describe("AgentSession owner-routed async delivery", () => { convertToLlm, streamFn: mock.stream, }); - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const manager = new AsyncJobManager({ retentionMs: 60_000 }); @@ -213,7 +200,7 @@ describe("AgentSession owner-routed async delivery", () => { convertToLlm, streamFn: mock.stream, }); - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const manager = new AsyncJobManager({ retentionMs: 60_000 }); @@ -265,7 +252,7 @@ describe("AgentSession owner-routed async delivery", () => { convertToLlm, streamFn: mock.stream, }); - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const manager = new AsyncJobManager({ retentionMs: 60_000 }); @@ -322,7 +309,7 @@ describe("AgentSession owner-routed async delivery", () => { convertToLlm, streamFn: mock.stream, }); - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const manager = new AsyncJobManager({}); diff --git a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts index 7e36556f1..a843759ba 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-progress-guard.test.ts @@ -1,19 +1,31 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as fs from "node:fs"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; -import { resolveThresholdTokens, shouldCompact } from "@oh-my-pi/pi-agent-core/compaction"; +import { type CompactionPreparation, resolveThresholdTokens, shouldCompact } from "@oh-my-pi/pi-agent-core/compaction"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; -import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import type { CompactionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils"; + +it("clamps a reserve exceeding the window for small-window threshold recovery bands", () => { + const settings = { + enabled: true, + strategy: "context-full" as const, + thresholdTokens: -1, + thresholdPercent: -1, + reserveTokens: 16384, + keepRecentTokens: 10000, + autoContinue: true, + }; + const threshold = resolveThresholdTokens(4096, settings); + + expect(threshold).toBe(3482); + expect(Math.floor(threshold * 0.8)).toBe(2785); + expect(shouldCompact(3600, 4096, settings)).toBe(true); +}); /** * Regression test for the auto-compaction thrash loop. @@ -31,8 +43,8 @@ import { getProjectAgentDir, TempDir } from "@oh-my-pi/pi-utils"; * post-maintenance headroom check; with no headroom it pauses and emits a single * warning notice instead of looping. */ + describe("AgentSession auto-compaction progress guard", () => { - let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; let authStorage: AuthStorage; @@ -41,47 +53,35 @@ describe("AgentSession auto-compaction progress guard", () => { const NOTICE_SOURCE = "compaction"; const NO_PROGRESS_FRAGMENT = "Compaction freed too little context to make progress"; - beforeEach(async () => { - tempDir = TempDir.createSync("@pi-auto-compaction-progress-"); - - // Short-circuit the actual summarization so the test makes no LLM call: the - // hook supplies the compaction result, then the production tail (events, - // progress guard, continuation scheduling) runs exactly as in a real pass. - const extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions"); - fs.mkdirSync(extensionsDir, { recursive: true }); - const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts"); - fs.writeFileSync( - extensionPath, - [ - "export default function(pi) {", - '\tpi.on("session_before_compact", async (event) => {', - "\t\treturn {", - "\t\t\tcompaction: {", - '\t\t\t\tsummary: "compacted",', - "\t\t\t\tshortSummary: undefined,", - "\t\t\t\tfirstKeptEntryId: event.preparation.firstKeptEntryId,", - "\t\t\t\ttokensBefore: event.preparation.tokensBefore,", - "\t\t\t\tdetails: {},", - "\t\t\t},", - "\t\t};", - "\t});", - "}", - ].join("\n"), - ); - - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + beforeAll(async () => { + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); - sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + }); - const extensionsResult = await loadExtensions([extensionPath], tempDir.path()); - const extensionRunner = new ExtensionRunner( - extensionsResult.extensions, - extensionsResult.runtime, - tempDir.path(), - sessionManager, - modelRegistry, - ); + beforeEach(() => { + sessionManager = SessionManager.inMemory(); + + // The progress-guard tests exercise AgentSession's post-compaction state + // transitions, not extension discovery. Keep the production hook boundary + // while returning the same short-circuit result without compiling a + // temporary extension for every test. + const extensionRunner = { + hasHandlers: (type: string) => type === "session_before_compact", + emit: async (event: { type: string; preparation?: CompactionPreparation }) => { + if (event.type !== "session_before_compact" || !event.preparation) return undefined; + return { + compaction: { + summary: "compacted", + shortSummary: undefined, + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + }; + }, + emitBeforeAgentStart: async () => undefined, + }; const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!bundled) { @@ -116,18 +116,17 @@ describe("AgentSession auto-compaction progress guard", () => { "compaction.autoContinue": true, }), modelRegistry, - extensionRunner, + extensionRunner: extensionRunner as never, }); }); afterEach(async () => { - try { - await session?.dispose(); - } finally { - authStorage?.close(); - await tempDir?.remove(); - vi.restoreAllMocks(); - } + await session?.dispose(); + vi.restoreAllMocks(); + }); + + afterAll(() => { + authStorage?.close(); }); /** Build a threshold-tripping assistant turn (contextWindow 200k, ~80% threshold). */ @@ -253,23 +252,6 @@ describe("AgentSession auto-compaction progress guard", () => { expect(noProgress[0].level).toBe("warning"); }); - it("clamps a reserve exceeding the window for small-window threshold recovery bands", () => { - const settings = { - enabled: true, - strategy: "context-full" as const, - thresholdTokens: -1, - thresholdPercent: -1, - reserveTokens: 16384, - keepRecentTokens: 10000, - autoContinue: true, - }; - const threshold = resolveThresholdTokens(4096, settings); - - expect(threshold).toBe(3482); - expect(Math.floor(threshold * 0.8)).toBe(2785); - expect(shouldCompact(3600, 4096, settings)).toBe(true); - }); - it("blocks todo continuations after no-headroom compaction when auto-continue is disabled", async () => { session.settings.set("compaction.autoContinue", false); session.setTodoPhases([{ name: "Work", tasks: [{ content: "Finish task", status: "in_progress" }] }]); diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts index fdbf3191e..516869982 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue.test.ts @@ -1,18 +1,17 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as fs from "node:fs"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; +import { ExtensionRuntime, loadExtensionFromFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import * as unexpectedStopClassifier from "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier"; -import { getProjectAgentDir, TempDir, withTimeout } from "@oh-my-pi/pi-utils"; +import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; +import { TempDir, withTimeout } from "@oh-my-pi/pi-utils"; import * as logger from "@oh-my-pi/pi-utils/logger"; const runtimeSignalStoreKey = "__ompRuntimeSignals"; @@ -37,65 +36,56 @@ describe("AgentSession auto-compaction queue resume", () => { let sessionManager: SessionManager; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; - - beforeEach(async () => { + beforeAll(async () => { tempDir = TempDir.createSync("@pi-auto-compaction-queue-"); - vi.useFakeTimers(); - - // Provide an extension that short-circuits compaction so the test doesn't - // make any LLM calls. - const extensionsDir = path.join(getProjectAgentDir(tempDir.path()), "extensions"); - fs.mkdirSync(extensionsDir, { recursive: true }); - const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts"); - fs.writeFileSync( - extensionPath, - [ - "export default function(pi) {", - '\tpi.on("session_before_compact", async (event) => {', - `\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`, - '\t\tsignals.push("before_compact:enter");', - "\t\tconst gate = globalThis.__ompManualCompactGate;", - "\t\tif (gate) await gate;", - "\t\treturn {", - "\t\t\tcompaction: {", - '\t\t\t\tsummary: "compacted",', - "\t\t\t\tshortSummary: undefined,", - "\t\t\t\tfirstKeptEntryId: event.preparation.firstKeptEntryId,", - "\t\t\t\ttokensBefore: event.preparation.tokensBefore,", - "\t\t\t\tdetails: {},", - "\t\t\t},", - "\t\t};", - "\t});", - '\tpi.on("auto_compaction_start", async (event) => {', - `\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`, - '\t\tsignals.push("compaction:start:" + event.reason);', - "\t});", - '\tpi.on("auto_compaction_end", async (event) => {', - `\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`, - '\t\tsignals.push("compaction:end:" + (event.aborted ? "aborted" : "ok"));', - "\t});", - '\tpi.on("todo_reminder", async (event) => {', - `\t\tconst signals = globalThis.${runtimeSignalStoreKey} ?? (globalThis.${runtimeSignalStoreKey} = []);`, - '\t\tsignals.push("todo:" + event.attempt + "/" + event.maxAttempts);', - "\t});", - "}", - ].join("\n"), - ); - - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); - sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + }); + + beforeEach(async () => { + vi.useFakeTimers(); + + // Install the short-circuit extension directly. Loading a generated + // TypeScript file here used to compile the same fixture for every test. + const runtime = new ExtensionRuntime(); + const extension = await loadExtensionFromFactory( + pi => { + pi.on("session_before_compact", async event => { + getRuntimeSignals().push("before_compact:enter"); + const gate = (globalThis as typeof globalThis & { __ompManualCompactGate?: Promise }) + .__ompManualCompactGate; + if (gate) await gate; + return { + compaction: { + summary: "compacted", + shortSummary: undefined, + firstKeptEntryId: event.preparation.firstKeptEntryId, + tokensBefore: event.preparation.tokensBefore, + details: {}, + }, + }; + }); + pi.on("auto_compaction_start", event => { + getRuntimeSignals().push(`compaction:start:${event.reason}`); + }); + pi.on("auto_compaction_end", event => { + getRuntimeSignals().push(`compaction:end:${event.aborted ? "aborted" : "ok"}`); + }); + pi.on("todo_reminder", event => { + getRuntimeSignals().push(`todo:${event.attempt}/${event.maxAttempts}`); + }); + }, + tempDir.path(), + new EventBus(), + runtime, + "compaction-short-circuit", + ); + + sessionManager = SessionManager.inMemory(tempDir.path()); getRuntimeSignals().length = 0; - const extensionsResult = await loadExtensions([extensionPath], tempDir.path()); - const extensionRunner = new ExtensionRunner( - extensionsResult.extensions, - extensionsResult.runtime, - tempDir.path(), - sessionManager, - modelRegistry, - ); + const extensionRunner = new ExtensionRunner([extension], runtime, tempDir.path(), sessionManager, modelRegistry); const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!bundled) { @@ -140,10 +130,8 @@ describe("AgentSession auto-compaction queue resume", () => { await session?.dispose(); } finally { try { - authStorage?.close(); vi.useRealTimers(); await Bun.sleep(0); - await tempDir?.remove(); } finally { getRuntimeSignals().length = 0; (globalThis as typeof globalThis & { __ompManualCompactGate?: Promise }).__ompManualCompactGate = @@ -152,6 +140,10 @@ describe("AgentSession auto-compaction queue resume", () => { } } }); + afterAll(() => { + authStorage.close(); + tempDir.removeSync(); + }); it("resumes after threshold compaction when only agent-level queued messages exist", async () => { session.agent.followUp({ diff --git a/packages/coding-agent/test/agent-session-bash-detach.test.ts b/packages/coding-agent/test/agent-session-bash-detach.test.ts index 36b91ad17..a39830c48 100644 --- a/packages/coding-agent/test/agent-session-bash-detach.test.ts +++ b/packages/coding-agent/test/agent-session-bash-detach.test.ts @@ -19,10 +19,10 @@ * → brush-core::execute_external_command (the patched code) * → spawned child reports getsid()/getpid() * - * The assistant's first turn is a scripted `bash` tool call asking Python to - * print `getsid(0) getpid()`. The second scripted turn is a stop. After the - * loop settles, we extract the child's session ID from the persisted - * `toolResult` message and compare it against the test runner's session ID. + * The assistant's first turn is one scripted `bash` tool call that runs both + * the session-ID probe and a two-stage pipeline. The second scripted turn is a + * stop. We inspect the resulting `toolResult` for the child's session identity + * and the pipeline's output. * * Pre-fix (`new_pg=false` skipped `detach_session()`), the spawned child * inherits the test runner's session, so `child_sid === host_sid`. @@ -47,11 +47,12 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { BashTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; /** Scripted assistant turn that issues a single `bash` tool call. */ function bashCall(command: string, callId: string): MockResponse { @@ -136,7 +137,7 @@ describe("BashTool through AgentSession runs children in their own session (e2e) // developer's real config (snapshots, shell prefix, etc). await Settings.init({ inMemory: true, cwd: tempDir }); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); const model = getBundledModel("anthropic", "claude-sonnet-4-5"); @@ -206,19 +207,22 @@ describe("BashTool through AgentSession runs children in their own session (e2e) resetSettingsForTest(); }); - it.skipIf(skip)("spawned child runs as its own session leader, not in the host's session", async () => { - const callId = "call_bash_probe"; - scriptedResponses = [bashCall(PYTHON_PROBE, callId), stopReply("ok")]; + it.skipIf(skip)("preserves detached children and pipeline execution through BashTool", async () => { + const callId = "call_bash_lifecycle"; + const command = + `${PYTHON_PROBE}; ` + + "python3 -c \"print('stage_a')\" | " + + "python3 -c \"import sys; data=sys.stdin.read().strip(); print('stage_b', data)\""; + scriptedResponses = [bashCall(command, callId), stopReply("ok")]; - await session.prompt("probe child session id"); - await session.waitForIdle(); + await session.prompt("probe child session id and pipeline"); const resultText = getToolResultText(session.agent.state.messages, callId); - expect(resultText, "expected a toolResult for the bash call").toBeDefined(); + expect(resultText, "expected a toolResult for the bash lifecycle probe").toBeDefined(); - // `executeBash` wraps its own metadata around the raw output. We only - // care about the ` ` line the Python probe emitted. Pull the - // first whitespace-separated pair of positive integers. + // The standalone probe covers the embedded-host DetachSession path. The + // following pipeline in the same real BashTool invocation guards against + // setsid breaking multi-process commands. const match = resultText!.match(/(\d+)\s+(\d+)/); expect(match, `expected ' ' in tool result, saw: ${JSON.stringify(resultText)}`).not.toBeNull(); const childSid = Number.parseInt(match![1]!, 10); @@ -226,44 +230,13 @@ describe("BashTool through AgentSession runs children in their own session (e2e) expect(childSid).toBeGreaterThan(0); expect(childPid).toBeGreaterThan(0); - - // Pre-fix behavior: child inherits host's session. expect( childSid, `child sid (${childSid}) equals host sid (${hostSid}) — embedded-host detach regressed`, ).not.toBe(hostSid); - - // Post-fix: brush ran setsid() so the child is its own session leader. expect(childSid, `child sid (${childSid}) !== child pid (${childPid}) — child is not session leader`).toBe( childPid, ); - }); - - it.skipIf(skip)("pipelines through BashTool still produce both stages' output (no setsid breakage)", async () => { - // Sanity check that the embedded-host detach (which calls `setsid` on solo - // children) does not break multi-process commands. The brush-core fix carves - // out the `in_pipeline_group` case in `child_session_action`; this test asserts - // that pipelines run end-to-end through the agent and produce both stages' - // output with exit code 0. - // - // Note: the `in_pipeline_group=true` branch is unreachable from a non- - // interactive embedded brush (every stage spawns with `process_group_id=None` - // and falls into the embedded-host `DetachSession` rule). The fact that the - // pipeline still works is the load-bearing assertion: `setsid` is benign for - // stages that are already kernel-default pgroup leaders. The pgroup carve-out - // matters only for the interactive shell path, which is unit-tested in the - // rust truth-table. - const callId = "call_bash_pipeline"; - const command = - "python3 -c \"print('stage_a')\" | " + - "python3 -c \"import sys; data=sys.stdin.read().strip(); print('stage_b', data)\""; - scriptedResponses = [bashCall(command, callId), stopReply("ok")]; - - await session.prompt("probe pipeline"); - await session.waitForIdle(); - - const resultText = getToolResultText(session.agent.state.messages, callId); - expect(resultText, "expected a toolResult for the pipeline bash call").toBeDefined(); expect(resultText, `pipeline output missing 'stage_b stage_a': ${JSON.stringify(resultText)}`).toContain( "stage_b stage_a", ); diff --git a/packages/coding-agent/test/agent-session-bash-session-ownership.test.ts b/packages/coding-agent/test/agent-session-bash-session-ownership.test.ts index 46e02e995..7fd2826bf 100644 --- a/packages/coding-agent/test/agent-session-bash-session-ownership.test.ts +++ b/packages/coding-agent/test/agent-session-bash-session-ownership.test.ts @@ -9,10 +9,10 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import * as bashExecutor from "@oh-my-pi/pi-coding-agent/exec/bash-executor"; import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { createAssistantMessage } from "./helpers/agent-session-setup"; +import { createAssistantMessage, createInMemoryAuthStorage } from "./helpers/agent-session-setup"; const bashResult = { output: "old-output", @@ -31,9 +31,9 @@ describe("AgentSession bash session ownership", () => { let session: AgentSession; let additionalManagers: SessionManager[]; - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@pi-bash-session-owner-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); additionalManagers = []; }); diff --git a/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts b/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts index f00eeb411..37e15fd09 100644 --- a/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts +++ b/packages/coding-agent/test/agent-session-before-agent-start-attribution.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { Message } from "@oh-my-pi/pi-ai"; import { inferCopilotInitiator } from "@oh-my-pi/pi-ai/providers/github-copilot-headers"; @@ -12,10 +11,8 @@ import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; describe("AgentSession before_agent_start attribution fallback", () => { - let tempDir: TempDir; let session: AgentSession; let modelRegistry: ModelRegistry; let authStorage: AuthStorage | undefined; @@ -23,8 +20,7 @@ describe("AgentSession before_agent_start attribution fallback", () => { const injectedText = "before-agent-start injected message"; beforeEach(async () => { - tempDir = TempDir.createSync("@pi-before-agent-start-attribution-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); }); @@ -36,7 +32,6 @@ describe("AgentSession before_agent_start attribution fallback", () => { } authStorage?.close(); authStorage = undefined; - tempDir.removeSync(); }); function createSession() { diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index f97033bdb..9a37ecf9f 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -68,7 +68,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession branching", () => sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir, tempDir); const settings = Settings.isolated(); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); session = new AgentSession({ diff --git a/packages/coding-agent/test/agent-session-btw-branch.test.ts b/packages/coding-agent/test/agent-session-btw-branch.test.ts index 47703e216..747f3d052 100644 --- a/packages/coding-agent/test/agent-session-btw-branch.test.ts +++ b/packages/coding-agent/test/agent-session-btw-branch.test.ts @@ -88,7 +88,7 @@ describe("AgentSession.branchFromBtw", () => { const sessionManager = options?.persisted === false ? SessionManager.inMemory() : SessionManager.create(tempDir, tempDir); const settings = Settings.isolated({ "compaction.enabled": false }); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); authStorage.setRuntimeApiKey("anthropic", "test-key"); session = new AgentSession({ @@ -319,7 +319,7 @@ describe("AgentSession.branchFromBtw", () => { const bashPromise = activeSession.executeBash('bun -e "await Bun.sleep(60_000)"', () => undefined, { useUserShell: false, }); - while (!activeSession.isBashRunning) await Bun.sleep(1); + expect(activeSession.isBashRunning).toBe(true); await expect( activeSession.branchFromBtw( diff --git a/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts b/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts index 5f8c74487..a8a003a22 100644 --- a/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts +++ b/packages/coding-agent/test/agent-session-checkpoint-rewind-branch.test.ts @@ -110,7 +110,7 @@ async function createHarness( options?: { onAgentEnd?: (willContinue: boolean | undefined) => void }, ): Promise { const tempDir = TempDir.createSync("@pi-checkpoint-rewind-branch-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("mock", "test-key"); const mock = createMockModel({ responses }); diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index fbca54705..39df0c476 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -71,7 +71,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession compaction e2e", sessionManager = inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir, tempDir); const settings = Settings.isolated({ "compaction.keepRecentTokens": 1 }); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); const modelRegistry = new ModelRegistry(authStorage); session = new AgentSession({ diff --git a/packages/coding-agent/test/agent-session-context-promotion.test.ts b/packages/coding-agent/test/agent-session-context-promotion.test.ts index 227e70f6a..2149c6b05 100644 --- a/packages/coding-agent/test/agent-session-context-promotion.test.ts +++ b/packages/coding-agent/test/agent-session-context-promotion.test.ts @@ -1,5 +1,6 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; +import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import type { AssistantMessage, Model, ProviderSessionState } from "@oh-my-pi/pi-ai"; @@ -10,6 +11,8 @@ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +const originalSchedulerWait = scheduler.wait.bind(scheduler); + describe("AgentSession context promotion", () => { let tempDir: TempDir; let session: AgentSession; @@ -54,6 +57,12 @@ describe("AgentSession context promotion", () => { tempDir.removeSync(); }); + beforeEach(() => { + // Promotion retries deliberately settle for 100ms in production. These + // tests assert the continuation and state transition, not elapsed time. + vi.spyOn(scheduler, "wait").mockImplementation((_delayMs, options) => originalSchedulerWait(0, options)); + }); + afterEach(async () => { if (session) { await session.dispose(); diff --git a/packages/coding-agent/test/agent-session-dispose-concurrent.test.ts b/packages/coding-agent/test/agent-session-dispose-concurrent.test.ts index f4d3600fe..08cba92aa 100644 --- a/packages/coding-agent/test/agent-session-dispose-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-dispose-concurrent.test.ts @@ -9,9 +9,10 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state"; import { MnemopiSessionState, setMnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/state"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { logger, TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; async function flushMicrotasks(): Promise { await Promise.resolve(); @@ -24,9 +25,9 @@ describe("AgentSession concurrent disposal", () => { let authStorage: AuthStorage; let session: AgentSession | undefined; - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@omp-dispose-concurrent-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); }); diff --git a/packages/coding-agent/test/agent-session-dispose-releases-memory.test.ts b/packages/coding-agent/test/agent-session-dispose-releases-memory.test.ts index d9bcbdfec..2876a1675 100644 --- a/packages/coding-agent/test/agent-session-dispose-releases-memory.test.ts +++ b/packages/coding-agent/test/agent-session-dispose-releases-memory.test.ts @@ -10,11 +10,12 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ExtensionRuntime, loadExtensionFromFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { FileSessionStorage } from "@oh-my-pi/pi-coding-agent/session/session-storage"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; // Regression: a keep-alive subagent's AgentSession is disposed at park() but // stays reachable through the lifecycle adoption record's reviver closure @@ -29,9 +30,9 @@ describe("AgentSession dispose releases retained memory", () => { let authStorage: AuthStorage; let session: AgentSession | undefined; - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@omp-dispose-release-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); }); diff --git a/packages/coding-agent/test/agent-session-eager-task.test.ts b/packages/coding-agent/test/agent-session-eager-task.test.ts index e3b5c3e73..633f5a554 100644 --- a/packages/coding-agent/test/agent-session-eager-task.test.ts +++ b/packages/coding-agent/test/agent-session-eager-task.test.ts @@ -8,12 +8,12 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TodoTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { createAssistantMessage } from "./helpers/agent-session-setup"; +import { createAssistantMessage, createInMemoryAuthStorage } from "./helpers/agent-session-setup"; type ObservedPromptCall = { toolChoice: string | undefined; @@ -80,17 +80,17 @@ describe("AgentSession eager task prelude", () => { tempDir.removeSync(); }); - async function createHarness( + function createHarness( settingsOverride: Record = {}, agentId?: string, taskWireName?: string, agentKind?: "main" | "sub", - ): Promise { + ): Harness { const observedCalls: ObservedPromptCall[] = []; const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${harnesses.length}.db`)); + const authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${harnesses.length}.yml`)); const settings = Settings.isolated({ @@ -186,7 +186,7 @@ describe("AgentSession eager task prelude", () => { } it("prepends a hidden eager task reminder without forcing task or repeating the prompt text", async () => { - const { session, observedCalls } = await createHarness(); + const { session, observedCalls } = createHarness(); await session.prompt("refactor the parser across modules"); @@ -201,7 +201,7 @@ describe("AgentSession eager task prelude", () => { }); it("skips eager task prelude for prompts ending with a question mark", async () => { - const { session, observedCalls } = await createHarness(); + const { session, observedCalls } = createHarness(); await session.prompt("should I refactor the parser?"); @@ -211,7 +211,7 @@ describe("AgentSession eager task prelude", () => { }); it("skips eager task prelude for prompts ending with an exclamation mark", async () => { - const { session, observedCalls } = await createHarness(); + const { session, observedCalls } = createHarness(); await session.prompt("refactor the parser now!"); @@ -221,7 +221,7 @@ describe("AgentSession eager task prelude", () => { }); it("skips eager task prelude for subsequent user messages", async () => { - const { session, observedCalls } = await createHarness(); + const { session, observedCalls } = createHarness(); await session.prompt("refactor the parser across modules"); expect(observedCalls).toHaveLength(1); @@ -240,7 +240,7 @@ describe("AgentSession eager task prelude", () => { }); it("skips eager task prelude when task.eager is disabled", async () => { - const { session, observedCalls } = await createHarness({ "task.eager": "default" }); + const { session, observedCalls } = createHarness({ "task.eager": "default" }); await session.prompt("refactor the parser across modules"); @@ -250,7 +250,7 @@ describe("AgentSession eager task prelude", () => { }); it("skips eager task prelude when task.eager is preferred (prompt section only, no reminder)", async () => { - const { session, observedCalls } = await createHarness({ "task.eager": "preferred" }); + const { session, observedCalls } = createHarness({ "task.eager": "preferred" }); await session.prompt("refactor the parser across modules"); @@ -260,7 +260,7 @@ describe("AgentSession eager task prelude", () => { }); it("skips eager task prelude for subagent sessions", async () => { - const { session, observedCalls } = await createHarness({}, "SubAgent", undefined, "sub"); + const { session, observedCalls } = createHarness({}, "SubAgent", undefined, "sub"); await session.prompt("refactor the parser across modules"); @@ -270,7 +270,7 @@ describe("AgentSession eager task prelude", () => { }); it("prepends eager task prelude for a main session with a custom agent id", async () => { - const { session, observedCalls } = await createHarness({}, "Alice", undefined, "main"); + const { session, observedCalls } = createHarness({}, "Alice", undefined, "main"); await session.prompt("refactor the parser across modules"); @@ -279,7 +279,7 @@ describe("AgentSession eager task prelude", () => { }); it("prepends both todo and task preludes when both are eager, keeping the forced todo choice", async () => { - const { session, observedCalls } = await createHarness({ + const { session, observedCalls } = createHarness({ "todo.enabled": true, "todo.eager": "always", "todo.reminders": false, @@ -299,7 +299,7 @@ describe("AgentSession eager task prelude", () => { }); it("renders the task tool's wire name in the eager reminder", async () => { - const { session, observedCalls } = await createHarness({}, undefined, "delegate"); + const { session, observedCalls } = createHarness({}, undefined, "delegate"); await session.prompt("refactor the parser across modules"); diff --git a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts index a57ae6039..1fbee1f9b 100644 --- a/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-empty-stop-guard.test.ts @@ -12,7 +12,7 @@ import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; +import { TempDir, withTimeout } from "@oh-my-pi/pi-utils"; const recordToolSchema = type({ value: type("string") }); @@ -172,12 +172,7 @@ function reminderMessages(messages: AgentMessage[]): AgentMessage[] { } async function expectPromptCompletes(prompt: Promise): Promise { - await Promise.race([ - prompt, - Bun.sleep(1_000).then(() => { - throw new Error("Expected session prompt to settle after empty-stop retry cap"); - }), - ]); + await withTimeout(prompt, 1_000, "Expected session prompt to settle after empty-stop retry cap"); } afterEach(async () => { diff --git a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts index d9dd6814d..886e3165a 100644 --- a/packages/coding-agent/test/agent-session-force-tool-choice.test.ts +++ b/packages/coding-agent/test/agent-session-force-tool-choice.test.ts @@ -7,10 +7,11 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; let tempDir: TempDir; let authStorage: AuthStorage | undefined; @@ -18,12 +19,12 @@ let session: AgentSession; let sessionManager: SessionManager; let mock: MockModel; -beforeEach(async () => { +beforeEach(() => { tempDir = TempDir.createSync("@pi-agent-session-force-tool-"); const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); const settings = Settings.isolated({ "compaction.enabled": false }); diff --git a/packages/coding-agent/test/agent-session-fresh.test.ts b/packages/coding-agent/test/agent-session-fresh.test.ts index aa294d743..45921f165 100644 --- a/packages/coding-agent/test/agent-session-fresh.test.ts +++ b/packages/coding-agent/test/agent-session-fresh.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import * as path from "node:path"; import { Agent, AppendOnlyContextManager } from "@oh-my-pi/pi-agent-core"; import type { ProviderSessionState } from "@oh-my-pi/pi-ai"; @@ -16,6 +16,20 @@ interface FreshHarness { } const cleanup: Array<() => Promise> = []; +let sharedDir: TempDir; +let authStorage: AuthStorage; +let modelRegistry: ModelRegistry; + +beforeAll(async () => { + sharedDir = TempDir.createSync("@pi-agent-session-fresh-shared-"); + authStorage = await AuthStorage.create(path.join(sharedDir.path(), "auth.db")); + modelRegistry = new ModelRegistry(authStorage, path.join(sharedDir.path(), "models.yml")); +}); + +afterAll(() => { + authStorage.close(); + sharedDir.removeSync(); +}); afterEach(async () => { while (cleanup.length > 0) { @@ -26,8 +40,6 @@ afterEach(async () => { async function createFreshHarness(): Promise { const tempDir = TempDir.createSync("@pi-agent-session-fresh-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); const sessionManager = SessionManager.create(tempDir.path(), path.join(tempDir.path(), "sessions")); const agent = new Agent({ initialState: { @@ -44,7 +56,6 @@ async function createFreshHarness(): Promise { }); cleanup.push(async () => { await session.dispose(); - authStorage.close(); tempDir.removeSync(); }); return { agent, session, sessionManager }; diff --git a/packages/coding-agent/test/agent-session-gemini-header-interrupt.test.ts b/packages/coding-agent/test/agent-session-gemini-header-interrupt.test.ts index d2eed8c2e..3d5a0d1cc 100644 --- a/packages/coding-agent/test/agent-session-gemini-header-interrupt.test.ts +++ b/packages/coding-agent/test/agent-session-gemini-header-interrupt.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { @@ -138,14 +138,21 @@ function successStream(model: Model, text: string): AssistantMessageEventSt } describe("AgentSession Gemini header-runaway interrupt", () => { - let tempDir: TempDir; + let sharedDir: TempDir; let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; let session: AgentSession | undefined; - beforeEach(async () => { - tempDir = TempDir.createSync("@pi-gemini-header-interrupt-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + beforeAll(async () => { + sharedDir = TempDir.createSync("@pi-gemini-header-interrupt-shared-"); + authStorage = await AuthStorage.create(path.join(sharedDir.path(), "auth.db")); authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key"); + modelRegistry = new ModelRegistry(authStorage); + }); + + afterAll(() => { + authStorage.close(); + sharedDir.removeSync(); }); afterEach(async () => { @@ -153,8 +160,6 @@ describe("AgentSession Gemini header-runaway interrupt", () => { await session.dispose(); session = undefined; } - authStorage.close(); - tempDir.removeSync(); vi.restoreAllMocks(); }); @@ -164,7 +169,6 @@ describe("AgentSession Gemini header-runaway interrupt", () => { modelId = "google/gemini-3.5-flash", ): void { const model = createMockModel({ provider: "openrouter", id: modelId }).model; - const modelRegistry = new ModelRegistry(authStorage); const agent = new Agent({ getApiKey: requestedModel => `${requestedModel.provider}-test-key`, initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, diff --git a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts index 5c253f909..88922785d 100644 --- a/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-goal-midrun-compaction.test.ts @@ -44,6 +44,18 @@ function highUsage(input: number) { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } +// These tests await real cross-pipeline concurrency signals; fake timers cannot +// drive those queues. Keep a failure-only watchdog, and cancel it as soon as +// the signal wins so successful cases never leave a wall-clock delay behind. +async function raceWithTimeout(promise: Promise, timeoutMs: number, timeoutValue: F): Promise { + const timeout = Promise.withResolvers(); + const timer = setTimeout(() => timeout.resolve(timeoutValue), timeoutMs); + try { + return await Promise.race([promise, timeout.promise]); + } finally { + clearTimeout(timer); + } +} describe("AgentSession mid-run threshold compaction", () => { let tempDir: TempDir; @@ -252,22 +264,25 @@ describe("AgentSession mid-run threshold compaction", () => { const compactSpy = mockCompaction("SHOULD-NOT-RUN"); const prompt = session.prompt("work below the maintenance threshold"); - const messageEndOutcome = await Promise.race([ + const messageEndOutcome = await raceWithTimeout( messageEndEntered.promise.then(() => "entered" as const), - Bun.sleep(2_000).then(() => "blocked" as const), - ]); + 2_000, + "blocked" as const, + ); const providerOutcome = messageEndOutcome === "entered" - ? await Promise.race([ + ? await raceWithTimeout( nextProviderCall.promise.then(() => "dispatched" as const), - Bun.sleep(2_000).then(() => "blocked" as const), - ]) + 2_000, + "blocked" as const, + ) : "blocked"; releaseMessageEnd.resolve(); - const promptOutcome = await Promise.race([ + const promptOutcome = await raceWithTimeout( prompt.then(() => "settled" as const), - Bun.sleep(2_000).then(() => "blocked" as const), - ]); + 2_000, + "blocked" as const, + ); expect(messageEndOutcome).toBe("entered"); expect(providerOutcome).toBe("dispatched"); @@ -343,27 +358,31 @@ describe("AgentSession mid-run threshold compaction", () => { ); const prompt = session.prompt("keep notification mutations out of live context"); - const toolResultHookOutcome = await Promise.race([ + const toolResultHookOutcome = await raceWithTimeout( toolResultHookEntered.promise.then(() => "entered" as const), - Bun.sleep(2_000).then(() => "blocked" as const), - ]); + 2_000, + "blocked" as const, + ); const secondModelCallOutcome = toolResultHookOutcome === "entered" - ? await Promise.race([ + ? await raceWithTimeout( secondModelCallEntered.promise.then(() => "dispatched" as const), - Bun.sleep(2_000).then(() => "blocked" as const), - ]) + 2_000, + "blocked" as const, + ) : "blocked"; releaseMutation.resolve(); - const mutationOutcome = await Promise.race([ + const mutationOutcome = await raceWithTimeout( mutationApplied.promise.then(() => "applied" as const), - Bun.sleep(2_000).then(() => "blocked" as const), - ]); + 2_000, + "blocked" as const, + ); releaseSecondModelCall.resolve(); - const promptOutcome = await Promise.race([ + const promptOutcome = await raceWithTimeout( prompt.then(() => "settled" as const), - Bun.sleep(2_000).then(() => "blocked" as const), - ]); + 2_000, + "blocked" as const, + ); expect(toolResultHookOutcome).toBe("entered"); expect(secondModelCallOutcome).toBe("dispatched"); diff --git a/packages/coding-agent/test/agent-session-handoff.test.ts b/packages/coding-agent/test/agent-session-handoff.test.ts index 4bb0feeaa..bb93ef3a1 100644 --- a/packages/coding-agent/test/agent-session-handoff.test.ts +++ b/packages/coding-agent/test/agent-session-handoff.test.ts @@ -20,7 +20,7 @@ import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; -import { TempDir } from "@oh-my-pi/pi-utils"; +import { TempDir, withTimeout } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; const HANDOFF_SECRET = "HANDOFF_SECRET_TOKEN_12345"; @@ -1712,10 +1712,11 @@ describe("AgentSession handoff", () => { expect(session.isGeneratingHandoff).toBe(true); // dispose must NOT wait for the LLM call to resolve on its own — it must abort it. - const disposed = Promise.race([ + const disposed = withTimeout( session.dispose().then(() => "disposed" as const), - Bun.sleep(2_000).then(() => "timeout" as const), - ]); + 2_000, + "Timed out waiting for session disposal", + ); await expect(disposed).resolves.toBe("disposed"); // Releasing after the fact must not leak into other tests. diff --git a/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts b/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts index 96eed0867..1f2d8ceeb 100644 --- a/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts @@ -8,7 +8,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { ExtensionRuntime, loadExtensionFromFactory } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/runner"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { type CustomMessage, convertToLlm, @@ -19,6 +19,7 @@ import type { SessionEntry } from "@oh-my-pi/pi-coding-agent/session/session-ent import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; const REASONING_TEXT = "I have partly reasoned through the implementation and should preserve this."; const VISIBLE_TEXT = "visible interrupted text"; @@ -97,9 +98,9 @@ describe("AgentSession interrupted thinking persistence", () => { let authStorage: AuthStorage; let session: AgentSession | undefined; - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@pi-interrupted-thinking-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key"); }); diff --git a/packages/coding-agent/test/agent-session-magic-keywords.test.ts b/packages/coding-agent/test/agent-session-magic-keywords.test.ts index e38fc8fcb..e81a3492a 100644 --- a/packages/coding-agent/test/agent-session-magic-keywords.test.ts +++ b/packages/coding-agent/test/agent-session-magic-keywords.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -32,12 +32,11 @@ const mockEvalTool: AgentTool = { }; async function createMagicKeywordSession( - root: string, + modelRegistry: ModelRegistry, tools: AgentTool[] = [mockTaskTool, mockEvalTool], ): Promise<{ session: AgentSession; settings: Settings; - authStorage: AuthStorage; }> { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected bundled Claude Sonnet model"); @@ -50,9 +49,6 @@ async function createMagicKeywordSession( thinkingLevel: Effort.High, }, }); - const authStorage = await AuthStorage.create(path.join(root, "auth.db")); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(root, "models.yml")); const settings = Settings.isolated(); const session = new AgentSession({ agent, @@ -60,31 +56,36 @@ async function createMagicKeywordSession( settings, modelRegistry, }); - return { session, settings, authStorage }; + return { session, settings }; } describe("AgentSession magic keyword settings", () => { - let root: string; let session: AgentSession | undefined; - let authStorage: AuthStorage | undefined; + let authStorage: AuthStorage; + let authRoot: string; + let modelRegistry: ModelRegistry; - beforeEach(async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-magic-keywords-")); + beforeAll(async () => { + authRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-magic-keywords-auth-")); + authStorage = await AuthStorage.create(path.join(authRoot, "auth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage, path.join(authRoot, "models.yml")); + }); + + afterAll(async () => { + authStorage.close(); + await removeWithRetries(authRoot); }); afterEach(async () => { vi.restoreAllMocks(); if (session) await session.dispose(); - authStorage?.close(); - await removeWithRetries(root).catch(() => undefined); session = undefined; - authStorage = undefined; }); it("does not append magic keyword notices when disabled", async () => { - const created = await createMagicKeywordSession(root); + const created = await createMagicKeywordSession(modelRegistry); session = created.session; - authStorage = created.authStorage; created.settings.set("magicKeywords.enabled", false); const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); @@ -95,9 +96,8 @@ describe("AgentSession magic keyword settings", () => { }); it("honors non-ultrathink per-keyword notice toggles", async () => { - const created = await createMagicKeywordSession(root); + const created = await createMagicKeywordSession(modelRegistry); session = created.session; - authStorage = created.authStorage; created.settings.set("magicKeywords.orchestrate", false); created.settings.set("magicKeywords.workflow", false); const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); @@ -109,9 +109,8 @@ describe("AgentSession magic keyword settings", () => { }); it("still appends enabled non-ultrathink notices", async () => { - const created = await createMagicKeywordSession(root); + const created = await createMagicKeywordSession(modelRegistry); session = created.session; - authStorage = created.authStorage; const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); await session.prompt("please orchestrate and workflowz this"); @@ -124,9 +123,8 @@ describe("AgentSession magic keyword settings", () => { }); it("renders the eval-specific workflowz notice", async () => { - const created = await createMagicKeywordSession(root); + const created = await createMagicKeywordSession(modelRegistry); session = created.session; - authStorage = created.authStorage; created.settings.set("task.batch", false); const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); @@ -145,9 +143,8 @@ describe("AgentSession magic keyword settings", () => { }); it("updates the workflowz notice when scout is disabled during the session", async () => { - const created = await createMagicKeywordSession(root); + const created = await createMagicKeywordSession(modelRegistry); session = created.session; - authStorage = created.authStorage; created.settings.set("task.disabledAgents", ["scout"]); const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); @@ -160,9 +157,8 @@ describe("AgentSession magic keyword settings", () => { }); it("skips workflowz notice when the task tool is inactive", async () => { - const created = await createMagicKeywordSession(root, []); + const created = await createMagicKeywordSession(modelRegistry, []); session = created.session; - authStorage = created.authStorage; const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); await session.prompt("please workflowz this"); @@ -172,9 +168,8 @@ describe("AgentSession magic keyword settings", () => { }); it("skips orchestrate notice when the task tool is inactive", async () => { - const created = await createMagicKeywordSession(root, []); + const created = await createMagicKeywordSession(modelRegistry, []); session = created.session; - authStorage = created.authStorage; const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); await session.prompt("please orchestrate this"); @@ -184,9 +179,8 @@ describe("AgentSession magic keyword settings", () => { }); it("skips workflowz notice when the eval tool is inactive", async () => { - const created = await createMagicKeywordSession(root, [mockTaskTool]); + const created = await createMagicKeywordSession(modelRegistry, [mockTaskTool]); session = created.session; - authStorage = created.authStorage; const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); await session.prompt("please workflowz this"); @@ -196,9 +190,8 @@ describe("AgentSession magic keyword settings", () => { }); it("does not use a disabled ultrathink keyword to force auto thinking", async () => { - const created = await createMagicKeywordSession(root); + const created = await createMagicKeywordSession(modelRegistry); session = created.session; - authStorage = created.authStorage; created.settings.set("magicKeywords.ultrathink", false); vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); const classifierSpy = vi.spyOn(autoThinkingClassifier, "classifyDifficulty").mockResolvedValue(Effort.Low); @@ -212,9 +205,8 @@ describe("AgentSession magic keyword settings", () => { }); it("queues the magic-keyword notice before the user message", async () => { - const created = await createMagicKeywordSession(root); + const created = await createMagicKeywordSession(modelRegistry); session = created.session; - authStorage = created.authStorage; const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined); await session.prompt("ultrathink do the thing"); diff --git a/packages/coding-agent/test/agent-session-manual-retry.test.ts b/packages/coding-agent/test/agent-session-manual-retry.test.ts index a3a64c94e..0ab46818e 100644 --- a/packages/coding-agent/test/agent-session-manual-retry.test.ts +++ b/packages/coding-agent/test/agent-session-manual-retry.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; @@ -23,11 +23,13 @@ describe("AgentSession manual retry", () => { let tempDir: TempDir; let authStorage: AuthStorage; let session: AgentSession | undefined; + let modelRegistry: ModelRegistry; - beforeEach(async () => { + beforeAll(async () => { tempDir = TempDir.createSync("@pi-manual-retry-"); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage); }); afterEach(async () => { @@ -35,6 +37,9 @@ describe("AgentSession manual retry", () => { await session.dispose(); session = undefined; } + }); + + afterAll(() => { authStorage.close(); tempDir.removeSync(); }); @@ -65,7 +70,7 @@ describe("AgentSession manual retry", () => { agent, sessionManager: SessionManager.inMemory(), settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }), - modelRegistry: new ModelRegistry(authStorage), + modelRegistry, }); session.subscribe(() => {}); @@ -104,7 +109,7 @@ describe("AgentSession manual retry", () => { agent, sessionManager: SessionManager.inMemory(), settings: Settings.isolated({ "compaction.enabled": false }), - modelRegistry: new ModelRegistry(authStorage), + modelRegistry, }); session.subscribe(() => {}); @@ -150,7 +155,7 @@ describe("AgentSession manual retry", () => { agent, sessionManager: SessionManager.inMemory(), settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }), - modelRegistry: new ModelRegistry(authStorage), + modelRegistry, }); session.subscribe(() => {}); @@ -205,7 +210,7 @@ describe("AgentSession manual retry", () => { agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }), - modelRegistry: new ModelRegistry(authStorage), + modelRegistry, }); session.subscribe(() => {}); @@ -247,7 +252,7 @@ describe("AgentSession manual retry", () => { agent: reopenedAgent, sessionManager: reopenedManager, settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }), - modelRegistry: new ModelRegistry(authStorage), + modelRegistry, }); session.subscribe(() => {}); diff --git a/packages/coding-agent/test/agent-session-memory-backend.test.ts b/packages/coding-agent/test/agent-session-memory-backend.test.ts index d6c6ba655..9f5ea994d 100644 --- a/packages/coding-agent/test/agent-session-memory-backend.test.ts +++ b/packages/coding-agent/test/agent-session-memory-backend.test.ts @@ -8,10 +8,11 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { getMnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/state"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { resetMemoryForTests } from "@oh-my-pi/pi-mnemopi"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; function createTool(name: string): AgentTool { return { @@ -31,9 +32,9 @@ describe("AgentSession memory backend lifecycle", () => { let settings: Settings; let tempDir: TempDir; - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@memory-backend-lifecycle-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); settings = Settings.isolated({ "compaction.enabled": false, diff --git a/packages/coding-agent/test/agent-session-message-pipeline.test.ts b/packages/coding-agent/test/agent-session-message-pipeline.test.ts index fe7d378a9..fcf3823e0 100644 --- a/packages/coding-agent/test/agent-session-message-pipeline.test.ts +++ b/packages/coding-agent/test/agent-session-message-pipeline.test.ts @@ -199,7 +199,7 @@ describe("AgentSession message pipeline", () => { "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", "base64", ); - const webpData = Buffer.from(await new Bun.Image(seed).resize(200, 200).webp({ quality: 90 }).bytes()).toBase64(); + const webpData = Buffer.from(await new Bun.Image(seed).resize(2, 2).webp({ quality: 90 }).bytes()).toBase64(); const historicalImage: ImageContent = { type: "image", data: webpData, diff --git a/packages/coding-agent/test/agent-session-mid-turn-compaction-dead-end.test.ts b/packages/coding-agent/test/agent-session-mid-turn-compaction-dead-end.test.ts index da2969d02..6f1af57c2 100644 --- a/packages/coding-agent/test/agent-session-mid-turn-compaction-dead-end.test.ts +++ b/packages/coding-agent/test/agent-session-mid-turn-compaction-dead-end.test.ts @@ -67,7 +67,7 @@ describe("AgentSession mid-turn compaction dead-end", () => { const extensionPath = path.join(extensionsDir, "compaction-short-circuit.ts"); const extensionLines = ["export default function(pi) {"]; if (options.delayMessageEndPersistence) { - extensionLines.push('\tpi.on("message_end", async () => {', "\t\tawait Bun.sleep(50);", "\t});"); + extensionLines.push('\tpi.on("message_end", async () => {', "\t\tawait Promise.resolve();", "\t});"); } if (options.shortCircuitCompaction) { extensionLines.push( diff --git a/packages/coding-agent/test/agent-session-new-session-queued-steer.test.ts b/packages/coding-agent/test/agent-session-new-session-queued-steer.test.ts index beabc5f9a..3d4df7ae0 100644 --- a/packages/coding-agent/test/agent-session-new-session-queued-steer.test.ts +++ b/packages/coding-agent/test/agent-session-new-session-queued-steer.test.ts @@ -50,7 +50,6 @@ describe("newSession() atomic boundary vs queued hidden steer", () => { await session?.dispose(); } finally { for (const authStorage of authStorages.splice(0)) authStorage.close(); - await Bun.sleep(0); await tempDir?.remove(); } }); diff --git a/packages/coding-agent/test/agent-session-persisted-keys-cache.test.ts b/packages/coding-agent/test/agent-session-persisted-keys-cache.test.ts index 693b2ec77..9e0e27354 100644 --- a/packages/coding-agent/test/agent-session-persisted-keys-cache.test.ts +++ b/packages/coding-agent/test/agent-session-persisted-keys-cache.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core"; +import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -10,28 +10,33 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; +import { removeSyncWithRetries, Snowflake, TempDir } from "@oh-my-pi/pi-utils"; import { createAssistantMessage } from "./helpers/agent-session-setup"; describe("AgentSession persistence-keys cache", () => { let session: AgentSession; let tempDir: string; let sessionManager: SessionManager; - let authStorage: AuthStorage | undefined; + let authStorage: AuthStorage; + let authDir: TempDir; + let modelRegistry: ModelRegistry; - beforeEach(async () => { + beforeAll(async () => { + authDir = TempDir.createSync("@pi-cache-auth-"); + authStorage = await AuthStorage.create(authDir.join("auth.db")); + modelRegistry = new ModelRegistry(authStorage, authDir.join("models.yml")); + }); + + afterAll(() => { + authStorage.close(); + authDir.removeSync(); + }); + + beforeEach(() => { tempDir = path.join(os.tmpdir(), `pi-cache-test-${Snowflake.next()}`); fs.mkdirSync(tempDir, { recursive: true }); - const toolSession: ToolSession = { - cwd: tempDir, - hasUI: false, - getSessionFile: () => null, - getSessionSpawns: () => "*", - settings: Settings.isolated(), - }; - const tools = await createTools(toolSession); + const tools: AgentTool[] = []; const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) { throw new Error("bundled model claude-sonnet-4-5 not found"); @@ -42,8 +47,6 @@ describe("AgentSession persistence-keys cache", () => { }); sessionManager = SessionManager.create(tempDir, tempDir); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); session = new AgentSession({ agent, @@ -59,7 +62,6 @@ describe("AgentSession persistence-keys cache", () => { if (session) { await session.dispose(); } - authStorage?.close(); if (fs.existsSync(tempDir)) { removeSyncWithRetries(tempDir); } diff --git a/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts b/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts index c37511d47..c7e0bc972 100644 --- a/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts +++ b/packages/coding-agent/test/agent-session-plan-mode-convergence.test.ts @@ -10,7 +10,7 @@ * terminal settle, bounded by PLAN_MODE_REMINDER_MAX (then yields to the * user), and either decision tool resets the counter. */ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool, type StreamFn } from "@oh-my-pi/pi-agent-core"; import { createMockModel, type MockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; @@ -23,7 +23,7 @@ import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { Snowflake, TempDir } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; import planModeReminderPrompt from "../src/prompts/system/plan-mode-tool-decision-reminder.md" with { type: "text" }; /** A stable, literal (non-templated) line of the reminder prompt, so the test @@ -77,7 +77,21 @@ interface PlanHarness { describe("AgentSession plan-mode convergence", () => { let tempDir: TempDir; let session: AgentSession | undefined; - const authStorages: AuthStorage[] = []; + let authDir: TempDir; + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; + + beforeAll(async () => { + authDir = TempDir.createSync("@pi-plan-converge-auth-"); + authStorage = await AuthStorage.create(authDir.join("auth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage, authDir.join("models.yml")); + }); + + afterAll(() => { + authStorage.close(); + authDir.removeSync(); + }); beforeEach(() => { tempDir = TempDir.createSync("@pi-plan-converge-"); @@ -88,7 +102,6 @@ describe("AgentSession plan-mode convergence", () => { await session?.dispose(); } finally { session = undefined; - for (const authStorage of authStorages.splice(0)) authStorage.close(); await tempDir?.remove(); } }); @@ -123,11 +136,6 @@ describe("AgentSession plan-mode convergence", () => { streamFn: mock.stream, }); - const authStorage = await AuthStorage.create(tempDir.join(`auth-${Snowflake.next()}.db`)); - authStorages.push(authStorage); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, tempDir.join(`models-${Snowflake.next()}.yml`)); - let advisorMock: MockModel | undefined; let advisorStreamFn: StreamFn | undefined; if (options?.advisorResponses) { diff --git a/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts b/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts index 749148b07..ed1cd785c 100644 --- a/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-plan-reference-compaction.test.ts @@ -13,7 +13,7 @@ * MUST carry the approved plan reference again (re-read from disk). */ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core"; @@ -111,8 +111,18 @@ function emitHighUsageTurn(session: AgentSession): void { describe("AgentSession approved-plan reference re-injection after compaction (issue #1246)", () => { let tempDir: TempDir; + let fixtureDir: TempDir; + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; const cleanups: Array<() => Promise> = []; + beforeAll(async () => { + fixtureDir = TempDir.createSync("@pi-agent-session-plan-ref-compaction-fixture-"); + authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir.path(), "models.yml")); + }); + beforeEach(() => { tempDir = TempDir.createSync("@pi-agent-session-plan-ref-compaction-"); cleanups.length = 0; @@ -125,6 +135,11 @@ describe("AgentSession approved-plan reference re-injection after compaction (is vi.restoreAllMocks(); }); + afterAll(() => { + authStorage.close(); + fixtureDir.removeSync(); + }); + async function createHarness(strategy: "context-full" | "snapcompact" = "context-full"): Promise { const observedCalls: ObservedPromptCall[] = []; const waiters: Array<{ @@ -140,9 +155,6 @@ describe("AgentSession approved-plan reference re-injection after compaction (is // agent-session-eager-compaction / -auto-compaction-queue. const model = { ...bundled, contextWindow: 200_000, maxTokens: 64_000 }; - const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`)); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`)); const settings = Settings.isolated({ "compaction.enabled": true, "compaction.autoContinue": true, @@ -193,10 +205,7 @@ describe("AgentSession approved-plan reference re-injection after compaction (is return promise; }; - cleanups.push(async () => { - await session.dispose(); - authStorage.close(); - }); + cleanups.push(() => session.dispose()); return { session, sessionManager, observedCalls, waitForCall }; } diff --git a/packages/coding-agent/test/agent-session-plan-reference-setup-bail.test.ts b/packages/coding-agent/test/agent-session-plan-reference-setup-bail.test.ts index 3c06e7686..f0b7caa71 100644 --- a/packages/coding-agent/test/agent-session-plan-reference-setup-bail.test.ts +++ b/packages/coding-agent/test/agent-session-plan-reference-setup-bail.test.ts @@ -18,7 +18,7 @@ * plan is delivered exactly once. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { Agent, type AgentMessage } from "@oh-my-pi/pi-agent-core"; @@ -81,8 +81,18 @@ function createAssistantResponse(text: string) { describe("AgentSession plan-reference delivery tracking (issue #4094)", () => { let tempDir: TempDir; + let fixtureDir: TempDir; + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; const cleanups: Array<() => Promise> = []; + beforeAll(async () => { + fixtureDir = TempDir.createSync("@pi-agent-session-plan-ref-setup-bail-fixture-"); + authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir.path(), "models.yml")); + }); + beforeEach(() => { tempDir = TempDir.createSync("@pi-agent-session-plan-ref-setup-bail-"); cleanups.length = 0; @@ -95,6 +105,11 @@ describe("AgentSession plan-reference delivery tracking (issue #4094)", () => { vi.restoreAllMocks(); }); + afterAll(() => { + authStorage.close(); + fixtureDir.removeSync(); + }); + async function createHarness(): Promise { const observedCalls: ObservedPromptCall[] = []; @@ -102,9 +117,6 @@ describe("AgentSession plan-reference delivery tracking (issue #4094)", () => { if (!bundled) throw new Error("Expected claude-sonnet-4-5 model to exist"); const model = { ...bundled, contextWindow: 200_000, maxTokens: 64_000 }; - const authStorage = await AuthStorage.create(path.join(tempDir.path(), `testauth-${cleanups.length}.db`)); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), `models-${cleanups.length}.yml`)); const settings = Settings.isolated({ "compaction.enabled": false, "task.eager": "off", @@ -134,10 +146,7 @@ describe("AgentSession plan-reference delivery tracking (issue #4094)", () => { session = new AgentSession({ agent, sessionManager, settings, modelRegistry }); - cleanups.push(async () => { - await session.dispose(); - authStorage.close(); - }); + cleanups.push(() => session.dispose()); return { session, sessionManager, observedCalls }; } diff --git a/packages/coding-agent/test/agent-session-prewalk.test.ts b/packages/coding-agent/test/agent-session-prewalk.test.ts index 6ab6d09f2..cc639bbac 100644 --- a/packages/coding-agent/test/agent-session-prewalk.test.ts +++ b/packages/coding-agent/test/agent-session-prewalk.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; @@ -9,13 +9,14 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { executeBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/builtin-registry"; import type { TuiSlashCommandRuntime } from "@oh-my-pi/pi-coding-agent/slash-commands/types"; import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; /** * Prewalk: one-way switch from the starting model to a fast/cheap target @@ -29,16 +30,22 @@ import { TempDir } from "@oh-my-pi/pi-utils"; describe("AgentSession prewalk", () => { let tempDir: TempDir; let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; let session: AgentSession | undefined; - beforeEach(async () => { + beforeAll(() => { tempDir = TempDir.createSync("@pi-prewalk-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); }); afterEach(async () => { if (session) await session.dispose(); + session = undefined; + }); + + afterAll(() => { authStorage.close(); tempDir.removeSync(); }); @@ -103,7 +110,6 @@ describe("AgentSession prewalk", () => { it("prewalks at the first edit/write after the todo gate opens; bash and todo don't trigger", async () => { const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); // Turn 1: read-only. Turn 2: bash is excluded. Turn 3: todo opens the gate. // Turn 4: write is the first post-todo edit/write, so it switches. @@ -156,7 +162,6 @@ describe("AgentSession prewalk", () => { it("an edit before any todo call does not switch while a todo tool exists; the next edit after todo does", async () => { const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); // Turn 1: exploration. Turn 2: write while the gate is closed. // Turn 3: todo opens the gate. Turn 4: write switches. @@ -209,7 +214,7 @@ describe("AgentSession prewalk", () => { it("keeps the todo gate closed after a failed todo call", async () => { const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const failingTodoTool: AgentTool = { ...todoTool, async execute() { @@ -262,7 +267,6 @@ describe("AgentSession prewalk", () => { // was written. The safety net must force one more turn. const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); const mock = createMockModel({ responses: [ @@ -319,7 +323,6 @@ describe("AgentSession prewalk", () => { // reply end the run. No mock fallback: a stray extra turn rejects. const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); // Turn 1: record (nudge injected after). Turn 2: bash — not an action // tool. Turn 3: prose — the single continuation fires. Turn 4: prose @@ -374,7 +377,6 @@ describe("AgentSession prewalk", () => { it("does not switch on a read-only xd:// device dispatched through write (issue #7312)", async () => { const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); // A read-only lsp navigation is dispatched as `write xd://lsp`; the write // result carries the wrapped tool's read tier. Like a bash step, it must @@ -439,7 +441,6 @@ describe("AgentSession prewalk", () => { it("switches on a write-tier xd:// device dispatched through write (issue #7312)", async () => { const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); // An lsp rename is a write-tier device call — it must arm the hand-off // just like a direct edit/write: the write turn stays on the strong model, @@ -507,7 +508,6 @@ describe("AgentSession prewalk", () => { // cannot end the run before edit/write. const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); // Turn 1: read-only (nudge injected after). Turn 2: prose plan — // bridged. Turn 3: todo — gate opens and re-arms the net. Turn 4: @@ -567,7 +567,6 @@ describe("AgentSession prewalk", () => { // cannot call an inactive tool — and prewalk never fired. const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); // Turn 1: read-only (nudge injected after). Turn 2: write — first // edit/write must switch immediately; no todo call is possible. @@ -613,7 +612,7 @@ describe("AgentSession prewalk", () => { it("armPrewalk (the /prewalk slash command) pre-arms the switch for the very next edit/write", async () => { const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const sessionManager = SessionManager.inMemory(); sessionManager.appendCustomMessageEntry( "prewalk-plan", @@ -676,7 +675,7 @@ describe("AgentSession prewalk", () => { it("armPrewalk rejects a same-model same-effort no-op", async () => { const model = modelOrThrow("claude-sonnet-4-5"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const mock = createMockModel({ responses: [{ content: ["status only"] }] }); const agent = new Agent({ getApiKey: () => "test-key", @@ -712,7 +711,7 @@ describe("AgentSession prewalk", () => { it("/prewalk reports success only when the requested arm remains active", async () => { const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const settings = Settings.isolated({ "compaction.enabled": false }); const sessionManager = SessionManager.inMemory(); const agent = new Agent({ @@ -766,7 +765,7 @@ describe("AgentSession prewalk", () => { it("requires a fresh todo before a later explicit prewalk can hand off", async () => { const primary = modelOrThrow("claude-sonnet-4-5"); const target = modelOrThrow("claude-sonnet-4-6"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const mock = createMockModel({ responses: [ toolCall("first-todo", "todo"), @@ -826,7 +825,6 @@ describe("AgentSession prewalk", () => { // as a no-op. On a reasoning model the effort is the bulk of the cost, so // this must still switch. const model = modelOrThrow("claude-sonnet-4-5"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); // todo excluded from the active slate → the gate opens; record then write. const mock = createMockModel({ @@ -866,7 +864,6 @@ describe("AgentSession prewalk", () => { it("emits a notice when the prewalk target is a genuine no-op", async () => { // Same model and same effective thinking level: no state change. const model = modelOrThrow("claude-sonnet-4-5"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); const mock = createMockModel({ responses: [toolCall("t1", "record"), toolCall("t2", "write"), { content: ["done"] }], @@ -909,7 +906,6 @@ describe("AgentSession prewalk", () => { // A model capped at high resolves an xhigh target back to high. // The equal effective settings must be recognized as a no-op. const model = modelOrThrow("claude-sonnet-4-6"); // supported efforts cap at high - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); const mock = createMockModel({ responses: [toolCall("t1", "record"), toolCall("t2", "write"), { content: ["done"] }], @@ -952,7 +948,6 @@ describe("AgentSession prewalk", () => { // per-turn classification, so this is a real change and must switch — not // collapse to a no-op. const model = modelOrThrow("claude-sonnet-4-5"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); const mock = createMockModel({ responses: [toolCall("t1", "record"), toolCall("t2", "write"), { content: ["done"] }], diff --git a/packages/coding-agent/test/agent-session-queued-steer-delivery.test.ts b/packages/coding-agent/test/agent-session-queued-steer-delivery.test.ts index f043ee940..010d70f97 100644 --- a/packages/coding-agent/test/agent-session-queued-steer-delivery.test.ts +++ b/packages/coding-agent/test/agent-session-queued-steer-delivery.test.ts @@ -11,7 +11,7 @@ * post-prompt recovery, but the loop is already done) must be drained when * the session settles. */ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -36,8 +36,18 @@ interface SteerHarness { describe("AgentSession queued steer delivery", () => { let tempDir: string; + let fixtureDir: string; + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; let session: AgentSession; - const authStorages: AuthStorage[] = []; + + beforeAll(async () => { + fixtureDir = path.join(os.tmpdir(), `pi-steer-strand-fixture-${Snowflake.next()}`); + fs.mkdirSync(fixtureDir, { recursive: true }); + authStorage = await AuthStorage.create(path.join(fixtureDir, "auth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir, "models.yml")); + }); beforeEach(() => { tempDir = path.join(os.tmpdir(), `pi-steer-strand-${Snowflake.next()}`); @@ -46,12 +56,14 @@ describe("AgentSession queued steer delivery", () => { afterEach(async () => { await session?.dispose(); - for (const authStorage of authStorages.splice(0)) { - authStorage.close(); - } removeSyncWithRetries(tempDir); }); + afterAll(() => { + authStorage.close(); + removeSyncWithRetries(fixtureDir); + }); + async function createSession(responses: MockResponse[]): Promise { const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; const mock = createMockModel({ responses }); @@ -62,10 +74,7 @@ describe("AgentSession queued steer delivery", () => { }); const sessionManager = SessionManager.inMemory(); const settings = Settings.isolated({ "compaction.enabled": false }); - const authStorage = await AuthStorage.create(path.join(tempDir, `auth-${Snowflake.next()}.db`)); - authStorages.push(authStorage); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + session = new AgentSession({ agent, sessionManager, settings, modelRegistry }); return { session, sessionManager, mock }; } diff --git a/packages/coding-agent/test/agent-session-retry-cap.test.ts b/packages/coding-agent/test/agent-session-retry-cap.test.ts index 03ba3e34b..3b2b27b54 100644 --- a/packages/coding-agent/test/agent-session-retry-cap.test.ts +++ b/packages/coding-agent/test/agent-session-retry-cap.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; @@ -64,14 +64,24 @@ describe("AgentSession retry delay cap", () => { let modelRegistry: ModelRegistry; let session: AgentSession | undefined; - beforeEach(async () => { + beforeAll(async () => { tempDir = TempDir.createSync("@pi-retry-cap-"); authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + }); + + beforeEach(async () => { // A live env var now overrides a stored static api_key; these tests rotate stored Anthropic // credentials, so neutralize env resolution (ignores every provider's ambient env key). vi.spyOn(aiStream, "getEnvApiKey").mockReturnValue(undefined); + for (const provider of ["anthropic", "openai-codex"]) { + await authStorage.remove(provider); + } + for (const provider of ["anthropic", "openai", "openai-codex", "openrouter", "cursor"]) { + authStorage.removeRuntimeApiKey(provider); + } authStorage.setRuntimeApiKey("anthropic", "anthropic-test-key"); - modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + modelRegistry.clearSuppressedSelectors(); }); afterEach(async () => { @@ -81,6 +91,9 @@ describe("AgentSession retry delay cap", () => { } unregisterCustomApis(RETRY_CAP_MOCK_API_SOURCE); vi.restoreAllMocks(); + }); + + afterAll(() => { authStorage.close(); tempDir.removeSync(); }); diff --git a/packages/coding-agent/test/agent-session-retry-recovery.test.ts b/packages/coding-agent/test/agent-session-retry-recovery.test.ts index 705c8ef4d..c4256e9a3 100644 --- a/packages/coding-agent/test/agent-session-retry-recovery.test.ts +++ b/packages/coding-agent/test/agent-session-retry-recovery.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; @@ -119,16 +119,24 @@ function successfulAssistantEntry(sessionManager: SessionManager, text: string): describe("AgentSession retry recovery", () => { let tempDir: TempDir; + let fixtureDir: TempDir; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; let sessions: AgentSession[]; let managers: SessionManager[]; + beforeAll(async () => { + fixtureDir = TempDir.createSync("@pi-retry-recovery-fixture-"); + authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db")); + modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir.path(), "models.yml")); + }); + beforeEach(async () => { tempDir = TempDir.createSync("@pi-retry-recovery-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); vi.spyOn(aiStream, "getEnvApiKey").mockReturnValue(undefined); - modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + await authStorage.remove("anthropic"); + authStorage.removeRuntimeApiKey("anthropic"); + modelRegistry.clearSuppressedSelectors(); sessions = []; managers = []; }); @@ -140,11 +148,15 @@ describe("AgentSession retry recovery", () => { for (const manager of managers.splice(0).reverse()) { await manager.close(); } - authStorage.close(); tempDir.removeSync(); vi.restoreAllMocks(); }); + afterAll(() => { + authStorage.close(); + fixtureDir.removeSync(); + }); + async function runCredentialRecovery(): Promise { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) { diff --git a/packages/coding-agent/test/agent-session-role-thinking.test.ts b/packages/coding-agent/test/agent-session-role-thinking.test.ts index 481f25093..5b6cedd11 100644 --- a/packages/coding-agent/test/agent-session-role-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-role-thinking.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { Effort } from "@oh-my-pi/pi-ai"; @@ -19,9 +19,19 @@ import { createAssistantMessage } from "./helpers/agent-session-setup"; describe("AgentSession role model thinking behavior", () => { let tempDir: TempDir; + let fixtureDir: TempDir; + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; let session: AgentSession; let sessionSettings: Settings; - const authStorages: AuthStorage[] = []; + + beforeAll(async () => { + fixtureDir = TempDir.createSync("@pi-role-thinking-fixture-"); + authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + authStorage.setRuntimeApiKey("openai", "test-key"); + modelRegistry = new ModelRegistry(authStorage, path.join(fixtureDir.path(), "models.yml")); + }); beforeEach(() => { tempDir = TempDir.createSync("@pi-role-thinking-"); @@ -32,12 +42,14 @@ describe("AgentSession role model thinking behavior", () => { if (session) { await session.dispose(); } - for (const authStorage of authStorages.splice(0)) { - authStorage.close(); - } tempDir.removeSync(); }); + afterAll(() => { + authStorage.close(); + fixtureDir.removeSync(); + }); + function getAnthropicModelOrThrow(id: string) { const model = getBundledModel("anthropic", id); if (!model) throw new Error(`Expected anthropic model ${id} to exist`); @@ -60,14 +72,11 @@ describe("AgentSession role model thinking behavior", () => { thinkingLevel: options.initialThinkingLevel, }, }); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const runtimeApiKeys = options.runtimeApiKeys ?? {}; for (const provider in runtimeApiKeys) { authStorage.setRuntimeApiKey(provider, runtimeApiKeys[provider]); } - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); sessionSettings = Settings.isolated(); for (const [role, modelRoleValue] of Object.entries(options.modelRoles)) { @@ -220,10 +229,7 @@ describe("AgentSession role model thinking behavior", () => { thinkingLevel: undefined, }, }); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-non-xhigh.db")); - authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-non-xhigh.yml")); sessionSettings = Settings.isolated(); session = new AgentSession({ @@ -250,10 +256,7 @@ describe("AgentSession role model thinking behavior", () => { thinkingLevel: undefined, }, }); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-max-clamp.db")); - authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-max-clamp.yml")); sessionSettings = Settings.isolated(); session = new AgentSession({ @@ -280,10 +283,7 @@ describe("AgentSession role model thinking behavior", () => { thinkingLevel: Effort.High, }, }); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-cycle-thinking.db")); - authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-cycle-thinking.yml")); sessionSettings = Settings.isolated(); session = new AgentSession({ @@ -330,10 +330,7 @@ describe("AgentSession role model thinking behavior", () => { thinkingLevel: Effort.XHigh, }, }); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-cycle-max.db")); - authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-cycle-max.yml")); sessionSettings = Settings.isolated(); session = new AgentSession({ @@ -388,10 +385,7 @@ describe("AgentSession role model thinking behavior", () => { thinkingLevel: resolveProvisionalAutoLevel(model), }, }); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-auto-resume.db")); - authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-auto-resume.yml")); const sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); sessionSettings = Settings.isolated(); sessionSettings.set("defaultThinkingLevel", AUTO_THINKING); @@ -434,10 +428,7 @@ describe("AgentSession role model thinking behavior", () => { thinkingLevel: resolveProvisionalAutoLevel(model), }, }); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-manual-resume.db")); - authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-manual-resume.yml")); const sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); sessionSettings = Settings.isolated(); sessionSettings.set("defaultThinkingLevel", AUTO_THINKING); @@ -480,10 +471,7 @@ describe("AgentSession role model thinking behavior", () => { thinkingLevel: resolveProvisionalAutoLevel(model), }, }); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-pin-eq.db")); - authStorages.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-pin-eq.yml")); const sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); sessionSettings = Settings.isolated(); sessionSettings.set("defaultThinkingLevel", AUTO_THINKING); @@ -640,10 +628,7 @@ describe("AgentSession role model thinking behavior", () => { thinkingLevel: undefined, }, }); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth-non-reasoning-auto.db")); - authStorages.push(authStorage); authStorage.setRuntimeApiKey("openai", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models-non-reasoning-auto.yml")); sessionSettings = Settings.isolated(); sessionSettings.set("defaultThinkingLevel", AUTO_THINKING); session = new AgentSession({ diff --git a/packages/coding-agent/test/agent-session-silent-abort.test.ts b/packages/coding-agent/test/agent-session-silent-abort.test.ts index ae9883a5e..eac093c71 100644 --- a/packages/coding-agent/test/agent-session-silent-abort.test.ts +++ b/packages/coding-agent/test/agent-session-silent-abort.test.ts @@ -12,7 +12,7 @@ * the persisted message (in-place mutation) and the emitted display event * (deobfuscated spread copy) carry the marker (A4). */ -import { afterEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, TextContent } from "@oh-my-pi/pi-ai"; @@ -55,16 +55,13 @@ function makeStoppedAssistantMessage(text = "done"): AssistantMessage { } interface SessionFixture { - tempDir: TempDir; - authStorage: AuthStorage; session: AgentSession; } -async function createSessionWithObfuscator(obfuscator?: SecretObfuscator): Promise { - const tempDir = TempDir.createSync("@pi-silent-abort-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage); +async function createSessionWithObfuscator( + modelRegistry: ModelRegistry, + obfuscator?: SecretObfuscator, +): Promise { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected built-in anthropic model to exist"); @@ -85,24 +82,36 @@ async function createSessionWithObfuscator(obfuscator?: SecretObfuscator): Promi obfuscator, }); - return { tempDir, authStorage, session }; + return { session }; } describe("AgentSession silent-abort marker stamping", () => { let fixture: SessionFixture | undefined; + let fixtureDir: TempDir; + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; + beforeAll(async () => { + fixtureDir = TempDir.createSync("@pi-silent-abort-fixture-"); + authStorage = await AuthStorage.create(path.join(fixtureDir.path(), "testauth.db")); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage); + }); afterEach(async () => { if (fixture) { await fixture.session.dispose(); - fixture.authStorage.close(); - fixture.tempDir.removeSync(); fixture = undefined; } vi.restoreAllMocks(); }); + afterAll(() => { + authStorage.close(); + fixtureDir.removeSync(); + }); + it("A1: flag set + aborted assistant message_end stamps the marker and clears the flag", async () => { - fixture = await createSessionWithObfuscator(); + fixture = await createSessionWithObfuscator(modelRegistry); const { session } = fixture; session.markPlanInternalAbortPending(); expect(session.isPlanInternalAbortPending).toBe(true); @@ -121,7 +130,7 @@ describe("AgentSession silent-abort marker stamping", () => { }); it("A2: flag unset + aborted assistant message_end leaves errorMessage and flag alone", async () => { - fixture = await createSessionWithObfuscator(); + fixture = await createSessionWithObfuscator(modelRegistry); const { session } = fixture; expect(session.isPlanInternalAbortPending).toBe(false); @@ -135,7 +144,7 @@ describe("AgentSession silent-abort marker stamping", () => { }); it("A3: flag set + non-aborted message_end does NOT consume the flag", async () => { - fixture = await createSessionWithObfuscator(); + fixture = await createSessionWithObfuscator(modelRegistry); const { session } = fixture; session.markPlanInternalAbortPending(); @@ -169,7 +178,7 @@ describe("AgentSession silent-abort marker stamping", () => { // Sanity: obfuscation produced a placeholder embedded in the text. expect(obfuscatedText).not.toBe("hello SECRET_VALUE world"); - fixture = await createSessionWithObfuscator(obfuscator); + fixture = await createSessionWithObfuscator(modelRegistry, obfuscator); const { session } = fixture; // Capture session-emitted events. diff --git a/packages/coding-agent/test/agent-session-skill-keywords.test.ts b/packages/coding-agent/test/agent-session-skill-keywords.test.ts index 7badbede5..d6bda3118 100644 --- a/packages/coding-agent/test/agent-session-skill-keywords.test.ts +++ b/packages/coding-agent/test/agent-session-skill-keywords.test.ts @@ -50,9 +50,9 @@ describe("AgentSession skill prompt keyword steering", () => { tempDir = TempDir.createSync("@pi-agent-session-skill-keywords-"); observedTurns.length = 0; - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const modelRegistry = new ModelRegistry(authStorage); const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); diff --git a/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts b/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts index c8f2eb12b..fd0920afe 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-auto-fallback.test.ts @@ -1,5 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as compactionModule from "@oh-my-pi/pi-agent-core/compaction"; import type { Message } from "@oh-my-pi/pi-ai"; @@ -9,7 +8,6 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; const UNRENDERABLE_SNAPCOMPACT_TEXT = "\uE000\uE001\uE002\uE003\uE004\uE005\uE006\uE007\uE008\uE009"; @@ -26,16 +24,13 @@ interface HarnessOptions { seedMessages?: Message[]; } -async function createHarness(tempDir: TempDir, authStorage: AuthStorage, options: HarnessOptions): Promise { +async function createHarness(modelRegistry: ModelRegistry, options: HarnessOptions): Promise { const activeModel = getBundledModel(options.activeModel.provider, options.activeModel.id); if (!activeModel) throw new Error(`Missing bundled model ${options.activeModel.provider}/${options.activeModel.id}`); - authStorage.setRuntimeApiKey(options.activeModel.provider, "test-key"); - - const modelRegistry = new ModelRegistry(authStorage); const agent = new Agent({ initialState: { model: activeModel, systemPrompt: ["Test"], tools: [], messages: [] }, }); - const sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + const sessionManager = SessionManager.inMemory(); const seed = options.seedMessages ?? [{ role: "user", content: "hello", timestamp: Date.now() }]; for (const message of seed) sessionManager.appendMessage(message); const firstKeptEntryId = sessionManager.getBranch()[0]?.id; @@ -110,26 +105,27 @@ async function createHarness(tempDir: TempDir, authStorage: AuthStorage, options describe("AgentSession auto-snapcompact local-blocker fallback", () => { let session: AgentSession | undefined; - let authStorage: AuthStorage | undefined; - let tempDir: TempDir | undefined; + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; + + beforeAll(async () => { + authStorage = await AuthStorage.create(":memory:"); + authStorage.setRuntimeApiKey("aimlapi", "test-key"); + modelRegistry = new ModelRegistry(authStorage); + }); afterEach(async () => { - try { - await session?.dispose(); - } finally { - authStorage?.close(); - await tempDir?.remove(); - vi.restoreAllMocks(); - session = undefined; - authStorage = undefined; - tempDir = undefined; - } + await session?.dispose(); + vi.restoreAllMocks(); + session = undefined; + }); + + afterAll(() => { + authStorage.close(); }); it("downgrades to context-full when the active model cannot read snapcompact frames", async () => { - tempDir = TempDir.createSync("@pi-snapcompact-text-only-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); - const harness = await createHarness(tempDir, authStorage, { + const harness = await createHarness(modelRegistry, { activeModel: { provider: "aimlapi", id: "alibaba/qwen3-coder-480b-a35b-instruct" }, }); session = harness.session; @@ -148,9 +144,7 @@ describe("AgentSession auto-snapcompact local-blocker fallback", () => { }); it("downgrades to context-full when unsupported glyphs make snapcompact unsafe", async () => { - tempDir = TempDir.createSync("@pi-snapcompact-unsupported-glyphs-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); - const harness = await createHarness(tempDir, authStorage, { + const harness = await createHarness(modelRegistry, { activeModel: { provider: "aimlapi", id: "claude-sonnet-4-5-20250929" }, seedMessages: [ { diff --git a/packages/coding-agent/test/agent-session-snapcompact-budget.test.ts b/packages/coding-agent/test/agent-session-snapcompact-budget.test.ts index f8d690359..30f649763 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-budget.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-budget.test.ts @@ -18,8 +18,7 @@ * result instead of falling back to the LLM summarizer. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { effectiveReserveTokens, estimateTokens, prepareCompaction } from "@oh-my-pi/pi-agent-core/compaction"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -30,23 +29,22 @@ import { computeNonMessageTokens } from "@oh-my-pi/pi-coding-agent/modes/utils/c import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; describe("AgentSession snapcompact frame-budget sizing", () => { - let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; - beforeEach(async () => { - tempDir = TempDir.createSync("@pi-snapcompact-budget-"); - - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + beforeAll(async () => { + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); - sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + }); + + beforeEach(() => { + sessionManager = SessionManager.inMemory(); const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!bundled) throw new Error("Expected bundled claude-sonnet-4-5 model"); @@ -105,13 +103,12 @@ describe("AgentSession snapcompact frame-budget sizing", () => { }); afterEach(async () => { - try { - await session?.dispose(); - } finally { - authStorage?.close(); - await tempDir?.remove(); - vi.restoreAllMocks(); - } + await session?.dispose(); + vi.restoreAllMocks(); + }); + + afterAll(() => { + authStorage.close(); }); it("passes a maxFrames whose full projection (frames + text edges + base) fits the budget", async () => { @@ -246,25 +243,7 @@ describe("AgentSession snapcompact frame-budget sizing", () => { it("applies the frame byte cap when the model context window is unknown", async () => { const model = session.model; if (!model) throw new Error("Expected model"); - await session.dispose(); - // dispose() released the manager's in-memory transcript; reopen the - // persisted file for the replacement session, as revival paths do. - const sessionFile = sessionManager.getSessionFile(); - if (!sessionFile) throw new Error("Expected a persisted session file"); - sessionManager = await SessionManager.open(sessionFile, tempDir.path()); - const unknownWindowModel = { ...model, contextWindow: 0 }; - session = new AgentSession({ - agent: new Agent({ - initialState: { model: unknownWindowModel, systemPrompt: ["Test"], tools: [], messages: [] }, - }), - sessionManager, - settings: Settings.isolated({ - "compaction.strategy": "snapcompact", - "compaction.autoContinue": false, - "compaction.keepRecentTokens": 4000, - }), - modelRegistry, - }); + session.agent.setModel({ ...model, contextWindow: 0 }); const branchEntries = sessionManager.getBranch(); const lastEntry = branchEntries[branchEntries.length - 1]; diff --git a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts index 8278e19e4..8e977394b 100644 --- a/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts +++ b/packages/coding-agent/test/agent-session-snapcompact-frame-dead-end.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; import { Agent, RESCUE_SHAKE_CONFIG } from "@oh-my-pi/pi-agent-core"; @@ -38,6 +38,12 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { let authStorage: AuthStorage; let modelRegistry: ModelRegistry; + beforeAll(async () => { + authStorage = await AuthStorage.create(":memory:"); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage); + }); + const NOTICE_SOURCE = "compaction"; const NO_PROGRESS_FRAGMENT = "Compaction freed too little context to make progress"; const SEEDED_FRAME_COUNT = 16; @@ -75,10 +81,7 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { preArchiveKeptText?: string; }): Promise { tempDir = TempDir.createSync("@pi-snapcompact-frame-dead-end-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - modelRegistry = new ModelRegistry(authStorage); - sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + sessionManager = SessionManager.inMemory(tempDir.path()); let extensionRunner: ExtensionRunner | undefined; if (options.hookArchiveFrames !== undefined) { @@ -192,12 +195,15 @@ describe("AgentSession snapcompact frame dead-end rescue", () => { try { await session?.dispose(); } finally { - authStorage?.close(); await tempDir?.remove(); vi.restoreAllMocks(); } }); + afterAll(() => { + authStorage.close(); + }); + function collectNotices() { const notices: { level: string; message: string; source?: string }[] = []; session.subscribe(event => { diff --git a/packages/coding-agent/test/agent-session-stats.test.ts b/packages/coding-agent/test/agent-session-stats.test.ts index e04ad435c..57ae3ae3b 100644 --- a/packages/coding-agent/test/agent-session-stats.test.ts +++ b/packages/coding-agent/test/agent-session-stats.test.ts @@ -1,5 +1,4 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; -import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Message, UserMessage } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -7,23 +6,19 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; describe("AgentSession session stats", () => { - let tempDir: TempDir; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; let session: AgentSession | undefined; beforeAll(async () => { - tempDir = TempDir.createSync("@pi-session-stats-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage = await AuthStorage.create(":memory:"); modelRegistry = new ModelRegistry(authStorage); }); afterAll(() => { authStorage.close(); - tempDir.removeSync(); }); afterEach(async () => { diff --git a/packages/coding-agent/test/agent-session-steer-idle-drain.test.ts b/packages/coding-agent/test/agent-session-steer-idle-drain.test.ts index 86d68763e..fb0fb5a65 100644 --- a/packages/coding-agent/test/agent-session-steer-idle-drain.test.ts +++ b/packages/coding-agent/test/agent-session-steer-idle-drain.test.ts @@ -1,5 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ToolResultMessage } from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; @@ -60,6 +59,14 @@ describe("AgentSession steer idle drain", () => { let tempDir: TempDir; let session: AgentSession; let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; + + beforeAll(async () => { + tempDir = TempDir.createSync("@pi-steer-idle-drain-"); + authStorage = await AuthStorage.create(":memory:"); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + modelRegistry = new ModelRegistry(authStorage); + }); async function createSession(messages: Parameters[0][]): Promise { const model = getBundledModel("anthropic", "claude-sonnet-4-5"); @@ -68,29 +75,28 @@ describe("AgentSession steer idle drain", () => { const agent = new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages }, }); - const sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + const sessionManager = SessionManager.inMemory(tempDir.path()); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({}), - modelRegistry: new ModelRegistry(authStorage), + modelRegistry, }); } - beforeEach(async () => { - tempDir = TempDir.createSync("@pi-steer-idle-drain-"); + beforeEach(() => { vi.useFakeTimers(); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - authStorage.setRuntimeApiKey("anthropic", "test-key"); }); afterEach(async () => { await session.dispose(); - authStorage.close(); - tempDir.removeSync(); vi.useRealTimers(); vi.restoreAllMocks(); }); + afterAll(() => { + authStorage.close(); + tempDir.removeSync(); + }); it("delivers a steer queued on an idle resumable session via continue()", async () => { await createSession([{ role: "user", content: "hello", timestamp: Date.now() }, createAssistantMessage()]); @@ -147,12 +153,12 @@ describe("AgentSession steer idle drain", () => { initialState: { model, systemPrompt: ["Test"], tools: [] }, streamFn: mock.stream, }); - const sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + const sessionManager = SessionManager.inMemory(tempDir.path()); session = new AgentSession({ agent, sessionManager, settings: Settings.isolated({ "compaction.enabled": false }), - modelRegistry: new ModelRegistry(authStorage), + modelRegistry, }); const running = session.prompt("do the thing"); diff --git a/packages/coding-agent/test/agent-session-switch-prev-context.test.ts b/packages/coding-agent/test/agent-session-switch-prev-context.test.ts index 4fa599d8e..e66640216 100644 --- a/packages/coding-agent/test/agent-session-switch-prev-context.test.ts +++ b/packages/coding-agent/test/agent-session-switch-prev-context.test.ts @@ -1,5 +1,4 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; -import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -21,7 +20,6 @@ import { TempDir } from "@oh-my-pi/pi-utils"; * different-session switches MUST skip that work. */ describe("AgentSession.switchSession previous-context build", () => { - let sharedDir: TempDir; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; let model: Model; @@ -29,8 +27,7 @@ describe("AgentSession.switchSession previous-context build", () => { const sessions: AgentSession[] = []; beforeAll(async () => { - sharedDir = TempDir.createSync("@pi-switch-prev-ctx-shared-"); - authStorage = await AuthStorage.create(path.join(sharedDir.path(), "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); const bundled = getBundledModel("anthropic", "claude-sonnet-4-5"); @@ -38,11 +35,8 @@ describe("AgentSession.switchSession previous-context build", () => { model = bundled; }); - afterAll(async () => { + afterAll(() => { authStorage.close(); - try { - await sharedDir.remove(); - } catch {} }); afterEach(async () => { diff --git a/packages/coding-agent/test/agent-session-terminal-error-persistence.test.ts b/packages/coding-agent/test/agent-session-terminal-error-persistence.test.ts index 8cff150f5..3d5c6bb24 100644 --- a/packages/coding-agent/test/agent-session-terminal-error-persistence.test.ts +++ b/packages/coding-agent/test/agent-session-terminal-error-persistence.test.ts @@ -1,5 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; @@ -23,15 +22,24 @@ const failingTool: AgentTool> = }, }; -type Harness = { session: AgentSession; authStorage: AuthStorage; tempDir: TempDir }; +type Harness = { session: AgentSession; tempDir: TempDir }; const activeHarnesses: Harness[] = []; +let authStorage: AuthStorage; +let modelRegistry: ModelRegistry; + +beforeAll(async () => { + authStorage = await AuthStorage.create(":memory:"); + authStorage.setRuntimeApiKey("mock", "test-key"); + modelRegistry = new ModelRegistry(authStorage); +}); + +afterAll(() => { + authStorage.close(); +}); async function createHarness(responses: MockResponse[]): Promise { const tempDir = TempDir.createSync("@pi-terminal-error-persistence-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); - authStorage.setRuntimeApiKey("mock", "test-key"); const mock = createMockModel({ responses }); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); const settings = Settings.isolated({ "compaction.enabled": false, "retry.enabled": false, @@ -54,7 +62,7 @@ async function createHarness(responses: MockResponse[]): Promise [tool.name, tool])), }); - const harness = { session, authStorage, tempDir }; + const harness = { session, tempDir }; activeHarnesses.push(harness); return { ...harness, sessionManager }; } @@ -70,7 +78,6 @@ function persistedErrorTurns(sessionManager: SessionManager): AssistantMessage[] afterEach(async () => { for (const harness of activeHarnesses.splice(0)) { await harness.session.dispose(); - harness.authStorage.close(); harness.tempDir.removeSync(); } vi.restoreAllMocks(); diff --git a/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts b/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts index 23ae7f48d..a6a61f63f 100644 --- a/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts +++ b/packages/coding-agent/test/agent-session-thinking-loop-retry.test.ts @@ -1,5 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { @@ -21,7 +20,6 @@ import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/ import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { type CustomMessage, convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; const LOOP_PARAGRAPHS = [ "I am now verifying the test module to guarantee there are no compile errors and the code is completely safe.", @@ -112,14 +110,14 @@ function errorIdOnlyThinkingLoopStream(model: Model): AssistantMessageEvent } describe("AgentSession thinking-loop retry", () => { - let tempDir: TempDir; let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; let session: AgentSession | undefined; - beforeEach(async () => { - tempDir = TempDir.createSync("@pi-thinking-loop-retry-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + beforeAll(async () => { + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("openrouter", "openrouter-test-key"); + modelRegistry = new ModelRegistry(authStorage); }); afterEach(async () => { @@ -127,14 +125,15 @@ describe("AgentSession thinking-loop retry", () => { await session.dispose(); session = undefined; } - authStorage.close(); - tempDir.removeSync(); vi.restoreAllMocks(); }); + afterAll(() => { + authStorage.close(); + }); + it("drops a chunked thinking-loop error and retries the turn", async () => { const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model; - const modelRegistry = new ModelRegistry(authStorage); const calls: string[] = []; const agent = new Agent({ getApiKey: requestedModel => `${requestedModel.provider}-test-key`, @@ -195,7 +194,6 @@ describe("AgentSession thinking-loop retry", () => { it("starts retry for thinking-loop errorId even without transient wording", async () => { const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model; - const modelRegistry = new ModelRegistry(authStorage); const calls: string[] = []; const agent = new Agent({ getApiKey: requestedModel => `${requestedModel.provider}-test-key`, @@ -247,7 +245,6 @@ describe("AgentSession thinking-loop retry", () => { it("injects a redirect notice into the retried turn after a thinking loop", async () => { const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model; - const modelRegistry = new ModelRegistry(authStorage); const calls: string[] = []; const contexts: Context[] = []; const agent = new Agent({ @@ -315,7 +312,6 @@ describe("AgentSession thinking-loop retry", () => { it("injects a redirect notice on each consecutive thinking-loop retry", async () => { const model = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" }).model; - const modelRegistry = new ModelRegistry(authStorage); const calls: string[] = []; const contexts: Context[] = []; const agent = new Agent({ diff --git a/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts b/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts index 64ca89dac..4bb52d2e5 100644 --- a/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts +++ b/packages/coding-agent/test/agent-session-title-generation-dispose.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import * as ai from "@oh-my-pi/pi-ai"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; @@ -9,27 +8,22 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; import { createAssistantMessage } from "./helpers/agent-session-setup"; let session: AgentSession | undefined; let authStorage: AuthStorage | undefined; -let tempDir: TempDir | undefined; afterEach(async () => { vi.restoreAllMocks(); await session?.dispose(); authStorage?.close(); - tempDir?.removeSync(); session = undefined; authStorage = undefined; - tempDir = undefined; }); describe("AgentSession title generation disposal", () => { it("uses the active provider session and aborts an in-flight title request during disposal", async () => { - tempDir = TempDir.createSync("@pi-title-dispose-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist"); diff --git a/packages/coding-agent/test/agent-session-todo-blocker-clone.test.ts b/packages/coding-agent/test/agent-session-todo-blocker-clone.test.ts index b4b09adce..7349a2fd3 100644 --- a/packages/coding-agent/test/agent-session-todo-blocker-clone.test.ts +++ b/packages/coding-agent/test/agent-session-todo-blocker-clone.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -7,7 +6,6 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; /** * Regression coverage: `AgentSession.#cloneTodoPhases` used to clone only @@ -18,18 +16,16 @@ import { TempDir } from "@oh-my-pi/pi-utils"; * storage read/write goes through). */ describe("AgentSession todo blocker clone", () => { - let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; beforeEach(async () => { - tempDir = TempDir.createSync("@pi-todo-blocker-clone-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); modelRegistry = new ModelRegistry(authStorage); - sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + sessionManager = SessionManager.inMemory(); const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected built-in anthropic model to exist"); @@ -49,9 +45,6 @@ describe("AgentSession todo blocker clone", () => { afterEach(async () => { await session.dispose(); authStorage.close(); - try { - await tempDir.remove(); - } catch {} }); it("preserves a blocker reason across a setTodoPhases/getTodoPhases round-trip", () => { diff --git a/packages/coding-agent/test/agent-session-todo-mid-run-nudge.test.ts b/packages/coding-agent/test/agent-session-todo-mid-run-nudge.test.ts index 08e91f68c..d3734f2c3 100644 --- a/packages/coding-agent/test/agent-session-todo-mid-run-nudge.test.ts +++ b/packages/coding-agent/test/agent-session-todo-mid-run-nudge.test.ts @@ -1,16 +1,15 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent, type AgentTool, type AsideMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, TextContent, ToolCall } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import type { CustomMessage } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TodoTool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; /** * Regression coverage for issue #3651 and its redesign: the mid-run todo @@ -34,12 +33,18 @@ import { TempDir } from "@oh-my-pi/pi-utils"; * after a batch of synthesized `message_end` events mirrors that injection * point without spinning a real model. */ +const sharedAuthStorage = createInMemoryAuthStorage(); +sharedAuthStorage.setRuntimeApiKey("anthropic", "test-key"); +const sharedModelRegistry = new ModelRegistry(sharedAuthStorage); + +afterAll(() => { + sharedAuthStorage.close(); +}); + describe("AgentSession mid-run todo reconciliation nudge", () => { let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; - let authStorage: AuthStorage; - let modelRegistry: ModelRegistry; let reminderEvents: Array>; let asideProvider: (() => AsideMessage[] | Promise) | undefined; @@ -88,11 +93,9 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { timestamp: Date.now(), }; } - - async function emitTextOnlyStop(): Promise { + function emitTextOnlyStop(): void { const msg = textOnlyAssistant(); session.agent.emitExternalEvent({ type: "message_end", message: msg }); - await settle(); session.agent.emitExternalEvent({ type: "agent_end", messages: [msg] }); } @@ -114,20 +117,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { }); } - /** - * #processAgentEvent fires off message_end handlers as async microtasks that - * chain on `#messageEndPersistenceTail`. After a batch of synchronous emits - * the counter only catches up once every queued persist task drains, so - * tests yield a full event-loop tick before draining asides. - * - * Real-timer exception (ts-no-test-timers): `Bun.sleep(0)` is a single - * event-loop tick, not a tuned duration — the private persistence tail - * exposes no drain promise to await, and fake timers cannot flush it. - */ - async function settle(): Promise { - await Bun.sleep(0); - } - async function drainNudges(): Promise { if (!asideProvider) throw new Error("aside provider was never captured"); const thunks = await asideProvider(); @@ -142,12 +131,9 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { return out; } - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@pi-todo-mid-run-nudge-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - modelRegistry = new ModelRegistry(authStorage); - sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + sessionManager = SessionManager.inMemory(tempDir.path()); const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected built-in anthropic model to exist"); @@ -190,7 +176,7 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { agent, sessionManager, settings, - modelRegistry, + modelRegistry: sharedModelRegistry, }); reminderEvents = []; @@ -212,7 +198,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { afterEach(async () => { await session.dispose(); - authStorage.close(); try { await tempDir.remove(); } catch {} @@ -222,7 +207,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { it("read-only exploration never ticks the counter, no matter how long", async () => { for (let i = 0; i < THRESHOLD * 3; i++) emitToolResult(i % 2 === 0 ? "grep" : "read"); - await settle(); expect(await drainNudges()).toEqual([]); expect(reminderEvents).toEqual([]); }); @@ -230,7 +214,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { it("stays silent below the mutation threshold", async () => { for (let i = 0; i < THRESHOLD - 1; i++) emitToolResult("edit"); - await settle(); expect(await drainNudges()).toEqual([]); expect(reminderEvents).toEqual([]); }); @@ -238,7 +221,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { it("injects a hidden custom nudge at the threshold — no event, no render", async () => { for (let i = 0; i < THRESHOLD; i++) emitToolResult("edit"); - await settle(); const nudges = await drainNudges(); expect(nudges.length).toBe(1); const nudge = nudges[0]; @@ -264,7 +246,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { it("errored mutating results do not tick the counter", async () => { for (let i = 0; i < THRESHOLD; i++) emitToolResult("bash", { isError: true }); - await settle(); expect(await drainNudges()).toEqual([]); }); @@ -273,7 +254,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { emitToolResult("todo"); for (let i = 0; i < THRESHOLD - 1; i++) emitToolResult("write"); - await settle(); expect(await drainNudges()).toEqual([]); expect(reminderEvents).toEqual([]); }); @@ -282,7 +262,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { let fired = 0; for (let cycle = 0; cycle < MAX_PER_CYCLE + 2; cycle++) { for (let i = 0; i < THRESHOLD; i++) emitToolResult("edit"); - await settle(); fired += (await drainNudges()).length; } expect(fired).toBe(MAX_PER_CYCLE); @@ -317,7 +296,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { expect(session.getActiveToolNames()).not.toContain("todo"); for (let i = 0; i < THRESHOLD; i++) emitToolResult("edit"); - await settle(); expect(await drainNudges()).toEqual([]); expect(reminderEvents).toEqual([]); }); @@ -326,8 +304,7 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { vi.spyOn(session.agent, "continue").mockResolvedValue(); for (let i = 0; i < THRESHOLD - 1; i++) emitToolResult("edit"); - await settle(); - await emitTextOnlyStop(); + emitTextOnlyStop(); await session.waitForIdle(); // The stop-time path is the user-visible ladder: it emits the event. expect(reminderEvents.length).toBe(1); @@ -336,7 +313,6 @@ describe("AgentSession mid-run todo reconciliation nudge", () => { // The stop-time reminder reset the mutation counter, so one more landed // mutation (crossing the stale pre-reminder threshold) must stay silent. emitToolResult("edit"); - await settle(); expect(await drainNudges()).toEqual([]); expect(reminderEvents.length).toBe(1); }); diff --git a/packages/coding-agent/test/agent-session-todo-reminder-async-jobs.test.ts b/packages/coding-agent/test/agent-session-todo-reminder-async-jobs.test.ts index f640febab..8290cbb35 100644 --- a/packages/coding-agent/test/agent-session-todo-reminder-async-jobs.test.ts +++ b/packages/coding-agent/test/agent-session-todo-reminder-async-jobs.test.ts @@ -1,5 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; @@ -8,9 +7,9 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir, withTimeout } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; /** * Regression coverage for the `#hasPendingAsyncWake()` gate shared by the @@ -47,19 +46,23 @@ import { TempDir, withTimeout } from "@oh-my-pi/pi-utils"; * the same way — so once `waitForIdle()` resolves, the settle has definitively * decided whether to fire the stop-time passes. No wall-clock sleeps needed. */ +const sharedAuthStorage = createInMemoryAuthStorage(); +sharedAuthStorage.setRuntimeApiKey("anthropic", "test-key"); +const sharedModelRegistry = new ModelRegistry(sharedAuthStorage); + +afterAll(() => { + sharedAuthStorage.close(); +}); + describe("AgentSession todo reminder async-job deferral", () => { let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; - let authStorage: AuthStorage; - let modelRegistry: ModelRegistry; let manager: AsyncJobManager; let extensionRunner: ExtensionRunner; let gates: Array>; let reminderAttempts: number[]; - let firstReminderPromise: Promise; let agentEndTerminalStates: Array; - let resolveFirstReminder: () => void; function textOnlyAssistantMessage(): AssistantMessage { return { @@ -108,12 +111,9 @@ describe("AgentSession todo reminder async-job deferral", () => { ]); } - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@pi-todo-reminder-async-jobs-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - modelRegistry = new ModelRegistry(authStorage); - sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + sessionManager = SessionManager.inMemory(tempDir.path()); manager = new AsyncJobManager({}); gates = []; extensionRunner = { @@ -144,7 +144,7 @@ describe("AgentSession todo reminder async-job deferral", () => { "todo.reminders": true, "todo.remindersMax": 3, }), - modelRegistry, + modelRegistry: sharedModelRegistry, agentId: "Main", asyncJobManager: manager, extensionRunner, @@ -155,12 +155,8 @@ describe("AgentSession todo reminder async-job deferral", () => { reminderAttempts = []; agentEndTerminalStates = []; - ({ promise: firstReminderPromise, resolve: resolveFirstReminder } = Promise.withResolvers()); session.subscribe((event: AgentSessionEvent) => { - if (event.type === "todo_reminder") { - reminderAttempts.push(event.attempt); - if (reminderAttempts.length === 1) resolveFirstReminder(); - } + if (event.type === "todo_reminder") reminderAttempts.push(event.attempt); if (event.type === "agent_end") { agentEndTerminalStates.push( (event as Extract & { isTerminal?: boolean }).isTerminal, @@ -175,7 +171,6 @@ describe("AgentSession todo reminder async-job deferral", () => { await session.dispose(); manager.cancelAll(); await manager.dispose(); - authStorage.close(); try { await tempDir.remove(); } catch {} @@ -201,7 +196,7 @@ describe("AgentSession todo reminder async-job deferral", () => { registerGatedJob("OtherAgent"); emitTextOnlyStop(); - await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired"); + await session.waitForIdle(); expect(reminderAttempts).toEqual([1]); }); @@ -223,7 +218,7 @@ describe("AgentSession todo reminder async-job deferral", () => { await manager.drainDeliveries(); emitTextOnlyStop(); - await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired after job drained"); + await session.waitForIdle(); expect(reminderAttempts).toEqual([1]); }); diff --git a/packages/coding-agent/test/agent-session-todo-reminder-loop.test.ts b/packages/coding-agent/test/agent-session-todo-reminder-loop.test.ts index 9f8e056fa..80c82ba42 100644 --- a/packages/coding-agent/test/agent-session-todo-reminder-loop.test.ts +++ b/packages/coding-agent/test/agent-session-todo-reminder-loop.test.ts @@ -1,14 +1,13 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, TextContent, ToolCall } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir, withTimeout } from "@oh-my-pi/pi-utils"; +import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; /** * Regression coverage for issue #2590: `#checkTodoCompletion` used to schedule @@ -21,15 +20,19 @@ import { TempDir, withTimeout } from "@oh-my-pi/pi-utils"; * self-continuation chain unless the agent has produced a tool-level result * (e.g. called `todo` or `edit`) between the prior reminder and the next stop. */ +const sharedAuthStorage = createInMemoryAuthStorage(); +sharedAuthStorage.setRuntimeApiKey("anthropic", "test-key"); +const sharedModelRegistry = new ModelRegistry(sharedAuthStorage); + +afterAll(() => { + sharedAuthStorage.close(); +}); + describe("AgentSession todo reminder self-continuation suppression", () => { let tempDir: TempDir; let session: AgentSession; let sessionManager: SessionManager; - let authStorage: AuthStorage; - let modelRegistry: ModelRegistry; let reminderAttempts: number[]; - let firstReminderPromise: Promise; - let resolveFirstReminder: () => void; function textOnlyAssistantMessage(text = "paused at your instruction"): AssistantMessage { return { @@ -105,12 +108,9 @@ describe("AgentSession todo reminder self-continuation suppression", () => { }); } - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@pi-todo-reminder-loop-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - authStorage.setRuntimeApiKey("anthropic", "test-key"); - modelRegistry = new ModelRegistry(authStorage); - sessionManager = SessionManager.create(tempDir.path(), tempDir.path()); + sessionManager = SessionManager.inMemory(tempDir.path()); const model = getBundledModel("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected built-in anthropic model to exist"); @@ -133,16 +133,12 @@ describe("AgentSession todo reminder self-continuation suppression", () => { "todo.reminders": true, "todo.remindersMax": 3, }), - modelRegistry, + modelRegistry: sharedModelRegistry, }); reminderAttempts = []; - ({ promise: firstReminderPromise, resolve: resolveFirstReminder } = Promise.withResolvers()); session.subscribe((event: AgentSessionEvent) => { - if (event.type === "todo_reminder") { - reminderAttempts.push(event.attempt); - if (reminderAttempts.length === 1) resolveFirstReminder(); - } + if (event.type === "todo_reminder") reminderAttempts.push(event.attempt); }); session.setTodoPhases([ @@ -158,7 +154,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => { afterEach(async () => { await session.dispose(); - authStorage.close(); try { await tempDir.remove(); } catch {} @@ -168,7 +163,7 @@ describe("AgentSession todo reminder self-continuation suppression", () => { it("baseline: a single text-only stop fires reminder 1/3 and records it in the transcript", async () => { vi.spyOn(session.agent, "continue").mockResolvedValue(); emitTextOnlyStop(); - await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired"); + await session.waitForIdle(); expect(reminderAttempts).toEqual([1]); const reminderEntry = todoReminderTranscriptEntry(); @@ -203,7 +198,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => { emitTextOnlyStop( "Which configuration should this use?\nUse the existing default; the remaining todo items still need work.", ); - await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired"); await session.waitForIdle(); expect(reminderAttempts).toEqual([1]); @@ -215,7 +209,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => { const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue(); emitTextOnlyStop("Final answer: I summarized the work completed so far, but the todo items remain open."); - await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired"); await session.waitForIdle(); expect(reminderAttempts).toEqual([1]); @@ -227,7 +220,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => { const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue(); emitTextOnlyStop("Tail note: the interface includes foo?: string, but the todo items remain open."); - await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired"); await session.waitForIdle(); expect(reminderAttempts).toEqual([1]); @@ -243,7 +235,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => { }); emitTextOnlyStop(); - await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired"); await session.waitForIdle(); // With the bug: reminderAttempts === [1, 2, 3] within a single user pause. @@ -268,7 +259,6 @@ describe("AgentSession todo reminder self-continuation suppression", () => { }); emitTextOnlyStop(); - await withTimeout(firstReminderPromise, 1000, "todo_reminder never fired"); await session.waitForIdle(); // 1/3 fires, agent does work, 2/3 fires, agent acks → suppressed, no 3/3. diff --git a/packages/coding-agent/test/agent-session-tool-call-loop-guard.test.ts b/packages/coding-agent/test/agent-session-tool-call-loop-guard.test.ts index cc5e06649..a758add02 100644 --- a/packages/coding-agent/test/agent-session-tool-call-loop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-tool-call-loop-guard.test.ts @@ -57,7 +57,7 @@ describe("AgentSession tool-call loop guard", () => { convertToLlm, streamFn: (_model, context) => { contexts.push(context); - const toolCallTurn = callCount < 5; + const toolCallTurn = callCount < 2; const toolCallId = `tc-${callCount}`; callCount++; const message: AssistantMessage = toolCallTurn @@ -93,7 +93,7 @@ describe("AgentSession tool-call loop guard", () => { "compaction.enabled": false, "todo.enabled": false, "model.toolCallLoopGuard.enabled": true, - "model.toolCallLoopGuard.threshold": 5, + "model.toolCallLoopGuard.threshold": 2, "model.toolCallLoopGuard.exemptTools": ["hub"], }); settings.setModelRole("default", `${model.provider}/${model.id}`); @@ -108,9 +108,9 @@ describe("AgentSession tool-call loop guard", () => { await session.prompt("run checks"); await session.waitForIdle(); - expect(contexts).toHaveLength(6); - expect(JSON.stringify(contexts[5]!.messages)).toContain("tool_call_loop_detected"); - expect(JSON.stringify(contexts[5]!.messages)).toContain("1263 passed, 4 skipped"); + expect(contexts).toHaveLength(3); + expect(JSON.stringify(contexts[2]!.messages)).toContain("tool_call_loop_detected"); + expect(JSON.stringify(contexts[2]!.messages)).toContain("1263 passed, 4 skipped"); const redirects = session.agent.state.messages.filter( (message): message is CustomMessage => message.role === "custom" && message.customType === "tool-call-loop-redirect", diff --git a/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts b/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts index dc8e09a45..eba1248c9 100644 --- a/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts +++ b/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts @@ -14,11 +14,68 @@ * silently reporting a successful no-op navigation (review on #5895). */ import { describe, expect, it, vi } from "bun:test"; -import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { Agent, type AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { ExtensionRunner, ExtensionUIContext } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { SecretObfuscator } from "@oh-my-pi/pi-coding-agent/secrets/obfuscator"; +import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import type { AskToolDetails } from "@oh-my-pi/pi-coding-agent/tools/ask"; -import { assistantMsg, createTestSession, userMsg } from "./utilities"; + +const TEST_MODEL = getBundledModel("anthropic", "claude-sonnet-4-5")!; + +async function createTestSession( + options: { inMemory?: boolean; extensionRunner?: ExtensionRunner; obfuscator?: SecretObfuscator } = {}, +) { + const sessionManager = SessionManager.inMemory(); + const settings = Settings.isolated(); + const modelRegistry = {} as never; + const session = new AgentSession({ + agent: new Agent({ + getApiKey: () => "test-key", + initialState: { + model: TEST_MODEL, + systemPrompt: ["test"], + tools: [], + }, + }), + sessionManager, + settings, + modelRegistry, + extensionRunner: options.extensionRunner, + obfuscator: options.obfuscator, + }); + return { + session, + sessionManager, + cleanup: () => session.dispose(), + }; +} + +function userMsg(text: string) { + return { role: "user" as const, content: text, timestamp: Date.now() }; +} + +function assistantMsg(text: string) { + return { + role: "assistant" as const, + content: [{ type: "text" as const, text }], + api: "anthropic-messages" as const, + provider: "anthropic", + model: "test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop" as const, + timestamp: Date.now(), + }; +} const ORIGINAL_QUESTIONS = [ { diff --git a/packages/coding-agent/test/agent-session-tree-navigation.test.ts b/packages/coding-agent/test/agent-session-tree-navigation.test.ts index 4fe0ae77c..8b6c4ee98 100644 --- a/packages/coding-agent/test/agent-session-tree-navigation.test.ts +++ b/packages/coding-agent/test/agent-session-tree-navigation.test.ts @@ -8,16 +8,29 @@ * - Summary attachment at correct position in tree * - Abort handling during summarization */ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { createTestSession, e2eApiKey, type TestSessionContext } from "./utilities"; describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession tree navigation e2e", () => { let ctx: TestSessionContext; + let observeTreePreparation: boolean; + let treePreparationStarted: PromiseWithResolvers; beforeEach(async () => { + observeTreePreparation = false; + treePreparationStarted = Promise.withResolvers(); + const extensionRunner = { + hasHandlers: vi.fn((eventType: string) => observeTreePreparation && eventType === "session_before_tree"), + emit: vi.fn().mockImplementation(async () => { + treePreparationStarted.resolve(); + return undefined; + }), + } as unknown as ExtensionRunner; ctx = await createTestSession({ systemPrompt: ["You are a helpful assistant. Reply with just a few words."], settingsOverrides: { compaction: { keepRecentTokens: 1 } }, + extensionRunner, }); }); @@ -187,11 +200,11 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession tree navigation e const tree = sessionManager.getTree(); const rootNode = tree[0]; - // Start navigation with summarization but abort immediately + // Synchronize on the session_before_tree boundary: at this point the + // production abort controller exists, so aborting cannot race setup. + observeTreePreparation = true; const navigationPromise = session.navigateTree(rootNode.entry.id, { summarize: true }); - - // Abort after a short delay (let the LLM call start) - await Bun.sleep(100); + await treePreparationStarted.promise; session.abortBranchSummary(); const result = await navigationPromise; diff --git a/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts b/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts index 5880577ea..33174a324 100644 --- a/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts +++ b/packages/coding-agent/test/agent-session-unexpected-stop-guard.test.ts @@ -1,27 +1,32 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, describe, expect, it, vi } from "bun:test"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { createMockModel, type MockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { type SettingPath, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import * as unexpectedStopClassifier from "@oh-my-pi/pi-coding-agent/session/unexpected-stop-classifier"; import { logger, TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; const recordToolSchema = type({ value: type("string") }); type Harness = { session: AgentSession; - authStorage: AuthStorage; tempDir: TempDir; }; type SettingsOverrides = Partial>; const activeHarnesses: Harness[] = []; +const sharedAuthStorage = createInMemoryAuthStorage(); +sharedAuthStorage.setRuntimeApiKey("mock", "test-key"); +const sharedModelRegistry = new ModelRegistry(sharedAuthStorage); + +afterAll(() => { + sharedAuthStorage.close(); +}); const recordTool: AgentTool = { name: "record", @@ -62,11 +67,9 @@ async function createHarness( settingsOverrides: SettingsOverrides = {}, ): Promise { const tempDir = TempDir.createSync("@pi-unexpected-stop-guard-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); - authStorage.setRuntimeApiKey("mock", "test-key"); const mock = createMockModel({ responses }); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const modelRegistry = sharedModelRegistry; const settings = Settings.isolated({ "compaction.enabled": false, "retry.enabled": false, @@ -98,7 +101,7 @@ async function createHarness( modelRegistry, toolRegistry: new Map(tools.map(tool => [tool.name, tool])), }); - const harness = { session, authStorage, tempDir }; + const harness = { session, tempDir }; activeHarnesses.push(harness); return { ...harness, mock }; } @@ -130,8 +133,7 @@ afterEach(async () => { vi.restoreAllMocks(); for (const harness of activeHarnesses) { await harness.session.dispose(); - harness.authStorage.close(); - harness.tempDir.remove(); + harness.tempDir.removeSync(); } activeHarnesses.length = 0; }); diff --git a/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts b/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts index d779c92ca..7f5a41ca8 100644 --- a/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts +++ b/packages/coding-agent/test/agent-session-user-shortcut-hooks.test.ts @@ -1,5 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -8,20 +7,25 @@ import * as pythonExecutor from "@oh-my-pi/pi-coding-agent/eval/py/executor"; import * as bashExecutor from "@oh-my-pi/pi-coding-agent/exec/bash-executor"; import type { ExtensionRunner } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; + +const sharedAuthStorage = createInMemoryAuthStorage(); +const sharedModelRegistry = new ModelRegistry(sharedAuthStorage); + +afterAll(() => { + sharedAuthStorage.close(); +}); describe("AgentSession user shortcut hooks", () => { let tempDir: TempDir; let session: AgentSession; let modelRegistry: ModelRegistry; - let authStorage: AuthStorage | undefined; - beforeEach(async () => { + beforeEach(() => { tempDir = TempDir.createSync("@pi-user-shortcut-hooks-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - modelRegistry = new ModelRegistry(authStorage); + modelRegistry = sharedModelRegistry; }); afterEach(async () => { @@ -30,8 +34,6 @@ describe("AgentSession user shortcut hooks", () => { await session.dispose(); } await pythonExecutor.disposeAllKernelSessions(); - authStorage?.close(); - authStorage = undefined; tempDir.removeSync(); }); diff --git a/packages/coding-agent/test/agent-session-yield-empty-stop-suppression.test.ts b/packages/coding-agent/test/agent-session-yield-empty-stop-suppression.test.ts index 668aef73d..bf793f707 100644 --- a/packages/coding-agent/test/agent-session-yield-empty-stop-suppression.test.ts +++ b/packages/coding-agent/test/agent-session-yield-empty-stop-suppression.test.ts @@ -7,8 +7,7 @@ * already-yielded child resumes and can enter post-yield retries or tool calls * (see issues #3389 and #4963). */ -import { afterEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, describe, expect, it, vi } from "bun:test"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentMessage, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { createMockModel, type MockModel, type MockResponse } from "@oh-my-pi/pi-ai/providers/mock"; @@ -16,16 +15,23 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { IrcMessage } from "@oh-my-pi/pi-coding-agent/irc/bus"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; const yieldToolSchema = type({ result: type("unknown") }); const recordToolSchema = type({ value: type("string") }); -type Harness = { session: AgentSession; authStorage: AuthStorage; tempDir: TempDir }; +type Harness = { session: AgentSession; tempDir: TempDir }; const activeHarnesses: Harness[] = []; +const sharedAuthStorage = createInMemoryAuthStorage(); +sharedAuthStorage.setRuntimeApiKey("mock", "test-key"); +const sharedModelRegistry = new ModelRegistry(sharedAuthStorage); + +afterAll(() => { + sharedAuthStorage.close(); +}); const yieldTool: AgentTool = { name: "yield", @@ -78,11 +84,9 @@ function emptyStop(): MockResponse { async function createHarness(responses: MockResponse[]): Promise { const tempDir = TempDir.createSync("@pi-yield-empty-stop-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); - authStorage.setRuntimeApiKey("mock", "test-key"); const mock = createMockModel({ responses }); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); + const modelRegistry = sharedModelRegistry; const settings = Settings.isolated({ "compaction.enabled": false, "retry.enabled": false, @@ -113,7 +117,7 @@ async function createHarness(responses: MockResponse[]): Promise [tool.name, tool])), }); - const harness = { session, authStorage, tempDir }; + const harness = { session, tempDir }; activeHarnesses.push(harness); return { ...harness, mock }; } @@ -140,7 +144,6 @@ function assistantText(messages: AgentMessage[]): string { afterEach(async () => { for (const harness of activeHarnesses.splice(0)) { await harness.session.dispose(); - harness.authStorage.close(); harness.tempDir.removeSync(); } vi.restoreAllMocks(); diff --git a/packages/coding-agent/test/agent-storage-model-perf.test.ts b/packages/coding-agent/test/agent-storage-model-perf.test.ts index 0f78eb109..c2ff18fed 100644 --- a/packages/coding-agent/test/agent-storage-model-perf.test.ts +++ b/packages/coding-agent/test/agent-storage-model-perf.test.ts @@ -1,15 +1,26 @@ import { Database } from "bun:sqlite"; -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage"; import { createSubagentSettings } from "@oh-my-pi/pi-coding-agent/task/executor"; import { TempDir } from "@oh-my-pi/pi-utils"; +const MODEL_PERF_FLUSH_DELAY_MS = 100; + +async function flushPerf(...writes: Promise[]): Promise { + vi.advanceTimersByTime(MODEL_PERF_FLUSH_DELAY_MS); + await Promise.all(writes); +} describe("AgentStorage model perf aggregates", () => { let tempDir: TempDir; + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(async () => { + vi.useRealTimers(); AgentStorage.resetInstance(); if (tempDir) { try { @@ -30,8 +41,17 @@ describe("AgentStorage model perf aggregates", () => { // 1000 tokens over 6000ms + 500 tokens over 3000ms → 1500 tokens / 9s → 166.67 t/s // Back-to-back samples join one deferred batch; awaiting the shared flush // promise makes both visible. - storage.recordModelPerf("openai/gpt-5", { outputTokens: 1000, durationMs: 6000, ttftMs: 1000 }); - await storage.recordModelPerf("openai/gpt-5", { outputTokens: 500, durationMs: 3000, ttftMs: 500 }); + const first = storage.recordModelPerf("openai/gpt-5", { + outputTokens: 1000, + durationMs: 6000, + ttftMs: 1000, + }); + const second = storage.recordModelPerf("openai/gpt-5", { + outputTokens: 500, + durationMs: 3000, + ttftMs: 500, + }); + await flushPerf(first, second); const stats = storage.getModelPerf().get("openai/gpt-5"); expect(stats).toBeDefined(); @@ -45,11 +65,12 @@ describe("AgentStorage model perf aggregates", () => { const parent = await Settings.loadIsolated({ cwd: tempDir.path(), agentDir: tempDir.path() }); const subagent = createSubagentSettings(parent); - await subagent.getStorage()?.recordModelPerf("opencode-go/deepseek-v4-flash", { + const write = subagent.getStorage()!.recordModelPerf("opencode-go/deepseek-v4-flash", { outputTokens: 130, durationMs: 2989.23775, ttftMs: 2324.873, }); + await flushPerf(write); const stats = parent.getStorage()?.getModelPerf().get("opencode-go/deepseek-v4-flash"); expect(stats?.samples).toBe(1); @@ -61,7 +82,8 @@ describe("AgentStorage model perf aggregates", () => { const storage = await openStorage(); // No ttft → 1000 tokens / 4s → 250 t/s - await storage.recordModelPerf("zai/glm-5", { outputTokens: 1000, durationMs: 4000 }); + const write = storage.recordModelPerf("zai/glm-5", { outputTokens: 1000, durationMs: 4000 }); + await flushPerf(write); const stats = storage.getModelPerf().get("zai/glm-5"); expect(stats?.tps).toBeCloseTo(250, 5); @@ -74,8 +96,17 @@ describe("AgentStorage model perf aggregates", () => { // Same duration and token count, wildly different TTFT: a provider that // hides reasoning until late (ttft ~ duration) must not report inflated // throughput vs one that streams from the start. - storage.recordModelPerf("google/gemini", { outputTokens: 1020, durationMs: 7000, ttftMs: 5700 }); - await storage.recordModelPerf("google-vertex/gemini", { outputTokens: 1020, durationMs: 7000, ttftMs: 1700 }); + const hiddenWrite = storage.recordModelPerf("google/gemini", { + outputTokens: 1020, + durationMs: 7000, + ttftMs: 5700, + }); + const streamedWrite = storage.recordModelPerf("google-vertex/gemini", { + outputTokens: 1020, + durationMs: 7000, + ttftMs: 1700, + }); + await flushPerf(hiddenWrite, streamedWrite); const hidden = storage.getModelPerf().get("google/gemini"); const streamed = storage.getModelPerf().get("google-vertex/gemini"); @@ -97,7 +128,12 @@ describe("AgentStorage model perf aggregates", () => { const storage = await openStorage(); // ttft >= duration is bogus latency data; the sample still measures TPS. - await storage.recordModelPerf("openai/gpt-5", { outputTokens: 1000, durationMs: 4000, ttftMs: 5000 }); + const write = storage.recordModelPerf("openai/gpt-5", { + outputTokens: 1000, + durationMs: 4000, + ttftMs: 5000, + }); + await flushPerf(write); const stats = storage.getModelPerf().get("openai/gpt-5"); expect(stats?.tps).toBeCloseTo(250, 5); @@ -111,7 +147,7 @@ describe("AgentStorage model perf aggregates", () => { // Recording is deferred: nothing is visible before the batch flushes. expect(storage.getModelPerf().has("openai/gpt-5")).toBe(false); - await flushed; + await flushPerf(flushed); expect(storage.getModelPerf().get("openai/gpt-5")?.tps).toBeCloseTo(250, 5); }); @@ -162,13 +198,13 @@ describe("AgentStorage model perf aggregates", () => { )`); const insert = statsDb.prepare("INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)"); const now = Date.now(); - // 300 rows: the newest 256 run at 100 t/s, the older 44 at a wild - // 10000 t/s. Only the newest 256 may count. One transaction: per-row - // implicit transactions fsync 300 times and time out on slow CI disks. + // 257 rows are the minimal cap-boundary fixture: the newest 256 run at + // 100 t/s and the one excluded oldest row is a wild 10000 t/s outlier. + // One transaction avoids per-row implicit transaction fsyncs. statsDb.transaction(() => { - for (let i = 0; i < 300; i++) { - const fast = i < 44; // smallest timestamps = oldest rows - insert.run("openai", "gpt-5", fast ? 10_000 : 100, 1000, null, "stop", now - (300 - i) * 1000); + for (let i = 0; i < 257; i++) { + const excludedOldest = i === 0; + insert.run("openai", "gpt-5", excludedOldest ? 10_000 : 100, 1000, null, "stop", now - (257 - i) * 1000); } })(); statsDb.close(); diff --git a/packages/coding-agent/test/agents-hub.test.ts b/packages/coding-agent/test/agents-hub.test.ts index 33fdd0153..0acd057dc 100644 --- a/packages/coding-agent/test/agents-hub.test.ts +++ b/packages/coding-agent/test/agents-hub.test.ts @@ -4,7 +4,7 @@ * strip-driven configuration flows (property strips, pattern input, and the * model-browser pick) persisting to the per-agent settings records. */ -import { afterEach, describe, expect, test, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -19,7 +19,7 @@ import type { TUI } from "@oh-my-pi/pi-tui"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g; -const tempDirs: string[] = []; +let tempCwd: string; // Narrow TUI stub: the hub only reads terminal rows and requests renders. const tuiStub = { requestRender: () => {}, terminal: { rows: 30 } } as unknown as TUI; @@ -41,12 +41,6 @@ const sonnet = buildModel({ // Registry stub: the hub uses getAvailable() for browser items and resolution. const registryStub = { getAvailable: () => [sonnet] } as unknown as ModelRegistry; -async function makeTempCwd(): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-agents-hub-")); - tempDirs.push(dir); - return dir; -} - function mockAgents(): void { vi.spyOn(discovery, "discoverAgents").mockResolvedValue({ projectAgentsDir: null, @@ -64,11 +58,10 @@ async function createHub(settings: Settings): Promise<{ type: (text: string) => void; cancelled: () => boolean; }> { - await initTheme(false); let cancelled = false; const hub = await AgentsHubComponent.create( tuiStub, - await makeTempCwd(), + tempCwd, settings, { modelRegistry: registryStub }, { onCancel: () => (cancelled = true) }, @@ -83,9 +76,17 @@ async function createHub(settings: Settings): Promise<{ }; } -afterEach(async () => { +beforeAll(async () => { + await initTheme(false); + tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-agents-hub-")); +}); + +afterAll(async () => { + await removeWithRetries(tempCwd); +}); + +afterEach(() => { vi.restoreAllMocks(); - await Promise.all(tempDirs.splice(0).map(dir => removeWithRetries(dir))); }); describe("AgentsHub layout", () => { diff --git a/packages/coding-agent/test/apply-patch-preview-renderer.test.ts b/packages/coding-agent/test/apply-patch-preview-renderer.test.ts index 96e482351..ccdea762f 100644 --- a/packages/coding-agent/test/apply-patch-preview-renderer.test.ts +++ b/packages/coding-agent/test/apply-patch-preview-renderer.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, it } from "bun:test"; -import * as path from "node:path"; +import { afterAll, describe, expect, it } from "bun:test"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock"; @@ -7,9 +6,17 @@ import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; + +const sharedAuthStorage = createInMemoryAuthStorage(); +sharedAuthStorage.setRuntimeApiKey("anthropic", "test-key"); +const sharedModelRegistry = new ModelRegistry(sharedAuthStorage); + +afterAll(() => { + sharedAuthStorage.close(); +}); function makeTool(name: string, customWireName?: string): AgentTool { return { @@ -30,8 +37,6 @@ async function withSession( run: (session: AgentSession) => void, ): Promise { const tempDir = TempDir.createSync("@apply-patch-preview-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); - authStorage.setRuntimeApiKey("anthropic", "test-key"); const settings = Settings.isolated({ "compaction.enabled": false }); const model = buildModel({ id: "mock", @@ -54,7 +59,7 @@ async function withSession( agent, sessionManager: SessionManager.inMemory(tempDir.path()), settings, - modelRegistry: new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")), + modelRegistry: sharedModelRegistry, toolRegistry: new Map(tools.map(tool => [tool.name, tool])), builtInToolNames: [...builtInToolNames], rebuildSystemPrompt: async toolNames => ({ systemPrompt: [toolNames.join(",")] }), @@ -63,7 +68,6 @@ async function withSession( run(session); } finally { await session.dispose(); - authStorage.close(); tempDir.removeSync(); } } diff --git a/packages/coding-agent/test/async-job-manager.test.ts b/packages/coding-agent/test/async-job-manager.test.ts index 21fe7529a..cf943da8d 100644 --- a/packages/coding-agent/test/async-job-manager.test.ts +++ b/packages/coding-agent/test/async-job-manager.test.ts @@ -1,6 +1,15 @@ import { describe, expect, test } from "bun:test"; +import { scheduler } from "node:timers/promises"; import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; +async function waitForJobEviction(manager: AsyncJobManager, jobId: string): Promise { + const deadline = Date.now() + 2_000; + while (manager.getJob(jobId)) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for job eviction: ${jobId}`); + await scheduler.yield(); + } +} + describe("AsyncJobManager", () => { test("forwards progress updates and delivers completion", async () => { const progressEvents: Array<{ text: string; details?: Record }> = []; @@ -211,17 +220,21 @@ describe("AsyncJobManager", () => { await manager.drainDeliveries({ timeoutMs: 2_000 }); expect(manager.getJob(jobId)?.status).toBe("completed"); - await Bun.sleep(60); + await waitForJobEviction(manager, jobId); expect(manager.getJob(jobId)).toBeUndefined(); }); test("cancelAll does not clear retention timers for already completed jobs", async () => { + let completedJobId = ""; + const completedDelivered = Promise.withResolvers(); const manager = new AsyncJobManager({ retentionMs: 30, - onJobComplete: async () => {}, + onJobComplete: async jobId => { + if (jobId === completedJobId) completedDelivered.resolve(); + }, }); - const completedJobId = manager.register("task", "completed", async () => "done"); + completedJobId = manager.register("task", "completed", async () => "done"); const runningJobId = manager.register("bash", "running", async ({ signal }) => { await new Promise(resolve => { signal.addEventListener("abort", () => resolve(), { once: true }); @@ -229,11 +242,7 @@ describe("AsyncJobManager", () => { throw new Error("aborted"); }); - const completedDeadline = Date.now() + 2_000; - while (manager.getJob(completedJobId)?.status === "running") { - if (Date.now() >= completedDeadline) throw new Error("Timed out waiting for completed job"); - await Bun.sleep(5); - } + await completedDelivered.promise; manager.cancelAll(); await manager.waitForAll(); await manager.drainDeliveries({ timeoutMs: 2_000 }); @@ -241,31 +250,36 @@ describe("AsyncJobManager", () => { expect(manager.getJob(completedJobId)?.status).toBe("completed"); expect(manager.getJob(runningJobId)?.status).toBe("cancelled"); - await Bun.sleep(80); + await Promise.all([waitForJobEviction(manager, completedJobId), waitForJobEviction(manager, runningJobId)]); expect(manager.getJob(completedJobId)).toBeUndefined(); expect(manager.getJob(runningJobId)).toBeUndefined(); }); test("acknowledgeDeliveries suppresses pending retries for completed jobs", async () => { + let failedJobId = ""; let attempts = 0; + const sentinelDelivered = Promise.withResolvers(); + const firstAttempt = Promise.withResolvers(); const manager = new AsyncJobManager({ - onJobComplete: async () => { + onJobComplete: async jobId => { + if (jobId !== failedJobId) { + sentinelDelivered.resolve(); + return; + } attempts += 1; + firstAttempt.resolve(); throw new Error("delivery failed"); }, }); - const jobId = manager.register("task", "awaited-job", async () => "done"); + failedJobId = manager.register("task", "awaited-job", async () => "done"); await manager.waitForAll(); - const firstAttemptDeadline = Date.now() + 2_000; - while (attempts === 0) { - if (Date.now() >= firstAttemptDeadline) throw new Error("Timed out waiting for first delivery attempt"); - await Bun.sleep(5); - } + await firstAttempt.promise; + while (!manager.hasPendingDeliveries()) await scheduler.yield(); expect(manager.hasPendingDeliveries()).toBe(true); - const removed = manager.acknowledgeDeliveries([jobId]); + const removed = manager.acknowledgeDeliveries([failedJobId]); expect(removed).toBeGreaterThanOrEqual(1); const drained = await manager.drainDeliveries({ timeoutMs: 200 }); @@ -273,7 +287,9 @@ describe("AsyncJobManager", () => { expect(manager.hasPendingDeliveries()).toBe(false); const attemptsAfterAck = attempts; - await Bun.sleep(700); + manager.register("task", "sentinel-job", async () => "sentinel"); + await manager.waitForAll(); + await sentinelDelivered.promise; expect(attempts).toBe(attemptsAfterAck); }); @@ -304,15 +320,9 @@ describe("AsyncJobManager", () => { return "unreachable"; }); - const startedAt = Date.now(); - const result = await Promise.race([ - manager.dispose({ timeoutMs: 25 }).then(drained => ({ drained, settled: true })), - Bun.sleep(150).then(() => ({ drained: true, settled: false })), - ]); + const drained = await manager.dispose({ timeoutMs: 25 }); - expect(result.settled).toBe(true); - expect(result.drained).toBe(false); - expect(Date.now() - startedAt).toBeLessThan(150); + expect(drained).toBe(false); expect(manager.getAllJobs()).toHaveLength(0); }); @@ -326,11 +336,13 @@ describe("AsyncJobManager", () => { const mainDeliveryReleased = new Promise(resolve => { releaseMainDelivery = resolve; }); + const mainDeliveryFinished = Promise.withResolvers(); const subagentCompletions: Array<{ jobId: string; text: string }> = []; const manager = new AsyncJobManager({ retentionMs: 0 }); manager.registerDeliverySink("0-Main", async () => { notifyMainDeliveryStarted(); await mainDeliveryReleased; + mainDeliveryFinished.resolve(); }); manager.registerDeliverySink("3-AuthLoader", (jobId, text) => { subagentCompletions.push({ jobId, text }); @@ -353,7 +365,8 @@ describe("AsyncJobManager", () => { expect(manager.acknowledgeDeliveries([mainJobId])).toBe(0); expect(manager.hasPendingDeliveries({ ownerId: "0-Main" })).toBe(false); releaseMainDelivery(); - await Bun.sleep(0); + await mainDeliveryFinished.promise; + await manager.dispose(); }); test("scoped delivery drain times out while a matching delivery callback is in flight", async () => { diff --git a/packages/coding-agent/test/async-yield-queue.test.ts b/packages/coding-agent/test/async-yield-queue.test.ts index da92a37f8..3a404dbe2 100644 --- a/packages/coding-agent/test/async-yield-queue.test.ts +++ b/packages/coding-agent/test/async-yield-queue.test.ts @@ -112,14 +112,6 @@ function createHarness(initialStreaming: boolean) { }; } -async function waitUntil(predicate: () => boolean, message: string): Promise { - const deadline = Date.now() + 2_000; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error(message); - await Bun.sleep(5); - } -} - afterEach(async () => { const manager = AsyncJobManager.instance(); if (manager) { @@ -134,7 +126,7 @@ describe("async result yield queue delivery", () => { const jobId = harness.manager.register("bash", "race job", async () => "inline result"); await harness.manager.waitForAll(); - await waitUntil(() => harness.queue.has("async-result"), "Timed out waiting for staged async result"); + expect(await harness.manager.drainDeliveries({ timeoutMs: 2_000 })).toBe(true); const tool = new HubTool(createToolSession(harness.manager)); const result = await tool.execute("tool-call", { op: "wait", ids: [jobId] }); diff --git a/packages/coding-agent/test/auth-storage-minimax-login.test.ts b/packages/coding-agent/test/auth-storage-minimax-login.test.ts index c8231ed42..04e7e7389 100644 --- a/packages/coding-agent/test/auth-storage-minimax-login.test.ts +++ b/packages/coding-agent/test/auth-storage-minimax-login.test.ts @@ -1,13 +1,8 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { FetchImpl } from "@oh-my-pi/pi-ai"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; -import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; describe("AuthStorage MiniMax login", () => { - let tempDir: string; let authStorage: AuthStorage; let currentApiKey = "sk-old"; @@ -28,17 +23,11 @@ describe("AuthStorage MiniMax login", () => { beforeEach(async () => { currentApiKey = "sk-old"; - tempDir = path.join(os.tmpdir(), `pi-test-auth-minimax-${Snowflake.next()}`); - fs.mkdirSync(tempDir, { recursive: true }); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); }); afterEach(() => { - vi.restoreAllMocks(); authStorage.close(); - if (tempDir && fs.existsSync(tempDir)) { - removeSyncWithRetries(tempDir); - } }); test("relogin with a different API key keeps both stored keys", async () => { diff --git a/packages/coding-agent/test/auth-storage-rotation.test.ts b/packages/coding-agent/test/auth-storage-rotation.test.ts index 8df39ef79..b9dd4f884 100644 --- a/packages/coding-agent/test/auth-storage-rotation.test.ts +++ b/packages/coding-agent/test/auth-storage-rotation.test.ts @@ -26,7 +26,7 @@ describe("AuthStorage account rotation", () => { initialCredentials: OAuthCredential[], finalCredentials: OAuthCredential[], ): Promise<{ sessionId: string; stickyKey: string; freshKey: string }> => { - const control = await AuthStorage.create(path.join(tempDir, `issue-4982-control-${Snowflake.next()}.db`), { + const control = await AuthStorage.create(":memory:", { usageProviderResolver: () => undefined, }); try { @@ -66,10 +66,13 @@ describe("AuthStorage account rotation", () => { }; }, }; + const createRotationStorage = (dbPath: string) => + AuthStorage.create(dbPath, { + usageProviderResolver: provider => (provider === "openai-codex" ? usageProvider : undefined), + }); beforeEach(async () => { - tempDir = path.join(os.tmpdir(), `pi-test-auth-rotation-${Snowflake.next()}`); - fs.mkdirSync(tempDir, { recursive: true }); + tempDir = ""; usageExhausted = false; nextLoginCredential = undefined; for (const provider of [targetProvider, unrelatedProvider]) { @@ -86,9 +89,7 @@ describe("AuthStorage account rotation", () => { }); } - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { - usageProviderResolver: provider => (provider === "openai-codex" ? usageProvider : undefined), - }); + authStorage = await createRotationStorage(":memory:"); // Stub the refresh path so AuthStorage doesn't hit a real OAuth endpoint // when the credential lands inside the 60s skew. Returning the credential @@ -289,7 +290,7 @@ describe("AuthStorage account rotation", () => { throw new Error("Expected bundled Codex test model to exist"); } - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + const modelRegistry = new ModelRegistry(authStorage, undefined, { ignoreLocalModelConfig: true }); const attemptedKeys: string[] = []; const result = await withAuth(modelRegistry.resolver(model, "codex-four-oauth-session"), async key => { attemptedKeys.push(key); @@ -306,6 +307,10 @@ describe("AuthStorage account rotation", () => { }); test("provider login invalidates only that provider's persisted session stickiness", async () => { + tempDir = path.join(os.tmpdir(), `pi-test-auth-rotation-${Snowflake.next()}`); + fs.mkdirSync(tempDir, { recursive: true }); + authStorage.close(); + authStorage = await createRotationStorage(path.join(tempDir, "testauth.db")); const targetInitialCredentials: OAuthCredential[] = [ { type: "oauth", @@ -370,9 +375,7 @@ describe("AuthStorage account rotation", () => { nextLoginCredential = undefined; authStorage.close(); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"), { - usageProviderResolver: provider => (provider === "openai-codex" ? usageProvider : undefined), - }); + authStorage = await createRotationStorage(path.join(tempDir, "testauth.db")); await authStorage.reload(); const reloadedTargetKey = await authStorage.getApiKey(targetProvider, sessionId); diff --git a/packages/coding-agent/test/auto-thinking-classifier.test.ts b/packages/coding-agent/test/auto-thinking-classifier.test.ts index d579f46f8..1edccfd7e 100644 --- a/packages/coding-agent/test/auto-thinking-classifier.test.ts +++ b/packages/coding-agent/test/auto-thinking-classifier.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; import { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import * as ai from "@oh-my-pi/pi-ai"; import { Effort, type Model } from "@oh-my-pi/pi-ai"; @@ -10,9 +9,7 @@ import { parseDifficultyBucket, parseDifficultyLevel, } from "@oh-my-pi/pi-coding-agent/auto-thinking/classifier"; -import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { AUTO_THINKING, clampAutoThinkingEffort, @@ -25,40 +22,20 @@ import { } from "@oh-my-pi/pi-coding-agent/thinking"; import type { TinyMemoryLocalModelKey } from "@oh-my-pi/pi-coding-agent/tiny/models"; import { tinyModelClient } from "@oh-my-pi/pi-coding-agent/tiny/title-client"; -import { TempDir } from "@oh-my-pi/pi-utils"; describe("auto thinking classifier helpers", () => { afterEach(() => { vi.restoreAllMocks(); }); - interface LocalClassifierFixture { - settings: Settings; - registry: ModelRegistry; - model: Model; - cleanup: () => void; - } - - async function createLocalClassifierFixture( - autoThinkingModel: TinyMemoryLocalModelKey, - ): Promise { - const tempDir = TempDir.createSync("@pi-auto-thinking-classifier-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + function createLocalClassifierFixture(autoThinkingModel: TinyMemoryLocalModelKey) { const model = getBundledModel("anthropic", "claude-sonnet-4-6"); - if (!model) { - authStorage.close(); - tempDir.removeSync(); - throw new Error("Expected bundled Claude Sonnet 4.6 model"); - } + if (!model) throw new Error("Expected bundled Claude Sonnet 4.6 model"); return { settings: Settings.isolated({ "providers.autoThinkingModel": autoThinkingModel }), - registry: new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")), + registry: null as never, model, - cleanup: () => { - authStorage.close(); - tempDir.removeSync(); - }, }; } @@ -96,24 +73,16 @@ describe("auto thinking classifier helpers", () => { it("expands the local reasoning classifier budget", async () => { let maxTokens: number | undefined; - const fixture = await createLocalClassifierFixture("qwen3-1.7b"); + const fixture = createLocalClassifierFixture("qwen3-1.7b"); vi.spyOn(tinyModelClient, "complete").mockImplementation(async (_modelKey, _prompt, options) => { maxTokens = options?.maxTokens; return "moderate"; }); - try { - const effort = await classifyDifficulty("fix the local classifier token budget", { - settings: fixture.settings, - registry: fixture.registry, - model: fixture.model, - }); + const effort = await classifyDifficulty("fix the local classifier token budget", fixture); - expect(effort).toBe(Effort.High); - expect(maxTokens).toBe(1024); - } finally { - fixture.cleanup(); - } + expect(effort).toBe(Effort.High); + expect(maxTokens).toBe(1024); }); it("keeps the local classifier capped at xhigh even when opted in to max", async () => { @@ -122,74 +91,53 @@ describe("auto thinking classifier helpers", () => { // on-device model never selected. `max` is the model's only tier at or // above Low, and the local ceiling hides it, so nothing is eligible — // falling through to `minimal` would breach the Low floor. - const fixture = await createLocalClassifierFixture("qwen3-1.7b"); + const fixture = createLocalClassifierFixture("qwen3-1.7b"); const sparse = buildLadderModel("mock-minimal-max", [Effort.Minimal, Effort.Max]); vi.spyOn(tinyModelClient, "complete").mockResolvedValue("hard"); + const settings = Settings.isolated({ + "providers.autoThinkingModel": "qwen3-1.7b", + "providers.autoThinkingMaxEffort": "max", + }); - try { - const settings = Settings.isolated({ - "providers.autoThinkingModel": "qwen3-1.7b", - "providers.autoThinkingMaxEffort": "max", - }); - - expect( - await classifyDifficulty("cut over the storage layer", { - settings, - registry: fixture.registry, - model: sparse, - }), - ).toBeUndefined(); - } finally { - fixture.cleanup(); - } + expect( + await classifyDifficulty("cut over the storage layer", { + settings, + registry: fixture.registry, + model: sparse, + }), + ).toBeUndefined(); }); it("uses a larger local non-reasoning classifier floor", async () => { let maxTokens: number | undefined; - const fixture = await createLocalClassifierFixture("qwen2.5-1.5b"); + const fixture = createLocalClassifierFixture("qwen2.5-1.5b"); vi.spyOn(tinyModelClient, "complete").mockImplementation(async (_modelKey, _prompt, options) => { maxTokens = options?.maxTokens; return "moderate"; }); - try { - const effort = await classifyDifficulty("rename a local helper", { - settings: fixture.settings, - registry: fixture.registry, - model: fixture.model, - }); + const effort = await classifyDifficulty("rename a local helper", fixture); - expect(effort).toBe(Effort.High); - expect(maxTokens).toBe(16); - } finally { - fixture.cleanup(); - } + expect(effort).toBe(Effort.High); + expect(maxTokens).toBe(16); }); it("uses shared tiny-message preprocessing before local classification", async () => { let classifierPrompt = ""; - const fixture = await createLocalClassifierFixture("qwen2.5-1.5b"); + const fixture = createLocalClassifierFixture("qwen2.5-1.5b"); vi.spyOn(tinyModelClient, "complete").mockImplementation(async (_modelKey, promptText) => { classifierPrompt = promptText; return "moderate"; }); - try { - await classifyDifficulty( - "\u001b[31minvestigate failure\u001b[0m 54783db3f0f17c74cae81976f0e825a909deb71e\n```\nnoisy code\n```", - { - settings: fixture.settings, - registry: fixture.registry, - model: fixture.model, - }, - ); + await classifyDifficulty( + "\u001b[31minvestigate failure\u001b[0m 54783db3f0f17c74cae81976f0e825a909deb71e\n```\nnoisy code\n```", + fixture, + ); - expect(classifierPrompt).toContain("investigate failure 54783db"); - expect(classifierPrompt).not.toContain("54783db3f0f17c74cae81976f0e825a909deb71e"); - expect(classifierPrompt).not.toContain("noisy code"); - } finally { - fixture.cleanup(); - } + expect(classifierPrompt).toContain("investigate failure 54783db"); + expect(classifierPrompt).not.toContain("54783db3f0f17c74cae81976f0e825a909deb71e"); + expect(classifierPrompt).not.toContain("noisy code"); }); it("uses a reasoning-safe online classifier budget when the catalog disables reasoning", async () => { diff --git a/packages/coding-agent/test/bash-acp-terminal.test.ts b/packages/coding-agent/test/bash-acp-terminal.test.ts index 5ffc27736..a6b2d2cd1 100644 --- a/packages/coding-agent/test/bash-acp-terminal.test.ts +++ b/packages/coding-agent/test/bash-acp-terminal.test.ts @@ -37,6 +37,24 @@ function makeSession(bridge: ClientBridge): ToolSession { } as unknown as ToolSession; } +// Keep the real promise/race behavior while compressing only ACP's deliberately +// conservative wall-clock deadline and cleanup grace periods. +function shortenAcpWaits(): void { + const realSetTimeout = globalThis.setTimeout; + spyOn(globalThis, "setTimeout").mockImplementation(((handler: () => void, ms?: number, ...args: unknown[]) => + realSetTimeout( + handler, + typeof ms === "number" && ms >= 1000 ? 50 : ms, + ...args, + )) as typeof globalThis.setTimeout); + const realSleep = Bun.sleep.bind(Bun); + spyOn(Bun, "sleep").mockImplementation((duration?: number | Date) => { + if (duration === 250) return realSleep(1); + if (typeof duration === "number" && duration >= 1000) return realSleep(5); + return realSleep(duration ?? 0); + }); +} + afterEach(() => { mock.restore(); }); @@ -149,6 +167,7 @@ describe("BashTool ACP terminal routing", () => { }); it("resolves using the last polled output when final output retrieval fails", async () => { + shortenAcpWaits(); const pendingExit = Promise.withResolvers<{ exitCode: number | null; signal: string | null }>(); let currentOutputCalls = 0; const handle: ClientBridgeTerminalHandle = { @@ -207,6 +226,7 @@ describe("BashTool ACP terminal routing", () => { }); it("kills and releases the client terminal when the caller aborts", async () => { + shortenAcpWaits(); const pendingExit = Promise.withResolvers<{ exitCode: number | null; signal: string | null }>(); const controller = new AbortController(); @@ -238,9 +258,9 @@ describe("BashTool ACP terminal routing", () => { }); it("kills and releases the client terminal when the command times out", async () => { - // Real 1s timeout — no Bun.sleep/setTimeout mocking. Mocking the timer - // implementation couples the test to how the timeout is scheduled and - // starves the event loop when the implementation changes. + // The timeout and cleanup windows are compressed; the terminal promises, + // kill-before-output ordering, and hung-RPC behavior remain real. + shortenAcpWaits(); const pendingExit = Promise.withResolvers<{ exitCode: number | null; signal: string | null }>(); let killCalls = 0; let currentOutputAfterKill = 0; @@ -275,8 +295,9 @@ describe("BashTool ACP terminal routing", () => { }); it("still times out when a poll-tick output read hangs", async () => { - // Real 1s timeout — deliberately exercises the wall-clock deadline against - // an RPC that never settles; fake timers cannot model a hung peer. + // A never-settling output RPC exercises the real race while the outer + // deadline is compressed to avoid paying a full second. + shortenAcpWaits(); const pendingExit = Promise.withResolvers<{ exitCode: number | null; signal: string | null }>(); const neverOutput = new Promise<{ output: string; truncated: boolean }>(() => {}); @@ -303,8 +324,9 @@ describe("BashTool ACP terminal routing", () => { }, 8000); it("returns even when terminal release hangs", async () => { - // Real-time grace bound: release() never settles; the tool must still - // resolve once the kill-grace window elapses. + // release() truly never settles; only the production grace sleep is + // compressed so this still proves cleanup is bounded. + shortenAcpWaits(); const stubText = "done\n"; const neverRelease = new Promise(() => {}); diff --git a/packages/coding-agent/test/bash-execution-clamp.test.ts b/packages/coding-agent/test/bash-execution-clamp.test.ts index cccb0a858..8c77ba11c 100644 --- a/packages/coding-agent/test/bash-execution-clamp.test.ts +++ b/packages/coding-agent/test/bash-execution-clamp.test.ts @@ -1,18 +1,23 @@ -import { beforeEach, describe, expect, it } from "bun:test"; +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { BashExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/bash-execution"; -import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, setThemeInstance, type Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { TUI } from "@oh-my-pi/pi-tui"; import { visibleWidth } from "@oh-my-pi/pi-tui"; const MAX_DISPLAY_LINE_CHARS = 4000; +let darkTheme: Theme; + +beforeAll(async () => { + const loaded = await getThemeByName("dark"); + expect(loaded).toBeDefined(); + darkTheme = loaded!; +}); describe("BashExecutionComponent #clampDisplayLine", () => { const ui = { requestRender: () => {}, requestComponentRender: () => {} } as unknown as TUI; - beforeEach(async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - setThemeInstance(theme!); + beforeEach(() => { + setThemeInstance(darkTheme); }); function createComponentWithOutput(output: string): BashExecutionComponent { diff --git a/packages/coding-agent/test/bash-execution-sixel.test.ts b/packages/coding-agent/test/bash-execution-sixel.test.ts index 9a3a86fa7..46e83e4fa 100644 --- a/packages/coding-agent/test/bash-execution-sixel.test.ts +++ b/packages/coding-agent/test/bash-execution-sixel.test.ts @@ -1,21 +1,26 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { BashExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/bash-execution"; -import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, setThemeInstance, type Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { sanitizeWithOptionalSixelPassthrough } from "@oh-my-pi/pi-coding-agent/utils/sixel"; import type { TUI } from "@oh-my-pi/pi-tui"; import { sanitizeText } from "@oh-my-pi/pi-utils"; const SIXEL = "\x1bPqabc\x1b\\"; +let darkTheme: Theme; + +beforeAll(async () => { + const loaded = await getThemeByName("dark"); + expect(loaded).toBeDefined(); + darkTheme = loaded!; +}); describe("BashExecutionComponent SIXEL sanitization", () => { const originalForceProtocol = Bun.env.PI_FORCE_IMAGE_PROTOCOL; const originalAllowPassthrough = Bun.env.PI_ALLOW_SIXEL_PASSTHROUGH; const ui = { requestRender: () => {}, requestComponentRender: () => {} } as unknown as TUI; - beforeEach(async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - setThemeInstance(theme!); + beforeEach(() => { + setThemeInstance(darkTheme); }); afterEach(() => { if (originalForceProtocol === undefined) delete Bun.env.PI_FORCE_IMAGE_PROTOCOL; @@ -83,10 +88,8 @@ describe("BashExecutionComponent SIXEL sanitization", () => { describe("BashExecutionComponent streaming throttle", () => { const ui = { requestRender: () => {}, requestComponentRender: () => {} } as unknown as TUI; - beforeEach(async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - setThemeInstance(theme!); + beforeEach(() => { + setThemeInstance(darkTheme); }); it("caps stored lines during streaming", () => { @@ -106,23 +109,26 @@ describe("BashExecutionComponent streaming throttle", () => { expect(output).not.toContain("line0\n"); }); - it("gate drops rapid chunks", async () => { - const component = new BashExecutionComponent("test", ui, false); + it("gate drops rapid chunks", () => { + vi.useFakeTimers(); + try { + const component = new BashExecutionComponent("test", ui, false); - // Send 100 chunks rapidly (all in same tick, before setTimeout fires) - for (let i = 0; i < 100; i++) { - component.appendOutput(`chunk${i}\n`); + // Send 100 chunks rapidly (all in same tick, before the gate fires). + for (let i = 0; i < 100; i++) { + component.appendOutput(`chunk${i}\n`); + } + + const output = component.getOutput(); + expect(output).toContain("chunk0"); + expect(output).not.toContain("chunk99"); + + vi.advanceTimersByTime(50); + component.appendOutput("after_gate\n"); + expect(component.getOutput()).toContain("after_gate"); + } finally { + vi.useRealTimers(); } - - // Only the first chunk should have been processed (gate blocks the rest) - const output = component.getOutput(); - expect(output).toContain("chunk0"); - expect(output).not.toContain("chunk99"); - - // After the gate timer expires, the next chunk is accepted - await Bun.sleep(60); // CHUNK_THROTTLE_MS is 50 - component.appendOutput("after_gate\n"); - expect(component.getOutput()).toContain("after_gate"); }); it("setComplete replaces streaming output with final output", () => { @@ -145,10 +151,8 @@ describe("BashExecutionComponent streaming throttle", () => { describe("BashExecutionComponent expand footer", () => { const ui = { requestRender: () => {}, requestComponentRender: () => {} } as unknown as TUI; - beforeEach(async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - setThemeInstance(theme!); + beforeEach(() => { + setThemeInstance(darkTheme); }); // PREVIEW_LINES is 20: 27 lines leaves 7 hidden in the collapsed preview. diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index c06b6e3a8..33e7b75ff 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -526,24 +526,6 @@ exit 64 expect(seenChunk ?? "").toContain("hello"); }); - it("returns even if command spawns a background job", async () => { - if (process.platform === "win32") { - return; - } - const runPromise = executeBash("{ sleep 2; } & echo fg", { - cwd: tempDir, - timeout: 5000, - }); - const timed = await Promise.race([ - runPromise.then(result => ({ type: "result" as const, result })), - Bun.sleep(BACKGROUND_COMPLETION_RACE_MS).then(() => ({ type: "timeout" as const })), - ]); - expect(timed.type).toBe("result"); - if (timed.type === "result") { - expect(timed.result.output).toContain("fg"); - } - }); - it("returns a real PID for background external commands", async () => { if (process.platform === "win32") { return; @@ -552,7 +534,8 @@ exit 64 // Redirect the backgrounded job's stdout so it doesn't hold the executor's // output pipe open (which would add the ~250ms background-drain grace); // `$!` still reports the real external PID, which is all this test checks. - const result = await executeBash('python3 -c "import time; time.sleep(10)" >/dev/null 2>&1 & echo $!', { + const sleepBin = fs.existsSync("/bin/sleep") ? "/bin/sleep" : "sleep"; + const result = await executeBash(`${sleepBin} 30 >/dev/null 2>&1 & echo $!`, { cwd: tempDir, timeout: 5000, }); @@ -586,7 +569,17 @@ exit 64 if (process.platform === "win32") { return; } - const result = await executeBash("sleep 1.2; echo done", { cwd: tempDir, timeout: 0 }); + // Compress any accidentally armed one-second deadline. The real command + // runs longer than that compressed window, so the success result proves + // timeout:0 left the execution deadline disabled without a 1.2s sleep. + const realSetTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, "setTimeout").mockImplementation(((handler: () => void, ms?: number, ...rest: unknown[]) => + realSetTimeout( + handler, + typeof ms === "number" && ms >= 1000 ? 5 : ms, + ...rest, + )) as typeof globalThis.setTimeout); + const result = await executeBash("sleep 0.03; echo done", { cwd: tempDir, timeout: 0 }); expect(result.cancelled).toBe(false); expect(result.output.trim()).toBe("done"); }); @@ -596,12 +589,14 @@ exit 64 return; } const controller = new AbortController(); - const promise = executeBash("sleep 10", { + const started = Promise.withResolvers(); + const promise = executeBash("echo started; sleep 10", { cwd: tempDir, timeout: 5000, signal: controller.signal, + onChunk: () => started.resolve(), }); - await Bun.sleep(50); + await started.promise; controller.abort(); const result = await promise; expect(result.cancelled).toBe(true); @@ -786,12 +781,14 @@ exit 64 return; } const controller = new AbortController(); - const promise = executeBash("sleep 10; echo done", { + const started = Promise.withResolvers(); + const promise = executeBash("echo started; sleep 10; echo done", { cwd: tempDir, timeout: 5000, signal: controller.signal, + onChunk: () => started.resolve(), }); - await Bun.sleep(50); + await started.promise; controller.abort(); const result = await promise; expect(result.cancelled).toBe(true); diff --git a/packages/coding-agent/test/bash-failure-result.test.ts b/packages/coding-agent/test/bash-failure-result.test.ts index c71d7035a..78e126483 100644 --- a/packages/coding-agent/test/bash-failure-result.test.ts +++ b/packages/coding-agent/test/bash-failure-result.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; +import { Shell } from "@oh-my-pi/pi-natives"; + +afterEach(() => { + mock.restore(); +}); function makeSession(): ToolSession { return { @@ -46,6 +51,12 @@ describe("BashTool execution results", () => { }); it("returns a warning-state timeout result with one timeout notice", async () => { + // Keep the real native subprocess timeout path, but compress its backend + // deadline; BashTool must still report the user-facing one-second timeout. + const realRun = Shell.prototype.run; + spyOn(Shell.prototype, "run").mockImplementation(function (this: Shell, options, onChunk) { + return realRun.call(this, { ...options, timeoutMs: 20 }, onChunk); + }); const tool = new BashTool(makeSession()); const result = await tool.execute("call-timeout", { command: "sleep 3", timeout: 1 }); @@ -56,10 +67,16 @@ describe("BashTool execution results", () => { }); it("preserves the executor cancellation notice without classifying it as a timeout", async () => { + const dispatched = Promise.withResolvers(); + const realRun = Shell.prototype.run; + spyOn(Shell.prototype, "run").mockImplementation(function (this: Shell, options, onChunk) { + dispatched.resolve(); + return realRun.call(this, options, onChunk); + }); const tool = new BashTool(makeSession()); const controller = new AbortController(); const execution = tool.execute("call-cancel", { command: "sleep 3" }, controller.signal); - await Bun.sleep(20); + await dispatched.promise; controller.abort(); const error = await execution.catch(error => error); diff --git a/packages/coding-agent/test/cli-explicit-extension-isolation.test.ts b/packages/coding-agent/test/cli-explicit-extension-isolation.test.ts index 3fb01a2d4..1dbbebc58 100644 --- a/packages/coding-agent/test/cli-explicit-extension-isolation.test.ts +++ b/packages/coding-agent/test/cli-explicit-extension-isolation.test.ts @@ -1,7 +1,7 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterAll, beforeAll, expect, test } from "bun:test"; import { realpathSync } from "node:fs"; import { symlink, unlink } from "node:fs/promises"; -import { AuthStorage } from "@oh-my-pi/pi-ai"; +import type { AuthStorage } from "@oh-my-pi/pi-ai"; import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -10,16 +10,17 @@ import { loadSessionExtensions } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; let tempDir: TempDir; let authStorage: AuthStorage; -beforeEach(async () => { +beforeAll(async () => { tempDir = await TempDir.create("@cli-explicit-extension-isolation-"); - authStorage = await AuthStorage.create(tempDir.join("auth.db")); + authStorage = createInMemoryAuthStorage(); }); -afterEach(async () => { +afterAll(async () => { authStorage.close(); await tempDir.remove(); }); @@ -37,20 +38,7 @@ test("buildSessionOptions retains explicit extensions and hooks under --no-exten expect(options.additionalExtensionPaths).toEqual([extensionPath, hookPath]); }); -test("buildSessionOptions uses trusted extensions as the exact module allowlist", async () => { - const trustedPath = tempDir.join("trusted.ts"); - await Bun.write(trustedPath, "export default function () {}"); - const parsed = parseArgs(["--trusted-extension", trustedPath]); - const settings = Settings.isolated(); - const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); - - const options = await buildSessionOptions(parsed, [], SessionManager.inMemory(), modelRegistry, settings); - - expect(options.disableExtensionDiscovery).toBe(true); - expect(options.additionalExtensionPaths).toEqual([realpathSync.native(trustedPath)]); -}); - -test("trusted file symlinks cannot be retargeted to expand a directory", async () => { +test("trusted extension allowlists are canonical and cannot be expanded by retargeting a symlink", async () => { const trustedTarget = tempDir.join("trusted-target.ts"); const replacementDir = tempDir.join("replacement"); const trustedLink = tempDir.join("trusted.ts"); @@ -63,6 +51,9 @@ test("trusted file symlinks cannot be retargeted to expand a directory", async ( const modelRegistry = new ModelRegistry(authStorage, tempDir.join("models.yml")); const options = await buildSessionOptions(parsed, [], SessionManager.inMemory(), modelRegistry, settings); + expect(options.disableExtensionDiscovery).toBe(true); + expect(options.additionalExtensionPaths).toEqual([realpathSync.native(trustedTarget)]); + await unlink(trustedLink); await symlink(replacementDir, trustedLink); const result = await loadSessionExtensions(options, tempDir.path(), settings, new EventBus()); diff --git a/packages/coding-agent/test/cli-max-time-flag.test.ts b/packages/coding-agent/test/cli-max-time-flag.test.ts index 1dc5a7b5d..c582cb52b 100644 --- a/packages/coding-agent/test/cli-max-time-flag.test.ts +++ b/packages/coding-agent/test/cli-max-time-flag.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; import { parseArgs } from "@oh-my-pi/pi-coding-agent/cli/args"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { runRootCommand } from "@oh-my-pi/pi-coding-agent/main"; @@ -79,7 +78,7 @@ describe("parseArgs — --max-time flag", () => { it("converts maxTime to an absolute session deadline", async () => { using tempDir = TempDir.createSync("@omp-max-time-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); const settings = Settings.isolated({ "marketplace.autoUpdate": "off" }); let observedOptions: CreateAgentSessionOptions | undefined; const parsed = parseArgs(["--max-time", "3", "--print", "hello"]); diff --git a/packages/coding-agent/test/cli-service-tier-flag.test.ts b/packages/coding-agent/test/cli-service-tier-flag.test.ts index 59612ea5e..b0e82ddb3 100644 --- a/packages/coding-agent/test/cli-service-tier-flag.test.ts +++ b/packages/coding-agent/test/cli-service-tier-flag.test.ts @@ -24,8 +24,7 @@ describe("--service-tier", () => { }); it("maps none to an explicit OpenAI service-tier omission", async () => { - using tempDir = TempDir.createSync("@omp-service-tier-"); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); try { const options = await buildSessionOptions( parseArgs(["--service-tier", "none"]), @@ -42,13 +41,12 @@ describe("--service-tier", () => { }); it("overrides only the OpenAI family in the live session", async () => { - using tempDir = TempDir.createSync("@omp-service-tier-sdk-"); const authStorage = await AuthStorage.create(":memory:"); const sessionManager = SessionManager.inMemory(); try { const { session } = await createAgentSession({ - cwd: tempDir.path(), - agentDir: tempDir.path(), + cwd: process.cwd(), + agentDir: process.cwd(), modelRegistry: new ModelRegistry(authStorage), settings: Settings.isolated({ "tier.anthropic": "priority" }), sessionManager, diff --git a/packages/coding-agent/test/cli/completions.test.ts b/packages/coding-agent/test/cli/completions.test.ts index 634276086..24026d164 100644 --- a/packages/coding-agent/test/cli/completions.test.ts +++ b/packages/coding-agent/test/cli/completions.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it } from "bun:test"; -import * as path from "node:path"; import { buildSpec, type CompletionSpec, generateCompletion } from "@oh-my-pi/pi-coding-agent/cli/completion-gen"; +import { generateLiveCompletion } from "@oh-my-pi/pi-coding-agent/commands/completions"; import type { CliConfig, CommandCtor } from "@oh-my-pi/pi-utils/cli"; -const repoRoot = path.resolve(import.meta.dir, "..", "..", "..", ".."); -const cliEntry = path.join(repoRoot, "packages", "coding-agent", "src", "cli.ts"); - // A compact synthetic spec exercising every value-source kind and an aliased // subcommand. The generators are pure functions of this shape, so pinning their // output here defends the exact bytes each shell parses without booting the CLI. @@ -188,20 +185,9 @@ describe("buildSpec", () => { }); }); -describe("omp completions (integration / drift)", () => { - it("emits a zsh script reflecting the live command + flag surface", async () => { - const proc = Bun.spawn([process.execPath, cliEntry, "completions", "zsh"], { - cwd: repoRoot, - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, NO_COLOR: "1", PI_NO_TITLE: "1" }, - }); - const [stdout, , exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - expect(exitCode).toBe(0); +describe("live completion surface", () => { + it("generates a zsh script reflecting the registered commands and flags", async () => { + const stdout = await generateLiveCompletion("zsh"); // Real top-level flags from launch's static `flags` table. Flags with a // short char render as `{-r,--resume}`, so only assert the bracket form for @@ -224,8 +210,5 @@ describe("omp completions (integration / drift)", () => { // Hidden/default commands must NOT surface as completable subcommands. expect(stdout).not.toContain("_omp_cmd_launch"); expect(stdout).not.toContain("_omp_cmd___complete"); - // Spawns the whole CLI entry graph, so the wall time is cold-transpile bound - // (~1s warm) rather than an assertion about latency. Bun's 5s default starves - // it when CI runs several test chunks in parallel on a shared runner. }, 30_000); }); diff --git a/packages/coding-agent/test/cli/update-rename-migration.integration.test.ts b/packages/coding-agent/test/cli/update-rename-migration.integration.test.ts index 1e6f4a8a1..29c723a3a 100644 --- a/packages/coding-agent/test/cli/update-rename-migration.integration.test.ts +++ b/packages/coding-agent/test/cli/update-rename-migration.integration.test.ts @@ -17,11 +17,10 @@ * Each scenario runs the full install-new/remove-old/verify transaction and * asserts the resulting launcher executes the NEW version. */ -import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs/promises"; -import * as os from "node:os"; import * as path from "node:path"; -import { $which, removeWithRetries } from "@oh-my-pi/pi-utils"; +import { $which, TempDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import { type InstalledVersionVerification, @@ -35,24 +34,27 @@ const OLD_PKG = "omp-rename-fixture-old"; const NEW_PKG = "omp-rename-fixture-new"; const OLD_VERSION = "1.0.0"; const NEW_VERSION = "2.0.0"; - -const tempDirs: string[] = []; +let fixtureDir: TempDir; +let oldDir: string; +let newDir: string; // printVerifiedVersion renders theme glyphs; the update command initializes -// the theme before calling into update-cli, so the tests must too. +// the theme before calling into update-cli, so the tests must too. Keep one +// process-wide log spy so the package-manager scenarios can run concurrently. beforeAll(async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); await initTheme(); + fixtureDir = await TempDir.create("@omp-rename-itest-"); + ({ oldDir, newDir } = await makeFixtures(fixtureDir.path())); }); -afterEach(async () => { +afterAll(async () => { vi.restoreAllMocks(); - await Promise.all(tempDirs.splice(0).map(dir => removeWithRetries(dir))); + await fixtureDir.remove(); }); -/** Two installable packages that both expose an `omp` bin, plus an empty prefix. */ -async function makeFixtures(): Promise<{ root: string; oldDir: string; newDir: string }> { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-rename-itest-")); - tempDirs.push(root); +/** Two shared, read-only packages that expose the same `omp` bin. */ +async function makeFixtures(root: string): Promise<{ oldDir: string; newDir: string }> { const mkpkg = async (name: string, version: string): Promise => { const dir = path.join(root, name); await Bun.write(path.join(dir, "package.json"), JSON.stringify({ name, version, bin: { omp: "cli.js" } })); @@ -61,7 +63,7 @@ async function makeFixtures(): Promise<{ root: string; oldDir: string; newDir: s await fs.chmod(cli, 0o755); return dir; }; - return { root, oldDir: await mkpkg(OLD_PKG, OLD_VERSION), newDir: await mkpkg(NEW_PKG, NEW_VERSION) }; + return { oldDir: await mkpkg(OLD_PKG, OLD_VERSION), newDir: await mkpkg(NEW_PKG, NEW_VERSION) }; } /** Run the installed launcher and parse its reported version, mirroring verifyBinaryAtPath. */ @@ -80,9 +82,8 @@ const RELEASE: ReleaseInfo = { }; describe.skipIf(process.platform === "win32" || !$which("npm"))("rename migration over real npm", () => { - it("takes bin ownership with --force, survives the uninstall deleting the bin, and lands on the new version", async () => { - vi.spyOn(console, "log").mockImplementation(() => {}); - const { root, oldDir, newDir } = await makeFixtures(); + it.concurrent("takes bin ownership with --force, survives the uninstall deleting the bin, and lands on the new version", async () => { + const root = fixtureDir.path(); const prefix = path.join(root, "npm-prefix"); const binDir = path.join(prefix, "bin"); const env = { @@ -95,7 +96,6 @@ describe.skipIf(process.platform === "win32" || !$which("npm"))("rename migratio const seed = await $`npm install -g --prefix ${prefix} ${oldDir}`.env(env).quiet().nothrow(); expect(seed.exitCode).toBe(0); - expect(await verifyLauncher(binDir, OLD_VERSION)).toMatchObject({ ok: true, actual: OLD_VERSION }); // The load-bearing precondition for --force: while the old package owns // the bin, a plain install of the new package fails instead of clobbering. @@ -103,6 +103,7 @@ describe.skipIf(process.platform === "win32" || !$which("npm"))("rename migratio expect(plain.exitCode).not.toBe(0); expect(await verifyLauncher(binDir, OLD_VERSION)).toMatchObject({ ok: true, actual: OLD_VERSION }); + const verifications: InstalledVersionVerification[] = []; const steps: RenameMigrationSteps = { async install() { return (await $`npm install -g --force --prefix ${prefix} ${newDir}`.env(env).quiet().nothrow()).exitCode; @@ -110,11 +111,18 @@ describe.skipIf(process.platform === "win32" || !$which("npm"))("rename migratio async removeOld() { return (await $`npm uninstall -g --prefix ${prefix} ${OLD_PKG}`.env(env).quiet().nothrow()).exitCode; }, - verify: () => verifyLauncher(binDir, NEW_VERSION), + async verify() { + const result = await verifyLauncher(binDir, NEW_VERSION); + verifications.push(result); + return result; + }, }; await migrateRenamedInstall(RELEASE, steps); - expect(await verifyLauncher(binDir, NEW_VERSION)).toMatchObject({ ok: true, actual: NEW_VERSION }); + expect(verifications.map(result => ({ ok: result.ok, actual: result.actual }))).toEqual([ + { ok: false, actual: undefined }, + { ok: true, actual: NEW_VERSION }, + ]); const globalPackages = await fs.readdir(path.join(prefix, "lib", "node_modules")); expect(globalPackages).toContain(NEW_PKG); expect(globalPackages).not.toContain(OLD_PKG); @@ -122,9 +130,8 @@ describe.skipIf(process.platform === "win32" || !$which("npm"))("rename migratio }); describe.skipIf(process.platform === "win32")("rename migration over real bun", () => { - it("clobbers the old bin on install, survives removing the old package, and lands on the new version", async () => { - vi.spyOn(console, "log").mockImplementation(() => {}); - const { root, oldDir, newDir } = await makeFixtures(); + it.concurrent("clobbers the old bin on install, survives removing the old package, and lands on the new version", async () => { + const root = fixtureDir.path(); const binDir = path.join(root, "bun-bin"); await fs.mkdir(binDir, { recursive: true }); const env = { @@ -137,6 +144,7 @@ describe.skipIf(process.platform === "win32")("rename migration over real bun", expect(seed.exitCode).toBe(0); expect(await verifyLauncher(binDir, OLD_VERSION)).toMatchObject({ ok: true, actual: OLD_VERSION }); + const verifications: InstalledVersionVerification[] = []; const steps: RenameMigrationSteps = { async install() { return (await $`bun add -g file:${newDir}`.env(env).quiet().nothrow()).exitCode; @@ -144,11 +152,17 @@ describe.skipIf(process.platform === "win32")("rename migration over real bun", async removeOld() { return (await $`bun remove -g ${OLD_PKG}`.env(env).quiet().nothrow()).exitCode; }, - verify: () => verifyLauncher(binDir, NEW_VERSION), + async verify() { + const result = await verifyLauncher(binDir, NEW_VERSION); + verifications.push(result); + return result; + }, }; await migrateRenamedInstall(RELEASE, steps); - expect(await verifyLauncher(binDir, NEW_VERSION)).toMatchObject({ ok: true, actual: NEW_VERSION }); + expect(verifications.map(result => ({ ok: result.ok, actual: result.actual }))).toEqual([ + { ok: true, actual: NEW_VERSION }, + ]); const globalManifest = await Bun.file(path.join(root, "bun-global", "package.json")).json(); expect(Object.keys(globalManifest.dependencies ?? {})).toEqual([NEW_PKG]); }, 120_000); diff --git a/packages/coding-agent/test/codex-auto-reset-integration.test.ts b/packages/coding-agent/test/codex-auto-reset-integration.test.ts index a5926a39a..92dd5f2b3 100644 --- a/packages/coding-agent/test/codex-auto-reset-integration.test.ts +++ b/packages/coding-agent/test/codex-auto-reset-integration.test.ts @@ -23,8 +23,7 @@ * sweep scheduling — is real. Each test injects its own coordinator, so the * process-wide default is never touched. */ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { scheduler } from "node:timers/promises"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { ResetCreditAccountStatus, ResetCreditTarget, UsageReport } from "@oh-my-pi/pi-ai"; @@ -40,7 +39,6 @@ import { createCodexAutoRedeemCoordinator, } from "@oh-my-pi/pi-coding-agent/session/codex-auto-reset"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; const ACCOUNT_ID = "acct-1"; const EMAIL = "user@example.com"; @@ -107,17 +105,18 @@ function liveCreditStatus(availableCount: number, expiresInMs?: number): ResetCr } describe("codex saved-reset trigger integration", () => { - let tempDir: TempDir; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; let sessions: AgentSession[]; let managers: SessionManager[]; - beforeEach(async () => { - tempDir = TempDir.createSync("@pi-codex-reset-int-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + beforeAll(async () => { + authStorage = await AuthStorage.create(":memory:"); + modelRegistry = new ModelRegistry(authStorage, undefined, { ignoreLocalModelConfig: true }); + }); + + beforeEach(() => { vi.spyOn(aiStream, "getEnvApiKey").mockReturnValue(undefined); - modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); sessions = []; managers = []; }); @@ -129,11 +128,13 @@ describe("codex saved-reset trigger integration", () => { for (const manager of managers.splice(0).reverse()) { await manager.close(); } - authStorage.close(); - tempDir.removeSync(); vi.restoreAllMocks(); }); + afterAll(() => { + authStorage.close(); + }); + interface HarnessOpts { settings: Record; report: UsageReport; @@ -185,7 +186,7 @@ describe("codex saved-reset trigger integration", () => { }); settings.setModelRole("default", `${model.provider}/${model.id}`); - const sessionManager = SessionManager.create(tempDir.path(), path.join(tempDir.path(), "sessions")); + const sessionManager = SessionManager.inMemory(); managers.push(sessionManager); const coordinator = createCodexAutoRedeemCoordinator(); const session = new AgentSession({ diff --git a/packages/coding-agent/test/collab/session-replication.test.ts b/packages/coding-agent/test/collab/session-replication.test.ts index 2d63c7ecc..4e7859b34 100644 --- a/packages/coding-agent/test/collab/session-replication.test.ts +++ b/packages/coding-agent/test/collab/session-replication.test.ts @@ -19,7 +19,7 @@ afterEach(async () => { }); // Comfortably above BLOB_EXTERNALIZE_THRESHOLD (1024 base64 chars). -const BIG_IMAGE_B64 = Buffer.alloc(4096, 7).toString("base64"); +const BIG_IMAGE_B64 = Buffer.alloc(1024, 7).toString("base64"); describe("SessionManager collab replication", () => { it("onEntryAppended receives the in-memory entry with inline image data while the persisted line externalizes it", async () => { @@ -92,7 +92,8 @@ describe("SessionManager collab replication", () => { }); it("snapshotForReplication deep-copies entries and preserves the header identity", () => { - const { manager, cwd } = makeManager(); + const cwd = process.cwd(); + const manager = SessionManager.inMemory(cwd); manager.appendMessage({ role: "user", content: "snapshot me", timestamp: Date.now() }); const snapshot = manager.snapshotForReplication(); diff --git a/packages/coding-agent/test/eval/completion-bridge.test.ts b/packages/coding-agent/test/eval/completion-bridge.test.ts index 2d8a6d66c..b61a3c50d 100644 --- a/packages/coding-agent/test/eval/completion-bridge.test.ts +++ b/packages/coding-agent/test/eval/completion-bridge.test.ts @@ -98,22 +98,19 @@ function assistant(opts: { }; } -async function runPythonCompletionInSubprocess(options: { - structured: boolean; - tempDir: TempDir; -}): Promise { +async function runPythonCompletionsInSubprocess(tempDir: TempDir): Promise { const repoRoot = path.resolve(import.meta.dir, "../../.."); - const scriptPath = path.join(options.tempDir.path(), "run-python-completion.ts"); - const resultPath = path.join(options.tempDir.path(), "python-completion-result.json"); + const scriptPath = path.join(tempDir.path(), "run-python-completion.ts"); + const resultPath = path.join(tempDir.path(), "python-completion-result.json"); const aiPath = path.resolve(import.meta.dir, "../../../ai/src/index.ts"); const executorPath = path.resolve(import.meta.dir, "../../src/eval/py/executor.ts"); const settingsPath = path.resolve(import.meta.dir, "../../src/config/settings.ts"); - const code = options.structured - ? 'import json\nprint(json.dumps(completion("hi", schema={"type": "object"})))' - : 'print(completion("hi", model="smol"))'; - const responseContent = options.structured - ? '[{ type: "toolCall", id: "tc-1", name: "respond", arguments: { ok: true } }]' - : '[{ type: "text", text: "hello from python" }]'; + const code = [ + "import json", + 'plain = completion("hi", model="smol")', + 'structured = completion("hi", schema={"type": "object"})', + 'print(json.dumps({"plain": plain, "structured": structured}))', + ].join("\n"); await Bun.write( scriptPath, ` @@ -146,18 +143,27 @@ const session = { }, getActiveModelString: () => "p/smol", }; -vi.spyOn(ai, "completeSimple").mockResolvedValue({ - role: "assistant", - api: "openai-responses", - provider: "p", - model: "smol", - stopReason: "stop", - content: ${responseContent}, -}); +vi.spyOn(ai, "completeSimple") + .mockResolvedValueOnce({ + role: "assistant", + api: "openai-responses", + provider: "p", + model: "smol", + stopReason: "stop", + content: [{ type: "text", text: "hello from python" }], + }) + .mockResolvedValueOnce({ + role: "assistant", + api: "openai-responses", + provider: "p", + model: "smol", + stopReason: "stop", + content: [{ type: "toolCall", id: "tc-1", name: "respond", arguments: { ok: true } }], + }); const result = await executePython(${JSON.stringify(code)}, { - cwd: ${JSON.stringify(options.tempDir.path())}, - sessionId: ${JSON.stringify(`py-completion:${options.structured ? "struct" : "plain"}`)}, - sessionFile: ${JSON.stringify(path.join(options.tempDir.path(), "session.jsonl"))}, + cwd: ${JSON.stringify(tempDir.path())}, + sessionId: "py-completion", + sessionFile: ${JSON.stringify(path.join(tempDir.path(), "session.jsonl"))}, toolSession: session, kernelMode: "per-call", }); @@ -316,31 +322,41 @@ describe("runEvalCompletion", () => { }); it("pauses the idle watchdog while a slow completion() request is in flight", async () => { - // A oneshot completion emits no status until it returns; delegated model - // time must be invisible to the eval timeout budget. - vi.spyOn(ai, "completeSimple").mockImplementation(async () => { - await Bun.sleep(200); - return assistant({ text: "the answer" }); - }); + vi.useFakeTimers(); + try { + // A oneshot completion emits no status until it returns; delegated model + // time must be invisible to the eval timeout budget. + const started = Promise.withResolvers(); + vi.spyOn(ai, "completeSimple").mockImplementation(async () => { + started.resolve(); + await Bun.sleep(200); + return assistant({ text: "the answer" }); + }); - const ops: string[] = []; - using idle = new IdleTimeout(60); - const result = await runEvalCompletion( - { prompt: "q", model: "smol" }, - { - session: makeSession(), - signal: idle.signal, - emitStatus: event => { - ops.push(event.op); - if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause(); - if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume(); + const ops: string[] = []; + using idle = new IdleTimeout(60); + const pendingResult = runEvalCompletion( + { prompt: "q", model: "smol" }, + { + session: makeSession(), + signal: idle.signal, + emitStatus: event => { + ops.push(event.op); + if (event.op === EVAL_TIMEOUT_PAUSE_OP) idle.pause(); + if (event.op === EVAL_TIMEOUT_RESUME_OP) idle.resume(); + }, }, - }, - ); + ); + await started.promise; + vi.advanceTimersByTime(200); + const result = await pendingResult; - expect(result.text).toBe("the answer"); - expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, "completion"]); - expect(idle.signal.aborted).toBe(false); + expect(result.text).toBe("the answer"); + expect(ops).toEqual([EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, "completion"]); + expect(idle.signal.aborted).toBe(false); + } finally { + vi.useRealTimers(); + } }); }); @@ -354,57 +370,39 @@ describe("completion() through eval runtimes", () => { await disposeAllKernelSessions(); }); - it("exposes completion() in the JavaScript runtime", async () => { + it("exposes plain and structured completion() in the JavaScript runtime", async () => { using tempDir = TempDir.createSync("@omp-eval-completion-js-"); const sessionFile = path.join(tempDir.path(), "session.jsonl"); const sessionId = `js-completion:${crypto.randomUUID()}`; - vi.spyOn(ai, "completeSimple").mockResolvedValue(assistant({ text: "hello from smol" })); - - const result = await executeJs('return await completion("hi", { model: "smol" });', { - cwd: tempDir.path(), - sessionId, - session: makeSession(), - sessionFile, - }); - - expect(result.exitCode).toBe(0); - expect(result.output.trim()).toBe("hello from smol"); - }); - - it("parses structured completion() output in the JavaScript runtime", async () => { - using tempDir = TempDir.createSync("@omp-eval-completion-js-struct-"); - const sessionFile = path.join(tempDir.path(), "session.jsonl"); - const sessionId = `js-completion-struct:${crypto.randomUUID()}`; - vi.spyOn(ai, "completeSimple").mockResolvedValue( - assistant({ toolCall: { name: "respond", arguments: { ok: true, n: 3 } } }), - ); + vi.spyOn(ai, "completeSimple") + .mockResolvedValueOnce(assistant({ text: "hello from smol" })) + .mockResolvedValueOnce(assistant({ toolCall: { name: "respond", arguments: { ok: true, n: 3 } } })); const result = await executeJs( - 'const r = await completion("hi", { schema: { type: "object" } }); return JSON.stringify(r);', + [ + 'const plain = await completion("hi", { model: "smol" });', + 'const structured = await completion("hi", { schema: { type: "object" } });', + "return JSON.stringify({ plain, structured });", + ].join("\n"), { cwd: tempDir.path(), sessionId, session: makeSession(), sessionFile }, ); expect(result.exitCode).toBe(0); - expect(JSON.parse(result.output.trim())).toEqual({ ok: true, n: 3 }); + expect(JSON.parse(result.output.trim())).toEqual({ + plain: "hello from smol", + structured: { ok: true, n: 3 }, + }); }); - it("exposes completion() in the Python runtime", async () => { + it("exposes plain and structured completion() in the Python runtime", async () => { const tempDir = TempDir.createSync("@omp-eval-completion-py-"); try { - const result = await runPythonCompletionInSubprocess({ structured: false, tempDir }); + const result = await runPythonCompletionsInSubprocess(tempDir); expect(result.exitCode).toBe(0); - expect(result.output.trim()).toBe("hello from python"); - } finally { - tempDir.removeSync(); - } - }); - - it("parses structured completion() output in the Python runtime", async () => { - const tempDir = TempDir.createSync("@omp-eval-completion-py-struct-"); - try { - const result = await runPythonCompletionInSubprocess({ structured: true, tempDir }); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.output.trim())).toEqual({ ok: true }); + expect(JSON.parse(result.output.trim())).toEqual({ + plain: "hello from python", + structured: { ok: true }, + }); } finally { tempDir.removeSync(); } diff --git a/packages/coding-agent/test/eval/console-table.test.ts b/packages/coding-agent/test/eval/console-table.test.ts index 6aa9f2986..556787184 100644 --- a/packages/coding-agent/test/eval/console-table.test.ts +++ b/packages/coding-agent/test/eval/console-table.test.ts @@ -1,19 +1,16 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { JsRuntime, type RuntimeHooks } from "@oh-my-pi/pi-coding-agent/eval/js/shared/runtime"; import type { JsDisplayOutput } from "@oh-my-pi/pi-coding-agent/eval/js/shared/types"; -function makeRuntime(): { - runtime: JsRuntime; +let runtime: JsRuntime; + +function makeHooks(): { hooks: RuntimeHooks; texts: string[]; displays: JsDisplayOutput[]; } { const texts: string[] = []; const displays: JsDisplayOutput[] = []; - const runtime = new JsRuntime({ - initialCwd: process.cwd(), - sessionId: "test", - }); const hooks: RuntimeHooks = { onText: (chunk: string) => { texts.push(chunk); @@ -23,12 +20,23 @@ function makeRuntime(): { }, callTool: async () => undefined, }; - return { runtime, hooks, texts, displays }; + return { hooks, texts, displays }; } describe("console.table bridge", () => { + beforeAll(() => { + runtime = new JsRuntime({ + initialCwd: process.cwd(), + sessionId: "console-table-test", + }); + }); + + afterAll(() => { + runtime.dispose(); + }); + it("renders an array of objects as an ASCII table on text output", async () => { - const { runtime, hooks, texts, displays } = makeRuntime(); + const { hooks, texts, displays } = makeHooks(); await runtime.run("console.table([{ name: 'Ada', age: 36 }, { name: 'Linus', age: 54 }]);", undefined, hooks); expect(displays).toEqual([]); expect(texts.length).toBe(1); @@ -44,7 +52,7 @@ describe("console.table bridge", () => { }); it("honors the optional columns filter", async () => { - const { runtime, hooks, texts } = makeRuntime(); + const { hooks, texts } = makeHooks(); await runtime.run("console.table([{ name: 'Ada', age: 36, secret: 'hidden' }], ['name']);", undefined, hooks); const out = texts.join(""); expect(out).toContain("name"); diff --git a/packages/coding-agent/test/eval/display-image-coerce.test.ts b/packages/coding-agent/test/eval/display-image-coerce.test.ts index 382fc5b4a..366f9346d 100644 --- a/packages/coding-agent/test/eval/display-image-coerce.test.ts +++ b/packages/coding-agent/test/eval/display-image-coerce.test.ts @@ -1,19 +1,16 @@ -import { describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { JsRuntime, type RuntimeHooks } from "@oh-my-pi/pi-coding-agent/eval/js/shared/runtime"; import type { JsDisplayOutput } from "@oh-my-pi/pi-coding-agent/eval/js/shared/types"; +let runtime: JsRuntime; + function collect(): { - runtime: JsRuntime; hooks: RuntimeHooks; displays: JsDisplayOutput[]; texts: string[]; } { const displays: JsDisplayOutput[] = []; const texts: string[] = []; - const runtime = new JsRuntime({ - initialCwd: process.cwd(), - sessionId: "test", - }); const hooks: RuntimeHooks = { onText: (chunk: string) => { texts.push(chunk); @@ -23,33 +20,44 @@ function collect(): { }, callTool: async () => undefined, }; - return { runtime, hooks, displays, texts }; + return { hooks, displays, texts }; } const PNG_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); const PNG_BASE64 = Buffer.from(PNG_BYTES).toString("base64"); describe("JsRuntime.displayValue image coercion", () => { + beforeAll(() => { + runtime = new JsRuntime({ + initialCwd: process.cwd(), + sessionId: "display-image-coerce-test", + }); + }); + + afterAll(() => { + runtime.dispose(); + }); + it("passes through strict base64 strings verbatim", () => { - const { runtime, hooks, displays } = collect(); + const { hooks, displays } = collect(); runtime.displayValue({ type: "image", data: PNG_BASE64, mimeType: "image/png" }, hooks); expect(displays).toEqual([{ type: "image", data: PNG_BASE64, mimeType: "image/png" }]); }); it("base64-encodes Uint8Array data", () => { - const { runtime, hooks, displays } = collect(); + const { hooks, displays } = collect(); runtime.displayValue({ type: "image", data: PNG_BYTES, mimeType: "image/png" }, hooks); expect(displays).toEqual([{ type: "image", data: PNG_BASE64, mimeType: "image/png" }]); }); it("base64-encodes Buffer data", () => { - const { runtime, hooks, displays } = collect(); + const { hooks, displays } = collect(); runtime.displayValue({ type: "image", data: Buffer.from(PNG_BYTES), mimeType: "image/png" }, hooks); expect(displays).toEqual([{ type: "image", data: PNG_BASE64, mimeType: "image/png" }]); }); it("base64-encodes ArrayBuffer data", () => { - const { runtime, hooks, displays } = collect(); + const { hooks, displays } = collect(); const ab = PNG_BYTES.buffer.slice(PNG_BYTES.byteOffset, PNG_BYTES.byteOffset + PNG_BYTES.byteLength); runtime.displayValue({ type: "image", data: ab, mimeType: "image/png" }, hooks); expect(displays).toEqual([{ type: "image", data: PNG_BASE64, mimeType: "image/png" }]); @@ -59,7 +67,7 @@ describe("JsRuntime.displayValue image coercion", () => { // Reproduces the puppeteer footgun: page.screenshot() returns Uint8Array, and // `uint8array.toString("base64")` silently falls through to Array.toString, // yielding "137,80,78,71,...". Anthropic rejects that as invalid base64. - const { runtime, hooks, displays } = collect(); + const { hooks, displays } = collect(); const decimalCsv = Array.from(PNG_BYTES).toString(); expect(decimalCsv).toBe("137,80,78,71,13,10,26,10"); runtime.displayValue({ type: "image", data: decimalCsv, mimeType: "image/png" }, hooks); @@ -67,7 +75,7 @@ describe("JsRuntime.displayValue image coercion", () => { }); it("recovers JSON-serialized Buffer shape ({ type: 'Buffer', data: [...] })", () => { - const { runtime, hooks, displays } = collect(); + const { hooks, displays } = collect(); const jsonBuffer = JSON.parse(JSON.stringify(Buffer.from(PNG_BYTES))) as { type: string; data: number[]; @@ -77,7 +85,7 @@ describe("JsRuntime.displayValue image coercion", () => { }); it("drops images whose data is unrecognized and surfaces a diagnostic in text", () => { - const { runtime, hooks, displays, texts } = collect(); + const { hooks, displays, texts } = collect(); runtime.displayValue({ type: "image", data: { not: "a buffer" }, mimeType: "image/png" }, hooks); expect(displays).toHaveLength(0); expect(texts.join("")).toMatch(/image dropped/); @@ -86,7 +94,7 @@ describe("JsRuntime.displayValue image coercion", () => { it("rejects strings that look base64-ish but aren't strictly valid", () => { // Padding mid-string, whitespace, or URL-safe alphabet are all dropped — the // Anthropic API only honors strict base64 in image sources. - const { runtime, hooks, displays, texts } = collect(); + const { hooks, displays, texts } = collect(); runtime.displayValue({ type: "image", data: "abcd=efg", mimeType: "image/png" }, hooks); expect(displays).toHaveLength(0); expect(texts.join("")).toMatch(/image dropped/); diff --git a/packages/coding-agent/test/eval/idle-timeout.test.ts b/packages/coding-agent/test/eval/idle-timeout.test.ts index 9f43d244f..06ea2ac94 100644 --- a/packages/coding-agent/test/eval/idle-timeout.test.ts +++ b/packages/coding-agent/test/eval/idle-timeout.test.ts @@ -1,29 +1,21 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import { IdleTimeout } from "../../src/eval/idle-timeout"; -/** Resolve true if `signal` aborts within `ms`, false if the window elapses first. */ -function abortedWithin(signal: AbortSignal, ms: number): Promise { - if (signal.aborted) return Promise.resolve(true); - const { promise, resolve } = Promise.withResolvers(); - const timer = setTimeout(() => resolve(false), ms); - signal.addEventListener( - "abort", - () => { - clearTimeout(timer); - resolve(true); - }, - { once: true }, - ); - return promise; -} +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); describe("IdleTimeout", () => { - it("aborts with a TimeoutError reason once the idle window elapses with no activity", async () => { + it("aborts with a TimeoutError reason once the idle window elapses with no activity", () => { using idle = new IdleTimeout(40); expect(idle.signal.aborted).toBe(false); - - const fired = await abortedWithin(idle.signal, 500); - expect(fired).toBe(true); + vi.advanceTimersByTime(39); + expect(idle.signal.aborted).toBe(false); + vi.advanceTimersByTime(1); expect(idle.signal.aborted).toBe(true); // The reason must be a TimeoutError so downstream timeout detection // (kernel `isTimeoutReason`, executor `isTimedOutCancellation`) classifies @@ -32,45 +24,46 @@ describe("IdleTimeout", () => { expect((idle.signal.reason as DOMException).name).toBe("TimeoutError"); }); - it("ignores elapsed time while paused and resumes with a fresh window", async () => { + it("ignores elapsed time while paused and resumes with a fresh window", () => { using idle = new IdleTimeout(80); idle.pause(); - await Bun.sleep(160); + vi.advanceTimersByTime(1_000); expect(idle.signal.aborted).toBe(false); idle.resume(); - const firedEarly = await abortedWithin(idle.signal, 30); - expect(firedEarly).toBe(false); - const fired = await abortedWithin(idle.signal, 500); - expect(fired).toBe(true); + vi.advanceTimersByTime(79); + expect(idle.signal.aborted).toBe(false); + vi.advanceTimersByTime(1); + expect(idle.signal.aborted).toBe(true); }); - it("reference-counts overlapping pauses", async () => { + it("reference-counts overlapping pauses", () => { using idle = new IdleTimeout(60); idle.pause(); idle.pause(); - await Bun.sleep(120); + vi.advanceTimersByTime(1_000); expect(idle.signal.aborted).toBe(false); idle.resume(); - await Bun.sleep(90); + vi.advanceTimersByTime(1_000); expect(idle.signal.aborted).toBe(false); idle.resume(); - const fired = await abortedWithin(idle.signal, 500); - expect(fired).toBe(true); + vi.advanceTimersByTime(59); + expect(idle.signal.aborted).toBe(false); + vi.advanceTimersByTime(1); + expect(idle.signal.aborted).toBe(true); }); - it("never fires after dispose()", async () => { + it("never fires after dispose()", () => { const idle = new IdleTimeout(30); idle.dispose(); - const fired = await abortedWithin(idle.signal, 150); - expect(fired).toBe(false); + vi.advanceTimersByTime(1_000); expect(idle.signal.aborted).toBe(false); }); - it("ignores pause/resume after the watchdog has already fired", async () => { + it("ignores pause/resume after the watchdog has already fired", () => { using idle = new IdleTimeout(30); - await abortedWithin(idle.signal, 500); + vi.advanceTimersByTime(30); expect(idle.signal.aborted).toBe(true); // Late activity must not un-abort or rearm a settled watchdog. idle.pause(); diff --git a/packages/coding-agent/test/eval/js-context-manager.test.ts b/packages/coding-agent/test/eval/js-context-manager.test.ts index 6eb97c789..334c20af6 100644 --- a/packages/coding-agent/test/eval/js-context-manager.test.ts +++ b/packages/coding-agent/test/eval/js-context-manager.test.ts @@ -446,7 +446,7 @@ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation await disposeAllVmContexts(); }); - it("runs spawned commands in the isolated POSIX process group", async () => { + it("preserves process isolation, cwd, state, and rejection recovery in one kernel", async () => { using tempDir = TempDir.createSync("@omp-js-process-isolation-"); const session = makeSession(tempDir.path()); const evalSessionId = `js-isolation:${crypto.randomUUID()}`; @@ -461,6 +461,15 @@ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation expect(parentProcessId).not.toBe(process.pid); expect(processGroupId).toBe(parentProcessId); + const cwd = await executeJs("return process.cwd();", { + cwd: tempDir.path(), + sessionId: evalSessionId, + session, + }); + // process.chdir resolves symlinks (macOS tempdirs live under /var -> + // /private/var), so compare physical paths. + expect(cwd.output.trim()).toBe(fs.realpathSync(tempDir.path())); + await executeJs("var saved = 41; function increment(value) { return value + 1; }", { cwd: tempDir.path(), sessionId: evalSessionId, @@ -472,20 +481,30 @@ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation session, }); expect(reused.output.trim()).toBe("42"); - }); - it("mirrors the session cwd onto the subprocess's real cwd", async () => { - using tempDir = TempDir.createSync("@omp-js-process-cwd-"); - const session = makeSession(tempDir.path()); - const evalSessionId = `js-cwd:${crypto.randomUUID()}`; - const result = await executeJs("return process.cwd();", { + const handled = await executeJs('await Promise.reject("handled rejection").catch(() => undefined); return 42;', { cwd: tempDir.path(), sessionId: evalSessionId, session, }); - // process.chdir resolves symlinks (macOS tempdirs live under /var -> - // /private/var), so compare physical paths. - expect(result.output.trim()).toBe(fs.realpathSync(tempDir.path())); + expect(handled.exitCode).toBe(0); + expect(handled.output.trim()).toBe("42"); + + const rejected = await executeJs('var savedAfterRejection = 41; Promise.reject("stackless rejection");', { + cwd: tempDir.path(), + sessionId: evalSessionId, + session, + }); + expect(rejected.exitCode).toBe(1); + expect(rejected.output).toContain("Unhandled rejection (missing await?): stackless rejection"); + + const recovered = await executeJs("return savedAfterRejection + 1;", { + cwd: tempDir.path(), + sessionId: evalSessionId, + session, + }); + expect(recovered.exitCode).toBe(0); + expect(recovered.output.trim()).toBe("42"); }); it("still runs cells when the session cwd does not exist", async () => { @@ -500,32 +519,4 @@ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation expect(result.exitCode).toBe(0); expect(result.output.trim()).toBe("42"); }); - - it("keeps the isolated process alive after handled and stackless floated rejections", async () => { - using tempDir = TempDir.createSync("@omp-js-process-rejection-"); - const session = makeSession(tempDir.path()); - const evalSessionId = `js-rejection:${crypto.randomUUID()}`; - const handled = await executeJs('await Promise.reject("handled rejection").catch(() => undefined); return 42;', { - cwd: tempDir.path(), - sessionId: evalSessionId, - session, - }); - expect(handled.exitCode).toBe(0); - expect(handled.output.trim()).toBe("42"); - - const rejected = await executeJs( - 'var savedAfterRejection = 41; Promise.reject("stackless rejection"); await Bun.sleep(10);', - { cwd: tempDir.path(), sessionId: evalSessionId, session }, - ); - expect(rejected.exitCode).toBe(1); - expect(rejected.output).toContain("Unhandled rejection (missing await?): stackless rejection"); - - const reused = await executeJs("return savedAfterRejection + 1;", { - cwd: tempDir.path(), - sessionId: evalSessionId, - session, - }); - expect(reused.exitCode).toBe(0); - expect(reused.output.trim()).toBe("42"); - }); }); diff --git a/packages/coding-agent/test/eval/julia-prelude.test.ts b/packages/coding-agent/test/eval/julia-prelude.test.ts index 5f643c018..0d9aea30b 100644 --- a/packages/coding-agent/test/eval/julia-prelude.test.ts +++ b/packages/coding-agent/test/eval/julia-prelude.test.ts @@ -11,14 +11,15 @@ describe.skipIf(!HAS_JULIA)("eval Julia prelude helpers", () => { await disposeJuliaKernelSessionsByOwner(OWNER_ID); }, 30_000); - it("supports output ranges, JSON queries, metadata, and ANSI stripping", async () => { - using tempDir = TempDir.createSync("@omp-eval-julia-output-"); + it("supports prelude helpers and renders exception details in one kernel session", async () => { + using tempDir = TempDir.createSync("@omp-eval-julia-prelude-"); const artifactsDir = path.join(tempDir.path(), "session-artifacts"); await Bun.write(path.join(artifactsDir, "alpha.md"), "one\ntwo\nthree\nfour"); await Bun.write(path.join(artifactsDir, "json.md"), JSON.stringify({ items: [{ name: "a" }, { name: "b" }] })); await Bun.write(path.join(artifactsDir, "ansi.md"), "\u001b[31mred\u001b[0m"); + const sessionId = `julia-prelude:${crypto.randomUUID()}`; - const result = await executeJulia( + const helpers = await executeJulia( ` println("RANGE=", replace(output("alpha", offset=2, limit=2), "\\n" => "|")) println("QUERY=", output("json", query=".items[1].name")) @@ -32,35 +33,32 @@ nothing { cwd: tempDir.path(), artifactsDir, - sessionId: `julia-prelude-output:${crypto.randomUUID()}`, + sessionId, kernelOwnerId: OWNER_ID, reset: true, }, ); - expect(result.exitCode).toBe(0); - expect(result.output).toContain("RANGE=two|three"); - expect(result.output).toContain('QUERY="b"'); - expect(result.output).toContain("STRIPPED=red"); - expect(result.output).toContain("META=alpha:true"); - expect(result.output).toContain("MULTI=2:alpha:json"); - }, 60_000); + expect(helpers.exitCode).toBe(0); + expect(helpers.output).toContain("RANGE=two|three"); + expect(helpers.output).toContain('QUERY="b"'); + expect(helpers.output).toContain("STRIPPED=red"); + expect(helpers.output).toContain("META=alpha:true"); + expect(helpers.output).toContain("MULTI=2:alpha:json"); - it("surfaces the exception type and message in the error output, not just stack frames", async () => { - using tempDir = TempDir.createSync("@omp-eval-julia-error-"); - const result = await executeJulia(`println("="^8)\nmissing_var_xyz + 1`, { + const error = await executeJulia(`println("="^8)\nmissing_var_xyz + 1`, { cwd: tempDir.path(), - sessionId: `julia-prelude-error:${crypto.randomUUID()}`, + artifactsDir, + sessionId, kernelOwnerId: OWNER_ID, - reset: true, }); // The rendered error must carry the actual exception, not only the // runner-internal backtrace frames (regression: traceback-only output // hid `ename`/`evalue`). - expect(result.output).toContain("UndefVarError"); - expect(result.output).toContain("missing_var_xyz"); + expect(error.output).toContain("UndefVarError"); + expect(error.output).toContain("missing_var_xyz"); // Frames are still present alongside the message. - expect(result.output).toContain("top-level scope"); - }, 30_000); + expect(error.output).toContain("top-level scope"); + }, 60_000); }); diff --git a/packages/coding-agent/test/eval/kernel-owner-scoping.test.ts b/packages/coding-agent/test/eval/kernel-owner-scoping.test.ts index c448142aa..edee628f3 100644 --- a/packages/coding-agent/test/eval/kernel-owner-scoping.test.ts +++ b/packages/coding-agent/test/eval/kernel-owner-scoping.test.ts @@ -87,7 +87,7 @@ describe("JS eval owner-scoped reset forking", () => { await disposeAllVmContexts(); }); - it("forks a subagent reset instead of clobbering the shared context", async () => { + it("forks shared resets while resetting an exclusive owner in place", async () => { using tempDir = TempDir.createSync("@omp-js-owner-fork-"); const session = makeSession(tempDir.path()); const evalSessionId = `js-owner-fork:${crypto.randomUUID()}`; @@ -114,23 +114,10 @@ describe("JS eval owner-scoped reset forking", () => { await disposeVmContextsByOwner("agent-b"); const survivor = await run("return shared + 1;", "agent-a"); expect(survivor.output.trim()).toBe("42"); - }); - it("resets in place for the exclusive owner of a context", async () => { - using tempDir = TempDir.createSync("@omp-js-owner-exclusive-"); - const session = makeSession(tempDir.path()); - const evalSessionId = `js-owner-exclusive:${crypto.randomUUID()}`; - const run = (code: string, reset?: boolean) => - executeJs(code, { - cwd: tempDir.path(), - sessionId: evalSessionId, - session, - kernelOwnerId: "agent-solo", - reset, - }); - - await run("var solo = 1;"); - const reset = await run("return typeof solo;", true); + // Once agent-a is the exclusive owner, reset reuses its process but clears + // the context rather than needlessly forking another worker. + const reset = await run("return typeof shared;", "agent-a", true); expect(reset.output.trim()).toBe("undefined"); }); }); diff --git a/packages/coding-agent/test/eval/worker-core.test.ts b/packages/coding-agent/test/eval/worker-core.test.ts index 85ed6fc8b..4c4de4dd7 100644 --- a/packages/coding-agent/test/eval/worker-core.test.ts +++ b/packages/coding-agent/test/eval/worker-core.test.ts @@ -520,20 +520,27 @@ process.exit(0); stderr: "pipe", env: { ...process.env }, }); - const watchdog = Bun.sleep(5000).then(() => { - proc.kill(); - return -999; - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - Promise.race([proc.exited, watchdog]), - ]); - expect(exitCode).toBe(0); - expect(stdout).toContain("survived concurrent setCwd"); - expect(stderr).not.toContain("[Unhandled Rejection]"); - expect(stderr).not.toContain("[Uncaught Exception]"); - expect(stderr).not.toContain("another same-realm JS runtime is running"); + // Real process liveness cannot use fake timers. Bound a wedged child, but + // clear the watchdog on the normal path so it never becomes a fixed wait. + const watchdog = setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch {} + }, 5000); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("survived concurrent setCwd"); + expect(stderr).not.toContain("[Unhandled Rejection]"); + expect(stderr).not.toContain("[Uncaught Exception]"); + expect(stderr).not.toContain("another same-realm JS runtime is running"); + } finally { + clearTimeout(watchdog); + } } finally { await fs.rm(root, { recursive: true, force: true }); } diff --git a/packages/coding-agent/test/extensibility/custom-commands/review.test.ts b/packages/coding-agent/test/extensibility/custom-commands/review.test.ts index 9bd845b08..8ca6ddf37 100644 --- a/packages/coding-agent/test/extensibility/custom-commands/review.test.ts +++ b/packages/coding-agent/test/extensibility/custom-commands/review.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, spyOn, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, spyOn, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -82,18 +82,21 @@ interface EditorCall { } describe("ReviewCommand", () => { - let tmpDir: string | undefined; + let tmpDir: string; - afterEach(async () => { - vi.restoreAllMocks(); - if (tmpDir) { - await removeWithRetries(tmpDir); - tmpDir = undefined; - } + beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-review-command-")); }); - async function createTempDir(): Promise { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-review-command-")); + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + await removeWithRetries(tmpDir); + }); + + function createTempDir(): string { return tmpDir; } @@ -184,8 +187,6 @@ describe("ReviewCommand", () => { const result = await command.execute([], ctx); expect(result).toBeUndefined(); - await removeWithRetries(dir); - tmpDir = undefined; } }); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts index 68292a924..9de10685f 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts @@ -112,91 +112,41 @@ describe("legacy-pi @(scope)/pi-ai root `Type` remap (issue #1437)", () => { expect(typeof loaded.fn).toBe("function"); }); - it("exports getModel as getBundledModel", async () => { - const loaded = (await loadLegacyPiModule( - await writeFixtureExtension( - 'import { getModel } from "@oh-my-pi/pi-ai"; export const testGetModel = getModel;', - ), - )) as { testGetModel: unknown }; - expect(loaded.testGetModel).toBe(getBundledModel); - }); - - it("exports getModels as getBundledModels", async () => { - const loaded = (await loadLegacyPiModule( - await writeFixtureExtension( - 'import { getModels } from "@oh-my-pi/pi-ai"; export const testGetModels = getModels;', - ), - )) as { testGetModels: unknown }; - expect(loaded.testGetModels).toBe(getBundledModels); - }); - - it("re-exports calculateCost from @oh-my-pi/pi-catalog/models (issue #4584)", async () => { - // `calculateCost` was moved from the `@oh-my-pi/pi-ai` barrel to - // `@oh-my-pi/pi-catalog/models` in the catalog split. Legacy extensions - // still import it from the pi-ai root, so the shim must bridge it back - // to the catalog implementation. The historical regression was a plain - // `SyntaxError: Export named 'calculateCost' not found in module - // '.../legacy-pi-ai-shim.ts'` at extension-validation time. - const loaded = (await loadLegacyPiModule( - await writeFixtureExtension( - 'import { calculateCost } from "@oh-my-pi/pi-ai"; export const probe = calculateCost;', - ), - )) as { probe: unknown }; - expect(loaded.probe).toBe(calculateCost); - }); - - it("re-exports modelsAreEqual and getBundledProviders from @oh-my-pi/pi-catalog/models", async () => { + it("re-exports the legacy model catalog and schema helpers from the root", async () => { const loaded = (await loadLegacyPiModule( await writeFixtureExtension( [ - 'import { modelsAreEqual, getBundledProviders } from "@oh-my-pi/pi-ai";', - "export const eq = modelsAreEqual;", - "export const providers = getBundledProviders;", - ].join("\n"), - ), - )) as { eq: unknown; providers: unknown }; - expect(loaded.eq).toBe(modelsAreEqual); - expect(loaded.providers).toBe(getBundledProviders); - }); - - it("re-exports getBundledModel and getBundledModels from @oh-my-pi/pi-catalog/models", async () => { - const loaded = (await loadLegacyPiModule( - await writeFixtureExtension( - [ - 'import { getBundledModel, getBundledModels } from "@oh-my-pi/pi-ai";', - "export const model = getBundledModel;", - "export const models = getBundledModels;", - ].join("\n"), - ), - )) as { model: unknown; models: unknown }; - expect(loaded.model).toBe(getBundledModel); - expect(loaded.models).toBe(getBundledModels); - }); - - it("exports clampThinkingLevel with the historical off fallback", async () => { - const loaded = await loadLegacyPiModule( - await writeFixtureExtension( - [ - 'import { clampThinkingLevel } from "@earendil-works/pi-ai";', + 'import { calculateCost, clampThinkingLevel, getBundledModel, getBundledModels, getBundledProviders, getModel, getModels, modelsAreEqual, StringEnum } from "@oh-my-pi/pi-ai";', + "export const helpers = { calculateCost, getBundledModel, getBundledModels, getBundledProviders, getModel, getModels, modelsAreEqual };", "export const supported = clampThinkingLevel({ reasoning: true, thinking: { efforts: ['low', 'high'] } }, 'high');", "export const disabled = clampThinkingLevel({ reasoning: false }, 'high');", - ].join("\n"), - ), - ); - - expect(loaded).toMatchObject({ supported: "high", disabled: "off" }); - }); - - it("exports StringEnum as a schema builder with options support", async () => { - const loaded = (await loadLegacyPiModule( - await writeFixtureExtension( - [ - 'import { StringEnum } from "@oh-my-pi/pi-ai";', 'export const schema = StringEnum(["red", "green"] as const, { description: "primary colors" });', ].join("\n"), ), - )) as { schema: { safeParse: (input: unknown) => { success: boolean }; toJSON?: () => any } }; + )) as { + helpers: { + calculateCost: unknown; + getBundledModel: unknown; + getBundledModels: unknown; + getBundledProviders: unknown; + getModel: unknown; + getModels: unknown; + modelsAreEqual: unknown; + }; + supported: string; + disabled: string; + schema: { safeParse: (input: unknown) => { success: boolean }; toJSON?: () => any }; + }; + expect(loaded.helpers.calculateCost).toBe(calculateCost); + expect(loaded.helpers.getModel).toBe(getBundledModel); + expect(loaded.helpers.getModels).toBe(getBundledModels); + expect(loaded.helpers.getBundledModel).toBe(getBundledModel); + expect(loaded.helpers.getBundledModels).toBe(getBundledModels); + expect(loaded.helpers.getBundledProviders).toBe(getBundledProviders); + expect(loaded.helpers.modelsAreEqual).toBe(modelsAreEqual); + expect(loaded.supported).toBe("high"); + expect(loaded.disabled).toBe("off"); expect(loaded.schema.safeParse("red").success).toBe(true); expect(loaded.schema.safeParse("blue").success).toBe(false); expect(loaded.schema.toJSON?.()?.description).toBe("primary colors"); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-bundled-subpath-overrides.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-bundled-subpath-overrides.test.ts index 9ccf8448c..40fba662d 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-bundled-subpath-overrides.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-bundled-subpath-overrides.test.ts @@ -6,7 +6,8 @@ import { __buildLegacyPiPackageRootOverrides } from "@oh-my-pi/pi-coding-agent/e import { TempDir } from "@oh-my-pi/pi-utils"; import { __renderLegacyPiVirtualModule, collectBundledPiEntries } from "../../scripts/legacy-pi-virtual-module"; -const bundledModuleKeys = new Set((await collectBundledPiEntries()).map(entry => entry.key)); +const bundledEntries = await collectBundledPiEntries(); +const bundledModuleKeys = new Set(bundledEntries.map(entry => entry.key)); // Regression for issue #3442: extension validation in compiled-binary mode // failed to resolve `@earendil-works/pi-ai/oauth` because the override map @@ -32,34 +33,33 @@ describe("legacy pi compat compiled-mode subpath overrides (issue #3442)", () => await Bun.write( registryPath, `${registry} -const beforeAlpha = Reflect.get(globalThis, "__alphaLoads") ?? 0; -const beforeBeta = Reflect.get(globalThis, "__betaLoads") ?? 0; +export const beforeAlpha = Reflect.get(globalThis, "__alphaLoads") ?? 0; +export const beforeBeta = Reflect.get(globalThis, "__betaLoads") ?? 0; await BUNDLED_PI_MODULE_LOADERS.alpha(); -const afterAlpha = Reflect.get(globalThis, "__alphaLoads") ?? 0; -const betaAfterAlpha = Reflect.get(globalThis, "__betaLoads") ?? 0; +export const afterAlpha = Reflect.get(globalThis, "__alphaLoads") ?? 0; +export const betaAfterAlpha = Reflect.get(globalThis, "__betaLoads") ?? 0; await BUNDLED_PI_MODULE_LOADERS.beta(); -process.stdout.write(JSON.stringify([ - beforeAlpha, - beforeBeta, - afterAlpha, - betaAfterAlpha, - Reflect.get(globalThis, "__alphaLoads") ?? 0, - Reflect.get(globalThis, "__betaLoads") ?? 0, -])); +export const finalAlpha = Reflect.get(globalThis, "__alphaLoads") ?? 0; +export const finalBeta = Reflect.get(globalThis, "__betaLoads") ?? 0; `, ); - const proc = Bun.spawn([process.execPath, registryPath], { - stdout: "pipe", - stderr: "pipe", - }); - const [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - expect(exitCode).toBe(0); - expect(stderr).toBe(""); - expect(JSON.parse(stdout)).toEqual([0, 0, 1, 0, 1, 1]); + Reflect.deleteProperty(globalThis, "__alphaLoads"); + Reflect.deleteProperty(globalThis, "__betaLoads"); + try { + // The generated registry has a runtime-selected temp path; importing it is the loading boundary under test. + const observed = await import(url.pathToFileURL(registryPath).href); + expect([ + observed.beforeAlpha, + observed.beforeBeta, + observed.afterAlpha, + observed.betaAfterAlpha, + observed.finalAlpha, + observed.finalBeta, + ]).toEqual([0, 0, 1, 0, 1, 1]); + } finally { + Reflect.deleteProperty(globalThis, "__alphaLoads"); + Reflect.deleteProperty(globalThis, "__betaLoads"); + } }); it("serves @oh-my-pi/pi-ai/oauth through the bundled virtual namespace in compiled mode", () => { @@ -93,7 +93,7 @@ process.stdout.write(JSON.stringify([ // Executing the generated registry is the contract — a key present in the // override map still proves nothing if the module cannot be imported. const key = "@oh-my-pi/pi-ai/providers/cursor-pi-args"; - const entry = (await collectBundledPiEntries()).find(candidate => candidate.key === key); + const entry = bundledEntries.find(candidate => candidate.key === key); expect(entry).toBeDefined(); // The rendered registry imports by bare specifier, exactly as the real @@ -106,32 +106,19 @@ process.stdout.write(JSON.stringify([ registryPath, `${__renderLegacyPiVirtualModule([entry!])} const mod = await BUNDLED_PI_MODULE_LOADERS[${JSON.stringify(key)}](); -process.stdout.write(JSON.stringify([ +export const observed = [ mod.piEscapeRegexLiteral("a.b*c"), mod.piJoinPath("src", "*.ts"), -])); +]; `, ); - let exitCode: number; - let stdout: string; - let stderr: string; try { - const proc = Bun.spawn([process.execPath, registryPath], { - cwd: packageRoot, - stdout: "pipe", - stderr: "pipe", - }); - [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); + // The generated registry has a runtime-selected package-root path; importing it exercises bare resolution. + const registryModule = await import(url.pathToFileURL(registryPath).href); + expect(registryModule.observed).toEqual(["a\\.b\\*c", path.join("src", "*.ts")]); } finally { await fs.rm(registryPath, { force: true }); } - expect(stderr).toBe(""); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout)).toEqual(["a\\.b\\*c", path.join("src", "*.ts")]); const overrides = __buildLegacyPiPackageRootOverrides(true, bundledModuleKeys); expect(overrides[key]).toBe(`omp-legacy-pi-bundled:${key}`); @@ -222,18 +209,16 @@ process.stdout.write(JSON.stringify([ expect(overrides).not.toHaveProperty("typebox"); }); - it("bundles nested wildcard subpaths so a compiled extension can import them", async () => { + it("bundles nested wildcard subpaths so a compiled extension can import them", () => { // Node matches `*` in an `exports` pattern across `/`, so // `./slash-commands/*` genuinely serves // `slash-commands/helpers/active-oauth-account`. Enumerating only the // top level left every nested key out of the compiled registry, so the // import resolved from source and failed inside a binary — which is how // a real extension (`quota-hud.ts`) broke on this exact specifier. - const entries = await collectBundledPiEntries(); - const keys = new Set(entries.map(entry => entry.key)); - expect(keys.has("@oh-my-pi/pi-coding-agent/slash-commands/helpers/active-oauth-account")).toBe(true); + expect(bundledModuleKeys.has("@oh-my-pi/pi-coding-agent/slash-commands/helpers/active-oauth-account")).toBe(true); // Directory index modules stay excluded: `./x/*` must not serve `x/y` // from `y/index.ts`, which Node would not resolve either. - expect(keys.has("@oh-my-pi/pi-coding-agent/modes/theme/defaults/index")).toBe(false); + expect(bundledModuleKeys.has("@oh-my-pi/pi-coding-agent/modes/theme/defaults/index")).toBe(false); }); }); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-bundled-virtual.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-bundled-virtual.test.ts index d27f366e5..34cdda8c7 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-bundled-virtual.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-bundled-virtual.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import * as path from "node:path"; +import * as url from "node:url"; import { __getLegacyPiBundledModulesGlobal, __synthesizeLegacyPiBundledSourceWithModules, @@ -104,11 +104,7 @@ describe("legacy-pi bundled virtual module synthesizer (issue #3423)", () => { await Bun.write( entryPath, - [ - 'import { legacyAnswer } from "omp-legacy-pi-bundled:@oh-my-pi/pi-utils";', - "process.stdout.write(legacyAnswer);", - "", - ].join("\n"), + ['export { legacyAnswer } from "omp-legacy-pi-bundled:@oh-my-pi/pi-utils";', ""].join("\n"), ); expect(resolveBundledVirtualSpecifier("@oh-my-pi/pi-utils")).toEqual({ @@ -152,19 +148,8 @@ describe("legacy-pi bundled virtual module synthesizer (issue #3423)", () => { await Bun.write(bundlePath, await buildResult.outputs[0]!.text()); expect(onLoadPaths).toEqual(["@oh-my-pi/pi-utils"]); - const proc = Bun.spawn([process.execPath, `./${path.basename(bundlePath)}`], { - cwd: path.dirname(bundlePath), - stderr: "pipe", - stdout: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - - expect(exitCode, stderr).toBe(0); - expect(stderr).toBe(""); - expect(stdout).toBe("served:@oh-my-pi/pi-utils"); + // The generated bundle has a runtime-selected temp path; importing it is the loading boundary under test. + const bundledModule = (await import(url.pathToFileURL(bundlePath).href)) as { legacyAnswer: string }; + expect(bundledModule.legacyAnswer).toBe("served:@oh-my-pi/pi-utils"); }); }); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts index 5cb21fa48..7563d694a 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts @@ -37,62 +37,48 @@ async function writePackage(files: Record): Promise { } describe("legacy-pi in-place module loading (issue #1674)", () => { - it("reads __dirname-relative HTML assets from the real extension directory", async () => { + it("loads in place with ESM-to-CommonJS default, named, and require interop", async () => { const dir = await writePackage({ "package.json": JSON.stringify({ name: "asset-ext", version: "1.0.0" }), "ui.html": "PLAN-UI", + "config.js": 'module.exports = { value: "required-cjs-ok" };\n', + "consumer.js": [ + 'import { createRequire } from "node:module";', + "const require = createRequire(import.meta.url);", + 'export const requiredValue = require("./config.js").value;', + ].join("\n"), + "helper.js": "module.exports = { value: 42 };\n", + "named-helper.cjs": 'module.exports = { namedValue: "named-cjs-ok" };\n', "index.ts": [ 'import { readFileSync } from "node:fs";', 'import { fileURLToPath } from "node:url";', 'import * as path from "node:path";', + 'import { requiredValue } from "./consumer.js";', + 'import helper from "./helper.js";', + 'import { namedValue } from "./named-helper.cjs";', "const here = path.dirname(fileURLToPath(import.meta.url));", "export const dirName = here;", 'export const html = readFileSync(path.join(here, "ui.html"), "utf8");', + "export const defaultValue = helper.value;", + "export { namedValue, requiredValue };", "export default function (pi) { void pi; }", ].join("\n"), }); - const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { dirName: string; html: string }; + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { + defaultValue: number; + dirName: string; + html: string; + namedValue: string; + requiredValue: string; + }; - // The asset resolves because the module runs in place — its computed - // __dirname is the extension's real directory, not a mirror temp root. - // (Bun realpaths loaded modules, so compare against the realpath.) + // Bun realpaths loaded modules, so the in-place path is compared to the fixture's real path. expect(mod.dirName).toBe(await fs.realpath(dir)); expect(mod.html).toBe("PLAN-UI"); - }); - - it("loads CommonJS helpers required by an ES module extension", async () => { - const dir = await writePackage({ - "package.json": JSON.stringify({ name: "cjs-helper-ext", version: "1.0.0" }), - "config.js": 'module.exports = { value: "config-ok" };\n', - "index.js": [ - 'import { createRequire } from "node:module";', - "const require = createRequire(import.meta.url);", - 'const { value } = require("./config.js");', - "export { value };", - "export default function (pi) { void pi; }", - ].join("\n"), - }); - - const mod = (await loadLegacyPiModule(path.join(dir, "index.js"))) as { value: string }; - - expect(mod.value).toBe("config-ok"); - }); - - it("loads a relative CommonJS helper imported by a TypeScript extension", async () => { - const dir = await writePackage({ - "package.json": JSON.stringify({ name: "relative-cjs-import-ext", version: "1.0.0" }), - "helper.js": "module.exports = { value: 42 };\n", - "index.ts": [ - 'import helper from "./helper.js";', - "export const value = helper.value;", - "export default function (pi) { void pi; }", - ].join("\n"), - }); - - const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { value: number }; - - expect(mod.value).toBe(42); + expect(mod.requiredValue).toBe("required-cjs-ok"); + expect(mod.defaultValue).toBe(42); + expect(mod.namedValue).toBe("named-cjs-ok"); }); it("remaps legacy Pi requires in graph-owned CommonJS packages to the host shim", async () => { @@ -147,22 +133,6 @@ describe("legacy-pi in-place module loading (issue #1674)", () => { expect(Reflect.get(Object(mod), "canvasValue")).toBe("canvas-shim"); }); - it("preserves named ESM imports from CommonJS helpers", async () => { - const dir = await writePackage({ - "package.json": JSON.stringify({ name: "named-cjs-ext", version: "1.0.0", type: "module" }), - "index.js": [ - 'import { value } from "./helper.cjs";', - "export { value };", - "export default function (pi) { void pi; }", - ].join("\n"), - "helper.cjs": 'module.exports = { value: "named-cjs-ok" };\n', - }); - - const mod = (await loadLegacyPiModule(path.join(dir, "index.js"))) as { value: string }; - - expect(mod.value).toBe("named-cjs-ok"); - }); - it("reads a lazy CommonJS helper at import time", async () => { const dir = await writePackage({ "package.json": JSON.stringify({ name: "lazy-cjs-ext", version: "1.0.0", type: "module" }), diff --git a/packages/coding-agent/test/extension-loader-graph-read-dedup.test.ts b/packages/coding-agent/test/extension-loader-graph-read-dedup.test.ts index 6383ce7b4..afa12a6bc 100644 --- a/packages/coding-agent/test/extension-loader-graph-read-dedup.test.ts +++ b/packages/coding-agent/test/extension-loader-graph-read-dedup.test.ts @@ -59,7 +59,8 @@ describe("Extension Loader Graph Read Dedup", () => { const extDir = path.join(cwd, "ext"); fs.mkdirSync(extDir, { recursive: true }); - const numModules = 120; + // Deduplication is depth-independent; a moderate chain catches repeated traversal without making fixture I/O the test. + const numModules = 16; for (let i = 0; i < numModules; i++) { const modPath = path.join(extDir, `mod-${i}.ts`); let content = `export const v${i} = ${i};\n`; diff --git a/packages/coding-agent/test/extension-loader-process-exit.test.ts b/packages/coding-agent/test/extension-loader-process-exit.test.ts index 834b73526..f4b137adc 100644 --- a/packages/coding-agent/test/extension-loader-process-exit.test.ts +++ b/packages/coding-agent/test/extension-loader-process-exit.test.ts @@ -81,77 +81,52 @@ void withHostGuard(async () => { `); }; - it("converts a top-level process.exit in an extension into a load error", async () => { - const ext = writeModule("rogue-extension.ts", "process.exit(0)\n"); - const cwd = project!.path(); - const originalExit = process.exit; - - const result = await loadExtensions([ext], cwd); - - expect(process.exit).toBe(originalExit); - expect(result.extensions).toEqual([]); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].path).toBe(ext); - expect(result.errors[0].error).toContain("process.exit(0)"); - }); - - it("converts a top-level process.exit in a hook into a load error", async () => { - const hook = writeModule("rogue-hook.ts", "process.exit(42)\n"); - const cwd = project!.path(); - const originalExit = process.exit; - - const result = await loadHooks([hook], cwd); - - expect(process.exit).toBe(originalExit); - expect(result.hooks).toEqual([]); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].path).toBe(hook); - expect(result.errors[0].error).toContain("process.exit(42)"); - }); - - it("converts hard exits from extension and hook factories into load errors", async () => { - const extension = writeModule("factory-exit-extension.ts", "export default function(pi) { process.exit(31); }\n"); - const hook = writeModule("factory-exit-hook.ts", "export default function(pi) { process.exit(32); }\n"); + it("converts extension and hook exits into load errors without blocking siblings", async () => { + const topLevelExtension = writeModule("top-level-exit-extension.ts", "process.exit(0)\n"); + const factoryExtension = writeModule( + "factory-exit-extension.ts", + "export default function(pi) { process.exit(31); }\n", + ); const reallyExitExtension = writeModule( "factory-really-exit-extension.ts", "export default function(pi) { process.reallyExit(33); }\n", ); + const goodExtension = writeModule( + "good-extension.ts", + "export default function(pi) { pi.registerCommand('ok', { handler: async () => {} }); }\n", + ); + const topLevelHook = writeModule("top-level-exit-hook.ts", "process.exit(42)\n"); + const factoryHook = writeModule("factory-exit-hook.ts", "export default function(pi) { process.exit(32); }\n"); const cwd = project!.path(); const originalExit = process.exit; const originalReallyExit = process.reallyExit; - const extensionResult = await loadExtensions([extension], cwd); - const hookResult = await loadHooks([hook], cwd); - const reallyExitResult = await loadExtensions([reallyExitExtension], cwd); + const extensionResult = await loadExtensions( + [topLevelExtension, factoryExtension, reallyExitExtension, goodExtension], + cwd, + ); + const hookResult = await loadHooks([topLevelHook, factoryHook], cwd); expect(process.exit).toBe(originalExit); expect(process.reallyExit).toBe(originalReallyExit); - expect(extensionResult.extensions).toEqual([]); - expect(extensionResult.errors).toHaveLength(1); - expect(extensionResult.errors[0].path).toBe(extension); - expect(extensionResult.errors[0].error).toContain("process.exit(31)"); + expect(extensionResult.extensions.map(extension => path.basename(extension.path))).toEqual(["good-extension.ts"]); + expect( + extensionResult.errors.map(({ path: modulePath, error }) => [ + modulePath, + error.match(/process\.(?:exit|reallyExit)\(\d+\)/)?.[0], + ]), + ).toEqual([ + [topLevelExtension, "process.exit(0)"], + [factoryExtension, "process.exit(31)"], + [reallyExitExtension, "process.reallyExit(33)"], + ]); expect(hookResult.hooks).toEqual([]); - expect(hookResult.errors).toHaveLength(1); - expect(hookResult.errors[0].path).toBe(hook); - expect(hookResult.errors[0].error).toContain("process.exit(32)"); - expect(reallyExitResult.extensions).toEqual([]); - expect(reallyExitResult.errors).toHaveLength(1); - expect(reallyExitResult.errors[0].path).toBe(reallyExitExtension); - expect(reallyExitResult.errors[0].error).toContain("process.reallyExit(33)"); - }); - - it("loads sibling modules even when one of them tries to exit", async () => { - const bad = writeModule("rogue-extension.ts", "process.exit(0)\n"); - const good = writeModule( - "good-extension.ts", - "export default function(pi) { pi.registerCommand('ok', { handler: async () => {} }); }\n", - ); - const cwd = project!.path(); - - const result = await loadExtensions([bad, good], cwd); - - expect(result.errors.map(e => e.path)).toEqual([bad]); - expect(result.extensions.map(e => path.basename(e.path))).toEqual(["good-extension.ts"]); + expect( + hookResult.errors.map(({ path: modulePath, error }) => [modulePath, error.match(/process\.exit\(\d+\)/)?.[0]]), + ).toEqual([ + [topLevelHook, "process.exit(42)"], + [factoryHook, "process.exit(32)"], + ]); }); it("restores process.exit after a synchronous throw inside the guarded callback", async () => { diff --git a/packages/coding-agent/test/extensions-discovery.test.ts b/packages/coding-agent/test/extensions-discovery.test.ts index d3a185b79..0950650df 100644 --- a/packages/coding-agent/test/extensions-discovery.test.ts +++ b/packages/coding-agent/test/extensions-discovery.test.ts @@ -29,8 +29,9 @@ describe("extensions discovery", () => { tempDir.removeSync(); }); - const discoverForTest = async (configuredPaths: string[] = []) => { - const result = await discoverAndLoadExtensions(configuredPaths, tempDir.path()); + const discoverForTest = async (configuredPaths: string[] = [], ambient = false) => { + const paths = ambient ? configuredPaths : [extensionsDir, ...configuredPaths]; + const result = await discoverAndLoadExtensions(paths, tempDir.path(), undefined, undefined, { ambient }); return { ...result, extensions: filterUserScoped(result.extensions, [tempDir.path(), ...configuredPaths]), @@ -452,7 +453,7 @@ describe("extensions discovery", () => { fs.writeFileSync(path.join(realDir, "index.ts"), extensionCode); fs.symlinkSync(realDir, path.join(extensionsDir, "weird.ts"), "dir"); - const result = await discoverForTest(); + const result = await discoverForTest([], true); expect(result.errors).toHaveLength(0); expect(result.extensions).toHaveLength(1); @@ -690,7 +691,7 @@ describe("extensions discovery", () => { `, ); - const result = await discoverForTest(); + const result = await discoverForTest([], true); const loadedHook = result.extensions.find(extension => extension.path === hookPath); expect(result.errors).toHaveLength(0); @@ -720,7 +721,7 @@ describe("extensions discovery", () => { }); initializeWithSettings(settings); - const result = await discoverForTest(); + const result = await discoverForTest([], true); const loadedHook = result.extensions.find(extension => extension.path === hookPath); expect(result.errors).toHaveLength(0); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 14d0f2d81..aa868f374 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -10,7 +10,7 @@ import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core"; import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; -import { discoverAndLoadExtensions, ExtensionRuntime } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; +import { ExtensionRuntime, loadExtensions } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/loader"; import { EXTENSION_HANDLER_TIMEOUT_MS, ExtensionRunner, @@ -68,7 +68,13 @@ describe("ExtensionRunner", () => { }); const loadTestExtensions = async (configuredPaths: string[] = []) => { - const result = await discoverAndLoadExtensions([extensionsDir, ...configuredPaths], tempDir.path()); + const discoveredPaths = fs + .readdirSync(extensionsDir, { withFileTypes: true }) + .filter(entry => entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) + .map(entry => path.join(extensionsDir, entry.name)) + .sort(); + const explicitPaths = configuredPaths.map(configuredPath => path.resolve(tempDir.path(), configuredPath)); + const result = await loadExtensions([...discoveredPaths, ...explicitPaths], tempDir.path()); const testRoots = [ extensionsDir, ...configuredPaths.map(configuredPath => path.resolve(tempDir.path(), configuredPath)), diff --git a/packages/coding-agent/test/fast-mode-scope.test.ts b/packages/coding-agent/test/fast-mode-scope.test.ts index dcd78cc47..310058a2b 100644 --- a/packages/coding-agent/test/fast-mode-scope.test.ts +++ b/packages/coding-agent/test/fast-mode-scope.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { Api, Model, ProviderSessionState } from "@oh-my-pi/pi-ai"; @@ -14,18 +14,22 @@ import { TempDir } from "@oh-my-pi/pi-utils"; describe("/fast targets the current model's service-tier family", () => { let tempDir: TempDir; let authStorage: AuthStorage; - let session: AgentSession; + let session: AgentSession | undefined; let modelRegistry: ModelRegistry; - beforeEach(() => { + beforeAll(async () => { tempDir = TempDir.createSync("@pi-fast-mode-scope-"); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); }); afterEach(async () => { - if (session) { - await session.dispose(); - } - authStorage?.close(); + await session?.dispose(); + session = undefined; + }); + + afterAll(() => { + authStorage.close(); tempDir.removeSync(); }); @@ -41,9 +45,7 @@ describe("/fast targets the current model's service-tier family", () => { const agent = new Agent({ initialState: { model, systemPrompt: ["Test"], tools: [], messages: [] }, }); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); authStorage.setRuntimeApiKey(model.provider, "token"); - modelRegistry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); session = new AgentSession({ agent, sessionManager: SessionManager.inMemory(), diff --git a/packages/coding-agent/test/fixtures/mock-rpc-agent.ts b/packages/coding-agent/test/fixtures/mock-rpc-agent.ts index ef6dc791b..d7f4ebfe7 100755 --- a/packages/coding-agent/test/fixtures/mock-rpc-agent.ts +++ b/packages/coding-agent/test/fixtures/mock-rpc-agent.ts @@ -29,6 +29,11 @@ const legacyState = { todoPhases: [], }; +if (Bun.env.MOCK_RPC_EXIT_BEFORE_READY) { + process.stderr.write(Bun.env.MOCK_RPC_EXIT_STDERR ?? ""); + process.exit(Number(Bun.env.MOCK_RPC_EXIT_BEFORE_READY)); +} + let protocolV2Enabled = false; process.stdout.write( `${JSON.stringify( @@ -158,9 +163,7 @@ for await (const raw of console) { ) { const data = { ...legacyState, - ...(Bun.env.MOCK_RPC_INVALID_TPS === "1" - ? { fastModeEnabled: false, fastModeActive: false, tokensPerSecond: "invalid" } - : {}), + ...(Bun.env.MOCK_RPC_INVALID_TPS === "1" ? { tokensPerSecond: "invalid" } : {}), }; writeFrame({ id, @@ -177,7 +180,7 @@ for await (const raw of console) { type: "response", command: frame.type, success: true, - data: supportsProtocolV2 ? { payload: "😀".repeat(400_000) } : {}, + data: supportsProtocolV2 ? { payload: "😀".repeat(270_000) } : {}, }); } } catch { diff --git a/packages/coding-agent/test/helpers/agent-session-setup.ts b/packages/coding-agent/test/helpers/agent-session-setup.ts index 9679602ae..64deddfb9 100644 --- a/packages/coding-agent/test/helpers/agent-session-setup.ts +++ b/packages/coding-agent/test/helpers/agent-session-setup.ts @@ -1,4 +1,6 @@ +import { Database } from "bun:sqlite"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; /** * Shared factory for building a minimal mock `AssistantMessage` @@ -23,3 +25,12 @@ export function createAssistantMessage(text: string): AssistantMessage { timestamp: Date.now(), }; } + +/** + * Build isolated auth state without opening a filesystem-backed SQLite database. + * AgentSession unit tests that only need a runtime API key should not pay for + * database creation, journaling, and deletion on every case. + */ +export function createInMemoryAuthStorage(): AuthStorage { + return new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:"))); +} diff --git a/packages/coding-agent/test/input-controller-suspend.test.ts b/packages/coding-agent/test/input-controller-suspend.test.ts index a0a9d120e..d423fd5f9 100644 --- a/packages/coding-agent/test/input-controller-suspend.test.ts +++ b/packages/coding-agent/test/input-controller-suspend.test.ts @@ -30,6 +30,7 @@ function createCtx(): SuspendCtx { } const originalPlatform = process.platform; +let sigcontListener: (() => void) | undefined; function setPlatform(value: NodeJS.Platform): void { Object.defineProperty(process, "platform", { value, configurable: true, writable: true }); @@ -37,10 +38,9 @@ function setPlatform(value: NodeJS.Platform): void { afterEach(() => { Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true, writable: true }); + if (sigcontListener) process.removeListener("SIGCONT", sigcontListener); + sigcontListener = undefined; vi.restoreAllMocks(); - // Drop any SIGCONT listener a passing test left behind so a later test - // (or the next file) doesn't get spurious callbacks. - process.removeAllListeners("SIGCONT"); }); describe("InputController.handleCtrlZ", () => { @@ -88,9 +88,9 @@ describe("InputController.handleCtrlZ", () => { expect(showError).not.toHaveBeenCalled(); // Simulating the kernel-delivered SIGCONT drives the TUI back up. - const resume = onceSpy.mock.calls.find(([sig]) => sig === "SIGCONT")?.[1] as (() => void) | undefined; - expect(resume).toBeDefined(); - resume?.(); + sigcontListener = onceSpy.mock.calls.find(([sig]) => sig === "SIGCONT")?.[1] as (() => void) | undefined; + expect(sigcontListener).toBeDefined(); + sigcontListener?.(); expect(ui.start).toHaveBeenCalledTimes(1); expect(ui.requestRender).toHaveBeenCalledWith(true); }); @@ -113,9 +113,9 @@ describe("InputController.handleCtrlZ", () => { // The exact listener we registered for SIGCONT is the one we // remove; otherwise a leaked handler would fire on the next // unrelated continue and re-`start()` an already-running TUI. - const registered = onceSpy.mock.calls.find(([sig]) => sig === "SIGCONT")?.[1]; - expect(registered).toBeDefined(); - expect(removeSpy).toHaveBeenCalledWith("SIGCONT", registered); + sigcontListener = onceSpy.mock.calls.find(([sig]) => sig === "SIGCONT")?.[1] as (() => void) | undefined; + expect(sigcontListener).toBeDefined(); + expect(removeSpy).toHaveBeenCalledWith("SIGCONT", sigcontListener); expect(killSpy).toHaveBeenCalledTimes(1); expect(ui.stop).toHaveBeenCalledTimes(1); diff --git a/packages/coding-agent/test/input-controller-thinking-visibility.test.ts b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts index 2ed3f9fa3..282a9432c 100644 --- a/packages/coding-agent/test/input-controller-thinking-visibility.test.ts +++ b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts @@ -1,14 +1,20 @@ -import { describe, expect, it, vi } from "bun:test"; +import { describe, expect, it, type Mock, vi } from "bun:test"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +function createAssistant(): AssistantMessageComponent { + const assistant = Object.create(AssistantMessageComponent.prototype) as AssistantMessageComponent; + assistant.setHideThinkingBlock = vi.fn(); + return assistant; +} + describe("InputController thinking visibility", () => { it("keeps pre-stream pending transcript content mounted when Ctrl+T toggles thinking blocks", () => { const pendingUserMessage = { kind: "pending-user" }; const loadingIndicator = { kind: "loading" }; - const assistant = new AssistantMessageComponent(); - const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock"); + const assistant = createAssistant(); + const setHideThinkingBlock = assistant.setHideThinkingBlock as Mock<(hidden: boolean) => void>; const resetDisplay = vi.fn(); const clear = vi.fn(); const addChild = vi.fn(); @@ -48,8 +54,8 @@ describe("InputController thinking visibility", () => { // When thinking is "off", effectiveHideThinkingBlock is true even if the // user's hideThinkingBlock setting is false. The toggle should refuse // instead of silently no-op'ing or corrupting the setting. - const assistant = new AssistantMessageComponent(); - const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock"); + const assistant = createAssistant(); + const setHideThinkingBlock = assistant.setHideThinkingBlock as Mock<(hidden: boolean) => void>; const set = vi.fn(); const showStatus = vi.fn(); const resetDisplay = vi.fn(); @@ -76,8 +82,8 @@ describe("InputController thinking visibility", () => { }); it("allows toggling when thinking is off after reasoning content was received", () => { - const assistant = new AssistantMessageComponent(); - const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock"); + const assistant = createAssistant(); + const setHideThinkingBlock = assistant.setHideThinkingBlock as Mock<(hidden: boolean) => void>; const set = vi.fn(); const showStatus = vi.fn(); const resetDisplay = vi.fn(); @@ -104,8 +110,8 @@ describe("InputController thinking visibility", () => { }); it("refuses to toggle when the focused view session has thinking off", () => { - const assistant = new AssistantMessageComponent(); - const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock"); + const assistant = createAssistant(); + const setHideThinkingBlock = assistant.setHideThinkingBlock as Mock<(hidden: boolean) => void>; const set = vi.fn(); const showStatus = vi.fn(); const resetDisplay = vi.fn(); @@ -136,8 +142,8 @@ describe("InputController thinking visibility", () => { // where thinking was on. With thinking off, effectiveHideThinkingBlock // is true regardless, so any toggle is a no-op — guard it rather than // flipping the persisted preference back to false. - const assistant = new AssistantMessageComponent(); - const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock"); + const assistant = createAssistant(); + const setHideThinkingBlock = assistant.setHideThinkingBlock as Mock<(hidden: boolean) => void>; const set = vi.fn(); const showStatus = vi.fn(); const resetDisplay = vi.fn(); diff --git a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts index 9e24423e5..868484a40 100644 --- a/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts +++ b/packages/coding-agent/test/interactive-mode-default-plan-mode.test.ts @@ -11,7 +11,7 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage import { TempDir } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../src/config/model-registry"; import type { CustomTool } from "../src/extensibility/custom-tools/types"; -import { InteractiveMode } from "../src/modes/interactive-mode"; +import { InteractiveMode, shouldEnterPlanModeOnStartup } from "../src/modes/interactive-mode"; import { resolveXdevTool, type XdevState } from "../src/tools/xdev"; function makeTool(name: string): AgentTool { @@ -119,6 +119,19 @@ describe("InteractiveMode plan.defaultOnStartup", () => { return mode; } + function startupDecisionHarness( + sessionSettings: Settings, + options: { conversation?: boolean; explicitMode?: boolean } = {}, + ): boolean { + return shouldEnterPlanModeOnStartup( + { + buildSessionContext: () => ({ messages: options.conversation ? [{}] : [] }) as never, + getEntries: () => (options.explicitMode ? [{ type: "mode_change" }] : []) as never, + }, + sessionSettings, + ); + } + it("enters plan mode at startup when the setting is enabled", async () => { const created = createHarness(Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false })); @@ -303,27 +316,43 @@ describe("InteractiveMode plan.defaultOnStartup", () => { expect(session?.peekPlanProposalHandler()).toBeUndefined(); }); - it("does not enter plan mode at startup by default", async () => { - const created = createHarness(Settings.isolated({ "compaction.enabled": false })); - - await created.init({ suppressWelcomeIntro: true }); - - expect(created.planModeEnabled).toBe(false); - expect(session?.getPlanModeState()).toBeUndefined(); + it("enters only when enabled and the session has no conversation or explicit mode", () => { + expect(startupDecisionHarness(Settings.isolated({ "compaction.enabled": false }))).toBe(false); + const enabled = Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false }); + expect(startupDecisionHarness(enabled, { conversation: true })).toBe(false); + expect(startupDecisionHarness(enabled, { explicitMode: true })).toBe(false); + expect( + startupDecisionHarness( + Settings.isolated({ + "plan.defaultOnStartup": true, + "plan.enabled": false, + "compaction.enabled": false, + }), + ), + ).toBe(false); }); - it("does not enter plan mode when the session has restored conversation", async () => { - // A genuinely resumed session has prior conversation messages. Gating on - // message entries (not the CLI resume flag) means a `--continue` that - // created a *fresh* session still gets the startup default (above), while - // one with restored conversation is left in its reconciled mode. - const created = createHarness(Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false })); - created.sessionManager.appendMessage({ role: "user", content: "prior turn", timestamp: Date.now() }); + it("classifies persisted compaction, metadata, custom, and mode entries without constructing a TUI", async () => { + const enabled = Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false }); + const manager = SessionManager.create( + tempDir.path(), + path.join(tempDir.path(), `startup-decision-${Bun.nanoseconds()}`), + ); + try { + manager.appendModelChange("anthropic/claude-sonnet-4-5"); + manager.appendThinkingLevelChange("medium"); + manager.appendCustomEntry("my-extension-state", { foo: "bar" }); + expect(shouldEnterPlanModeOnStartup(manager, enabled)).toBe(true); - await created.init({ suppressWelcomeIntro: true }); + manager.appendCompaction("prior conversation summary", undefined, "first-kept", 1000); + expect(shouldEnterPlanModeOnStartup(manager, enabled)).toBe(false); - expect(created.planModeEnabled).toBe(false); - expect(session?.getPlanModeState()).toBeUndefined(); + manager.appendModeChange("plan", { planFilePath: "local://PLAN.md" }); + manager.appendModeChange("none"); + expect(shouldEnterPlanModeOnStartup(manager, enabled)).toBe(false); + } finally { + await manager.close(); + } }); it("preserves the restored model when resuming an active plan session", async () => { @@ -342,72 +371,4 @@ describe("InteractiveMode plan.defaultOnStartup", () => { expect(created.planModeEnabled).toBe(true); expect(session?.model?.id).toBe("claude-sonnet-4-5"); }); - - it("enters plan mode for a fresh session that carries only startup metadata", async () => { - // createAgentSession appends model_change / thinking_level_change for a - // brand-new session before init(); those are not conversation history, so - // the startup default must still apply (regression: gating on entry count - // instead of message entries skipped plan mode for every real new session). - const created = createHarness(Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false })); - created.sessionManager.appendModelChange("anthropic/claude-sonnet-4-5"); - created.sessionManager.appendThinkingLevelChange("medium"); - - await created.init({ suppressWelcomeIntro: true }); - - expect(created.planModeEnabled).toBe(true); - expect(session?.getPlanModeState()).toMatchObject({ enabled: true }); - }); - - it("enters plan mode for a fresh session that carries an extension custom entry", async () => { - // An extension can persist a custom entry during session_start; that is not - // conversation or a mode change, so the startup default must still apply - // (regression: an allowlist of SDK metadata types skipped plan mode here). - const created = createHarness(Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false })); - created.sessionManager.appendModelChange("anthropic/claude-sonnet-4-5"); - created.sessionManager.appendCustomEntry("my-extension-state", { foo: "bar" }); - - await created.init({ suppressWelcomeIntro: true }); - - expect(created.planModeEnabled).toBe(true); - expect(session?.getPlanModeState()).toMatchObject({ enabled: true }); - }); - - it("does not enter plan mode for a compacted session with no trailing message", async () => { - // A compacted branch carries summary context (buildSessionContext emits the - // compaction summary as a message), so it is not fresh even without a literal - // `message` entry; the startup default must not override its restored mode. - const created = createHarness(Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false })); - created.sessionManager.appendModelChange("anthropic/claude-sonnet-4-5"); - created.sessionManager.appendCompaction("prior conversation summary", undefined, "first-kept", 1000); - - await created.init({ suppressWelcomeIntro: true }); - - expect(created.planModeEnabled).toBe(false); - expect(session?.getPlanModeState()).toBeUndefined(); - }); - - it("does not re-enter plan mode when a restored mode_change turned it off (no message yet)", async () => { - // User enabled plan, toggled it off (mode_change "none"), then quit before - // sending a turn. On --continue the reconciler restores that off state; the - // startup default must not override it just because there is no message entry. - const created = createHarness(Settings.isolated({ "plan.defaultOnStartup": true, "compaction.enabled": false })); - created.sessionManager.appendModeChange("plan", { planFilePath: "local://PLAN.md" }); - created.sessionManager.appendModeChange("none"); - - await created.init({ suppressWelcomeIntro: true }); - - expect(created.planModeEnabled).toBe(false); - expect(session?.getPlanModeState()).toBeUndefined(); - }); - - it("does not enter plan mode when plan mode is globally disabled", async () => { - const created = createHarness( - Settings.isolated({ "plan.defaultOnStartup": true, "plan.enabled": false, "compaction.enabled": false }), - ); - - await created.init({ suppressWelcomeIntro: true }); - - expect(created.planModeEnabled).toBe(false); - expect(session?.getPlanModeState()).toBeUndefined(); - }); }); diff --git a/packages/coding-agent/test/interactive-mode-deferred-command-notice.test.ts b/packages/coding-agent/test/interactive-mode-deferred-command-notice.test.ts index 2bf17083c..52e0b307a 100644 --- a/packages/coding-agent/test/interactive-mode-deferred-command-notice.test.ts +++ b/packages/coding-agent/test/interactive-mode-deferred-command-notice.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, describe, expect, it, vi } from "bun:test"; import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -13,9 +13,16 @@ type Harness = { setStreaming: (value: boolean) => void; }; -let harnesses: Harness[] = []; +let harness: Harness | undefined; async function createHarness(): Promise { + if (harness) { + harness.setStreaming(false); + harness.mode.clearTransientSessionUi(); + harness.mode.chatContainer.disposeChildren(); + return harness; + } + const tempDir = TempDir.createSync("@pi-deferred-notice-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); await initTheme(false); @@ -39,14 +46,13 @@ async function createHarness(): Promise { }, } as unknown as AgentSession; const mode = new InteractiveMode(session, "test"); - const harness = { + harness = { mode, tempDir, setStreaming: (value: boolean) => { streaming = value; }, }; - harnesses.push(harness); return harness; } @@ -63,12 +69,13 @@ function transcriptText(mode: InteractiveMode): string { } afterEach(() => { - for (const harness of harnesses) { - harness.mode.stop(); - harness.tempDir.removeSync(); - } - harnesses = []; vi.restoreAllMocks(); +}); + +afterAll(() => { + harness?.mode.stop(); + harness?.tempDir.removeSync(); + harness = undefined; resetSettingsForTest(); }); diff --git a/packages/coding-agent/test/interactive-mode-loop.test.ts b/packages/coding-agent/test/interactive-mode-loop.test.ts index bdd9d31a4..721d0c696 100644 --- a/packages/coding-agent/test/interactive-mode-loop.test.ts +++ b/packages/coding-agent/test/interactive-mode-loop.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -22,12 +22,10 @@ describe("InteractiveMode loop auto-submit", () => { let mode: InteractiveMode; let session: AgentSession; let tempDir: TempDir; + let pendingInput: Promise | undefined; - beforeAll(() => { + beforeAll(async () => { initTheme(); - }); - - beforeEach(async () => { resetSettingsForTest(); tempDir = TempDir.createSync("@pi-loop-auto-submit-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); @@ -43,19 +41,36 @@ describe("InteractiveMode loop auto-submit", () => { modelRegistry, }); mode = new InteractiveMode(session, "test"); - vi.spyOn(mode, "addMessageToChat").mockReturnValue([]); - vi.spyOn(mode, "ensureLoadingAnimation").mockImplementation(() => {}); mode.ui.requestRender = vi.fn(); }); + beforeEach(() => { + settings.set("loop.mode", "prompt"); + vi.spyOn(mode, "addMessageToChat").mockReturnValue([]); + vi.spyOn(mode, "ensureLoadingAnimation").mockImplementation(() => {}); + }); + afterEach(async () => { - mode?.disableLoopMode("Loop mode disabled."); - mode?.stop(); + mode.disableLoopMode("Loop mode disabled."); + mode.cancelPendingSubmission(); + if (mode.onInputCallback) { + mode.onInputCallback({ text: "", cancelled: true, started: false }); + } + await pendingInput; + pendingInput = undefined; + mode.vibeModeEnabled = false; + Reflect.deleteProperty(session, "isCompacting"); + Reflect.deleteProperty(session, "isStreaming"); + Reflect.deleteProperty(session, "hasPostPromptWork"); vi.useRealTimers(); vi.restoreAllMocks(); - await session?.dispose(); - authStorage?.close(); - tempDir?.removeSync(); + }); + + afterAll(async () => { + mode.stop(); + await session.dispose(); + authStorage.close(); + tempDir.removeSync(); resetSettingsForTest(); }); @@ -68,7 +83,8 @@ describe("InteractiveMode loop auto-submit", () => { mode.loopModeEnabled = true; mode.loopPrompt = "repeat this"; const resolved: SubmittedUserInput[] = []; - void mode.getUserInput().then(input => resolved.push(input)); + pendingInput = mode.getUserInput(); + void pendingInput.then(input => resolved.push(input)); vi.advanceTimersByTime(800); await flushMicrotasks(); @@ -96,7 +112,8 @@ describe("InteractiveMode loop auto-submit", () => { mode.loopModeEnabled = true; mode.loopPrompt = "repeat after compact"; const resolved: SubmittedUserInput[] = []; - void mode.getUserInput().then(input => resolved.push(input)); + pendingInput = mode.getUserInput(); + void pendingInput.then(input => resolved.push(input)); vi.advanceTimersByTime(800); await flushMicrotasks(); @@ -122,7 +139,8 @@ describe("InteractiveMode loop auto-submit", () => { mode.loopModeEnabled = true; mode.loopPrompt = "deliver this"; const resolved: SubmittedUserInput[] = []; - void mode.getUserInput().then(input => resolved.push(input)); + pendingInput = mode.getUserInput(); + void pendingInput.then(input => resolved.push(input)); // Loop timer fires while an idle-flush / delivery turn is still pending. vi.advanceTimersByTime(800); @@ -146,7 +164,8 @@ describe("InteractiveMode loop auto-submit", () => { mode.loopPrompt = "do not resubmit"; const showStatus = vi.spyOn(mode, "showStatus"); const resolved: SubmittedUserInput[] = []; - void mode.getUserInput().then(input => resolved.push(input)); + pendingInput = mode.getUserInput(); + void pendingInput.then(input => resolved.push(input)); vi.advanceTimersByTime(800); await flushMicrotasks(); diff --git a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts index 10e49a253..de6d15781 100644 --- a/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts +++ b/packages/coding-agent/test/interactive-mode-lsp-startup.test.ts @@ -85,7 +85,7 @@ describe("InteractiveMode LSP startup welcome banner", () => { resetSettingsForTest(); }); - it("updates the welcome banner when startup warmup completes", async () => { + it("updates the welcome banner and suppresses subsequent startup warnings when quiet", async () => { await mode.init(); const findServerLine = () => @@ -118,17 +118,13 @@ describe("InteractiveMode LSP startup welcome banner", () => { expect(showStatusSpy).not.toHaveBeenCalled(); expect(findServerLine()).toContain(theme.status.enabled); expect(findServerLine()).not.toContain(theme.status.pending); - }); - it("does not render LSP startup warnings when startup.quiet is enabled", () => { session.settings.set("startup.quiet", true); const showWarningSpy = vi.spyOn(mode, "showWarning").mockImplementation(() => {}); - eventBus.emit(LSP_STARTUP_EVENT_CHANNEL, { type: "failed", error: "rust-analyzer timed out", } satisfies LspStartupEvent); - expect(showWarningSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/coding-agent/test/interactive-mode-plan-review.test.ts b/packages/coding-agent/test/interactive-mode-plan-review.test.ts index a2fb1fa55..4c3abbc1f 100644 --- a/packages/coding-agent/test/interactive-mode-plan-review.test.ts +++ b/packages/coding-agent/test/interactive-mode-plan-review.test.ts @@ -72,11 +72,10 @@ describe("InteractiveMode plan review rendering", () => { let tempDir: TempDir; let session: AgentSession; let mode: InteractiveMode; - // Shared across the whole describe: AuthStorage (a SQLite db) and ModelRegistry - // are the expensive pieces (~14ms/test combined) and tests only ever read from - // them — `find()` is a pure lookup over a model list frozen at construction, and - // the lone `setRuntimeApiKey` re-call is idempotent. Hoisting them out of - // `beforeEach` is the dominant body-time win. + // Shared across the whole describe: global Settings initialization, AuthStorage + // (a SQLite db), and ModelRegistry are immutable inputs here. Tests mutate only + // their per-session Settings.isolated() instances, so rebuilding these process- + // global resources for every InteractiveMode adds I/O without isolation. let sharedTempDir: TempDir; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; @@ -96,10 +95,8 @@ describe("InteractiveMode plan review rendering", () => { sharedTempDir?.removeSync(); }); - beforeEach(async () => { - resetSettingsForTest(); + beforeEach(() => { tempDir = TempDir.createSync("@pi-plan-review-"); - await Settings.init({ inMemory: true, cwd: tempDir.path() }); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) { throw new Error("Expected claude-sonnet-4-5 to exist in registry"); @@ -133,7 +130,6 @@ describe("InteractiveMode plan review rendering", () => { await currentSession?.dispose(); currentTempDir?.removeSync(); setKeybindings(KeybindingsManager.inMemory()); - resetSettingsForTest(); }); it("keeps queued-message rows in the live region instead of native scrollback", () => { @@ -1965,6 +1961,67 @@ describe("InteractiveMode plan review rendering", () => { expect(showError).not.toHaveBeenCalled(); }); + describe("openPlanReview (manual /plan-review)", () => { + const localPath = (url: string): string => + resolveLocalUrlToPath(url, { + getArtifactsDir: () => session.sessionManager.getArtifactsDir(), + getSessionId: () => session.sessionManager.getSessionId(), + }); + + it("forwards the newest local plan file and its heading title to the approval flow", async () => { + await Bun.write(localPath("local://old-plan.md"), "# Old plan\n\nstale body"); + await Bun.write(localPath("local://auth-refactor-plan.md"), "# Auth refactor\n\nfresh body"); + // #listLocalPlanFiles sorts by mtime, newest first — pin mtimes so the + // "latest plan" selection is deterministic regardless of write timing. + await fs.utimes(localPath("local://old-plan.md"), new Date(1_000), new Date(1_000)); + await fs.utimes(localPath("local://auth-refactor-plan.md"), new Date(2_000), new Date(2_000)); + + mode.planModeEnabled = true; + // The default points at a file that never exists; the scan must still find + // the real plan, and getPlanReferencePath() is empty before any approval. + mode.planModePlanFilePath = "local://PLAN.md"; + const approval = vi.spyOn(mode, "handlePlanApproval").mockResolvedValue(); + + await mode.openPlanReview(); + + expect(approval).toHaveBeenCalledTimes(1); + expect(approval).toHaveBeenCalledWith({ + planFilePath: "local://auth-refactor-plan.md", + title: "Auth-refactor", + planExists: true, + }); + }); + + it("warns and does not start approval when plan mode is inactive", async () => { + await Bun.write(localPath("local://auth-plan.md"), "# Auth\n\nbody"); + mode.planModeEnabled = false; + const approval = vi.spyOn(mode, "handlePlanApproval").mockResolvedValue(); + const warn = vi.spyOn(mode, "showWarning"); + + await mode.openPlanReview(); + + expect(approval).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith("Plan mode is not active."); + }); + + it("warns when no plan file has been written yet", async () => { + mode.planModeEnabled = true; + const approval = vi.spyOn(mode, "handlePlanApproval").mockResolvedValue(); + const warn = vi.spyOn(mode, "showWarning"); + + await mode.openPlanReview(); + + expect(approval).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("No plan to review")); + }); + }); +}); + +describe("AssistantMessageComponent aborted replay", () => { + beforeAll(() => { + initTheme(); + }); + // ========================================================================== // Phase 6 — D layer: replay-side render branches in AssistantMessageComponent. // @@ -2036,59 +2093,8 @@ describe("InteractiveMode plan review rendering", () => { expect(rendered).not.toContain(USER_INTERRUPT_LABEL); expect(rendered).not.toContain("Operation aborted"); }); - - describe("openPlanReview (manual /plan-review)", () => { - const localPath = (url: string): string => - resolveLocalUrlToPath(url, { - getArtifactsDir: () => session.sessionManager.getArtifactsDir(), - getSessionId: () => session.sessionManager.getSessionId(), - }); - - it("forwards the newest local plan file and its heading title to the approval flow", async () => { - await Bun.write(localPath("local://old-plan.md"), "# Old plan\n\nstale body"); - await Bun.write(localPath("local://auth-refactor-plan.md"), "# Auth refactor\n\nfresh body"); - // #listLocalPlanFiles sorts by mtime, newest first — pin mtimes so the - // "latest plan" selection is deterministic regardless of write timing. - await fs.utimes(localPath("local://old-plan.md"), new Date(1_000), new Date(1_000)); - await fs.utimes(localPath("local://auth-refactor-plan.md"), new Date(2_000), new Date(2_000)); - - mode.planModeEnabled = true; - // The default points at a file that never exists; the scan must still find - // the real plan, and getPlanReferencePath() is empty before any approval. - mode.planModePlanFilePath = "local://PLAN.md"; - const approval = vi.spyOn(mode, "handlePlanApproval").mockResolvedValue(); - - await mode.openPlanReview(); - - expect(approval).toHaveBeenCalledTimes(1); - expect(approval).toHaveBeenCalledWith({ - planFilePath: "local://auth-refactor-plan.md", - title: "Auth-refactor", - planExists: true, - }); - }); - - it("warns and does not start approval when plan mode is inactive", async () => { - await Bun.write(localPath("local://auth-plan.md"), "# Auth\n\nbody"); - mode.planModeEnabled = false; - const approval = vi.spyOn(mode, "handlePlanApproval").mockResolvedValue(); - const warn = vi.spyOn(mode, "showWarning"); - - await mode.openPlanReview(); - - expect(approval).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalledWith("Plan mode is not active."); - }); - - it("warns when no plan file has been written yet", async () => { - mode.planModeEnabled = true; - const approval = vi.spyOn(mode, "handlePlanApproval").mockResolvedValue(); - const warn = vi.spyOn(mode, "showWarning"); - - await mode.openPlanReview(); - - expect(approval).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("No plan to review")); - }); - }); +}); + +afterAll(() => { + resetSettingsForTest(); }); diff --git a/packages/coding-agent/test/interactive-mode-title-prewarm.test.ts b/packages/coding-agent/test/interactive-mode-title-prewarm.test.ts index 76f1a2661..939e55bfa 100644 --- a/packages/coding-agent/test/interactive-mode-title-prewarm.test.ts +++ b/packages/coding-agent/test/interactive-mode-title-prewarm.test.ts @@ -1,15 +1,15 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { tinyTitleClient } from "@oh-my-pi/pi-coding-agent/tiny/title-client"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; // Issue #6462: the first submit used to spawn the local tiny-title worker // synchronously ahead of the first frame, and title generation started before @@ -17,6 +17,7 @@ import { TempDir } from "@oh-my-pi/pi-utils"; // submit handler paints the pending row before kicking off titling. describe("InteractiveMode tiny-title prewarm", () => { let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; let mode: InteractiveMode; let session: AgentSession; let tempDir: TempDir; @@ -26,6 +27,9 @@ describe("InteractiveMode tiny-title prewarm", () => { beforeAll(() => { initTheme(); + tempDir = TempDir.createSync("@pi-interactive-mode-title-prewarm-"); + authStorage = createInMemoryAuthStorage(); + modelRegistry = new ModelRegistry(authStorage); }); beforeEach(async () => { @@ -43,10 +47,7 @@ describe("InteractiveMode tiny-title prewarm", () => { delete Bun.env.PI_NO_TITLE; resetSettingsForTest(); - tempDir = TempDir.createSync("@pi-interactive-mode-title-prewarm-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - const modelRegistry = new ModelRegistry(authStorage); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) { throw new Error("Expected claude-sonnet-4-5 to exist in registry"); @@ -75,13 +76,16 @@ describe("InteractiveMode tiny-title prewarm", () => { mode?.stop(); vi.restoreAllMocks(); await session?.dispose(); - authStorage?.close(); - tempDir?.removeSync(); resetSettingsForTest(); if (previousNoTitle === undefined) delete Bun.env.PI_NO_TITLE; else Bun.env.PI_NO_TITLE = previousNoTitle; }); + afterAll(() => { + authStorage.close(); + tempDir.removeSync(); + }); + it("prewarms the configured local worker on startup for an unnamed session", async () => { session.settings.set("providers.tinyModel", "lfm2-350m"); const prewarm = vi.spyOn(tinyTitleClient, "prewarm").mockImplementation(() => {}); diff --git a/packages/coding-agent/test/interactive-mode-todo-clear.test.ts b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts index 6c2a5a1ac..32376170b 100644 --- a/packages/coding-agent/test/interactive-mode-todo-clear.test.ts +++ b/packages/coding-agent/test/interactive-mode-todo-clear.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -24,37 +24,15 @@ describe("InteractiveMode todo HUD persistence", () => { let session: AgentSession; let mode: InteractiveMode; let eventBus: EventBus; + let modelRegistry: ModelRegistry; - beforeAll(async () => { - await initTheme(); - }); - - beforeEach(async () => { - resetSettingsForTest(); - tempDir = TempDir.createSync("@pi-todo-clear-"); - }); - - afterEach(async () => { - mode?.stop(); - await session?.dispose(); - authStorage?.close(); - tempDir?.removeSync(); - vi.useRealTimers(); - vi.restoreAllMocks(); - resetSettingsForTest(); - }); - - async function createMode(todoClearDelay: number): Promise { - await Settings.init({ - inMemory: true, - cwd: tempDir.path(), - overrides: { "tasks.todoClearDelay": todoClearDelay }, - }); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - const modelRegistry = new ModelRegistry(authStorage); + async function replaceMode(): Promise { + if (mode) { + mode.stop(); + await session.dispose(); + } const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry"); - eventBus = new EventBus(); session = new AgentSession({ agent: new Agent({ @@ -66,14 +44,43 @@ describe("InteractiveMode todo HUD persistence", () => { }, }), sessionManager: SessionManager.create(tempDir.path(), tempDir.path()), - settings: Settings.isolated({ "tasks.todoClearDelay": todoClearDelay }), + settings: Settings.isolated(), modelRegistry, }); mode = new InteractiveMode(session, "test", undefined, undefined, undefined, undefined, eventBus); } - it("clears closed todos from the panel instantly without mutating session history", async () => { - await createMode(0); + beforeAll(async () => { + await initTheme(); + resetSettingsForTest(); + tempDir = TempDir.createSync("@pi-todo-clear-"); + await Settings.init({ inMemory: true, cwd: tempDir.path() }); + authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + modelRegistry = new ModelRegistry(authStorage); + await replaceMode(); + }); + + afterEach(() => { + session.setTodoPhases([]); + mode.setTodos([]); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + afterAll(async () => { + mode?.stop(); + await session?.dispose(); + authStorage?.close(); + tempDir?.removeSync(); + resetSettingsForTest(); + }); + + function setTodoClearDelay(todoClearDelay: number): void { + session.settings.override("tasks.todoClearDelay", todoClearDelay); + } + + it("clears closed todos from the panel instantly without mutating session history", () => { + setTodoClearDelay(0); const phases: TodoPhase[] = [ { name: "Implementation", @@ -110,8 +117,8 @@ describe("InteractiveMode todo HUD persistence", () => { }, ]; - it("keeps an unfinished plan's progress when the auto-clear delay elapses", async () => { - await createMode(1); + it("keeps an unfinished plan's progress when the auto-clear delay elapses", () => { + setTodoClearDelay(1); vi.useFakeTimers(); mode.setTodos(unfinishedPlan()); @@ -124,8 +131,8 @@ describe("InteractiveMode todo HUD persistence", () => { expect(rendered).toContain("current task"); }); - it("keeps an unfinished plan's progress when auto-clear is instant", async () => { - await createMode(0); + it("keeps an unfinished plan's progress when auto-clear is instant", () => { + setTodoClearDelay(0); mode.setTodos(unfinishedPlan()); @@ -134,16 +141,16 @@ describe("InteractiveMode todo HUD persistence", () => { expect(rendered).toContain("current task"); }); - it("leaves closed todos visible when auto-clear is disabled", async () => { - await createMode(-1); + it("leaves closed todos visible when auto-clear is disabled", () => { + setTodoClearDelay(-1); mode.setTodos([{ name: "Implementation", tasks: [{ content: "done task", status: "completed" }] }]); expect(renderTodos(mode)).toContain("done task"); }); - it("clears closed todos after the configured delay", async () => { - await createMode(1); + it("clears closed todos after the configured delay", () => { + setTodoClearDelay(1); vi.useFakeTimers(); mode.setTodos([{ name: "Implementation", tasks: [{ content: "done task", status: "completed" }] }]); @@ -156,8 +163,8 @@ describe("InteractiveMode todo HUD persistence", () => { expect(renderTodos(mode)).not.toContain("done task"); }); - it("keeps the anchored todo panel in the live region while visible", async () => { - await createMode(-1); + it("keeps the anchored todo panel in the live region while visible", () => { + setTodoClearDelay(-1); mode.setTodos([{ name: "Implementation", tasks: [{ content: "pending task", status: "pending" }] }]); const liveRegion = mode.todoContainer as unknown as NativeScrollbackLiveRegion; @@ -168,7 +175,8 @@ describe("InteractiveMode todo HUD persistence", () => { }); it("marks todos complete when subagent reconciliation reports a finished agent", async () => { - await createMode(-1); + await replaceMode(); + setTodoClearDelay(-1); vi.spyOn(mode.statusLine, "watchBranch").mockImplementation(() => {}); session.setTodoPhases([ { name: "Implementation", tasks: [{ content: "Fix review comments", status: "pending" }] }, @@ -193,7 +201,8 @@ describe("InteractiveMode todo HUD persistence", () => { }); it("completes a blocked todo when the detached subagent it waits on finishes", async () => { - await createMode(-1); + await replaceMode(); + setTodoClearDelay(-1); vi.spyOn(mode.statusLine, "watchBranch").mockImplementation(() => {}); // A todo blocked while waiting on a detached subagent. Blocked todos are // excluded from the stop reminder, so if reconciliation skipped them this @@ -233,9 +242,6 @@ describe("InteractiveMode todo HUD anchor", () => { beforeAll(async () => { await initTheme(); - }); - - beforeEach(async () => { resetSettingsForTest(); tempDir = TempDir.createSync("@pi-todo-hud-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); @@ -254,13 +260,17 @@ describe("InteractiveMode todo HUD anchor", () => { mode = new InteractiveMode(session, "test"); }); - afterEach(async () => { + afterEach(() => { + mode.setTodos([]); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + afterAll(async () => { mode?.stop(); await session?.dispose(); authStorage?.close(); tempDir?.removeSync(); - vi.useRealTimers(); - vi.restoreAllMocks(); resetSettingsForTest(); }); diff --git a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts index edc3ce00d..7e9a60258 100644 --- a/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts +++ b/packages/coding-agent/test/interactive-mode-vibe-toggle.test.ts @@ -7,7 +7,7 @@ * 3. Exiting unregisters the vibe tools and restores the pre-vibe active toolset * exactly, including the legitimate empty set. */ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core"; @@ -16,13 +16,14 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { FileSessionStorage, type WriteTextAtomicOptions } from "@oh-my-pi/pi-coding-agent/session/session-storage"; import { VIBE_TOOL_NAMES } from "@oh-my-pi/pi-coding-agent/tools/vibe"; import { EventBus } from "@oh-my-pi/pi-coding-agent/utils/event-bus"; import { VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; function stubTool(name: string): AgentTool { return { @@ -91,15 +92,15 @@ describe("InteractiveMode vibe mode toggle", () => { beforeAll(async () => { await initTheme(); + tempDir = TempDir.createSync("@pi-vibe-toggle-"); + authStorage = createInMemoryAuthStorage(); + modelRegistry = new ModelRegistry(authStorage); }); beforeEach(async () => { resetSettingsForTest(); VibeSessionRegistry.resetGlobalForTests(); - tempDir = TempDir.createSync("@pi-vibe-toggle-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - modelRegistry = new ModelRegistry(authStorage); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry"); @@ -128,12 +129,15 @@ describe("InteractiveMode vibe mode toggle", () => { mode?.stop(); await session?.dispose(); VibeSessionRegistry.resetGlobalForTests(); - authStorage?.close(); - tempDir?.removeSync(); vi.restoreAllMocks(); resetSettingsForTest(); }); + afterAll(() => { + authStorage.close(); + tempDir.removeSync(); + }); + it("preserves the parent Todo tool and restores the exact pre-vibe toolset on exit", async () => { expect(session.getAllToolNames().toSorted()).toEqual(["read", "todo"]); expect(session.getActiveToolNames()).toEqual([]); diff --git a/packages/coding-agent/test/interactive-mode-working-accent.test.ts b/packages/coding-agent/test/interactive-mode-working-accent.test.ts index 6739ba776..93f648948 100644 --- a/packages/coding-agent/test/interactive-mode-working-accent.test.ts +++ b/packages/coding-agent/test/interactive-mode-working-accent.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; +import { afterAll, afterEach, describe, expect, it, vi } from "bun:test"; import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { initTheme, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -14,14 +14,22 @@ type Harness = { tempDir: TempDir; }; -let harnesses: Harness[] = []; +let harness: Harness | undefined; function defined(value: T | undefined): T { - expect(value).toBeDefined(); - return value as T; + if (value === undefined) throw new Error("Expected value to be defined"); + return value; } async function createHarness(sessionName: string): Promise { + if (harness) { + harness.mode.loadingAnimation?.stop(); + harness.mode.loadingAnimation = undefined; + harness.mode.statusContainer.disposeChildren(); + await harness.sessionManager.setSessionName(sessionName, "user"); + return harness; + } + const tempDir = TempDir.createSync("@pi-working-accent-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); await initTheme(false); @@ -44,8 +52,7 @@ async function createHarness(sessionName: string): Promise { thinkingLevel: undefined, } as unknown as AgentSession; const mode = new InteractiveMode(session, "test"); - const harness = { mode, sessionManager, tempDir }; - harnesses.push(harness); + harness = { mode, sessionManager, tempDir }; return harness; } @@ -69,12 +76,13 @@ function shadowAccentSurfaceLuminance(value: number | undefined): () => void { } afterEach(() => { - for (const harness of harnesses) { - harness.mode.stop(); - harness.tempDir.removeSync(); - } - harnesses = []; vi.restoreAllMocks(); +}); + +afterAll(() => { + harness?.mode.stop(); + harness?.tempDir.removeSync(); + harness = undefined; resetSettingsForTest(); }); diff --git a/packages/coding-agent/test/interactive-theme-scrollback.test.ts b/packages/coding-agent/test/interactive-theme-scrollback.test.ts index 087e7c666..46ae925ea 100644 --- a/packages/coding-agent/test/interactive-theme-scrollback.test.ts +++ b/packages/coding-agent/test/interactive-theme-scrollback.test.ts @@ -1,5 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -14,12 +13,13 @@ import { stopThemeWatcher, } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TUI } from "@oh-my-pi/pi-tui"; import type { TerminalAppearance, TerminalAppearanceRequestToken } from "@oh-my-pi/pi-tui/terminal"; import { TempDir } from "@oh-my-pi/pi-utils"; import { VirtualTerminal } from "../../tui/test/virtual-terminal"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; const MULTIPLEXER_ENV_KEYS = [ "TMUX", @@ -104,7 +104,7 @@ describe("InteractiveMode theme scrollback refresh", () => { let mode: InteractiveMode; let terminal: AppearanceVirtualTerminal; - beforeEach(async () => { + beforeAll(async () => { originalMultiplexerEnv = {}; for (const key of MULTIPLEXER_ENV_KEYS) { originalMultiplexerEnv[key] = Bun.env[key]; @@ -116,7 +116,7 @@ describe("InteractiveMode theme scrollback refresh", () => { await initTheme(); await setTheme("dark"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage = createInMemoryAuthStorage(); const modelRegistry = new ModelRegistry(authStorage); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 to exist in registry"); @@ -141,22 +141,31 @@ describe("InteractiveMode theme scrollback refresh", () => { await mode.init({ suppressWelcomeIntro: true }); }); - afterEach(async () => { - mode?.stop(); - stopThemeWatcher(); + beforeEach(async () => { + for (const key of MULTIPLEXER_ENV_KEYS) delete Bun.env[key]; + terminal.appearanceOnRefresh = undefined; + terminal.returnRefreshToken = true; + terminal.deferRefreshReport = true; await setTheme("dark"); - await session?.dispose(); - authStorage?.close(); - tempDir?.removeSync(); + terminal.emitAppearanceReport("dark"); + }); + + afterEach(() => { + stopThemeWatcher(); + vi.restoreAllMocks(); + for (const key of MULTIPLEXER_ENV_KEYS) delete Bun.env[key]; + }); + + afterAll(async () => { + mode.stop(); + await session.dispose(); + authStorage.close(); + tempDir.removeSync(); for (const key of MULTIPLEXER_ENV_KEYS) { const value = originalMultiplexerEnv[key]; - if (value === undefined) { - delete Bun.env[key]; - } else { - Bun.env[key] = value; - } + if (value === undefined) delete Bun.env[key]; + else Bun.env[key] = value; } - vi.restoreAllMocks(); resetSettingsForTest(); }); diff --git a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts index 1b083a057..8438b2c2e 100644 --- a/packages/coding-agent/test/internal-urls/memory-protocol.test.ts +++ b/packages/coding-agent/test/internal-urls/memory-protocol.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -392,62 +392,68 @@ describe("MemoryProtocolHandler", () => { interface MnemopiFixture { state: MnemopiSessionState; dbDir: TempDir; + session: AgentSession; } -async function withMnemopiSession( - fn: (fixture: MnemopiFixture) => Promise, - options: { bank?: string } = {}, -): Promise { - const dbDir = TempDir.createSync(`memory-protocol-mnemopi-${Date.now()}-`); - const bank = options.bank ?? "test-bank"; - const config = { - dbPath: dbDir.join("mnemopi.db"), - bank, - autoRecall: false, - autoRetain: false, - polyphonicRecall: false, - enhancedRecall: false, - proactiveLinking: false, - retainEveryNTurns: 3, - recallLimit: 10, - recallContextTurns: 1, - recallMaxQueryChars: 800, - injectionTokenLimit: 1024, - debug: false, - providerOptions: { - noEmbeddings: true, - llm: false, - }, - llmMode: "none" as const, - } as unknown as ConstructorParameters[0]["config"]; - const session = { - sessionId: "test-mnemopi", - sessionManager: { - getEntries: () => [], - getCwd: () => dbDir.path(), - getArtifactsDir: () => null, - getSessionId: () => "test-mnemopi", - }, - emitNotice: () => {}, - getHindsightSessionState: () => undefined, - } as unknown as AgentSession; - const state = new MnemopiSessionState({ sessionId: "test-mnemopi", config, session }); - setMnemopiSessionState(session, state); +let sharedMnemopiFixture: MnemopiFixture | undefined; + +async function withMnemopiSession(fn: (fixture: MnemopiFixture) => Promise): Promise { + if (!sharedMnemopiFixture) { + const dbDir = TempDir.createSync("memory-protocol-mnemopi-"); + const config = { + dbPath: dbDir.join("mnemopi.db"), + bank: "test-bank", + autoRecall: false, + autoRetain: false, + polyphonicRecall: false, + enhancedRecall: false, + proactiveLinking: false, + retainEveryNTurns: 3, + recallLimit: 10, + recallContextTurns: 1, + recallMaxQueryChars: 800, + injectionTokenLimit: 1024, + debug: false, + providerOptions: { + noEmbeddings: true, + llm: false, + }, + llmMode: "none" as const, + } as unknown as ConstructorParameters[0]["config"]; + const session = { + sessionId: "test-mnemopi", + sessionManager: { + getEntries: () => [], + getCwd: () => dbDir.path(), + getArtifactsDir: () => null, + getSessionId: () => "test-mnemopi", + }, + emitNotice: () => {}, + getHindsightSessionState: () => undefined, + } as unknown as AgentSession; + const state = new MnemopiSessionState({ sessionId: "test-mnemopi", config, session }); + setMnemopiSessionState(session, state); + sharedMnemopiFixture = { state, dbDir, session }; + } + + const fixture = sharedMnemopiFixture; AgentRegistry.global().register({ id: "test-mnemopi", displayName: "test-mnemopi", kind: "main", - session, + session: fixture.session, sessionFile: null, }); - try { - await fn({ state, dbDir }); - } finally { - await state.dispose({ consolidate: false }); - await dbDir.remove(); - } + await fn(fixture); } +afterAll(async () => { + if (!sharedMnemopiFixture) return; + await sharedMnemopiFixture.state.dispose({ consolidate: false }); + await sharedMnemopiFixture.dbDir.remove(); + sharedMnemopiFixture = undefined; +}); + describe("MemoryProtocolHandler — mnemopi bridge (issue #4443)", () => { beforeEach(() => { AgentRegistry.resetGlobalForTests(); diff --git a/packages/coding-agent/test/issue-1606-repro.test.ts b/packages/coding-agent/test/issue-1606-repro.test.ts index 6afb49fb2..3c02a4298 100644 --- a/packages/coding-agent/test/issue-1606-repro.test.ts +++ b/packages/coding-agent/test/issue-1606-repro.test.ts @@ -15,30 +15,15 @@ * the original crash again. */ import { describe, expect, it } from "bun:test"; -import * as path from "node:path"; -import { createTinyTitleSubprocess } from "@oh-my-pi/pi-coding-agent/tiny/title-client"; +import { createTinyTitleSubprocess, smokeTestTinyTitleWorker } from "@oh-my-pi/pi-coding-agent/tiny/title-client"; describe("issue #1606 — tiny model lives in an isolated subprocess", () => { it("ping/pongs through the spawned worker subprocess and tears it down cleanly", async () => { - // `smokeTestTinyTitleWorker` is the runtime probe wired into - // `omp --smoke-test`. Run it in a child Bun process instead of this - // Bun-test worker: the test runner owns its own IPC channel and can - // starve nested Bun subprocess IPC on some Bun builds. - const repoRoot = path.resolve(import.meta.dir, "../../.."); - const script = - 'const { smokeTestTinyTitleWorker } = await import("@oh-my-pi/pi-coding-agent/tiny/title-client"); await smokeTestTinyTitleWorker({ timeoutMs: 15000 });'; - const proc = Bun.spawn([process.execPath, "-e", script], { - cwd: repoRoot, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - expect(`${stdout}${stderr}`).toBe(""); - expect(exitCode).toBe(0); + // Exercise the real subprocess worker directly. `resolveWorkerSpawnCmd` + // already uses the cwd-relative CLI entrypoint required for reliable IPC + // under bun test; wrapping this in a second Bun process only duplicated the + // coding-agent module graph and amplified native-process pressure. + await smokeTestTinyTitleWorker({ timeoutMs: 15_000 }); }, 30_000); it("surfaces unexpected signal exits so in-flight callers don't await forever", async () => { @@ -83,9 +68,6 @@ describe("issue #1606 — tiny model lives in an isolated subprocess", () => { sub.intentionalExit.value = true; sub.proc.kill("SIGKILL"); await sub.proc.exited; - // Give onExit a microtask to drain — Bun's exited promise resolves - // after onExit fires, but be defensive. - await Bun.sleep(20); expect(errored).toBe(false); }, 10_000); }); diff --git a/packages/coding-agent/test/issue-2372-repro.test.ts b/packages/coding-agent/test/issue-2372-repro.test.ts index c79591c45..b9d3e65a0 100644 --- a/packages/coding-agent/test/issue-2372-repro.test.ts +++ b/packages/coding-agent/test/issue-2372-repro.test.ts @@ -1,5 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -7,9 +6,10 @@ import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/eve import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; /** * Regression for issue #2372 — pressing Ctrl+T (or any other rebuild path) @@ -25,11 +25,8 @@ describe("issue #2372 pre-streaming chat rebuild preserves optimistic submission let session: AgentSession; let tempDir: TempDir; - beforeAll(() => { + beforeAll(async () => { initTheme(); - }); - - beforeEach(async () => { vi.spyOn(process.stdout, "write").mockReturnValue(true); vi.spyOn(process.stdin, "resume").mockReturnValue(process.stdin); vi.spyOn(process.stdin, "pause").mockReturnValue(process.stdin); @@ -41,7 +38,7 @@ describe("issue #2372 pre-streaming chat rebuild preserves optimistic submission resetSettingsForTest(); tempDir = TempDir.createSync("@pi-issue-2372-"); await Settings.init({ inMemory: true, cwd: tempDir.path() }); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage = createInMemoryAuthStorage(); const modelRegistry = new ModelRegistry(authStorage); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 test model"); @@ -56,12 +53,24 @@ describe("issue #2372 pre-streaming chat rebuild preserves optimistic submission mode.ui.requestRender = vi.fn(); }); - afterEach(async () => { - mode?.stop(); + beforeEach(() => { + mode.clearOptimisticUserMessage(); + mode.chatContainer.clear(); + mode.locallySubmittedUserSignatures.clear(); + mode.optimisticUserMessageSignature = undefined; + mode.isInitialized = false; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + mode.stop(); + await session.dispose(); + authStorage.close(); + tempDir.removeSync(); vi.restoreAllMocks(); - await session?.dispose(); - authStorage?.close(); - tempDir?.removeSync(); resetSettingsForTest(); }); diff --git a/packages/coding-agent/test/issue-5780-repro.test.ts b/packages/coding-agent/test/issue-5780-repro.test.ts index eae8af6d3..0a6bb95e6 100644 --- a/packages/coding-agent/test/issue-5780-repro.test.ts +++ b/packages/coding-agent/test/issue-5780-repro.test.ts @@ -25,7 +25,7 @@ describe("issue #5780 post-auth runtime provider refresh", () => { fs.mkdirSync(tempDir, { recursive: true }); modelsJsonPath = path.join(tempDir, "models.json"); dbPath = path.join(tempDir, "models.db"); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: offlineFetch }); }); diff --git a/packages/coding-agent/test/issue-905-repro.test.ts b/packages/coding-agent/test/issue-905-repro.test.ts index 8d7d1aa94..63aea3398 100644 --- a/packages/coding-agent/test/issue-905-repro.test.ts +++ b/packages/coding-agent/test/issue-905-repro.test.ts @@ -112,7 +112,6 @@ beforeAll(async () => { }); afterAll(async () => { - await Bun.sleep(0); await tmp.remove(); }); diff --git a/packages/coding-agent/test/job-tool-agent-roster.test.ts b/packages/coding-agent/test/job-tool-agent-roster.test.ts index 06f22536f..b9c12cdd2 100644 --- a/packages/coding-agent/test/job-tool-agent-roster.test.ts +++ b/packages/coding-agent/test/job-tool-agent-roster.test.ts @@ -50,7 +50,14 @@ function resultText(result: { content: Array<{ type: string; text?: string }> }) return result.content.find(part => part.type === "text")?.text ?? ""; } -const neverResolves = () => new Promise(() => {}); +const runsUntilAborted = ({ signal }: { signal: AbortSignal }) => + new Promise(resolve => { + if (signal.aborted) { + resolve(""); + return; + } + signal.addEventListener("abort", () => resolve(""), { once: true }); + }); afterEach(async () => { for (const manager of managers.splice(0)) { @@ -90,10 +97,18 @@ describe("hub jobs snapshot", () => { const manager = createManager(); const registry = new AgentRegistry(); // Task-style spawn: job id == agent id. - manager.register("task", "AgentA", neverResolves, { id: "AgentA", agentId: "AgentA", ownerId: "Main" }); + manager.register("task", "AgentA", runsUntilAborted, { + id: "AgentA", + agentId: "AgentA", + ownerId: "Main", + }); registerRunningSub(registry, "AgentA"); // Vibe-style turn job: job id differs from the agent id; linkage via agentId. - manager.register("task", "vibe turn", neverResolves, { id: "vibe-1-t1", agentId: "vibe-1", ownerId: "Main" }); + manager.register("task", "vibe turn", runsUntilAborted, { + id: "vibe-1-t1", + agentId: "vibe-1", + ownerId: "Main", + }); registerRunningSub(registry, "vibe-1"); // Woken via irc: running agent with no job at all. registerRunningSub(registry, "Loner"); diff --git a/packages/coding-agent/test/launch/broker-idle-shutdown.test.ts b/packages/coding-agent/test/launch/broker-idle-shutdown.test.ts index 9302ce260..e1ad7f6c8 100644 --- a/packages/coding-agent/test/launch/broker-idle-shutdown.test.ts +++ b/packages/coding-agent/test/launch/broker-idle-shutdown.test.ts @@ -40,17 +40,17 @@ describe("daemon broker idle shutdown", () => { const previousTitle = process.title; // Create the client (writes broker.token) before starting the broker, which reads that token. - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - const broker = startBroker(projectDir, runtimeDir, 200); + const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 100 }); + const broker = startBroker(projectDir, runtimeDir, 100); try { - // A persistent daemon that outlives the first idle-shutdown timer (200ms) and then - // self-exits (~700ms). restart:"no" so its exit is terminal. + // A persistent daemon that outlives the first idle-shutdown timer (100ms) and then + // self-exits (~300ms). restart:"no" so its exit is terminal. const started = await client.request({ op: "start", spec: { name: "persistent-temp", application: process.execPath, - args: ["-e", "setTimeout(() => {}, 700)"], + args: ["-e", "setTimeout(() => {}, 300)"], env: {}, cwd: projectDir, pty: false, diff --git a/packages/coding-agent/test/launch/broker-list-order.test.ts b/packages/coding-agent/test/launch/broker-list-order.test.ts index 7963b033a..0f00295ad 100644 --- a/packages/coding-agent/test/launch/broker-list-order.test.ts +++ b/packages/coding-agent/test/launch/broker-list-order.test.ts @@ -2,11 +2,37 @@ import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { startDaemonBrokerFromEnvironment } from "../../src/launch/broker"; import { createDaemonBrokerClient, type DaemonBrokerClient } from "../../src/launch/client"; -import type { DaemonSnapshot, DaemonSpec } from "../../src/launch/protocol"; +import { + DAEMON_IDLE_GRACE_ENV, + DAEMON_PROJECT_DIR_ENV, + DAEMON_RUNTIME_DIR_ENV, + type DaemonSnapshot, + type DaemonSpec, +} from "../../src/launch/protocol"; const TERMINAL_HISTORY_LIMIT = 10; +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +function startBroker(projectDir: string, runtimeDir: string): Promise { + const previousProjectDir = process.env[DAEMON_PROJECT_DIR_ENV]; + const previousRuntimeDir = process.env[DAEMON_RUNTIME_DIR_ENV]; + const previousGrace = process.env[DAEMON_IDLE_GRACE_ENV]; + process.env[DAEMON_PROJECT_DIR_ENV] = projectDir; + process.env[DAEMON_RUNTIME_DIR_ENV] = runtimeDir; + process.env[DAEMON_IDLE_GRACE_ENV] = "5000"; + const broker = startDaemonBrokerFromEnvironment(); + restoreEnv(DAEMON_PROJECT_DIR_ENV, previousProjectDir); + restoreEnv(DAEMON_RUNTIME_DIR_ENV, previousRuntimeDir); + restoreEnv(DAEMON_IDLE_GRACE_ENV, previousGrace); + return broker; +} + function spec(name: string, cwd: string): DaemonSpec { return { name, @@ -43,10 +69,11 @@ async function seedTerminalRecord(runtimeDir: string, cwd: string, snapshot: Dae await Bun.write(metaPath, JSON.stringify({ daemon: snapshot, spec: spec(snapshot.name, cwd) })); } -async function shutdown(client: DaemonBrokerClient, activeName: string): Promise { +async function shutdown(client: DaemonBrokerClient, broker: Promise, activeName: string): Promise { await client.request({ op: "stop", name: activeName, timeoutMs: 2_000 }).catch(() => undefined); await client.request({ op: "shutdown" }).catch(() => undefined); client.close(); + await broker; } describe("broker list", () => { @@ -56,11 +83,15 @@ describe("broker list", () => { const runtimeDir = path.join(tempDir.path(), "runtime"); await fs.mkdir(projectDir); - for (let index = 0; index < TERMINAL_HISTORY_LIMIT + 5; index++) { - await seedTerminalRecord(runtimeDir, projectDir, terminalSnapshot(index)); - } + await Promise.all( + Array.from({ length: TERMINAL_HISTORY_LIMIT + 5 }, (_, index) => + seedTerminalRecord(runtimeDir, projectDir, terminalSnapshot(index)), + ), + ); const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); + const previousTitle = process.title; + const broker = startBroker(projectDir, runtimeDir); const activeName = "active-server"; try { const started = await client.request({ @@ -83,7 +114,8 @@ describe("broker list", () => { expect(listed.daemons[0]?.state).toBe("running"); expect(listed.daemons.at(-1)?.exitedAt).toBe(51); } finally { - await shutdown(client, activeName); + await shutdown(client, broker, activeName); + process.title = previousTitle; } }, 20_000); }); diff --git a/packages/coding-agent/test/launch/broker-restarting-settle.test.ts b/packages/coding-agent/test/launch/broker-restarting-settle.test.ts index 3805994ec..ab275ee64 100644 --- a/packages/coding-agent/test/launch/broker-restarting-settle.test.ts +++ b/packages/coding-agent/test/launch/broker-restarting-settle.test.ts @@ -1,14 +1,14 @@ // Integration test — real timers are required (ts-no-test-timers exception): this spawns the // actual cross-process daemon broker driving real child processes, and the bug is a leaked real // `setTimeout` in #settle that resurrects a stopped daemon. Fake timers cannot control the OS -// process-exit promise or the unix-socket RPC the broker relies on, and proving the *absence* of a -// resurrection means waiting past the real backoff window (no signal exists to await). +// process-exit promise or the unix-socket RPC the broker relies on. The embedded broker uses a +// shorter real backoff here; proving the absence of resurrection still requires crossing it. import { describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { Process } from "@oh-my-pi/pi-natives"; import { TempDir } from "@oh-my-pi/pi-utils"; -import { startDaemonBrokerFromEnvironment } from "../../src/launch/broker"; +import { type DaemonBrokerStartOptions, startDaemonBrokerFromEnvironment } from "../../src/launch/broker"; import { createDaemonBrokerClient, type DaemonBrokerClient } from "../../src/launch/client"; import { DAEMON_IDLE_GRACE_ENV, @@ -17,19 +17,23 @@ import { type DaemonSnapshot, } from "../../src/launch/protocol"; +const RESTART_BACKOFF_BASE_MS = 250; +const INITIAL_RESTART_DELAY_MS = RESTART_BACKOFF_BASE_MS * 2; +const RESTART_SETTLE_MARGIN_MS = 150; + function restoreEnv(name: string, value: string | undefined): void { if (value === undefined) delete process.env[name]; else process.env[name] = value; } -function startBroker(projectDir: string, runtimeDir: string): Promise { +function startBroker(projectDir: string, runtimeDir: string, options: DaemonBrokerStartOptions = {}): Promise { const previousProjectDir = process.env[DAEMON_PROJECT_DIR_ENV]; const previousRuntimeDir = process.env[DAEMON_RUNTIME_DIR_ENV]; const previousGrace = process.env[DAEMON_IDLE_GRACE_ENV]; process.env[DAEMON_PROJECT_DIR_ENV] = projectDir; process.env[DAEMON_RUNTIME_DIR_ENV] = runtimeDir; process.env[DAEMON_IDLE_GRACE_ENV] = "5000"; - const broker = startDaemonBrokerFromEnvironment(); + const broker = startDaemonBrokerFromEnvironment(options); restoreEnv(DAEMON_PROJECT_DIR_ENV, previousProjectDir); restoreEnv(DAEMON_RUNTIME_DIR_ENV, previousRuntimeDir); restoreEnv(DAEMON_IDLE_GRACE_ENV, previousGrace); @@ -69,7 +73,9 @@ describe("daemon broker restart settling", () => { const previousTitle = process.title; // Create the client (writes broker.token) before starting the broker, which reads that token. const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const broker = startBroker(projectDir, runtimeDir); + const broker = startBroker(projectDir, runtimeDir, { + restartBackoffBaseMs: RESTART_BACKOFF_BASE_MS, + }); const name = "crash-loop"; try { const started = await client.request({ @@ -106,8 +112,8 @@ describe("daemon broker restart settling", () => { if (stopped.op !== "stop") throw new Error(`unexpected result: ${stopped.op}`); expect(stopped.daemon.state).toBe("exited"); - // Wait past the initial backoff (2s) where a leaked timer would have fired #launch. - await Bun.sleep(2_600); + // Cross the configured initial backoff where a leaked timer would fire #launch. + await Bun.sleep(INITIAL_RESTART_DELAY_MS + RESTART_SETTLE_MARGIN_MS); const afterStop = await snapshotOf(client, name); expect(afterStop.state).toBe("exited"); expect(afterStop.pid).toBeUndefined(); diff --git a/packages/coding-agent/test/lsp-mux.test.ts b/packages/coding-agent/test/lsp-mux.test.ts index 90d9c1cc3..99e0241c6 100644 --- a/packages/coding-agent/test/lsp-mux.test.ts +++ b/packages/coding-agent/test/lsp-mux.test.ts @@ -148,12 +148,14 @@ class MuxTestClient { } async function withTimeout(promise: Promise, description: string, timeoutMs = 5_000): Promise { - return Promise.race([ - promise, - Bun.sleep(timeoutMs).then(() => { - throw new Error(`Timed out waiting for ${description}`); - }), - ]); + // Real socket/subprocess integration needs a wall-clock failure watchdog; always cancel it when the event wins. + const timeout = Promise.withResolvers(); + const timer = setTimeout(() => timeout.reject(new Error(`Timed out waiting for ${description}`)), timeoutMs); + try { + return await Promise.race([promise, timeout.promise]); + } finally { + clearTimeout(timer); + } } const fixturePath = path.join(import.meta.dir, "fixtures", "fake-lsp-server.ts"); diff --git a/packages/coding-agent/test/mcp/transports/stdio.test.ts b/packages/coding-agent/test/mcp/transports/stdio.test.ts index b77901c63..67a44633f 100644 --- a/packages/coding-agent/test/mcp/transports/stdio.test.ts +++ b/packages/coding-agent/test/mcp/transports/stdio.test.ts @@ -109,7 +109,7 @@ describe("StdioTransport.connect", () => { // forever. `sleep` is POSIX-only, so the check is scoped to non-Windows hosts. describe.skipIf(process.platform === "win32")("StdioTransport request write stall", () => { it("rejects with the timeout error when the child never drains stdin", async () => { - const timeoutMs = 400; + const timeoutMs = 100; const orphaned: Error[] = []; const captureOrphan = (reason: unknown) => { if (reason instanceof Error) orphaned.push(reason); @@ -181,6 +181,7 @@ function processExists(pid: number): boolean { // end-to-end through `connect()` on a non-Linux dev/CI host, but a real // detached process group can still be spawned directly on any POSIX host. describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => { + const TEST_TERM_GRACE_MS = 50; it("escalates a detached child that traps SIGTERM to SIGKILL", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-kill-solo-")); const scriptPath = path.join(tempDir, "child.mjs"); @@ -194,7 +195,7 @@ describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => { "setInterval(() => {}, 60_000);", ].join("\n"), ); - const proc = Bun.spawn(["bun", "run", scriptPath], { + const proc = Bun.spawn([process.execPath, scriptPath], { stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -214,14 +215,14 @@ describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => { } const started = performance.now(); - await terminateStdioProcess(proc, true); + await terminateStdioProcess(proc, true, process.platform, TEST_TERM_GRACE_MS); await proc.exited; const elapsedMs = performance.now() - started; expect(proc.signalCode).toBe("SIGKILL"); - // Escalation only fires after the ~1s SIGTERM grace window elapses — - // a too-fast exit would mean SIGKILL fired without waiting. - expect(elapsedMs).toBeGreaterThanOrEqual(900); + // The injected test grace preserves the production transition without + // making this subprocess boundary test sleep for the full 1s window. + expect(elapsedMs).toBeGreaterThanOrEqual(TEST_TERM_GRACE_MS - 15); } finally { try { process.kill(-proc.pid, "SIGKILL"); @@ -254,12 +255,12 @@ describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => { parentScriptPath, [ "process.on('SIGTERM', () => {});", - `Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`, + `Bun.spawn([process.execPath, ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`, "setInterval(() => {}, 60_000);", ].join("\n"), ); - const proc = Bun.spawn(["bun", "run", parentScriptPath], { + const proc = Bun.spawn([process.execPath, parentScriptPath], { stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -281,7 +282,7 @@ describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => { if (grandchildPid === undefined) throw new Error("grandchild never reported its pid"); expect(processExists(grandchildPid)).toBe(true); - await terminateStdioProcess(proc, true); + await terminateStdioProcess(proc, true, process.platform, TEST_TERM_GRACE_MS); await proc.exited; expect(proc.signalCode).toBe("SIGKILL"); @@ -329,12 +330,12 @@ describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => { await fs.writeFile( parentScriptPath, [ - `Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`, + `Bun.spawn([process.execPath, ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`, "setInterval(() => {}, 60_000);", ].join("\n"), ); - const proc = Bun.spawn(["bun", "run", parentScriptPath], { + const proc = Bun.spawn([process.execPath, parentScriptPath], { stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -354,7 +355,7 @@ describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => { expect(processExists(grandchildPid)).toBe(true); const started = performance.now(); - await terminateStdioProcess(proc, true); + await terminateStdioProcess(proc, true, process.platform, TEST_TERM_GRACE_MS); await proc.exited; const elapsedMs = performance.now() - started; diff --git a/packages/coding-agent/test/memory-session-storage.test.ts b/packages/coding-agent/test/memory-session-storage.test.ts index 93d19f543..e296c5653 100644 --- a/packages/coding-agent/test/memory-session-storage.test.ts +++ b/packages/coding-agent/test/memory-session-storage.test.ts @@ -6,18 +6,16 @@ describe("MemorySessionStorage indexed mirror", () => { test("append builds the same content as a single writeTextSync of the join", async () => { const storage = new MemorySessionStorage(); const path = "/virtual/session.jsonl"; + const parts = Array.from({ length: 32 }, (_, i) => `{"i":${i}}\n`); const writer = storage.openWriter(path, { flags: "w" }); try { - const N = 1000; - for (let i = 0; i < N; i++) { - await writer.append(`{"i":${i}}\n`); - } + for (const part of parts) await writer.append(part); } finally { await writer.close(); } // Construct the baseline from the same parts. - const expected = Array.from({ length: 1000 }, (_, i) => `{"i":${i}}\n`).join(""); + const expected = parts.join(""); const actual = await storage.readText(path); expect(actual).toBe(expected); expect(actual.length).toBe(expected.length); diff --git a/packages/coding-agent/test/memory-tools.test.ts b/packages/coding-agent/test/memory-tools.test.ts index e3c68910c..3be87c71a 100644 --- a/packages/coding-agent/test/memory-tools.test.ts +++ b/packages/coding-agent/test/memory-tools.test.ts @@ -748,9 +748,14 @@ describe("Mnemopi backend lifecycle", () => { flushCalls++; await flushStall.promise; }); - const closeSpy = vi.spyOn(retainMemory, "close"); + const closeDone = Promise.withResolvers(); + const close = retainMemory.close.bind(retainMemory); + const closeSpy = vi.spyOn(retainMemory, "close").mockImplementation(() => { + close(); + closeDone.resolve(); + }); - const BUDGET_MS = 100; + const BUDGET_MS = 20; const start = Bun.nanoseconds(); await state.dispose({ timeoutMs: BUDGET_MS }); const elapsedMs = (Bun.nanoseconds() - start) / 1_000_000; @@ -767,7 +772,7 @@ describe("Mnemopi backend lifecycle", () => { // Release the stall and confirm the deferred close runs once consolidate // settles — i.e. the SQLite handle still ends up released eventually. flushStall.resolve(); - await Bun.sleep(50); + await closeDone.promise; expect(closeSpy).toHaveBeenCalledTimes(1); registeredMnemopiState = undefined; diff --git a/packages/coding-agent/test/model-registry-command-values.test.ts b/packages/coding-agent/test/model-registry-command-values.test.ts index 27e3d2704..75457026e 100644 --- a/packages/coding-agent/test/model-registry-command-values.test.ts +++ b/packages/coding-agent/test/model-registry-command-values.test.ts @@ -9,15 +9,29 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + function stdoutCommand(value: string): string { + if (process.platform !== "win32") return `printf %s ${shellQuote(value)}`; return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`process.stdout.write(${JSON.stringify(value)})`)}`; } function trackedTokenCommand(tokenFile: string, counterFile: string): string { + if (process.platform !== "win32") { + return `IFS= read -r token < ${shellQuote(tokenFile)}; printf 1 >> ${shellQuote(counterFile)}; [ "$token" = FAIL ] && exit 1; printf %s "$token"`; + } const script = `const fs=require("node:fs");fs.appendFileSync(${JSON.stringify(counterFile)}, "1");const token=fs.readFileSync(${JSON.stringify(tokenFile)}, "utf8").trim();if(token==="FAIL")process.exit(1);process.stdout.write(token);`; return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; } +function failedTrackingCommand(counterFile: string): string { + if (process.platform !== "win32") return `printf 1 >> ${shellQuote(counterFile)}; exit 1`; + const script = `const fs=require("node:fs");fs.appendFileSync(${JSON.stringify(counterFile)}, "1");process.exit(1);`; + return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; +} + describe("ModelRegistry command-resolved models.yml values", () => { let tempDir = ""; let authStorage: AuthStorage; @@ -179,10 +193,10 @@ describe("ModelRegistry command-resolved models.yml values", () => { test("resolveCommandConfig caches failed executions so they do not retry", async () => { const counterFile = path.join(tempDir, "counter.txt"); - fs.writeFileSync(counterFile, "0"); + fs.writeFileSync(counterFile, ""); // Command increments a counter and then fails (exit 1). - const trackingCommand = `node -e "const fs=require('fs'); fs.writeFileSync('${counterFile.replace(/\\/g, "/")}', String(Number(fs.readFileSync('${counterFile.replace(/\\/g, "/")}', 'utf8')) + 1)); process.exit(1);"`; + const trackingCommand = failedTrackingCommand(counterFile); fs.writeFileSync( modelsPath, diff --git a/packages/coding-agent/test/model-registry-create.test.ts b/packages/coding-agent/test/model-registry-create.test.ts index 0253a3efc..ce098de87 100644 --- a/packages/coding-agent/test/model-registry-create.test.ts +++ b/packages/coding-agent/test/model-registry-create.test.ts @@ -21,7 +21,7 @@ describe("ModelRegistry.create() factory (F6)", () => { }); test("produces an instance whose authStorage matches and that exposes bundled models", async () => { - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); try { const registry = new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")); expect(registry.authStorage).toBe(authStorage); @@ -44,7 +44,7 @@ describe("ModelRegistry.create() factory (F6)", () => { await Bun.write(json, JSON.stringify({ models: [] })); expect(fs.existsSync(yml)).toBe(false); - const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); try { new ModelRegistry(authStorage, yml); expect(fs.existsSync(yml)).toBe(true); diff --git a/packages/coding-agent/test/model-registry-default-config.test.ts b/packages/coding-agent/test/model-registry-default-config.test.ts index bb06df38f..e1d95a1ab 100644 --- a/packages/coding-agent/test/model-registry-default-config.test.ts +++ b/packages/coding-agent/test/model-registry-default-config.test.ts @@ -1,18 +1,28 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs"; import * as path from "node:path"; -import { TempDir } from "@oh-my-pi/pi-utils"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { getAgentDir, setAgentDir, TempDir } from "@oh-my-pi/pi-utils"; -const packageRoot = path.resolve(import.meta.dir, ".."); +const originalAgentDir = getAgentDir(); +const originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR; let tempDir: TempDir; +let authStorage: AuthStorage; describe("ModelRegistry default custom models config", () => { - beforeEach(() => { + beforeEach(async () => { tempDir = TempDir.createSync("@model-registry-default-config-"); + setAgentDir(tempDir.path()); + authStorage = await AuthStorage.create(":memory:"); }); afterEach(async () => { + authStorage.close(); + setAgentDir(originalAgentDir); + if (originalAgentDirEnv === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv; await tempDir.remove().catch(() => {}); }); @@ -24,7 +34,7 @@ describe("ModelRegistry default custom models config", () => { baseUrl: "https://yaml-default.example.com/v1", }); - const model = loadDefaultRegistryModel({ + const [model] = loadDefaultRegistryModels({ provider: "yaml-default-only", modelId: "yaml-model", }); @@ -42,7 +52,7 @@ describe("ModelRegistry default custom models config", () => { imageInputDecoder: "stb", }); - const model = loadDefaultRegistryModel({ provider: "managed-primary", modelId: "local-vision" }); + const [model] = loadDefaultRegistryModels({ provider: "managed-primary", modelId: "local-vision" }); expect(model?.imageInputDecoder).toBe("stb"); }); @@ -50,7 +60,7 @@ describe("ModelRegistry default custom models config", () => { test("loads Bedrock cache capabilities from a model override", () => { writeBedrockCacheOverride(); - const model = loadDefaultRegistryModel({ + const [model] = loadDefaultRegistryModels({ provider: "amazon-bedrock", modelId: "us.anthropic.claude-opus-4-8", }); @@ -80,14 +90,10 @@ describe("ModelRegistry default custom models config", () => { baseUrl: "https://yaml-loser.example.com/v1", }); - const ymlModel = loadDefaultRegistryModel({ - provider: "yaml-precedence", - modelId: "from-yml", - }); - const yamlModel = loadDefaultRegistryModel({ - provider: "yaml-precedence", - modelId: "from-yaml", - }); + const [ymlModel, yamlModel] = loadDefaultRegistryModels( + { provider: "yaml-precedence", modelId: "from-yml" }, + { provider: "yaml-precedence", modelId: "from-yaml" }, + ); expect(ymlModel?.baseUrl).toBe("https://yml-winner.example.com/v1"); expect(yamlModel).toBeUndefined(); @@ -107,14 +113,10 @@ describe("ModelRegistry default custom models config", () => { baseUrl: "https://json-loser.example.com/v1", }); - const yamlModel = loadDefaultRegistryModel({ - provider: "yaml-json-precedence", - modelId: "from-yaml", - }); - const jsonModel = loadDefaultRegistryModel({ - provider: "yaml-json-precedence", - modelId: "from-json", - }); + const [yamlModel, jsonModel] = loadDefaultRegistryModels( + { provider: "yaml-json-precedence", modelId: "from-yaml" }, + { provider: "yaml-json-precedence", modelId: "from-json" }, + ); expect(yamlModel?.baseUrl).toBe("https://yaml-over-json.example.com/v1"); expect(jsonModel).toBeUndefined(); @@ -223,40 +225,18 @@ function writeModelsJson(fixture: ProviderFixture): void { ); } -function loadDefaultRegistryModel(lookup: ModelLookup): ModelSnapshot | undefined { - const script = ` - import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; - import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; - - const authStorage = await AuthStorage.create(":memory:"); - try { - const registry = new ModelRegistry(authStorage); - const model = registry.find(${JSON.stringify(lookup.provider)}, ${JSON.stringify(lookup.modelId)}); - process.stdout.write(JSON.stringify(model ? { - provider: model.provider, - id: model.id, - name: model.name, - baseUrl: model.baseUrl, - imageInputDecoder: model.imageInputDecoder, - compat: model.compat, - } : null)); - } finally { - authStorage.close(); - } - `; - const result = Bun.spawnSync([process.execPath, "-e", script], { - cwd: packageRoot, - env: { - ...process.env, - PI_CODING_AGENT_DIR: tempDir.path(), - }, - stdout: "pipe", - stderr: "pipe", +function loadDefaultRegistryModels(...lookups: ModelLookup[]): Array { + const registry = new ModelRegistry(authStorage); + return lookups.map(lookup => { + const model = registry.find(lookup.provider, lookup.modelId); + if (!model) return undefined; + return { + provider: model.provider, + id: model.id, + name: model.name, + baseUrl: model.baseUrl, + imageInputDecoder: model.imageInputDecoder, + compat: model.compat as ModelSnapshot["compat"], + }; }); - const stdout = new TextDecoder().decode(result.stdout).trim(); - const stderr = new TextDecoder().decode(result.stderr).trim(); - if (result.exitCode !== 0) { - throw new Error(`default ModelRegistry lookup failed: ${stderr || stdout || `exit ${result.exitCode}`}`); - } - return JSON.parse(stdout) ?? undefined; } diff --git a/packages/coding-agent/test/model-registry-runtime-cleanup.test.ts b/packages/coding-agent/test/model-registry-runtime-cleanup.test.ts index e7b06a069..608902f3b 100644 --- a/packages/coding-agent/test/model-registry-runtime-cleanup.test.ts +++ b/packages/coding-agent/test/model-registry-runtime-cleanup.test.ts @@ -1,16 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; import { type AssistantMessageEventStream, clearCustomApis, getCustomApi } from "@oh-my-pi/pi-ai"; import { getOAuthProvider } from "@oh-my-pi/pi-ai/oauth"; import { ModelRegistry, type ProviderConfigInput } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; -import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; describe("ModelRegistry runtime source cleanup", () => { - let tempDir: string; - let modelsJsonPath: string; let authStorage: AuthStorage; const sourceId = "ext://runtime-cleanup"; @@ -28,22 +22,16 @@ describe("ModelRegistry runtime source cleanup", () => { ({}) as unknown as AssistantMessageEventStream; beforeEach(async () => { - tempDir = path.join(os.tmpdir(), `pi-test-model-registry-runtime-cleanup-${Snowflake.next()}`); - fs.mkdirSync(tempDir, { recursive: true }); - modelsJsonPath = path.join(tempDir, "models.json"); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); }); afterEach(() => { clearCustomApis(); authStorage.close(); - if (tempDir && fs.existsSync(tempDir)) { - removeSyncWithRetries(tempDir); - } }); test("clearSourceRegistrations removes runtime overlays and fallback auth for that source", () => { - const registry = new ModelRegistry(authStorage, modelsJsonPath); + const registry = new ModelRegistry(authStorage, undefined, { ignoreLocalModelConfig: true }); const config: ProviderConfigInput = { baseUrl: "https://runtime.example.com/v1", apiKey: "RUNTIME_KEY", @@ -66,7 +54,7 @@ describe("ModelRegistry runtime source cleanup", () => { }); test("unregisterProvider removes only the named provider and its login entry", () => { - const registry = new ModelRegistry(authStorage, modelsJsonPath); + const registry = new ModelRegistry(authStorage, undefined, { ignoreLocalModelConfig: true }); registry.registerProvider( "runtime-provider", { diff --git a/packages/coding-agent/test/model-registry-runtime-provider.test.ts b/packages/coding-agent/test/model-registry-runtime-provider.test.ts index 6711d7284..ae501382e 100644 --- a/packages/coding-agent/test/model-registry-runtime-provider.test.ts +++ b/packages/coding-agent/test/model-registry-runtime-provider.test.ts @@ -35,7 +35,7 @@ describe("ModelRegistry runtime provider registration", () => { tempDir = path.join(os.tmpdir(), `pi-test-model-registry-runtime-${Snowflake.next()}`); fs.mkdirSync(tempDir, { recursive: true }); modelsJsonPath = path.join(tempDir, "models.json"); - authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: offlineFetch }); }); diff --git a/packages/coding-agent/test/non-interactive-env.test.ts b/packages/coding-agent/test/non-interactive-env.test.ts index 2635d7880..7fcea87c4 100644 --- a/packages/coding-agent/test/non-interactive-env.test.ts +++ b/packages/coding-agent/test/non-interactive-env.test.ts @@ -86,13 +86,14 @@ describe("buildNonInteractiveEnv", () => { }); }); -it("filters expanded dotenv values while preserving matching launcher values", async () => { +it("filters expanded dotenv values while preserving matching and empty launcher values", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "omp-env-")); try { await Bun.write( path.join(tmp, ".env"), [ "BASE=loaded-by-omp", + "EMPTY_PARENT_VAR=project-secret", "TEST_ENV_FROM_DOTENV=$BASE-suffix", "NODE_ENV=development", "export EXPORTED_SECRET=exported", @@ -113,6 +114,7 @@ it("filters expanded dotenv values while preserving matching launcher values", a " deployment: env.CONVEX_DEPLOYMENT ?? null,", " url: env.CONVEX_URL ?? null,", " inherited: env.OMP_TEST_INHERITED_MARKER ?? null,", + " empty: env.EMPTY_PARENT_VAR ?? null,", " matching: env.NODE_ENV ?? null,", " exported: env.EXPORTED_SECRET ?? null,", " commented: env.COMMENTED_SECRET ?? null,", @@ -124,6 +126,7 @@ it("filters expanded dotenv values while preserving matching launcher values", a cwd: tmp, env: { HOME: process.env.HOME ?? "", + EMPTY_PARENT_VAR: "", OMP_TEST_INHERITED_MARKER: "keep-me", NODE_ENV: "development", PATH: process.env.PATH ?? "", @@ -145,6 +148,7 @@ it("filters expanded dotenv values while preserving matching launcher values", a deployment: string | null; url: string | null; inherited: string | null; + empty: string | null; matching: string | null; exported: string | null; commented: string | null; @@ -155,6 +159,7 @@ it("filters expanded dotenv values while preserving matching launcher values", a url: null, inherited: "keep-me", matching: "development", + empty: "", exported: null, commented: null, }); @@ -163,40 +168,3 @@ it("filters expanded dotenv values while preserving matching launcher values", a await fs.rm(tmp, { recursive: true, force: true }); } }); - -it("keeps an empty launcher value instead of the project dotenv value", async () => { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "omp-env-empty-")); - try { - await Bun.write(path.join(tmp, ".env"), "EMPTY_PARENT_VAR=project-secret\n"); - const procmgrPath = path.resolve(import.meta.dir, "../../utils/src/procmgr.ts"); - const script = [ - `import { getShellConfig } from ${JSON.stringify(procmgrPath)};`, - "console.log(JSON.stringify({ value: getShellConfig().env.EMPTY_PARENT_VAR ?? null }));", - ].join("\n"); - const bunArgSets = process.platform === "linux" ? [[], ["--no-env-file"]] : [["--no-env-file"]]; - for (const bunArgs of bunArgSets) { - const proc = Bun.spawn([process.execPath, ...bunArgs, "--no-install", "--eval", script], { - cwd: tmp, - env: { - HOME: process.env.HOME ?? "", - EMPTY_PARENT_VAR: "", - PATH: process.env.PATH ?? "", - SHELL: process.env.SHELL ?? "/bin/bash", - }, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - - expect(stderr).toBe(""); - expect(exitCode).toBe(0); - expect(JSON.parse(stdout)).toEqual({ value: "" }); - } - } finally { - await fs.rm(tmp, { recursive: true, force: true }); - } -}); diff --git a/packages/coding-agent/test/oauth-flow.test.ts b/packages/coding-agent/test/oauth-flow.test.ts index 3ed62624e..8115ac291 100644 --- a/packages/coding-agent/test/oauth-flow.test.ts +++ b/packages/coding-agent/test/oauth-flow.test.ts @@ -608,6 +608,7 @@ describe("mcp oauth flow", () => { const progress: string[] = []; let authCalls = 0; let advertisedUrl = ""; + const callbackReady = new AbortController(); try { const flow = new MCPOAuthFlow( { @@ -622,10 +623,12 @@ describe("mcp oauth flow", () => { onAuth: ({ url }) => { authCalls += 1; advertisedUrl = url; + callbackReady.abort("callback URL captured"); }, onProgress: msg => progress.push(msg), - // Abort once the flow is waiting for the browser callback we never deliver. - signal: AbortSignal.timeout(500), + // Stop immediately once the fallback URL has been observed; no browser + // callback is needed for this port-selection/DCR contract. + signal: callbackReady.signal, }, ); diff --git a/packages/coding-agent/test/output-sink-fd-lifecycle.test.ts b/packages/coding-agent/test/output-sink-fd-lifecycle.test.ts index e7dbef517..eb7ea2b58 100644 --- a/packages/coding-agent/test/output-sink-fd-lifecycle.test.ts +++ b/packages/coding-agent/test/output-sink-fd-lifecycle.test.ts @@ -34,10 +34,9 @@ describe("OutputSink fd lifecycle", () => { const skill = path.join(dir, "SKILL.md"); await Bun.write(skill, "# skill\n"); - // Far more iterations than the 64-descriptor limit the repro runs under. - // A leaked spill fd would exhaust the table and make the skill read below - // throw EMFILE — exactly the reported failure. - for (let i = 0; i < 256; i++) { + // Cross the 64-descriptor limit used by the leak repro. More iterations do + // not strengthen that boundary and only multiply serial file I/O. + for (let i = 0; i < 72; i++) { const artifactPath = path.join(dir, `spill-${i}.txt`); const sink = new OutputSink({ artifactPath, artifactId: `art-${i}`, spillThreshold: 16 }); spill(sink); diff --git a/packages/coding-agent/test/plan-mode-thinking-level.test.ts b/packages/coding-agent/test/plan-mode-thinking-level.test.ts index 548593336..bc87e7c3b 100644 --- a/packages/coding-agent/test/plan-mode-thinking-level.test.ts +++ b/packages/coding-agent/test/plan-mode-thinking-level.test.ts @@ -6,58 +6,50 @@ * calls resolveModelRoleValue() but only returns .model, dropping the thinking level. * #applyPlanModeModel() therefore has no thinking level to apply. */ -import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; -import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { Agent, ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; -import { TempDir } from "@oh-my-pi/pi-utils"; describe("plan mode thinking level", () => { - let tempDir: TempDir; let session: AgentSession; let modelRegistry: ModelRegistry; let authStorage: AuthStorage; + let sessionSettings: Settings; beforeAll(async () => { - tempDir = TempDir.createSync("@pi-plan-thinking-"); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); + authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); - modelRegistry = new ModelRegistry(authStorage); - }); - - afterEach(async () => { - if (session) { - await session.dispose(); - } - }); - - afterAll(() => { - authStorage.close(); - tempDir.removeSync(); - }); - - function createSessionWithRoles(modelRoles: Record): AgentSession { + modelRegistry = new ModelRegistry(authStorage, undefined, { ignoreLocalModelConfig: true }); + sessionSettings = Settings.isolated(); const sonnet = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!sonnet) throw new Error("Expected claude-sonnet-4-5 to exist in registry"); - session = new AgentSession({ agent: new Agent({ initialState: { model: sonnet, systemPrompt: ["Test"], tools: [], messages: [] }, }), sessionManager: SessionManager.inMemory(), - settings: Settings.isolated({ modelRoles }), + settings: sessionSettings, modelRegistry, }); + }); + + afterAll(async () => { + await session.dispose(); + authStorage.close(); + }); + + function configureRoles(modelRoles: Record): AgentSession { + sessionSettings.override("modelRoles", modelRoles); return session; } describe("resolveRoleModelWithThinking", () => { it("returns thinking level when plan role includes a thinking suffix", () => { - createSessionWithRoles({ plan: "anthropic/claude-sonnet-4-5:xhigh" }); + configureRoles({ plan: "anthropic/claude-sonnet-4-5:xhigh" }); const result = session.resolveRoleModelWithThinking("plan"); @@ -69,7 +61,7 @@ describe("plan mode thinking level", () => { }); it("returns no explicit thinking level when plan role has no thinking suffix", () => { - createSessionWithRoles({ plan: "anthropic/claude-sonnet-4-5" }); + configureRoles({ plan: "anthropic/claude-sonnet-4-5" }); const result = session.resolveRoleModelWithThinking("plan"); @@ -79,7 +71,7 @@ describe("plan mode thinking level", () => { }); it("returns no model when no plan role is configured", () => { - createSessionWithRoles({}); + configureRoles({}); const result = session.resolveRoleModelWithThinking("plan"); @@ -87,7 +79,7 @@ describe("plan mode thinking level", () => { }); it("returns thinking level for different levels", () => { - createSessionWithRoles({ plan: "anthropic/claude-sonnet-4-5:high" }); + configureRoles({ plan: "anthropic/claude-sonnet-4-5:high" }); const result = session.resolveRoleModelWithThinking("plan"); expect(result.thinkingLevel).toBe(ThinkingLevel.High); @@ -95,7 +87,7 @@ describe("plan mode thinking level", () => { }); it("works with the default role", () => { - createSessionWithRoles({ default: "anthropic/claude-sonnet-4-5:medium" }); + configureRoles({ default: "anthropic/claude-sonnet-4-5:medium" }); const result = session.resolveRoleModelWithThinking("default"); expect(result.model!.id).toBe("claude-sonnet-4-5"); @@ -104,7 +96,7 @@ describe("plan mode thinking level", () => { }); it("resolveRoleModel still returns just the model (backward compat)", () => { - createSessionWithRoles({ plan: "anthropic/claude-sonnet-4-5:xhigh" }); + configureRoles({ plan: "anthropic/claude-sonnet-4-5:xhigh" }); const model = session.resolveRoleModel("plan"); expect(model).toBeDefined(); diff --git a/packages/coding-agent/test/plugin-install-git.test.ts b/packages/coding-agent/test/plugin-install-git.test.ts index e3e6a0f47..877ff2e53 100644 --- a/packages/coding-agent/test/plugin-install-git.test.ts +++ b/packages/coding-agent/test/plugin-install-git.test.ts @@ -396,9 +396,8 @@ describe("PluginManager.install with git sources", () => { test("drains stdout/stderr concurrently with proc.exited (pipe-buffer deadlock, #4230)", async () => { // Model the OS-pipe semantics that caused the deadlock: `exited` cannot - // resolve until both pipes have been read. If PluginManager.install - // awaits `exited` before starting to drain either stream, this test - // hangs — which we catch with Promise.race + a short timeout. + // resolve until both pipes have been read. Awaiting install directly is + // sufficient: the test runner's timeout catches a regression. await Bun.write( pluginsPkgJson, JSON.stringify({ name: "omp-plugins", private: true, dependencies: {} }, null, 2), @@ -445,12 +444,9 @@ describe("PluginManager.install with git sources", () => { }) as typeof Bun.spawn); const mgr = new PluginManager(tmpRoot); - const installed = await Promise.race([ - mgr.install("github:foo/bar"), - new Promise((_, reject) => setTimeout(() => reject(new Error("install deadlocked")), 2000)), - ]); + const installed = await mgr.install("github:foo/bar"); expect(installed.name).toBe("real-name"); - }); + }, 2000); test("refreshes Bun's cached git clone before updating an existing plugin (#5401)", async () => { const sourceDir = path.join(tmpRoot, "source"); @@ -460,14 +456,24 @@ describe("PluginManager.install with git sources", () => { await fs.mkdir(sourceDir, { recursive: true }); await fs.mkdir(path.dirname(remoteDir), { recursive: true }); await runCommand(["git", "init", "-b", "main"], sourceDir); - await runCommand(["git", "config", "user.name", "Plugin test"], sourceDir); - await runCommand(["git", "config", "user.email", "plugin-test@example.com"], sourceDir); await Bun.write( path.join(sourceDir, "package.json"), JSON.stringify({ name: "@test/pi-package", version: "1.0.0" }, null, 2), ); await runCommand(["git", "add", "package.json"], sourceDir); - await runCommand(["git", "commit", "-m", "version A"], sourceDir); + await runCommand( + [ + "git", + "-c", + "user.name=Plugin test", + "-c", + "user.email=plugin-test@example.com", + "commit", + "-m", + "version A", + ], + sourceDir, + ); await runCommand(["git", "clone", "--bare", sourceDir, remoteDir], tmpRoot); await runCommand(["git", "update-server-info"], remoteDir); @@ -506,7 +512,19 @@ describe("PluginManager.install with git sources", () => { JSON.stringify({ name: "@test/pi-package", version: "2.0.0" }, null, 2), ); await runCommand(["git", "add", "package.json"], sourceDir); - await runCommand(["git", "commit", "-m", "version B"], sourceDir); + await runCommand( + [ + "git", + "-c", + "user.name=Plugin test", + "-c", + "user.email=plugin-test@example.com", + "commit", + "-m", + "version B", + ], + sourceDir, + ); await runCommand(["git", "push", remoteDir, "main"], sourceDir); await runCommand(["git", "update-server-info"], remoteDir); diff --git a/packages/coding-agent/test/plugin-install-local.test.ts b/packages/coding-agent/test/plugin-install-local.test.ts index a2d365ee5..3b8841c9a 100644 --- a/packages/coding-agent/test/plugin-install-local.test.ts +++ b/packages/coding-agent/test/plugin-install-local.test.ts @@ -78,21 +78,19 @@ describe("runPluginCommand({ action: 'install', args: [] })", () => { await removeWithRetries(tmpRoot); }); - for (const spec of [".", "./pkg", "../pkg", "/abs/pkg", "~/pkg"]) { - test(`dispatches ${JSON.stringify(spec)} to link() instead of install()`, async () => { - const linkSpy = spyOn(PluginManager.prototype, "link").mockResolvedValue(FAKE_INSTALLED); - const installSpy = spyOn(PluginManager.prototype, "install").mockResolvedValue(FAKE_INSTALLED); - try { - await runPluginCommand({ action: "install", args: [spec], flags: { json: true } }); - expect(linkSpy).toHaveBeenCalledTimes(1); - expect(linkSpy.mock.calls[0]?.[0]).toBe(spec); - expect(installSpy).not.toHaveBeenCalled(); - } finally { - linkSpy.mockRestore(); - installSpy.mockRestore(); - } - }); - } + test("dispatches a local path to link() instead of install()", async () => { + const linkSpy = spyOn(PluginManager.prototype, "link").mockResolvedValue(FAKE_INSTALLED); + const installSpy = spyOn(PluginManager.prototype, "install").mockResolvedValue(FAKE_INSTALLED); + try { + await runPluginCommand({ action: "install", args: ["."], flags: { json: true } }); + expect(linkSpy).toHaveBeenCalledTimes(1); + expect(linkSpy.mock.calls[0]?.[0]).toBe("."); + expect(installSpy).not.toHaveBeenCalled(); + } finally { + linkSpy.mockRestore(); + installSpy.mockRestore(); + } + }); test("npm-style spec still dispatches to install(), not link()", async () => { // Guard against an overly-eager local detector: a bare package name with diff --git a/packages/coding-agent/test/prewalk-startup-degradation.test.ts b/packages/coding-agent/test/prewalk-startup-degradation.test.ts index 81259a750..1ca370eda 100644 --- a/packages/coding-agent/test/prewalk-startup-degradation.test.ts +++ b/packages/coding-agent/test/prewalk-startup-degradation.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -17,32 +17,29 @@ import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; // out of the app. describe("prewalk startup degradation", () => { let tempDir: string; - const authStoragesToClose: AuthStorage[] = []; + let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; - beforeEach(() => { + beforeAll(async () => { tempDir = path.join(os.tmpdir(), `pi-prewalk-repro-${Snowflake.next()}`); fs.mkdirSync(tempDir, { recursive: true }); + authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + }); + + afterAll(() => { + authStorage.close(); + if (tempDir && fs.existsSync(tempDir)) removeSyncWithRetries(tempDir); }); afterEach(() => { vi.restoreAllMocks(); - for (const authStorage of authStoragesToClose) authStorage.close(); - authStoragesToClose.length = 0; - if (tempDir && fs.existsSync(tempDir)) removeSyncWithRetries(tempDir); }); - async function newRegistry(name: string): Promise<{ authStorage: AuthStorage; modelRegistry: ModelRegistry }> { - const authStorage = await AuthStorage.create(path.join(tempDir, `${name}.db`)); - authStoragesToClose.push(authStorage); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, `${name}.yml`)); - return { authStorage, modelRegistry }; - } - test("leaves prewalk unarmed instead of crashing when the target has no configured auth", async () => { const settings = Settings.isolated(); settings.set("prewalk.enabled", true); settings.setModelRole("smol", "cerebras/zai-glm-4.7"); - const { modelRegistry } = await newRegistry("no-auth"); // Force the no-auth condition: hasAuth() also consults $HOME/.env via // getEnvApiKey (packages/utils/src/env.ts), so a CEREBRAS_API_KEY in the // runner's home .env would otherwise legitimately arm prewalk and make @@ -60,7 +57,6 @@ describe("prewalk startup degradation", () => { const settings = Settings.isolated(); settings.set("prewalk.enabled", true); settings.setModelRole("smol", `${model.provider}/${model.id}`); - const { authStorage, modelRegistry } = await newRegistry("with-auth"); authStorage.setRuntimeApiKey(model.provider, "test-key"); const options = await buildSessionOptions(parseArgs([]), [], SessionManager.inMemory(), modelRegistry, settings); @@ -75,7 +71,6 @@ describe("prewalk startup degradation", () => { const settings = Settings.isolated(); settings.set("prewalk.enabled", true); settings.setModelRole("smol", `${model.provider}/${model.id}`); - const { authStorage, modelRegistry } = await newRegistry("restoring"); authStorage.setRuntimeApiKey(model.provider, "test-key"); for (const args of [parseArgs(["--continue"]), parseArgs(["--resume=session.jsonl"])]) { @@ -89,7 +84,6 @@ describe("prewalk startup degradation", () => { if (!model) throw new Error("expected claude-sonnet-4-5 to be bundled"); const settings = Settings.isolated(); settings.setModelRole("smol", `${model.provider}/${model.id}`); - const { authStorage, modelRegistry } = await newRegistry("explicit-restore"); authStorage.setRuntimeApiKey(model.provider, "test-key"); const options = await buildSessionOptions( diff --git a/packages/coding-agent/test/print-mode-plan-startup-hang.test.ts b/packages/coding-agent/test/print-mode-plan-startup-hang.test.ts index 2a314c3fc..b2c951f56 100644 --- a/packages/coding-agent/test/print-mode-plan-startup-hang.test.ts +++ b/packages/coding-agent/test/print-mode-plan-startup-hang.test.ts @@ -65,7 +65,6 @@ describe("print mode + plan.defaultOnStartup (#8272)", () => { content: [ { type: "toolCall", name: "write", arguments: { path: "xd://propose", content: "the-plan" } }, ], - delayMs: 5, }; } return { content: ["OK"] }; diff --git a/packages/coding-agent/test/read-cli-mcp-resource.test.ts b/packages/coding-agent/test/read-cli-mcp-resource.test.ts index 80bbed7cf..71b6b6157 100644 --- a/packages/coding-agent/test/read-cli-mcp-resource.test.ts +++ b/packages/coding-agent/test/read-cli-mcp-resource.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import * as url from "node:url"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; const CLI_ENTRY = path.join(import.meta.dir, "..", "src", "cli.ts"); @@ -11,6 +12,7 @@ describe("omp read MCP resources", () => { let root: string; let projectDir: string; let agentDir: string; + let probePath: string; beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-read-mcp-")); @@ -29,14 +31,25 @@ describe("omp read MCP resources", () => { }, }), ); + probePath = path.join(root, "probe.ts"); + await Bun.write( + probePath, + [ + `import { runCli } from ${JSON.stringify(url.pathToFileURL(CLI_ENTRY).href)};`, + 'await runCli(["read", "test://alpha"]);', + 'await runCli(["read", "urn:fixture:gamma"]);', + 'await runCli(["read", "mcp://test://beta"]);', + 'await runCli(["read", "test://missing"]);', + ].join("\n"), + ); }); afterEach(async () => { await removeWithRetries(root); }); - async function runRead(resourceUri: string): Promise<{ exitCode: number; output: string; error: string }> { - const proc = Bun.spawn([process.execPath, CLI_ENTRY, "read", resourceUri], { + async function runReadProbe(): Promise<{ exitCode: number; output: string; error: string }> { + const proc = Bun.spawn([process.execPath, probePath], { cwd: projectDir, stdout: "pipe", stderr: "pipe", @@ -53,35 +66,13 @@ describe("omp read MCP resources", () => { return { exitCode, output, error }; } - it("discovers MCP before reading a server-advertised native URI", async () => { - const { exitCode, output, error } = await runRead("test://alpha"); - - expect(exitCode).toBe(0); - expect(error).toBe(""); - expect(output).toContain("fixture content for test://alpha"); - }, 30_000); - - it("discovers MCP before reading a server-advertised opaque URI", async () => { - const { exitCode, output, error } = await runRead("urn:fixture:gamma"); - - expect(exitCode).toBe(0); - expect(error).toBe(""); - expect(output).toContain("fixture content for urn:fixture:gamma"); - }, 30_000); - - it("keeps the mcp:// wrapper working in the standalone CLI", async () => { - const { exitCode, output, error } = await runRead("mcp://test://beta"); - - expect(exitCode).toBe(0); - expect(error).toBe(""); - expect(output).toContain("fixture content for test://beta"); - }, 30_000); - - it("exits after an MCP resource read error", async () => { - const { exitCode, output, error } = await runRead("test://missing"); + it("reads native, opaque, and wrapped MCP resources and reports missing resources through the CLI", async () => { + const { exitCode, output, error } = await runReadProbe(); expect(exitCode).toBe(1); - expect(output).toBe(""); + expect(output).toContain("fixture content for test://alpha"); + expect(output).toContain("fixture content for urn:fixture:gamma"); + expect(output).toContain("fixture content for test://beta"); expect(error).toContain('No MCP server has resource "test://missing"'); }, 30_000); }); diff --git a/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts b/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts index a483ddbaa..add725a28 100644 --- a/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts +++ b/packages/coding-agent/test/repro-issue-1022-disabled-default-model.test.ts @@ -57,7 +57,7 @@ describe("issue #1022 — path-scoped enabledModels respected by default fallbac expect(settings.get("enabledModels")).toEqual(["openai-codex"]); expect(settings.get("disabledProviders")).toEqual(["github-copilot"]); - const authStorage = await AuthStorage.create(path.join(testDir, "auth.db")); + const authStorage = await AuthStorage.create(":memory:"); // Only anthropic has credentials. Per `enabledModels` the path allows // only openai-codex, so no anthropic model should be selected. authStorage.setRuntimeApiKey("anthropic", "test-anthropic-key"); diff --git a/packages/coding-agent/test/repro-issue-2600-shutdown-timeout.test.ts b/packages/coding-agent/test/repro-issue-2600-shutdown-timeout.test.ts index 027b7d98b..e604ec76a 100644 --- a/packages/coding-agent/test/repro-issue-2600-shutdown-timeout.test.ts +++ b/packages/coding-agent/test/repro-issue-2600-shutdown-timeout.test.ts @@ -99,16 +99,24 @@ describe("issue #2600 - session_shutdown handler timeout", () => { it("runs multiple session_shutdown handlers within one cap", async () => { const { runner, hangExtensionPaths, cleanup } = await buildRunnerWithHangingShutdown(4); const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + vi.useFakeTimers(); try { testSetSessionShutdownHandlerTimeoutMs(100); + let settled = false; + const emitted = runner.emit({ type: "session_shutdown" }).then(() => { + settled = true; + }); - const startedAt = performance.now(); - await runner.emit({ type: "session_shutdown" }); - const elapsedMs = performance.now() - startedAt; + await Promise.resolve(); + vi.advanceTimersByTime(99); + await Promise.resolve(); + expect(settled).toBe(false); - // Multiple hung shutdown handlers must share the cap. Sequential - // dispatch would consume roughly count × cap and keep `/exit` slow. - expect(elapsedMs).toBeLessThan(350); + vi.advanceTimersByTime(1); + await Promise.resolve(); + vi.advanceTimersByTime(0); + await emitted; + expect(settled).toBe(true); for (const hangExtensionPath of hangExtensionPaths) { expect(warnSpy).toHaveBeenCalledWith("Extension handler timed out", { extensionPath: hangExtensionPath, @@ -117,6 +125,7 @@ describe("issue #2600 - session_shutdown handler timeout", () => { }); } } finally { + vi.useRealTimers(); warnSpy.mockRestore(); cleanup(); } @@ -127,58 +136,38 @@ describe("issue #2600 - session_shutdown handler timeout", () => { expect(SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS).toBeLessThan(EXTENSION_HANDLER_TIMEOUT_MS); }); - it("returns within the short cap when a session_shutdown handler hangs forever", async () => { - const { runner, hangExtensionPath, cleanup } = await buildRunnerWithHangingShutdown(); - try { - const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - - // Generic budget is left at the production default (30s). The - // shutdown cap is shortened to 100ms so this test stays under a - // second while still asserting the dispatch path uses the dedicated - // cap. - testSetSessionShutdownHandlerTimeoutMs(100); - - const startedAt = performance.now(); - await runner.emit({ type: "session_shutdown" }); - const elapsedMs = performance.now() - startedAt; - - // Loose upper bound to absorb CI scheduler jitter; the regression - // would expire at ~30_000ms. - expect(elapsedMs).toBeLessThan(1_000); - expect(warnSpy).toHaveBeenCalledWith("Extension handler timed out", { - extensionPath: hangExtensionPath, - event: "session_shutdown", - timeoutMs: 100, - }); - warnSpy.mockRestore(); - } finally { - cleanup(); - } - }); - it("session_shutdown cap is independent from the generic handler cap", async () => { const { runner, hangExtensionPath, cleanup } = await buildRunnerWithHangingShutdown(); + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + vi.useFakeTimers(); try { - const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); - - // Raise the *generic* timeout to a value the test would never - // tolerate (10s) while leaving the shutdown cap at 50ms. If the - // dispatcher pulls from the wrong knob the test wall-clock balloons. + // If dispatch reads the generic knob, advancing the shutdown budget + // cannot settle this handler. testSetExtensionHandlerTimeoutMs(10_000); testSetSessionShutdownHandlerTimeoutMs(50); + let settled = false; + const emitted = runner.emit({ type: "session_shutdown" }).then(() => { + settled = true; + }); - const startedAt = performance.now(); - await runner.emit({ type: "session_shutdown" }); - const elapsedMs = performance.now() - startedAt; + await Promise.resolve(); + vi.advanceTimersByTime(49); + await Promise.resolve(); + expect(settled).toBe(false); - expect(elapsedMs).toBeLessThan(500); + vi.advanceTimersByTime(1); + await Promise.resolve(); + vi.advanceTimersByTime(0); + await emitted; + expect(settled).toBe(true); expect(warnSpy).toHaveBeenCalledWith("Extension handler timed out", { extensionPath: hangExtensionPath, event: "session_shutdown", timeoutMs: 50, }); - warnSpy.mockRestore(); } finally { + vi.useRealTimers(); + warnSpy.mockRestore(); cleanup(); } }); diff --git a/packages/coding-agent/test/repro-issue-6516-tool-double-render.test.ts b/packages/coding-agent/test/repro-issue-6516-tool-double-render.test.ts index 4b2a12a2c..04bf3bae2 100644 --- a/packages/coding-agent/test/repro-issue-6516-tool-double-render.test.ts +++ b/packages/coding-agent/test/repro-issue-6516-tool-double-render.test.ts @@ -1,5 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; @@ -51,16 +50,22 @@ function countCommand(mode: InteractiveMode): number { describe("issue #6516 — tool output appears twice", () => { let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; let mode: InteractiveMode; let session: AgentSession; let tempDir: TempDir; + let settingsDir: TempDir; const created: ToolExecutionComponent[] = []; - - beforeAll(() => { + beforeAll(async () => { initTheme(); + resetSettingsForTest(); + settingsDir = TempDir.createSync("@pi-issue-6516-settings-"); + await Settings.init({ inMemory: true, cwd: settingsDir.path() }); + authStorage = await AuthStorage.create(":memory:"); + modelRegistry = new ModelRegistry(authStorage); }); - beforeEach(async () => { + beforeEach(() => { vi.spyOn(process.stdout, "write").mockReturnValue(true); vi.spyOn(process.stdin, "resume").mockReturnValue(process.stdin); vi.spyOn(process.stdin, "pause").mockReturnValue(process.stdin); @@ -69,11 +74,7 @@ describe("issue #6516 — tool output appears twice", () => { vi.spyOn(process.stdin, "setRawMode").mockReturnValue(process.stdin); } - resetSettingsForTest(); tempDir = TempDir.createSync("@pi-issue-6516-"); - await Settings.init({ inMemory: true, cwd: tempDir.path() }); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - const modelRegistry = new ModelRegistry(authStorage); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 test model"); @@ -92,8 +93,12 @@ describe("issue #6516 — tool output appears twice", () => { mode?.stop(); vi.restoreAllMocks(); await session?.dispose(); - authStorage?.close(); tempDir?.removeSync(); + }); + + afterAll(() => { + authStorage.close(); + settingsDir.removeSync(); resetSettingsForTest(); }); diff --git a/packages/coding-agent/test/repro-issue-6879-tool-double-render-retry.test.ts b/packages/coding-agent/test/repro-issue-6879-tool-double-render-retry.test.ts index 72b0d8ba0..e0764f0f8 100644 --- a/packages/coding-agent/test/repro-issue-6879-tool-double-render-retry.test.ts +++ b/packages/coding-agent/test/repro-issue-6879-tool-double-render-retry.test.ts @@ -1,5 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; -import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import { Agent } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, ToolCall } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -66,15 +65,22 @@ function countCommand(mode: InteractiveMode): number { describe("issue #6879 — tool output appears twice after a superseded turn", () => { let authStorage: AuthStorage; + let modelRegistry: ModelRegistry; let mode: InteractiveMode; let session: AgentSession; let tempDir: TempDir; + let settingsDir: TempDir; - beforeAll(() => { + beforeAll(async () => { initTheme(); + resetSettingsForTest(); + settingsDir = TempDir.createSync("@pi-issue-6879-settings-"); + await Settings.init({ inMemory: true, cwd: settingsDir.path() }); + authStorage = await AuthStorage.create(":memory:"); + modelRegistry = new ModelRegistry(authStorage); }); - beforeEach(async () => { + beforeEach(() => { vi.spyOn(process.stdout, "write").mockReturnValue(true); vi.spyOn(process.stdin, "resume").mockReturnValue(process.stdin); vi.spyOn(process.stdin, "pause").mockReturnValue(process.stdin); @@ -83,11 +89,7 @@ describe("issue #6879 — tool output appears twice after a superseded turn", () vi.spyOn(process.stdin, "setRawMode").mockReturnValue(process.stdin); } - resetSettingsForTest(); tempDir = TempDir.createSync("@pi-issue-6879-"); - await Settings.init({ inMemory: true, cwd: tempDir.path() }); - authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db")); - const modelRegistry = new ModelRegistry(authStorage); const model = modelRegistry.find("anthropic", "claude-sonnet-4-5"); if (!model) throw new Error("Expected claude-sonnet-4-5 test model"); @@ -109,8 +111,12 @@ describe("issue #6879 — tool output appears twice after a superseded turn", () mode?.stop(); vi.restoreAllMocks(); await session?.dispose(); - authStorage?.close(); tempDir?.removeSync(); + }); + + afterAll(() => { + authStorage.close(); + settingsDir.removeSync(); resetSettingsForTest(); }); diff --git a/packages/coding-agent/test/rpc-client.restart.test.ts b/packages/coding-agent/test/rpc-client.restart.test.ts index 195c54389..e6f56b9d1 100644 --- a/packages/coding-agent/test/rpc-client.restart.test.ts +++ b/packages/coding-agent/test/rpc-client.restart.test.ts @@ -23,17 +23,17 @@ describe("RpcClient lifecycle (issue #4079 B)", () => { await client.start(); const state = (await client.getState()) as unknown as { payload: string }; - expect(state.payload).toBe("😀".repeat(400_000)); + expect(state.payload).toBe("😀".repeat(270_000)); expect((await client.getMessages()) as unknown).toEqual([ { role: "user", content: "first", timestamp: 1 }, { role: "assistant", content: [{ type: "text", text: "second" }], timestamp: 2 }, ]); }, 20_000); - test("normalizes state fields omitted by a legacy RPC server", async () => { + test("normalizes omitted state fields and a runtime-invalid tokensPerSecond", async () => { using client = new RpcClient({ cliPath: MOCK_AGENT, - env: { MOCK_RPC_LEGACY_STATE: "1" }, + env: { MOCK_RPC_LEGACY_STATE: "1", MOCK_RPC_INVALID_TPS: "1" }, }); await client.start(); @@ -43,17 +43,6 @@ describe("RpcClient lifecycle (issue #4079 B)", () => { expect(state.tokensPerSecond).toBeNull(); }, 20_000); - test("normalizes a runtime-invalid tokensPerSecond from the RPC server", async () => { - using client = new RpcClient({ - cliPath: MOCK_AGENT, - env: { MOCK_RPC_INVALID_TPS: "1" }, - }); - - await client.start(); - const state = await client.getState(); - expect(state.tokensPerSecond).toBeNull(); - }, 20_000); - test("preserves getMessages snapshot behavior while a v2 page walk is unavailable", async () => { using client = new RpcClient({ cliPath: MOCK_AGENT, @@ -112,6 +101,7 @@ describe("RpcClient lifecycle (issue #4079 B)", () => { MOCK_RPC_PID_FILE: pidFile, MOCK_RPC_IGNORE_SIGTERM: process.platform === "win32" ? "0" : "1", }, + terminationGraceMs: 10, }); await client.start(); @@ -128,22 +118,25 @@ describe("RpcClient lifecycle (issue #4079 B)", () => { }, 20_000); test("start() may be retried after a failed start (child is cleaned up on failure)", async () => { + const env: Record = { + MOCK_RPC_EXIT_BEFORE_READY: "17", + MOCK_RPC_EXIT_STDERR: "fixture startup failed", + }; using client = new RpcClient({ - cliPath: path.join(import.meta.dir, "..", "src", "cli.ts"), - cwd: path.join(import.meta.dir, ".."), - provider: "__missing_provider__", - model: "claude-sonnet-4-5", - env: { PI_NO_TITLE: "1" }, + cliPath: MOCK_AGENT, + env, + terminationGraceMs: 10, }); - await expect(client.start()).rejects.toThrow(/Unknown provider.*__missing_provider__/); + await expect(client.start()).rejects.toThrow("fixture startup failed"); // Before the fix, #process stayed set after the failed spawn so the - // second start() rejected with "Client already started". Post-fix, - // state is cleared and the second attempt fails with the same - // legitimate startup error. - await expect(client.start()).rejects.toThrow(/Unknown provider.*__missing_provider__/); - }, 30000); + // second start() rejected with "Client already started". A successful + // retry proves both the child and the client lifecycle state were reset. + delete env.MOCK_RPC_EXIT_BEFORE_READY; + await client.start(); + await client.stop(); + }, 10_000); test("stop() rejects active requests instead of leaving them to time out", async () => { using client = new RpcClient({ @@ -170,6 +163,7 @@ describe("RpcClient lifecycle (issue #4079 B)", () => { MOCK_RPC_INVALID_OUTPUT: "1", MOCK_RPC_IGNORE_SIGTERM: process.platform === "win32" ? "0" : "1", }, + terminationGraceMs: 10, }); let pid = 0; diff --git a/packages/coding-agent/test/rpc-frame.test.ts b/packages/coding-agent/test/rpc-frame.test.ts index 8babaeccf..a3c6ca4f3 100644 --- a/packages/coding-agent/test/rpc-frame.test.ts +++ b/packages/coding-agent/test/rpc-frame.test.ts @@ -12,8 +12,8 @@ function decode(frame: string): Record { } function oversizedMessageHistory(prefix: string) { - const payload = "x".repeat(1024); - return Array.from({ length: 1024 }, (_, index) => ({ + const payload = "x".repeat(64 * 1024); + return Array.from({ length: 20 }, (_, index) => ({ role: "assistant", content: [{ type: "text", text: `${prefix}-${index}-${payload}` }], })); @@ -43,15 +43,15 @@ describe("RPC frame encoding", () => { }); it("compacts agent_end after message events have streamed", () => { - const messages = Array.from({ length: 10_000 }, (_, index) => ({ + const messages = Array.from({ length: 32 }, (_, index) => ({ role: "assistant", - content: [{ type: "text", text: `message-${index}-${"x".repeat(128)}` }], + content: [{ type: "text", text: `message-${index}-${"x".repeat(40 * 1024)}` }], })); const encoded = encodeRpcFrame({ type: "agent_end", messages, telemetry: { stepCount: 42 } }, messages.length); const decoded = decode(encoded); expect(Buffer.byteLength(encoded, "utf8")).toBeLessThanOrEqual(MAX_RPC_FRAME_BYTES); - expect(decoded).toEqual({ type: "agent_end", messages: [], messageCount: 10_000, telemetry: { stepCount: 42 } }); + expect(decoded).toEqual({ type: "agent_end", messages: [], messageCount: 32, telemetry: { stepCount: 42 } }); }); it("retains a terminal error emitted only by agent_end after earlier message events", () => { @@ -146,7 +146,7 @@ describe("RPC frame encoding", () => { it("bounds a single multi-byte message without losing its event discriminator", () => { const encoded = encodeRpcFrame({ type: "message_end", - message: { role: "assistant", content: [{ type: "text", text: "😀".repeat(600_000) }] }, + message: { role: "assistant", content: [{ type: "text", text: "😀".repeat(300_000) }] }, }); const decoded = decode(encoded); @@ -157,7 +157,7 @@ describe("RPC frame encoding", () => { it("bounds objects with many small fields", () => { const details = Object.fromEntries( - Array.from({ length: 20_000 }, (_, index) => [`field-${index}`, `value-${index}-${"x".repeat(64)}`]), + Array.from({ length: 12_000 }, (_, index) => [`field-${index}`, `value-${index}-${"x".repeat(64)}`]), ); const encoded = encodeRpcFrame({ type: "tool_execution_end", toolCallId: "tool-1", details }); const decoded = decode(encoded); @@ -189,7 +189,7 @@ describe("RPC frame encoding", () => { it("keeps overflow response metadata within the hard byte ceiling", () => { const encoded = encodeRpcFrame({ - id: "😀".repeat(MAX_RPC_FRAME_BYTES), + id: "😀".repeat(Math.ceil(MAX_RPC_FRAME_BYTES / 4)), type: "response", command: "get_state", success: true, @@ -208,7 +208,7 @@ describe("RPC frame encoding", () => { type: "response", command: "get_messages", success: true, - data: { messages: [{ role: "assistant", content: "😀".repeat(400_000) }] }, + data: { messages: [{ role: "assistant", content: "😀".repeat(300_000) }] }, }; const encoder = new RpcFrameEncoder(); encoder.setProtocolVersion(2); @@ -251,7 +251,7 @@ describe("RPC frame encoding", () => { encoder.setProtocolVersion(2); const encoded = encoder.encode({ type: "agent_end", - messages: [{ role: "assistant", content: "x".repeat(MAX_RPC_REASSEMBLED_BYTES) }], + messages: [{ role: "assistant", content: "😀".repeat(Math.ceil(MAX_RPC_REASSEMBLED_BYTES / 4)) }], }); expect(decode(encoded)).toEqual({ @@ -269,7 +269,7 @@ describe("RPC frame encoding", () => { type: "response", command: "get_messages", success: true, - data: { transcript: "x".repeat(MAX_RPC_REASSEMBLED_BYTES) }, + data: { transcript: "😀".repeat(Math.ceil(MAX_RPC_REASSEMBLED_BYTES / 4)) }, }); expect(decode(encoded)).toEqual({ diff --git a/packages/coding-agent/test/rpc-host-tools.test.ts b/packages/coding-agent/test/rpc-host-tools.test.ts index fe34cd404..4af7b7ee7 100644 --- a/packages/coding-agent/test/rpc-host-tools.test.ts +++ b/packages/coding-agent/test/rpc-host-tools.test.ts @@ -230,7 +230,7 @@ function handle(frame) { content: [{ type: "text", text: "working:hello" }], }); } finally { - client.stop(); + await client.stop(); } }); }); diff --git a/packages/coding-agent/test/rpc-input-frame.test.ts b/packages/coding-agent/test/rpc-input-frame.test.ts index f7876f906..8c5081300 100644 --- a/packages/coding-agent/test/rpc-input-frame.test.ts +++ b/packages/coding-agent/test/rpc-input-frame.test.ts @@ -137,7 +137,6 @@ describe("dispatchRpcInputFrame", () => { const finished: string[] = []; const handleCommand = async (command: RpcCommand): Promise => { started.push(command.type); - await Bun.sleep(5); finished.push(command.type); if (command.type === "abort_retry") { return { id: command.id, type: "response", command: "abort_retry", success: true }; diff --git a/packages/coding-agent/test/rpc-malformed-input.test.ts b/packages/coding-agent/test/rpc-malformed-input.test.ts index c6ef029bd..116ddfc10 100644 --- a/packages/coding-agent/test/rpc-malformed-input.test.ts +++ b/packages/coding-agent/test/rpc-malformed-input.test.ts @@ -1,61 +1,32 @@ import { describe, expect, test } from "bun:test"; -import * as path from "node:path"; -import { isRecord, readJsonl } from "@oh-my-pi/pi-utils"; +import { readRpcInputFrames } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-input"; /** * Regression test for issue #5194: a non-JSON stdin line crashed the whole RPC - * process with an uncaught `SyntaxError: Failed to parse JSONL` escaping the - * frame loop. A malformed line must instead be reported as an error frame and - * the process must keep reading subsequent frames. + * process with an uncaught parse error escaping the frame loop. A malformed + * line must instead be reported and the reader must keep yielding later frames. */ describe("RPC mode malformed stdin", () => { - test("reports a bad line as an error frame and keeps serving subsequent commands", async () => { - const cliPath = path.join(import.meta.dir, "..", "src", "cli.ts"); - const child = Bun.spawn( - ["bun", cliPath, "--mode", "rpc", "--provider", "anthropic", "--model", "claude-sonnet-4-5"], - { - cwd: path.join(import.meta.dir, ".."), - env: { ...Bun.env, PI_NO_TITLE: "1" }, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - }, + test("reports a bad line and keeps reading subsequent commands", async () => { + const input = new Blob([ + "this is not json\n", + `${JSON.stringify({ type: "get_state", id: "probe" })}\n`, + `${JSON.stringify({ type: "get_messages_page", id: "page-probe", limit: 1 })}\n`, + ]).stream(); + const frames: unknown[] = []; + const parseErrors: string[] = []; + + await readRpcInputFrames( + input, + frame => frames.push(frame), + message => parseErrors.push(message), ); - // A non-JSON line followed by a valid command. Pre-fix the first line - // crashed the generator before the second was ever read. - child.stdin.write("this is not json\n"); - child.stdin.write(`${JSON.stringify({ type: "get_state", id: "probe" })}\n`); - child.stdin.write(`${JSON.stringify({ type: "get_messages_page", id: "page-probe", limit: 1 })}\n`); - await child.stdin.flush(); - - let parseError: Record | undefined; - let stateResponse: Record | undefined; - let pageResponse: Record | undefined; - - for await (const frame of readJsonl(child.stdout as ReadableStream)) { - if (!isRecord(frame)) continue; - if (frame.type === "response" && frame.command === "parse" && frame.success === false) { - parseError = frame; - } - if (frame.type === "response" && frame.id === "probe") { - stateResponse = frame; - } - if (frame.type === "response" && frame.id === "page-probe") pageResponse = frame; - if (stateResponse && pageResponse) break; - } - - child.stdin.end(); - child.kill(); - await child.exited.catch(() => {}); - - expect(parseError).toBeDefined(); - expect(String(parseError?.error)).toContain("Failed to parse command"); - expect(stateResponse).toBeDefined(); - expect(stateResponse?.success).toBe(true); - expect(pageResponse).toMatchObject({ - success: true, - data: { messages: [], totalMessages: 0 }, - }); - }, 30000); + expect(parseErrors).toHaveLength(1); + expect(parseErrors[0]).toContain("Failed to parse command"); + expect(frames).toEqual([ + { type: "get_state", id: "probe" }, + { type: "get_messages_page", id: "page-probe", limit: 1 }, + ]); + }); }); diff --git a/packages/coding-agent/test/rpc-messages.test.ts b/packages/coding-agent/test/rpc-messages.test.ts index d78e0008f..d7c1e15bb 100644 --- a/packages/coding-agent/test/rpc-messages.test.ts +++ b/packages/coding-agent/test/rpc-messages.test.ts @@ -10,7 +10,7 @@ function message(index: number, bytes = 32 * 1024): AgentMessage { const snapshot: RpcMessageSnapshot = { sessionId: "session-1", leafId: "leaf-1", - messageCount: 60, + messageCount: 40, }; describe("RPC message pagination", () => { diff --git a/packages/coding-agent/test/rpc-stdin-lock.test.ts b/packages/coding-agent/test/rpc-stdin-lock.test.ts index a184e5d3e..45752e3b6 100644 --- a/packages/coding-agent/test/rpc-stdin-lock.test.ts +++ b/packages/coding-agent/test/rpc-stdin-lock.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import * as path from "node:path"; import { isRecord, readJsonl } from "@oh-my-pi/pi-utils"; -async function expectRpcModeOwnsStdin(mode: "rpc" | "rpc-ui"): Promise { +async function expectRpcOwnsStdin(): Promise { const cliPath = path.join(import.meta.dir, "..", "src", "cli.ts"); const extensionPath = path.join(import.meta.dir, "fixtures", "locked-stdin-reader.ts"); const child = Bun.spawn( @@ -12,7 +12,7 @@ async function expectRpcModeOwnsStdin(mode: "rpc" | "rpc-ui"): Promise { "--extension", extensionPath, "--mode", - mode, + "rpc", "--provider", "anthropic", "--model", @@ -55,11 +55,8 @@ async function expectRpcModeOwnsStdin(mode: "rpc" | "rpc-ui"): Promise { expect(stateResponse?.success).toBe(true); } +// rpc-ui shares this exact pre-discovery claim path (`rpc || rpc-ui`) in main; +// a second full CLI startup would exercise no distinct ownership behavior. describe("RPC mode stdin ownership", () => { - test("rpc claims stdin before extensions can lock its singleton stream", () => expectRpcModeOwnsStdin("rpc"), 30000); - test( - "rpc-ui claims stdin before extensions can lock its singleton stream", - () => expectRpcModeOwnsStdin("rpc-ui"), - 30000, - ); + test("claims stdin before extensions can lock its singleton stream", () => expectRpcOwnsStdin(), 30000); }); diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index 41e764d05..e77daba72 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -90,8 +90,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("RPC mode", () => { const messageEndEvents = events.filter(e => e.type === "message_end"); expect(messageEndEvents.length).toBeGreaterThanOrEqual(2); // user + assistant - // Wait for file writes - await Bun.sleep(200); + // SessionManager appends each JSONL entry synchronously before the RPC response completes. // Verify session file const sessionsPath = path.join(sessionDir, "sessions"); @@ -130,8 +129,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("RPC mode", () => { expect(result.summary).toBeDefined(); expect(result.tokensBefore).toBeGreaterThan(0); - // Wait for file writes - await Bun.sleep(200); + // Compaction persistence is synchronous with the completed RPC command. // Verify compaction in session file const sessionsPath = path.join(sessionDir, "sessions"); @@ -165,8 +163,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("RPC mode", () => { const uniqueValue = `test-${Snowflake.next()}`; await client.bash(`echo ${uniqueValue}`); - // Wait for file writes - await Bun.sleep(200); + // Bash context persistence is synchronous with the completed RPC command. // Verify bash message in session const sessionsPath = path.join(sessionDir, "sessions"); diff --git a/packages/coding-agent/test/sdk-autolearn-active-tools.test.ts b/packages/coding-agent/test/sdk-autolearn-active-tools.test.ts index 6dd520107..17a157f41 100644 --- a/packages/coding-agent/test/sdk-autolearn-active-tools.test.ts +++ b/packages/coding-agent/test/sdk-autolearn-active-tools.test.ts @@ -23,6 +23,18 @@ describe("createAgentSession auto-learn tool activation", () => { let authStorage: AuthStorage; let modelRegistry: ModelRegistry; const sessions: AgentSession[] = []; + function noDiscoveryOptions() { + return { + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + skipPythonPreflight: true, + }; + } beforeAll(async () => { registryDir = path.join(os.tmpdir(), `pi-autolearn-active-${Snowflake.next()}`); @@ -32,7 +44,7 @@ describe("createAgentSession auto-learn tool activation", () => { }); afterAll(async () => { - for (const session of sessions) await session.dispose().catch(() => {}); + await Promise.all(sessions.map(session => session.dispose().catch(() => {}))); authStorage.close(); if (fs.existsSync(registryDir)) removeSyncWithRetries(registryDir); }); @@ -45,7 +57,7 @@ describe("createAgentSession auto-learn tool activation", () => { sessionManager: SessionManager.inMemory(), settings, model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, + ...noDiscoveryOptions(), toolNames: ["read"], }); sessions.push(session); @@ -73,7 +85,7 @@ describe("createAgentSession auto-learn tool activation", () => { "hindsight.mentalModelsEnabled": false, }), model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, + ...noDiscoveryOptions(), toolNames: ["read"], }); sessions.push(session); @@ -87,24 +99,6 @@ describe("createAgentSession auto-learn tool activation", () => { expect(names).not.toContain("manage_skill"); }); - it("activates checkpoint and rewind when only checkpoint is in an explicit toolNames list", async () => { - const { session } = await createAgentSession({ - cwd: registryDir, - agentDir: registryDir, - modelRegistry, - sessionManager: SessionManager.inMemory(), - settings: Settings.isolated({ "checkpoint.enabled": true }), - model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, - toolNames: ["checkpoint"], - requireYieldTool: true, - }); - sessions.push(session); - const names = session.getActiveToolNames(); - expect(names).toContain("checkpoint"); - expect(names).toContain("rewind"); - }); - it("activates checkpoint and rewind when only rewind is in an explicit toolNames list", async () => { const { session } = await createAgentSession({ cwd: registryDir, @@ -113,7 +107,7 @@ describe("createAgentSession auto-learn tool activation", () => { sessionManager: SessionManager.inMemory(), settings: Settings.isolated({ "checkpoint.enabled": true }), model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, + ...noDiscoveryOptions(), toolNames: ["rewind"], requireYieldTool: true, }); @@ -131,7 +125,7 @@ describe("createAgentSession auto-learn tool activation", () => { sessionManager: SessionManager.inMemory(), settings: Settings.isolated({ "checkpoint.enabled": true }), model: getBundledModel("openai", "gpt-4o-mini"), - disableExtensionDiscovery: true, + ...noDiscoveryOptions(), toolNames: ["checkpoint"], requireYieldTool: true, restrictToolNames: true, diff --git a/packages/coding-agent/test/sdk-computer-tool-toggle.test.ts b/packages/coding-agent/test/sdk-computer-tool-toggle.test.ts index 9fcd1e24c..c2620c5f3 100644 --- a/packages/coding-agent/test/sdk-computer-tool-toggle.test.ts +++ b/packages/coding-agent/test/sdk-computer-tool-toggle.test.ts @@ -48,6 +48,13 @@ describe("AgentSession.setComputerToolEnabled", () => { settings, model: getBundledModel("openai", "gpt-4o-mini"), disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + skipPythonPreflight: true, }); sessions.push(session); diff --git a/packages/coding-agent/test/sdk-context-file-refresh.test.ts b/packages/coding-agent/test/sdk-context-file-refresh.test.ts index baf8fa9b0..b3bdf517e 100644 --- a/packages/coding-agent/test/sdk-context-file-refresh.test.ts +++ b/packages/coding-agent/test/sdk-context-file-refresh.test.ts @@ -27,7 +27,7 @@ async function createContextSession( settings.set("advisor.enabled", true); settings.setModelRole("advisor", `${model.provider}/${model.id}`); } - const modelRegistry = new ModelRegistry(authStorage); + const modelRegistry = new ModelRegistry(authStorage, `${cwd}/models.json`); const sessionManager = SessionManager.inMemory(cwd); const { session } = await createAgentSession({ cwd, @@ -41,6 +41,8 @@ async function createContextSession( slashCommands: [], enableMCP: false, enableLsp: false, + toolNames: [], + restrictToolNames: true, skipPythonPreflight: true, }); return { session, authStorage, sessionManager }; diff --git a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts index c9ee70ed0..e3715ad18 100644 --- a/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts +++ b/packages/coding-agent/test/sdk-credential-disabled-bridge.test.ts @@ -107,6 +107,11 @@ describe("createAgentSession credential_disabled subscription", () => { contextFiles: [], promptTemplates: [], workspaceTree: emptyWorkspaceTree(dirs.cwd), + // This suite exercises the SDK's credential event bridge, not ambient tools. + // Avoid rebuilding the full built-in/custom-tool surface for every session. + toolNames: ["read"], + preloadedCustomToolPaths: [], + skipPythonPreflight: true, slashCommands: [], enableMCP: false, enableLsp: false, diff --git a/packages/coding-agent/test/sdk-custom-tools-per-session-binding.test.ts b/packages/coding-agent/test/sdk-custom-tools-per-session-binding.test.ts index a743c11bf..f32f26557 100644 --- a/packages/coding-agent/test/sdk-custom-tools-per-session-binding.test.ts +++ b/packages/coding-agent/test/sdk-custom-tools-per-session-binding.test.ts @@ -16,11 +16,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { - type CustomToolAPI, - loadCustomTools, - type ToolPathWithSource, -} from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools"; +import { type CustomToolAPI, loadCustomTools } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; describe("loadCustomTools per-session binding (#2190 review fix)", () => { @@ -51,29 +47,7 @@ describe("loadCustomTools per-session binding (#2190 review fix)", () => { await removeWithRetries(tmp); }); - it("binds each load to the cwd passed to loadCustomTools", async () => { - const paths: ToolPathWithSource[] = [{ path: toolPath }]; - const parentResult = await loadCustomTools(paths, "/tmp/parent-cwd", []); - const subagentResult = await loadCustomTools(paths, "/tmp/subagent-cwd", []); - - expect(parentResult.errors).toEqual([]); - expect(subagentResult.errors).toEqual([]); - expect(parentResult.tools).toHaveLength(1); - expect(subagentResult.tools).toHaveLength(1); - - expect(parentResult.tools[0]).toBeDefined(); - expect(subagentResult.tools[0]).toBeDefined(); - const parentApi = (parentResult.tools[0]!.tool as unknown as { __boundApi: CustomToolAPI }).__boundApi; - const subagentApi = (subagentResult.tools[0]!.tool as unknown as { __boundApi: CustomToolAPI }).__boundApi; - - expect(parentApi.cwd).toBe("/tmp/parent-cwd"); - expect(subagentApi.cwd).toBe("/tmp/subagent-cwd"); - expect(subagentApi).not.toBe(parentApi); - // Different tool instances — a session must never see the other's tool. - expect(subagentResult.tools[0]?.tool).not.toBe(parentResult.tools[0]?.tool); - }); - - it("routes pushPendingAction to the loader's own callback, not a shared one", async () => { + it("binds each load to its own cwd and pending-action callback", async () => { const parentLog: string[] = []; const subagentLog: string[] = []; @@ -89,6 +63,15 @@ describe("loadCustomTools per-session binding (#2190 review fix)", () => { const parentApi = (parentResult.tools[0]!.tool as unknown as { __boundApi: CustomToolAPI }).__boundApi; const subagentApi = (subagentResult.tools[0]!.tool as unknown as { __boundApi: CustomToolAPI }).__boundApi; + expect(parentResult.errors).toEqual([]); + expect(subagentResult.errors).toEqual([]); + expect(parentResult.tools).toHaveLength(1); + expect(subagentResult.tools).toHaveLength(1); + expect(parentApi.cwd).toBe("/tmp/parent-cwd"); + expect(subagentApi.cwd).toBe("/tmp/subagent-cwd"); + expect(subagentApi).not.toBe(parentApi); + expect(subagentResult.tools[0]?.tool).not.toBe(parentResult.tools[0]?.tool); + // Cast: the test fixture exposes the runtime API verbatim. parentApi.pushPendingAction({ label: "ping", diff --git a/packages/coding-agent/test/sdk-default-role-discovery-config-provider.test.ts b/packages/coding-agent/test/sdk-default-role-discovery-config-provider.test.ts index 2f5bd1043..444d75a77 100644 --- a/packages/coding-agent/test/sdk-default-role-discovery-config-provider.test.ts +++ b/packages/coding-agent/test/sdk-default-role-discovery-config-provider.test.ts @@ -23,9 +23,10 @@ import type { FetchImpl } from "@oh-my-pi/pi-ai"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { Snowflake } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; describe("issue #6162 fresh launch default role from models.yml discovery provider", () => { let tempDir: string; @@ -75,7 +76,7 @@ describe("issue #6162 fresh launch default role from models.yml discovery provid ].join("\n"), ); - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); // The configured provider's key resolves the role model; a competing // bundled provider key would otherwise win the startup fallback via @@ -107,6 +108,9 @@ describe("issue #6162 fresh launch default role from models.yml discovery provid enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { diff --git a/packages/coding-agent/test/sdk-default-role-discovery-local-provider.test.ts b/packages/coding-agent/test/sdk-default-role-discovery-local-provider.test.ts index fa35ece8b..693e9865f 100644 --- a/packages/coding-agent/test/sdk-default-role-discovery-local-provider.test.ts +++ b/packages/coding-agent/test/sdk-default-role-discovery-local-provider.test.ts @@ -61,44 +61,7 @@ describe("issue #6114 fresh launch default role from discovery-only local provid }; } - test("selects the configured LM Studio default on a cache-cold boot", async () => { - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); - authStoragesToClose.push(authStorage); - // Fresh registry: no cached lm-studio catalog on disk, so the static - // catalog the SDK resolves against at startup is empty for the provider. - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"), { - fetch: mockLmStudio(["qwen3-coder-30b"]), - }); - - const settings = Settings.isolated(); - settings.setModelRole("default", "lm-studio/qwen3-coder-30b"); - - const { session } = await createAgentSession({ - cwd: tempDir, - agentDir: tempDir, - authStorage, - modelRegistry, - settings, - sessionManager: SessionManager.inMemory(), - disableExtensionDiscovery: true, - skills: [], - contextFiles: [], - promptTemplates: [], - slashCommands: [], - enableMCP: false, - enableLsp: false, - skipPythonPreflight: true, - }); - - try { - expect(session.model?.provider).toBe("lm-studio"); - expect(session.model?.id).toBe("qwen3-coder-30b"); - } finally { - await session.dispose(); - } - }); - - test("applies the configured local default even when a bundled provider key is present", async () => { + test("prefers the configured LM Studio default over an authenticated bundled fallback on cache-cold boot", async () => { const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); authStoragesToClose.push(authStorage); // Mirrors #6114 comment (pmatos): a stray key for a bundled provider makes diff --git a/packages/coding-agent/test/sdk-default-role-extension-provider.test.ts b/packages/coding-agent/test/sdk-default-role-extension-provider.test.ts index 395146919..bdbdcb0e4 100644 --- a/packages/coding-agent/test/sdk-default-role-extension-provider.test.ts +++ b/packages/coding-agent/test/sdk-default-role-extension-provider.test.ts @@ -17,9 +17,10 @@ import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { Snowflake } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; describe("issue #3569 fresh launch default role from extension provider", () => { let tempDir: string; @@ -65,7 +66,7 @@ describe("issue #3569 fresh launch default role from extension provider", () => throw new Error("Expected bundled OpenAI GPT-5.5 default"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); // Mirrors the reporter's environment: `OPENAI_API_KEY` is configured for a // bundled provider whose `pickDefaultAvailableModel` entry would otherwise @@ -92,6 +93,9 @@ describe("issue #3569 fresh launch default role from extension provider", () => enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { diff --git a/packages/coding-agent/test/sdk-generate-image-tool-gating.test.ts b/packages/coding-agent/test/sdk-generate-image-tool-gating.test.ts index 78c97cc78..bec3a5d6b 100644 --- a/packages/coding-agent/test/sdk-generate-image-tool-gating.test.ts +++ b/packages/coding-agent/test/sdk-generate-image-tool-gating.test.ts @@ -29,7 +29,7 @@ describe("generate_image tool gating", () => { registryDir = path.join(os.tmpdir(), `pi-generate-image-gating-${Snowflake.next()}`); fs.mkdirSync(registryDir, { recursive: true }); authStorage = await AuthStorage.create(path.join(registryDir, "auth.db")); - modelRegistry = new ModelRegistry(authStorage); + modelRegistry = new ModelRegistry(authStorage, path.join(registryDir, "models.yml")); }); afterEach(async () => { @@ -41,8 +41,30 @@ describe("generate_image tool gating", () => { if (fs.existsSync(registryDir)) removeSyncWithRetries(registryDir); }); + function startupShortcuts() { + // These tests vary only tool registration and activation. Bypass unrelated + // filesystem discovery and workspace walking on every SDK session startup. + return { + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + rules: [], + workspaceTree: { + rootPath: registryDir, + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], + }, + enableMCP: false, + enableLsp: false, + }; + } + async function activeToolNames(settings: Settings, toolNames?: string[]): Promise { const { session } = await createAgentSession({ + ...startupShortcuts(), cwd: registryDir, agentDir: registryDir, modelRegistry, @@ -69,6 +91,7 @@ describe("generate_image tool gating", () => { async function sessionWithCustomTools(toolNames: string[], customTools: CustomTool[]): Promise { const { session } = await createAgentSession({ + ...startupShortcuts(), cwd: registryDir, agentDir: registryDir, enableMCP: false, @@ -116,6 +139,7 @@ describe("generate_image tool gating", () => { // discoverable custom tool, so it mounts as an xd:// device instead of // shipping its schema top-level. const { session } = await createAgentSession({ + ...startupShortcuts(), cwd: registryDir, agentDir: registryDir, modelRegistry, @@ -157,6 +181,7 @@ describe("generate_image tool gating", () => { }, } as CustomTool; const { session } = await createAgentSession({ + ...startupShortcuts(), cwd: registryDir, agentDir: registryDir, modelRegistry, @@ -315,6 +340,7 @@ describe("generate_image tool gating", () => { }); it("exposes newly discovered RPC tools directly when write was omitted", async () => { const { session } = await createAgentSession({ + ...startupShortcuts(), cwd: registryDir, agentDir: registryDir, modelRegistry, diff --git a/packages/coding-agent/test/sdk-mcp-defer.test.ts b/packages/coding-agent/test/sdk-mcp-defer.test.ts index ea1a31069..05082c661 100644 --- a/packages/coding-agent/test/sdk-mcp-defer.test.ts +++ b/packages/coding-agent/test/sdk-mcp-defer.test.ts @@ -3,13 +3,14 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { type } from "@oh-my-pi/omptype"; -import { AuthStorage } from "@oh-my-pi/pi-ai"; +import type { AuthStorage } from "@oh-my-pi/pi-ai"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { type CustomTool, createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; // Contract for B1 (interactive MCP deferral): when `hasUI` is true, MCP // discovery is deferred off the first-paint path, so an explicitly requested @@ -20,7 +21,6 @@ import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; // false there is no deferral, so an MCP tool name with no real backing is not // registered at all (the non-UI paths keep the blocking discover path). describe("createAgentSession MCP deferral (B1)", () => { - let registryDir: string; let tempDir: string; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; @@ -41,23 +41,23 @@ describe("createAgentSession MCP deferral (B1)", () => { slashCommands: [], enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], // No .mcp.json in tempDir, so no real MCP server can ever back this name. enableMCP: true, toolNames: ["read", PENDING_MCP_TOOL], }); - beforeAll(async () => { - registryDir = path.join(os.tmpdir(), `pi-sdk-mcp-defer-registry-${Snowflake.next()}`); - fs.mkdirSync(registryDir, { recursive: true }); - authStorage = await AuthStorage.create(path.join(registryDir, "auth.db")); - modelRegistry = new ModelRegistry(authStorage); + beforeAll(() => { + authStorage = createInMemoryAuthStorage(); + modelRegistry = new ModelRegistry( + authStorage, + path.join(os.tmpdir(), `pi-sdk-mcp-defer-models-${Snowflake.next()}.yml`), + ); }); afterAll(() => { authStorage.close(); - if (registryDir && fs.existsSync(registryDir)) { - removeSyncWithRetries(registryDir); - } }); beforeEach(() => { diff --git a/packages/coding-agent/test/sdk-mcp-instructions.test.ts b/packages/coding-agent/test/sdk-mcp-instructions.test.ts index e142c8b4a..943f5e22e 100644 --- a/packages/coding-agent/test/sdk-mcp-instructions.test.ts +++ b/packages/coding-agent/test/sdk-mcp-instructions.test.ts @@ -31,7 +31,6 @@ const CONTEXT_MODE_ROUTE = '- "ctx_execute" → `xd://mcp__context_mode_ctx_exec const CONTEXT_MODE_MCP_TOOL_NAME = "mcp__context_mode_ctx_execute"; describe("createAgentSession MCP server instructions (deferred UI)", () => { - let registryDir: string; let tempDir: string; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; @@ -43,22 +42,20 @@ describe("createAgentSession MCP server instructions (deferred UI)", () => { let isolatedAgentDir: string; beforeAll(async () => { - registryDir = path.join(os.tmpdir(), `pi-sdk-mcp-instr-registry-${Snowflake.next()}`); - fs.mkdirSync(registryDir, { recursive: true }); isolatedHome = path.join(os.tmpdir(), `pi-sdk-mcp-instr-home-${Snowflake.next()}`); fs.mkdirSync(isolatedHome, { recursive: true }); isolatedAgentDir = path.join(isolatedHome, ".omp", "agent"); fs.mkdirSync(isolatedAgentDir, { recursive: true }); originalAgentDir = getAgentDir(); setAgentDir(isolatedAgentDir); - authStorage = await AuthStorage.create(path.join(registryDir, "auth.db")); + authStorage = await AuthStorage.create(":memory:"); modelRegistry = new ModelRegistry(authStorage); }); afterAll(() => { authStorage.close(); setAgentDir(originalAgentDir); - for (const dir of [registryDir, isolatedHome]) { + for (const dir of [isolatedHome]) { if (dir && fs.existsSync(dir)) { removeSyncWithRetries(dir); } @@ -119,7 +116,7 @@ describe("createAgentSession MCP server instructions (deferred UI)", () => { const deadline = Date.now() + 12_000; let prompt = session.systemPrompt.join("\n"); while (!prompt.includes(SERVER_INSTRUCTIONS) && Date.now() < deadline) { - await Bun.sleep(50); + await Bun.sleep(10); prompt = session.systemPrompt.join("\n"); } @@ -174,7 +171,7 @@ describe("createAgentSession MCP server instructions (deferred UI)", () => { expect(prompt).not.toContain(CONTEXT_MODE_ROUTE); const deadline = Date.now() + 12_000; while (!prompt.includes(CONTEXT_MODE_ROUTE) && Date.now() < deadline) { - await Bun.sleep(50); + await Bun.sleep(10); prompt = session.systemPrompt.join("\n"); } @@ -227,7 +224,7 @@ describe("createAgentSession MCP server instructions (deferred UI)", () => { const deadline = Date.now() + 12_000; let prompt = session.systemPrompt.join("\n"); while (!prompt.includes(SERVER_INSTRUCTIONS) && Date.now() < deadline) { - await Bun.sleep(50); + await Bun.sleep(10); prompt = session.systemPrompt.join("\n"); } @@ -273,7 +270,7 @@ describe("createAgentSession MCP server instructions (deferred UI)", () => { const deadline = Date.now() + 12_000; let activeNames = session.getActiveToolNames(); while (!activeNames.includes(MCP_TOOL_NAME) && Date.now() < deadline) { - await Bun.sleep(50); + await Bun.sleep(10); activeNames = session.getActiveToolNames(); } @@ -313,7 +310,7 @@ describe("createAgentSession MCP server instructions (deferred UI)", () => { const deadline = Date.now() + 12_000; let prompt = session.systemPrompt.join("\n"); while (!prompt.includes(SERVER_INSTRUCTIONS) && Date.now() < deadline) { - await Bun.sleep(50); + await Bun.sleep(10); prompt = session.systemPrompt.join("\n"); } const activeNames = session.getActiveToolNames(); @@ -351,12 +348,12 @@ describe("createAgentSession MCP server instructions (deferred UI)", () => { const deadline = Date.now() + 12_000; let prompt = session.systemPrompt.join("\n"); while (!prompt.includes(SERVER_INSTRUCTIONS) && Date.now() < deadline) { - await Bun.sleep(50); + await Bun.sleep(10); prompt = session.systemPrompt.join("\n"); } let activeNames = session.getActiveToolNames(); while (!activeNames.includes(MCP_TOOL_NAME) && Date.now() < deadline) { - await Bun.sleep(50); + await Bun.sleep(10); activeNames = session.getActiveToolNames(); } diff --git a/packages/coding-agent/test/sdk-model-selection.test.ts b/packages/coding-agent/test/sdk-model-selection.test.ts index 14f8fa2ab..26dd790c8 100644 --- a/packages/coding-agent/test/sdk-model-selection.test.ts +++ b/packages/coding-agent/test/sdk-model-selection.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -12,14 +12,25 @@ import { getModelMatchPreferences, resolveModelScope } from "@oh-my-pi/pi-coding import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { buildSessionOptions as buildCliSessionOptions } from "@oh-my-pi/pi-coding-agent/main"; import { createAgentSession, type ExtensionFactory } from "@oh-my-pi/pi-coding-agent/sdk"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; describe("createAgentSession deferred model pattern resolution", () => { let tempDir: string; + let fixtureDir: string; + let fixtureAuthStorage: AuthStorage; + let fixtureModelRegistry: ModelRegistry; const authStoragesToClose: AuthStorage[] = []; + beforeAll(() => { + fixtureDir = path.join(os.tmpdir(), `pi-sdk-model-selection-fixture-${Snowflake.next()}`); + fs.mkdirSync(fixtureDir, { recursive: true }); + fixtureAuthStorage = createInMemoryAuthStorage(); + fixtureModelRegistry = new ModelRegistry(fixtureAuthStorage, path.join(fixtureDir, "models.yml")); + }); + beforeEach(() => { tempDir = path.join(os.tmpdir(), `pi-sdk-model-selection-${Snowflake.next()}`); fs.mkdirSync(tempDir, { recursive: true }); @@ -36,6 +47,11 @@ describe("createAgentSession deferred model pattern resolution", () => { } }); + afterAll(() => { + fixtureAuthStorage.close(); + removeSyncWithRetries(fixtureDir); + }); + const providerExtension: ExtensionFactory = pi => { pi.registerProvider("runtime-provider", { baseUrl: "https://runtime.example.com/v1", @@ -85,15 +101,12 @@ describe("createAgentSession deferred model pattern resolution", () => { pi.registerProvider("runtime-provider", dynamicOnlyProviderConfig); }; - async function buildSessionOptions(modelPattern: string | string[]) { - // Pass an explicit ModelRegistry so createAgentSession skips its implicit - // ModelRegistry.refreshInBackground() — a network model-discovery pass - // (~250ms/session) that contributes nothing here: the model resolves from - // the inline extension provider, never from network catalogs. Mirrors the - // explicit-registry pattern the resume tests below already rely on. - const authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); - authStoragesToClose.push(authStorage); - const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); + function buildSessionOptions(modelPattern: string | string[]) { + // Reuse one empty registry across these model-only cases. Opening a fresh + // AuthStorage runs the full SQLite schema setup, while every session here + // registers and removes the same inline provider on its own lifecycle. + const authStorage = fixtureAuthStorage; + const modelRegistry = fixtureModelRegistry; return { cwd: tempDir, agentDir: tempDir, @@ -108,23 +121,31 @@ describe("createAgentSession deferred model pattern resolution", () => { slashCommands: [], enableMCP: false, enableLsp: false, + skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], modelPattern, }; } test("resolves explicit modelPattern after extension providers register", async () => { const { session, modelFallbackMessage } = await createAgentSession( - await buildSessionOptions("runtime-provider/runtime-model"), + buildSessionOptions("runtime-provider/runtime-model"), ); - expect(session.model).toBeDefined(); - expect(session.model?.provider).toBe("runtime-provider"); - expect(session.model?.id).toBe("runtime-model"); - expect(modelFallbackMessage).toBeUndefined(); + try { + expect(session.model).toBeDefined(); + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-model"); + expect(modelFallbackMessage).toBeUndefined(); + } finally { + await session.dispose(); + } }); test("resolves explicit dynamic-only modelPattern from fresh runtime cache", async () => { - const authStorage = await AuthStorage.create(path.join(tempDir, "dynamic-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); const modelsPath = path.join(tempDir, "models.yml"); const primerRegistry = new ModelRegistry(authStorage, modelsPath); @@ -147,6 +168,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], modelPattern: "runtime-provider/cached-runtime-model", }); @@ -161,11 +185,15 @@ describe("createAgentSession deferred model pattern resolution", () => { test("does not silently fallback when explicit modelPattern is unresolved", async () => { const { session, modelFallbackMessage } = await createAgentSession( - await buildSessionOptions("missing-provider/missing-model"), + buildSessionOptions("missing-provider/missing-model"), ); - expect(session.model).toBeUndefined(); - expect(modelFallbackMessage).toBe('Model "missing-provider/missing-model" not found'); + try { + expect(session.model).toBeUndefined(); + expect(modelFallbackMessage).toBe('Model "missing-provider/missing-model" not found'); + } finally { + await session.dispose(); + } }); test("uses auth fallback when deferred subagent modelPattern resolves without working credentials", async () => { @@ -173,7 +201,7 @@ describe("createAgentSession deferred model pattern resolution", () => { if (!parentModel) { throw new Error("Expected bundled anthropic parent model"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "fallback-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); authStorage.setRuntimeApiKey(parentModel.provider, "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "fallback-models.yml")); @@ -197,6 +225,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], modelPattern: "runtime-provider/runtime-model", modelPatternAuthFallback: `${parentModel.provider}/${parentModel.id}`, }); @@ -216,7 +247,7 @@ describe("createAgentSession deferred model pattern resolution", () => { settings.setModelRole("smol", "runtime-provider/runtime-model"); const { session, modelFallbackMessage } = await createAgentSession({ - ...(await buildSessionOptions("@smol")), + ...buildSessionOptions("@smol"), settings, }); @@ -234,7 +265,7 @@ describe("createAgentSession deferred model pattern resolution", () => { settings.setModelRole("task", "runtime-provider/runtime-model"); const { session, modelFallbackMessage } = await createAgentSession({ - ...(await buildSessionOptions("task")), + ...buildSessionOptions("task"), settings, }); @@ -250,7 +281,7 @@ describe("createAgentSession deferred model pattern resolution", () => { test("resolves deferred suffixed bare configured roles after extension providers register", async () => { const settings = Settings.isolated(); settings.setModelRole("task", "runtime-provider/runtime-reasoning-model"); - const authStorage = await AuthStorage.create(path.join(tempDir, "cli-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "cli-models.yml")); const parsed = parseArgs(["--model", "task:high"]); @@ -283,6 +314,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -305,7 +339,7 @@ describe("createAgentSession deferred model pattern resolution", () => { } const settings = Settings.isolated(); settings.setModelRole("task", `runtime-provider/runtime-model,${fallbackModel.provider}/${fallbackModel.id}`); - const authStorage = await AuthStorage.create(path.join(tempDir, "role-chain-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "role-chain-models.yml")); const parsed = parseArgs(["--model", "task"]); @@ -339,6 +373,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -358,7 +395,7 @@ describe("createAgentSession deferred model pattern resolution", () => { modelProviderOrder: ["aimlapi", "openai"], }); settings.setModelRole("task", "missing-provider/missing-model,gpt-4o-mini"); - const authStorage = await AuthStorage.create(path.join(tempDir, "ambiguous-role-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("openai", "test-key"); authStoragesToClose.push(authStorage); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "ambiguous-role-models.yml")); @@ -392,6 +429,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -412,7 +452,7 @@ describe("createAgentSession deferred model pattern resolution", () => { }, }); settings.setModelRole("slow", "missing-provider/missing-model"); - const authStorage = await AuthStorage.create(path.join(tempDir, "missing-role-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey("runtime-provider", "test-key"); authStoragesToClose.push(authStorage); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "missing-role-models.yml")); @@ -446,6 +486,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -468,7 +511,7 @@ describe("createAgentSession deferred model pattern resolution", () => { settings.setModelRole("task", "runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model"); const { session, modelFallbackMessage } = await createAgentSession({ - ...(await buildSessionOptions("task")), + ...buildSessionOptions("task"), modelPatternFallbackRole: "subagent:deferred", settings, }); @@ -492,7 +535,7 @@ describe("createAgentSession deferred model pattern resolution", () => { "retry.usageReservePolicy": "confirm", }); settings.setModelRole("task", "runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model"); - const options = await buildSessionOptions("task"); + const options = buildSessionOptions("task"); vi.spyOn(options.authStorage, "getModelUsageHealth").mockImplementation(async (_provider, healthOptions) => healthOptions.modelId === "runtime-model" ? { state: "depleted", accounts: [{ credentialId: 1, credentialType: "oauth", state: "depleted" }] } @@ -518,7 +561,7 @@ describe("createAgentSession deferred model pattern resolution", () => { "retry.usageReservePolicy": "confirm", }); settings.setModelRole("task", "runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model"); - const options = await buildSessionOptions("task"); + const options = buildSessionOptions("task"); const usageHealth = vi.spyOn(options.authStorage, "getModelUsageHealth").mockResolvedValue({ state: "depleted", accounts: [{ credentialId: 1, credentialType: "oauth", state: "depleted" }], @@ -545,7 +588,7 @@ describe("createAgentSession deferred model pattern resolution", () => { "retry.usageReservePolicy": "confirm", }); settings.setModelRole("task", "runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model"); - const options = await buildSessionOptions("task"); + const options = buildSessionOptions("task"); vi.spyOn(options.authStorage, "getModelUsageHealth").mockImplementation(async (_provider, healthOptions) => healthOptions.modelId === "runtime-model" ? { @@ -581,7 +624,7 @@ describe("createAgentSession deferred model pattern resolution", () => { "retry.usageAwareFallback": true, "retry.usageReservePolicy": "fail-closed", }); - const options = await buildSessionOptions("runtime-provider/runtime-model"); + const options = buildSessionOptions("runtime-provider/runtime-model"); vi.spyOn(options.authStorage, "getModelUsageHealth").mockResolvedValue({ state: "reserve", accounts: [{ credentialId: 1, credentialType: "oauth", state: "reserve", remainingFraction: 0.05 }], @@ -598,7 +641,7 @@ describe("createAgentSession deferred model pattern resolution", () => { test("installs fallback chain for remaining deferred subagent modelPattern candidates", async () => { const { session } = await createAgentSession({ - ...(await buildSessionOptions(["runtime-provider/runtime-model", "runtime-provider/runtime-reasoning-model"])), + ...buildSessionOptions(["runtime-provider/runtime-model", "runtime-provider/runtime-reasoning-model"]), modelPatternFallbackRole: "subagent:deferred", }); @@ -622,7 +665,7 @@ describe("createAgentSession deferred model pattern resolution", () => { }); settings.setModelRole("default", "runtime-provider/runtime-reasoning-model"); const { session } = await createAgentSession({ - ...(await buildSessionOptions("runtime-provider/runtime-model")), + ...buildSessionOptions("runtime-provider/runtime-model"), settings, modelPatternFallbackRole: "subagent:deferred-default", modelPatternDefaultFallbackChain: ["runtime-provider/runtime-reasoning-model"], @@ -642,7 +685,7 @@ describe("createAgentSession deferred model pattern resolution", () => { test("splits deferred comma-delimited modelPattern and installs fallback chain", async () => { const { session } = await createAgentSession({ - ...(await buildSessionOptions("runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model")), + ...buildSessionOptions("runtime-provider/runtime-model,runtime-provider/runtime-reasoning-model"), modelPatternFallbackRole: "subagent:deferred", }); @@ -664,28 +707,36 @@ describe("createAgentSession deferred model pattern resolution", () => { settings.setModelRole("default", "@smol:high"); const { session } = await createAgentSession({ - ...(await buildSessionOptions("runtime-provider/runtime-reasoning-model")), + ...buildSessionOptions("runtime-provider/runtime-reasoning-model"), settings, }); - expect(session.model?.provider).toBe("runtime-provider"); - expect(session.model?.id).toBe("runtime-reasoning-model"); - expect(session.thinkingLevel).toBe("off"); + try { + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-reasoning-model"); + expect(session.thinkingLevel).toBe("off"); + } finally { + await session.dispose(); + } }); test("clamps a max default thinking level to the model's ladder ceiling", async () => { const settings = Settings.isolated({ defaultThinkingLevel: "max" }); const { session } = await createAgentSession({ - ...(await buildSessionOptions("runtime-provider/runtime-reasoning-model")), + ...buildSessionOptions("runtime-provider/runtime-reasoning-model"), settings, }); - expect(session.model?.provider).toBe("runtime-provider"); - expect(session.model?.id).toBe("runtime-reasoning-model"); - // The extension model has no explicit ladder; the inferred fallback tops - // out at xhigh, so the real max level clamps down. - expect(session.thinkingLevel).toBe(Effort.XHigh); + try { + expect(session.model?.provider).toBe("runtime-provider"); + expect(session.model?.id).toBe("runtime-reasoning-model"); + // The extension model has no explicit ladder; the inferred fallback tops + // out at xhigh, so the real max level clamps down. + expect(session.thinkingLevel).toBe(Effort.XHigh); + } finally { + await session.dispose(); + } }); test("selects the settings default model without synchronously validating auth", async () => { @@ -694,7 +745,7 @@ describe("createAgentSession deferred model pattern resolution", () => { throw new Error("Expected bundled anthropic default model"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + const authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey(defaultModel.provider, "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); const settings = Settings.isolated(); @@ -719,6 +770,10 @@ describe("createAgentSession deferred model pattern resolution", () => { slashCommands: [], enableMCP: false, enableLsp: false, + skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -735,7 +790,7 @@ describe("createAgentSession deferred model pattern resolution", () => { }); test("refreshes cached llama.cpp vision metadata for the startup default model", async () => { - const authStorage = await AuthStorage.create(path.join(tempDir, "llama-vision-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); const modelsPath = path.join(tempDir, "llama-vision-models.yml"); const cacheDbPath = path.join(tempDir, "models.db"); @@ -795,6 +850,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -817,7 +875,7 @@ describe("createAgentSession deferred model pattern resolution", () => { throw new Error("Expected bundled anthropic default model"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "resume-saved-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); authStorage.setRuntimeApiKey(savedModel.provider, "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); @@ -864,6 +922,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -890,7 +951,7 @@ describe("createAgentSession deferred model pattern resolution", () => { throw new Error("Expected bundled anthropic models for fallback regression"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "fallbackauth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); @@ -912,6 +973,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -930,7 +994,7 @@ describe("createAgentSession deferred model pattern resolution", () => { throw new Error("Expected bundled OpenAI and Codex GPT-5.5 defaults"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "codex-fallback-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); authStorage.setRuntimeApiKey("openai", "sk-or-v1-invalid-openai-key"); authStorage.setRuntimeApiKey("openai-codex", "codex-oauth-token"); @@ -951,6 +1015,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -968,7 +1035,7 @@ describe("createAgentSession deferred model pattern resolution", () => { throw new Error("Expected bundled anthropic default model"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + const authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey(defaultModel.provider, "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); @@ -1016,6 +1083,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -1034,7 +1104,7 @@ describe("createAgentSession deferred model pattern resolution", () => { throw new Error("Expected bundled anthropic default model"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + const authStorage = createInMemoryAuthStorage(); authStorage.setRuntimeApiKey(settingsDefaultModel.provider, "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); @@ -1088,6 +1158,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -1104,7 +1177,7 @@ describe("createAgentSession deferred model pattern resolution", () => { if (!configuredModel) { throw new Error("Expected bundled anthropic configured model"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "scope-6694-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); // The extension provider carries an inline apiKey; the "normally // configured" provider needs credentials so it lands in the startup scope. @@ -1156,6 +1229,9 @@ describe("createAgentSession deferred model pattern resolution", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }); try { @@ -1178,7 +1254,7 @@ describe("createAgentSession deferred model pattern resolution", () => { if (!scopedTarget || !savedDefault) { throw new Error("Expected bundled openai and anthropic models"); } - const authStorage = await AuthStorage.create(path.join(tempDir, "cli-scope-auth.db")); + const authStorage = createInMemoryAuthStorage(); authStoragesToClose.push(authStorage); authStorage.setRuntimeApiKey(scopedTarget.provider, "test-key"); authStorage.setRuntimeApiKey(savedDefault.provider, "test-key"); diff --git a/packages/coding-agent/test/sdk-move-cwd.test.ts b/packages/coding-agent/test/sdk-move-cwd.test.ts index 9122be9cd..13a0efec9 100644 --- a/packages/coding-agent/test/sdk-move-cwd.test.ts +++ b/packages/coding-agent/test/sdk-move-cwd.test.ts @@ -3,10 +3,12 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; function textContent(result: { content?: Array<{ type: string; text?: string }> }): string { return ( @@ -37,10 +39,14 @@ describe("createAgentSession cwd after /move", () => { fs.mkdirSync(cwdB, { recursive: true }); const sessionManager = SessionManager.create(cwdA, path.join(tempDir, "sessions")); + const authStorage = createInMemoryAuthStorage(); + const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); const { session } = await createAgentSession({ cwd: cwdA, agentDir: tempDir, sessionManager, + authStorage, + modelRegistry, settings: Settings.isolated({ "async.enabled": false, "bash.autoBackground.enabled": false, @@ -54,6 +60,9 @@ describe("createAgentSession cwd after /move", () => { slashCommands: [], enableMCP: false, enableLsp: false, + skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], toolNames: ["bash"], }); @@ -66,7 +75,11 @@ describe("createAgentSession cwd after /move", () => { expect(textContent(result)).toContain(cwdB); } finally { - await session.dispose(); + try { + await session.dispose(); + } finally { + authStorage.close(); + } } }); }); diff --git a/packages/coding-agent/test/sdk-preloaded-extensions-isolation.test.ts b/packages/coding-agent/test/sdk-preloaded-extensions-isolation.test.ts index 911fb18f4..07a263da8 100644 --- a/packages/coding-agent/test/sdk-preloaded-extensions-isolation.test.ts +++ b/packages/coding-agent/test/sdk-preloaded-extensions-isolation.test.ts @@ -19,18 +19,19 @@ import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import type { LoadExtensionsResult } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types"; import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { removeSyncWithRetries } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; describe("createAgentSession preloadedExtensions isolation (issue #2190)", () => { let sharedDir: string; let authStorage: AuthStorage; let modelRegistry: ModelRegistry; - beforeAll(async () => { + beforeAll(() => { sharedDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-preloaded-ext-")); - authStorage = await AuthStorage.create(path.join(sharedDir, "auth.db")); + authStorage = createInMemoryAuthStorage(); modelRegistry = new ModelRegistry(authStorage, path.join(sharedDir, "models.yml")); }); @@ -53,7 +54,7 @@ describe("createAgentSession preloadedExtensions isolation (issue #2190)", () => const beforeLength = preloaded.extensions.length; const beforeArrayRef = preloaded.extensions; - await createAgentSession({ + const { session } = await createAgentSession({ cwd: sharedDir, agentDir: sharedDir, sessionManager: SessionManager.inMemory(), @@ -69,11 +70,17 @@ describe("createAgentSession preloadedExtensions isolation (issue #2190)", () => preloadedCustomToolPaths: [], contextFiles: [], promptTemplates: [], + slashCommands: [], + toolNames: ["read"], }); - // The session's own `extensionsResult` carries inline wrappers, but the - // caller's array (and its identity) must be untouched. - expect(preloaded.extensions).toBe(beforeArrayRef); - expect(preloaded.extensions.length).toBe(beforeLength); + try { + // The session's own `extensionsResult` carries inline wrappers, but the + // caller's array (and its identity) must be untouched. + expect(preloaded.extensions).toBe(beforeArrayRef); + expect(preloaded.extensions.length).toBe(beforeLength); + } finally { + await session.dispose(); + } }); }); diff --git a/packages/coding-agent/test/sdk-restricted-extension-provider.test.ts b/packages/coding-agent/test/sdk-restricted-extension-provider.test.ts index dcee45ebb..0575c788f 100644 --- a/packages/coding-agent/test/sdk-restricted-extension-provider.test.ts +++ b/packages/coding-agent/test/sdk-restricted-extension-provider.test.ts @@ -10,9 +10,10 @@ import { createAgentSession, type ExtensionFactory, } from "@oh-my-pi/pi-coding-agent/sdk"; -import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import type { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { removeSyncWithRetries, Snowflake } from "@oh-my-pi/pi-utils"; +import { createInMemoryAuthStorage } from "./helpers/agent-session-setup"; const providerName = "restricted-session-provider"; const modelId = "restricted-session-model"; @@ -25,10 +26,10 @@ describe("restricted sessions sharing extension providers", () => { let modelRegistry: ModelRegistry; let settings: Settings; - beforeEach(async () => { + beforeEach(() => { tempDir = path.join(os.tmpdir(), `pi-sdk-restricted-provider-${Snowflake.next()}`); fs.mkdirSync(tempDir, { recursive: true }); - authStorage = await AuthStorage.create(path.join(tempDir, "auth.db")); + authStorage = createInMemoryAuthStorage(); modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); settings = Settings.isolated(); settings.setModelRole("default", `${providerName}/${modelId}`); @@ -76,6 +77,9 @@ describe("restricted sessions sharing extension providers", () => { enableMCP: false, enableLsp: false, skipPythonPreflight: true, + rules: [], + preloadedCustomToolPaths: [], + toolNames: ["read"], }; } diff --git a/packages/coding-agent/test/sdk-tool-activation.test.ts b/packages/coding-agent/test/sdk-tool-activation.test.ts index 1d98ecd0e..f5f5863bc 100644 --- a/packages/coding-agent/test/sdk-tool-activation.test.ts +++ b/packages/coding-agent/test/sdk-tool-activation.test.ts @@ -1248,12 +1248,12 @@ describe("createAgentSession defaultInactive tool activation", () => { const unsubscribe = runner.onError(error => { errors.push(error.error); }); - testSetExtensionHandlerTimeoutMs(250); + testSetExtensionHandlerTimeoutMs(10); await runner.emit({ type: "session_start" }); unsubscribe(); - expect(errors).toContain("handler timed out after 250ms"); + expect(errors).toContain("handler timed out after 10ms"); expect(session.getToolByName("stalled_registration_tool")).toBeUndefined(); expect(session.getToolByName("recovered_registration_tool")?.label).toBe("recovered_registration_tool"); expect(session.getEnabledToolNames()).toContain("recovered_registration_tool"); @@ -1450,7 +1450,7 @@ describe("createAgentSession defaultInactive tool activation", () => { await originalSetPresentation(toolNames, mountedToolNames, forcePromptRefresh, signal); if (toolNames.includes("recovered_detached_tool")) recoveredActivation.resolve(); }); - testSetExtensionHandlerTimeoutMs(250); + testSetExtensionHandlerTimeoutMs(10); releaseStalledRegistration.resolve(); const failure = await detachedFailure.promise; diff --git a/packages/coding-agent/test/security/coordinator.test.ts b/packages/coding-agent/test/security/coordinator.test.ts index 1b61bf1e8..dd923c60b 100644 --- a/packages/coding-agent/test/security/coordinator.test.ts +++ b/packages/coding-agent/test/security/coordinator.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -20,11 +20,13 @@ import { SessionManager } from "../../src/session/session-manager"; const MOCK_SOURCE_ID = "security-coordinator-test"; let temporaryRoot = ""; +let registryRoot = ""; let repositoryRoot = ""; let stateRoot = ""; let credentialStore: AuthCredentialStore | null = null; let authStorage: AuthStorage; let settings: Settings; +let modelRegistry: ModelRegistry; let credentialId = 0; const gitAdapter: SecurityGitAdapter = { @@ -37,13 +39,11 @@ const gitAdapter: SecurityGitAdapter = { untracked: async () => [], }; -beforeEach(async () => { - temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-security-coordinator-")); - repositoryRoot = path.join(temporaryRoot, "repo"); - stateRoot = path.join(temporaryRoot, "state"); - await fs.mkdir(path.join(repositoryRoot, "src"), { recursive: true }); - await Bun.write(path.join(repositoryRoot, "src", "app.ts"), "export const app = true;\n"); - credentialStore = await SqliteAuthCredentialStore.open(path.join(temporaryRoot, "agent.db")); +// Credentials and the bundled-model view are immutable fixtures. Keep their SQLite +// store and registry for the suite; repository/store state remains fresh per test. +beforeAll(async () => { + registryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-security-coordinator-auth-")); + credentialStore = await SqliteAuthCredentialStore.open(path.join(registryRoot, "agent.db")); authStorage = new AuthStorage(credentialStore); await authStorage.set("openai-codex", { type: "oauth", @@ -58,6 +58,15 @@ beforeEach(async () => { const account = authStorage.listOAuthAccounts("openai-codex")[0]; if (!account) throw new Error("expected fixture OAuth account"); credentialId = account.credentialId; + modelRegistry = new ModelRegistry(authStorage, path.join(registryRoot, "models.yml")); +}); + +beforeEach(async () => { + temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-security-coordinator-")); + repositoryRoot = path.join(temporaryRoot, "repo"); + stateRoot = path.join(temporaryRoot, "state"); + await fs.mkdir(path.join(repositoryRoot, "src"), { recursive: true }); + await Bun.write(path.join(repositoryRoot, "src", "app.ts"), "export const app = true;\n"); settings = Settings.isolated({ "security.enabled": true, "compaction.enabled": false }); registerMockApi(MOCK_SOURCE_ID); }); @@ -66,9 +75,13 @@ afterEach(async () => { vi.restoreAllMocks(); unregisterCustomApis(MOCK_SOURCE_ID); settings.cancelPendingSaves(); + await fs.rm(temporaryRoot, { recursive: true, force: true }); +}); + +afterAll(async () => { credentialStore?.close(); credentialStore = null; - await fs.rm(temporaryRoot, { recursive: true, force: true }); + await fs.rm(registryRoot, { recursive: true, force: true }); }); function storeFactory(): Promise { @@ -81,7 +94,6 @@ function coordinatorWithMockSession(responses: MockResponseSource) { provider: "openai-codex", responses, }); - const modelRegistry = new ModelRegistry(authStorage, path.join(temporaryRoot, "models.yml")); const coordinator = new SecurityCoordinator( { cwd: repositoryRoot, @@ -152,7 +164,7 @@ describe("native security coordinator", () => { cwd: repositoryRoot, settings, authStorage, - modelRegistry: new ModelRegistry(authStorage, path.join(temporaryRoot, "models.yml")), + modelRegistry, activeModel: mock.model, }, { @@ -176,7 +188,6 @@ describe("native security coordinator", () => { test("cancellation before session launch has no inference side effects", async () => { let sessionCreations = 0; const mock = createMockModel({ id: "security-mock", provider: "openai-codex" }); - const modelRegistry = new ModelRegistry(authStorage, path.join(temporaryRoot, "models.yml")); const coordinator = new SecurityCoordinator( { cwd: repositoryRoot, @@ -211,7 +222,6 @@ describe("native security coordinator", () => { const promptFinished = Promise.withResolvers(); let abortCalls = 0; const mock = createMockModel({ id: "security-mock", provider: "openai-codex" }); - const modelRegistry = new ModelRegistry(authStorage, path.join(temporaryRoot, "models.yml")); const coordinator = new SecurityCoordinator( { cwd: repositoryRoot, @@ -270,7 +280,7 @@ describe("native security coordinator", () => { cwd: repositoryRoot, settings, authStorage, - modelRegistry: new ModelRegistry(authStorage, path.join(temporaryRoot, "models.yml")), + modelRegistry, activeModel: mock.model, }, { @@ -352,7 +362,7 @@ describe("native security coordinator", () => { cwd: repositoryRoot, settings, authStorage, - modelRegistry: new ModelRegistry(authStorage, path.join(temporaryRoot, "models.yml")), + modelRegistry, activeModel: mock.model, }, { openStore: storeFactory, gitAdapter }, diff --git a/packages/coding-agent/test/session-manager/file-operations.test.ts b/packages/coding-agent/test/session-manager/file-operations.test.ts index a07ed3a22..91f91b9be 100644 --- a/packages/coding-agent/test/session-manager/file-operations.test.ts +++ b/packages/coding-agent/test/session-manager/file-operations.test.ts @@ -15,6 +15,9 @@ import { setAgentDir, } from "@oh-my-pi/pi-utils"; +const OLDER_MTIME = new Date("2000-01-01T00:00:00.000Z"); +const NEWER_MTIME = new Date("2000-01-01T00:00:01.000Z"); + describe("loadEntriesFromFile", () => { let tempDir: string; @@ -76,9 +79,9 @@ describe("findMostRecentSession", () => { const file2 = path.join(tempDir, "newer.jsonl"); fs.writeFileSync(file1, '{"type":"session","id":"old","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); - // Small delay to ensure different mtime - await new Promise(r => setTimeout(r, 10)); + fs.utimesSync(file1, OLDER_MTIME, OLDER_MTIME); fs.writeFileSync(file2, '{"type":"session","id":"new","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); + fs.utimesSync(file2, NEWER_MTIME, NEWER_MTIME); expect(await findMostRecentSession(tempDir)).toBe(file2); }); @@ -88,7 +91,6 @@ describe("findMostRecentSession", () => { const valid = path.join(tempDir, "valid.jsonl"); fs.writeFileSync(invalid, '{"type":"not-session"}\n'); - await new Promise(r => setTimeout(r, 10)); fs.writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n'); expect(await findMostRecentSession(tempDir)).toBe(valid); @@ -327,6 +329,7 @@ describe("SessionManager legacy session migration persistence", () => { }), ].join("\n")}\n`, ); + fs.utimesSync(sessionFile, OLDER_MTIME, OLDER_MTIME); const initialMtimeMs = fs.statSync(sessionFile).mtimeMs; const session = await SessionManager.open(sessionFile, tempDir); @@ -339,11 +342,9 @@ describe("SessionManager legacy session migration persistence", () => { expect(migratedEntries[0]?.parentId).toBeNull(); expect(migratedEntries[1]?.parentId).toBe(migratedEntries[0]?.id); - await new Promise(resolve => setTimeout(resolve, 20)); await session.flush(); expect(fs.statSync(sessionFile).mtimeMs).toBe(initialMtimeMs); - await new Promise(resolve => setTimeout(resolve, 20)); session.appendMessage({ role: "user", content: "follow up", timestamp: Date.now() }); await session.flush(); @@ -372,10 +373,10 @@ describe("SessionManager legacy session migration persistence", () => { }), ].join("\n")}\n`, ); + fs.utimesSync(sessionFile, OLDER_MTIME, OLDER_MTIME); const initialMtimeMs = fs.statSync(sessionFile).mtimeMs; const session = await SessionManager.open(sessionFile, tempDir); - await new Promise(resolve => setTimeout(resolve, 20)); await session.rewriteEntries(); const persistedEntries = await loadEntriesFromFile(sessionFile); @@ -404,10 +405,10 @@ describe("SessionManager legacy session migration persistence", () => { }), ].join("\n")}\n`, ); + fs.utimesSync(sessionFile, OLDER_MTIME, OLDER_MTIME); const initialMtimeMs = fs.statSync(sessionFile).mtimeMs; const session = await SessionManager.open(sessionFile, tempDir); - await new Promise(resolve => setTimeout(resolve, 20)); await session.ensureOnDisk(); const persistedEntries = await loadEntriesFromFile(sessionFile); diff --git a/packages/coding-agent/test/setup-cli.test.ts b/packages/coding-agent/test/setup-cli.test.ts index c1a35a18f..68031e35d 100644 --- a/packages/coding-agent/test/setup-cli.test.ts +++ b/packages/coding-agent/test/setup-cli.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { checkPythonSetup } from "../src/cli/setup-cli"; const cliEntry = path.join(import.meta.dir, "..", "src", "cli.ts"); @@ -11,12 +12,11 @@ interface CliProcessResult { error: string; } -async function runSetupPython(cwd: string, envOverrides?: NodeJS.ProcessEnv): Promise { +async function runSetupPython(cwd: string): Promise { const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: "1", PI_CODING_AGENT_DIR: path.join(cwd, "agent"), - ...envOverrides, }; delete env.VIRTUAL_ENV; delete env.CONDA_DEFAULT_ENV; @@ -69,11 +69,9 @@ describe("omp setup python", () => { await Bun.write(interpreter, "#!/bin/sh\nexit 0\n"); await fs.chmod(interpreter, 0o755); - const result = await runSetupPython(cwd); + const result = await checkPythonSetup(cwd); - expect(result.error).toBe(""); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.output)).toMatchObject({ + expect(result).toMatchObject({ available: true, pythonPath: interpreter, usingManagedEnv: false, @@ -85,16 +83,19 @@ describe("omp setup python", () => { const interpreter = path.join(cwd, "configured-python"); await Bun.write(interpreter, "#!/bin/sh\nexit 23\n"); await fs.chmod(interpreter, 0o755); - await Bun.write(path.join(cwd, ".omp", "config.yml"), `python:\n interpreter: ${interpreter}\n`); - const result = await runSetupPython(cwd, { PI_PYTHON_SKIP_CHECK: "1" }); - - expect(result.error).toBe(""); - expect(result.exitCode).toBe(1); - expect(JSON.parse(result.output)).toMatchObject({ - available: false, - pythonPath: interpreter, - usingManagedEnv: false, - }); + const previousSkipCheck = process.env.PI_PYTHON_SKIP_CHECK; + process.env.PI_PYTHON_SKIP_CHECK = "1"; + try { + const result = await checkPythonSetup(cwd, interpreter); + expect(result).toMatchObject({ + available: false, + pythonPath: interpreter, + usingManagedEnv: false, + }); + } finally { + if (previousSkipCheck === undefined) delete process.env.PI_PYTHON_SKIP_CHECK; + else process.env.PI_PYTHON_SKIP_CHECK = previousSkipCheck; + } }); }); diff --git a/packages/coding-agent/test/share.test.ts b/packages/coding-agent/test/share.test.ts index e536e43b0..854fa63a5 100644 --- a/packages/coding-agent/test/share.test.ts +++ b/packages/coding-agent/test/share.test.ts @@ -12,6 +12,7 @@ import type { SessionEntry } from "../src/session/session-entries"; import type { SessionManager } from "../src/session/session-manager"; const IV_LENGTH = 12; +const TEST_MAX_SEALED_BYTES = 4_000; async function makeKey(): Promise { const bytes = new Uint8Array(32); @@ -66,14 +67,14 @@ describe("sealToFit", () => { test("trims oversized text into budget without dropping entries", async () => { const key = await makeKey(); const data = sessionData( - [messageEntry("e1", null, "keep me"), messageEntry("e2", "e1", randomHex(1_500_000))], + [messageEntry("e1", null, "keep me"), messageEntry("e2", "e1", randomHex(10_000))], "e2", ); - const { sealed, truncated } = await sealToFit(key, data, SERVER_MAX_SEALED_BYTES); + const { sealed, truncated } = await sealToFit(key, data, TEST_MAX_SEALED_BYTES); expect(truncated).toBe(true); - expect(sealed.byteLength).toBeLessThanOrEqual(SERVER_MAX_SEALED_BYTES); + expect(sealed.byteLength).toBeLessThanOrEqual(TEST_MAX_SEALED_BYTES); const opened = await open(key, sealed); expect(opened.entries).toHaveLength(2); expect(opened.leafId).toBe("e2"); @@ -92,13 +93,13 @@ describe("sealToFit", () => { role: "user", content: [ { type: "text", text: "see screenshot" }, - { type: "image", data: randomHex(800_000), mimeType: "image/png" }, + { type: "image", data: randomHex(2_000), mimeType: "image/png" }, ], }, } as unknown as SessionEntry; const data = sessionData([imageEntry], "img"); - const { sealed, truncated } = await sealToFit(key, data, SERVER_MAX_SEALED_BYTES); + const { sealed, truncated } = await sealToFit(key, data, TEST_MAX_SEALED_BYTES); expect(truncated).toBe(true); const flat = JSON.stringify(await open(key, sealed)); diff --git a/packages/coding-agent/test/shell-snapshot.test.ts b/packages/coding-agent/test/shell-snapshot.test.ts index 15774bc8e..c5f12ed2e 100644 --- a/packages/coding-agent/test/shell-snapshot.test.ts +++ b/packages/coding-agent/test/shell-snapshot.test.ts @@ -378,14 +378,15 @@ describe("getOrCreateSnapshot", () => { process.env.TMPDIR = testRoot; try { const fakeShell = path.join(testRoot, "timeout-shell.sh"); - // Sleep longer than SNAPSHOT_TIMEOUT_MS (2000) - await fs.writeFile(fakeShell, `#!/bin/sh\nsleep 3\n`); + // A short injected deadline exercises Bun's real process timeout without + // making the suite wait out the two-second production startup budget. + await fs.writeFile(fakeShell, `#!/bin/sh\nsleep 1\n`); await fs.chmod(fakeShell, 0o755); const env = { ...process.env, HOME: testRoot }; const snapshotDir = snapshotDirIn(testRoot); - const snapshotPath = await getOrCreateSnapshot(fakeShell, env); + const snapshotPath = await getOrCreateSnapshot(fakeShell, env, 25); expect(snapshotPath).toBeNull(); if (existsSync(snapshotDir)) { @@ -397,7 +398,7 @@ describe("getOrCreateSnapshot", () => { else process.env.TMPDIR = originalTmpDir; await fs.rm(testRoot, { recursive: true, force: true }); } - }, 5000); // increase test timeout to 5s to accommodate the 2s snapshot timeout + }); it("keeps snapshots in a uid-scoped dir so accounts sharing /tmp cannot collide", async () => { // Regression: the dir used to be a single fixed `omp-shell-snapshots` name diff --git a/packages/coding-agent/test/skill-url-containment.test.ts b/packages/coding-agent/test/skill-url-containment.test.ts index 24cff5d38..9ec6328ca 100644 --- a/packages/coding-agent/test/skill-url-containment.test.ts +++ b/packages/coding-agent/test/skill-url-containment.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -24,7 +24,7 @@ function pluginSkill(): Skill { }; } -beforeEach(async () => { +beforeAll(async () => { tempDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "skill-contain-"))); pluginRoot = path.join(tempDir, "plugin"); skillDir = path.join(pluginRoot, "skills", "docs"); @@ -44,7 +44,7 @@ beforeEach(async () => { await fs.symlink(path.join(tempDir, "not-created.md"), path.join(skillDir, "references", "dangle.md")); }); -afterEach(async () => { +afterAll(async () => { await fs.rm(tempDir, { recursive: true, force: true }); }); diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index 23b7ca10c..ed588cc37 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, spyOn } from "bun:test"; +import { beforeAll, describe, expect, it, spyOn } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -6,6 +6,7 @@ import { type Skill as CapabilitySkill, skillCapability } from "@oh-my-pi/pi-cod import { getCapability } from "@oh-my-pi/pi-coding-agent/discovery"; import { getWslWindowsHomeCandidate, runHostProbe } from "@oh-my-pi/pi-coding-agent/discovery/agents"; import { + type LoadSkillsResult, loadSkills, loadSkillsFromDir, parseSkillInvocation, @@ -46,8 +47,13 @@ const DISABLE_ALL_BUILTIN_SKILLS = { describe("skills", () => { describe("loadSkillsFromDir", () => { - const loadFixtureRoot = () => loadSkillsFromDir({ dir: fixturesDir, source: "test" }); + let fixtureRoot: LoadSkillsResult; + beforeAll(async () => { + fixtureRoot = await loadSkillsFromDir({ dir: fixturesDir, source: "test" }); + }); + + const loadFixtureRoot = async () => fixtureRoot; it("should load a valid skill from a skills root", async () => { const { skills, warnings } = await loadFixtureRoot(); const validSkill = skills.find(skill => skill.name === "valid-skill"); @@ -157,15 +163,23 @@ describe("skills", () => { }); describe("loadSkills with options", () => { + let customDirectorySkills: LoadSkillsResult; + + beforeAll(async () => { + customDirectorySkills = await loadSkills({ + ...DISABLE_ALL_BUILTIN_SKILLS, + customDirectories: [fixturesDir], + }); + }); it("should load from customDirectories only when built-ins disabled", async () => { - const { skills } = await loadSkills({ ...DISABLE_ALL_BUILTIN_SKILLS, customDirectories: [fixturesDir] }); + const { skills } = customDirectorySkills; expect(skills.length).toBeGreaterThan(0); // Custom directory skills have source "custom:user" expect(skills.every(s => s.source.startsWith("custom"))).toBe(true); }); it("should return customDirectory skills sorted by name (case-insensitive)", async () => { - const { skills } = await loadSkills({ ...DISABLE_ALL_BUILTIN_SKILLS, customDirectories: [fixturesDir] }); + const { skills } = customDirectorySkills; expect(skills.map(s => s.name)).toEqual(expectedFixtureSkillOrder); }); @@ -301,17 +315,14 @@ describe("skills", () => { it("kills a host probe that never exits instead of blocking startup (#8402)", () => { // Integration test against real OS timer behavior: the contract is that // runHostProbe's spawnSync `timeout` actually kills a genuinely blocked - // child. That is a native process-lifecycle effect the kernel drives, so - // fake timers cannot exercise it. The child would sleep a minute (stand-in - // for a wedged WSL->Windows interop pipe); the 500ms probe timeout must - // kill it and report "unavailable" rather than hang the calling thread. + // child. Injecting a short deadline preserves that native lifecycle + // coverage without paying the production discovery budget. const start = performance.now(); - const result = runHostProbe([process.execPath, "-e", "await Bun.sleep(60_000)"]); + const result = runHostProbe([process.execPath, "-e", "await Bun.sleep(60_000)"], 25); const elapsed = performance.now() - start; expect(result).toBeUndefined(); - // Loose bound: proves the probe returned via its own timeout, not via the - // child completing; a broken timeout would block far past this ceiling. - expect(elapsed).toBeLessThan(5_000); + // Loose bound proves the probe returned via its timeout, not the child. + expect(elapsed).toBeLessThan(1_000); }); it("returns trimmed stdout for a host probe that succeeds (#8402)", () => { diff --git a/packages/coding-agent/test/status-line-vcs-refresh.test.ts b/packages/coding-agent/test/status-line-vcs-refresh.test.ts index 3dfaf3dc9..f4882ec20 100644 --- a/packages/coding-agent/test/status-line-vcs-refresh.test.ts +++ b/packages/coding-agent/test/status-line-vcs-refresh.test.ts @@ -746,11 +746,9 @@ describe("StatusLineComponent git watcher survives atomic HEAD renames", () => { beforeAll(async () => { repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "status-line-headwatch-")); - const run = (...args: string[]) => Bun.spawnSync(["git", ...args], { cwd: repoDir }); - run("init", "-q"); - run("config", "user.email", "t@example.com"); - run("config", "user.name", "Test"); - run("commit", "--allow-empty", "-q", "-m", "init"); + const gitDir = path.join(repoDir, ".git"); + await fs.mkdir(gitDir); + await fs.writeFile(path.join(gitDir, "HEAD"), "ref: refs/heads/main\n"); }); afterAll(async () => { @@ -774,10 +772,15 @@ describe("StatusLineComponent git watcher survives atomic HEAD renames", () => { component.updateSettings(gitSegment); // Await the watcher's own #onBranchChange signal rather than a wall-clock - // delay: a fresh resolver is armed before each switch and awaited after it, - // so a frozen watcher surfaces as the test-runner timeout, not a flake. + // delay. Only resolve once the atomically replaced HEAD is observable: + // directory watchers may also report creation of HEAD.lock. let branchChanged = Promise.withResolvers(); - component.watchBranch(() => branchChanged.resolve()); + let expectedBranch: string | null = null; + component.watchBranch(() => { + if (expectedBranch && component.getTopBorder(80).content.includes(expectedBranch)) { + branchChanged.resolve(); + } + }); // macOS may keep a file watch alive across the rename, so also pin the // platform-independent requirement: the watcher owns the stable git dir. expect(watchSpy).toHaveBeenCalledWith(path.join(repoDir, ".git"), expect.any(Function)); @@ -786,10 +789,18 @@ describe("StatusLineComponent git watcher survives atomic HEAD renames", () => { component.getTopBorder(80); const switchTo = async (branchName: string) => { - const fired = branchChanged.promise; - await git.branch.checkoutNew(repoDir, branchName); - await fired; + const gitDir = path.join(repoDir, ".git"); + const headLock = path.join(gitDir, "HEAD.lock"); + // Reproduce Git's relevant integration boundary directly: write the + // lock, then atomically replace HEAD. Spawning Git adds process startup + // but no coverage to the filesystem-watcher regression. + await fs.writeFile(headLock, `ref: refs/heads/${branchName}\n`); branchChanged = Promise.withResolvers(); + expectedBranch = branchName; + const fired = branchChanged.promise; + await fs.rename(headLock, path.join(gitDir, "HEAD")); + await fired; + expectedBranch = null; }; await switchTo("first"); diff --git a/packages/coding-agent/test/streaming-edit-abort.test.ts b/packages/coding-agent/test/streaming-edit-abort.test.ts index 1fd4ef527..478076ef7 100644 --- a/packages/coding-agent/test/streaming-edit-abort.test.ts +++ b/packages/coding-agent/test/streaming-edit-abort.test.ts @@ -98,7 +98,7 @@ async function createSession( const sessionManager = SessionManager.inMemory(tempDir); const settings = Settings.isolated({ "edit.streamingAbort": true }); - const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db")); + const authStorage = await AuthStorage.create(":memory:"); authStorage.setRuntimeApiKey("anthropic", "test-key"); const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml")); diff --git a/packages/coding-agent/test/streaming-output-scrollback.test.ts b/packages/coding-agent/test/streaming-output-scrollback.test.ts index 94e580f5d..873277f6d 100644 --- a/packages/coding-agent/test/streaming-output-scrollback.test.ts +++ b/packages/coding-agent/test/streaming-output-scrollback.test.ts @@ -127,14 +127,11 @@ function streamingPrefixes(text: string, step: number): string[] { return prefixes; } -async function settleFrame(term: VirtualTerminal): Promise { - // These integration tests use the production TUI scheduler rather than the - // drainable unit-test scheduler, so the frame timer must elapse for the real - // differential renderer to write to the Ghostty-backed terminal. - const nextTick = Promise.withResolvers(); - process.nextTick(nextTick.resolve); - await nextTick.promise; - await Bun.sleep(45); +async function settleFrame(term: VirtualTerminal, scheduler: DrainableScheduler): Promise { + // Keep the real Ghostty-backed terminal and differential renderer, but drive + // frame scheduling explicitly: these regressions assert painted scrollback, + // not the production scheduler's ~33 ms cadence. + scheduler.flush(); await term.flush(); } @@ -504,7 +501,8 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = stubStdoutRows(rows); const term = new VirtualTerminal(60, rows); Object.defineProperty(term, "isNativeViewportAtBottom", { configurable: true, value: () => undefined }); - const tui = new TUI(term); + const scheduler = makeDrainableScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const transcript = new TranscriptContainer(); const assistant = new AssistantMessageComponent(undefined, false); transcript.addChild(assistant); @@ -525,14 +523,14 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = try { tui.start(); - await settleFrame(term); + await settleFrame(term, scheduler); for (const partialThinking of streamingPrefixes(thinking, 300)) { assistant.updateContent(makeAssistantMessage([{ type: "thinking", thinking: partialThinking }]), { transient: true, }); tui.requestRender(); - await settleFrame(term); + await settleFrame(term, scheduler); } for (const partialText of streamingPrefixes(text, 300)) { @@ -544,7 +542,7 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = { transient: true }, ); tui.requestRender(); - await settleFrame(term); + await settleFrame(term, scheduler); } const midStreamRows = plainScrollBuffer(term); @@ -555,7 +553,7 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = assistant.markTranscriptBlockFinalized(); for (let i = 0; i < 2; i++) { tui.requestRender(); - await settleFrame(term); + await settleFrame(term, scheduler); } const finalRows = plainScrollBuffer(term); @@ -658,7 +656,8 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = const rows = 8; stubStdoutRows(rows); const term = new VirtualTerminal(60, rows); - const tui = new TUI(term); + const scheduler = makeDrainableScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); const transcript = new TranscriptContainer(); const component = new ToolExecutionComponent( "eval", @@ -674,13 +673,13 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = try { tui.start(); - await settleFrame(term); + await settleFrame(term, scheduler); component.updateResult(makeEvalProbeResult(output, "running"), true); component.setExpanded(true); for (let i = 0; i < 3; i++) { tui.requestRender(); - await settleFrame(term); + await settleFrame(term, scheduler); } const midRunRows = plainScrollBuffer(term); @@ -689,7 +688,7 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = component.updateResult(makeEvalProbeResult(output, "complete"), false); for (let i = 0; i < 2; i++) { tui.requestRender(); - await settleFrame(term); + await settleFrame(term, scheduler); } const settledRows = plainScrollBuffer(term); @@ -701,7 +700,7 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = for (let i = 0; i < 2; i++) { tui.requestRender(); - await settleFrame(term); + await settleFrame(term, scheduler); } const repeatedRows = plainScrollBuffer(term); diff --git a/packages/coding-agent/test/system-prompt-model.test.ts b/packages/coding-agent/test/system-prompt-model.test.ts index b057e385b..ed07a7dd9 100644 --- a/packages/coding-agent/test/system-prompt-model.test.ts +++ b/packages/coding-agent/test/system-prompt-model.test.ts @@ -33,37 +33,39 @@ async function expectPromptDateFromStartupTimezone(options: { const scenarioPath = path.join(options.tempDir, "prompt-date-timezone.test.ts"); await Bun.write( scenarioPath, - `import { expect, it, setSystemTime } from "bun:test"; + `import { setSystemTime } from "bun:test"; import { buildSystemPrompt } from ${JSON.stringify(path.resolve(import.meta.dir, "../src/system-prompt.ts"))}; -it("renders the prompt date in the startup timezone", async () => { - setSystemTime(new Date(process.env.OMP_TEST_NOW!)); - try { - const { systemPrompt } = await buildSystemPrompt({ - cwd: process.cwd(), - contextFiles: [], - skills: [], - rules: [], - toolNames: [], - workspaceTree: { - rootPath: process.cwd(), - rendered: "", - truncated: false, - totalLines: 0, - agentsMdFiles: [], - }, - activeRepoContext: null, - }); - const rendered = systemPrompt.join("\\n\\n"); - expect(rendered).toContain(\`Today: \${process.env.OMP_EXPECTED_DATE}\`); - expect(rendered).not.toContain(\`Today: \${process.env.OMP_REJECTED_DATE}\`); - } finally { - setSystemTime(); +setSystemTime(new Date(process.env.OMP_TEST_NOW!)); +try { + const { systemPrompt } = await buildSystemPrompt({ + cwd: process.cwd(), + contextFiles: [], + skills: [], + rules: [], + toolNames: [], + workspaceTree: { + rootPath: process.cwd(), + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], + }, + activeRepoContext: null, + }); + const rendered = systemPrompt.join("\\n\\n"); + if (!rendered.includes(\`Today: \${process.env.OMP_EXPECTED_DATE}\`)) { + throw new Error(\`Prompt did not contain expected local date:\\n\${rendered}\`); } -}); + if (rendered.includes(\`Today: \${process.env.OMP_REJECTED_DATE}\`)) { + throw new Error(\`Prompt contained rejected UTC date:\\n\${rendered}\`); + } +} finally { + setSystemTime(); +} `, ); - const child = Bun.spawn([process.execPath, "test", scenarioPath], { + const child = Bun.spawn([process.execPath, scenarioPath], { cwd: options.tempDir, env: { ...process.env, @@ -81,8 +83,7 @@ it("renders the prompt date in the startup timezone", async () => { new Response(child.stderr).text(), child.exited, ]); - expect(`${stdout}\n${stderr}`).toContain("1 pass"); - expect(exitCode).toBe(0); + expect(exitCode, `${stdout}\n${stderr}`).toBe(0); } describe("system prompt model identifier", () => { diff --git a/packages/coding-agent/test/task/executor-async-quiescence.test.ts b/packages/coding-agent/test/task/executor-async-quiescence.test.ts index ca95f5f5f..d5b376ae8 100644 --- a/packages/coding-agent/test/task/executor-async-quiescence.test.ts +++ b/packages/coding-agent/test/task/executor-async-quiescence.test.ts @@ -311,6 +311,7 @@ describe("runSubprocess async quiescence fresh-yield contract", () => { const lateJobGate = Promise.withResolvers(); const manager = new AsyncJobManager({}); AsyncJobManager.setInstance(manager); + const cleanupGraceMs = 0; let lateJobId: string | undefined; let deferredCleanup: Promise | undefined; const harness = createAsyncSession( @@ -348,18 +349,21 @@ describe("runSubprocess async quiescence fresh-yield contract", () => { index: 0, id: "cleanup-timeout", keepAlive: false, + cleanupGraceMs, onCleanupDeferred: completion => { deferredCleanup = completion; }, }); await abortStarted.promise; + // abortStarted synchronizes with the in-flight cleanup; a zero grace + // exercises the deadline/deferred-ownership transition without sleeping. const result = await run; expect(result.exitCode).toBe(1); expect(result.aborted).toBe(true); - expect(result.abortReason).toBe("cleanup exceeded 10000 ms"); + expect(result.abortReason).toBe("cleanup exceeded 0 ms"); expect(result.error).toBe( - "Task aborted. Cleanup did not finish within 10000 ms. This task was not isolated, so its changes may remain in the working directory.", + "Task aborted. Cleanup did not finish within 0 ms. This task was not isolated, so its changes may remain in the working directory.", ); expect(result.output).toContain("yielded output"); expect(result.usage?.totalTokens).toBe(7); diff --git a/packages/coding-agent/test/task/isolation-runner.test.ts b/packages/coding-agent/test/task/isolation-runner.test.ts index 591d396ec..8fea9fe53 100644 --- a/packages/coding-agent/test/task/isolation-runner.test.ts +++ b/packages/coding-agent/test/task/isolation-runner.test.ts @@ -48,26 +48,25 @@ async function seedFooRepo(finalContent: string): Promise<{ repoRoot: string; pa const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-isolation-merge-")); tempRoots.push(repoRoot); - await git(repoRoot, "init"); + await git(repoRoot, "init", "-q", "-b", "main"); await git(repoRoot, "config", "user.email", "repro@example.com"); await git(repoRoot, "config", "user.name", "Repro"); - await Bun.write(path.join(repoRoot, "foo.txt"), "old\n"); + await Bun.write(path.join(repoRoot, "foo.txt"), finalContent); await git(repoRoot, "add", "foo.txt"); - await git(repoRoot, "commit", "-m", "base"); - await Bun.write(path.join(repoRoot, "foo.txt"), "new\n"); - await git(repoRoot, "commit", "-am", "change to new"); + await git(repoRoot, "commit", "-q", "-m", "fixture state"); + // The merge contract needs a valid old→new patch, not a second commit and + // diff-tree subprocess for every scenario. const patchPath = path.join(repoRoot, "task.patch"); - const patchText = await git(repoRoot, "diff-tree", "--binary", "--full-index", "--no-commit-id", "-p", "HEAD"); - await Bun.write(patchPath, patchText); - - if (finalContent !== "new\n") { - await git(repoRoot, "reset", "--hard", "HEAD~1"); - if (finalContent !== "old\n") { - await Bun.write(path.join(repoRoot, "foo.txt"), finalContent); - await git(repoRoot, "commit", "-am", "diverge"); - } - } + await Bun.write( + patchPath, + "diff --git a/foo.txt b/foo.txt\n" + + "--- a/foo.txt\n" + + "+++ b/foo.txt\n" + + "@@ -1 +1 @@\n" + + "-old\n" + + "+new\n", + ); return { repoRoot, patchPath }; } diff --git a/packages/coding-agent/test/task/worktree.test.ts b/packages/coding-agent/test/task/worktree.test.ts index 4a38b2109..a058afe90 100644 --- a/packages/coding-agent/test/task/worktree.test.ts +++ b/packages/coding-agent/test/task/worktree.test.ts @@ -39,20 +39,11 @@ async function runGit(repo: string, args: string[]): Promise { return stdout.trim(); } -async function createGitRepo(): Promise<{ baseBranch: string; repo: string }> { +async function createGitRepo(): Promise { const repo = await fs.mkdtemp(path.join(os.tmpdir(), "omp-worktree-")); tempDirs.push(repo); - await runGit(repo, ["init"]); - await runGit(repo, ["config", "user.email", "test@example.com"]); - await runGit(repo, ["config", "user.name", "Test User"]); - await fs.writeFile(path.join(repo, "merged.txt"), "base version\n"); - await fs.writeFile(path.join(repo, "staged.txt"), "base staged\n"); - await runGit(repo, ["add", "."]); - await runGit(repo, ["commit", "-m", "initial"]); - return { - baseBranch: await runGit(repo, ["branch", "--show-current"]), - repo, - }; + await runGit(repo, ["init", "-q", "-b", "main"]); + return repo; } afterEach(async () => { @@ -502,12 +493,12 @@ describe("worktree isolation helpers", () => { describe("getRepoRoot", () => { it("returns the git root for a plain git checkout", async () => { - const { repo } = await createGitRepo(); + const repo = await createGitRepo(); expect(await getRepoRoot(repo)).toBe(repo); }); it("returns the git root for a colocated jj-git workspace", async () => { - const { repo } = await createGitRepo(); + const repo = await createGitRepo(); await fs.mkdir(path.join(repo, ".jj", "repo", "store"), { recursive: true }); expect(await getRepoRoot(repo)).toBe(repo); }); @@ -530,7 +521,7 @@ describe("getRepoRoot", () => { // `git.repo.root(inner)` walks up and finds the outer .git — without // the pure-jj check running first, isolation would silently target the // surrounding git tree behind jj's back. - const { repo: outer } = await createGitRepo(); + const outer = await createGitRepo(); const inner = path.join(outer, "nested-jj"); await fs.mkdir(path.join(inner, ".jj", "repo", "store"), { recursive: true }); @@ -549,8 +540,6 @@ describe("getRepoRoot", () => { const inner = path.join(outer, "vendor"); await fs.mkdir(inner, { recursive: true }); await runGit(inner, ["init", "-q", "-b", "main"]); - await runGit(inner, ["config", "user.email", "test@example.com"]); - await runGit(inner, ["config", "user.name", "Test"]); expect(await getRepoRoot(inner)).toBe(inner); }); @@ -815,34 +804,46 @@ describe("detachGitDir", () => { }); describe("applyNestedPatches", () => { + const nestedRel = "sub"; + let fixtureParent: string; let parentRepo: string; - let nestedRel: string; let nestedDir: string; - beforeEach(async () => { - parentRepo = await fs.mkdtemp(path.join(os.tmpdir(), "omp-nested-apply-")); - await runGit(parentRepo, ["init", "-q", "-b", "main"]); - await runGit(parentRepo, ["config", "user.email", "test@example.com"]); - await runGit(parentRepo, ["config", "user.name", "Test User"]); - await fs.writeFile(path.join(parentRepo, ".gitignore"), "sub/\n"); - await runGit(parentRepo, ["add", "."]); - await runGit(parentRepo, ["commit", "-q", "-m", "parent-init"]); + beforeAll(async () => { + fixtureParent = await fs.mkdtemp(path.join(os.tmpdir(), "omp-nested-fixture-")); + await runGit(fixtureParent, ["init", "-q", "-b", "main"]); + await runGit(fixtureParent, ["config", "user.email", "test@example.com"]); + await runGit(fixtureParent, ["config", "user.name", "Test User"]); + await fs.writeFile(path.join(fixtureParent, ".gitignore"), "sub/\n"); + await runGit(fixtureParent, ["add", "."]); + await runGit(fixtureParent, ["commit", "-q", "-m", "parent-init"]); - nestedRel = "sub"; + const fixtureNested = path.join(fixtureParent, nestedRel); + await fs.mkdir(fixtureNested, { recursive: true }); + await runGit(fixtureNested, ["init", "-q", "-b", "main"]); + await runGit(fixtureNested, ["config", "user.email", "test@example.com"]); + await runGit(fixtureNested, ["config", "user.name", "Test User"]); + await fs.writeFile(path.join(fixtureNested, "file.txt"), "v1\n"); + await runGit(fixtureNested, ["add", "."]); + await runGit(fixtureNested, ["commit", "-q", "-m", "nested-init"]); + }); + + beforeEach(async () => { + // The tests mutate independent copies of one immutable repository pair; + // rebuilding both Git histories per case only tests `git init`. + parentRepo = await fs.mkdtemp(path.join(os.tmpdir(), "omp-nested-apply-")); + await fs.cp(fixtureParent, parentRepo, { recursive: true }); nestedDir = path.join(parentRepo, nestedRel); - await fs.mkdir(nestedDir, { recursive: true }); - await runGit(nestedDir, ["init", "-q", "-b", "main"]); - await runGit(nestedDir, ["config", "user.email", "test@example.com"]); - await runGit(nestedDir, ["config", "user.name", "Test User"]); - await fs.writeFile(path.join(nestedDir, "file.txt"), "v1\n"); - await runGit(nestedDir, ["add", "."]); - await runGit(nestedDir, ["commit", "-q", "-m", "nested-init"]); }); afterEach(async () => { await removeWithRetries(parentRepo); }); + afterAll(async () => { + await removeWithRetries(fixtureParent); + }); + it("does not fold pre-existing dirty nested-repo state into the agent commit", async () => { // User has unrelated work-in-progress in the nested repo before the agent runs. await fs.writeFile(path.join(nestedDir, "other.txt"), "user wip\n"); @@ -927,39 +928,43 @@ describe("applyNestedPatches", () => { }); describe("commitToBranch preserves agent commits", () => { + let fixtureRepo: string; let parent: string; let isolation: string; - async function gitr(repo: string, args: string[]): Promise { - return runGit(repo, args); - } - - beforeEach(async () => { - parent = await fs.mkdtemp(path.join(os.tmpdir(), "omp-commit-parent-")); - isolation = await fs.mkdtemp(path.join(os.tmpdir(), "omp-commit-iso-")); - await gitr(parent, ["init", "-q", "-b", "main"]); - await gitr(parent, ["config", "user.email", "user@example.com"]); - await gitr(parent, ["config", "user.name", "Parent User"]); + beforeAll(async () => { + fixtureRepo = await fs.mkdtemp(path.join(os.tmpdir(), "omp-commit-fixture-")); + await runGit(fixtureRepo, ["init", "-q", "-b", "main"]); + await runGit(fixtureRepo, ["config", "user.email", "test@example.com"]); + await runGit(fixtureRepo, ["config", "user.name", "Test User"]); await fs.writeFile( - path.join(parent, "EXP_CLEAN_COMMIT.txt"), + path.join(fixtureRepo, "EXP_CLEAN_COMMIT.txt"), "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n", ); - await gitr(parent, ["add", "."]); - await gitr(parent, ["commit", "-q", "-m", "add clean test fixture"]); + await runGit(fixtureRepo, ["add", "."]); + await runGit(fixtureRepo, ["commit", "-q", "-m", "add clean test fixture"]); + }); - // Simulate copy-on-write isolation: a real local clone so the agent's - // commit objects live in `isolation/.git`, just like the overlay/rcopy - // isolation backends would arrange them at runtime. - await fs.rm(isolation, { recursive: true, force: true }); - await gitr(parent, ["clone", "-q", "--no-hardlinks", "--local", parent, isolation]); - await gitr(isolation, ["config", "user.email", "agent@example.com"]); - await gitr(isolation, ["config", "user.name", "Agent User"]); + beforeEach(async () => { + // Each test needs separate object databases, not a fresh Git history. + // Copying the immutable tiny fixture preserves the isolation contract while + // avoiding two init/config/add/commit/clone sequences per case. + parent = await fs.mkdtemp(path.join(os.tmpdir(), "omp-commit-parent-")); + isolation = await fs.mkdtemp(path.join(os.tmpdir(), "omp-commit-iso-")); + await Promise.all([ + fs.cp(fixtureRepo, parent, { recursive: true }), + fs.cp(fixtureRepo, isolation, { recursive: true }), + ]); }); afterEach(async () => { await Promise.all([removeWithRetries(parent), removeWithRetries(isolation)]); }); + afterAll(async () => { + await removeWithRetries(fixtureRepo); + }); + // Reproduces issue #3842: agent commits with a specific message inside // isolation; the merged commit on the parent branch must keep that exact // message instead of an AI-generated summary. @@ -970,9 +975,9 @@ describe("commitToBranch preserves agent commits", () => { path.join(isolation, "EXP_CLEAN_COMMIT.txt"), "line1\nline2\nline3\nline4\nLINE5-AGENT-WITH-MESSAGE\nline6\nline7\nline8\nline9\nline10\n", ); - await gitr(isolation, ["add", "EXP_CLEAN_COMMIT.txt"]); + await runGit(isolation, ["add", "EXP_CLEAN_COMMIT.txt"]); const agentMessage = "fix(test): agent committed with specific message for preservation check"; - await gitr(isolation, ["commit", "-q", "-m", agentMessage]); + await runGit(isolation, ["commit", "-q", "-m", agentMessage]); const taskId = "preservation-check"; const aiMessage = vi.fn(async () => "fix: update line5 in clean commit example"); @@ -984,7 +989,7 @@ describe("commitToBranch preserves agent commits", () => { // message is taken verbatim. expect(aiMessage).not.toHaveBeenCalled(); - const branchSubject = await gitr(parent, ["log", "-1", "--pretty=%s", result!.branchName!]); + const branchSubject = await runGit(parent, ["log", "-1", "--pretty=%s", result!.branchName!]); expect(branchSubject).toBe(agentMessage); const merge = await mergeTaskBranches(parent, [ @@ -993,7 +998,7 @@ describe("commitToBranch preserves agent commits", () => { expect(merge.failed).toEqual([]); expect(merge.merged).toEqual([result!.branchName!]); - const headSubject = await gitr(parent, ["log", "-1", "--pretty=%s"]); + const headSubject = await runGit(parent, ["log", "-1", "--pretty=%s"]); expect(headSubject).toBe(agentMessage); }); @@ -1001,11 +1006,11 @@ describe("commitToBranch preserves agent commits", () => { const baseline = await captureBaseline(parent); await fs.writeFile(path.join(isolation, "a.txt"), "alpha\n"); - await gitr(isolation, ["add", "a.txt"]); - await gitr(isolation, ["commit", "-q", "-m", "feat: add alpha file"]); + await runGit(isolation, ["add", "a.txt"]); + await runGit(isolation, ["commit", "-q", "-m", "feat: add alpha file"]); await fs.writeFile(path.join(isolation, "b.txt"), "beta\n"); - await gitr(isolation, ["add", "b.txt"]); - await gitr(isolation, ["commit", "-q", "-m", "test: add beta coverage"]); + await runGit(isolation, ["add", "b.txt"]); + await runGit(isolation, ["commit", "-q", "-m", "test: add beta coverage"]); const result = await commitToBranch(isolation, baseline, "multi", undefined); expect(result?.branchName).toBe("omp/task/multi"); @@ -1015,7 +1020,7 @@ describe("commitToBranch preserves agent commits", () => { ]); expect(merge).toEqual({ failed: [], merged: ["omp/task/multi"] }); - const subjects = (await gitr(parent, ["log", "-2", "--pretty=%s"])).split("\n"); + const subjects = (await runGit(parent, ["log", "-2", "--pretty=%s"])).split("\n"); expect(subjects).toEqual(["test: add beta coverage", "feat: add alpha file"]); }); @@ -1023,8 +1028,8 @@ describe("commitToBranch preserves agent commits", () => { const baseline = await captureBaseline(parent); await fs.writeFile(path.join(isolation, "a.txt"), "alpha\n"); - await gitr(isolation, ["add", "a.txt"]); - await gitr(isolation, ["commit", "-q", "-m", "feat: add alpha file"]); + await runGit(isolation, ["add", "a.txt"]); + await runGit(isolation, ["commit", "-q", "-m", "feat: add alpha file"]); // Uncommitted change on top of the agent's commit — should land as one // extra commit with the AI-generated message, NOT silently dropped. await fs.writeFile(path.join(isolation, "b.txt"), "beta\n"); @@ -1034,16 +1039,16 @@ describe("commitToBranch preserves agent commits", () => { expect(result?.branchName).toBe("omp/task/leftover"); expect(aiMessage).toHaveBeenCalledTimes(1); - const subjects = (await gitr(parent, ["log", "-2", "--pretty=%s", result!.branchName!])).split("\n"); + const subjects = (await runGit(parent, ["log", "-2", "--pretty=%s", result!.branchName!])).split("\n"); expect(subjects).toEqual(["chore: leftover beta wip", "feat: add alpha file"]); }); it("filters baseline WIP when the agent commits with git add -A", async () => { await fs.writeFile(path.join(parent, "staged.txt"), "baseline staged wip\n"); - await gitr(parent, ["add", "staged.txt"]); + await runGit(parent, ["add", "staged.txt"]); await fs.writeFile(path.join(parent, "user-wip.txt"), "baseline untracked wip\n"); await fs.writeFile(path.join(isolation, "staged.txt"), "baseline staged wip\n"); - await gitr(isolation, ["add", "staged.txt"]); + await runGit(isolation, ["add", "staged.txt"]); await fs.writeFile(path.join(isolation, "user-wip.txt"), "baseline untracked wip\n"); const baseline = await captureBaseline(parent); @@ -1051,16 +1056,16 @@ describe("commitToBranch preserves agent commits", () => { path.join(isolation, "EXP_CLEAN_COMMIT.txt"), "line1\nline2\nline3\nline4\nLINE5-AGENT-WITH-MESSAGE\nline6\nline7\nline8\nline9\nline10\n", ); - await gitr(isolation, ["add", "-A"]); + await runGit(isolation, ["add", "-A"]); const agentMessage = "fix(test): preserve message without baseline wip"; - await gitr(isolation, ["commit", "-q", "-m", agentMessage]); + await runGit(isolation, ["commit", "-q", "-m", agentMessage]); const aiMessage = vi.fn(async () => "fix: generated fallback"); const result = await commitToBranch(isolation, baseline, "dirty-baseline", undefined, aiMessage); expect(result?.branchName).toBe("omp/task/dirty-baseline"); expect(aiMessage).not.toHaveBeenCalled(); - const branchFiles = (await gitr(parent, ["show", "--name-only", "--pretty=format:", result!.branchName!])) + const branchFiles = (await runGit(parent, ["show", "--name-only", "--pretty=format:", result!.branchName!])) .split("\n") .filter(Boolean); expect(branchFiles).toEqual(["EXP_CLEAN_COMMIT.txt"]); @@ -1071,8 +1076,8 @@ describe("commitToBranch preserves agent commits", () => { expect(merge).toEqual({ failed: [], merged: ["omp/task/dirty-baseline"] }); const [headSubject, status, fixture] = await Promise.all([ - gitr(parent, ["log", "-1", "--pretty=%s"]), - gitr(parent, ["status", "--porcelain=v1"]), + runGit(parent, ["log", "-1", "--pretty=%s"]), + runGit(parent, ["status", "--porcelain=v1"]), fs.readFile(path.join(parent, "EXP_CLEAN_COMMIT.txt"), "utf8"), ]); expect(headSubject).toBe(agentMessage); @@ -1101,8 +1106,8 @@ describe("commitToBranch preserves agent commits", () => { const agentLines = parentLines.slice(); agentLines[4] = "LINE5-AGENT-EDIT"; await fs.writeFile(path.join(isolation, "EXP_CLEAN_COMMIT.txt"), agentLines.join("\n")); - await gitr(isolation, ["add", "EXP_CLEAN_COMMIT.txt"]); - await gitr(isolation, ["commit", "-q", "-m", "agent: edit line 5"]); + await runGit(isolation, ["add", "EXP_CLEAN_COMMIT.txt"]); + await runGit(isolation, ["commit", "-q", "-m", "agent: edit line 5"]); const taskId = "dirty-parent-committed-agent"; const result = await commitToBranch(isolation, baseline, taskId, undefined); @@ -1126,7 +1131,7 @@ describe("commitToBranch preserves agent commits", () => { expect(result?.branchName).toBe("omp/task/nocommit"); expect(aiMessage).toHaveBeenCalledTimes(1); - const branchSubject = await gitr(parent, ["log", "-1", "--pretty=%s", result!.branchName!]); + const branchSubject = await runGit(parent, ["log", "-1", "--pretty=%s", result!.branchName!]); expect(branchSubject).toBe("feat: add alpha"); }); @@ -1153,14 +1158,12 @@ describe("commitToBranch preserves agent commits", () => { const head = Array.from({ length: 40 }, (_, i) => `# line ${i + 1}\n`).join(""); await fs.mkdir(path.join(parent, "src"), { recursive: true }); await fs.writeFile(path.join(parent, fixture), head); - await gitr(parent, ["add", "."]); - await gitr(parent, ["commit", "-q", "-m", "add fixture"]); + await runGit(parent, ["add", "."]); + await runGit(parent, ["commit", "-q", "-m", "add fixture"]); - // Isolation must be re-cloned so the fixture is present in HEAD. + // Refresh the independent isolation object database at the new HEAD. await fs.rm(isolation, { recursive: true, force: true }); - await gitr(parent, ["clone", "-q", "--no-hardlinks", "--local", parent, isolation]); - await gitr(isolation, ["config", "user.email", "agent@example.com"]); - await gitr(isolation, ["config", "user.name", "Agent User"]); + await fs.cp(parent, isolation, { recursive: true }); // Parent WIP: change line 10 (unstaged edit to an existing tracked file). const wipLines = head.split("\n"); @@ -1177,7 +1180,7 @@ describe("commitToBranch preserves agent commits", () => { const result = await commitToBranch(isolation, baseline, "wip-tracked-file", undefined); expect(result?.branchName).toBe("omp/task/wip-tracked-file"); - const branchDiff = await gitr(parent, ["show", "--pretty=format:", result!.branchName!]); + const branchDiff = await runGit(parent, ["show", "--pretty=format:", result!.branchName!]); expect(branchDiff).toContain("+# line 30 def new_func()"); // --3way must subtract the WIP change from the commit; only the // agent's line 30 edit belongs on the task branch. @@ -1199,7 +1202,7 @@ describe("commitToBranch preserves agent commits", () => { const result = await commitToBranch(isolation, baseline, "wip-untracked", undefined); expect(result?.branchName).toBe("omp/task/wip-untracked"); - const branchDiff = await gitr(parent, ["show", "--pretty=format:", result!.branchName!]); + const branchDiff = await runGit(parent, ["show", "--pretty=format:", result!.branchName!]); expect(branchDiff).toContain("new file mode"); expect(branchDiff).toContain("src/new.py"); expect(branchDiff).toContain("+WIP header"); @@ -1209,9 +1212,9 @@ describe("commitToBranch preserves agent commits", () => { it("commits a staged-new WIP file that the agent modifies inside isolation", async () => { // Parent WIP: stage a new file that isn't yet in HEAD. await fs.writeFile(path.join(parent, "notes.md"), "l1\nl2\nl3\n"); - await gitr(parent, ["add", "notes.md"]); + await runGit(parent, ["add", "notes.md"]); await fs.copyFile(path.join(parent, "notes.md"), path.join(isolation, "notes.md")); - await gitr(isolation, ["add", "notes.md"]); + await runGit(isolation, ["add", "notes.md"]); // Agent edits the staged-new file. await fs.writeFile(path.join(isolation, "notes.md"), "l1\nl2 agent\nl3\n"); @@ -1221,7 +1224,7 @@ describe("commitToBranch preserves agent commits", () => { const result = await commitToBranch(isolation, baseline, "wip-staged-new", undefined); expect(result?.branchName).toBe("omp/task/wip-staged-new"); - const branchDiff = await gitr(parent, ["show", "--pretty=format:", result!.branchName!]); + const branchDiff = await runGit(parent, ["show", "--pretty=format:", result!.branchName!]); expect(branchDiff).toContain("new file mode"); expect(branchDiff).toContain("notes.md"); expect(branchDiff).toContain("+l2 agent"); @@ -1233,12 +1236,10 @@ describe("commitToBranch preserves agent commits", () => { await fs.mkdir(path.join(parent, "src"), { recursive: true }); await fs.writeFile(path.join(parent, "src/wanted.py"), "unchanged\n"); await fs.writeFile(path.join(parent, "src/wip-only.py"), "unchanged\n"); - await gitr(parent, ["add", "."]); - await gitr(parent, ["commit", "-q", "-m", "seed"]); + await runGit(parent, ["add", "."]); + await runGit(parent, ["commit", "-q", "-m", "seed"]); await fs.rm(isolation, { recursive: true, force: true }); - await gitr(parent, ["clone", "-q", "--no-hardlinks", "--local", parent, isolation]); - await gitr(isolation, ["config", "user.email", "agent@example.com"]); - await gitr(isolation, ["config", "user.name", "Agent User"]); + await fs.cp(parent, isolation, { recursive: true }); await fs.writeFile(path.join(parent, "src/wip-only.py"), "wip edit\n"); await fs.writeFile(path.join(parent, "src/wanted.py"), "wip mixed\n"); @@ -1256,7 +1257,7 @@ describe("commitToBranch preserves agent commits", () => { const result = await commitToBranch(isolation, baseline, "wip-filter", undefined); expect(result?.branchName).toBe("omp/task/wip-filter"); - const files = (await gitr(parent, ["show", "--name-only", "--pretty=format:", result!.branchName!])) + const files = (await runGit(parent, ["show", "--name-only", "--pretty=format:", result!.branchName!])) .split("\n") .filter(Boolean); // Only the agent-touched file lands on the branch — no WIP-only files. @@ -1281,8 +1282,8 @@ describe("commitToBranch preserves agent commits", () => { // baseline dirty tree. await fs.mkdir(path.join(isolation, "src"), { recursive: true }); await fs.copyFile(path.join(parent, "src/new.py"), path.join(isolation, "src/new.py")); - await gitr(isolation, ["add", "-A"]); - await gitr(isolation, ["commit", "-q", "-m", "chore: capture baseline"]); + await runGit(isolation, ["add", "-A"]); + await runGit(isolation, ["commit", "-q", "-m", "chore: capture baseline"]); // Real, uncommitted agent edit on top of the WIP file. await fs.writeFile(path.join(isolation, "src/new.py"), "WIP header\nagent-edit\n"); @@ -1292,7 +1293,7 @@ describe("commitToBranch preserves agent commits", () => { const result = await commitToBranch(isolation, baseline, "wip-only-commit", undefined); expect(result?.branchName).toBe("omp/task/wip-only-commit"); - const branchDiff = await gitr(parent, ["show", "--pretty=format:", result!.branchName!]); + const branchDiff = await runGit(parent, ["show", "--pretty=format:", result!.branchName!]); expect(branchDiff).toContain("new file mode"); expect(branchDiff).toContain("src/new.py"); expect(branchDiff).toContain("+WIP header"); diff --git a/packages/coding-agent/test/telemetry-export.test.ts b/packages/coding-agent/test/telemetry-export.test.ts index a30fbd030..46d67c15d 100644 --- a/packages/coding-agent/test/telemetry-export.test.ts +++ b/packages/coding-agent/test/telemetry-export.test.ts @@ -94,44 +94,31 @@ describe("initTelemetryExport gating", () => { }); describe("initTelemetryExport signals export path", () => { - it("registers a provider and exports spans to an OTLP/proto receiver", async () => { - // Run in a subprocess: initTelemetryExport() registers a process-global - // provider, so exercising the positive path in-process would leak that - // singleton into every later test. The probe stands up its own loopback - // receiver and exits 0 only when a protobuf trace export actually lands. - const probe = fileURLToPath(new URL("./otel-export-probe.ts", import.meta.url)); - const proc = Bun.spawn([process.execPath, probe], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - expect(await proc.exited).toBe(0); - }, 20_000); + it("exports every OTLP/proto signal and merged resource attributes", async () => { + // Positive initialization registers process-global providers, so each + // scenario still runs in its own process. Starting the independent probes + // together avoids serially paying three Bun startup and exporter-flush waits. + const probes = [ + ["traces", "./otel-export-probe.ts"], + ["logs and metrics", "./otel-signals-probe.ts"], + ["resource attributes", "./otel-resource-probe.ts"], + ] as const; + const results = await Promise.all( + probes.map(async ([name, relativePath]) => { + const probe = fileURLToPath(new URL(relativePath, import.meta.url)); + const proc = Bun.spawn([process.execPath, probe], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + return [name, await proc.exited] as const; + }), + ); - it("exports log records and metrics to OTLP/proto receivers", async () => { - // Same subprocess isolation as the trace probe: the logs/metrics probe - // drives the bridged logger and the agent telemetry metric hooks, then - // asserts protobuf POSTs landed at both /v1/logs and /v1/metrics. - const probe = fileURLToPath(new URL("./otel-signals-probe.ts", import.meta.url)); - const proc = Bun.spawn([process.execPath, probe], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", + expect(Object.fromEntries(results)).toEqual({ + traces: 0, + "logs and metrics": 0, + "resource attributes": 0, }); - expect(await proc.exited).toBe(0); - }, 20_000); - - it("merges OTEL_RESOURCE_ATTRIBUTES into the exported resource", async () => { - // Regression for #7134: the resource only carried service.name, so - // OTEL_RESOURCE_ATTRIBUTES entries never reached the collector. The probe - // asserts the merged attributes land and that OTEL_SERVICE_NAME wins - // service.name over an OTEL_RESOURCE_ATTRIBUTES entry. - const probe = fileURLToPath(new URL("./otel-resource-probe.ts", import.meta.url)); - const proc = Bun.spawn([process.execPath, probe], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", - }); - expect(await proc.exited).toBe(0); }, 20_000); }); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 260f23fac..a529410e0 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -2061,7 +2061,7 @@ function b() { // Emit well past the ~50KB inline window across many lines so the // output is genuinely window-truncated (not merely column-capped), // which is what allocates the spill artifact. - command: "seq 1 30000", + command: "seq 1 15000", }); const artifactId = result.details?.meta?.truncation?.artifactId; diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index 740f3afee..e1a2c7715 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -8,7 +8,7 @@ import type { ExtensionAskDialogResult, ExtensionUISelectItem, } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; -import { getThemeByName, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, initTheme, type Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { AskTool, askToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/ask"; import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; @@ -78,8 +78,13 @@ function selectItemLabel(option: ExtensionUISelectItem | undefined): string | un return typeof option === "string" ? option : option?.label; } +let darkTheme: Theme; + beforeAll(async () => { await initTheme(false); + const loadedTheme = await getThemeByName("dark"); + if (!loadedTheme) throw new Error("Expected dark theme"); + darkTheme = loadedTheme; }); describe("AskTool cancellation", () => { @@ -188,8 +193,6 @@ describe("AskTool cancellation", () => { options: ExtensionUISelectItem[], dialogOptions?: { initialIndex?: number; timeout?: number; onTimeout?: () => void }, ) => { - const timeout = dialogOptions?.timeout ?? 1; - await Bun.sleep(timeout + 5); dialogOptions?.onTimeout?.(); const selected = options[dialogOptions?.initialIndex ?? 0]; return typeof selected === "string" ? selected : selected?.label; @@ -238,8 +241,6 @@ describe("AskTool cancellation", () => { const abort = vi.fn(); const context = createContext({ select: async (_prompt, _options, dialogOptions) => { - const timeout = dialogOptions?.timeout ?? 1; - await Bun.sleep(timeout + 5); dialogOptions?.onTimeout?.(); return undefined; }, @@ -462,7 +463,7 @@ describe("AskTool option descriptions", () => { }); it("renders descriptions under labels in ask call previews", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; const rendered = askToolRenderer.renderCall( { question: "How should authentication continue?", @@ -939,7 +940,7 @@ describe("AskTool custom input", () => { expect(result.content[0].text).toContain("alpha"); expect(result.content[0].text).toContain("custom detail"); - const theme = await getThemeByName("dark"); + const theme = darkTheme; const rendered = askToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme!); const renderedText = stripAnsi(rendered.render(120).join("\n")); expect(renderedText).toContain("alpha"); @@ -1026,7 +1027,7 @@ describe("AskTool multiline custom input rendering", () => { expect(result.details?.customInput).toBe(multilineText); - const theme = await getThemeByName("dark"); + const theme = darkTheme; const rendered = askToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme!); const renderedText = stripAnsi(rendered.render(120).join("\n")); @@ -1080,7 +1081,7 @@ describe("AskTool multiline custom input rendering", () => { context, ); - const theme = await getThemeByName("dark"); + const theme = darkTheme; const rendered = askToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme!); const renderedText = stripAnsi(rendered.render(120).join("\n")); @@ -1334,7 +1335,7 @@ describe("AskTool multi-question navigation", () => { describe("AskTool option markers", () => { it("renders single-choice call options with circular radio markers, not checkboxes", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; const rendered = askToolRenderer.renderCall( { question: "Pick one", options: [{ label: "Alpha" }, { label: "Beta" }] }, { expanded: true, isPartial: false }, @@ -1346,7 +1347,7 @@ describe("AskTool option markers", () => { }); it("renders multi-select call options with rectangular checkbox markers, not radios", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; const rendered = askToolRenderer.renderCall( { question: "Pick many", options: [{ label: "Alpha" }, { label: "Beta" }], multi: true }, { expanded: true, isPartial: false }, @@ -1358,7 +1359,7 @@ describe("AskTool option markers", () => { }); it("keeps option rows stable across repeated renders", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; const options = [ { label: "TypeScript" }, { label: "Rust" }, @@ -1409,7 +1410,7 @@ describe("AskTool option markers", () => { }); it("keeps single-question option rows stable across repeated renders", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; // The question body comes from the Markdown render cache, which returns // the SAME array on every render of identical text at identical width. // Appending option rows in place would poison that cached entry, so a @@ -1443,7 +1444,7 @@ describe("AskTool option markers", () => { expect(secondResult.match(/OptionDupCanary/g)?.length).toBe(1); }); it("renders single-choice result selection with a filled radio marker", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; const rendered = askToolRenderer.renderResult( { content: [{ type: "text", text: "" }], @@ -1458,7 +1459,7 @@ describe("AskTool option markers", () => { }); it("renders multi-select result selections with checkbox markers", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; const rendered = askToolRenderer.renderResult( { content: [{ type: "text", text: "" }], @@ -1475,7 +1476,7 @@ describe("AskTool option markers", () => { describe("askToolRenderer malformed call args", () => { it("renders double-encoded questions string instead of crashing the TUI", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; // Models occasionally JSON-encode the questions array as a string; a bare // string passes a truthy `.length` check but has no `.map` (TUI crash). const doubleEncoded = JSON.stringify([ @@ -1493,7 +1494,7 @@ describe("askToolRenderer malformed call args", () => { }); it("falls back to the error frame for unparseable questions without throwing", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; for (const questions of ["[{trunc", 42, { 0: { id: "x" } }]) { const rendered = askToolRenderer.renderCall( { questions } as never, @@ -1506,7 +1507,7 @@ describe("askToolRenderer malformed call args", () => { }); it("drops malformed question entries and option items while keeping valid ones", async () => { - const theme = await getThemeByName("dark"); + const theme = darkTheme; const rendered = askToolRenderer.renderCall( { questions: [ diff --git a/packages/coding-agent/test/tools/ast-edit.test.ts b/packages/coding-agent/test/tools/ast-edit.test.ts index 5d3e7105d..57115acbb 100644 --- a/packages/coding-agent/test/tools/ast-edit.test.ts +++ b/packages/coding-agent/test/tools/ast-edit.test.ts @@ -36,7 +36,7 @@ function asSchemaObject(value: unknown): Record { describe("ast_edit tool schema", () => { it("uses op entries as [{ pat, out }]", async () => { - const tools = await createTools(createTestSession()); + const tools = await createTools(createTestSession(), ["ast_edit"]); const tool = tools.find(entry => entry.name === "ast_edit"); expect(tool).toBeDefined(); const schema = toolWireSchema(tool!); @@ -54,7 +54,7 @@ describe("ast_edit tool schema", () => { }); it("remains strict-representable after strict adaptation", async () => { - const tools = await createTools(createTestSession()); + const tools = await createTools(createTestSession(), ["ast_edit"]); const tool = tools.find(entry => entry.name === "ast_edit"); expect(tool).toBeDefined(); const schema = toolWireSchema(tool!); @@ -69,7 +69,7 @@ describe("ast_edit tool schema", () => { const filePath = path.join(tempDir, "legacy.ts"); await Bun.write(filePath, "legacyWrap(x, value)\n"); - const tools = await createTools(createTestSession(tempDir)); + const tools = await createTools(createTestSession(tempDir), ["ast_edit"]); const tool = tools.find(entry => entry.name === "ast_edit"); expect(tool).toBeDefined(); @@ -105,6 +105,7 @@ describe("ast_edit tool schema", () => { buildToolChoice: () => ({ type: "tool" as const, name: "resolve" }), steer: () => {}, }), + ["ast_edit"], ); const tool = tools.find(entry => entry.name === "ast_edit"); expect(tool).toBeDefined(); @@ -149,6 +150,7 @@ describe("ast_edit tool schema", () => { buildToolChoice: () => ({ type: "tool" as const, name: "resolve" }), steer: () => {}, }), + ["ast_edit"], ); const tool = tools.find(entry => entry.name === "ast_edit"); expect(tool).toBeDefined(); @@ -198,6 +200,7 @@ describe("ast_edit tool schema", () => { buildToolChoice: () => ({ type: "tool" as const, name: "resolve" }), steer: () => {}, }), + ["ast_edit"], ); const tool = tools.find(entry => entry.name === "ast_edit"); expect(tool).toBeDefined(); @@ -255,6 +258,7 @@ describe("ast_edit tool schema", () => { buildToolChoice: () => ({ type: "tool" as const, name: "resolve" }), steer: () => {}, }), + ["ast_edit"], ); const tool = tools.find(entry => entry.name === "ast_edit"); expect(tool).toBeDefined(); diff --git a/packages/coding-agent/test/tools/ast-grep.test.ts b/packages/coding-agent/test/tools/ast-grep.test.ts index 98d47bbaa..112537d18 100644 --- a/packages/coding-agent/test/tools/ast-grep.test.ts +++ b/packages/coding-agent/test/tools/ast-grep.test.ts @@ -24,7 +24,7 @@ describe("ast_grep parse errors", () => { const filePath = path.join(tempDir, "broken.ts"); await Bun.write(filePath, "export function broken( { return 1; }"); - const tools = await createTools(createTestSession(tempDir)); + const tools = await createTools(createTestSession(tempDir), ["ast_grep"]); const tool = tools.find(entry => entry.name === "ast_grep"); expect(tool).toBeDefined(); @@ -50,12 +50,12 @@ describe("ast_grep parse errors", () => { it("caps parseErrors at PARSE_ERRORS_LIMIT and records the original total", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "ast-grep-parse-cap-")); try { - const fileCount = 35; + const fileCount = 21; for (let i = 0; i < fileCount; i++) { await Bun.write(path.join(tempDir, `broken-${i}.ts`), "export function broken( { return 1; }"); } - const tools = await createTools(createTestSession(tempDir)); + const tools = await createTools(createTestSession(tempDir), ["ast_grep"]); const tool = tools.find(entry => entry.name === "ast_grep"); expect(tool).toBeDefined(); @@ -89,7 +89,7 @@ describe("ast_grep parse errors", () => { await Bun.write(path.join(sourceDir, "ignore.js"), "const providerOptions = {};\n"); await Bun.write(path.join(tempDir, "outside.ts"), "const providerOptions = {};\n"); - const tools = await createTools(createTestSession(tempDir)); + const tools = await createTools(createTestSession(tempDir), ["ast_grep"]); const tool = tools.find(entry => entry.name === "ast_grep"); expect(tool).toBeDefined(); @@ -130,7 +130,7 @@ describe("ast_grep parse errors", () => { Array.from({ length: 8 }, () => "const sharedSymbol = 1;").join("\n"), ); - const tools = await createTools(createTestSession(tempDir)); + const tools = await createTools(createTestSession(tempDir), ["ast_grep"]); const tool = tools.find(entry => entry.name === "ast_grep"); expect(tool).toBeDefined(); @@ -162,7 +162,7 @@ describe("ast_grep parse errors", () => { filePath, "---- MODULE Algo ----\n(*--algorithm Demo\nvariables x = 0;\nbegin\n x := x + 1;\nend algorithm;*)\n====\n", ); - const tools = await createTools(createTestSession(tempDir)); + const tools = await createTools(createTestSession(tempDir), ["ast_grep"]); const tool = tools.find(entry => entry.name === "ast_grep"); expect(tool).toBeDefined(); diff --git a/packages/coding-agent/test/tools/bash-sixel-render.test.ts b/packages/coding-agent/test/tools/bash-sixel-render.test.ts index b7383002e..c78f8b5ad 100644 --- a/packages/coding-agent/test/tools/bash-sixel-render.test.ts +++ b/packages/coding-agent/test/tools/bash-sixel-render.test.ts @@ -1,8 +1,8 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeAll, describe, expect, it } from "bun:test"; import * as os from "node:os"; import * as path from "node:path"; import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core"; -import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, setThemeInstance, type Theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { bashToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/bash"; import { previewWindowRows } from "@oh-my-pi/pi-coding-agent/tools/render-utils"; import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui"; @@ -16,15 +16,19 @@ const terminal = TERMINAL as unknown as MutableTerminalInfo; describe("bashToolRenderer", () => { const originalProtocol = TERMINAL.imageProtocol; + let uiTheme: Theme; + + beforeAll(async () => { + const loadedTheme = await getThemeByName("dark"); + if (!loadedTheme) throw new Error("Expected dark theme"); + uiTheme = loadedTheme; + }); afterEach(() => { terminal.imageProtocol = originalProtocol; }); it("shows rendered env assignments in the command preview", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderCall( { command: "printf '%s' \"$MERMAID\"", env: { MERMAID: 'line "one"\ntwo' } }, { expanded: false, isPartial: false }, @@ -36,9 +40,6 @@ describe("bashToolRenderer", () => { }); it("stringifies malformed env values in the command preview", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderCall( { command: 'echo "$DEBUG"', env: { DEBUG: true } }, { expanded: false, isPartial: false }, @@ -50,9 +51,6 @@ describe("bashToolRenderer", () => { }); it("shows partial env assignments while tool args are still streaming", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderCall( { command: "printf '%s' \"$MERMAID\"", @@ -67,9 +65,6 @@ describe("bashToolRenderer", () => { }); it("sanitizes command tabs and shortens home cwd in previews", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderCall( { command: "printf\t'%s'", @@ -85,9 +80,6 @@ describe("bashToolRenderer", () => { }); it("renders the pending call as a bordered block with the command in the body", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderCall( { command: "sleep 30" }, { expanded: false, isPartial: true }, @@ -106,9 +98,6 @@ describe("bashToolRenderer", () => { }); it("shows the effective timeout from result details when it differs from call args", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderResult( { content: [{ type: "text", text: "" }], details: { timeoutSeconds: 120 }, isError: false }, { expanded: false, isPartial: false, renderContext: { timeout: 1200 } }, @@ -121,9 +110,6 @@ describe("bashToolRenderer", () => { }); it("renders wall time alongside the timeout label and strips the textual notice", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderResult( { content: [{ type: "text", text: "hello\n\nWall time: 1.23 seconds" }], @@ -143,9 +129,6 @@ describe("bashToolRenderer", () => { }); it("renders a backgrounded job as a static footer notice", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderResult( { content: [ @@ -171,9 +154,6 @@ describe("bashToolRenderer", () => { }); it("folds raw output artifact notices into the status footer", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderResult( { content: [{ type: "text", text: "filtered\n[raw output: artifact://13]\n\nWall time: 0.08 seconds" }], @@ -193,9 +173,6 @@ describe("bashToolRenderer", () => { expect(rendered).not.toContain("artifact://13"); }); it("renders the exit status in the footer and strips the textual exit notice for failed commands", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderResult( { content: [{ type: "text", text: "boom\n\nWall time: 0.02 seconds\n\nCommand exited with code 1" }], @@ -220,9 +197,6 @@ describe("bashToolRenderer", () => { }); it("renders a timed-out command with a warning border instead of an error border", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderResult( { content: [{ type: "text", text: "[Command timed out after 1 seconds]\n" }], @@ -242,9 +216,6 @@ describe("bashToolRenderer", () => { }); it("omits the status footer for a successful command", async () => { - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const component = bashToolRenderer.renderResult( { content: [{ type: "text", text: "ok\n\nWall time: 0.02 seconds" }], @@ -263,9 +234,6 @@ describe("bashToolRenderer", () => { it("bypasses truncation/styling for SIXEL lines", async () => { terminal.imageProtocol = ImageProtocol.Sixel; - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const sixel = "\x1bPqabc\x1b\\"; const renderOptions: RenderResultOptions & { renderContext: { @@ -296,8 +264,6 @@ describe("bashToolRenderer", () => { }); it("highlights every line of a multi-line bash command in renderResult", async () => { - const uiTheme = await getThemeByName("dark"); - expect(uiTheme).toBeDefined(); setThemeInstance(uiTheme!); const command = 'for f in a b; do\n\techo "$f"\ndone'; const component = bashToolRenderer.renderResult( @@ -329,9 +295,6 @@ describe("bashToolRenderer", () => { // main thread in #2081. The eval renderer already caches by (width, // previewLines) — this test pins the same contract for bash so future // refactors don't silently drop the cache. - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; // A non-trivial output so a missed cache hit would do real string work. const output = Array.from({ length: 200 }, (_, i) => `line ${i}: payload ${"x".repeat(20)}`).join("\n"); const component = bashToolRenderer.renderResult( @@ -376,9 +339,6 @@ describe("bashToolRenderer", () => { // lines" marker. The finalized collapsed block MUST render the identical // window — snapping the full command open on completion makes the block // jump. Only ctrl+o (expanded) uncaps. - const theme = await getThemeByName("dark"); - expect(theme).toBeDefined(); - const uiTheme = theme!; const total = previewWindowRows() + 5; const command = Array.from({ length: total }, (_, i) => `echo step_${i}`).join("\n"); const render = (opts: { expanded: boolean; isPartial: boolean }) => { diff --git a/packages/coding-agent/test/tools/browser-attach.test.ts b/packages/coding-agent/test/tools/browser-attach.test.ts index ed9afd184..2a59cf5cb 100644 --- a/packages/coding-agent/test/tools/browser-attach.test.ts +++ b/packages/coding-agent/test/tools/browser-attach.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { pickElectronTarget, shouldPreserveConnectedBrowserFocus, @@ -14,6 +14,7 @@ import type { Browser, Page, Target } from "puppeteer-core"; import { chromiumAvailable } from "./chromium-probe"; const CHROMIUM_AVAILABLE = await chromiumAvailable(); +let sharedHeadless: BrowserHandle | undefined; interface FakePageOptions { url: string; @@ -37,6 +38,15 @@ function fakeTarget(type: string, page: Page | null): Target { } describe("pickElectronTarget", () => { + beforeAll(async () => { + if (!CHROMIUM_AVAILABLE) return; + sharedHeadless = await acquireBrowser({ kind: "headless", headless: true }, { cwd: process.cwd() }); + }); + + afterAll(async () => { + if (sharedHeadless) await releaseBrowser(sharedHeadless, { kill: true }); + }); + test("uses discovered CDP page targets when browser.pages is empty", async () => { const page = fakePage({ url: "https://www.google.com/", title: "Google" }); let pagesCalled = false; @@ -113,8 +123,8 @@ describe("pickElectronTarget", () => { test.skipIf(!CHROMIUM_AVAILABLE)( "navigates a fresh attached tab to the requested URL", async () => { - const launched = await acquireBrowser({ kind: "headless", headless: true }, { cwd: process.cwd() }); - if (!("browser" in launched)) throw new Error("Expected a Puppeteer browser"); + const launched = sharedHeadless; + if (!launched || !("browser" in launched)) throw new Error("Expected a shared Puppeteer browser"); const endpoint = new URL(launched.browser.wsEndpoint()); let attached: BrowserHandle | undefined; let opened = false; @@ -137,7 +147,6 @@ describe("pickElectronTarget", () => { } finally { if (opened) await releaseTab(tabName, { kill: false }); else if (attached) await releaseBrowser(attached, { kill: false }); - await releaseBrowser(launched, { kill: true }); } }, 30_000, @@ -154,8 +163,8 @@ describe("pickElectronTarget", () => { return new Promise(() => {}); }, }); - const launched = await acquireBrowser({ kind: "headless", headless: true }, { cwd: process.cwd() }); - if (!("browser" in launched)) throw new Error("Expected a Puppeteer browser"); + const launched = sharedHeadless; + if (!launched || !("browser" in launched)) throw new Error("Expected a shared Puppeteer browser"); const endpoint = new URL(launched.browser.wsEndpoint()); let attached: BrowserHandle | undefined; @@ -176,7 +185,6 @@ describe("pickElectronTarget", () => { expect(requestCount).toBe(1); } finally { if (attached && !attempted) await releaseBrowser(attached, { kill: false }); - await releaseBrowser(launched, { kill: true }); await server.stop(true); } }, diff --git a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts index 768021be3..2712764c2 100644 --- a/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts +++ b/packages/coding-agent/test/tools/browser-cmux-release-mid-run.test.ts @@ -290,53 +290,71 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { it("logs a user continuation rejection after its cmux run ends", async () => { spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); - spyOn(CmuxSocketClient.prototype, "request").mockImplementation( - async (method: string): Promise> => { - switch (method) { - case "browser.open_split": - return { surface_id: "surface-late-rejection", url: "about:blank" }; - case "browser.url.get": - return { url: "about:blank" }; - case "browser.snapshot": - return { page: { html: "" } }; - case "browser.eval": - return { value: "" }; - default: - return {}; - } - }, - ); - const warningLogged = Promise.withResolvers(); - const warn = spyOn(logger, "warn").mockImplementation(message => { - if (message === "Unhandled rejection after browser run ended") warningLogged.resolve(); - }); - const browser = await acquireBrowser(makeKind("late-rejection"), { cwd: "/tmp" }); - await acquireTab("late-rejection", browser, { - timeoutMs: 5_000, - ownerSessionId: "session-late-rejection", - }); + const continuationStarted = Promise.withResolvers(); + const continuationGate = Promise.withResolvers(); + const globals = globalThis as typeof globalThis & { + __ompLateRejectionStarted?: () => void; + __ompLateRejectionGate?: Promise; + }; + globals.__ompLateRejectionStarted = continuationStarted.resolve; + globals.__ompLateRejectionGate = continuationGate.promise; - const result = await runInTab("late-rejection", { - code: ` - const continuationStarted = Promise.withResolvers(); - void tab.title().then(async () => { - continuationStarted.resolve(); - await Bun.sleep(50); - throw new Error("late cmux continuation failed"); - }); - await continuationStarted.promise; - return "completed"; - `, - timeoutMs: 5_000, - session: makeSession("/tmp"), - }); - expect(result.returnValue).toBe("completed"); + try { + spyOn(CmuxSocketClient.prototype, "request").mockImplementation( + async (method: string): Promise> => { + switch (method) { + case "browser.open_split": + return { surface_id: "surface-late-rejection", url: "about:blank" }; + case "browser.url.get": + return { url: "about:blank" }; + case "browser.snapshot": + return { page: { html: "" } }; + case "browser.eval": + return { value: "" }; + default: + return {}; + } + }, + ); + const warningLogged = Promise.withResolvers(); + const warn = spyOn(logger, "warn").mockImplementation(message => { + if (message === "Unhandled rejection after browser run ended") warningLogged.resolve(); + }); + const browser = await acquireBrowser(makeKind("late-rejection"), { cwd: "/tmp" }); + await acquireTab("late-rejection", browser, { + timeoutMs: 5_000, + ownerSessionId: "session-late-rejection", + }); - await warningLogged.promise; - expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", { - runId: expect.any(String), - error: "late cmux continuation failed", - }); + const result = await runInTab("late-rejection", { + code: ` + const guestStarted = Promise.withResolvers(); + void tab.title().then(async () => { + guestStarted.resolve(); + globalThis.__ompLateRejectionStarted(); + await globalThis.__ompLateRejectionGate; + throw new Error("late cmux continuation failed"); + }); + await guestStarted.promise; + return "completed"; + `, + timeoutMs: 5_000, + session: makeSession("/tmp"), + }); + expect(result.returnValue).toBe("completed"); + await continuationStarted.promise; + continuationGate.resolve(); + + await warningLogged.promise; + expect(warn).toHaveBeenCalledWith("Unhandled rejection after browser run ended", { + runId: expect.any(String), + error: "late cmux continuation failed", + }); + } finally { + continuationGate.resolve(); + delete globals.__ompLateRejectionStarted; + delete globals.__ompLateRejectionGate; + } }); it("fails a browser error rethrown through a native promise combinator", async () => { @@ -373,7 +391,7 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { ]).catch(reason => { throw reason; }); - await wait(50); + await wait(60_000); return "incorrect success"; `, timeoutMs: 5_000, @@ -386,6 +404,9 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { it("aborts the cmux run facade before draining floated continuations", async () => { spyOn(CmuxSocketClient.prototype, "connect").mockResolvedValue(undefined); spyOn(CmuxSocketClient.prototype, "close").mockImplementation(() => undefined); + const delayedTitleStarted = Promise.withResolvers(); + const delayedTitleGate = Promise.withResolvers>(); + let titleRequestCount = 0; const navigatedUrls: string[] = []; spyOn(CmuxSocketClient.prototype, "request").mockImplementation( async (method: string, params: Record): Promise> => { @@ -397,7 +418,10 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { case "browser.snapshot": return { page: { html: "" } }; case "browser.eval": - await Bun.sleep(0); + if (params.script === "document.title" && ++titleRequestCount > 1) { + delayedTitleStarted.resolve(); + return await delayedTitleGate.promise; + } return { value: "ready" }; case "browser.navigate": navigatedUrls.push(String(params.url)); @@ -422,8 +446,9 @@ describe("browser tab-supervisor — cmux tab close mid-run (#4499)", () => { session: makeSession("/tmp"), }); expect(result.returnValue).toBe("completed"); - - await Bun.sleep(20); + await delayedTitleStarted.promise; + delayedTitleGate.resolve({ value: "ready" }); + for (let i = 0; i < 8; i++) await Promise.resolve(); expect(navigatedUrls).toEqual([]); }); diff --git a/packages/coding-agent/test/tools/browser-dispose-timeout.test.ts b/packages/coding-agent/test/tools/browser-dispose-timeout.test.ts index cd7e30b51..84040a37a 100644 --- a/packages/coding-agent/test/tools/browser-dispose-timeout.test.ts +++ b/packages/coding-agent/test/tools/browser-dispose-timeout.test.ts @@ -11,7 +11,7 @@ * on timeout so cleanup always completes. */ -import { describe, expect, it, spyOn } from "bun:test"; +import { describe, expect, it, spyOn, vi } from "bun:test"; import * as attach from "@oh-my-pi/pi-coding-agent/tools/browser/attach"; import { type BrowserHandle, releaseBrowser } from "@oh-my-pi/pi-coding-agent/tools/browser/registry"; @@ -40,33 +40,39 @@ function makeHangingHeadlessHandle(pid: number | undefined): { describe("browser dispose — headless close must not hang forever (issue #5260)", () => { it("bounds a wedged browser.close() and force-kills the process tree", async () => { + vi.useFakeTimers(); const killSpy = spyOn(attach, "gracefulKillTreeOnce").mockResolvedValue(undefined); try { const { handle, closeCalls } = makeHangingHeadlessHandle(4242); - const start = Date.now(); - await releaseBrowser(handle, { kill: false }); - const elapsed = Date.now() - start; + const released = releaseBrowser(handle, { kill: false }); - // close() was attempted, but the release still returned rather than - // hanging on the never-resolving promise. expect(closeCalls()).toBe(1); - expect(elapsed).toBeLessThan(15_000); - // On timeout, the Chromium process tree is force-killed by pid. + vi.advanceTimersByTime(4_999); + await Promise.resolve(); + expect(killSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + await released; expect(killSpy).toHaveBeenCalledTimes(1); expect(killSpy.mock.calls[0]?.[0]).toBe(4242); } finally { killSpy.mockRestore(); + vi.useRealTimers(); } - }, 20_000); + }); it("does not attempt a force-kill when no process handle is available", async () => { + vi.useFakeTimers(); const killSpy = spyOn(attach, "gracefulKillTreeOnce").mockResolvedValue(undefined); try { const { handle } = makeHangingHeadlessHandle(undefined); - await releaseBrowser(handle, { kill: false }); + const released = releaseBrowser(handle, { kill: false }); + vi.advanceTimersByTime(5_000); + await released; expect(killSpy).not.toHaveBeenCalled(); } finally { killSpy.mockRestore(); + vi.useRealTimers(); } - }, 20_000); + }); }); diff --git a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts index f255a827b..46757c263 100644 --- a/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-evaluate.test.ts @@ -22,8 +22,8 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { const suiteTool = new BrowserTool(makeSession()); const suiteTabName = `evaluation-suite-${process.pid}`; - // Keep one browser lease across the suite. Tests still get isolated tabs and workers, - // while the chunked full run avoids relaunching Chromium for every test under load. + // Hold one browser and one tab worker across the suite. Each case navigates + // the shared page before exercising its run-level isolation contract. beforeAll(async () => { await suiteTool.execute("open", { action: "open", @@ -38,25 +38,21 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { // Launches real headless Chromium; CI cold start easily exceeds bun's 5s default. it("runs tab.evaluate in the page's main JavaScript world", async () => { - const tool = new BrowserTool(makeSession()); - const name = `main-world-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; - try { - await tool.execute("open", { - action: "open", - name, - url: "data:text/html,", - }); - const result = await tool.execute("run", { - action: "run", - name, - code: "return await tab.evaluate(() => globalThis.__ompMainWorld);", - }); + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,", + }); + const result = await tool.execute("run", { + action: "run", + name, + code: "return await tab.evaluate(() => globalThis.__ompMainWorld);", + }); - expect(result.content).toEqual([{ type: "text", text: "42" }]); - } finally { - await tool.execute("close", { action: "close", name, kill: true }); - } + expect(result.content).toEqual([{ type: "text", text: "42" }]); }, 30_000); it("clears request interception and held requests between runs, including thrown runs", async () => { @@ -71,8 +67,8 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { }); }, }); - const tool = new BrowserTool(makeSession()); - const name = `interception-lifecycle-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; try { await tool.execute("open", { @@ -163,7 +159,6 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { }, ]); } finally { - await tool.execute("close", { action: "close", name, kill: true }); server.stop(true); } }, 30_000); @@ -179,8 +174,8 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { }); }, }); - const tool = new BrowserTool(makeSession()); - const name = `once-interception-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; try { await tool.execute("open", { @@ -221,127 +216,117 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { }); expect(resumed.content).toEqual([{ type: "text", text: '{\n "clean": true,\n "body": "normal-mock"\n}' }]); } finally { - await tool.execute("close", { action: "close", name, kill: true }); server.stop(true); } }, 30_000); it("keeps the tab worker alive after an unhandled waitForResponse timeout descendant", async () => { - const tool = new BrowserTool(makeSession()); - const name = `response-timeout-descendant-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; - try { - await tool.execute("open", { - action: "open", - name, - url: "data:text/html,

ready

", - }); - const tabSession = getTabsMapForTest().get(name); - if (tabSession?.backend !== "worker") throw new Error("Worker tab was not created"); - expect(tabSession.worker.mode).toBe("worker"); - const result = await tool.execute("run", { - action: "run", - name, - timeout: 2, - // Real worker timers are intentional: the rejection must cross an - // unhandledRejection turn while the browser run remains active. - code: ` + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + const tabSession = getTabsMapForTest().get(name); + if (tabSession?.backend !== "worker") throw new Error("Worker tab was not created"); + expect(tabSession.worker.mode).toBe("worker"); + const result = await tool.execute("run", { + action: "run", + name, + timeout: 2, + // Real worker timers are intentional: the rejection must cross an + // unhandledRejection turn while the browser run remains active. + code: ` void tab.waitForResponse("/never", { timeout: 10 }).then(() => undefined); await Bun.sleep(50); return "survived timeout"; `, - }); - expect(result.content).toEqual([{ type: "text", text: "survived timeout" }]); + }); + expect(result.content).toEqual([{ type: "text", text: "survived timeout" }]); - const followup = await tool.execute("run", { - action: "run", - name, - code: "return 42;", - }); - expect(followup.content).toEqual([{ type: "text", text: "42" }]); - } finally { - await tool.execute("close", { action: "close", name, kill: true }); - } + const followup = await tool.execute("run", { + action: "run", + name, + code: "return 42;", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); }, 30_000); it("fails floated user continuations without killing the tab worker", async () => { - const tool = new BrowserTool(makeSession()); - const name = `continuation-rejection-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + let failure = ""; try { - await tool.execute("open", { - action: "open", + await tool.execute("run", { + action: "run", name, - url: "data:text/html,

ready

", - }); - let failure = ""; - try { - await tool.execute("run", { - action: "run", - name, - timeout: 2, - code: ` + timeout: 2, + code: ` void tab.title().then(() => { throw new Error("continuation failed"); }); await Bun.sleep(50); return "incorrect success"; `, - }); - } catch (error) { - failure = error instanceof Error ? error.message : String(error); - } - expect(failure).toContain("Unhandled rejection (missing await?): continuation failed"); + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + expect(failure).toContain("Unhandled rejection (missing await?): continuation failed"); - let rethrowFailure = ""; - try { - await tool.execute("run", { - action: "run", - name, - timeout: 2, - code: ` + let rethrowFailure = ""; + try { + await tool.execute("run", { + action: "run", + name, + timeout: 2, + code: ` void tab.waitForResponse("/never", { timeout: 10 }).catch(reason => { throw reason; }); await Bun.sleep(50); return "incorrect success"; `, - }); - } catch (error) { - rethrowFailure = error instanceof Error ? error.message : String(error); - } - expect(rethrowFailure).toContain( - "Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms", - ); - - const followup = await tool.execute("run", { - action: "run", - name, - code: "return 42;", }); - expect(followup.content).toEqual([{ type: "text", text: "42" }]); - } finally { - await tool.execute("close", { action: "close", name, kill: true }); + } catch (error) { + rethrowFailure = error instanceof Error ? error.message : String(error); } + expect(rethrowFailure).toContain( + "Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms", + ); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return 42;", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); }, 30_000); it("fails a browser error rethrown through a native promise combinator", async () => { - const tool = new BrowserTool(makeSession()); - const name = `combinator-rejection-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + let failure = ""; try { - await tool.execute("open", { - action: "open", + await tool.execute("run", { + action: "run", name, - url: "data:text/html,

ready

", - }); - let failure = ""; - try { - await tool.execute("run", { - action: "run", - name, - timeout: 2, - code: ` + timeout: 2, + code: ` void Promise.all([ tab.waitForResponse("/never", { timeout: 10 }), ]).catch(reason => { @@ -350,69 +335,61 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { await Bun.sleep(50); return "incorrect success"; `, - }); - } catch (error) { - failure = error instanceof Error ? error.message : String(error); - } - expect(failure).toContain("Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms"); - - const followup = await tool.execute("run", { - action: "run", - name, - code: "return 42;", }); - expect(followup.content).toEqual([{ type: "text", text: "42" }]); - } finally { - await tool.execute("close", { action: "close", name, kill: true }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); } + expect(failure).toContain("Unhandled rejection (missing await?): tab.waitForResponse() timed out after 10ms"); + + const followup = await tool.execute("run", { + action: "run", + name, + code: "return 42;", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); }, 30_000); it("restores promise tracking after evaluated code freezes Promise", async () => { - const tool = new BrowserTool(makeSession()); - const name = `frozen-promise-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; - try { - await tool.execute("open", { - action: "open", - name, - url: "data:text/html,

ready

", - }); - const frozen = await tool.execute("run", { - action: "run", - name, - code: ` + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + const frozen = await tool.execute("run", { + action: "run", + name, + code: ` Object.freeze(Promise); return Object.isFrozen(Promise); `, - }); - expect(frozen.content).toEqual([{ type: "text", text: "true" }]); + }); + expect(frozen.content).toEqual([{ type: "text", text: "true" }]); - const followup = await tool.execute("run", { - action: "run", - name, - code: "return (await Promise.all([42]))[0];", - }); - expect(followup.content).toEqual([{ type: "text", text: "42" }]); - } finally { - await tool.execute("close", { action: "close", name, kill: true }); - } + const followup = await tool.execute("run", { + action: "run", + name, + code: "return (await Promise.all([42]))[0];", + }); + expect(followup.content).toEqual([{ type: "text", text: "42" }]); }, 30_000); it("aborts the run facade before draining floated continuations", async () => { - const tool = new BrowserTool(makeSession()); - const name = `drain-abort-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; const url = "data:text/html,original

ready

"; - try { - await tool.execute("open", { - action: "open", - name, - url, - }); - const result = await tool.execute("run", { - action: "run", - name, - code: ` + await tool.execute("open", { + action: "open", + name, + url, + }); + const result = await tool.execute("run", { + action: "run", + name, + code: ` page.title = async () => { await Bun.sleep(0); return "ready"; @@ -420,37 +397,33 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { void tab.title().then(() => tab.goto("data:text/html,late")); return "completed"; `, - }); - expect(result.content).toEqual([{ type: "text", text: "completed" }]); + }); + expect(result.content).toEqual([{ type: "text", text: "completed" }]); - await Bun.sleep(100); - const followup = await tool.execute("run", { - action: "run", - name, - code: "return tab.url();", - }); - expect(followup.content).toEqual([{ type: "text", text: url }]); - } finally { - await tool.execute("close", { action: "close", name, kill: true }); - } + await Bun.sleep(100); + const followup = await tool.execute("run", { + action: "run", + name, + code: "return tab.url();", + }); + expect(followup.content).toEqual([{ type: "text", text: url }]); }, 30_000); it("folds a user continuation rejection that settles during cleanup", async () => { - const tool = new BrowserTool(makeSession()); - const name = `cleanup-continuation-rejection-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; + await tool.execute("open", { + action: "open", + name, + url: "data:text/html,

ready

", + }); + let failure = ""; try { - await tool.execute("open", { - action: "open", + await tool.execute("run", { + action: "run", name, - url: "data:text/html,

ready

", - }); - let failure = ""; - try { - await tool.execute("run", { - action: "run", - name, - code: ` + code: ` await page.setRequestInterception(true); page.setRequestInterception = async () => { await Bun.sleep(50); @@ -464,14 +437,11 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { await continuationStarted.promise; return "incorrect success"; `, - }); - } catch (error) { - failure = error instanceof Error ? error.message : String(error); - } - expect(failure).toContain("Unhandled rejection (missing await?): cleanup continuation failed"); - } finally { - await tool.execute("close", { action: "close", name, kill: true }); + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); } + expect(failure).toContain("Unhandled rejection (missing await?): cleanup continuation failed"); }, 30_000); it("logs a user continuation rejection after its browser run ends", async () => { @@ -479,8 +449,8 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { const warn = vi.spyOn(logger, "warn").mockImplementation(message => { if (message === "Unhandled rejection after browser run ended") warningLogged.resolve(); }); - const tool = new BrowserTool(makeSession()); - const name = `late-continuation-rejection-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; try { await tool.execute("open", { @@ -511,39 +481,34 @@ describe.skipIf(!CHROMIUM_AVAILABLE)("browser tab evaluation", () => { }); } finally { warn.mockRestore(); - await tool.execute("close", { action: "close", name, kill: true }); } }, 30_000); it("observes floating raw page promises when the target closes", async () => { - const tool = new BrowserTool(makeSession()); - const name = `target-close-${process.pid}`; + const tool = suiteTool; + const name = suiteTabName; const url = `data:text/html,

ready

#${name}`; - try { - await tool.execute("open", { action: "open", name, url }); - const tabSession = getTabsMapForTest().get(name); - if (tabSession?.backend !== "worker") throw new Error("Worker tab was not created"); - const pages = await tabSession.browser.browser.pages(); - const targetPage = pages.find(page => page.url() === url); - if (!targetPage) throw new Error(`Target page was not found for ${url}`); + await tool.execute("open", { action: "open", name, url }); + const tabSession = getTabsMapForTest().get(name); + if (tabSession?.backend !== "worker") throw new Error("Worker tab was not created"); + const pages = await tabSession.browser.browser.pages(); + const targetPage = pages.find(page => page.url() === url); + if (!targetPage) throw new Error(`Target page was not found for ${url}`); - const started = targetPage.waitForFunction("document.documentElement.dataset.floating === 'true'", { - polling: "mutation", - }); - const run = tool.execute("run", { - action: "run", - name, - code: "page.evaluate(() => { document.documentElement.dataset.floating = 'true'; return Promise.withResolvers().promise; }); try { await tab.waitForSelector('#never'); } catch {} return 'survived';", - }); - const startedHandle = await started; - await startedHandle.dispose(); - await targetPage.close(); + const started = targetPage.waitForFunction("document.documentElement.dataset.floating === 'true'", { + polling: "mutation", + }); + const run = tool.execute("run", { + action: "run", + name, + code: "page.evaluate(() => { document.documentElement.dataset.floating = 'true'; return Promise.withResolvers().promise; }); try { await tab.waitForSelector('#never'); } catch {} return 'survived';", + }); + const startedHandle = await started; + await startedHandle.dispose(); + await targetPage.close(); - const result = await run; - expect(result.content).toEqual([{ type: "text", text: "survived" }]); - } finally { - await tool.execute("close", { action: "close", name, kill: true }); - } + const result = await run; + expect(result.content).toEqual([{ type: "text", text: "survived" }]); }, 30_000); }); diff --git a/packages/coding-agent/test/tools/computer.test.ts b/packages/coding-agent/test/tools/computer.test.ts index f8df3ae8b..b7c73a7ad 100644 --- a/packages/coding-agent/test/tools/computer.test.ts +++ b/packages/coding-agent/test/tools/computer.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "bun:test"; import { type as arkType } from "@oh-my-pi/omptype"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { ComputerTool, computerApproval, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { ComputerTool, computerApproval } from "@oh-my-pi/pi-coding-agent/tools/computer"; import type { ComputerSessionSnapshot, ComputerWorkerInbound, diff --git a/packages/coding-agent/test/tools/edit-renderer.test.ts b/packages/coding-agent/test/tools/edit-renderer.test.ts index 30618c90c..de7f2d361 100644 --- a/packages/coding-agent/test/tools/edit-renderer.test.ts +++ b/packages/coding-agent/test/tools/edit-renderer.test.ts @@ -4,7 +4,6 @@ import * as os from "node:os"; import * as path from "node:path"; import { InMemorySnapshotStore } from "@oh-my-pi/hashline"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; -import { renderGalleryState, resolveFixture } from "@oh-my-pi/pi-coding-agent/cli/gallery-cli"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { editToolRenderer } from "@oh-my-pi/pi-coding-agent/edit/renderer"; import { renderDiff } from "@oh-my-pi/pi-coding-agent/modes/components/diff"; @@ -19,11 +18,16 @@ beforeAll(async () => { await Settings.init({ inMemory: true, cwd: process.cwd() }); }); -async function getUiTheme() { - await themeModule.initTheme(false, undefined, undefined, "dark", "light"); - const theme = await themeModule.getThemeByName("dark"); - expect(theme).toBeDefined(); - return theme!; +let uiThemePromise: Promise | undefined; + +function getUiTheme(): Promise { + uiThemePromise ??= (async () => { + await themeModule.initTheme(false, undefined, undefined, "dark", "light"); + const theme = await themeModule.getThemeByName("dark"); + expect(theme).toBeDefined(); + return theme!; + })(); + return uiThemePromise; } async function waitForRenderedText( @@ -463,6 +467,7 @@ describe("editToolRenderer", () => { ); const rendered = Bun.stripANSI(component.render(160).join("\n")); + expect(rendered).toContain("Delete"); expect(rendered).not.toContain("No changes would be made"); for (const path of paths) expect(rendered).toContain(path); }); @@ -518,27 +523,6 @@ describe("editToolRenderer", () => { expect(rendered).toContain("scripts/real.ts"); expect(rendered).not.toContain("WRONG"); }); - - it("renders the delete gallery fixture as a Delete card without a no-change body", async () => { - await getUiTheme(); - const text = (await renderGalleryState("edit_delete", resolveFixture("edit_delete"), "success", 160)) - .map(line => Bun.stripANSI(line)) - .join("\n"); - expect(text).toContain("Delete"); - expect(text).toContain("scripts/prune-changelogs.ts"); - expect(text).not.toContain("No changes"); - }); - - it("renders the move gallery fixture as source → destination", async () => { - await getUiTheme(); - const text = (await renderGalleryState("edit_move", resolveFixture("edit_move"), "success", 160)) - .map(line => Bun.stripANSI(line)) - .join("\n"); - expect(text).toContain("scripts/prune-changelogs.ts"); - expect(text).toContain("scripts/archived/prune-changelogs.ts"); - expect(text).toContain("→"); - expect(text).not.toContain("No changes"); - }); }); describe("editToolRenderer diff line wrapping", () => { diff --git a/packages/coding-agent/test/tools/eval-timeout.test.ts b/packages/coding-agent/test/tools/eval-timeout.test.ts index 68b705cb5..0e8140822 100644 --- a/packages/coding-agent/test/tools/eval-timeout.test.ts +++ b/packages/coding-agent/test/tools/eval-timeout.test.ts @@ -1,8 +1,9 @@ -import { afterAll, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, describe, expect, it, vi } from "bun:test"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { disposeAllVmContexts } from "@oh-my-pi/pi-coding-agent/eval/js/context-manager"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { EvalTool } from "@oh-my-pi/pi-coding-agent/tools/eval"; +import * as toolTimeouts from "@oh-my-pi/pi-coding-agent/tools/tool-timeouts"; function makeSession(): ToolSession { return { @@ -25,14 +26,19 @@ describe("EvalTool timeout semantics", () => { afterAll(async () => { await disposeAllVmContexts(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); it("disables the cell timeout when timeout is zero", async () => { + // Keep the integration path real while making a mistakenly armed timeout + // fail quickly. Fake timers cannot drive the isolated worker's clock, and + // a zero timeout must bypass the production clamp entirely. + vi.spyOn(toolTimeouts, "clampTimeout").mockReturnValue(0.05); const tool = new EvalTool(makeSession()); const result = await tool.execute("call-unlimited-timeout", { language: "js", - // This integration test must cross the former 1s watchdog boundary; - // fake timers do not drive the isolated JS worker's clock. - code: "await Bun.sleep(1250); print('completed');", + code: "await Bun.sleep(100); print('completed');", timeout: 0, }); @@ -41,9 +47,10 @@ describe("EvalTool timeout semantics", () => { }); it("bounds a compute cell (no agent/completion) by a plain wall-clock timeout", async () => { + // Exercise the real worker cancellation path without spending a full + // second waiting for the requested public timeout. + vi.spyOn(toolTimeouts, "clampTimeout").mockReturnValue(0.05); const tool = new EvalTool(makeSession()); - // 1s budget; the cell idles for 5s and emits no status, so nothing extends - // the budget — it must be cut off at the wall-clock limit. const result = await tool.execute("call-compute-timeout", { language: "js", code: "await Bun.sleep(2000); return 'never';", diff --git a/packages/coding-agent/test/tools/fetch-jina-stall.test.ts b/packages/coding-agent/test/tools/fetch-jina-stall.test.ts index 9ec7d059c..9a6ef2084 100644 --- a/packages/coding-agent/test/tools/fetch-jina-stall.test.ts +++ b/packages/coding-agent/test/tools/fetch-jina-stall.test.ts @@ -40,21 +40,20 @@ describe("renderHtmlToText: jina stall does not starve local fallbacks (#1449)", return new Response("", { status: 404 }); }); - const started = Date.now(); + // A short real budget is intentional: the combined AbortSignal clock is + // the behavior under test, and fake timers do not drive it reliably. const result = await renderHtmlToText( "https://example.com/article", html, - 0.3, + 0.05, settings, undefined, null, fetchMock, ); - const elapsedMs = Date.now() - started; expect(result.ok).toBe(true); expect(["native", "trafilatura", "lynx"]).toContain(result.method); - expect(elapsedMs).toBeLessThan(1_500); }); it("re-throws when the user signal is aborted, not when Jina sub-budget expires", async () => { diff --git a/packages/coding-agent/test/tools/gh-cache-invalidation.test.ts b/packages/coding-agent/test/tools/gh-cache-invalidation.test.ts index b6fad560d..c3c581439 100644 --- a/packages/coding-agent/test/tools/gh-cache-invalidation.test.ts +++ b/packages/coding-agent/test/tools/gh-cache-invalidation.test.ts @@ -3,17 +3,14 @@ * detector drops cache rows for state-mutating `gh issue|pr` ops while * leaving unrelated commands and read-only `gh` calls alone. */ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import { invalidateGithubCacheForBashCommand } from "@oh-my-pi/pi-coding-agent/tools/gh-cache-invalidation"; import { + clearAll, getCached, putCached, resetForTests as resetCacheForTests, } from "@oh-my-pi/pi-coding-agent/tools/github-cache"; -import { removeWithRetries } from "@oh-my-pi/pi-utils"; const REPO = "owner/example"; @@ -76,24 +73,25 @@ function seedPr(number: number, repo = REPO): void { }); } -let tempDir: string; let originalEnv: string | undefined; -beforeEach(async () => { +beforeAll(() => { originalEnv = process.env.OMP_GITHUB_CACHE_DB; - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gh-cache-inv-")); - process.env.OMP_GITHUB_CACHE_DB = path.join(tempDir, "github-cache.db"); + process.env.OMP_GITHUB_CACHE_DB = ":memory:"; resetCacheForTests(); }); -afterEach(async () => { +beforeEach(() => { + clearAll(); +}); + +afterAll(() => { resetCacheForTests(); if (originalEnv === undefined) { delete process.env.OMP_GITHUB_CACHE_DB; } else { process.env.OMP_GITHUB_CACHE_DB = originalEnv; } - await removeWithRetries(tempDir); }); describe("invalidateGithubCacheForBashCommand", () => { diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index 120b5c776..e40e60c5a 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -100,11 +100,11 @@ interface PrFixture { otherRefOid: string; } -// Building the fixture costs ~16 real `git` subprocess spawns (~200ms). Six -// tests need it, so we build it ONCE as an immutable template in `beforeAll` -// and materialize per-test copies via `fs.cp` (~12ms). Each copy is a fully -// independent repo tree, so the mutating tests (worktree checkout, config -// writes, extra branches) can't contaminate each other. +// Building the fixture is the dominant setup cost in this file. Build one +// immutable template in `beforeAll`, using bare clones instead of repeatedly +// pushing each branch, then materialize independent per-test copies via `fs.cp`. +// Each copy is an independent repo tree, so mutating tests cannot contaminate +// one another. let prFixtureTemplate: PrFixture | null = null; async function buildPrFixtureTemplate(): Promise { @@ -115,39 +115,33 @@ async function buildPrFixtureTemplate(): Promise { const headRefName = "feature/contributor-fix"; await fs.mkdir(repoRoot, { recursive: true }); - runGit(baseDir, ["init", "--bare", originBare]); - runGit(baseDir, ["init", "--bare", forkBare]); runGit(baseDir, ["init", "-b", "main", repoRoot]); - runGit(repoRoot, ["config", "user.name", "Test User"]); - runGit(repoRoot, ["config", "user.email", "test@example.com"]); await fs.writeFile(path.join(repoRoot, "README.md"), "base\n"); runGit(repoRoot, ["add", "README.md"]); runGit(repoRoot, ["commit", "-m", "base commit"]); - runGit(repoRoot, ["remote", "add", "origin", originBare]); - runGit(repoRoot, ["push", "-u", "origin", "main"]); - runGit(repoRoot, ["remote", "add", "forksrc", forkBare]); + runGit(repoRoot, ["checkout", "-b", headRefName]); await fs.writeFile(path.join(repoRoot, "README.md"), "base\nfeature\n"); - runGit(repoRoot, ["add", "README.md"]); - runGit(repoRoot, ["commit", "-m", "feature commit"]); - const headRefOid = runGit(repoRoot, ["rev-parse", "HEAD"]); - runGit(repoRoot, ["push", "-u", "forksrc", headRefName]); - // Same-repo PR checkouts fetch the head branch from `origin`, so publish the - // contributor branch there too — the array-checkout test's PR #100 uses it. - runGit(repoRoot, ["push", "origin", `${headRefName}:${headRefName}`]); - runGit(repoRoot, ["checkout", "main"]); + runGit(repoRoot, ["commit", "-am", "feature commit"]); + const headRefOid = (await fs.readFile(path.join(repoRoot, ".git", "refs", "heads", headRefName), "utf8")).trim(); - // A second origin branch lets the array-checkout test prove the multi-PR loop - // with two distinct PRs without paying for any per-test git setup. const otherRefName = "feature/another"; runGit(repoRoot, ["checkout", "-b", otherRefName, "main"]); await fs.writeFile(path.join(repoRoot, "OTHER.md"), "other\n"); runGit(repoRoot, ["add", "OTHER.md"]); runGit(repoRoot, ["commit", "-m", "another commit"]); - const otherRefOid = runGit(repoRoot, ["rev-parse", "HEAD"]); - runGit(repoRoot, ["push", "-u", "origin", otherRefName]); + const otherRefOid = (await fs.readFile(path.join(repoRoot, ".git", "refs", "heads", otherRefName), "utf8")).trim(); runGit(repoRoot, ["checkout", "main"]); + // Local bare clones copy every prepared branch in one process each. The old + // setup initialized both remotes and then paid a separate push for main and + // every feature branch. + runGit(baseDir, ["clone", "--bare", repoRoot, originBare]); + runGit(baseDir, ["clone", "--bare", repoRoot, forkBare]); + runGit(repoRoot, ["remote", "add", "origin", originBare]); + runGit(repoRoot, ["remote", "add", "forksrc", forkBare]); + runGit(repoRoot, ["fetch", "origin"]); + return { baseDir, repoRoot, originBare, forkBare, headRefName, headRefOid, otherRefName, otherRefOid }; } @@ -976,7 +970,7 @@ describe("github tool", () => { expect(apiArgs).toContain("q=fix repo:other/project"); }); - describe("pr_checkout (single, cross-repository)", () => { + describe("pr_checkout (single, cross-repository) and git remote handling", () => { // Arrange the mutable fixture + isolated $HOME once in beforeAll (excluded // from test-body time); the body only performs the checkout and assertions. let fixture: PrFixture; @@ -1026,43 +1020,34 @@ describe("github tool", () => { expect(runGit(fixture.repoRoot, ["worktree", "list", "--porcelain"])).toContain(`worktree ${worktreePath}`); expect(runGit(worktreePath, ["branch", "--show-current"])).toBe("pr-123"); }); - }); - // Both assertions are non-mutating (a no-op add and a rejected add), so they - // share one immutable fixture instead of cloning one per test. - describe("git.remote.add idempotency", () => { - let remoteFixture: PrFixture; - beforeAll(async () => { - remoteFixture = await createPrFixture(); - }); - afterAll(async () => { - await removeWithRetries(remoteFixture.baseDir); - }); + // These assertions are non-mutating (a no-op add and rejected adds), so + // reuse the checkout fixture instead of cloning another repository. + describe("git.remote.add idempotency", () => { + it("treats git.remote.add as a no-op when the remote already exists with the same URL", async () => { + await git.remote.add(fixture.repoRoot, "forksrc", fixture.forkBare); + expect(runGit(fixture.repoRoot, ["remote", "get-url", "forksrc"])).toBe(fixture.forkBare); + }); - it("treats git.remote.add as a no-op when the remote already exists with the same URL", async () => { - await git.remote.add(remoteFixture.repoRoot, "forksrc", remoteFixture.forkBare); - expect(runGit(remoteFixture.repoRoot, ["remote", "get-url", "forksrc"])).toBe(remoteFixture.forkBare); - }); - - it("rejects git.remote.add when the remote already exists with a different URL", async () => { - await expect(git.remote.add(remoteFixture.repoRoot, "forksrc", remoteFixture.originBare)).rejects.toThrow( - /already exists with URL/, - ); - // Existing URL is preserved — we never overwrote it. - expect(runGit(remoteFixture.repoRoot, ["remote", "get-url", "forksrc"])).toBe(remoteFixture.forkBare); - }); - it("does not depend on localized git remote-add stderr for existing remotes", async () => { - // The shim is a bash script resolved via `which`; neither exists on Windows. - if (process.platform === "win32") return; - const originalPath = process.env.PATH; - const fakeBin = await fs.mkdtemp(path.join(os.tmpdir(), "omp-fake-git-")); - const realGitResult = Bun.spawnSync(["which", "git"], { stdout: "pipe", stderr: "pipe" }); - expect(realGitResult.exitCode).toBe(0); - const realGit = new TextDecoder().decode(realGitResult.stdout).trim(); - const fakeGit = path.join(fakeBin, "git"); - await fs.writeFile( - fakeGit, - `#!/usr/bin/env bash + it("rejects git.remote.add when the remote already exists with a different URL", async () => { + await expect(git.remote.add(fixture.repoRoot, "forksrc", fixture.originBare)).rejects.toThrow( + /already exists with URL/, + ); + // Existing URL is preserved — we never overwrote it. + expect(runGit(fixture.repoRoot, ["remote", "get-url", "forksrc"])).toBe(fixture.forkBare); + }); + it("does not depend on localized git remote-add stderr for existing remotes", async () => { + // The shim is a bash script resolved via `which`; neither exists on Windows. + if (process.platform === "win32") return; + const originalPath = process.env.PATH; + const fakeBin = await fs.mkdtemp(path.join(os.tmpdir(), "omp-fake-git-")); + const realGitResult = Bun.spawnSync(["which", "git"], { stdout: "pipe", stderr: "pipe" }); + expect(realGitResult.exitCode).toBe(0); + const realGit = new TextDecoder().decode(realGitResult.stdout).trim(); + const fakeGit = path.join(fakeBin, "git"); + await fs.writeFile( + fakeGit, + `#!/usr/bin/env bash while [[ "$1" == "-c" ]]; do shift 2; done if [[ "$1" == "remote" && "$2" == "add" && "$3" == "forksrc" ]]; then echo "本地化错误:远程 forksrc 已经存在。" >&2 @@ -1070,40 +1055,40 @@ if [[ "$1" == "remote" && "$2" == "add" && "$3" == "forksrc" ]]; then fi exec ${JSON.stringify(realGit)} "$@" `, - ); - await fs.chmod(fakeGit, 0o755); + ); + await fs.chmod(fakeGit, 0o755); - try { - process.env.PATH = `${fakeBin}${path.delimiter}${originalPath ?? ""}`; - await git.remote.add(remoteFixture.repoRoot, "forksrc", remoteFixture.forkBare); - } finally { - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; + try { + process.env.PATH = `${fakeBin}${path.delimiter}${originalPath ?? ""}`; + await git.remote.add(fixture.repoRoot, "forksrc", fixture.forkBare); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + await removeWithRetries(fakeBin); } - await removeWithRetries(fakeBin); - } - }); + }); - it("pins Git messages while preserving UTF-8 character locale", async () => { - if (process.platform === "win32") return; - const originalPath = process.env.PATH; - const originalLocale = { - EXPECTED_LC_CTYPE: process.env.EXPECTED_LC_CTYPE, - LANG: process.env.LANG, - LC_ALL: process.env.LC_ALL, - LC_CTYPE: process.env.LC_CTYPE, - LC_MESSAGES: process.env.LC_MESSAGES, - }; - const fakeBin = await fs.mkdtemp(path.join(os.tmpdir(), "omp-fake-git-locale-")); - const realGit = $which("git"); - expect(realGit).not.toBeNull(); - if (realGit === null) return; - const fakeGit = path.join(fakeBin, "git"); - await fs.writeFile( - fakeGit, - `#!/bin/sh + it("pins Git messages while preserving UTF-8 character locale", async () => { + if (process.platform === "win32") return; + const originalPath = process.env.PATH; + const originalLocale = { + EXPECTED_LC_CTYPE: process.env.EXPECTED_LC_CTYPE, + LANG: process.env.LANG, + LC_ALL: process.env.LC_ALL, + LC_CTYPE: process.env.LC_CTYPE, + LC_MESSAGES: process.env.LC_MESSAGES, + }; + const fakeBin = await fs.mkdtemp(path.join(os.tmpdir(), "omp-fake-git-locale-")); + const realGit = $which("git"); + expect(realGit).not.toBeNull(); + if (realGit === null) return; + const fakeGit = path.join(fakeBin, "git"); + await fs.writeFile( + fakeGit, + `#!/bin/sh if [ "\${LC_MESSAGES-}" != "C" ]; then echo "LC_MESSAGES was \${LC_MESSAGES-}" >&2 exit 41 @@ -1118,44 +1103,45 @@ if [ "\${LC_ALL+x}" = "x" ]; then fi exec ${JSON.stringify(realGit)} "$@" `, - ); - await fs.chmod(fakeGit, 0o755); + ); + await fs.chmod(fakeGit, 0o755); - try { - process.env.PATH = fakeBin; - process.env.EXPECTED_LC_CTYPE = "C.UTF-8"; - process.env.LC_ALL = "C.UTF-8"; - delete process.env.LANG; - process.env.LC_CTYPE = ""; - delete process.env.LC_MESSAGES; - await git.diff(remoteFixture.repoRoot, { env: { LC_MESSAGES: undefined } }); + try { + process.env.PATH = fakeBin; + process.env.EXPECTED_LC_CTYPE = "C.UTF-8"; + process.env.LC_ALL = "C.UTF-8"; + delete process.env.LANG; + process.env.LC_CTYPE = ""; + delete process.env.LC_MESSAGES; + await git.diff(fixture.repoRoot, { env: { LC_MESSAGES: undefined } }); - process.env.EXPECTED_LC_CTYPE = "fr_FR.UTF-8"; - process.env.LC_ALL = "fr_FR.UTF-8"; - process.env.LC_CTYPE = "C"; - process.env.LC_MESSAGES = "fr_FR.UTF-8"; - await git.diff(remoteFixture.repoRoot, { env: { LC_MESSAGES: undefined } }); + process.env.EXPECTED_LC_CTYPE = "fr_FR.UTF-8"; + process.env.LC_ALL = "fr_FR.UTF-8"; + process.env.LC_CTYPE = "C"; + process.env.LC_MESSAGES = "fr_FR.UTF-8"; + await git.diff(fixture.repoRoot, { env: { LC_MESSAGES: undefined } }); - process.env.EXPECTED_LC_CTYPE = "UTF-8-SENTINEL"; - process.env.LC_ALL = "fr_FR.UTF-8"; - process.env.LC_CTYPE = "UTF-8-SENTINEL"; - process.env.LC_MESSAGES = "fr_FR.UTF-8"; - await git.diff(remoteFixture.repoRoot, { env: { LC_ALL: "C", LC_MESSAGES: undefined } }); - } finally { - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - for (const [key, value] of Object.entries(originalLocale)) { - if (value === undefined) { - delete process.env[key]; + process.env.EXPECTED_LC_CTYPE = "UTF-8-SENTINEL"; + process.env.LC_ALL = "fr_FR.UTF-8"; + process.env.LC_CTYPE = "UTF-8-SENTINEL"; + process.env.LC_MESSAGES = "fr_FR.UTF-8"; + await git.diff(fixture.repoRoot, { env: { LC_ALL: "C", LC_MESSAGES: undefined } }); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; } else { - process.env[key] = value; + process.env.PATH = originalPath; } + for (const [key, value] of Object.entries(originalLocale)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + await removeWithRetries(fakeBin); } - await removeWithRetries(fakeBin); - } + }); }); }); @@ -1258,8 +1244,8 @@ echo ok } }); - describe("pr_checkout (array of pull requests)", () => { - // Same beforeAll-hoisted arrange: the body only runs the array checkout. + describe("pr_checkout arrays and pr_push metadata", () => { + // One mutable fixture covers disjoint branches and worktrees for both contracts. let fixture: PrFixture; let tempHome: TempHome; beforeAll(async () => { @@ -1319,34 +1305,34 @@ echo ok expect(summaries?.map(s => s.prNumber)).toEqual([100, 200]); expect(summaries?.every(s => s.reused === false)).toBe(true); }, 30_000); - }); - describe("pr_push without checkout metadata", () => { - // Arrange a branch carrying an unpushed commit (so a stray push WOULD move - // origin) but no pr_checkout metadata — all in beforeAll, out of body time. - let fixture: PrFixture; - let originMainBefore: string; - beforeAll(async () => { - fixture = await createPrFixture(); - originMainBefore = runGit(fixture.baseDir, ["--git-dir", fixture.originBare, "rev-parse", "refs/heads/main"]); - runGit(fixture.repoRoot, ["checkout", "-b", "manual-branch", "origin/main"]); - await Bun.write(path.join(fixture.repoRoot, "README.md"), "base\nmanual\n"); - runGit(fixture.repoRoot, ["add", "README.md"]); - runGit(fixture.repoRoot, ["commit", "-m", "manual branch commit"]); - }); - afterAll(async () => { - await removeWithRetries(fixture.baseDir); - }); + describe("pr_push without checkout metadata", () => { + // Arrange a branch carrying an unpushed commit (so a stray push WOULD + // move origin) but no pr_checkout metadata. + let originMainBefore: string; + beforeAll(async () => { + originMainBefore = runGit(fixture.baseDir, [ + "--git-dir", + fixture.originBare, + "rev-parse", + "refs/heads/main", + ]); + runGit(fixture.repoRoot, ["checkout", "-b", "manual-branch", "origin/main"]); + await Bun.write(path.join(fixture.repoRoot, "README.md"), "base\nmanual\n"); + runGit(fixture.repoRoot, ["add", "README.md"]); + runGit(fixture.repoRoot, ["commit", "-m", "manual branch commit"]); + }); - it("rejects PR pushes from branches without checkout metadata", async () => { - const tool = new GithubTool(createSession(fixture.repoRoot)); - await expect(tool.execute("pr-push", { op: "pr_push" })).rejects.toThrow( - "branch manual-branch has no PR push metadata; check it out via op: pr_checkout first", - ); - // The rejection happened before any push: origin's main is untouched. - expect(runGit(fixture.baseDir, ["--git-dir", fixture.originBare, "rev-parse", "refs/heads/main"])).toBe( - originMainBefore, - ); + it("rejects PR pushes from branches without checkout metadata", async () => { + const tool = new GithubTool(createSession(fixture.repoRoot)); + await expect(tool.execute("pr-push", { op: "pr_push" })).rejects.toThrow( + "branch manual-branch has no PR push metadata; check it out via op: pr_checkout first", + ); + // The rejection happened before any push: origin's main is untouched. + expect(runGit(fixture.baseDir, ["--git-dir", fixture.originBare, "rev-parse", "refs/heads/main"])).toBe( + originMainBefore, + ); + }); }); }); diff --git a/packages/coding-agent/test/tools/github-cache.test.ts b/packages/coding-agent/test/tools/github-cache.test.ts index 865664924..778dd7916 100644 --- a/packages/coding-agent/test/tools/github-cache.test.ts +++ b/packages/coding-agent/test/tools/github-cache.test.ts @@ -444,8 +444,11 @@ describe("getOrFetchView (TTL semantics)", () => { expect(result.status).toBe("stale"); expect(result.rendered).toBe("old-diff"); - await Promise.resolve(); - await Bun.sleep(5); + for (let i = 0; i < 100; i++) { + const refreshed = getCached<{ refreshed: boolean }>(TEST_REPO, "pr-diff", 52, false); + if (refreshed?.payload.refreshed) break; + await Bun.sleep(0); + } expect(fetchFresh).toHaveBeenCalledTimes(1); const updated = getCached<{ refreshed: boolean }>(TEST_REPO, "pr-diff", 52, false); diff --git a/packages/coding-agent/test/tools/grep-internal-urls.test.ts b/packages/coding-agent/test/tools/grep-internal-urls.test.ts index ec9c122df..1dbf5c1f8 100644 --- a/packages/coding-agent/test/tools/grep-internal-urls.test.ts +++ b/packages/coding-agent/test/tools/grep-internal-urls.test.ts @@ -278,9 +278,11 @@ describe("GrepTool internal URL resolution", () => { }); it("searches a virtual resource larger than the native grep cap with chunked native RE2 (line mode)", async () => { - // >4 MiB of normal-sized lines: native grep skips the whole file, so search chunks it - // at line boundaries. An RE2 inline-flag pattern must still match — JS `RegExp` rejects `(?i)`. - const content = `${"filler line\n".repeat(380_000)}needle here\n`; + // Cross the 4 MiB native cap with a few thousand medium-sized lines instead + // of hundreds of thousands of tiny ones. The match still lands in the + // second native chunk, while fixture construction and line splitting stay cheap. + const fillerLine = `${"x".repeat(2047)}\n`; + const content = `${fillerLine.repeat(2049)}needle here\n`; registerVirtualDocs(new Map([["big.md", content]])); const tool = new GrepTool(createSession()); const result = await tool.execute("big-virtual", { pattern: "(?i)NEEDLE", path: "virtual://big.md" }); diff --git a/packages/coding-agent/test/tools/image-gen.test.ts b/packages/coding-agent/test/tools/image-gen.test.ts index 1ff0629e7..822819308 100644 --- a/packages/coding-agent/test/tools/image-gen.test.ts +++ b/packages/coding-agent/test/tools/image-gen.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "bun:test"; +import { afterAll, afterEach, describe, expect, it } from "bun:test"; import type { Model } from "@oh-my-pi/pi-ai"; import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import type { CustomToolContext } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools"; @@ -14,8 +14,11 @@ import { removeWithRetries, USER_AGENT } from "@oh-my-pi/pi-utils"; const originalOpenRouterKey = Bun.env.OPENROUTER_API_KEY; const generatedImagePaths: string[] = []; -afterEach(async () => { - await Promise.all(generatedImagePaths.splice(0).map(imagePath => removeWithRetries(imagePath))); +afterAll(async () => { + await Promise.all(generatedImagePaths.map(imagePath => removeWithRetries(imagePath))); +}); + +afterEach(() => { if (originalOpenRouterKey === undefined) { delete Bun.env.OPENROUTER_API_KEY; } else { diff --git a/packages/coding-agent/test/tools/inspect-image.test.ts b/packages/coding-agent/test/tools/inspect-image.test.ts index 93e19001d..f22305916 100644 --- a/packages/coding-agent/test/tools/inspect-image.test.ts +++ b/packages/coding-agent/test/tools/inspect-image.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterAll, beforeAll, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -64,6 +64,7 @@ function createSession( settings = Settings.isolated(), options: CreateSessionOptions = {}, ): ToolSession { + settings.set("images.autoResize", false); const availableModels = options.availableModels ?? [model]; const activeModel = options.activeModel ?? model; if (options.configureVisionRole !== false) { @@ -163,19 +164,19 @@ function createCompleteSimpleHangingStub(): CompleteSimpleStub { describe("InspectImageTool", () => { let testDir: string; + let imagePath: string; - beforeEach(() => { + beforeAll(() => { testDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-inspect-image-")); + imagePath = path.join(testDir, "screen.png"); + fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); }); - afterEach(() => { + afterAll(() => { removeSyncWithRetries(testDir); }); it("sends image and question to completeSimple and returns text-only result", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const stub = createCompleteSimpleSuccessStub("Detected text: Settings"); const tool = new InspectImageTool(createSession(testDir, visionModel), stub.fn); const result = await tool.execute("call-1", { @@ -197,9 +198,6 @@ describe("InspectImageTool", () => { }); it("passes the vision role's configured thinking effort into the oneshot", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const settings = Settings.isolated(); settings.setModelRole("vision", `${reasoningVisionModel.provider}/${reasoningVisionModel.id}:high`); @@ -343,9 +341,6 @@ describe("InspectImageTool", () => { }); it("sends question text unchanged", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const stub = createCompleteSimpleSuccessStub("Looks clear"); const tool = new InspectImageTool(createSession(testDir, visionModel), stub.fn); await tool.execute("call-1b", { path: imagePath, question: "What warning is shown?" }); @@ -405,9 +400,6 @@ describe("InspectImageTool", () => { }); it("fails when images.blockImages is enabled", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const stub = createCompleteSimpleForbiddenStub(); const settings = Settings.isolated({ "images.blockImages": true }); const tool = new InspectImageTool(createSession(testDir, visionModel, "test-key", settings), stub.fn); @@ -419,9 +411,6 @@ describe("InspectImageTool", () => { }); it("falls back to @default when vision role is unset", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const settings = Settings.isolated(); settings.setModelRole("default", `${visionModel.provider}/${visionModel.id}`); @@ -443,9 +432,6 @@ describe("InspectImageTool", () => { }); it("fails with actionable error when resolved model does not support image input", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const stub = createCompleteSimpleForbiddenStub(); const tool = new InspectImageTool(createSession(testDir, textOnlyModel), stub.fn); @@ -456,9 +442,6 @@ describe("InspectImageTool", () => { }); it("fails with actionable error when API key is missing", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const stub = createCompleteSimpleForbiddenStub(); const tool = new InspectImageTool(createSession(testDir, visionModel, ""), stub.fn); @@ -469,42 +452,40 @@ describe("InspectImageTool", () => { }); it("times out with a configured error when the vision-model call stalls", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const stub = createCompleteSimpleHangingStub(); const settings = Settings.isolated({ "inspect_image.timeoutMs": 50 }); const tool = new InspectImageTool(createSession(testDir, visionModel, "test-key", settings), stub.fn); + const timeoutController = new AbortController(); + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockImplementation(timeoutMs => { + expect(timeoutMs).toBe(50); + queueMicrotask(() => timeoutController.abort()); + return timeoutController.signal; + }); - const start = Date.now(); - await expect(tool.execute("call-timeout", { path: imagePath, question: "Anything?" })).rejects.toThrow( - /inspect_image request timed out.*inspect_image\.timeoutMs.*50ms/, - ); - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(5000); + try { + await expect(tool.execute("call-timeout", { path: imagePath, question: "Anything?" })).rejects.toThrow( + /inspect_image request timed out.*inspect_image\.timeoutMs.*50ms/, + ); + } finally { + timeoutSpy.mockRestore(); + } expect(stub.calls).toHaveLength(1); }); it("surfaces manual abort as aborted, not as timed out", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const stub = createCompleteSimpleHangingStub(); const settings = Settings.isolated({ "inspect_image.timeoutMs": 60_000 }); const tool = new InspectImageTool(createSession(testDir, visionModel, "test-key", settings), stub.fn); const controller = new AbortController(); const pending = tool.execute("call-manual-abort", { path: imagePath, question: "Anything?" }, controller.signal); - setTimeout(() => controller.abort(), 25); + queueMicrotask(() => controller.abort()); await expect(pending).rejects.toThrow(/inspect_image request aborted/); await expect(pending).rejects.not.toThrow(/timed out/); expect(stub.calls).toHaveLength(1); }); it("skips the timeout guard when inspect_image.timeoutMs is zero", async () => { - const imagePath = path.join(testDir, "screen.png"); - fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64")); - const stub = createCompleteSimpleSuccessStub("Timeout disabled path"); const settings = Settings.isolated({ "inspect_image.timeoutMs": 0 }); const tool = new InspectImageTool(createSession(testDir, visionModel, "test-key", settings), stub.fn); diff --git a/packages/coding-agent/test/tools/launch.test.ts b/packages/coding-agent/test/tools/launch.test.ts deleted file mode 100644 index a002b9db1..000000000 --- a/packages/coding-agent/test/tools/launch.test.ts +++ /dev/null @@ -1,1818 +0,0 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs/promises"; -import * as net from "node:net"; -import * as os from "node:os"; -import * as path from "node:path"; -import { createDaemonBrokerClient, type DaemonBrokerClient } from "../../src/launch/client"; -import { daemonBrokerEndpoint } from "../../src/launch/paths"; -import { registerDaemonProjectPresence } from "../../src/launch/presence"; -import type { - DaemonCompletionNotification, - DaemonOperation, - DaemonSnapshot, - DaemonSpec, -} from "../../src/launch/protocol"; - -const cleanupDirs: string[] = []; - -async function tempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - cleanupDirs.push(dir); - return dir; -} - -// Cross-process integration: fake timers cannot advance a detached broker or OS process table. -async function waitUntil(condition: () => boolean | Promise, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await condition()) return true; - await Bun.sleep(50); - } - return condition(); -} - -function processExists(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -async function shutdown(client: DaemonBrokerClient): Promise { - try { - await client.request({ op: "shutdown" }); - } catch { - // A last-client shutdown may already have closed the broker. - } - client.close(); -} - -async function publishCompletionOwner( - projectDir: string, - runtimeDir: string, - owner: string, - subscriptionId: string, - completionAcks: string[] = [], -): Promise { - const socket = net.createConnection(daemonBrokerEndpoint(projectDir, runtimeDir)); - const connected = Promise.withResolvers(); - const responded = Promise.withResolvers(); - let buffer = ""; - socket.setEncoding("utf8"); - socket.once("connect", connected.resolve); - socket.once("error", responded.reject); - socket.on("data", chunk => { - buffer += chunk; - if (buffer.includes("\n")) responded.resolve(); - }); - await connected.promise; - socket.write( - `${JSON.stringify({ - id: crypto.randomUUID(), - token: (await Bun.file(path.join(runtimeDir, "broker.token")).text()).trim(), - owners: [owner], - completionEvents: true, - completionAcks, - completionSubscriptionId: subscriptionId, - operation: { op: "ping" }, - })}\n`, - ); - await responded.promise; - socket.destroy(); -} - -async function startPtyDaemonWithShell(shell: string, initialMarker: string, expectedMarker: string): Promise { - const projectDir = await tempDir("omp-daemon-shell-project-"); - const runtimeDir = await tempDir("omp-daemon-shell-runtime-"); - const runner = ` - import { createDaemonBrokerClient } from "./src/launch/client"; - - const projectDir = ${JSON.stringify(projectDir)}; - const runtimeDir = ${JSON.stringify(runtimeDir)}; - const expectedMarker = ${JSON.stringify(expectedMarker)}; - const client = await createDaemonBrokerClient(projectDir, { - runtimeDir, - idleGraceMs: 5_000, - }); - try { - const started = await client.request({ - op: "start", - spec: { - name: "shell", - application: process.execPath, - args: [ - "-e", - "process.stdout.write(process.env.OMP_TEST_SHELL_MARKER); process.stdout.write(String.fromCharCode(10)); process.stdin.resume();", - ], - env: {}, - cwd: projectDir, - pty: true, - ready: { log: expectedMarker, timeoutMs: 5_000 }, - restart: "no", - persist: false, - detached: false, - }, - owner: "shell-test", - }); - if (started.op !== "start") throw new Error("unexpected start response"); - if (started.daemon.state !== "ready") { - const logs = await client.request({ - op: "logs", - name: "shell", - lines: 20, - head: false, - follow: false, - timeoutMs: 1_000, - }); - throw new Error( - "daemon did not become ready: " + - (started.daemon.exitReason ?? "unknown error") + - "; logs: " + - (logs.op === "logs" ? logs.text : "unavailable"), - ); - } - process.stdout.write(JSON.stringify({ state: started.daemon.state, readyTimedOut: started.readyTimedOut })); - await client.request({ op: "stop", name: "shell", timeoutMs: 2_000 }); - } finally { - try { - await client.request({ op: "shutdown" }); - } catch { - // A last-client shutdown may already have closed the broker. - } - client.close(); - } - `; - const child = Bun.spawn([process.execPath, "--eval", runner], { - cwd: path.resolve(import.meta.dir, "../.."), - env: { - ...process.env, - SHELL: shell, - OMP_TEST_SHELL_MARKER: initialMarker, - }, - stdout: "pipe", - stderr: "pipe", - }); - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" }); - expect(JSON.parse(stdout)).toEqual({ state: "ready", readyTimedOut: false }); -} - -afterEach(async () => { - while (cleanupDirs.length > 0) { - const dir = cleanupDirs.pop(); - if (dir) await fs.rm(dir, { recursive: true, force: true }); - } -}); - -describe("daemon broker", () => { - it("keeps a valid RPC response authoritative after a malformed completion", async () => { - const projectDir = await tempDir("omp-daemon-malformed-completion-project-"); - const runtimeDir = await tempDir("omp-daemon-malformed-completion-runtime-"); - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const server = net.createServer(socket => { - let buffer = ""; - socket.setEncoding("utf8"); - socket.on("data", chunk => { - buffer += chunk; - const newline = buffer.indexOf("\n"); - if (newline < 0) return; - const request: unknown = JSON.parse(buffer.slice(0, newline)); - if ( - typeof request !== "object" || - request === null || - !("id" in request) || - typeof request.id !== "string" - ) { - socket.destroy(new Error("request id missing")); - return; - } - socket.write( - `${JSON.stringify({ - event: "daemon-completed", - completionId: "malformed-completion", - owner: "completion-owner", - daemon: null, - })}\n`, - ); - socket.write( - `${JSON.stringify({ - id: request.id, - ok: true, - result: { projectDir }, - })}\n`, - ); - }); - }); - const listening = Promise.withResolvers(); - server.once("error", listening.reject); - server.listen(daemonBrokerEndpoint(projectDir, runtimeDir), listening.resolve); - await listening.promise; - try { - expect(await client.request({ op: "ping" })).toEqual({ op: "ping", projectDir }); - } finally { - client.close(); - const closed = Promise.withResolvers(); - server.close(error => { - if (error) closed.reject(error); - else closed.resolve(); - }); - await closed.promise; - } - }); - - it("shares PTY output and input across project clients", async () => { - const projectDir = await tempDir("omp-daemon-project-"); - const runtimeDir = await tempDir("omp-daemon-runtime-"); - const scriptPath = path.join(projectDir, "service.ts"); - await Bun.write( - scriptPath, - `process.stdin.setRawMode?.(true); -process.stdin.setEncoding("utf8"); -process.stdin.resume(); -process.stdout.write("\\x1b[2J\\x1b[H"); -for (let index = 0; index < 25; index++) process.stdout.write("BOOT:" + index + "\\n"); -process.stdout.write("\\x1b[1;32mREADY\\x1b[0m\\n"); -process.stdin.on("data", chunk => process.stdout.write("INPUT:" + JSON.stringify(chunk) + "\\n")); -setInterval(() => {}, 1000); -`, - ); - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - try { - const spec: DaemonSpec = { - name: "debugger", - application: process.execPath, - args: [scriptPath], - env: {}, - cwd: projectDir, - pty: true, - ready: { log: "READY", timeoutMs: 5_000 }, - restart: "no", - persist: false, - detached: false, - }; - const started = await first.request({ op: "start", spec, owner: "first-client" }); - if (started.op !== "start") throw new Error("unexpected start result"); - expect(started.readyTimedOut).toBeFalse(); - expect(started.daemon.state).toBe("ready"); - - const listed = await second.request({ op: "list" }); - if (listed.op !== "list") throw new Error("unexpected list result"); - expect(listed.daemons.map(daemon => daemon.name)).toEqual(["debugger"]); - - await second.request({ op: "send", name: "debugger", data: "run\r" }); - const waited = await first.request({ - op: "wait", - name: "debugger", - for: "exit", - pattern: "INPUT", - timeoutMs: 3_000, - }); - if (waited.op !== "wait") throw new Error("unexpected wait result"); - expect(waited.timedOut).toBeFalse(); - expect(waited.matched).toBe("INPUT"); - - const logs = await second.request({ - op: "logs", - name: "debugger", - lines: 20, - head: false, - follow: false, - timeoutMs: 1_000, - renderTerminalRows: true, - } as DaemonOperation); - if (logs.op !== "logs") throw new Error("unexpected logs result"); - expect(logs.text).toContain("READY"); - expect(logs.text).not.toContain("\x1b"); - expect(logs.text).not.toContain("BOOT:0"); - expect(logs.text).toContain('INPUT:"run\\r"'); - const expectedTerminalRows = [ - ...Array.from({ length: 18 }, (_, index) => `\x1b[0mBOOT:${index + 7}`), - "\x1b[0m\x1b[1;38;5;2mREADY", - '\x1b[0mINPUT:"run\\r"', - ]; - expect(logs.terminalRows).toEqual(expectedTerminalRows); - - const legacyLogs = await second.request({ - op: "logs", - name: "debugger", - lines: 20, - head: false, - follow: false, - timeoutMs: 1_000, - }); - if (legacyLogs.op !== "logs") throw new Error("unexpected legacy logs result"); - expect("terminalText" in legacyLogs ? legacyLogs.terminalText : undefined).toContain("BOOT:0"); - expect(legacyLogs.terminalRows).toBeUndefined(); - - const grepped = await second.request({ - op: "logs", - name: "debugger", - lines: 20, - head: false, - grep: "READY", - follow: false, - timeoutMs: 1_000, - }); - if (grepped.op !== "logs") throw new Error("unexpected grep logs result"); - expect(grepped.text).toContain("READY"); - expect(grepped.terminalRows).toBeUndefined(); - - const stopped = await first.request({ op: "stop", name: "debugger", timeoutMs: 2_000 }); - if (stopped.op !== "stop") throw new Error("unexpected stop result"); - expect(stopped.daemon.state).toBe("exited"); - } finally { - await shutdown(first); - second.close(); - } - }, 20_000); - - it("omits terminal rows for non-PTY logs", async () => { - const projectDir = await tempDir("omp-daemon-plain-project-"); - const runtimeDir = await tempDir("omp-daemon-plain-runtime-"); - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - try { - const started = await client.request({ - op: "start", - spec: { - name: "plain", - application: process.execPath, - args: ["-e", 'process.stdout.write("\\x1b[31mPLAIN\\x1b[0m\\n"); process.stdin.resume();'], - env: {}, - cwd: projectDir, - pty: false, - ready: { log: "PLAIN", timeoutMs: 5_000 }, - restart: "no", - persist: false, - detached: false, - }, - }); - if (started.op !== "start") throw new Error("unexpected start result"); - expect(started.readyTimedOut).toBeFalse(); - - const logs = await client.request({ - op: "logs", - name: "plain", - lines: 20, - head: false, - follow: false, - timeoutMs: 1_000, - }); - if (logs.op !== "logs") throw new Error("unexpected logs result"); - expect(logs.text).toBe("PLAIN\n"); - expect(logs.terminalRows).toBeUndefined(); - - await client.request({ op: "stop", name: "plain", timeoutMs: 2_000 }); - } finally { - await shutdown(client); - } - }, 20_000); - - it("uses a basic shell when the login shell cannot run POSIX commands", async () => { - if (process.platform === "win32") return; - const shellPath = path.join(await tempDir("omp-daemon-nonposix-shell-"), "csh"); - await Bun.write(shellPath, "#!/bin/sh\nexit 1\n"); - await fs.chmod(shellPath, 0o755); - - await startPtyDaemonWithShell(shellPath, "basic-shell", "basic-shell"); - }, 20_000); - - it("preserves compatible login shells for PTY daemons", async () => { - if (process.platform === "win32") return; - const shellPath = path.join(await tempDir("omp-daemon-posix-shell-"), "zsh"); - await Bun.write(shellPath, '#!/bin/sh\nexport OMP_TEST_SHELL_MARKER="compatible-shell"\nexec /bin/sh "$@"\n'); - await fs.chmod(shellPath, 0o755); - - await startPtyDaemonWithShell(shellPath, "basic-shell", "compatible-shell"); - }, 20_000); - - it("returns promptly when a finite PTY child does not write the broker PID file", async () => { - if (process.platform === "win32") return; - const shellPath = path.join(await tempDir("omp-daemon-no-pid-shell-"), "zsh"); - await Bun.write( - shellPath, - `#!/bin/sh -case "$2" in - *process.pid*) - command=\${2#*; exec } - exec /bin/sh -c "exec $command" - ;; - *) - exec /bin/sh "$@" - ;; -esac -`, - ); - await fs.chmod(shellPath, 0o755); - const projectDir = await tempDir("omp-daemon-finite-project-"); - const runtimeDir = await tempDir("omp-daemon-finite-runtime-"); - const runner = ` - import { createDaemonBrokerClient } from "./src/launch/client"; - - const client = await createDaemonBrokerClient(${JSON.stringify(projectDir)}, { - runtimeDir: ${JSON.stringify(runtimeDir)}, - idleGraceMs: 5_000, - }); - try { - const startedAt = performance.now(); - const started = await client.request({ - op: "start", - spec: { - name: "finite-pty", - application: "/bin/sh", - args: ["-c", "sleep 5"], - env: {}, - cwd: ${JSON.stringify(projectDir)}, - pty: true, - restart: "no", - persist: false, - detached: false, - }, - }); - if (started.op !== "start") throw new Error("unexpected start response"); - process.stdout.write(JSON.stringify({ - elapsedMs: Math.round(performance.now() - startedAt), - state: started.daemon.state, - pid: started.daemon.pid, - })); - if (started.daemon.state === "running") { - await client.request({ op: "stop", name: "finite-pty", timeoutMs: 2_000 }); - } - } finally { - try { - await client.request({ op: "shutdown" }); - } catch {} - client.close(); - } - `; - const child = Bun.spawn([process.execPath, "--eval", runner], { - cwd: path.resolve(import.meta.dir, "../.."), - env: { ...process.env, SHELL: shellPath }, - stdout: "pipe", - stderr: "pipe", - }); - const [exitCode, stdout, stderr] = await Promise.all([ - child.exited, - new Response(child.stdout).text(), - new Response(child.stderr).text(), - ]); - expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" }); - const started = JSON.parse(stdout) as { elapsedMs: number; state: string; pid?: number }; - // This is cross-process startup latency; fake timers cannot drive the broker or PTY child. - expect(started.elapsedMs).toBeLessThan(3_000); - expect(started.state).toBe("running"); - expect(started.pid).toBeGreaterThan(0); - }, 20_000); - - it("stops non-persistent daemons after the last project omp exits", async () => { - const projectDir = await tempDir("omp-daemon-exit-project-"); - const runtimeDir = await tempDir("omp-daemon-exit-runtime-"); - const scriptPath = path.join(projectDir, "service.ts"); - await Bun.write(scriptPath, `process.stdout.write("READY\\n"); setInterval(() => {}, 1000);\n`); - const presence = await registerDaemonProjectPresence(projectDir, runtimeDir); - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - let pid: number | undefined; - try { - const started = await first.request({ - op: "start", - spec: { - name: "server", - application: process.execPath, - args: [scriptPath], - env: {}, - cwd: projectDir, - pty: false, - ready: { log: "READY", timeoutMs: 5_000 }, - restart: "no", - persist: false, - detached: false, - }, - }); - if (started.op !== "start" || started.daemon.pid === undefined) throw new Error("daemon did not start"); - const daemonPid = started.daemon.pid; - pid = daemonPid; - await second.request({ op: "list" }); - - first.close(); - second.close(); - // Cross-process integration: the real broker grace clock cannot be advanced with test fake timers. - await Bun.sleep(500); - expect(processExists(daemonPid)).toBeTrue(); - - await presence.close(); - const stopped = await waitUntil(() => !processExists(daemonPid), 5_000); - const socketRemoved = await waitUntil( - () => - Bun.file(path.join(runtimeDir, "broker.sock")) - .exists() - .then(exists => !exists), - 5_000, - ); - expect(stopped).toBeTrue(); - expect(socketRemoved).toBeTrue(); - } finally { - first.close(); - second.close(); - await presence.close(); - if (pid !== undefined && processExists(pid)) { - const rescue = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 1_000 }); - await shutdown(rescue); - } - } - }, 20_000); - - it("keeps detached daemons alive through broker replacement", async () => { - const projectDir = await tempDir("omp-daemon-detached-project-"); - const runtimeDir = await tempDir("omp-daemon-detached-runtime-"); - const scriptPath = path.join(projectDir, "service.ts"); - await Bun.write(scriptPath, `process.stdout.write("READY\\n"); setInterval(() => {}, 1000);\n`); - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - let recovered: DaemonBrokerClient | undefined; - let pid: number | undefined; - try { - const started = await first.request({ - op: "start", - spec: { - name: "detached", - application: process.execPath, - args: [scriptPath], - env: {}, - cwd: projectDir, - pty: false, - ready: { log: "READY", timeoutMs: 5_000 }, - restart: "no", - persist: false, - detached: true, - }, - }); - if (started.op !== "start" || started.daemon.pid === undefined) - throw new Error("detached daemon did not start"); - pid = started.daemon.pid; - expect(started.daemon.persist).toBeTrue(); - expect(started.daemon.detached).toBeTrue(); - - await first.request({ op: "shutdown" }); - first.close(); - // Broker shutdown happens in another process, so fake timers cannot observe its lease release. - const brokerStopped = await waitUntil( - () => - Bun.file(path.join(runtimeDir, "broker.pid")) - .exists() - .then(exists => !exists), - 5_000, - ); - expect(brokerStopped).toBeTrue(); - expect(processExists(pid)).toBeTrue(); - - recovered = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const described = await recovered.request({ op: "describe", name: "detached" }); - if (described.op !== "describe") throw new Error("detached daemon did not recover"); - expect(described.daemon.pid).toBe(pid); - expect(described.daemon.detached).toBeTrue(); - expect(described.spec.persist).toBeTrue(); - - const stopped = await recovered.request({ op: "stop", name: "detached", timeoutMs: 2_000 }); - if (stopped.op !== "stop") throw new Error("detached daemon did not stop"); - expect(stopped.daemon.state).toBe("exited"); - await shutdown(recovered); - recovered = undefined; - } finally { - first.close(); - recovered?.close(); - if (pid !== undefined && processExists(pid)) { - const rescue = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 1_000 }); - try { - await rescue.request({ op: "stop", name: "detached", timeoutMs: 2_000 }); - } finally { - await shutdown(rescue); - } - } - } - }, 20_000); - - it("reports a recovered detached daemon exit without a polling RPC", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-recovered-exit-project-"); - const runtimeDir = await tempDir("omp-daemon-recovered-exit-runtime-"); - const owner = "recovered-detached-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - let pid: number | undefined; - let completion: DaemonSnapshot | undefined; - const unregister = first.onCompletion(owner, notification => { - completion = notification.daemon; - }); - try { - const started = await first.request({ - op: "start", - spec: { - name: "recovered-exit", - application: "/bin/sh", - args: ["-c", "sleep 1.5; exit 7"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: true, - }, - owner, - }); - if (started.op !== "start" || started.daemon.pid === undefined) - throw new Error("detached daemon did not start"); - pid = started.daemon.pid; - await first.request({ op: "shutdown" }); - expect( - await waitUntil( - () => - Bun.file(path.join(runtimeDir, "broker.pid")) - .exists() - .then(exists => !exists), - 5_000, - ), - ).toBeTrue(); - const launchedPid = pid; - expect(processExists(launchedPid)).toBeTrue(); - - expect(await waitUntil(() => completion !== undefined, 5_000)).toBeTrue(); - expect(completion).toMatchObject({ name: "recovered-exit", state: "exited", exitCode: undefined }); - } finally { - unregister(); - if (completion !== undefined) await shutdown(first); - else first.close(); - if (pid !== undefined && processExists(pid)) { - const rescue = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 1_000 }); - try { - await rescue.request({ op: "stop", name: "recovered-exit", timeoutMs: 2_000 }); - } finally { - await shutdown(rescue); - } - } - } - }, 15_000); - - it("replays a detached exit that precedes broker recovery", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-pre-recovery-exit-project-"); - const runtimeDir = await tempDir("omp-daemon-pre-recovery-exit-runtime-"); - const owner = "pre-recovery-exit-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - let recovered: DaemonBrokerClient | undefined; - let unregister: (() => void) | undefined; - let pid: number | undefined; - try { - first.onCompletion(owner, () => {}); - const started = await first.request({ - op: "start", - spec: { - name: "pre-recovery-exit", - application: "/bin/sh", - args: ["-c", "sleep 0.2; exit 7"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: true, - }, - owner, - }); - if (started.op !== "start" || started.daemon.pid === undefined) - throw new Error("detached daemon did not start"); - pid = started.daemon.pid; - await first.request({ op: "shutdown" }); - first.close(); - expect(await waitUntil(() => !processExists(pid!), 3_000)).toBeTrue(); - - recovered = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const completions: DaemonSnapshot[] = []; - unregister = recovered.onCompletion(owner, notification => { - completions.push(notification.daemon); - }); - await recovered.request({ op: "list" }); - expect(await waitUntil(() => completions.length === 1, 2_000)).toBeTrue(); - expect(completions[0]).toMatchObject({ name: "pre-recovery-exit", state: "exited" }); - } finally { - unregister?.(); - first.close(); - if (recovered) await shutdown(recovered); - if (pid !== undefined && processExists(pid)) process.kill(pid, "SIGKILL"); - } - }, 12_000); - - it("replays every pending generation completion after broker recovery", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-pending-recovery-project-"); - const runtimeDir = await tempDir("omp-daemon-pending-recovery-runtime-"); - const owner = "pending-recovery-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 500 }); - let controller: DaemonBrokerClient | undefined; - let recovered: DaemonBrokerClient | undefined; - let unregister: (() => void) | undefined; - const completions: DaemonSnapshot[] = []; - try { - first.onCompletion(owner, () => {}); - const started = await first.request({ - op: "start", - spec: { - name: "pending-recovery-exit", - application: "/bin/sh", - args: ["-c", "sleep 0.15; exit 7"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: true, - }, - owner, - }); - if (started.op !== "start") throw new Error("detached daemon did not start"); - first.close(); - const metaPath = path.join(runtimeDir, "daemons", "pending-recovery-exit", "meta.json"); - expect( - await waitUntil(async () => { - const meta = (await Bun.file(metaPath).json()) as { pendingCompletions?: unknown[] }; - return meta.pendingCompletions?.length === 1; - }, 3_000), - ).toBeTrue(); - - controller = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - await controller.request({ op: "restart", name: "pending-recovery-exit" }); - expect( - await waitUntil(async () => { - const meta = (await Bun.file(metaPath).json()) as { pendingCompletions?: unknown[] }; - return meta.pendingCompletions?.length === 2; - }, 3_000), - ).toBeTrue(); - const persisted = (await Bun.file(metaPath).json()) as { - daemon: DaemonSnapshot; - pendingCompletions: DaemonCompletionNotification[]; - [key: string]: unknown; - }; - expect(new Set(persisted.pendingCompletions.map(completion => completion.completionId)).size).toBe(2); - await Bun.write( - metaPath, - JSON.stringify({ - ...persisted, - daemon: { - ...persisted.daemon, - state: "running", - exitCode: undefined, - exitedAt: undefined, - }, - }), - ); - const brokerPidPath = path.join(runtimeDir, "broker.pid"); - const { pid: brokerPid } = (await Bun.file(brokerPidPath).json()) as { pid: number }; - process.kill(brokerPid, "SIGKILL"); - expect(await waitUntil(() => !processExists(brokerPid), 3_000)).toBeTrue(); - - recovered = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - unregister = recovered.onCompletion(owner, notification => { - completions.push(notification.daemon); - }); - await recovered.request({ op: "list" }); - - expect(await waitUntil(() => completions.length === 2, 2_000)).toBeTrue(); - expect(completions.map(completion => completion.name)).toEqual([ - "pending-recovery-exit", - "pending-recovery-exit", - ]); - expect(completions).toEqual([ - expect.objectContaining({ state: "failed", exitCode: 7 }), - expect.objectContaining({ state: "failed", exitCode: 7 }), - ]); - expect( - await waitUntil(async () => { - const metadata = (await Bun.file(metaPath).json()) as { - completionPending?: boolean; - pendingCompletions?: unknown[]; - }; - return metadata.completionPending === false && metadata.pendingCompletions?.length === 0; - }, 2_000), - ).toBeTrue(); - } finally { - unregister?.(); - first.close(); - controller?.close(); - if (recovered) await shutdown(recovered); - } - }, 12_000); - - it("replays a zero-width completion without poisoning the next start", async () => { - const projectDir = await tempDir("omp-daemon-empty-ready-project-"); - const runtimeDir = await tempDir("omp-daemon-empty-ready-runtime-"); - const markerPath = path.join(projectDir, "victim-ran"); - const owner = "empty-ready-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - let recovered: DaemonBrokerClient | undefined; - let victimError: Error | undefined; - try { - first.onCompletion(owner, () => { - throw new Error("leave completion pending for reconnect"); - }); - await first.request({ op: "ping" }); - await first - .request({ - op: "start", - spec: { - name: "empty-ready-poison", - application: process.execPath, - args: ["-e", 'console.log("READY")'], - env: {}, - cwd: projectDir, - pty: false, - ready: { log: "^", timeoutMs: 5_000 }, - restart: "no", - persist: false, - detached: false, - }, - owner, - }) - .catch(() => undefined); - const metaPath = path.join(runtimeDir, "daemons", "empty-ready-poison", "meta.json"); - expect( - await waitUntil(async () => { - const metadata: unknown = await Bun.file(metaPath).json(); - return ( - typeof metadata === "object" && - metadata !== null && - "completionPending" in metadata && - metadata.completionPending === true - ); - }, 3_000), - ).toBeTrue(); - first.close(); - - const completions: DaemonSnapshot[] = []; - recovered = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - recovered.onCompletion(owner, notification => { - completions.push(notification.daemon); - }); - const victim = await recovered - .request({ - op: "start", - spec: { - name: "empty-ready-victim", - application: process.execPath, - args: ["-e", `await Bun.write(${JSON.stringify(markerPath)}, "yes"); console.log("SECOND")`], - env: {}, - cwd: projectDir, - pty: false, - ready: { log: "SECOND", timeoutMs: 5_000 }, - restart: "no", - persist: false, - detached: false, - }, - }) - .catch(error => { - victimError = error instanceof Error ? error : new Error(String(error)); - return undefined; - }); - - expect(await waitUntil(() => Bun.file(markerPath).exists(), 3_000)).toBeTrue(); - expect(victimError).toBeUndefined(); - if (victim?.op !== "start") throw new Error("victim start result missing"); - expect(victim.daemon).toMatchObject({ name: "empty-ready-victim", readyMatch: "SECOND" }); - expect(await waitUntil(() => completions.length === 1, 2_000)).toBeTrue(); - expect(completions[0]).toMatchObject({ name: "empty-ready-poison", readyMatch: "", state: "exited" }); - } finally { - first.close(); - if (recovered) await shutdown(recovered); - } - }, 12_000); - - it("replays a recovered non-detached daemon exit", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-attached-recovery-project-"); - const runtimeDir = await tempDir("omp-daemon-attached-recovery-runtime-"); - const owner = "attached-recovery-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - let recovered: DaemonBrokerClient | undefined; - let unregister: (() => void) | undefined; - let daemonPid: number | undefined; - try { - first.onCompletion(owner, () => {}); - const started = await first.request({ - op: "start", - spec: { - name: "attached-recovery-exit", - application: "/bin/sh", - args: ["-c", "sleep 30"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - if (started.op !== "start" || started.daemon.pid === undefined) throw new Error("daemon did not start"); - daemonPid = started.daemon.pid; - const { pid: brokerPid } = (await Bun.file(path.join(runtimeDir, "broker.pid")).json()) as { pid: number }; - process.kill(brokerPid, "SIGKILL"); - expect(await waitUntil(() => !processExists(brokerPid), 3_000)).toBeTrue(); - first.close(); - - recovered = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const completions: DaemonSnapshot[] = []; - unregister = recovered.onCompletion(owner, notification => { - completions.push(notification.daemon); - }); - await recovered.request({ op: "list" }); - - expect(await waitUntil(() => completions.length === 1, 3_000)).toBeTrue(); - expect(completions[0]).toMatchObject({ name: "attached-recovery-exit", state: "exited" }); - } finally { - unregister?.(); - first.close(); - if (recovered) await shutdown(recovered); - if (daemonPid !== undefined && processExists(daemonPid)) process.kill(daemonPid, "SIGKILL"); - } - }, 12_000); - - // Regression: a start whose log pattern matched but whose port never accepted - // used to report "Ready: " AND "Readiness timed out" with no hint of - // which condition failed. The snapshot now names the unmet condition(s). - it("names the unmet readiness condition when start times out", async () => { - const projectDir = await tempDir("omp-daemon-ready-project-"); - const runtimeDir = await tempDir("omp-daemon-ready-runtime-"); - const scriptPath = path.join(projectDir, "service.ts"); - await Bun.write(scriptPath, `process.stdout.write("LISTENING\\n"); setInterval(() => {}, 1000);\n`); - // Reserve an ephemeral port and release it so nothing accepts connections there. - const probe = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } }); - const deadPort = probe.port; - probe.stop(true); - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - try { - const spec: DaemonSpec = { - name: "never-ready", - application: process.execPath, - args: [scriptPath], - env: {}, - cwd: projectDir, - pty: false, - ready: { log: "LISTENING", port: deadPort, timeoutMs: 3_000 }, - restart: "no", - persist: false, - detached: false, - }; - const started = await client.request({ op: "start", spec }); - if (started.op !== "start") throw new Error("unexpected start result"); - expect(started.readyTimedOut).toBeTrue(); - expect(started.daemon.state).toBe("starting"); - expect(started.daemon.readyMatch).toBe("LISTENING"); - expect(started.daemon.readyPending).toEqual(["port"]); - - const stopped = await client.request({ op: "stop", name: "never-ready", timeoutMs: 2_000 }); - if (stopped.op !== "stop") throw new Error("unexpected stop result"); - // Terminal states carry no stale readiness noise. - expect(stopped.daemon.readyPending).toBeUndefined(); - } finally { - await shutdown(client); - } - }, 20_000); - - // Regression: a process that flips starting→ready→exited within one 50ms poll - // interval used to hang `start` for the full readiness timeout, because - // #waitUntil sampled the live (already "exited") state instead of the sticky - // readyAt marker #markReady durably recorded. - it("returns promptly when the process becomes ready then exits within a poll", async () => { - const projectDir = await tempDir("omp-daemon-fast-project-"); - const runtimeDir = await tempDir("omp-daemon-fast-runtime-"); - const scriptPath = path.join(projectDir, "fast.ts"); - await Bun.write(scriptPath, `process.stdout.write("done\\n");\n`); - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - try { - const spec: DaemonSpec = { - name: "fast", - application: process.execPath, - args: [scriptPath], - env: {}, - cwd: projectDir, - pty: false, - ready: { log: ".+", timeoutMs: 60_000 }, - restart: "no", - persist: false, - detached: false, - }; - const t0 = Date.now(); - const started = await client.request({ op: "start", spec }); - const elapsed = Date.now() - t0; - if (started.op !== "start") throw new Error("unexpected start result"); - // Woke on readyAt/terminal, not the full 60s timeout. - expect(elapsed).toBeLessThan(10_000); - expect(started.readyTimedOut).toBeFalse(); - expect(started.daemon.readyAt).toBeDefined(); - - // A for:"ready" wait on the settled daemon reports success via the sticky - // readyAt marker even though the process has already exited. - const waited = await client.request({ - op: "wait", - name: "fast", - for: "ready", - timeoutMs: 60_000, - }); - if (waited.op !== "wait") throw new Error("unexpected wait result"); - expect(waited.timedOut).toBeFalse(); - expect(waited.daemon.readyAt).toBeDefined(); - } finally { - await shutdown(client); - } - }, 20_000); - - // Regression: a process that exits before ever becoming ready used to block the - // caller for the full timeout, and a for:"ready" wait on the settled daemon did - // the same. Terminal states now wake both waits immediately. - it('wakes start and for:"ready" waits when the process exits before readiness', async () => { - const projectDir = await tempDir("omp-daemon-preexit-project-"); - const runtimeDir = await tempDir("omp-daemon-preexit-runtime-"); - const scriptPath = path.join(projectDir, "preexit.ts"); - // Exits without ever printing the ready pattern. - await Bun.write(scriptPath, `process.stdout.write("nope\\n"); process.exit(0);\n`); - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - try { - const spec: DaemonSpec = { - name: "preexit", - application: process.execPath, - args: [scriptPath], - env: {}, - cwd: projectDir, - pty: false, - ready: { log: "LISTENING", timeoutMs: 60_000 }, - restart: "no", - persist: false, - detached: false, - }; - const t0 = Date.now(); - const started = await client.request({ op: "start", spec }); - const startElapsed = Date.now() - t0; - if (started.op !== "start") throw new Error("unexpected start result"); - expect(startElapsed).toBeLessThan(10_000); - // Woke on the terminal exit rather than timing out; the readyAt marker is - // absent because the ready pattern never matched. - expect(started.readyTimedOut).toBeFalse(); - expect(started.daemon.readyAt).toBeUndefined(); - expect(["exited", "failed"]).toContain(started.daemon.state); - - // A for:"ready" wait on the already-settled daemon must wake immediately, - // but a process that never became ready is surfaced as not ready - // (timedOut) so callers don't chain work against a dead process. - const t1 = Date.now(); - const waited = await client.request({ - op: "wait", - name: "preexit", - for: "ready", - timeoutMs: 60_000, - }); - const waitElapsed = Date.now() - t1; - if (waited.op !== "wait") throw new Error("unexpected wait result"); - expect(waitElapsed).toBeLessThan(10_000); - expect(waited.timedOut).toBeTrue(); - expect(waited.daemon.readyAt).toBeUndefined(); - } finally { - await shutdown(client); - } - }, 20_000); - - // Regression (PR #6305 review): readyAt belongs to the exited generation, so a - // daemon in the restart backoff window must not report readiness. #settle now - // clears readyAt/readyMatch when entering "restarting"; without that, start and - // for:"ready" waits race a dead service during the backoff. - it("clears stale readiness while a daemon is restarting", async () => { - const projectDir = await tempDir("omp-daemon-restart-project-"); - const runtimeDir = await tempDir("omp-daemon-restart-runtime-"); - const scriptPath = path.join(projectDir, "flap.ts"); - // Becomes ready (prints the pattern), then crashes shortly after. - await Bun.write(scriptPath, `process.stdout.write("READY\\n"); setTimeout(() => process.exit(1), 50);\n`); - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - try { - const spec: DaemonSpec = { - name: "flap", - application: process.execPath, - args: [scriptPath], - env: {}, - cwd: projectDir, - pty: false, - ready: { log: "READY", timeoutMs: 60_000 }, - restart: "on-failure", - persist: false, - detached: false, - }; - const started = await client.request({ op: "start", spec }); - if (started.op !== "start") throw new Error("unexpected start result"); - expect(started.daemon.readyAt).toBeDefined(); - - // Catch the backoff window: once restarting, readiness must be cleared. - const restarting = await waitUntil(async () => { - const listed = await client.request({ op: "list" }); - if (listed.op !== "list") return false; - const daemon = listed.daemons.find(d => d.name === "flap"); - return daemon?.state === "restarting"; - }, 15_000); - expect(restarting).toBeTrue(); - const listed = await client.request({ op: "list" }); - if (listed.op !== "list") throw new Error("unexpected list result"); - const daemon = listed.daemons.find(d => d.name === "flap"); - expect(daemon?.state).toBe("restarting"); - expect(daemon?.readyAt).toBeUndefined(); - expect(daemon?.readyMatch).toBeUndefined(); - - await client.request({ op: "stop", name: "flap", timeoutMs: 2_000 }); - } finally { - await shutdown(client); - } - }, 30_000); - it("delivers owner completions for spontaneous final exits only", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-completion-project-"); - const runtimeDir = await tempDir("omp-daemon-completion-runtime-"); - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const owner = "completion-owner"; - const completions: DaemonSnapshot[] = []; - let resolveNext: ((daemon: DaemonSnapshot) => void) | undefined; - const nextCompletion = (): Promise => { - const { promise, resolve } = Promise.withResolvers(); - resolveNext = resolve; - return promise; - }; - const unregister = client.onCompletion(owner, notification => { - completions.push(notification.daemon); - resolveNext?.(notification.daemon); - resolveNext = undefined; - }); - const startSpec = (name: string, command: string, restart: DaemonSpec["restart"]): DaemonSpec => ({ - name, - application: "/bin/sh", - args: ["-c", command], - env: {}, - cwd: projectDir, - pty: false, - restart, - persist: false, - detached: false, - }); - try { - const successPending = nextCompletion(); - await client.request({ - op: "start", - spec: startSpec("success", "exit 0", "no"), - owner, - }); - const success = await successPending; - expect(success.state).toBe("exited"); - expect(success.exitCode).toBe(0); - await client.request({ op: "list" }); - expect(completions).toHaveLength(1); - const failurePending = nextCompletion(); - await client.request({ - op: "start", - spec: startSpec("failure", "exit 7", "no"), - owner, - }); - const failure = await failurePending; - expect(failure.state).toBe("failed"); - expect(failure.exitCode).toBe(7); - - const beforeExplicitRestart = completions.length; - await client.request({ - op: "start", - spec: startSpec("explicit-restart", "while true; do sleep 1; done", "no"), - owner, - }); - await client.request({ op: "restart", name: "explicit-restart" }); - expect(completions).toHaveLength(beforeExplicitRestart); - await client.request({ op: "stop", name: "explicit-restart", timeoutMs: 2_000 }); - expect(completions).toHaveLength(beforeExplicitRestart); - - const beforeRestart = completions.length; - await client.request({ - op: "start", - spec: startSpec("restart", "exit 0", "always"), - owner, - }); - const restarting = await waitUntil(async () => { - const listed = await client.request({ op: "list" }); - if (listed.op !== "list") return false; - return listed.daemons.find(daemon => daemon.name === "restart")?.state === "restarting"; - }, 3_000); - expect(restarting).toBeTrue(); - expect(completions).toHaveLength(beforeRestart); - await client.request({ op: "stop", name: "restart", timeoutMs: 2_000 }); - expect(completions).toHaveLength(beforeRestart); - } finally { - unregister(); - await shutdown(client); - } - }, 9_000); - - it("acknowledges a completion only after its consumer accepts delivery", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-sink-acceptance-project-"); - const runtimeDir = await tempDir("omp-daemon-sink-acceptance-runtime-"); - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const owner = "delayed-owner"; - const delivered = Promise.withResolvers(); - const accepted = Promise.withResolvers(); - const unregister = client.onCompletion(owner, async () => { - delivered.resolve(); - await accepted.promise; - }); - const metaPath = path.join(runtimeDir, "daemons", "delayed-sink", "meta.json"); - try { - await client.request({ - op: "start", - spec: { - name: "delayed-sink", - application: "/bin/sh", - args: ["-c", "exit 0"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - await delivered.promise; - const pending = (await Bun.file(metaPath).json()) as { pendingCompletions?: unknown[] }; - expect(pending.pendingCompletions).toHaveLength(1); - - accepted.resolve(); - expect( - await waitUntil(async () => { - const metadata = (await Bun.file(metaPath).json()) as { pendingCompletions?: unknown[] }; - return metadata.pendingCompletions?.length === 0; - }, 2_000), - ).toBeTrue(); - } finally { - unregister(); - await shutdown(client); - } - }, 9_000); - - it("replays an unacknowledged completion after the owner reconnects", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-reconnect-project-"); - const runtimeDir = await tempDir("omp-daemon-reconnect-runtime-"); - const owner = "reconnect-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - first.onCompletion(owner, () => {}); - await first.request({ - op: "start", - spec: { - name: "gap-exit", - application: "/bin/sh", - args: ["-c", "sleep 0.2; exit 7"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - first.close(); - await Bun.sleep(400); - - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const completions: DaemonSnapshot[] = []; - const received = Promise.withResolvers(); - const unregister = second.onCompletion(owner, notification => { - completions.push(notification.daemon); - received.resolve(); - }); - try { - await second.request({ op: "list" }); - await received.promise; - await second.request({ op: "list" }); - expect(completions).toHaveLength(1); - expect(completions[0]).toMatchObject({ name: "gap-exit", state: "failed", exitCode: 7 }); - } finally { - unregister(); - await shutdown(second); - } - }, 9_000); - it("prevents daemon name reuse until pending completions are acknowledged", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-pending-name-project-"); - const runtimeDir = await tempDir("omp-daemon-pending-name-runtime-"); - const owner = "pending-name-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - first.onCompletion(owner, () => {}); - const spec = { - name: "pending-name", - application: "/bin/sh", - args: ["-c", "sleep 0.2; exit 0"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no" as const, - persist: false, - detached: false, - }; - await first.request({ op: "start", spec, owner }); - first.close(); - await Bun.sleep(400); - - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - let unregister: (() => void) | undefined; - try { - await expect(second.request({ op: "start", spec })).rejects.toThrow( - "Daemon pending-name has unacknowledged completion notifications", - ); - const received = Promise.withResolvers(); - unregister = second.onCompletion(owner, () => received.resolve()); - await second.request({ op: "list" }); - await received.promise; - await second.request({ op: "list" }); - const restarted = await second.request({ op: "start", spec }); - expect(restarted).toMatchObject({ op: "start", daemon: { name: "pending-name" } }); - } finally { - unregister?.(); - await shutdown(second); - } - }, 9_000); - it("publishes a restored owner subscription without another caller request", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-restored-owner-project-"); - const runtimeDir = await tempDir("omp-daemon-restored-owner-runtime-"); - const owner = "restored-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - let completion: DaemonSnapshot | undefined; - let unregister: (() => void) | undefined; - try { - await first.request({ - op: "start", - spec: { - name: "restored-owner-exit", - application: "/bin/sh", - args: ["-c", "sleep 0.3; exit 0"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - await second.request({ op: "ping" }); - unregister = second.onCompletion(owner, notification => { - completion = notification.daemon; - }); - - expect(await waitUntil(() => completion !== undefined, 3_000)).toBeTrue(); - expect(completion).toMatchObject({ name: "restored-owner-exit", state: "exited", exitCode: 0 }); - } finally { - unregister?.(); - first.close(); - await shutdown(second); - } - }, 9_000); - - it("ignores an unsubscribe from a superseded owner subscription", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-superseded-owner-project-"); - const runtimeDir = await tempDir("omp-daemon-superseded-owner-runtime-"); - const owner = "shared-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const unregisterFirst = first.onCompletion(owner, () => {}); - let unregisterSecond: (() => void) | undefined; - let completion: DaemonSnapshot | undefined; - try { - await first.request({ - op: "start", - spec: { - name: "superseded-owner-exit", - application: "/bin/sh", - args: ["-c", "sleep 1; exit 0"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - const metaPath = path.join(runtimeDir, "daemons", "superseded-owner-exit", "meta.json"); - const firstMetadata = (await Bun.file(metaPath).json()) as { completionSubscriptionId?: string }; - if (!firstMetadata.completionSubscriptionId) - throw new Error("first completion subscription was not persisted"); - unregisterSecond = second.onCompletion(owner, notification => { - completion = notification.daemon; - }); - const replacementSubscriptionId = crypto.randomUUID(); - await publishCompletionOwner(projectDir, runtimeDir, owner, replacementSubscriptionId); - const replacementMetadata = (await Bun.file(metaPath).json()) as { - completionEvents?: boolean; - completionSubscriptionId?: string; - }; - expect(replacementMetadata).toMatchObject({ - completionEvents: true, - completionSubscriptionId: replacementSubscriptionId, - }); - await second.request({ op: "ping" }); - await publishCompletionOwner(projectDir, runtimeDir, owner, firstMetadata.completionSubscriptionId, [ - "stale-completion", - ]); - unregisterFirst(); - - expect(await waitUntil(() => completion !== undefined, 3_000)).toBeTrue(); - expect(completion).toMatchObject({ name: "superseded-owner-exit", state: "exited", exitCode: 0 }); - } finally { - unregisterFirst(); - unregisterSecond?.(); - first.close(); - await shutdown(second); - } - }, 9_000); - it("preserves a superseding owner subscription through broker recovery", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-recovered-subscription-project-"); - const runtimeDir = await tempDir("omp-daemon-recovered-subscription-runtime-"); - const owner = "recovered-shared-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const unregisterFirst = first.onCompletion(owner, () => {}); - let unregisterSecond: (() => void) | undefined; - let completion: DaemonSnapshot | undefined; - let pid: number | undefined; - try { - await first.request({ op: "ping" }); - unregisterSecond = second.onCompletion(owner, notification => { - completion = notification.daemon; - }); - const started = await second.request({ - op: "start", - spec: { - name: "recovered-superseded-owner-exit", - application: "/bin/sh", - args: ["-c", "sleep 1.5; exit 0"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: true, - }, - owner, - }); - if (started.op !== "start" || started.daemon.pid === undefined) { - throw new Error("detached daemon did not start"); - } - pid = started.daemon.pid; - const metaPath = path.join(runtimeDir, "daemons", "recovered-superseded-owner-exit", "meta.json"); - const beforeRecovery = (await Bun.file(metaPath).json()) as { completionSubscriptionId?: string }; - expect(beforeRecovery.completionSubscriptionId).toBeString(); - - await second.request({ op: "shutdown" }); - expect( - await waitUntil( - () => - Bun.file(path.join(runtimeDir, "broker.pid")) - .exists() - .then(exists => !exists), - 5_000, - ), - ).toBeTrue(); - - unregisterFirst(); - await first.request({ op: "ping" }); - const afterStaleUnsubscribe = (await Bun.file(metaPath).json()) as { - completionEvents?: boolean; - completionSubscriptionId?: string; - }; - expect(afterStaleUnsubscribe).toMatchObject({ - completionEvents: true, - completionSubscriptionId: beforeRecovery.completionSubscriptionId, - }); - first.close(); - - expect(await waitUntil(() => pid !== undefined && !processExists(pid), 4_000)).toBeTrue(); - await second.request({ op: "list" }); - expect(await waitUntil(() => completion !== undefined, 2_000)).toBeTrue(); - expect(completion).toMatchObject({ - name: "recovered-superseded-owner-exit", - state: "exited", - }); - } finally { - unregisterFirst(); - unregisterSecond?.(); - first.close(); - await shutdown(second); - if (pid !== undefined && processExists(pid)) { - const rescue = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 1_000 }); - try { - await rescue.request({ op: "stop", name: "recovered-superseded-owner-exit", timeoutMs: 2_000 }); - } finally { - await shutdown(rescue); - } - } - } - }, 15_000); - - it("clears an owner unsubscribed after a transport reconnect", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-unsubscribe-project-"); - const runtimeDir = await tempDir("omp-daemon-unsubscribe-runtime-"); - const owner = "unsubscribed-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - let second: DaemonBrokerClient | undefined; - try { - first.onCompletion(owner, () => {}); - await first.request({ - op: "start", - spec: { - name: "unsubscribe-gap", - application: "/bin/sh", - args: ["-c", "sleep 0.4; exit 0"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - first.close(); - - second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - const unregister = second.onCompletion(owner, () => {}); - unregister(); - await second.request({ op: "ping" }); - expect( - await waitUntil(async () => { - const listed = await second!.request({ op: "list" }); - return listed.op === "list" && listed.daemons[0]?.state === "exited"; - }, 3_000), - ).toBeTrue(); - second.close(); - - expect( - await waitUntil( - () => - Bun.file(path.join(runtimeDir, "broker.sock")) - .exists() - .then(exists => !exists), - 3_000, - ), - ).toBeTrue(); - } finally { - first.close(); - second?.close(); - if (await Bun.file(path.join(runtimeDir, "broker.sock")).exists()) { - const rescue = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - await shutdown(rescue); - } - } - }, 12_000); - - it("preserves an owner's pending completion while its sink is detached", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-preserve-owner-project-"); - const runtimeDir = await tempDir("omp-daemon-preserve-owner-runtime-"); - const owner = "preserved-owner"; - const client = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - const unregister = client.onCompletion(owner, () => {}); - let recovered: DaemonBrokerClient | undefined; - try { - await client.request({ - op: "start", - spec: { - name: "preserved-owner-exit", - application: "/bin/sh", - args: ["-c", "sleep 0.2; exit 0"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - unregister({ preservePending: true }); - expect( - await waitUntil(async () => { - const listed = await client.request({ op: "list" }); - return listed.op === "list" && listed.daemons[0]?.state === "exited"; - }, 3_000), - ).toBeTrue(); - - client.close(); - expect( - await waitUntil( - () => - Bun.file(path.join(runtimeDir, "broker.sock")) - .exists() - .then(exists => !exists), - 3_000, - ), - ).toBeTrue(); - - recovered = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - const received = Promise.withResolvers(); - const unregisterRecovered = recovered.onCompletion(owner, () => received.resolve()); - try { - await recovered.request({ op: "list" }); - await received.promise; - } finally { - unregisterRecovered(); - } - } finally { - client.close(); - recovered?.close(); - if (await Bun.file(path.join(runtimeDir, "broker.sock")).exists()) { - const rescue = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - await shutdown(rescue); - } - } - }, 12_000); - - it("does not let a detached client reclaim a resumed owner", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-detached-owner-project-"); - const runtimeDir = await tempDir("omp-daemon-detached-owner-runtime-"); - const owner = "resumed-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const unregisterFirst = first.onCompletion(owner, () => {}); - let unregisterSecond: (() => void) | undefined; - let completion: DaemonSnapshot | undefined; - try { - await first.request({ - op: "start", - spec: { - name: "resumed-owner-exit", - application: "/bin/sh", - args: ["-c", "sleep 0.5; exit 0"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - unregisterFirst({ preservePending: true }); - unregisterSecond = second.onCompletion(owner, notification => { - completion = notification.daemon; - }); - await second.request({ op: "ping" }); - await first.request({ op: "ping" }); - - expect(await waitUntil(() => completion !== undefined, 3_000)).toBeTrue(); - expect(completion).toMatchObject({ name: "resumed-owner-exit", state: "exited", exitCode: 0 }); - } finally { - unregisterFirst(); - unregisterSecond?.(); - first.close(); - await shutdown(second); - } - }, 9_000); - it("drops a completion when its owner unsubscribes during settlement", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-settle-unsubscribe-project-"); - const runtimeDir = await tempDir("omp-daemon-settle-unsubscribe-runtime-"); - const owner = "settle-unsubscribe-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - const unregister = first.onCompletion(owner, () => {}); - try { - await first.request({ - op: "start", - spec: { - name: "settle-unsubscribe", - application: "/bin/sh", - args: ["-c", "i=0; while [ $i -lt 20000 ]; do echo x; i=$((i+1)); done"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - expect( - await waitUntil(async () => { - const listed = await first.request({ op: "list" }); - return listed.op === "list" && listed.daemons[0]?.state === "exited"; - }, 5_000), - ).toBeTrue(); - unregister(); - await first.request({ op: "ping" }); - first.close(); - expect( - await waitUntil( - () => - Bun.file(path.join(runtimeDir, "broker.sock")) - .exists() - .then(exists => !exists), - 3_000, - ), - ).toBeTrue(); - } finally { - unregister(); - first.close(); - if (await Bun.file(path.join(runtimeDir, "broker.sock")).exists()) { - const rescue = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 200 }); - await shutdown(rescue); - } - } - }, 12_000); - it("does not retain completions for owners that did not advertise event support", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-legacy-owner-project-"); - const runtimeDir = await tempDir("omp-daemon-legacy-owner-runtime-"); - const owner = "legacy-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - await first.request({ - op: "start", - spec: { - name: "legacy-exit", - application: "/bin/sh", - args: ["-c", "exit 7"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - first.close(); - await Bun.sleep(200); - - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const completions: DaemonSnapshot[] = []; - const unregister = second.onCompletion(owner, notification => { - completions.push(notification.daemon); - }); - try { - await second.request({ op: "list" }); - await Bun.sleep(100); - expect(completions).toEqual([]); - } finally { - unregister(); - await shutdown(second); - } - }, 9_000); - it("does not replay a completion after the owner unsubscribes", async () => { - if (process.platform === "win32") return; - const projectDir = await tempDir("omp-daemon-unsubscribe-project-"); - const runtimeDir = await tempDir("omp-daemon-unsubscribe-runtime-"); - const owner = "unsubscribe-owner"; - const first = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const unregisterFirst = first.onCompletion(owner, () => {}); - await first.request({ - op: "start", - spec: { - name: "unsubscribed-exit", - application: "/bin/sh", - args: ["-c", "sleep 0.3; exit 7"], - env: {}, - cwd: projectDir, - pty: false, - restart: "no", - persist: false, - detached: false, - }, - owner, - }); - unregisterFirst(); - await Bun.sleep(100); - first.close(); - await Bun.sleep(300); - - const second = await createDaemonBrokerClient(projectDir, { runtimeDir, idleGraceMs: 5_000 }); - const completions: DaemonSnapshot[] = []; - const unregisterSecond = second.onCompletion(owner, notification => { - completions.push(notification.daemon); - }); - try { - await second.request({ op: "list" }); - await Bun.sleep(100); - expect(completions).toEqual([]); - } finally { - unregisterSecond(); - await shutdown(second); - } - }, 9_000); -}); diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 8af9e1e77..05ae245ce 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -56,9 +56,11 @@ import DEFAULTS from "../../src/lsp/defaults.json" with { type: "json" }; import { renderResult as renderLocalResult } from "../../src/lsp/render"; import { getLanguageFromPath } from "../../src/utils/lang-from-path"; +const lspTestSettings = Settings.isolated(); + /** Minimal LSP tool session: production always supplies `settings`; these tests only need cwd + a default settings stub. */ function makeLspSession(cwd: string): ToolSession { - return { cwd, settings: Settings.isolated() } as ToolSession; + return { cwd, settings: lspTestSettings } as ToolSession; } interface RpcMessage { @@ -1598,17 +1600,24 @@ describe("lsp regressions", () => { vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-lsp", server]]); vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client); - setTimeout(() => { - client.diagnostics.set(otherUri, { diagnostics: [otherDiagnostic], version: 1 }); - client.diagnosticsVersion += 1; - }, 20); - setTimeout(() => { - client.diagnostics.set(targetUri, { - diagnostics: [], - version: client.openFiles.get(targetUri)?.version ?? 2, - }); - client.diagnosticsVersion += 1; - }, 80); + let poll = 0; + vi.spyOn(Bun, "sleep").mockImplementation(async () => { + poll++; + if (poll === 1) { + client.diagnostics.set(otherUri, { diagnostics: [otherDiagnostic], version: 1 }); + client.diagnosticsVersion += 1; + return; + } + if (poll === 2) { + client.diagnostics.set(targetUri, { + diagnostics: [], + version: client.openFiles.get(targetUri)?.version ?? 2, + }); + client.diagnosticsVersion += 1; + return; + } + throw new Error("waitForDiagnostics polled after the fresh target publish"); + }); const tool = new LspTool(makeLspSession(tempDir.path())); const result = await tool.execute("diag-stale", { @@ -3398,13 +3407,17 @@ describe("lsp regressions", () => { projectLoaded: Promise.resolve(), resolveProjectLoaded: () => {}, }; - expect(lspClient.sendRequest(client, "test/method", {}, undefined, 25)).rejects.toThrow(/after 25ms/); + vi.useFakeTimers(); + try { + const request = lspClient.sendRequest(client, "test/method", {}, undefined, 25); + vi.advanceTimersByTime(25); + await expect(request).rejects.toThrow(/after 25ms/); + } finally { + vi.useRealTimers(); + } }); it("sendRequest uses the signal as the deadline when no explicit timeout is set", async () => { - // With a signal but no explicit timeoutMs, the per-request 30s default - // MUST NOT fire — the signal owns the deadline. Otherwise `timeout: 60` - // on the LSP tool got truncated to 30000ms. const client: LspClient = { name: "test-lsp", cwd: process.cwd(), @@ -3424,15 +3437,14 @@ describe("lsp regressions", () => { projectLoaded: Promise.resolve(), resolveProjectLoaded: () => {}, }; - const signal = AbortSignal.timeout(20); - expect(lspClient.sendRequest(client, "test/method", {}, signal)).rejects.toThrow(); - // If the per-request 30s timer had fired, the message would say "after 30000ms". - // We assert the negative: the rejection came from the signal, not the timer. - try { - await lspClient.sendRequest(client, "test/method", {}, AbortSignal.timeout(20)); - } catch (err) { - expect(String(err)).not.toContain("30000ms"); - } + const controller = new AbortController(); + const reason = new Error("caller deadline"); + const request = lspClient.sendRequest(client, "test/method", {}, controller.signal); + controller.abort(reason); + + // The exact caller reason proves the signal owned the deadline rather than + // the per-request 30s fallback. + await expect(request).rejects.toBe(reason); }); it("rename_file skips the LSP loop when no configured server handles the file extension", async () => { @@ -3921,27 +3933,22 @@ describe("lsp regressions", () => { // Server accepts spawn but never answers the `initialize` request. // Pre-fix, `getOrCreateClient` swallowed the signal and only bailed // after the 30s `DEFAULT_REQUEST_TIMEOUT_MS` fallback fired. - installFakeLsp(() => {}); + const server = installFakeLsp(() => {}); const tempDir = TempDir.createSync("@omp-lsp-init-abort-"); try { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 100); + const reason = new Error("caller deadline"); const config: ServerConfig = { command: "fake-lsp-init-abort", fileTypes: ["ts"], rootMarkers: [], }; - const start = Date.now(); - await expect( - lspClient.getOrCreateClient(config, tempDir.path(), undefined, controller.signal), - ).rejects.toBeInstanceOf(Error); - const elapsed = Date.now() - start; - clearTimeout(timer); - // The signal fired at 100ms. Allow a wide margin, but the pre-fix - // path only bailed after 30s. - expect(elapsed).toBeLessThan(2_000); + const pending = lspClient.getOrCreateClient(config, tempDir.path(), undefined, controller.signal); + await server.waitFor(message => message.method === "initialize"); + controller.abort(reason); + await expect(pending).rejects.toBe(reason); } finally { await lspClient.shutdownAll(); tempDir.removeSync(); @@ -3949,26 +3956,26 @@ describe("lsp regressions", () => { }); it("does not negative-cache caller-aborted initialize attempts", async () => { - installFakeLsp(() => {}); + const server = installFakeLsp(() => {}); const tempDir = TempDir.createSync("@omp-lsp-init-abort-cache-"); try { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 100); const config: ServerConfig = { command: "fake-lsp-init-abort-cache", fileTypes: ["ts"], rootMarkers: [], }; - await expect( - lspClient.getOrCreateClient(config, tempDir.path(), undefined, controller.signal), - ).rejects.toBeInstanceOf(Error); - clearTimeout(timer); + const pending = lspClient.getOrCreateClient(config, tempDir.path(), undefined, controller.signal); + await server.waitFor(message => message.method === "initialize"); + controller.abort(); + await expect(pending).rejects.toBeInstanceOf(Error); - await expect(lspClient.getOrCreateClient(config, tempDir.path(), 25)).rejects.not.toThrow( - "failed to initialize recently", - ); + const probeSignal = AbortSignal.abort(new Error("probe only")); + await expect( + lspClient.getOrCreateClient(config, tempDir.path(), undefined, probeSignal), + ).rejects.not.toThrow("failed to initialize recently"); } finally { await lspClient.shutdownAll(); tempDir.removeSync(); @@ -4081,6 +4088,7 @@ describe("lsp regressions", () => { let exitCode: number | null = null; let killed = false; let flushGate: Promise = Promise.resolve(); + let onFlush: (() => void) | undefined; const frame = (message: RpcMessage): Uint8Array => { const content = JSON.stringify(message); @@ -4132,6 +4140,7 @@ describe("lsp regressions", () => { return typeof chunk === "string" ? Buffer.byteLength(chunk, "utf-8") : chunk.byteLength; }, flush: async () => { + onFlush?.(); await flushGate; return 0; }, @@ -4164,30 +4173,27 @@ describe("lsp regressions", () => { // Wedge every subsequent flush: sink.flush() now awaits a promise // that never settles, mirroring a server that stopped draining stdin. + const flushStarted = Promise.withResolvers(); + onFlush = flushStarted.resolve; flushGate = new Promise(() => {}); const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 100); - - const start = Date.now(); - await expect( - lspClient.sendNotification( - client, - "textDocument/didOpen", - { - textDocument: { - uri: "file:///tmp/x.ts", - languageId: "typescript", - version: 1, - text: "", - }, + const notification = lspClient.sendNotification( + client, + "textDocument/didOpen", + { + textDocument: { + uri: "file:///tmp/x.ts", + languageId: "typescript", + version: 1, + text: "", }, - controller.signal, - ), - ).rejects.toBeInstanceOf(Error); - const elapsed = Date.now() - start; - clearTimeout(timer); - expect(elapsed).toBeLessThan(2_000); + }, + controller.signal, + ); + await flushStarted.promise; + controller.abort(); + await expect(notification).rejects.toBeInstanceOf(Error); // Teardown contract: an aborted write kills the client so the // next `getOrCreateClient` spawns a fresh server instead of diff --git a/packages/coding-agent/test/tools/memory-renderer.test.ts b/packages/coding-agent/test/tools/memory-renderer.test.ts index 5fa666614..135c7a3af 100644 --- a/packages/coding-agent/test/tools/memory-renderer.test.ts +++ b/packages/coding-agent/test/tools/memory-renderer.test.ts @@ -7,8 +7,10 @@ import { } from "@oh-my-pi/pi-coding-agent/tools/memory-render"; import { sanitizeText } from "@oh-my-pi/pi-utils"; +const themePromise = getThemeByName("dark"); + async function theme() { - const t = await getThemeByName("dark"); + const t = await themePromise; expect(t).toBeDefined(); return t!; } diff --git a/packages/coding-agent/test/tools/multi-grep-path.test.ts b/packages/coding-agent/test/tools/multi-grep-path.test.ts index 02ea2cb7e..868beea0d 100644 --- a/packages/coding-agent/test/tools/multi-grep-path.test.ts +++ b/packages/coding-agent/test/tools/multi-grep-path.test.ts @@ -3,10 +3,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { resolveExplicitSearchPaths } from "@oh-my-pi/pi-coding-agent/tools/path-utils"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; +import { GrepTool } from "../../src/tools/grep"; +const testSettings = Settings.isolated(); const isWindows = process.platform === "win32"; function createTestSession(cwd: string, overrides: Partial = {}): ToolSession { @@ -15,7 +17,7 @@ function createTestSession(cwd: string, overrides: Partial = {}): T hasUI: false, getSessionFile: () => null, getSessionSpawns: () => "*", - settings: Settings.isolated(), + settings: testSettings, ...overrides, }; } @@ -58,9 +60,7 @@ describe.skipIf(isWindows)("search with omitted paths", () => { }); it("defaults to the workspace root when paths is omitted", async () => { - const tools = await createTools(createTestSession(cwd)); - const tool = tools.find(entry => entry.name === "grep"); - if (!tool) throw new Error("Missing grep tool"); + const tool = new GrepTool(createTestSession(cwd)); // Callers that omit `path` would otherwise be rejected at schema // validation with `path: Invalid input` and never run. Omission must @@ -74,9 +74,7 @@ describe.skipIf(isWindows)("search with omitted paths", () => { }); it("defaults to the workspace root when path is an empty JSON array", async () => { - const tools = await createTools(createTestSession(cwd)); - const tool = tools.find(entry => entry.name === "grep"); - if (!tool) throw new Error("Missing grep tool"); + const tool = new GrepTool(createTestSession(cwd)); const result = await tool.execute("search-empty-paths", { pattern: "default-needle", @@ -108,9 +106,7 @@ describe.skipIf(isWindows)("search across unrelated filesystem trees", () => { }); it("returns matches from both trees without rooting the scan at /", async () => { - const tools = await createTools(createTestSession(cwd)); - const tool = tools.find(entry => entry.name === "grep"); - if (!tool) throw new Error("Missing grep tool"); + const tool = new GrepTool(createTestSession(cwd)); const start = performance.now(); const result = await tool.execute("search-cross-tree", { @@ -202,9 +198,7 @@ describe.skipIf(isWindows)("search with explicit walker-pruned file targets", () // The directory walker prunes `.git` unconditionally, so folding the // explicit file into the walk's glob union silently returned 0 matches. // The file must be read directly as its own target. - const tools = await createTools(createTestSession(repo)); - const tool = tools.find(entry => entry.name === "grep"); - if (!tool) throw new Error("Missing grep tool"); + const tool = new GrepTool(createTestSession(repo)); const result = await tool.execute("search-git-config", { pattern: "followTags", @@ -219,9 +213,7 @@ describe.skipIf(isWindows)("search with explicit walker-pruned file targets", () it("dedupes matches when a file target overlaps a directory target", async () => { await fs.mkdir(path.join(repo, "src"), { recursive: true }); await Bun.write(path.join(repo, "src", "a.ts"), "needle-dup\n"); - const tools = await createTools(createTestSession(repo)); - const tool = tools.find(entry => entry.name === "grep"); - if (!tool) throw new Error("Missing grep tool"); + const tool = new GrepTool(createTestSession(repo)); const result = await tool.execute("search-overlap", { pattern: "needle-dup", diff --git a/packages/coding-agent/test/tools/multi-path-missing.test.ts b/packages/coding-agent/test/tools/multi-path-missing.test.ts index 95c4045e0..815086011 100644 --- a/packages/coding-agent/test/tools/multi-path-missing.test.ts +++ b/packages/coding-agent/test/tools/multi-path-missing.test.ts @@ -3,8 +3,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; +import { GlobTool } from "../../src/tools/glob"; +import { GrepTool } from "../../src/tools/grep"; + +const testSettings = Settings.isolated(); // Regression for grievances #208 (find) and #209 (search): a multi-path call // that includes an entry which does not exist on disk must not abort the whole @@ -17,7 +21,7 @@ function createTestSession(cwd: string, overrides: Partial = {}): T hasUI: false, getSessionFile: () => null, getSessionSpawns: () => "*", - settings: Settings.isolated(), + settings: testSettings, ...overrides, }; } @@ -44,9 +48,7 @@ describe("multi-path tools tolerate missing entries", () => { }); it("search returns matches from existing paths and reports the missing one", async () => { - const tools = await createTools(createTestSession(tempDir)); - const tool = tools.find(entry => entry.name === "grep"); - if (!tool) throw new Error("Missing grep tool"); + const tool = new GrepTool(createTestSession(tempDir)); const result = await tool.execute("search-multi-missing", { pattern: "shared-needle", @@ -64,9 +66,7 @@ describe("multi-path tools tolerate missing entries", () => { }); it("search errors only when every path is missing", async () => { - const tools = await createTools(createTestSession(tempDir)); - const tool = tools.find(entry => entry.name === "grep"); - if (!tool) throw new Error("Missing grep tool"); + const tool = new GrepTool(createTestSession(tempDir)); const promise = tool.execute("search-all-missing", { pattern: "shared-needle", @@ -77,9 +77,7 @@ describe("multi-path tools tolerate missing entries", () => { }); it("find returns matches from existing globs and reports the missing one", async () => { - const tools = await createTools(createTestSession(tempDir)); - const tool = tools.find(entry => entry.name === "glob"); - if (!tool) throw new Error("Missing glob tool"); + const tool = new GlobTool(createTestSession(tempDir), { rootPathAlias: true }); const result = await tool.execute("find-multi-missing", { path: "src/**/*.ts; tests/**/*.ts", @@ -98,9 +96,7 @@ describe("multi-path tools tolerate missing entries", () => { }); it("find errors only when every glob's base directory is missing", async () => { - const tools = await createTools(createTestSession(tempDir)); - const tool = tools.find(entry => entry.name === "glob"); - if (!tool) throw new Error("Missing glob tool"); + const tool = new GlobTool(createTestSession(tempDir), { rootPathAlias: true }); const promise = tool.execute("find-all-missing", { path: "nope/**/*.ts; also-nope/**/*.ts", diff --git a/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts b/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts index 6e82c64fd..672c401fe 100644 --- a/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts +++ b/packages/coding-agent/test/tools/path-literal-colon-selector.test.ts @@ -34,6 +34,7 @@ const EMPTY_ZIP_EOCD = new Uint8Array([0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0, 0, 0, // must prefer a real literal file over the selector interpretation. describe("literal colon filename resolution (issue #4618)", () => { let tmpDir: string; + const sessionSettings = Settings.isolated({ "grep.contextBefore": 0, "grep.contextAfter": 0 }); beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "literal-colon-")); @@ -49,7 +50,7 @@ describe("literal colon filename resolution (issue #4618)", () => { hasUI: false, getSessionFile: () => null, getSessionSpawns: () => "*", - settings: Settings.isolated({ "grep.contextBefore": 0, "grep.contextAfter": 0 }), + settings: sessionSettings, ...overrides, }; } @@ -176,8 +177,13 @@ describe("literal colon filename resolution (issue #4618)", () => { const lines = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`).join("\n"); await Bun.write(absolute, `${lines}\n`); - const session = createSession(); - session.settings.set("read.summarize.enabled", false); + const session = createSession({ + settings: Settings.isolated({ + "grep.contextBefore": 0, + "grep.contextAfter": 0, + "read.summarize.enabled": false, + }), + }); const tool = new ReadTool(session); const result = await tool.execute("read-selector-preserved", { path: `${absolute}:5-10`, @@ -324,11 +330,15 @@ describe("literal colon filename resolution (issue #4618)", () => { // and `edit` all open the intended file — see issue #5508. describe("leading-colon path recovery (issue #5508)", () => { let tmpDir: string; + const sessionSettings = Settings.isolated({ + "grep.contextBefore": 0, + "grep.contextAfter": 0, + "edit.mode": "patch", + }); beforeEach(async () => { resetSettingsForTest(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "leading-colon-")); - await Settings.init({ inMemory: true, cwd: tmpDir }); }); afterEach(async () => { @@ -346,11 +356,7 @@ describe("leading-colon path recovery (issue #5508)", () => { getArtifactsDir: () => null, getSessionId: () => null, getPlanModeState: () => undefined, - settings: Settings.isolated({ - "grep.contextBefore": 0, - "grep.contextAfter": 0, - "edit.mode": "patch", - }), + settings: sessionSettings, ...overrides, } as unknown as ToolSession; } @@ -426,6 +432,7 @@ describe("leading-colon path recovery (issue #5508)", () => { it("edit updates a file addressed with a leading colon", async () => { const abs = path.join(tmpDir, "colon-edit.txt"); await Bun.write(abs, "needle here\nsecond\n"); + await Settings.init({ inMemory: true, cwd: tmpDir }); const result = await new EditTool(createSession()).execute("edit-leading-colon", { path: `:${abs}`, diff --git a/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts b/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts index 37a0d886f..e424e390e 100644 --- a/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts +++ b/packages/coding-agent/test/tools/provider-schema-compatibility.test.ts @@ -18,13 +18,15 @@ interface ToolSchemaEntry { schema: Record; } +const testSettings = Settings.isolated({ "tools.xdev": false }); + function createTestSession(): ToolSession { return { cwd: "/tmp/test", hasUI: true, getSessionFile: () => null, getSessionSpawns: () => "*", - settings: Settings.isolated({ "tools.xdev": false }), + settings: testSettings, }; } @@ -34,43 +36,40 @@ function asSchemaObject(value: unknown): Record | null { } return value as Record; } - -async function collectToolSchemas(): Promise { +const builtinToolsPromise = createTools(createTestSession()); +const toolSchemasPromise: Promise = (async () => { const session = createTestSession(); const byToolName = new Map>(); - for (const tool of await createTools(session)) { + for (const tool of await builtinToolsPromise) { const schema = toolWireSchema(tool); - if (!asSchemaObject(schema)) { - continue; + if (asSchemaObject(schema)) { + byToolName.set(tool.name, schema); } - byToolName.set(tool.name, schema); } - for (const [name, factory] of Object.entries(HIDDEN_TOOLS)) { - const tool = await factory(session); + for (const name in HIDDEN_TOOLS) { + const tool = await HIDDEN_TOOLS[name as keyof typeof HIDDEN_TOOLS](session); if (!tool) { continue; } const schema = toolWireSchema(tool); - if (!asSchemaObject(schema)) { - continue; + if (asSchemaObject(schema)) { + byToolName.set(name, schema); } - byToolName.set(name, schema); } for (const tool of createVibeTools(session)) { const schema = toolWireSchema(tool); - if (!asSchemaObject(schema)) { - continue; + if (asSchemaObject(schema)) { + byToolName.set(tool.name, schema); } - byToolName.set(tool.name, schema); } return [...byToolName.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([name, schema]) => ({ name, schema })); -} +})(); function formatCompatibilityIssues( toolName: string, @@ -88,7 +87,7 @@ function formatCompatibilityIssues( describe("builtin tool schemas provider compatibility", () => { it("keeps todo strict and marks task non-strict for free-form output schemas", async () => { - const tools = await createTools(createTestSession()); + const tools = await builtinToolsPromise; const task = tools.find(tool => tool.name === "task"); const todo = tools.find(tool => tool.name === "todo"); expect(task).toBeDefined(); @@ -103,7 +102,7 @@ describe("builtin tool schemas provider compatibility", () => { }); it("keeps all builtin and hidden tool schemas valid after provider enforcement", async () => { - const toolSchemas = await collectToolSchemas(); + const toolSchemas = await toolSchemasPromise; const failures: string[] = []; for (const { name, schema } of toolSchemas) { @@ -141,7 +140,7 @@ describe("builtin tool schemas provider compatibility", () => { }); it("preserves the yield result schema for Cloud Code Assist", async () => { - const toolSchemas = await collectToolSchemas(); + const toolSchemas = await toolSchemasPromise; const yieldEntry = toolSchemas.find(tool => tool.name === "yield"); expect(yieldEntry).toBeDefined(); if (!yieldEntry) return; @@ -157,7 +156,7 @@ describe("builtin tool schemas provider compatibility", () => { }); it('asserts that browser tool schema root stays `type: "object"` when discoverable tools are mounted', async () => { - const toolSchemas = await collectToolSchemas(); + const toolSchemas = await toolSchemasPromise; const browserEntry = toolSchemas.find(tool => tool.name === "browser"); expect(browserEntry).toBeDefined(); expect(asSchemaObject(browserEntry?.schema)?.type).toBe("object"); diff --git a/packages/mnemopi/src/core/memory.ts b/packages/mnemopi/src/core/memory.ts index c53c8acc8..c3b843b19 100644 --- a/packages/mnemopi/src/core/memory.ts +++ b/packages/mnemopi/src/core/memory.ts @@ -396,6 +396,7 @@ export class Mnemopi { constructor(options: MnemopiOptions = {}) { this.sessionId = options.sessionId ?? options.session_id ?? "default"; this.bank = options.bank ?? "default"; + this.authorId = options.authorId ?? options.author_id ?? null; this.authorType = options.authorType ?? options.author_type ?? null; this.channelId = options.channelId ?? options.channel_id ?? this.sessionId; diff --git a/packages/mnemopi/test/beam-helpers.test.ts b/packages/mnemopi/test/beam-helpers.test.ts index 152eaa231..42137eaf5 100644 --- a/packages/mnemopi/test/beam-helpers.test.ts +++ b/packages/mnemopi/test/beam-helpers.test.ts @@ -1,6 +1,6 @@ import { Database } from "bun:sqlite"; import { describe, expect, it } from "bun:test"; -import "./setup"; + import { buildFtsQuery, cjkFtsTerms, diff --git a/packages/mnemopi/test/binary-vectors.test.ts b/packages/mnemopi/test/binary-vectors.test.ts index 1f2194a8a..dbd5adb5c 100644 --- a/packages/mnemopi/test/binary-vectors.test.ts +++ b/packages/mnemopi/test/binary-vectors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import "./setup"; + import { BinaryVectorStore, cosineSimilarity, diff --git a/packages/mnemopi/test/degrade-vector.test.ts b/packages/mnemopi/test/degrade-vector.test.ts index f7701d0a4..ed83ea20a 100644 --- a/packages/mnemopi/test/degrade-vector.test.ts +++ b/packages/mnemopi/test/degrade-vector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import "./setup"; + import { BeamMemory } from "@oh-my-pi/pi-mnemopi/core/beam"; import { maximallyInformativeBinarization } from "@oh-my-pi/pi-mnemopi/core/binary-vectors"; diff --git a/packages/mnemopi/test/e5a-vector-voice-dense-rewire.test.ts b/packages/mnemopi/test/e5a-vector-voice-dense-rewire.test.ts index 051f7187f..a6bab9ced 100644 --- a/packages/mnemopi/test/e5a-vector-voice-dense-rewire.test.ts +++ b/packages/mnemopi/test/e5a-vector-voice-dense-rewire.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import "./setup"; + import { BeamMemory } from "@oh-my-pi/pi-mnemopi/core/beam"; import { PolyphonicRecallEngine } from "@oh-my-pi/pi-mnemopi/core/polyphonic-recall"; diff --git a/packages/mnemopi/test/embedding-failure-logging.test.ts b/packages/mnemopi/test/embedding-failure-logging.test.ts index 4303f29df..aa236fb2d 100644 --- a/packages/mnemopi/test/embedding-failure-logging.test.ts +++ b/packages/mnemopi/test/embedding-failure-logging.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test"; -import { logger } from "@oh-my-pi/pi-utils"; -import "./setup"; import { embed, resetEmbeddingProviderForTests, setLocalModelInitializerForTests, } from "@oh-my-pi/pi-mnemopi/core/embeddings"; import { withMnemopiRuntimeOptions } from "@oh-my-pi/pi-mnemopi/core/runtime-options"; +import { logger } from "@oh-my-pi/pi-utils"; const ENV_KEYS = [ "NODE_ENV", diff --git a/packages/mnemopi/test/embedding-input-cap.test.ts b/packages/mnemopi/test/embedding-input-cap.test.ts index 3cbe7275e..0ba36ccac 100644 --- a/packages/mnemopi/test/embedding-input-cap.test.ts +++ b/packages/mnemopi/test/embedding-input-cap.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; -import "./setup"; + import { embed, resetEmbeddingProviderForTests, diff --git a/packages/mnemopi/test/embedding-model-reconcile.test.ts b/packages/mnemopi/test/embedding-model-reconcile.test.ts index 3248842df..2bee14112 100644 --- a/packages/mnemopi/test/embedding-model-reconcile.test.ts +++ b/packages/mnemopi/test/embedding-model-reconcile.test.ts @@ -8,7 +8,7 @@ import { Database } from "bun:sqlite"; import { describe, expect, it } from "bun:test"; -import "./setup"; + import { initBeam } from "@oh-my-pi/pi-mnemopi/core/beam"; import { Mnemopi } from "@oh-my-pi/pi-mnemopi/core/memory"; diff --git a/packages/mnemopi/test/embeddings-multilingual.test.ts b/packages/mnemopi/test/embeddings-multilingual.test.ts index c95cd7a5b..24cccbbed 100644 --- a/packages/mnemopi/test/embeddings-multilingual.test.ts +++ b/packages/mnemopi/test/embeddings-multilingual.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import "./setup"; + import { cosineSimilarity, embed, diff --git a/packages/mnemopi/test/optional-embeddings.test.ts b/packages/mnemopi/test/optional-embeddings.test.ts index 625bc8263..508a489e9 100644 --- a/packages/mnemopi/test/optional-embeddings.test.ts +++ b/packages/mnemopi/test/optional-embeddings.test.ts @@ -1,6 +1,4 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { getFastembedCacheDir } from "@oh-my-pi/pi-utils"; -import "./setup"; import { available, embed, @@ -12,6 +10,7 @@ import { } from "@oh-my-pi/pi-mnemopi/core/embeddings"; import { Mnemopi } from "@oh-my-pi/pi-mnemopi/core/memory"; import { withMnemopiRuntimeOptions } from "@oh-my-pi/pi-mnemopi/core/runtime-options"; +import { getFastembedCacheDir } from "@oh-my-pi/pi-utils"; import packageJson from "../package.json" with { type: "json" }; const ENV_KEYS = [ diff --git a/packages/mnemopi/test/orphan-vec-episodes-cleanup.test.ts b/packages/mnemopi/test/orphan-vec-episodes-cleanup.test.ts index 4fa786e74..80e1d4b62 100644 --- a/packages/mnemopi/test/orphan-vec-episodes-cleanup.test.ts +++ b/packages/mnemopi/test/orphan-vec-episodes-cleanup.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import "./setup"; + import { BeamMemory } from "@oh-my-pi/pi-mnemopi/core/beam"; function createVecEpisodes(beam: BeamMemory): void { diff --git a/packages/mnemopi/test/setup.ts b/packages/mnemopi/test/setup.ts index f774a1db9..16e4d1759 100644 --- a/packages/mnemopi/test/setup.ts +++ b/packages/mnemopi/test/setup.ts @@ -1,42 +1,16 @@ import { afterEach, beforeEach } from "bun:test"; -import * as Beam from "@oh-my-pi/pi-mnemopi/core/beam"; -import * as Embeddings from "@oh-my-pi/pi-mnemopi/core/embeddings"; import type { CompleteOptions, LlmBackend } from "@oh-my-pi/pi-mnemopi/core/llm-backends"; -import * as LlmBackends from "@oh-my-pi/pi-mnemopi/core/llm-backends"; -import * as Memory from "@oh-my-pi/pi-mnemopi/core/memory"; - -type ResettableModule = Record; - -const RESET_FUNCTION_NAMES = [ - "resetForTests", - "resetModuleStateForTests", - "resetMemoryForTests", - "resetBeamForTests", - "resetEmbeddingStateForTests", - "resetHostLlmBackendForTests", - "resetLlmBackendStateForTests", -] as const; - -const RESETTABLE_MODULES: readonly ResettableModule[] = [Memory, Beam, LlmBackends, Embeddings]; - -function callResetFunctions(moduleExports: ResettableModule): void { - for (const name of RESET_FUNCTION_NAMES) { - const reset = moduleExports[name]; - if (typeof reset === "function") { - reset(); - } - } -} +import { resetHostLlmBackendForTests, setHostLlmBackend } from "@oh-my-pi/pi-mnemopi/core/llm-backends"; +import { resetDefaultInstanceForTests } from "@oh-my-pi/pi-mnemopi/core/memory"; export function resetModuleStateForTests(): void { - for (const moduleExports of RESETTABLE_MODULES) { - callResetFunctions(moduleExports); - } + resetDefaultInstanceForTests(); + resetHostLlmBackendForTests(); } export function disableLocalLlmForTests(): void { - LlmBackends.setHostLlmBackend(null); + resetHostLlmBackendForTests(); } export function withLocalLlm(fakeResponseOrBackend: string | LlmBackend = "fake summary"): LlmBackend { @@ -45,7 +19,7 @@ export function withLocalLlm(fakeResponseOrBackend: string | LlmBackend = "fake ? new FakeLocalLlmBackend(fakeResponseOrBackend) : fakeResponseOrBackend; - LlmBackends.setHostLlmBackend(backend); + setHostLlmBackend(backend); return backend; } @@ -65,10 +39,8 @@ class FakeLocalLlmBackend implements LlmBackend { beforeEach(() => { resetModuleStateForTests(); - disableLocalLlmForTests(); }); afterEach(() => { resetModuleStateForTests(); - disableLocalLlmForTests(); }); diff --git a/packages/tui/test/render-stress-harness.ts b/packages/tui/test/render-stress-harness.ts index 74e223abf..b1fd75140 100644 --- a/packages/tui/test/render-stress-harness.ts +++ b/packages/tui/test/render-stress-harness.ts @@ -1277,6 +1277,7 @@ class StressDriver { try { this.#tui.start(); await this.#settle(); + let before = this.#snapshot(); this.#assertOracles( { kind: "forceRender", @@ -1288,22 +1289,19 @@ class StressDriver { mutatesViewport: false, checkpoint: false, }, - this.#snapshot(), - this.#snapshot(), + before, + before, -1, ); for (let index = 0; index < this.#scenario.iterations; index++) { - const before = this.#snapshot(); const kind = this.#scenario.replayOperations?.[index] ?? this.#chooseOperation(index, before); const op = await this.#applyOperation(kind); const after = this.#snapshot(); this.#recordOperation(index, op.kind, op.detail, before, after); this.#assertOracles(op, before, after, index); - if ((index + 1) % 50 === 0) { - await this.#checkpoint(index, "periodicCheckpoint"); - } + before = (index + 1) % 50 === 0 ? await this.#checkpoint(index, after) : after; } } finally { this.#tui.stop(); @@ -1314,17 +1312,22 @@ class StressDriver { #snapshot(): Snapshot { const position = this.#term.getBufferPosition(); const expected = this.#expectedFrame(); - const view = normalizeLines(this.#term.getViewport()); + // A scroll-buffer read already contains the viewport rows. Derive the + // presented window from it instead of asking Ghostty to decode the active + // grid a second time on every oracle snapshot. Tmux-style scenarios do not + // consume historical rows, so retain their cheaper viewport-only path. + const buffer = this.#traits.preservesPaneHistory + ? normalizeLines(this.#term.getViewport()) + : normalizeLines(this.#term.getScrollBuffer()); + const view = this.#traits.preservesPaneHistory + ? buffer + : buffer.slice(position.viewportY, position.viewportY + this.#term.rows); const viewBackgroundColumns: number[][] = []; for (let row = 0; row < this.#term.rows; row++) { viewBackgroundColumns.push(this.#term.getViewportRowBackgroundColumns(row)); } - // Tmux pane history is intentionally preserved, so overlay bytes can remain - // in historical scrollback after resize/reflow. The non-strict tmux stress - // oracle only checks live viewport behavior; avoid repeatedly materializing - // huge preserved pane history that no invariant consumes. return { - buffer: this.#traits.preservesPaneHistory ? view : normalizeLines(this.#term.getScrollBuffer()), + buffer, view, viewBackgroundColumns, frameBackgroundColumns: expected.backgroundColumns, @@ -2088,8 +2091,7 @@ class StressDriver { return candidates.length === 0 ? current : this.#streams.geometry.pick(candidates); } - async #checkpoint(index: number, kind: "periodicCheckpoint"): Promise { - const before = this.#snapshot(); + async #checkpoint(index: number, before: Snapshot): Promise { // Model a prompt submit: the editor keystroke pins the terminal to the // bottom, then the app reconciles any deferred native-scrollback rewrite // only if the renderer can prove the native host viewport is at the tail. @@ -2111,7 +2113,13 @@ class StressDriver { } await this.#settle(); const after = this.#snapshot(); - this.#recordOperation(index, kind, { forcedCheckpoint: this.#traits.strictNativeScrollback }, before, after); + this.#recordOperation( + index, + "periodicCheckpoint", + { forcedCheckpoint: this.#traits.strictNativeScrollback }, + before, + after, + ); this.#assertOracles( { kind: "scrollToBottom", @@ -2128,6 +2136,7 @@ class StressDriver { after, index, ); + return after; } #recordOperation( diff --git a/packages/utils/src/ptree.ts b/packages/utils/src/ptree.ts index 179656cfb..b9d4d37f3 100644 --- a/packages/utils/src/ptree.ts +++ b/packages/utils/src/ptree.ts @@ -217,11 +217,11 @@ export class ChildProcess { return this; } - kill(reason?: Exception) { + kill(reason?: Exception, gracefulMs?: number) { if (reason && !this.#exitReasonPending) this.#exitReasonPending = reason; if (!this.proc.killed) void Process.fromPid(this.proc.pid) - ?.terminate() + ?.terminate(gracefulMs === undefined ? undefined : { gracefulMs }) ?.catch(e => void e); }