diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index ff75e9bd..8483da37 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -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 diff --git a/extensions/subagents/src/agent-types.ts b/extensions/subagents/src/agent-types.ts index 7c52202a..299016c1 100644 --- a/extensions/subagents/src/agent-types.ts +++ b/extensions/subagents/src/agent-types.ts @@ -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 { @@ -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); @@ -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"), ); diff --git a/tests/extensions/subagents/agent-types.test.ts b/tests/extensions/subagents/agent-types.test.ts index 38669c3e..ef06185e 100644 --- a/tests/extensions/subagents/agent-types.test.ts +++ b/tests/extensions/subagents/agent-types.test.ts @@ -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", () => { @@ -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.", }, }); @@ -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/); }); }); @@ -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/, + ); }); }); @@ -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/, + ); }); }); diff --git a/tests/extensions/subagents/index.test.ts b/tests/extensions/subagents/index.test.ts index b1a892c3..b3365579 100644 --- a/tests/extensions/subagents/index.test.ts +++ b/tests/extensions/subagents/index.test.ts @@ -862,6 +862,118 @@ async function withTempDir(run: (directory: string) => Promise) { } } +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 });