diff --git a/packages/coding-agent/test/vibe/spawn-model-role.test.ts b/packages/coding-agent/test/vibe/spawn-model-role.test.ts new file mode 100644 index 000000000..ed124029e --- /dev/null +++ b/packages/coding-agent/test/vibe/spawn-model-role.test.ts @@ -0,0 +1,107 @@ +/** + * Contract: a vibe worker's spawn options carry the pre-expansion model role. + * + * `#resolveWorker` expands the bundled worker's role alias (`good` -> `task` -> + * `@task`, `fast` -> `sonic` -> `@smol`) into concrete patterns, so the role + * survives only as a separate field forwarded across `ResolvedVibeWorker` -> + * `VibeRecord` -> `#buildSpawnOptions` -> `runSubprocess`. The executor keys the + * child's inherited `retry.fallbackChains` entry off it; drop any link in that + * chain and vibe children silently retry on the `default` role's chain. + */ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { AsyncJobManager } from "@oh-my-pi/pi-coding-agent/async/job-manager"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import type { ExecutorOptions } from "@oh-my-pi/pi-coding-agent/task/executor"; +import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor"; +import type { SingleResult } from "@oh-my-pi/pi-coding-agent/task/types"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { type VibeCli, VibeSessionRegistry } from "@oh-my-pi/pi-coding-agent/vibe/runtime"; + +function makeParentSession(settings: Settings): ToolSession { + return { + cwd: "/tmp", + settings, + asyncJobManager: new AsyncJobManager({ onJobComplete: () => {} }), + getSessionId: () => "parent-session", + // No session file: spawn skips lifecycle persistence and stays in-memory. + getSessionFile: () => null, + getArtifactsDir: () => null, + taskDepth: 0, + enableLsp: false, + } as unknown as ToolSession; +} + +/** Spawn one worker and capture the ExecutorOptions the vibe path hands the executor. */ +async function spawnAndCaptureOptions(cli: VibeCli, settings: Settings): Promise { + const captured = Promise.withResolvers(); + vi.spyOn(executorModule, "runSubprocess").mockImplementation(async options => { + captured.resolve(options); + return { + index: 0, + id: options.id, + agent: options.agent.name, + agentSource: "bundled", + task: options.task, + exitCode: 0, + output: "done", + stderr: "", + truncated: false, + durationMs: 1, + tokens: 0, + requests: 0, + } as SingleResult; + }); + + const registry = VibeSessionRegistry.global(); + await registry.spawn(makeParentSession(settings), { cli, prompt: "work" }); + return captured.promise; +} + +describe("vibe worker spawn model role", () => { + afterEach(() => { + vi.restoreAllMocks(); + VibeSessionRegistry.resetGlobalForTests(); + AgentRegistry.resetGlobalForTests(); + }); + + it("forwards the `task` role behind the `good` worker's expanded patterns", async () => { + const options = await spawnAndCaptureOptions( + "good", + Settings.isolated({ + modelRoles: { default: "anthropic/opus", task: "anthropic/sonnet" }, + }), + ); + + expect(options.modelOverride).toEqual(["anthropic/sonnet"]); + expect(options.modelRole).toBe("task"); + }); + + it("forwards the `smol` role behind the `fast` worker's expanded patterns", async () => { + const options = await spawnAndCaptureOptions( + "fast", + Settings.isolated({ + modelRoles: { default: "anthropic/opus", smol: "fast/hy3" }, + }), + ); + + expect(options.modelOverride).toEqual(["fast/hy3"]); + expect(options.modelRole).toBe("smol"); + }); + + it("keeps the role identity when a per-agent model override replaces the alias", async () => { + // `task.agentModelOverrides` wins over the agent definition, and an explicit + // selector carries no role — the child must then inherit `default`, not + // capture the routing of whichever role happens to name the same model. + const options = await spawnAndCaptureOptions( + "good", + Settings.isolated({ + modelRoles: { default: "anthropic/opus", task: "anthropic/sonnet" }, + "task.agentModelOverrides": { task: "openai-codex/sol" }, + }), + ); + + expect(options.modelOverride).toEqual(["openai-codex/sol"]); + expect(options.modelRole).toBeUndefined(); + }); +});