diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index d82ab6de8..368211745 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added dynamic Antigravity and Gemini CLI discovery support for Gemini 3.7 Flash, with low/medium/high thinking-level routing. + ### Changed - Updated model metadata, context windows, pricing, and configurations in the catalog diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index 9caf3b766..039c844a3 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -12007,7 +12007,8 @@ ], "supportsDisplay": true }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "claude-opus-4-0": { "id": "claude-opus-4-0", @@ -18704,7 +18705,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "gemini-3.7-flash-low": { "id": "gemini-3.7-flash-low", @@ -18726,7 +18728,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "gemini-3.7-flash-medium": { "id": "gemini-3.7-flash-medium", @@ -18748,7 +18751,8 @@ "contextWindow": 200000, "maxTokens": 64000, "cursorMaxMode": false, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "glm-5.2-high": { "id": "glm-5.2-high", @@ -25111,6 +25115,36 @@ "requiresEffort": true } }, + "gemini-3.7-flash": { + "id": "gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "api": "google-generative-ai", + "provider": "google", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.75, + "output": 3.75, + "cacheRead": 0.075, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "google-level", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "requiresEffort": true + } + }, "gemini-flash-latest": { "id": "gemini-flash-latest", "name": "Gemini Flash Latest", @@ -26792,6 +26826,36 @@ "requiresEffort": true } }, + "gemini-3.7-flash": { + "id": "gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "api": "google-vertex", + "provider": "google-vertex", + "baseUrl": "https://{location}-aiplatform.googleapis.com", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0.75, + "output": 3.75, + "cacheRead": 0.075, + "cacheWrite": 0 + }, + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "google-level", + "efforts": [ + "minimal", + "low", + "medium", + "high" + ], + "requiresEffort": true + } + }, "gemini-flash-latest": { "id": "gemini-flash-latest", "name": "Gemini Flash Latest", @@ -32855,7 +32919,8 @@ "xhigh" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "inclusionai/ring-2.6-1t": { "id": "inclusionai/ring-2.6-1t", @@ -51761,7 +51826,8 @@ }, "contextWindow": 262144, "maxTokens": 32768, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "inclusionai/ling-3.0-tiny:thinking": { "id": "inclusionai/ling-3.0-tiny:thinking", @@ -51791,7 +51857,8 @@ "xhigh" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "inclusionai/ring-2.6-1t": { "id": "inclusionai/ring-2.6-1t", @@ -61824,7 +61891,8 @@ ], "requiresEffort": true }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "TEE/muse-glimmer-30b": { "id": "TEE/muse-glimmer-30b", @@ -77936,10 +78004,10 @@ "image" ], "cost": { - "input": 1.5, - "output": 7.5, - "cacheRead": 0.15, - "cacheWrite": 0.0833333333333333 + "input": 0.375, + "output": 1.875, + "cacheRead": 0.0375, + "cacheWrite": 0.0416666666666667 }, "contextWindow": 1048576, "maxTokens": 65536, @@ -81248,7 +81316,8 @@ ], "requiresEffort": true }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "google/gemini-3.7-flash:batch": { "id": "google/gemini-3.7-flash:batch", @@ -81279,7 +81348,8 @@ ], "requiresEffort": true }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "google/gemma-3-12b-it": { "id": "google/gemma-3-12b-it", @@ -90818,7 +90888,8 @@ }, "requiresEffort": true }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4": { "id": "hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4", @@ -90848,7 +90919,8 @@ "xhigh" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "hf:openai/gpt-oss-120b": { "id": "hf:openai/gpt-oss-120b", @@ -90876,7 +90948,8 @@ "high" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "hf:Qwen/Qwen3.6-27B": { "id": "hf:Qwen/Qwen3.6-27B", @@ -90906,7 +90979,8 @@ "high" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "hf:zai-org/GLM-4.7-Flash": { "id": "hf:zai-org/GLM-4.7-Flash", @@ -90936,7 +91010,8 @@ "xhigh" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "hf:zai-org/GLM-5.2": { "id": "hf:zai-org/GLM-5.2", @@ -90966,7 +91041,8 @@ "max" ] }, - "supportsComputerUse": false + "supportsComputerUse": false, + "supportsComputerUseConfig": false }, "syn:large:text": { "id": "syn:large:text", @@ -104236,6 +104312,7 @@ "contextWindow": 2000000, "maxTokens": 2000000, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104267,6 +104344,7 @@ "contextWindow": 2000000, "maxTokens": 2000000, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104310,6 +104388,7 @@ } }, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104354,6 +104433,7 @@ } }, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104398,6 +104478,7 @@ } }, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104457,6 +104538,7 @@ "contextWindow": 512000, "maxTokens": 512000, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104488,6 +104570,7 @@ "contextWindow": 256000, "maxTokens": 256000, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { "minimal": "low" @@ -104518,6 +104601,7 @@ "contextWindow": 200000, "maxTokens": 200000, "supportsComputerUse": false, + "supportsComputerUseConfig": false, "compat": { "reasoningEffortMap": { "minimal": "low" diff --git a/packages/catalog/src/variant-collapse.ts b/packages/catalog/src/variant-collapse.ts index b95e02097..89bacc111 100644 --- a/packages/catalog/src/variant-collapse.ts +++ b/packages/catalog/src/variant-collapse.ts @@ -265,22 +265,32 @@ function geminiFlashFamily(mode: "budget" | "google-level"): EffortVariantFamily }; } -const GEMINI_36_FLASH_FAMILY: EffortVariantFamily = { - id: "gemini-3.6-flash", - name: "Gemini 3.6 Flash", - members: ["gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-high", "gemini-3.6-flash-tiered"], - routing: { - [Effort.Minimal]: "gemini-3.6-flash-low", - [Effort.Low]: "gemini-3.6-flash-low", - [Effort.Medium]: "gemini-3.6-flash-medium", - [Effort.High]: "gemini-3.6-flash-high", - }, - thinking: { - mode: "google-level", - efforts: GEMINI_3_FLASH_FAMILY_EFFORTS, - requiresEffort: true, - }, -}; +/** + * Gemini 3.6+ Flash exposes one mandatory-reasoning wire id per thinking + * level. Some generations retain additional discovery-only aliases. + */ +function geminiLevelFlashFamily(version: "3.6" | "3.7", ...additionalMembers: string[]): EffortVariantFamily { + const id = `gemini-${version}-flash`; + return { + id, + name: `Gemini ${version} Flash`, + members: [`${id}-low`, `${id}-medium`, `${id}-high`, ...additionalMembers], + routing: { + [Effort.Minimal]: `${id}-low`, + [Effort.Low]: `${id}-low`, + [Effort.Medium]: `${id}-medium`, + [Effort.High]: `${id}-high`, + }, + thinking: { + mode: "google-level", + efforts: GEMINI_3_FLASH_FAMILY_EFFORTS, + requiresEffort: true, + }, + }; +} + +const GEMINI_36_FLASH_FAMILY = geminiLevelFlashFamily("3.6", "gemini-3.6-flash-tiered"); +const GEMINI_37_FLASH_FAMILY = geminiLevelFlashFamily("3.7"); function geminiProFamily(mode: "budget" | "google-level"): EffortVariantFamily { const budget = mode === "budget"; @@ -364,13 +374,20 @@ const SHARED_CCA_FAMILIES: readonly EffortVariantFamily[] = [ /** `google-antigravity` Gemini families, using each generation's native transport. */ export const ANTIGRAVITY_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = { - families: [GEMINI_36_FLASH_FAMILY, geminiFlashFamily("budget"), geminiProFamily("budget"), ...SHARED_CCA_FAMILIES], + families: [ + GEMINI_36_FLASH_FAMILY, + GEMINI_37_FLASH_FAMILY, + geminiFlashFamily("budget"), + geminiProFamily("budget"), + ...SHARED_CCA_FAMILIES, + ], }; /** `google-gemini-cli` Gemini families on the official CLI's level transport. */ export const GEMINI_CLI_VARIANT_COLLAPSE_TABLE: VariantCollapseTable = { families: [ GEMINI_36_FLASH_FAMILY, + GEMINI_37_FLASH_FAMILY, geminiFlashFamily("google-level"), geminiProFamily("google-level"), ...SHARED_CCA_FAMILIES, diff --git a/packages/catalog/test/variant-collapse.test.ts b/packages/catalog/test/variant-collapse.test.ts index 4540f9b6f..05b4d4545 100644 --- a/packages/catalog/test/variant-collapse.test.ts +++ b/packages/catalog/test/variant-collapse.test.ts @@ -814,6 +814,27 @@ describe("antigravity discovery collapsing", () => { supportsImages: true, thinkingBudget: 10_000, }, + "gemini-3.7-flash-low": { + displayName: "Gemini 3.7 Flash Low", + supportsThinking: true, + supportsImages: true, + maxTokens: 1_048_576, + maxOutputTokens: 65_536, + }, + "gemini-3.7-flash-medium": { + displayName: "Gemini 3.7 Flash Medium", + supportsThinking: true, + supportsImages: true, + maxTokens: 1_048_576, + maxOutputTokens: 65_536, + }, + "gemini-3.7-flash-high": { + displayName: "Gemini 3.7 Flash High", + supportsThinking: true, + supportsImages: true, + maxTokens: 1_048_576, + maxOutputTokens: 65_536, + }, "claude-sonnet-4-6": { displayName: "Claude Sonnet 4.6", supportsThinking: true, supportsImages: true }, "claude-sonnet-4-6-thinking": { displayName: "Claude Sonnet 4.6 Thinking", @@ -835,7 +856,12 @@ describe("antigravity discovery collapsing", () => { it("returns collapsed logical entries and keeps the denylist", async () => { const models = await fetchAntigravityDiscoveryModels({ token: "t", endpoint: "https://cca.test", fetcher }); - expect(models?.map(m => m.id).sort()).toEqual(["claude-sonnet-4-6", "gemini-2.5-flash", "gemini-3.5-flash"]); + expect(models?.map(m => m.id).sort()).toEqual([ + "claude-sonnet-4-6", + "gemini-2.5-flash", + "gemini-3.5-flash", + "gemini-3.7-flash", + ]); const flash = models?.find(m => m.id === "gemini-3.5-flash"); expect(flash?.requestModelId).toBe("gemini-3.5-flash-extra-low"); expect(flash?.thinking?.effortRouting?.[Effort.High]).toBe("gemini-3-flash-agent"); @@ -845,6 +871,19 @@ describe("antigravity discovery collapsing", () => { const flash25 = models?.find(m => m.id === "gemini-2.5-flash"); expect(flash25?.thinking?.effortRouting?.[Effort.High]).toBe("gemini-2.5-flash-thinking"); expect(flash25?.thinking?.effortRouting?.off).toBe("gemini-2.5-flash"); + const flash37 = models?.find(m => m.id === "gemini-3.7-flash"); + expect(flash37?.requestModelId).toBe("gemini-3.7-flash-low"); + expect(flash37?.thinking).toEqual({ + mode: "google-level", + efforts: [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High], + requiresEffort: true, + effortRouting: { + minimal: "gemini-3.7-flash-low", + low: "gemini-3.7-flash-low", + medium: "gemini-3.7-flash-medium", + high: "gemini-3.7-flash-high", + }, + }); }); it("keeps collapsed routing through the gemini-cli re-provision", async () => { @@ -860,6 +899,10 @@ describe("antigravity discovery collapsing", () => { expect(flash?.baseUrl).toBe("https://cca.test"); expect(flash?.requestModelId).toBe("gemini-3.5-flash-extra-low"); expect(flash?.thinking?.effortRouting?.off).toBe("gemini-3.5-flash-extra-low"); + const flash37 = models?.find(m => m.id === "gemini-3.7-flash"); + expect(flash37?.requestModelId).toBe("gemini-3.7-flash-low"); + expect(flash37?.thinking?.requiresEffort).toBe(true); + expect(flash37?.thinking?.effortRouting?.[Effort.High]).toBe("gemini-3.7-flash-high"); }); it("uses the primary daily endpoint by default", async () => { diff --git a/packages/coding-agent/test/eval/js-context-manager.test.ts b/packages/coding-agent/test/eval/js-context-manager.test.ts deleted file mode 100644 index 334c20af6..000000000 --- a/packages/coding-agent/test/eval/js-context-manager.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { type } from "@oh-my-pi/omptype"; -import type { AgentTool } from "@oh-my-pi/pi-agent-core"; -import { TempDir } from "@oh-my-pi/pi-utils"; -import { Settings } from "../../src/config/settings"; -import { - disposeAllVmContexts, - setJsEvalWorkerThreadForTests, - setWorkerCloseTimeoutMsForTests, -} from "../../src/eval/js/context-manager"; -import { executeJs } from "../../src/eval/js/executor"; -import type { ToolSession } from "../../src/tools"; - -const originalWorker = globalThis.Worker; - -interface FakeWorkerStats { - closeRequests: number; - terminateCalls: number; -} - -interface FakeWorkerBehavior { - exitOnClose: boolean; - settleRuns: boolean; - errorOnStart?: boolean; - /** - * Reproduces `WorkerCore#runOne` for a floated bridge call: start a tool call - * and report the run finished in the same turn, without awaiting the call. - */ - floatingToolCall?: string; -} - -function makeSession(cwd: string, ...tools: AgentTool[]): ToolSession { - return { - cwd, - hasUI: false, - settings: Settings.isolated({ - "async.enabled": false, - "task.isolation.mode": "none", - "task.enableLsp": true, - }), - taskDepth: 0, - enableLsp: true, - getSessionFile: () => null, - getSessionSpawns: () => "*", - getActiveModelString: () => "p/active", - getModelString: () => "p/fallback", - getArtifactsDir: () => null, - getSessionId: () => "test-session", - getEvalSessionId: () => "test-eval-session", - getToolByName: name => tools.find(tool => tool.name === name), - }; -} - -async function withTimeout(promise: Promise, ms: number, label: string): Promise { - let timeout: NodeJS.Timeout | undefined; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error(`${label} timed out`)), ms); - }), - ]); - } finally { - if (timeout) clearTimeout(timeout); - } -} - -async function waitForRealWorkerExitAfterClose(cwd: string): Promise { - const worker = new originalWorker(new URL("../../src/eval/js/worker-entry.ts", import.meta.url).href, { - type: "module", - }); - const ready = Promise.withResolvers(); - const runComplete = Promise.withResolvers(); - const closedAck = Promise.withResolvers(); - const workerClosed = Promise.withResolvers(); - const runId = `keep-alive:${crypto.randomUUID()}`; - const snapshot = { cwd, sessionId: `worker-exit:${crypto.randomUUID()}` }; - - worker.addEventListener("message", event => { - const msg = event.data as { type?: string; runId?: string; ok?: boolean }; - if (msg.type === "ready") ready.resolve(); - else if (msg.type === "result" && msg.runId === runId && msg.ok) runComplete.resolve(); - else if (msg.type === "closed") closedAck.resolve(); - }); - worker.addEventListener("close", () => workerClosed.resolve()); - - try { - worker.postMessage({ type: "init", snapshot }); - await withTimeout(ready.promise, 1_000, "worker ready"); - worker.postMessage({ - type: "run", - runId, - code: "globalThis.__keepAlive = setInterval(() => {}, 1000);\nundefined;", - filename: "keep-alive.js", - snapshot, - }); - await withTimeout(runComplete.promise, 1_000, "worker run"); - worker.postMessage({ type: "close" }); - await withTimeout(closedAck.promise, 1_000, "worker closed ack"); - await withTimeout(workerClosed.promise, 1_000, "worker close event"); - } finally { - worker.terminate(); - } -} - -function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior): void { - class FakeWorker { - #messageListeners = new Set<(event: MessageEvent) => void>(); - #closeListeners = new Set<(event: Event) => void>(); - #errorListeners = new Set<(event: Event) => void>(); - #readyQueued = false; - #exited = false; - - postMessage(message: unknown): void { - if (!message || typeof message !== "object") return; - const typed = message as { type?: string; runId?: string }; - if (typed.type === "run" && typed.runId && behavior.floatingToolCall) { - const runId = typed.runId; - queueMicrotask(() => { - this.#emitMessage({ - type: "tool-call", - runId, - id: `tc-${runId}`, - name: behavior.floatingToolCall, - args: {}, - }); - this.#emitMessage({ type: "result", runId, ok: true }); - }); - return; - } - if (typed.type === "run" && typed.runId && behavior.settleRuns) { - queueMicrotask(() => this.#emitMessage({ type: "result", runId: typed.runId, ok: true })); - return; - } - if (typed.type === "close") { - stats.closeRequests++; - queueMicrotask(() => { - this.#emitMessage({ type: "closed" }); - if (behavior.exitOnClose) this.#emitClose(); - }); - } - } - - addEventListener(type: string, listener: (event: MessageEvent | Event) => void): void { - if (type === "close") { - this.#closeListeners.add(listener as (event: Event) => void); - return; - } - if (type === "error") { - this.#errorListeners.add(listener as (event: Event) => void); - return; - } - if (type !== "message") return; - this.#messageListeners.add(listener as (event: MessageEvent) => void); - if (!this.#readyQueued) { - this.#readyQueued = true; - queueMicrotask(() => { - if (behavior.errorOnStart) this.#emitError(); - else this.#emitMessage({ type: "ready" }); - }); - } - } - - removeEventListener(type: string, listener: (event: MessageEvent | Event) => void): void { - if (type === "close") { - this.#closeListeners.delete(listener as (event: Event) => void); - return; - } - if (type === "error") { - this.#errorListeners.delete(listener as (event: Event) => void); - return; - } - if (type !== "message") return; - this.#messageListeners.delete(listener as (event: MessageEvent) => void); - } - - terminate(): void { - stats.terminateCalls++; - this.#emitClose(); - } - - #emitMessage(data: unknown): void { - const event = new MessageEvent("message", { data }); - for (const listener of this.#messageListeners) listener(event); - } - - #emitClose(): void { - if (this.#exited) return; - this.#exited = true; - const event = new Event("close"); - for (const listener of this.#closeListeners) listener(event); - } - - #emitError(): void { - const event = new ErrorEvent("error", { - message: "fake worker failed to start", - error: new Error("fake worker failed to start"), - }); - for (const listener of this.#errorListeners) listener(event); - } - } - - Object.defineProperty(globalThis, "Worker", { - configurable: true, - writable: true, - value: FakeWorker as unknown as typeof Worker, - }); -} - -describe("JavaScript eval worker lifecycle", () => { - let restoreCloseTimeoutMs = 0; - let restoreWorkerThread = false; - beforeEach(() => { - restoreWorkerThread = setJsEvalWorkerThreadForTests(true); - // Shrink the graceful-close grace period so the "close acked but the worker - // never exits -> force terminate" contract is proven without a real 1s wait. - restoreCloseTimeoutMs = setWorkerCloseTimeoutMsForTests(1); - }); - - afterEach(async () => { - // Dispose while the shrunk timeout is still active so a hung worker's afterEach - // close also force-terminates instantly, then restore the production default. - await disposeAllVmContexts(); - setWorkerCloseTimeoutMsForTests(restoreCloseTimeoutMs); - Object.defineProperty(globalThis, "Worker", { - configurable: true, - writable: true, - value: originalWorker, - }); - setJsEvalWorkerThreadForTests(restoreWorkerThread); - }); - - it("exits a real worker on graceful close even with ref'ed user handles", async () => { - using tempDir = TempDir.createSync("@omp-js-worker-real-close-"); - - await waitForRealWorkerExitAfterClose(tempDir.path()); - }); - - it("waits for the worker to close on reset instead of force-terminating it", async () => { - using tempDir = TempDir.createSync("@omp-js-worker-close-"); - const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 }; - installFakeWorker(stats, { exitOnClose: true, settleRuns: true }); - - const session = makeSession(tempDir.path()); - const sessionId = `js-close:${crypto.randomUUID()}`; - - const first = await executeJs("globalThis.marker = 1;", { cwd: tempDir.path(), sessionId, session }); - expect(first.exitCode).toBe(0); - - const second = await executeJs("globalThis.marker = 2;", { - cwd: tempDir.path(), - sessionId, - session, - reset: true, - }); - expect(second.exitCode).toBe(0); - expect(stats.closeRequests).toBe(1); - expect(stats.terminateCalls).toBe(0); - }); - - it("terminates when close is acknowledged but the worker does not exit", async () => { - using tempDir = TempDir.createSync("@omp-js-worker-close-hung-"); - const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 }; - installFakeWorker(stats, { exitOnClose: false, settleRuns: true }); - - const session = makeSession(tempDir.path()); - const sessionId = `js-close-hung:${crypto.randomUUID()}`; - - const first = await executeJs("globalThis.marker = 1;", { cwd: tempDir.path(), sessionId, session }); - expect(first.exitCode).toBe(0); - - const second = await executeJs("globalThis.marker = 2;", { - cwd: tempDir.path(), - sessionId, - session, - reset: true, - }); - expect(second.exitCode).toBe(0); - expect(stats.closeRequests).toBe(1); - expect(stats.terminateCalls).toBe(1); - }); - - it("force-terminates instead of closing when an in-flight run is aborted", async () => { - using tempDir = TempDir.createSync("@omp-js-worker-abort-"); - const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 }; - installFakeWorker(stats, { exitOnClose: true, settleRuns: false }); - - const session = makeSession(tempDir.path()); - const sessionId = `js-abort:${crypto.randomUUID()}`; - const controller = new AbortController(); - const resultPromise = executeJs("globalThis.neverFinishes = true;", { - cwd: tempDir.path(), - sessionId, - session, - signal: controller.signal, - }); - setTimeout(() => controller.abort(new DOMException("Execution aborted", "AbortError")), 0); - - const result = await resultPromise; - expect(result.cancelled).toBe(true); - expect(stats.closeRequests).toBe(0); - expect(stats.terminateCalls).toBe(1); - }); - - it("falls back to a Bun Worker when the subprocess cannot spawn", async () => { - using tempDir = TempDir.createSync("@omp-js-spawn-fallback-"); - // Exercise the production ladder (process -> worker -> inline), not the - // worker-thread test seam the surrounding describe enables. - setJsEvalWorkerThreadForTests(false); - const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 }; - installFakeWorker(stats, { exitOnClose: true, settleRuns: true }); - const originalSpawn = Bun.spawn; - let spawnAttempts = 0; - Bun.spawn = ((): never => { - spawnAttempts++; - throw new Error("subprocess spawn unavailable"); - }) as unknown as typeof Bun.spawn; - - try { - const session = makeSession(tempDir.path()); - const sessionId = `js-spawn-fallback:${crypto.randomUUID()}`; - // The fake Worker settles runs without executing the cell, so an empty - // output proves the middle rung handled it — the inline fallback would - // have actually evaluated the expression and printed 42. - const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session }); - expect(result.exitCode).toBe(0); - expect(result.output.trim()).toBe(""); - expect(spawnAttempts).toBe(1); - } finally { - Bun.spawn = originalSpawn; - } - }); - - it("falls back to a Bun Worker when the subprocess fails during initialization", async () => { - using tempDir = TempDir.createSync("@omp-js-init-fallback-"); - // Exercise the production ladder (process -> worker -> inline), not the - // worker-thread test seam the surrounding describe enables. - setJsEvalWorkerThreadForTests(false); - const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 }; - installFakeWorker(stats, { exitOnClose: true, settleRuns: true }); - const originalSpawn = Bun.spawn; - let spawnAttempts = 0; - Bun.spawn = ((options: unknown) => { - spawnAttempts++; - const spawnOptions = options as { - onExit?: (proc: unknown, exitCode: number | null, signalCode: string | null) => void; - }; - const fakeProcess = { - send: () => undefined, - kill: () => undefined, - unref: () => undefined, - }; - queueMicrotask(() => spawnOptions.onExit?.(fakeProcess, 1, null)); - return fakeProcess; - }) as unknown as typeof Bun.spawn; - - try { - const session = makeSession(tempDir.path()); - const sessionId = `js-init-fallback:${crypto.randomUUID()}`; - // The fake Worker settles runs without executing the cell, so empty - // output proves the middle rung handled the retry. Inline execution - // would evaluate the expression and print 42. - const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session }); - expect(result.exitCode).toBe(0); - expect(result.output.trim()).toBe(""); - expect(spawnAttempts).toBe(1); - } finally { - Bun.spawn = originalSpawn; - } - }); - - it("falls back to the inline worker when the spawned worker errors during startup", async () => { - using tempDir = TempDir.createSync("@omp-js-worker-error-"); - const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 }; - installFakeWorker(stats, { exitOnClose: true, settleRuns: true, errorOnStart: true }); - - const session = makeSession(tempDir.path()); - const sessionId = `js-worker-error:${crypto.randomUUID()}`; - - // The spawned worker emits an `error` event instead of `ready`. Without fail-fast - // error handling the handshake would stall until WORKER_INIT_TIMEOUT_MS (15s); with - // it, the handshake rejects at once and the inline worker runs the cell. - const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session }); - expect(result.exitCode).toBe(0); - expect(result.output.trim()).toBe("42"); - // The errored primary worker is torn down before the inline retry takes over. - expect(stats.terminateCalls).toBe(1); - }); - - it("holds a finished run until its floated tool call drains", async () => { - // Regression, and the sharpest form of the runaway `agent()` fan-out — no - // cancellation required. `WorkerCore#runOne` reports a finished run without - // awaiting outstanding tool calls, so `agent(...)` with no `await` used to - // settle the cell immediately; `runOnce` then dropped the run's abort - // listener and pending entry, leaving the subagent running with nothing - // able to cancel it. A cell must own every bridge call it starts. - using tempDir = TempDir.createSync("@omp-js-worker-float-"); - const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 }; - installFakeWorker(stats, { exitOnClose: true, settleRuns: false, floatingToolCall: "park" }); - - const started = Promise.withResolvers(); - const release = Promise.withResolvers(); - let toolReturned = false; - const park: AgentTool = { - name: "park", - label: "Park", - description: "Parks until released", - parameters: type({}), - execute: async () => { - started.resolve(); - await release.promise; - toolReturned = true; - return { content: [{ type: "text", text: "parked" }] }; - }, - }; - const session = makeSession(tempDir.path(), park); - - let settled = false; - const cell = executeJs('tool.park({});\nreturn "floated";', { - cwd: tempDir.path(), - sessionId: `js-float:${crypto.randomUUID()}`, - session, - }).finally(() => { - settled = true; - }); - - await started.promise; - // The fake worker already reported the run finished, in the same microtask - // that started the call. Draining the microtask queue is exact here: the - // harness is queueMicrotask-driven, with no IPC or timers in the path. - for (let i = 0; i < 50; i++) await Promise.resolve(); - expect(settled).toBe(false); - expect(toolReturned).toBe(false); - - release.resolve(); - const result = await cell; - expect(toolReturned).toBe(true); - expect(result.exitCode).toBe(0); - }); -}); - -describe.skipIf(process.platform === "win32")("JavaScript eval process isolation", () => { - afterEach(async () => { - await disposeAllVmContexts(); - }); - - 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()}`; - const result = await executeJs( - [ - `const child = Bun.spawn(["/bin/sh", "-c", 'pgid=$(ps -o pgid= -p $$); printf "%s %s\\n" "$pgid" "$PPID"'], { stdout: "pipe" });`, - "return await new Response(child.stdout).text();", - ].join("\n"), - { cwd: tempDir.path(), sessionId: evalSessionId, session }, - ); - const [processGroupId, parentProcessId] = result.output.trim().split(/\s+/).map(Number); - 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, - session, - }); - const reused = await executeJs("return increment(saved);", { - cwd: tempDir.path(), - sessionId: evalSessionId, - session, - }); - expect(reused.output.trim()).toBe("42"); - - 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");', { - 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 () => { - using tempDir = TempDir.createSync("@omp-js-process-cwd-missing-"); - const missingCwd = path.join(tempDir.path(), "deleted"); - const session = makeSession(missingCwd); - const result = await executeJs("return String(6 * 7);", { - cwd: missingCwd, - sessionId: `js-cwd-missing:${crypto.randomUUID()}`, - session, - }); - expect(result.exitCode).toBe(0); - expect(result.output.trim()).toBe("42"); - }); -}); diff --git a/packages/coding-agent/test/utils/markit-mupdf-warnings.test.ts b/packages/coding-agent/test/utils/markit-mupdf-warnings.test.ts deleted file mode 100644 index ea69e22b1..000000000 --- a/packages/coding-agent/test/utils/markit-mupdf-warnings.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "bun:test"; -import { convertBufferWithMarkit } from "@oh-my-pi/pi-coding-agent/utils/markit"; -import { logger } from "@oh-my-pi/pi-utils"; - -function warningPdf(): Uint8Array { - const objects: string[] = []; - function add(body: string): void { - objects.push(body); - } - - const pageText = "/P <> BDC\nBT /F1 24 Tf 72 720 Td (Tagged PDF repro text) Tj ET\nEMC\n"; - add("<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>"); - add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); - add( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R /StructParents 0 /Annots [9 0 R] >>", - ); - add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"); - add(`<< /Length ${pageText.length} >>\nstream\n${pageText}endstream`); - add("<< /Nums [0 [7 0 R]] >>"); - add("<< /Type /StructElem /S /P /P 8 0 R /Pg 3 0 R /K 99 >>"); - add("<< /Type /StructTreeRoot /K [7 0 R] /ParentTree 6 0 R /ParentTreeNextKey 1 >>"); - add("<< /Type /Annot /Subtype /Screen /Rect [72 650 200 700] /T (movie) >>"); - - let pdf = "%PDF-1.7\n"; - const offsets = [0]; - for (let i = 0; i < objects.length; i++) { - offsets.push(Buffer.byteLength(pdf)); - pdf += `${i + 1} 0 obj\n${objects[i]}\nendobj\n`; - } - - const xref = Buffer.byteLength(pdf); - pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; - for (let i = 1; i < offsets.length; i++) { - pdf += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`; - } - pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; - - return new TextEncoder().encode(pdf); -} - -describe("markit MuPDF warnings", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("routes recoverable PDF warnings to the file logger", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - const debug = vi.spyOn(logger, "debug").mockImplementation(() => undefined); - - const result = await convertBufferWithMarkit(warningPdf(), ".pdf", undefined, { useCache: false }); - - expect(result.ok).toBe(true); - expect(result.content).toContain("Tagged PDF repro text"); - expect(consoleError).not.toHaveBeenCalled(); - expect( - debug.mock.calls.some(([message, metadata]) => { - if (message !== "mupdf wasm output" || typeof metadata !== "object" || metadata === null) return false; - if (!("stream" in metadata) || metadata.stream !== "stderr") return false; - return "message" in metadata && String(metadata.message).includes("Screen annotations"); - }), - ).toBe(true); - }); -});