Files
oh-my-pi/packages/coding-agent/test/bundled-agent-parsing.test.ts
T
enieuwy 77ee3f2e7e fix(task): key subagent fallback chains off the pre-expansion model role
A single-model subagent is pinned to a `subagent:<id>` role whose
`retry.fallbackChains` entry shadows every configured role chain, so the
chain it inherits decides where the child retries. Inheritance resolved
the role by re-deriving it from the child's `modelPatterns` — but every
spawn path expands the role alias into `modelOverride` before calling
`runSubprocess` (`modelPatterns = normalizeModelPatterns(modelOverride ??
agent.model)`), so `@task` never reached the derivation and it returned
`undefined` every time. Every task subagent inherited `chains.default`.

With `modelRoles.task: anthropic/claude-sonnet-5`, `task` chained to
sonnet alone, and `default` chained to sonnet plus a second provider, a
transient stall on sonnet routed the child onto the default chain's
second model — one the operator had deliberately kept out of the `task`
chain — and a quota error there killed a 28-minute run.

#7694 fixed only the shape where an unexpanded alias reaches the
executor, which no production caller produces; its tests supplied a bare
`agent.model: ["@smol"]` with no `modelOverride`. The incident above
happened on v17.2.10, which contains that fix.

Route inheritance off the role identity the spawn path already computes
and passes as `modelRole`. Since that leaves the pattern-derived operand
unreachable, drop it and the parameter it was the only user of.

The vibe worker path had the same defect independently: `#resolveWorker`
expanded `@task`/`@smol` for the bundled `task`/`sonic` workers and kept
no role, so vibe children inherited `default` no matter what the
executor did. It now carries `modelRole` on `ResolvedVibeWorker` and
`VibeRecord` through both the spawn and rehydrate sites.

To stop the two halves drifting apart again — the mistake that caused
this bug — `resolveAgentModelSelection` returns the expanded `patterns`
and the pre-expansion `role` from one call, and both spawn paths take
both from it. `resolveAgentModelSource` is removed: its only use was
being fed to `resolveExplicitModelRole`, and keeping it invites the same
split derivation. `resolveAgentModelPatterns` stays for the UI callers
that legitimately want patterns alone.

Tests cover the producible shapes: the incident's chain layout (role
chain equal to the primary, default chain a superset), role identity
surviving expansion for every alias-routed bundled agent, and the
patterns/role pairing itself. #7694's two tests are re-anchored to a
shape a real caller produces.
2026-08-07 21:16:27 +08:00

95 lines
3.6 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import { Effort } from "@oh-my-pi/pi-ai";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import {
resolveAgentModelPatterns,
resolveAgentModelSelection,
resolveModelOverride,
} from "@oh-my-pi/pi-coding-agent/config/model-resolver";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getBundledAgent } from "@oh-my-pi/pi-coding-agent/task/agents";
import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking";
describe("bundled agent parsing", () => {
it("lets reviewer inherit thinking effort from its model role", () => {
const reviewer = getBundledAgent("reviewer");
expect(reviewer).toBeDefined();
expect(reviewer?.source).toBe("bundled");
expect(reviewer?.model).toEqual(["@slow"]);
expect(reviewer?.thinkingLevel).toBeUndefined();
});
it("defaults the task agent to the auto thinking selector", () => {
const task = getBundledAgent("task");
expect(task).toBeDefined();
expect(task?.model).toEqual(["@task"]);
expect(task?.thinkingLevel).toBe(AUTO_THINKING);
});
// Issue #4761: with `modelRoles.slow: ...:xhigh`, the role's explicit effort
// suffix must survive agent-pattern expansion and model resolution for the
// bundled agents routed at that role. The executor prefers an explicit
// resolved suffix over the agent-definition default (task/executor.ts), so
// the resolved level below is what the subagent runs at.
it("resolves the configured slow-role effort suffix for reviewer", () => {
const gpt55 = buildModel({
id: "gpt-5.5",
name: "GPT-5.5 Codex",
api: "openai-codex-responses",
provider: "openai-codex",
baseUrl: "https://chatgpt.com/backend-api/codex",
reasoning: true,
thinking: { mode: "effort", efforts: [Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] },
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 272000,
maxTokens: 128000,
});
const settings = Settings.isolated({
modelRoles: { slow: "openai-codex/gpt-5.5:xhigh" },
});
const registry = { getAvailable: () => [gpt55] } as Parameters<typeof resolveModelOverride>[1];
const agent = getBundledAgent("reviewer");
expect(agent?.thinkingLevel).toBeUndefined();
const patterns = resolveAgentModelPatterns({ agentModel: agent?.model, settings });
const resolved = resolveModelOverride(patterns, registry, settings);
expect(resolved.model?.provider).toBe("openai-codex");
expect(resolved.model?.id).toBe("gpt-5.5");
expect(resolved.thinkingLevel).toBe(Effort.XHigh);
expect(resolved.explicitThinkingLevel).toBe(true);
});
// The alias is expanded before it reaches the executor, so the role identity
// only survives as the `role` half of the selection. A subagent's inherited
// `retry.fallbackChains` entry is keyed off it — lose it and every bundled
// agent silently retries on the `default` role's chain.
it("keeps the role identity of every alias-routed bundled agent through expansion", () => {
const settings = Settings.isolated({
modelRoles: {
default: "anthropic/opus",
task: "anthropic/sonnet",
smol: "fast/hy3",
slow: "codex/sol",
designer: "anthropic/opus",
},
});
for (const [name, role, model] of [
["task", "task", "anthropic/sonnet"],
["sonic", "smol", "fast/hy3"],
["scout", "smol", "fast/hy3"],
["reviewer", "slow", "codex/sol"],
["designer", "designer", "anthropic/opus"],
] as const) {
const agent = getBundledAgent(name);
expect(resolveAgentModelSelection({ agentModel: agent?.model, settings })).toEqual({
patterns: [model],
role,
});
}
});
});