test(vibe): cover the worker spawn path's model-role forwarding
The vibe half of the role-chain fix is a chain of field forwarding — `#resolveWorker` derives the role, spawn and rehydrate copy it onto the `VibeRecord`, and `#buildSpawnOptions` hands it to the executor. Any dropped link silently reverts vibe children to the `default` chain, and only the `task` spawn path had a regression test. Drive a real `VibeSessionRegistry.spawn()` for both bundled workers (`good` -> `task` -> `@task`, `fast` -> `sonic` -> `@smol`) with `runSubprocess` spied, and assert the captured `ExecutorOptions` pair the expanded patterns with the role behind them. A third case covers `task.agentModelOverrides` replacing the alias with an explicit selector, which must carry no role so the child keeps inheriting `default`. Breaking either link — the derivation in `#resolveWorker` or the forward in `#buildSpawnOptions` — fails both role assertions.
This commit is contained in:
@@ -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<ExecutorOptions> {
|
||||
const captured = Promise.withResolvers<ExecutorOptions>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user