9885ee34fc
Hard-coded 'scout' references reached the model even when the scout agent was disabled via task.disabledAgents or absent from the session spawn list. Gate every such reference on scout actually being spawnable: the task tool description, the delegation gates, the plan-mode and workflowz notices, the glob/grep/ast-grep guidance, and the task specialization advisory. Prompt shape is otherwise unchanged; only erroneous references to the unavailable subagent are dropped. Closes #7313
139 lines
5.1 KiB
TypeScript
139 lines
5.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle";
|
|
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
|
import { buildSpecializationAdvisory, TaskTool } from "@oh-my-pi/pi-coding-agent/task";
|
|
import * as discoveryModule from "@oh-my-pi/pi-coding-agent/task/discovery";
|
|
import * as executorModule from "@oh-my-pi/pi-coding-agent/task/executor";
|
|
import type { AgentDefinition, SingleResult } from "@oh-my-pi/pi-coding-agent/task/types";
|
|
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
|
|
// Contract: the task tool appends an advisory (never a rejection) steering the
|
|
// spawner toward more specific agent types when one call resolves ≥2 items to
|
|
// a generic `task`/`sonic` worker and the spawner still holds spawn capacity
|
|
// (DepthCapacity). It is gated on depth so a leaf at max recursion is never
|
|
// nagged, and a lone generic spawn is never flagged.
|
|
|
|
describe("buildSpecializationAdvisory", () => {
|
|
it("nudges when one call spawns two generic workers with depth capacity", () => {
|
|
const advice = buildSpecializationAdvisory(["task", "task"], true);
|
|
expect(advice).toBeDefined();
|
|
expect(advice).toContain('`agent: "scout"`');
|
|
});
|
|
|
|
it("stays silent at max depth even for a generic fan-out", () => {
|
|
expect(buildSpecializationAdvisory(["task", "task"], false)).toBeUndefined();
|
|
});
|
|
|
|
it("stays silent for a single generic spawn", () => {
|
|
expect(buildSpecializationAdvisory(["task"], true)).toBeUndefined();
|
|
});
|
|
|
|
it("stays silent when the fan-out already uses specific agent types", () => {
|
|
expect(buildSpecializationAdvisory(["reviewer", "scout"], true)).toBeUndefined();
|
|
});
|
|
|
|
it("stays silent for a mixed call with only one generic worker", () => {
|
|
expect(buildSpecializationAdvisory(["task", "scout"], true)).toBeUndefined();
|
|
});
|
|
|
|
it("counts sonic as generic alongside task", () => {
|
|
const advice = buildSpecializationAdvisory(["sonic", "task"], true);
|
|
expect(advice).toBeDefined();
|
|
expect(advice).toContain("2 generic");
|
|
});
|
|
});
|
|
|
|
// Contract: the advisory rides the task-tool result for an interactive spawner,
|
|
// but a session that opts out (`suppressSpawnAdvisory` — internal/programmatic
|
|
// callers like the commit agent's file-analysis fan-out) gets a clean result so
|
|
// the nudge never contaminates code-consumed evidence.
|
|
describe("task tool advisory gating via suppressSpawnAdvisory", () => {
|
|
const agent: AgentDefinition = {
|
|
name: "task",
|
|
description: "General-purpose task agent",
|
|
systemPrompt: "You are a task agent.",
|
|
source: "bundled",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
AgentRegistry.resetGlobalForTests();
|
|
AgentLifecycleManager.resetGlobalForTests();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
AgentLifecycleManager.resetGlobalForTests();
|
|
AgentRegistry.resetGlobalForTests();
|
|
});
|
|
|
|
function session(suppress: boolean): ToolSession {
|
|
return {
|
|
cwd: "/tmp",
|
|
hasUI: false,
|
|
suppressSpawnAdvisory: suppress,
|
|
settings: Settings.isolated({ "task.isolation.mode": "none", "task.batch": true }),
|
|
getSessionFile: () => null,
|
|
getSessionSpawns: () => "*",
|
|
} as unknown as ToolSession;
|
|
}
|
|
|
|
function sessionWithScoutDisabled(): ToolSession {
|
|
return {
|
|
cwd: "/tmp",
|
|
hasUI: false,
|
|
// `task.disabledAgents` is what the task tool reads to drop scout from
|
|
// the rendered description and the appended specialization advisory.
|
|
settings: Settings.isolated({
|
|
"task.isolation.mode": "none",
|
|
"task.batch": true,
|
|
"task.disabledAgents": ["scout"],
|
|
}),
|
|
getSessionFile: () => null,
|
|
getSessionSpawns: () => "*",
|
|
} as unknown as ToolSession;
|
|
}
|
|
|
|
async function spawnTextFor(s: ToolSession): Promise<string> {
|
|
vi.spyOn(discoveryModule, "discoverAgents").mockResolvedValue({ agents: [agent], projectAgentsDir: null });
|
|
vi.spyOn(executorModule, "runSubprocess").mockImplementation(
|
|
async (options): Promise<SingleResult> => ({
|
|
index: options.index ?? 0,
|
|
id: options.id ?? "X",
|
|
agent: "task",
|
|
agentSource: "bundled",
|
|
task: "t",
|
|
assignment: "do the thing",
|
|
exitCode: 0,
|
|
output: "done",
|
|
stderr: "",
|
|
truncated: false,
|
|
durationMs: 1,
|
|
tokens: 0,
|
|
requests: 1,
|
|
}),
|
|
);
|
|
const tool = await TaskTool.create(s);
|
|
const result = await tool.execute("tc", {
|
|
context: "shared fan-out background",
|
|
tasks: [
|
|
{ name: "First", task: "do the thing" },
|
|
{ name: "Second", task: "do the other thing" },
|
|
],
|
|
});
|
|
return result.content.find(part => part.type === "text")?.text ?? "";
|
|
}
|
|
|
|
it("appends the specialization advisory when a batch resolves two generic workers", async () => {
|
|
expect(await spawnTextFor(session(false))).toContain('`agent: "scout"`');
|
|
});
|
|
|
|
it("drops the scout example when scout is disabled", async () => {
|
|
expect(await spawnTextFor(sessionWithScoutDisabled())).not.toContain("scout");
|
|
});
|
|
|
|
it("omits the advisory entirely when the session suppresses it", async () => {
|
|
expect(await spawnTextFor(session(true))).not.toContain('`agent: "scout"`');
|
|
});
|
|
});
|