feat(catalog): added Gemini Flash model definitions and routing
- Added Gemini 3.7 Flash model definitions and reasoning effort routing configurations. - Implemented geminiLevelFlashFamily in variant-collapse.ts to manage 3.6+ flash thinking levels. - Added test coverage for Gemini 3.7 Flash variant collapse and discovery routing.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(`${label} timed out`)), ms);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRealWorkerExitAfterClose(cwd: string): Promise<void> {
|
||||
const worker = new originalWorker(new URL("../../src/eval/js/worker-entry.ts", import.meta.url).href, {
|
||||
type: "module",
|
||||
});
|
||||
const ready = Promise.withResolvers<void>();
|
||||
const runComplete = Promise.withResolvers<void>();
|
||||
const closedAck = Promise.withResolvers<void>();
|
||||
const workerClosed = Promise.withResolvers<void>();
|
||||
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<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 <</MCID 0>> 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user