import { test } from "node:test";
import assert from "node:assert/strict";
import { DefaultResourceLoader, type ExtensionAPI, type ModelRuntime } from "@earendil-works/pi-coding-agent";
import extensionFactory, { runSingleTask, SubagentParams } from "../../extensions/index.ts";
import { validateSubagentParams } from "../../src/validate.ts";
import { buildSubagentCallText } from "../../src/render-call.ts";
import { createProgressTracker, type TaskProgress } from "../../src/progress.ts";
import { buildSubagentResultText } from "../../src/render-result.ts";
import type { AgentConfig } from "../../src/agents.ts";
import type { RunUsage } from "../../src/usage.ts";
const sampleUsage: RunUsage = {
input: 12500, output: 840, cacheRead: 1_200_000, cacheWrite: 3000,
cost: 0.4123, isSubscription: false,
context: { percent: 12.34, window: 200000 },
};
const fakeTheme = {
fg: (c: string, t: string) => `<${c}>${t}${c}>`,
bold: (t: string) => `${t}`,
};
function textOf(component: unknown): string {
return (component as { text: string }).text;
}
async function loadExtension(createModelRuntime?: () => Promise): Promise {
let captured: any;
const fakePi = {
registerTool: (cfg: any) => {
captured = cfg;
},
} as unknown as ExtensionAPI;
await extensionFactory(fakePi, createModelRuntime);
return captured;
}
function makeAgent(overrides: Partial = {}): AgentConfig {
return {
name: "scout",
description: "finds things",
systemPromptMode: "append",
inheritProjectContext: true,
defaultReads: [],
source: "user",
filePath: "/agents/scout.md",
systemPrompt: "",
...overrides,
};
}
// (S16) Smoke-level only: full schema-shape assertions belong to
// test/unit/schema-consistency.test.ts (Bucket 4); this just confirms the
// tools/skills keys exist at both the top level and inside tasks[] items.
test("SubagentParams: has tools/skills keys at both top level and tasks[] item level", () => {
const props = SubagentParams.properties;
assert.ok("tools" in props);
assert.ok("skills" in props);
const taskItemProps = (props.tasks as any).items.properties;
assert.ok("tools" in taskItemProps);
assert.ok("skills" in taskItemProps);
});
// (a)
test("execute: neither {agent, task} nor {tasks} returns validateSubagentParams' own error message as isError", async () => {
const captured = await loadExtension();
const expected = validateSubagentParams({});
assert.equal(expected.ok, false);
const expectedError = (expected as { ok: false; error: string }).error;
const result = await captured.execute("call-1", {}, undefined, undefined, {});
assert.equal(result.isError, true);
assert.deepEqual(result.content, [{ type: "text", text: expectedError }]);
});
// (b)
test("execute: unknown agent name returns isError with resolveAgents' unknown-agent message", async () => {
const captured = await loadExtension();
const result = await captured.execute(
"call-2",
{ agent: "definitely-not-a-real-agent-name-xyz", task: "x" },
undefined,
undefined,
{ cwd: process.cwd() },
);
assert.equal(result.isError, true);
const text = result.content[0].text as string;
assert.match(text, /Unknown agent\(s\): definitely-not-a-real-agent-name-xyz/);
});
// (c)
test("renderCall: argsComplete=false renders title only — never per-agent params, regardless of registry.peek", async () => {
const captured = await loadExtension();
const args = { agent: "scout", task: "Find X" };
const context = { cwd: "/some/realistic/project/path", argsComplete: false };
const component = captured.renderCall(args, fakeTheme, context);
const rendered = textOf(component);
const expected = buildSubagentCallText(args, fakeTheme, new Map());
assert.equal(rendered, expected);
assert.doesNotMatch(rendered, /model:|thinking:|tools:/);
});
// (d)
test("renderResult: isPartial with content:[] and details:undefined renders an empty Text without throwing", async () => {
const captured = await loadExtension();
const result = { content: [], details: undefined, isError: false };
const options = { expanded: false, isPartial: true };
const component = captured.renderResult(result, options, fakeTheme, {});
const rendered = textOf(component);
assert.equal(rendered, "");
});
// (d2)
test("renderResult: isPartial+expanded with progress delegates to buildSubagentResultText", async () => {
const captured = await loadExtension();
const progress: TaskProgress[] = [
{ agent: "scout", runningTools: [{ toolCallId: "a", toolName: "read" }], history: ["read foo.ts"], done: false },
];
const result = { content: [], details: { progress }, isError: false };
const options = { expanded: true, isPartial: true };
const component = captured.renderResult(result, options, fakeTheme, {});
const rendered = textOf(component);
const expected = buildSubagentResultText(
{ isPartial: true, expanded: true, progress, content: "" },
fakeTheme,
);
assert.equal(rendered, expected);
});
// (d3)
test("renderResult: final+collapsed renders nothing, delegating to buildSubagentResultText", async () => {
const captured = await loadExtension();
const result = { content: [{ type: "text" as const, text: "the full agent output" }], details: { runs: [] }, isError: false };
const options = { expanded: false, isPartial: false };
const component = captured.renderResult(result, options, fakeTheme, {});
const rendered = textOf(component);
assert.equal(rendered, "");
});
// (d4)
test("renderResult: final+expanded renders divider + full content, delegating to buildSubagentResultText", async () => {
const captured = await loadExtension();
const result = { content: [{ type: "text" as const, text: "the full agent output" }], details: { runs: [] }, isError: false };
const options = { expanded: true, isPartial: false };
const component = captured.renderResult(result, options, fakeTheme, {});
const rendered = textOf(component);
const expected = buildSubagentResultText(
{ isPartial: false, expanded: true, progress: undefined, content: "the full agent output" },
fakeTheme,
);
assert.equal(rendered, expected);
});
// (d5)
test("renderResult: final+expanded with runs carrying usage delegates runs through to buildSubagentResultText", async () => {
const captured = await loadExtension();
const runs = [{ agent: "scout", task: "find things", durationMs: 10, status: "success" as const, usage: sampleUsage }];
const result = { content: [{ type: "text" as const, text: "the full agent output" }], details: { runs }, isError: false };
const options = { expanded: true, isPartial: false };
const component = captured.renderResult(result, options, fakeTheme, {});
const rendered = textOf(component);
const expected = buildSubagentResultText(
{ isPartial: false, expanded: true, progress: undefined, content: "the full agent output", runs },
fakeTheme,
);
assert.equal(rendered, expected);
});
// (e)
// Mechanism note: runAgentViaSdk never rejects (it catches internally and
// always resolves), and createSession is hardcoded — neither is a reachable
// rejection seam today. resourceLoader.reload() is a real async I/O call
// inside the try block that CAN reject, so we mock
// DefaultResourceLoader.prototype.reload with node:test's built-in mock
// (same idiom already used for console.warn elsewhere in this suite) to
// force that rejection deterministically.
test("runSingleTask: resourceLoader.reload() rejecting still calls tracker.markTaskDone via finally, and the rejection propagates", async (t) => {
t.mock.method(DefaultResourceLoader.prototype, "reload", () => Promise.reject(new Error("reload failed")));
const agent = makeAgent();
const tracker = createProgressTracker(["scout"], () => {});
const doneSpy = t.mock.method(tracker, "markTaskDone");
await assert.rejects(
() =>
runSingleTask({ agent: "scout", task: "do it" }, agent, 0, tracker, {
cwd: process.cwd(),
signal: undefined,
modelRuntime: {} as unknown as ModelRuntime,
callerSessionFile: undefined,
mode: "tui" as const,
}),
/reload failed/,
);
assert.equal(doneSpy.mock.callCount(), 1);
// markTaskDone treats an explicit `undefined` usage the same as an omitted
// one, so only the index and the absence of usage matter here — not
// whether the second argument was passed at all.
const [index, usage] = doneSpy.mock.calls[0].arguments;
assert.equal(index, 0);
assert.equal(usage, undefined);
});
// (e2)
// Same mechanism as (g) below: getModel throws before createSession is ever
// called, so runAgentViaSdk settles an error result whose usage is the
// zeroed default (no session, no messages) — still a real RunUsage object,
// not undefined. This proves runSingleTask forwards result.usage to the
// tracker rather than dropping it.
test("runSingleTask: forwards the run's usage snapshot to tracker.markTaskDone", async (t) => {
t.mock.method(DefaultResourceLoader.prototype, "reload", () => Promise.resolve());
const agent = makeAgent({ model: "anthropic/claude-fable-5" });
const tracker = createProgressTracker(["scout"], () => {});
const doneSpy = t.mock.method(tracker, "markTaskDone");
const fakeModelRuntime = {
getModel: () => { throw new Error("stop before session creation"); },
} as unknown as ModelRuntime;
await runSingleTask({ agent: "scout", task: "do it" }, agent, 0, tracker, {
cwd: process.cwd(),
signal: undefined,
modelRuntime: fakeModelRuntime,
callerSessionFile: undefined,
mode: "tui" as const,
});
assert.equal(doneSpy.mock.callCount(), 1);
const [index, usage] = doneSpy.mock.calls[0].arguments;
assert.equal(index, 0);
assert.deepEqual(usage, {
input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0,
isSubscription: false, context: undefined,
});
});
// Mutation-tested against the reviewer's finding: deleting `mode,` at the
// runAgentViaSdk() call site inside runSingleTask (src/run.ts consumer) used
// to leave the whole ctx.mode -> RunTasksOptions -> runSingleTask ->
// RunAgentViaSdkOptions threading unverified end-to-end — tsc catches an
// omission (mode is required on RunTasksOptions), but not a wrong constant.
// This test exercises the real threading through an injected createSession,
// asserting the mode that actually reaches bindExtensions.
test("runSingleTask: forwards options.mode through to the nested session's bindExtensions call", async () => {
const agent = makeAgent({
tools: ["read"],
} as Partial);
const capturedTools = [{ sourceInfo: { origin: "package" as const, source: "pi-mcp-adapter" } }];
let capturedBindings: unknown;
const fakeSession = {
getAllTools: () => capturedTools,
bindExtensions: async (bindings: unknown) => { capturedBindings = bindings; },
extensionRunner: { hasHandlers: () => false, emit: async () => {} },
subscribe: () => () => {},
prompt: async () => {},
getLastAssistantText: () => "done",
getContextUsage: () => undefined,
dispose: () => {},
abort: () => {},
};
const createSession = async () => ({ session: fakeSession as any });
const modelRuntime = { isUsingSubscription: () => false } as unknown as ModelRuntime;
await runSingleTask({ agent: "scout", task: "do it" }, agent, 0, undefined, {
cwd: process.cwd(),
signal: undefined,
modelRuntime,
callerSessionFile: undefined,
mode: "rpc",
createSession,
});
assert.deepEqual(capturedBindings, { mode: "rpc" });
});
// (g)
// Mechanism note: runSingleTask's getModel closure delegates straight to
// modelRuntime.getModel(provider, modelId) (RF-2). To observe that call
// without triggering a real session/network call, resourceLoader.reload()
// is mocked to resolve (same idiom as the (e) test above, success instead
// of rejection, so the flow proceeds far enough to reach the closure) and
// the fake getModel throws right after recording its arguments — the throw
// is caught by runAgentViaSdk's own try/catch (src/run.ts), settling the
// run as an error result before options.createSession is ever invoked.
test("runSingleTask: getModel resolver calls modelRuntime.getModel with the parsed provider and modelId", async (t) => {
t.mock.method(DefaultResourceLoader.prototype, "reload", () => Promise.resolve());
const agent = makeAgent({ model: "anthropic/claude-fable-5" });
const captured: Array<[string, string]> = [];
const fakeModelRuntime = {
getModel: (provider: string, modelId: string) => {
captured.push([provider, modelId]);
throw new Error("stop before session creation");
},
} as unknown as ModelRuntime;
await runSingleTask({ agent: "scout", task: "do it" }, agent, 0, undefined, {
cwd: process.cwd(),
signal: undefined,
modelRuntime: fakeModelRuntime,
callerSessionFile: undefined,
mode: "tui" as const,
});
assert.deepEqual(captured, [["anthropic", "claude-fable-5"]]);
});
// (f)
test("execute: when ModelRuntime.create() rejects, the tool still registers and every invocation returns a clear error", async () => {
const captured = await loadExtension(() => Promise.reject(new Error("boom")));
assert.ok(captured);
const result = await captured.execute(
"call-3",
{ agent: "scout", task: "find things" },
undefined,
undefined,
{ cwd: process.cwd() },
);
assert.equal(result.isError, true);
const text = result.content[0].text as string;
assert.match(text, /failed to initialize model runtime/);
});