Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions extensions/subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,8 +889,14 @@ export default function (
// report it once. Never fatal: the rest still loaded. Non-UI modes receive
// stderr rather than a model-context message.
const notice = formatAgentTypeDiagnostics(agentTypeDiagnostics);
if (notice && ctx.hasUI) ctx.ui.notify(notice, "warning");
else if (notice) process.stderr.write(`${notice}\n`);
if (notice && ctx.hasUI) {
const severity = agentTypeDiagnostics.some(
(diagnostic) => diagnostic.severity !== "info",
)
? "warning"
: "info";
ctx.ui.notify(notice, severity);
} else if (notice) process.stderr.write(`${notice}\n`);
});

// A new explicit request starts a fresh unread window: previously finished
Expand Down
17 changes: 14 additions & 3 deletions extensions/subagents/src/agent-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,11 @@ export function roleModelForAgentType(
}

export interface AgentTypeDiagnostic {
/** File the problem came from, or the directory for a scan failure. */
/** File the diagnostic came from, or the directory for a scan failure. */
readonly source: string;
readonly message: string;
/** Omitted diagnostics are warnings; valid overrides are informational. */
readonly severity?: "info" | "warning";
}

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down Expand Up @@ -530,6 +532,7 @@ export function loadAgentTypes(options: LoadAgentTypesOptions) {
diagnostics.push({
source: agentType.source,
message: `overrides the agent type of the same name from ${shadowed.source}`,
severity: "info",
});
}
agentTypes.set(agentType.name, agentType);
Expand All @@ -539,14 +542,22 @@ export function loadAgentTypes(options: LoadAgentTypesOptions) {
return { agentTypes, diagnostics };
}

/** One-line-per-problem notice, or undefined when everything loaded cleanly. */
/** One-line-per-diagnostic notice, or undefined when everything loaded cleanly. */
export function formatAgentTypeDiagnostics(
diagnostics: readonly AgentTypeDiagnostic[],
) {
if (diagnostics.length === 0) return undefined;
const problemCount = diagnostics.filter(
(entry) => entry.severity !== "info",
).length;
const informationalCount = diagnostics.length - problemCount;
return sanitizeTerminalText(
[
`Agent types: ${diagnostics.length} problem${diagnostics.length === 1 ? "" : "s"}.`,
`Agent types: ${problemCount} problem${problemCount === 1 ? "" : "s"}${
informationalCount > 0
? `, ${informationalCount} informational notice${informationalCount === 1 ? "" : "s"}`
: ""
}.`,
...diagnostics.map((entry) => `- ${entry.source}: ${entry.message}`),
].join("\n"),
);
Expand Down
25 changes: 24 additions & 1 deletion tests/extensions/subagents/agent-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ test("an unrecognized tool name is reported but still applied", () => {
result.diagnostics[0]?.message ?? "",
/unrecognized tool "gerp"/,
);
assert.match(
formatAgentTypeDiagnostics(result.diagnostics) ?? "",
/1 problem/,
);
});

test("an over-long body is rejected rather than silently truncated", () => {
Expand Down Expand Up @@ -286,7 +290,7 @@ test("a project agent type overrides the global one of the same name", async ()
global: { "explorer.md": BUILT_IN_EXPLORER },
project: {
"explorer.md":
"---\nname: explorer\ndescription: Project override.\ntools: [read]\n---\nProject body.",
"---\nname: explorer\ndescription: Project override.\ntools: [read]\nmodel: project/model\nreasoning_effort: high\n---\nProject body.",
},
});

Expand All @@ -299,10 +303,21 @@ test("a project agent type overrides the global one of the same name", async ()
assert.equal(agentTypes.size, 4);
assert.deepEqual(agentTypes.get("explorer")?.tools, ["read"]);
assert.equal(agentTypes.get("explorer")?.planningCompatible, undefined);
assert.equal(agentTypes.get("explorer")?.model, "project/model");
assert.equal(agentTypes.get("explorer")?.reasoningEffort, "high");
assert.deepEqual(
diagnostics.map((entry) =>
"severity" in entry ? entry.severity : undefined,
),
["info", "info"],
);
// Global replaces the built-in, then the trusted project replaces global.
const messages = diagnostics.map((entry) => entry.message).join("\n");
assert.match(messages, /from built-in:explorer/);
assert.match(messages, /from .*agent[\\/]agents[\\/]explorer\.md/);
const notice = formatAgentTypeDiagnostics(diagnostics) ?? "";
assert.match(notice, /0 problems, 2 informational notices/);
assert.match(notice, /explorer\.md: overrides the agent type/);
});
});

Expand Down Expand Up @@ -359,6 +374,10 @@ test("an unreadable precedence layer blocks every broader fallback", async () =>
loaded.diagnostics.map((entry) => entry.message).join("\n"),
/all lower-precedence definitions are blocked/,
);
assert.match(
formatAgentTypeDiagnostics(loaded.diagnostics) ?? "",
/1 problem/,
);
});
});

Expand Down Expand Up @@ -493,6 +512,10 @@ Do not write.
loaded.diagnostics.map((entry) => entry.message).join("\n"),
/blocks fallback to built-in:implementer/,
);
assert.match(
formatAgentTypeDiagnostics(loaded.diagnostics) ?? "",
/2 problems/,
);
});
});

Expand Down
112 changes: 112 additions & 0 deletions tests/extensions/subagents/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,118 @@ async function withTempDir(run: (directory: string) => Promise<void>) {
}
}

async function readAgentTypeStartupNotices(cwd: string) {
const notices: Array<{ message: string; severity: string }> = [];
let sessionStart:
| ((event: unknown, ctx: ExtensionContext) => unknown)
| undefined;
let sessionShutdown: (() => unknown) | undefined;
const pi = {
on(event: string, handler: unknown) {
if (event === "session_start") {
sessionStart = handler as typeof sessionStart;
}
if (event === "session_shutdown") {
sessionShutdown = handler as typeof sessionShutdown;
}
},
events: { on() {} },
registerTool() {},
getActiveTools: () => [],
setActiveTools() {},
registerMessageRenderer() {},
registerEntryRenderer() {},
registerCommand() {},
} as unknown as ExtensionAPI;

subagents(pi);
assert.ok(sessionStart);
await sessionStart({}, {
cwd,
hasUI: true,
isProjectTrusted: () => true,
sessionManager: emptySessionManager,
ui: {
notify(message: string, severity: string) {
notices.push({ message, severity });
},
setStatus() {},
setWidget() {},
},
} as unknown as ExtensionContext);
await sessionShutdown?.();
return notices;
}

test("session start reports valid built-in and project overrides as info", async () => {
await withTempDir(async (cwd) => {
const globalDir = path.join(cwd, "agent", "agents");
const projectDir = path.join(cwd, ".pi", "agents");
await mkdir(globalDir, { recursive: true });
await mkdir(projectDir, { recursive: true });
await writeFile(
path.join(globalDir, "explorer.md"),
"---\nname: explorer\ndescription: Global override.\ntools: [read]\n---\nGlobal.",
);
await writeFile(
path.join(projectDir, "explorer.md"),
"---\nname: explorer\ndescription: Project override.\ntools: [read]\n---\nProject.",
);

const notices = await readAgentTypeStartupNotices(cwd);
assert.equal(notices.length, 1);
assert.equal(notices[0]?.severity, "info");
assert.match(
notices[0]?.message ?? "",
/0 problems, 2 informational notices/,
);
assert.match(notices[0]?.message ?? "", /from built-in:explorer/);
assert.match(
notices[0]?.message ?? "",
/\.pi[\\/]agents[\\/]explorer\.md: overrides the agent type/,
);
});
});

test("session start keeps mixed override and error diagnostics at warning", async () => {
await withTempDir(async (cwd) => {
const globalDir = path.join(cwd, "agent", "agents");
const projectDir = path.join(cwd, ".pi", "agents");
await mkdir(globalDir, { recursive: true });
await mkdir(projectDir, { recursive: true });
await writeFile(
path.join(globalDir, "explorer.md"),
"---\nname: explorer\ndescription: Global override.\ntools: [read]\n---\nGlobal.",
);
await writeFile(
path.join(globalDir, "unknown-tool.md"),
"---\nname: unknown-tool\ndescription: Unknown tool.\ntools: [read, gerp]\n---\nBody.",
);
await writeFile(
path.join(projectDir, "explorer.md"),
"---\nname: explorer\ndescription: Project override.\ntools: [read]\n---\nProject.",
);
await writeFile(
path.join(projectDir, "implementer.md"),
"---\nname: implementer\ndescription: Malformed override.\ntool: [read]\n---\nBody.",
);

const notices = await readAgentTypeStartupNotices(cwd);
assert.equal(notices.length, 1);
assert.equal(notices[0]?.severity, "warning");
assert.match(
notices[0]?.message ?? "",
/3 problems, 2 informational notices/,
);
assert.match(notices[0]?.message ?? "", /unrecognized tool "gerp"/);
assert.match(
notices[0]?.message ?? "",
/blocks fallback to built-in:implementer/,
);
assert.match(notices[0]?.message ?? "", /from built-in:explorer/);
});
});

test("session_start re-registers agent types for its cwd and live trust decision", async () => {
await withTempDir(async (cwd) => {
await mkdir(path.join(cwd, ".pi", "agents"), { recursive: true });
Expand Down
Loading