From 11ea3ebee7e0f14b44cd366acab9fc06cb225939 Mon Sep 17 00:00:00 2001 From: Kamil Jopek Date: Thu, 10 Sep 2026 01:19:51 -0500 Subject: [PATCH 1/7] fix(ci): select supported runtime models for agent checks --- .../gh-github-pull-request-opened.yml | 3 +- .../gh-github-pull-request-synchronized.yml | 3 +- .../poe-code-github-pull-request-opened.yml | 5 ++- ...-code-github-pull-request-synchronized.yml | 5 ++- e2e/claude-code.test.ts | 7 ++-- e2e/mcp-tool.test.ts | 12 +++++- e2e/runtime-models.ts | 28 ++++++++++++++ packages/agent-spawn/src/agent-spawn.test.ts | 7 ++++ packages/agent-spawn/src/configs/kimi.ts | 4 +- .../github-pull-request-opened.ejected.yml | 5 ++- ...thub-pull-request-synchronized.ejected.yml | 5 ++- scripts/e2e-runtime-models.test.ts | 37 +++++++++++++++++++ scripts/reviewer-runtime-model.test.ts | 26 +++++++++++++ 13 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 e2e/runtime-models.ts create mode 100644 scripts/e2e-runtime-models.test.ts create mode 100644 scripts/reviewer-runtime-model.test.ts diff --git a/.github/workflows/gh-github-pull-request-opened.yml b/.github/workflows/gh-github-pull-request-opened.yml index b3ede59ea1..78d2093912 100644 --- a/.github/workflows/gh-github-pull-request-opened.yml +++ b/.github/workflows/gh-github-pull-request-opened.yml @@ -120,7 +120,7 @@ jobs: env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} - - run: poe-code github-workflows github-pull-request-opened --yes + - run: poe-code github-workflows github-pull-request-opened --yes --model "${POE_CODE_REVIEW_MODEL:-gpt-5.4}" env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} @@ -129,3 +129,4 @@ jobs: PR_TITLE: ${{ inputs.PR_TITLE }} PR_AUTHOR: ${{ inputs.PR_AUTHOR }} GITHUB_REPOSITORY: ${{ inputs.GITHUB_REPOSITORY }} + POE_CODE_REVIEW_MODEL: ${{ vars.POE_CODE_REVIEW_MODEL }} diff --git a/.github/workflows/gh-github-pull-request-synchronized.yml b/.github/workflows/gh-github-pull-request-synchronized.yml index 9004b4733c..a1c65b87fa 100644 --- a/.github/workflows/gh-github-pull-request-synchronized.yml +++ b/.github/workflows/gh-github-pull-request-synchronized.yml @@ -114,10 +114,11 @@ jobs: env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} - - run: poe-code github-workflows github-pull-request-synchronized --yes + - run: poe-code github-workflows github-pull-request-synchronized --yes --model "${POE_CODE_REVIEW_MODEL:-gpt-5.4}" env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ inputs.PR_NUMBER }} GITHUB_REPOSITORY: ${{ inputs.GITHUB_REPOSITORY }} + POE_CODE_REVIEW_MODEL: ${{ vars.POE_CODE_REVIEW_MODEL }} diff --git a/.github/workflows/poe-code-github-pull-request-opened.yml b/.github/workflows/poe-code-github-pull-request-opened.yml index f548867805..f11720826e 100644 --- a/.github/workflows/poe-code-github-pull-request-opened.yml +++ b/.github/workflows/poe-code-github-pull-request-opened.yml @@ -2,7 +2,7 @@ name: 'GitHub: Pull Request Opened' on: pull_request: - types: [opened, ready_for_review] + types: [ opened, ready_for_review ] jobs: guard: @@ -103,7 +103,7 @@ jobs: env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} - - run: poe-code github-workflows github-pull-request-opened --yes + - run: poe-code github-workflows github-pull-request-opened --yes --model "${POE_CODE_REVIEW_MODEL:-gpt-5.4}" env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} @@ -112,3 +112,4 @@ jobs: PR_TITLE: ${{ github.event.pull_request.title }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} GITHUB_REPOSITORY: ${{ github.repository }} + POE_CODE_REVIEW_MODEL: ${{ vars.POE_CODE_REVIEW_MODEL }} diff --git a/.github/workflows/poe-code-github-pull-request-synchronized.yml b/.github/workflows/poe-code-github-pull-request-synchronized.yml index dbd044d074..138cb35d79 100644 --- a/.github/workflows/poe-code-github-pull-request-synchronized.yml +++ b/.github/workflows/poe-code-github-pull-request-synchronized.yml @@ -2,7 +2,7 @@ name: 'GitHub: Pull Request Synchronized' on: pull_request: - types: [synchronize] + types: [ synchronize ] jobs: guard: @@ -103,10 +103,11 @@ jobs: env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} - - run: poe-code github-workflows github-pull-request-synchronized --yes + - run: poe-code github-workflows github-pull-request-synchronized --yes --model "${POE_CODE_REVIEW_MODEL:-gpt-5.4}" env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ github.event.pull_request.number }} GITHUB_REPOSITORY: ${{ github.repository }} + POE_CODE_REVIEW_MODEL: ${{ vars.POE_CODE_REVIEW_MODEL }} diff --git a/e2e/claude-code.test.ts b/e2e/claude-code.test.ts index f612ef6e8e..08bbfa0195 100644 --- a/e2e/claude-code.test.ts +++ b/e2e/claude-code.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useContainer } from '@poe-code/e2e-test-runner'; +import { useContainer, shellQuote } from '@poe-code/e2e-test-runner'; +import { resolveE2eModel } from './runtime-models.js'; describe('claude-code', () => { const container = useContainer({ testName: 'claude-code' }); @@ -19,12 +20,12 @@ describe('claude-code', () => { expect(config).not.toHaveProperty('env.ANTHROPIC_CUSTOM_HEADERS'); expect(config).toHaveProperty('env.ANTHROPIC_BASE_URL'); - const testResult = await container.exec('poe-code test claude-code'); + const testResult = await container.exec(`poe-code test claude-code --model ${shellQuote(resolveE2eModel('claude-code'))}`); expect(testResult).toSucceedWith('Tested Claude Code.'); }); it('test --isolated', async () => { - const result = await container.exec('poe-code test claude-code --isolated'); + const result = await container.exec(`poe-code test claude-code --isolated --model ${shellQuote(resolveE2eModel('claude-code'))}`); expect(result).toSucceedWith('Tested Claude Code.'); }); }); diff --git a/e2e/mcp-tool.test.ts b/e2e/mcp-tool.test.ts index c52a35c622..d4f8ae2c66 100644 --- a/e2e/mcp-tool.test.ts +++ b/e2e/mcp-tool.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useContainer, shellQuote } from '@poe-code/e2e-test-runner'; +import { resolveE2eModel, registerKimiFixtureModel } from './runtime-models.js'; interface AgentMcpSpawnTest { - name: string; + name: Parameters[0]; expectSpawnSuccess: boolean; spawnArgs?: string[]; } @@ -51,11 +52,18 @@ describe.each(agents)('spawn --mcp-config: $name', ({ name, expectSpawnSuccess, const configResult = await container.exec(`poe-code configure ${name} --yes`); expect(configResult).toHaveExitCode(0); + const model = resolveE2eModel(name); + if (name === 'kimi') { + const configPath = `${container.home}/.kimi/config.toml`; + const contextSize = Number(process.env.POE_CODE_E2E_KIMI_CONTEXT_SIZE ?? 131072); + await container.writeFile(configPath, registerKimiFixtureModel(await container.readFile(configPath), model, contextSize)); + } + const prompt = 'Call the word_of_the_day tool and return only the exact tool output.'; const extraArgs = spawnArgs ? ` -- ${spawnArgs.map((arg) => shellQuote(arg)).join(' ')}` : ''; - const command = `poe-code spawn --mode yolo --mcp-config ${mcpConfig} ${name} ${shellQuote(prompt)}${extraArgs}`; + const command = `poe-code spawn --mode yolo --model ${shellQuote(model)} --mcp-config ${mcpConfig} ${name} ${shellQuote(prompt)}${extraArgs}`; const spawnResult = await container.exec(command); if (!expectSpawnSuccess) { diff --git a/e2e/runtime-models.ts b/e2e/runtime-models.ts new file mode 100644 index 0000000000..63a4eddc42 --- /dev/null +++ b/e2e/runtime-models.ts @@ -0,0 +1,28 @@ +import { parse, stringify, type TomlTable } from "smol-toml"; + +const models = { + "claude-code": { env: "POE_CODE_E2E_CLAUDE_CODE_MODEL", model: "claude-sonnet-4.6" }, + codex: { env: "POE_CODE_E2E_CODEX_MODEL", model: "gpt-5.4" }, + opencode: { env: "POE_CODE_E2E_OPENCODE_MODEL", model: "gpt-5.4" }, + kimi: { env: "POE_CODE_E2E_KIMI_MODEL", model: "gpt-5.4" }, + goose: { env: "POE_CODE_E2E_GOOSE_MODEL", model: "gpt-5.4" }, +} as const; + +export function resolveE2eModel(agent: keyof typeof models, env: NodeJS.ProcessEnv = process.env): string { + const selected = models[agent]; + return env[selected.env]?.trim() || selected.model; +} + +export function registerKimiFixtureModel(source: string, model: string, maxContextSize = 131072): string { + const config = parse(source); + const providers = config.providers as TomlTable | undefined; + if (!providers?.poe) throw new Error("Configure the poe provider before registering the Kimi fixture model"); + const configured = (config.models ?? {}) as TomlTable; + if (Object.hasOwn(configured, model)) return source; + if (!Number.isSafeInteger(maxContextSize) || maxContextSize <= 0) throw new Error("Kimi fixture context size must be a positive integer"); + return stringify({ ...config, models: { ...configured, + // Keep this fixture below the selected GPT-5.4 context limit; native user + // aliases above retain their own context configuration unchanged. + [model]: { provider: "poe", model, max_context_size: maxContextSize }, + } }); +} diff --git a/packages/agent-spawn/src/agent-spawn.test.ts b/packages/agent-spawn/src/agent-spawn.test.ts index 0e0b3efc47..3ea70df50e 100644 --- a/packages/agent-spawn/src/agent-spawn.test.ts +++ b/packages/agent-spawn/src/agent-spawn.test.ts @@ -201,6 +201,13 @@ describe("stripModelNamespace", () => { // === build-spawn-args.test.ts === describe("buildSpawnArgs", () => { + it("forwards an explicit Kimi model alias without choosing a default", () => { + const selected = buildSpawnArgs("kimi", { prompt: "test", model: "custom/model-alias", mode: "yolo" }); + expect(selected.args).toContain("--model"); + expect(selected.args[selected.args.indexOf("--model") + 1]).toBe("custom/model-alias"); + expect(buildSpawnArgs("kimi", { prompt: "test", mode: "yolo" }).args).not.toContain("--model"); + }); + it("throws error if agent ID cannot be resolved", () => { expect(() => buildSpawnArgs("unknown", { prompt: "test" })).toThrow(/Unknown agent/); }); diff --git a/packages/agent-spawn/src/configs/kimi.ts b/packages/agent-spawn/src/configs/kimi.ts index 84da0f4cca..4e1e6dfd97 100644 --- a/packages/agent-spawn/src/configs/kimi.ts +++ b/packages/agent-spawn/src/configs/kimi.ts @@ -9,7 +9,9 @@ export const kimiSpawnConfig: CliSpawnConfig = { // (no `{ event, ... }` field), so it needs the Kimi adapter (not "native"). adapter: "kimi", promptFlag: "-p", - modelStripProviderPrefix: true, + modelFlag: "--model", + // Kimi selects a local models-table alias, which may itself contain slashes. + modelStripProviderPrefix: false, defaultArgs: ["--print", "--output-format", "stream-json"], mcpArgs: serializeJsonMcpArgs, modes: { diff --git a/packages/github-workflows/src/workflow-templates/github-pull-request-opened.ejected.yml b/packages/github-workflows/src/workflow-templates/github-pull-request-opened.ejected.yml index 8d4f0958c7..527d08739c 100644 --- a/packages/github-workflows/src/workflow-templates/github-pull-request-opened.ejected.yml +++ b/packages/github-workflows/src/workflow-templates/github-pull-request-opened.ejected.yml @@ -1,7 +1,7 @@ name: 'GitHub: Pull Request Opened' on: pull_request: - types: [opened, ready_for_review] + types: [ opened, ready_for_review ] jobs: guard: @@ -104,7 +104,7 @@ jobs: env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} - - run: poe-code github-workflows github-pull-request-opened --yes + - run: poe-code github-workflows github-pull-request-opened --yes --model "${POE_CODE_REVIEW_MODEL:-gpt-5.4}" env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} @@ -113,3 +113,4 @@ jobs: PR_TITLE: ${{ github.event.pull_request.title }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} GITHUB_REPOSITORY: ${{ github.repository }} + POE_CODE_REVIEW_MODEL: ${{ vars.POE_CODE_REVIEW_MODEL }} diff --git a/packages/github-workflows/src/workflow-templates/github-pull-request-synchronized.ejected.yml b/packages/github-workflows/src/workflow-templates/github-pull-request-synchronized.ejected.yml index 6f3f345c65..6a57f4150d 100644 --- a/packages/github-workflows/src/workflow-templates/github-pull-request-synchronized.ejected.yml +++ b/packages/github-workflows/src/workflow-templates/github-pull-request-synchronized.ejected.yml @@ -1,7 +1,7 @@ name: 'GitHub: Pull Request Synchronized' on: pull_request: - types: [synchronize] + types: [ synchronize ] jobs: guard: @@ -104,10 +104,11 @@ jobs: env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} - - run: poe-code github-workflows github-pull-request-synchronized --yes + - run: poe-code github-workflows github-pull-request-synchronized --yes --model "${POE_CODE_REVIEW_MODEL:-gpt-5.4}" env: POE_CODE_STDERR_LOGS: "1" POE_API_KEY: ${{ secrets.POE_API_KEY }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ github.event.pull_request.number }} GITHUB_REPOSITORY: ${{ github.repository }} + POE_CODE_REVIEW_MODEL: ${{ vars.POE_CODE_REVIEW_MODEL }} diff --git a/scripts/e2e-runtime-models.test.ts b/scripts/e2e-runtime-models.test.ts new file mode 100644 index 0000000000..81636c1673 --- /dev/null +++ b/scripts/e2e-runtime-models.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { parse, stringify } from "smol-toml"; +import { resolveE2eModel, registerKimiFixtureModel } from "../e2e/runtime-models.js"; + +describe("live-test runtime models", () => { + it("selects endpoint-compatible fixtures and preserves explicit overrides", () => { + expect(resolveE2eModel("claude-code", {})).toBe("claude-sonnet-4.6"); + expect(resolveE2eModel("codex", {})).toBe("gpt-5.4"); + expect(resolveE2eModel("kimi", { POE_CODE_E2E_KIMI_MODEL: "custom-alias" })).toBe("custom-alias"); + }); + + it("registers the selected Kimi model without replacing provider credentials or preferences", () => { + const original = { + default_model: "personal", default_thinking: true, + providers: { poe: { type: "openai_legacy", base_url: "https://api.poe.com/v1", api_key: "fixture-key" } }, + models: { personal: { provider: "poe", model: "other", max_context_size: 64000 } }, + }; + const result = parse(registerKimiFixtureModel(stringify(original), "gpt-5.4")); + expect(result).toEqual({ ...original, models: { ...original.models, + "gpt-5.4": { provider: "poe", model: "gpt-5.4", max_context_size: 131072 }, + } }); + }); + + it("preserves a user-defined model alias and refuses a missing provider", () => { + const config = stringify({ providers: { poe: {} }, models: { custom: { provider: "other", model: "selected", max_context_size: 12345 } } }); + expect(registerKimiFixtureModel(config, "custom")).toBe(config); + expect(() => registerKimiFixtureModel("", "gpt-5.4")).toThrow("poe provider"); + }); + + it("uses an explicit context bound for another fixture model and rejects invalid bounds", () => { + const config = stringify({ providers: { poe: {} } }); + expect(parse(registerKimiFixtureModel(config, "another", 64000)).models).toEqual({ + another: { provider: "poe", model: "another", max_context_size: 64000 }, + }); + expect(() => registerKimiFixtureModel(config, "another", Number.NaN)).toThrow("positive integer"); + }); +}); diff --git a/scripts/reviewer-runtime-model.test.ts b/scripts/reviewer-runtime-model.test.ts new file mode 100644 index 0000000000..5db802a85e --- /dev/null +++ b/scripts/reviewer-runtime-model.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +describe("reviewer runtime invocation", () => { + for (const event of ["opened", "synchronized"]) { + for (const path of [ + `packages/github-workflows/src/workflow-templates/github-pull-request-${event}.ejected.yml`, + `.github/workflows/gh-github-pull-request-${event}.yml`, + `.github/workflows/poe-code-github-pull-request-${event}.yml`, + ]) { + it(`passes a supported default and an unchanged override in ${path}`, () => { + const workflow = parse(readFileSync(path, "utf8")); + const step = workflow.jobs.run.steps.find((entry: { run?: string }) => entry.run?.includes(`github-workflows github-pull-request-${event} --yes`)); + expect(step.env.POE_CODE_REVIEW_MODEL).toBe("${{ vars.POE_CODE_REVIEW_MODEL }}"); + const run = (model: string) => execFileSync("bash", ["-c", `poe-code() { printf '%s\\n' "$@"; }; ${step.run}`], { + encoding: "utf8", env: { PATH: process.env.PATH, POE_CODE_REVIEW_MODEL: model }, + }).trim().split("\n"); + expect(run("").slice(-2)).toEqual(["--model", "gpt-5.4"]); + expect(run("chosen-model").slice(-2)).toEqual(["--model", "chosen-model"]); + expect(run("chosen model; literal").slice(-2)).toEqual(["--model", "chosen model; literal"]); + }); + } + } +}); From 42d8622629cbc8170091bdf7217d05003286c408 Mon Sep 17 00:00:00 2001 From: Kamil Jopek Date: Thu, 10 Sep 2026 01:21:17 -0500 Subject: [PATCH 2/7] test(e2e): preserve the HTTP server type during registration --- e2e/tiny-mcp-server.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/tiny-mcp-server.test.ts b/e2e/tiny-mcp-server.test.ts index 8227e21f90..482cc8f6c4 100644 --- a/e2e/tiny-mcp-server.test.ts +++ b/e2e/tiny-mcp-server.test.ts @@ -42,8 +42,8 @@ describe("tiny MCP server", () => { }); it("serves prompts and resources over a real HTTP listener", async () => { - const server = createHttpServer({ name: "e2e-http", version: "1.0.0" }) - .prompt({ name: "hello" }, () => ({ messages: [{ role: "user", content: { type: "text", text: "hello" } }] })) + const server = createHttpServer({ name: "e2e-http", version: "1.0.0" }); + server.prompt({ name: "hello" }, () => ({ messages: [{ role: "user", content: { type: "text", text: "hello" } }] })) .resource({ uri: "memory://hello", name: "hello" }, () => ({ contents: [{ uri: "memory://hello", text: "hello" }] })); const handle = await server.listenHttp({ port: 0 }); const client = new Client({ name: "e2e-http-client", version: "1.0.0" }); From e14c1df1a556294c3931c9251067a0c7866a433d Mon Sep 17 00:00:00 2001 From: Kamil Jopek Date: Thu, 10 Sep 2026 01:48:05 -0500 Subject: [PATCH 3/7] test(e2e): invoke the built MCP fixture on host backends --- e2e/mcp-fixture.ts | 14 ++++++++++++++ e2e/mcp-tool.test.ts | 8 +++----- scripts/e2e-mcp-fixture.test.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 e2e/mcp-fixture.ts create mode 100644 scripts/e2e-mcp-fixture.test.ts diff --git a/e2e/mcp-fixture.ts b/e2e/mcp-fixture.ts new file mode 100644 index 0000000000..5940b663a9 --- /dev/null +++ b/e2e/mcp-fixture.ts @@ -0,0 +1,14 @@ +import { fileURLToPath } from 'node:url'; +import type { Backend } from '@poe-code/e2e-test-runner'; + +export function resolveMcpFixtureCommand(backend: Backend): { command: string; args: string[] } { + if (backend === 'podman') { + return { command: 'tiny-stdio-mcp-test-server', args: ['serve', 'word-of-the-day'] }; + } + // npm ci runs before build, so the workspace bin link may not exist. + // Host backends can invoke the built fixture without a link or executable bit. + return { + command: process.execPath, + args: [fileURLToPath(new URL('../packages/tiny-stdio-mcp-test-server/dist/cli.js', import.meta.url)), 'serve', 'word-of-the-day'], + }; +} diff --git a/e2e/mcp-tool.test.ts b/e2e/mcp-tool.test.ts index d4f8ae2c66..5673322151 100644 --- a/e2e/mcp-tool.test.ts +++ b/e2e/mcp-tool.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useContainer, shellQuote } from '@poe-code/e2e-test-runner'; +import { useContainer, shellQuote, resolveBackend } from '@poe-code/e2e-test-runner'; +import { resolveMcpFixtureCommand } from './mcp-fixture.js'; import { resolveE2eModel, registerKimiFixtureModel } from './runtime-models.js'; interface AgentMcpSpawnTest { @@ -34,10 +35,7 @@ const agents: AgentMcpSpawnTest[] = [ ]; const mcpConfig = shellQuote(JSON.stringify({ - 'tiny-stdio-mcp-test-server': { - command: 'tiny-stdio-mcp-test-server', - args: ['serve', 'word-of-the-day'], - }, + 'tiny-stdio-mcp-test-server': resolveMcpFixtureCommand(resolveBackend()), })); describe.each(agents)('spawn --mcp-config: $name', ({ name, expectSpawnSuccess, spawnArgs }) => { diff --git a/scripts/e2e-mcp-fixture.test.ts b/scripts/e2e-mcp-fixture.test.ts new file mode 100644 index 0000000000..a731557647 --- /dev/null +++ b/scripts/e2e-mcp-fixture.test.ts @@ -0,0 +1,33 @@ +import { fileURLToPath } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { describe, expect, it } from 'vitest'; +import { resolveMcpFixtureCommand } from '../e2e/mcp-fixture.js'; + +describe('MCP agent fixture command', () => { + it.each(['env', 'sandbox'] as const)('uses the built CLI directly on the %s host', (backend) => { + expect(resolveMcpFixtureCommand(backend)).toEqual({ + command: process.execPath, + args: [fileURLToPath(new URL('../packages/tiny-stdio-mcp-test-server/dist/cli.js', import.meta.url)), 'serve', 'word-of-the-day'], + }); + }); + + it('preserves the installed executable inside Podman', () => { + expect(resolveMcpFixtureCommand('podman')).toEqual({ + command: 'tiny-stdio-mcp-test-server', args: ['serve', 'word-of-the-day'], + }); + }); + + it('connects and calls the real built fixture without workspace bin links', async () => { + const client = new Client({ name: 'offline-fixture-regression', version: '1.0.0' }); + try { + await client.connect(new StdioClientTransport({ ...resolveMcpFixtureCommand('env'), stderr: 'pipe' })); + expect((await client.listTools()).tools.map((tool) => tool.name)).toContain('word_of_the_day'); + expect(await client.callTool({ name: 'word_of_the_day', arguments: {} })).toMatchObject({ + content: [{ type: 'text', text: 'Bumfuzzle - to confuse or fluster someone' }], + }); + } finally { + await client.close(); + } + }); +}); From 14c11f168c2046d4fbbcb820e2ebcfe89442cc74 Mon Sep 17 00:00:00 2001 From: Kamil Jopek Date: Thu, 10 Sep 2026 02:09:38 -0500 Subject: [PATCH 4/7] test(e2e): supply the selected model to Goose ACP --- e2e/mcp-tool.test.ts | 6 ++++-- e2e/runtime-models.ts | 6 ++++++ scripts/e2e-runtime-models.test.ts | 21 ++++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/e2e/mcp-tool.test.ts b/e2e/mcp-tool.test.ts index 5673322151..3521a8bf74 100644 --- a/e2e/mcp-tool.test.ts +++ b/e2e/mcp-tool.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useContainer, shellQuote, resolveBackend } from '@poe-code/e2e-test-runner'; import { resolveMcpFixtureCommand } from './mcp-fixture.js'; -import { resolveE2eModel, registerKimiFixtureModel } from './runtime-models.js'; +import { resolveE2eModel, resolveE2eModelEnvironment, registerKimiFixtureModel } from './runtime-models.js'; interface AgentMcpSpawnTest { name: Parameters[0]; @@ -61,7 +61,9 @@ describe.each(agents)('spawn --mcp-config: $name', ({ name, expectSpawnSuccess, const extraArgs = spawnArgs ? ` -- ${spawnArgs.map((arg) => shellQuote(arg)).join(' ')}` : ''; - const command = `poe-code spawn --mode yolo --model ${shellQuote(model)} --mcp-config ${mcpConfig} ${name} ${shellQuote(prompt)}${extraArgs}`; + const modelEnv = Object.entries(resolveE2eModelEnvironment(name, model)) + .map(([key, value]) => `${key}=${shellQuote(value)}`); + const command = [...modelEnv, `poe-code spawn --mode yolo --model ${shellQuote(model)} --mcp-config ${mcpConfig} ${name} ${shellQuote(prompt)}${extraArgs}`].join(' '); const spawnResult = await container.exec(command); if (!expectSpawnSuccess) { diff --git a/e2e/runtime-models.ts b/e2e/runtime-models.ts index 63a4eddc42..f140d800b7 100644 --- a/e2e/runtime-models.ts +++ b/e2e/runtime-models.ts @@ -26,3 +26,9 @@ export function registerKimiFixtureModel(source: string, model: string, maxConte [model]: { provider: "poe", model, max_context_size: maxContextSize }, } }); } + +export function resolveE2eModelEnvironment(agent: keyof typeof models, model: string): Record { + // Goose v1.50.0 ACP reads the model from config/environment before session/new; + // its ACP command has no --model flag. Keep this scoped to the live fixture. + return agent === "goose" ? { GOOSE_MODEL: model } : {}; +} diff --git a/scripts/e2e-runtime-models.test.ts b/scripts/e2e-runtime-models.test.ts index 81636c1673..9c369a8d58 100644 --- a/scripts/e2e-runtime-models.test.ts +++ b/scripts/e2e-runtime-models.test.ts @@ -1,6 +1,7 @@ +import { execFileSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { parse, stringify } from "smol-toml"; -import { resolveE2eModel, registerKimiFixtureModel } from "../e2e/runtime-models.js"; +import { resolveE2eModel, resolveE2eModelEnvironment, registerKimiFixtureModel } from "../e2e/runtime-models.js"; describe("live-test runtime models", () => { it("selects endpoint-compatible fixtures and preserves explicit overrides", () => { @@ -35,3 +36,21 @@ describe("live-test runtime models", () => { expect(() => registerKimiFixtureModel(config, "another", Number.NaN)).toThrow("positive integer"); }); }); + + +describe("native model environment for E2E", () => { + it("forwards the selected Goose model and preserves its fixture override", () => { + for (const override of [undefined, "custom/model; literal"]) { + const model = resolveE2eModel("goose", { POE_CODE_E2E_GOOSE_MODEL: override }); + const env = resolveE2eModelEnvironment("goose", model); + expect(env).toEqual({ GOOSE_MODEL: override ?? "gpt-5.4" }); + expect(execFileSync(process.execPath, ["-e", "process.stdout.write(process.env.GOOSE_MODEL ?? '')"], { + encoding: "utf8", env: { ...process.env, ...env }, + })).toBe(model); + } + }); + + it.each(["claude-code", "codex", "opencode", "kimi"] as const)("leaves %s runtime environment unchanged", (agent) => { + expect(resolveE2eModelEnvironment(agent, "selected")).toEqual({}); + }); +}); From 792300fbb382deaca1be6d83665bae7a06d094a7 Mon Sep 17 00:00:00 2001 From: Kamil Jopek Date: Thu, 10 Sep 2026 02:38:12 -0500 Subject: [PATCH 5/7] fix(goose): include the required provider model catalog --- src/cli/commands/commands.test.ts | 2 +- src/providers/goose.ts | 16 +++++++++------- src/providers/providers.test.ts | 14 +++++++++++++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/commands.test.ts b/src/cli/commands/commands.test.ts index 831f1be432..19dd25e913 100644 --- a/src/cli/commands/commands.test.ts +++ b/src/cli/commands/commands.test.ts @@ -263,7 +263,7 @@ describe("configure command", () => { ) as Record; expect(provider.name).toBe("custom_poe"); expect(provider.api_key_env).toBe("CUSTOM_POE_API_KEY"); - expect(provider.models).toBeUndefined(); + expect(provider.models).toEqual([]); }); it("prompts for an agent when core.defaultAgent is configured without --yes", async () => { diff --git a/src/providers/goose.ts b/src/providers/goose.ts index 5c348e6148..606d813b3c 100644 --- a/src/providers/goose.ts +++ b/src/providers/goose.ts @@ -70,10 +70,6 @@ export const GOOSE_INSTALL_DEFINITION: ServiceInstallDefinition = { successMessage: "Installed Goose CLI." }; -/** - * The models written into Goose's custom provider catalog: just the selected - * model when configure resolved one, otherwise the offered defaults. - */ function buildCustomProvider( baseUrl: string ): ConfigObject { @@ -154,11 +150,17 @@ export const gooseService = createProvider< fileMutation.backup({ target: CUSTOM_PROVIDER_FILE, once: true }), fileMutation.backup({ target: GOOSE_CONFIG_FILE, once: true }), fileMutation.backup({ target: GOOSE_SECRETS_FILE, once: true }), - configMutation.merge({ + configMutation.transform({ target: CUSTOM_PROVIDER_FILE, - value: (ctx) => { + transform: (document, ctx) => { const { provider } = (ctx ?? {}) as unknown as GooseConfigureContext; - return buildCustomProvider(provider?.baseUrl ?? ""); + const content: ConfigObject = { + ...document, + ...buildCustomProvider(provider?.baseUrl ?? ""), + // Goose requires the catalog field even when selection is per run. + models: document.models ?? [] + }; + return { content, changed: JSON.stringify(document) !== JSON.stringify(content) }; } }), configMutation.merge({ diff --git a/src/providers/providers.test.ts b/src/providers/providers.test.ts index 93303c5f98..5156aa9439 100644 --- a/src/providers/providers.test.ts +++ b/src/providers/providers.test.ts @@ -2195,7 +2195,7 @@ describe("goose service", () => { expect(provider.base_url).toBe("https://api.poe.com/v1/chat/completions"); expect(provider.api_key_env).toBe("CUSTOM_POE_API_KEY"); expect(provider.headers).toBeUndefined(); - expect(provider.models).toBeUndefined(); + expect(provider.models).toEqual([]); const secrets = parseYaml(await mockFsObj.readFile(secretsPath, "utf8")) as Record< string, @@ -2227,6 +2227,18 @@ describe("goose service", () => { ).toBe(true); }); + it("preserves an existing Goose provider model catalog", async () => { + const models = [{ name: "personal-model", context_limit: 64000 }]; + await mockFsObj.mkdir(path.dirname(providerPath), { recursive: true }); + await mockFsObj.writeFile(providerPath, JSON.stringify({ models, headers: { "X-Custom": "keep" } }), "utf8"); + + await configureGoose(); + + const provider = JSON.parse(await mockFsObj.readFile(providerPath, "utf8")); + expect(provider.models).toEqual(models); + expect(provider.headers).toEqual({ "X-Custom": "keep" }); + }); + it("uses provider.baseUrl when building the custom provider config", async () => { await configureGoose({ provider: { From 8e9fbc92113c2fe1456c6a6e03db244392a90776 Mon Sep 17 00:00:00 2001 From: Kamil Jopek Date: Thu, 10 Sep 2026 03:21:31 -0500 Subject: [PATCH 6/7] test: keep browser and stream fixtures compatible with Node20 Provide navigator.language in the Node-hosted browser bundle and Worker fixtures. Consume async stream results with for-await so Node20 executes the existing lifecycle, schema failure, and cleanup assertions. ### Tests Updated | Test group | Verifies | | --- | --- | | Real safe-bash browser kernel, execution, filesystem, and session cohorts | Actual browser engine and Worker behavior with explicit browser globals. | | defineStreamCommand SDK lifecycle | Lazy consumption, status separation, schema rejection, and exactly-once cleanup without Array.fromAsync. | Validation: Node20.20.0 passed all four browser files (134 tests) and the stream lifecycle file (5 tests). No production runtime or deadline changes. --- .../safe-bash-playground/src/engine/kernel.test.ts | 2 ++ .../src/execution-filesystem.test.ts | 5 ++++- packages/safe-bash-playground/src/execution.test.ts | 2 ++ packages/safe-bash-playground/src/session.test.ts | 2 ++ packages/safe-bash-playground/test/browser-worker.ts | 1 + packages/toolcraft/src/stream.test.ts | 12 +++++++++--- 6 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/safe-bash-playground/src/engine/kernel.test.ts b/packages/safe-bash-playground/src/engine/kernel.test.ts index 1f4599782e..8e4d648188 100644 --- a/packages/safe-bash-playground/src/engine/kernel.test.ts +++ b/packages/safe-bash-playground/src/engine/kernel.test.ts @@ -13,6 +13,7 @@ describe("real safe-bash browser kernel", () => { const activeWorkers = new Set<{ terminate(): void }>(); beforeAll(async () => { + vi.stubGlobal("navigator", { language: "en-US" }); vi.stubGlobal( "Worker", class extends EventTarget { @@ -28,6 +29,7 @@ describe("real safe-bash browser kernel", () => { const worker = new NodeWorker( ` const { parentPort } = require('node:worker_threads'); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { language: 'en-US' } }); globalThis.addEventListener = (event, handler) => parentPort.on(event, data => handler({ data })); globalThis.postMessage = (value, transfer) => parentPort.postMessage(value, transfer); ${code} diff --git a/packages/safe-bash-playground/src/execution-filesystem.test.ts b/packages/safe-bash-playground/src/execution-filesystem.test.ts index 2015b8e722..c988e3dbd7 100644 --- a/packages/safe-bash-playground/src/execution-filesystem.test.ts +++ b/packages/safe-bash-playground/src/execution-filesystem.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import { createMemoryFileSystem, FsError } from "./engine/index.js"; import type { FileSystem } from "./engine/index.js"; import { decodeError, encodeError, hostFileSystem, remoteFileSystem } from "./execution-filesystem.js"; +vi.hoisted(() => vi.stubGlobal("navigator", { language: "en-US" })); +afterAll(() => vi.unstubAllGlobals()); + vi.mock("./engine/index.js", async () => { const { buildBrowserEngine } = await import("./engine/build-plugin.mjs"); const built = await buildBrowserEngine(); diff --git a/packages/safe-bash-playground/src/execution.test.ts b/packages/safe-bash-playground/src/execution.test.ts index ee52c40f2a..0ecf2a3e7d 100644 --- a/packages/safe-bash-playground/src/execution.test.ts +++ b/packages/safe-bash-playground/src/execution.test.ts @@ -7,6 +7,8 @@ import type { ExecutionMessage } from "./execution-protocol.js"; import { browserWorkerFixture } from "../test/browser-worker.js"; import { setTimeout as delay } from "node:timers/promises"; +vi.hoisted(() => vi.stubGlobal("navigator", { language: "en-US" })); + vi.mock("virtual:safe-bash-worker-sources", async () => { const { buildBrowserEngine } = await import("./engine/build-plugin.mjs"); return { sources: (await buildBrowserEngine({ workersOnly: true })).workerSources }; diff --git a/packages/safe-bash-playground/src/session.test.ts b/packages/safe-bash-playground/src/session.test.ts index 4fd62e3d19..0e0ff593f3 100644 --- a/packages/safe-bash-playground/src/session.test.ts +++ b/packages/safe-bash-playground/src/session.test.ts @@ -3,6 +3,8 @@ import { createSession, SESSION_LIMITS } from "./session.js"; import { sampleFiles } from "./samples.js"; import { browserWorkerFixture } from "../test/browser-worker.js"; +vi.hoisted(() => vi.stubGlobal("navigator", { language: "en-US" })); + vi.mock("virtual:safe-bash-worker-sources", async () => { const { buildBrowserEngine } = await import("./engine/build-plugin.mjs"); return { sources: (await buildBrowserEngine({ workersOnly: true })).workerSources }; diff --git a/packages/safe-bash-playground/test/browser-worker.ts b/packages/safe-bash-playground/test/browser-worker.ts index 0bf50ce003..cd415285e5 100644 --- a/packages/safe-bash-playground/test/browser-worker.ts +++ b/packages/safe-bash-playground/test/browser-worker.ts @@ -18,6 +18,7 @@ export function browserWorkerFixture(executionSource: string) { this.worker = source.then((code) => { const worker = new NodeWorker(` const { parentPort } = require("node:worker_threads"); + Object.defineProperty(globalThis, "navigator", { configurable: true, value: { language: "en-US" } }); globalThis.addEventListener = (event, handler) => parentPort.on(event, data => handler({ data })); globalThis.postMessage = value => parentPort.postMessage(value); (() => { ${code} })(); diff --git a/packages/toolcraft/src/stream.test.ts b/packages/toolcraft/src/stream.test.ts index 2a5f5b0c6d..691c458ac2 100644 --- a/packages/toolcraft/src/stream.test.ts +++ b/packages/toolcraft/src/stream.test.ts @@ -7,6 +7,12 @@ import { type ToolcraftStream } from "./index.js"; +async function collect(stream: AsyncIterable): Promise { + const values: Value[] = []; + for await (const value of stream) values.push(value); + return values; +} + describe("defineStreamCommand SDK lifecycle", () => { it("starts lazily and advances only when the consumer pulls", async () => { const produced: number[] = []; @@ -78,7 +84,7 @@ describe("defineStreamCommand SDK lifecycle", () => { const stream = sdk.watch({}, { onStatus: (event) => statuses.push(event) }); - await expect(Array.fromAsync(stream)).resolves.toEqual([{ state: "fresh-token" }]); + await expect(collect(stream)).resolves.toEqual([{ state: "fresh-token" }]); expect(statuses).toEqual([ { type: "reconnecting", message: "Refreshing credentials" } ]); @@ -95,7 +101,7 @@ describe("defineStreamCommand SDK lifecycle", () => { }); const sdk = createSDK(defineGroup({ name: "devices", children: [watch] })); - await expect(Array.fromAsync(sdk.watch({}))).rejects.toThrow("state"); + await expect(collect(sdk.watch({}))).rejects.toThrow("state"); }); it("propagates terminal errors and releases resources once", async () => { @@ -115,7 +121,7 @@ describe("defineStreamCommand SDK lifecycle", () => { }); const sdk = createSDK(defineGroup({ name: "devices", children: [watch] })); - await expect(Array.fromAsync(sdk.watch({}))).rejects.toThrow("connection lost"); + await expect(collect(sdk.watch({}))).rejects.toThrow("connection lost"); expect(cleanup).toHaveBeenCalledOnce(); }); }); From 17400f2451dc4452cd8af953f5a788ef62fff5b8 Mon Sep 17 00:00:00 2001 From: Kamil Jopek Date: Thu, 10 Sep 2026 06:30:51 -0500 Subject: [PATCH 7/7] perf(safe-js): reuse unchanged intrinsic retained roots Cache only collected root references for groups backed entirely by tracked property tables. Successful property/prototype changes and baseline completion invalidate the cache; untracked and restored tables retain conservative scans. Nested values are still recursively measured on every reconciliation. Validation: 248 focused accounting/snapshot tests; maintained 24-workspace agent-harness build closure; unchanged five-second Node20 coverage-demo replay passed in 1.29s. Six new regressions cover allocation reuse, nested budget failure, descriptor/prototype changes, rejected mutations, restored aliases, and retained callback snapshots. Focused test typing and ESLint gate this commit. ### Tests Added | Test | Verifies | | --- | --- | | reuses the collected roots while still rejecting nested growth above the memory limit | Reference reuse preserves recursive budget rejection | | refreshes collected roots after define, delete, accessors, prototype changes and baseline completion | Successful mutations invalidate root collection | | remeasures mutations through an untracked restored property-table alias | Restored aliases keep conservative scanning | | keeps committed roots after failed definitions and deletions | Rejected mutations preserve committed state | | keeps both budgets current and ignores rejected prototype mutations | Shared objects invalidate each live budget correctly | | keeps root snapshots stable when a retained callback mutates the next collection | Callback mutation affects the next capture without changing prior snapshots | Cherry-picked-from: 344ddf40439da30819085a58d8bc94a8f301bda7 --- .../intrinsic-retained-roots-cache.test.ts | 119 ++++++++++++++++++ packages/safe-js/src/interp/object-model.ts | 30 ++++- 2 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 packages/safe-js/src/interp/intrinsic-retained-roots-cache.test.ts diff --git a/packages/safe-js/src/interp/intrinsic-retained-roots-cache.test.ts b/packages/safe-js/src/interp/intrinsic-retained-roots-cache.test.ts new file mode 100644 index 0000000000..4e6cd1c82f --- /dev/null +++ b/packages/safe-js/src/interp/intrinsic-retained-roots-cache.test.ts @@ -0,0 +1,119 @@ +import { expect, it, vi } from "vitest"; +import { Budget, SandboxError } from "./budget.js"; +import { accessorAdapter } from "./accessors.js"; +import { completeIntrinsicObjectInitialization, createIntrinsicObject, materializeFunctionProperties, registerIntrinsicFunction, registerIntrinsicObject, releaseObjectPrototype, setSandboxPrototype } from "./object-model.js"; +import { createSandboxClosure, measureSandboxData, reconcileCompiledValues, type SandboxObject } from "./values.js"; + +it("reuses the collected roots while still rejecting nested growth above the memory limit", () => { + const budget = new Budget({ dataSize: 100 }); + const registration = vi.spyOn(budget, "setRetainedValues"); + const root = createIntrinsicObject(); + registerIntrinsicObject(budget, root); + const collect = registration.mock.calls.at(-1)![1]!; + const nested = { payload: "small" }; + root.extra = nested; + try { + const roots = collect(); + expect([...roots]).toEqual(["extra", nested]); + expect(collect()).toBe(roots); + reconcileCompiledValues(budget, []); + nested.payload = "x".repeat(200); + expect(collect()).toBe(roots); + expect(() => reconcileCompiledValues(budget, [])).toThrow(SandboxError); + } finally { registration.mockRestore(); releaseObjectPrototype(budget); } +}); + +it("refreshes collected roots after define, delete, accessors, prototype changes and baseline completion", () => { + const budget = new Budget(); + const root = createIntrinsicObject(); + registerIntrinsicObject(budget, root); + const read = vi.fn(() => undefined); + const getter = createSandboxClosure({ guest: true, call: read, retainedValues: () => ["captured"] }); + const parent = { payload: "parent" }; + try { + expect([...budget.retainedValues()]).toEqual([]); + Object.defineProperty(root, "extra", { value: "defined", configurable: true }); + expect([...budget.retainedValues()]).toEqual(["extra", "defined"]); + delete root.extra; + expect([...budget.retainedValues()]).toEqual([]); + Object.defineProperty(root, "extra", { get: accessorAdapter(getter, "get"), configurable: true }); + expect([...budget.retainedValues()]).toEqual(["extra", undefined, getter]); + setSandboxPrototype(root, parent, budget); + expect([...budget.retainedValues()]).toEqual([parent, "extra", undefined, getter]); + completeIntrinsicObjectInitialization(budget, root); + expect([...budget.retainedValues()]).toEqual([]); + delete root.extra; + setSandboxPrototype(root, null, budget); + expect([...budget.retainedValues()]).toEqual([null]); + expect(read).not.toHaveBeenCalled(); + } finally { releaseObjectPrototype(budget); } +}); + +it("remeasures mutations through an untracked restored property-table alias", () => { + const budget = new Budget(); + const method = createSandboxClosure({ guest: true, name: "restored", call: () => undefined }); + const restored: SandboxObject = { name: "restored" }; + expect(materializeFunctionProperties(method, restored)).toBe(restored); + registerIntrinsicFunction(budget, method); + try { + expect([...budget.retainedValues()]).toEqual([]); + restored.extra = "first"; + expect([...budget.retainedValues()]).toEqual(["extra", "first"]); + restored.extra = "longer"; + expect(measureSandboxData(budget.retainedValues())).toBe(11); + delete restored.extra; + expect([...budget.retainedValues()]).toEqual([]); + } finally { releaseObjectPrototype(budget); } +}); + +it("keeps committed roots after failed definitions and deletions", () => { + const budget = new Budget(); + const root = createIntrinsicObject(); + registerIntrinsicObject(budget, root); + try { + Object.defineProperty(root, "locked", { value: "retained", configurable: false, writable: false }); + expect([...budget.retainedValues()]).toEqual(["locked", "retained"]); + expect(Reflect.defineProperty(root, "locked", { value: "replacement" })).toBe(false); + expect(Reflect.deleteProperty(root, "locked")).toBe(false); + Object.preventExtensions(root); + expect(Reflect.defineProperty(root, "new", { value: "uncommitted" })).toBe(false); + expect([...budget.retainedValues()]).toEqual(["locked", "retained"]); + } finally { releaseObjectPrototype(budget); } +}); + +it("keeps both budgets current and ignores rejected prototype mutations", () => { + const first = new Budget(); + const second = new Budget(); + const root = createIntrinsicObject(); + registerIntrinsicObject(first, root); + registerIntrinsicObject(second, root); + const parent = { retained: "parent" }; + try { + expect([...first.retainedValues()]).toEqual([]); + expect([...second.retainedValues()]).toEqual([]); + setSandboxPrototype(root, parent); + expect([...first.retainedValues()]).toEqual([parent]); + expect([...second.retainedValues()]).toEqual([parent]); + expect(setSandboxPrototype(parent, root, undefined, false)).toBe(false); + Object.preventExtensions(root); + expect(setSandboxPrototype(root, {}, undefined, false)).toBe(false); + expect([...first.retainedValues()]).toEqual([parent]); + releaseObjectPrototype(first); + expect([...second.retainedValues()]).toEqual([parent]); + } finally { releaseObjectPrototype(first); releaseObjectPrototype(second); } +}); + +it("keeps root snapshots stable when a retained callback mutates the next collection", () => { + const budget = new Budget(); + const root = createIntrinsicObject(); + registerIntrinsicObject(budget, root); + root.first = createSandboxClosure({ call: () => undefined, retainedValues: () => { + root.later = "z".repeat(100); + return []; + } }); + root.later = "initial"; + try { + expect(measureSandboxData(budget.retainedValues())).toBe(18); + expect(measureSandboxData(budget.retainedValues())).toBe(111); + } finally { releaseObjectPrototype(budget); } +}); diff --git a/packages/safe-js/src/interp/object-model.ts b/packages/safe-js/src/interp/object-model.ts index 6652770b66..6cc3c221e1 100644 --- a/packages/safe-js/src/interp/object-model.ts +++ b/packages/safe-js/src/interp/object-model.ts @@ -42,11 +42,17 @@ const functionPropertyRevisions = new WeakMap(); const prototypes = new WeakMap(); const trackedPrototypes = new WeakMap(); +// Identity tokens cannot overflow. Unrelated mutations conservatively invalidate +// all groups without retaining subscriber lists or their owning budgets. +let intrinsicMutationToken = {}; function storePrototype(value: object, prototype: object | null): void { prototypes.set(value, prototype); const tracked = trackedPrototypes.get(value); - if (tracked !== undefined) tracked.current = prototype; + if (tracked !== undefined && tracked.current !== prototype) { + tracked.current = prototype; + intrinsicMutationToken = {}; + } } const intrinsicPrototypes = new WeakMap(); const boxedPrototypes = new WeakMap>(); @@ -140,12 +146,18 @@ function trackPropertyTable(properties: SandboxObject): SandboxObject { const tracked = new Proxy(properties, { defineProperty(target, key, descriptor) { const changed = Reflect.defineProperty(target, key, descriptor); - if (changed) state.revision++; + if (changed) { + state.revision++; + intrinsicMutationToken = {}; + } return changed; }, deleteProperty(target, key) { const changed = Reflect.deleteProperty(target, key); - if (changed) state.revision++; + if (changed) { + state.revision++; + intrinsicMutationToken = {}; + } return changed; } }); @@ -293,6 +305,7 @@ export function completeIntrinsicObjectInitialization(budget: Budget, value: San const record = records.find(record => record.target === value); if (record === undefined) continue; Object.assign(record, captureIntrinsicRecords([value])[0]); + intrinsicMutationToken = {}; return; } } @@ -371,7 +384,14 @@ function trackIntrinsicState( retainedRecords.push(record); } if (retainedRecords.length === 0) return; + const cacheable = retainedRecords.every(record => record.revision !== undefined); + let capturedToken: object | undefined; + let capturedRoots: unknown[] = []; budget.setRetainedValues(root, () => { + // O(1) root collection when tracked tables are unchanged. Only references + // are reused: measurement still recursively visits their current contents. + // Restored/untracked tables must always take the conservative scan below. + if (cacheable && capturedToken === intrinsicMutationToken) return capturedRoots; // Capture every change before measurement invokes retained-value callbacks. const retained: unknown[] = []; for (const record of retainedRecords) { @@ -390,6 +410,10 @@ function trackIntrinsicState( } for (const item of record.captured) retained.push(item); } + if (cacheable) { + capturedRoots = retained; + capturedToken = intrinsicMutationToken; + } return retained; }); }