Skip to content
Merged
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
127 changes: 126 additions & 1 deletion extensions/subagents/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,136 @@ import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import type {
EntryRenderer,
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { PLAN_MODE_CHANNEL } from "../shared/plan-mode-state.ts";
import subagents from "./index.ts";
import subagents, { createSubagentResultDispatcher } from "./index.ts";

test("deferred subagent results render before a hidden next-turn injection", () => {
const events: unknown[] = [];
const pi = {
appendEntry(customType: string, data: unknown) {
events.push({ kind: "entry", customType, data });
},
sendMessage(message: unknown, options: unknown) {
events.push({ kind: "message", message, options });
},
} as unknown as ExtensionAPI;
const dispatch = createSubagentResultDispatcher(pi, () => "report");

dispatch(
[
{
id: "sa-3",
origin: "model",
backend: "pi",
title: "investigate plan mode",
prompt: "inspect",
cwd: process.cwd(),
status: "done",
createdAt: 0,
settledAt: 1_000,
meta: { backend: "pi" },
usage: {},
transcript: [],
liveTools: [],
queued: [],
finalText: "report",
turns: 1,
},
],
false,
);

assert.deepEqual(events, [
{
kind: "entry",
customType: "subagent-result",
data: {
content:
'Subagent sa-3 "investigate plan mode" finished.\n\nreport\n\n(This result is already shown to the user. Act on it and relay only the decisions or next steps — do not repeat it verbatim.)',
details: {
id: "sa-3",
title: "investigate plan mode",
status: "done",
},
},
},
{
kind: "message",
message: {
customType: "subagent-result",
content:
'Subagent sa-3 "investigate plan mode" finished.\n\nreport\n\n(This result is already shown to the user. Act on it and relay only the decisions or next steps — do not repeat it verbatim.)',
display: false,
details: {
id: "sa-3",
title: "investigate plan mode",
status: "done",
},
},
options: { deliverAs: "nextTurn" },
},
]);
});

test("the visible subagent result entry renders the completed report", () => {
const renderers = new Map<string, EntryRenderer>();
const pi = {
on() {},
events: { on() {} },
registerTool() {},
getActiveTools: () => [],
setActiveTools() {},
registerMessageRenderer() {},
registerEntryRenderer(customType: string, renderer: EntryRenderer) {
renderers.set(customType, renderer);
},
registerCommand() {},
} as unknown as ExtensionAPI;
subagents(pi);

const renderer = renderers.get("subagent-result");
assert.ok(renderer);
const theme = {
fg: (_color: string, text: string) => text,
bg: (_color: string, text: string) => text,
bold: (text: string) => text,
italic: (text: string) => text,
underline: (text: string) => text,
strikethrough: (text: string) => text,
inverse: (text: string) => text,
} as unknown as Parameters<EntryRenderer>[2];
const component = renderer(
{
type: "custom",
id: "entry-1",
parentId: null,
timestamp: new Date().toISOString(),
customType: "subagent-result",
data: {
content:
'Subagent sa-3 "investigate plan mode" finished.\n\nPlan Mode investigation report',
details: {
id: "sa-3",
title: "investigate plan mode",
status: "done",
},
},
},
{ expanded: true },
theme,
);

assert.ok(component);
assert.match(component.render(120).join("\n"), /subagent sa-3/);
assert.match(
component.render(120).join("\n"),
/Plan Mode investigation report/,
);
});

test("session start keeps only the subagent entry tool active", () => {
let active = ["read", "third_party_tool"];
Expand Down
206 changes: 130 additions & 76 deletions extensions/subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
ExtensionCommandContext,
ExtensionContext,
ExtensionUIContext,
MessageRenderer,
} from "@earendil-works/pi-coding-agent";
import {
DEFAULT_MAX_BYTES,
Expand Down Expand Up @@ -151,6 +152,23 @@ interface SubagentFinishedData {
readonly elapsed: string;
}

interface SubagentResultDetails {
readonly id?: string;
readonly title?: string;
readonly status?: SubagentSnapshot["status"];
readonly count?: number;
readonly results?: ReadonlyArray<{
readonly id: string;
readonly title: string;
readonly status: SubagentSnapshot["status"];
}>;
}

interface SubagentResultEntryData {
readonly content: string;
readonly details: SubagentResultDetails;
}

interface BtwResultData {
readonly id: string;
readonly title: string;
Expand Down Expand Up @@ -187,6 +205,99 @@ function truncatedOutput(
return text;
}

export function createSubagentResultDispatcher(
pi: ExtensionAPI,
outputFor: (snap: SubagentSnapshot) => string = truncatedOutput,
) {
return (snaps: readonly SubagentSnapshot[], wake: boolean) => {
if (snaps.length === 0) return;
const content = snaps
.map((snap) =>
buildSubagentResultMessage({
id: snap.id,
title: snap.title,
status: snap.status,
errorText: snap.errorText,
output: outputFor(snap),
}),
)
.join("\n\n");
const details: SubagentResultDetails =
snaps.length === 1
? {
id: snaps[0]!.id,
title: snaps[0]!.title,
status: snaps[0]!.status,
}
: {
count: snaps.length,
results: snaps.map((snap) => ({
id: snap.id,
title: snap.title,
status: snap.status,
})),
};
pi.appendEntry<SubagentResultEntryData>("subagent-result", {
content,
details,
});
pi.sendMessage(
{
customType: "subagent-result",
content,
display: false,
details,
},
resultDeliveryOptions(wake),
);
};
}

type SubagentResultTheme = Parameters<MessageRenderer>[2];

function renderSubagentResult(
content: string,
details: SubagentResultDetails,
expanded: boolean,
theme: SubagentResultTheme,
) {
const failed = details.status === "error";
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
const header =
`${icon} ` +
theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
theme.fg(
"muted",
` · ${details.title ?? ""} · ${failed ? "failed" : "finished"}`,
);

// Remove only the summary line. The following Error line (when present)
// is part of the actual result and must remain visible.
const body = content.split("\n").slice(1).join("\n").trim();
if (expanded || loadSetupConfig().ui.subagentResultDisplay === "full") {
const md = new Markdown(body, 0, 0, getMarkdownTheme());
const container = new Text(header, 0, 0);
return {
render: (width: number) => [
...container.render(width),
...md.render(width),
],
invalidate: () => {
container.invalidate();
md.invalidate();
},
};
}

const bodyLines = body.split("\n");
let text = header;
for (const line of bodyLines.slice(0, 8))
text += `\n${theme.fg("toolOutput", line)}`;
if (bodyLines.length > 8)
text += `\n${theme.fg("dim", `... (${keyHint("app.tools.expand", "to expand")})`)}`;
return new Text(text, 0, 0);
}

export default function (pi: ExtensionAPI) {
let runtime: SubagentRuntime | undefined;
let managerPromise: Promise<SubagentManagerShape> | undefined;
Expand All @@ -206,6 +317,7 @@ export default function (pi: ExtensionAPI) {
let navigationLayerRegistered = false;
let dashboardOpen = false;
const resultDelivery = createDeferredResultDelivery<SubagentSnapshot>();
const dispatchResults = createSubagentResultDispatcher(pi);
const hideLifecycleTools = () =>
patchOwnedTools(pi, "subagents", {
disable: OPENPI_TOOL_SURFACE.subagents.deferred,
Expand Down Expand Up @@ -330,41 +442,7 @@ export default function (pi: ExtensionAPI) {
snaps: readonly SubagentSnapshot[],
wake: boolean,
) => {
if (snaps.length === 0) return;
pi.sendMessage(
{
customType: "subagent-result",
// One message per flush, not per subagent.
content: snaps
.map((snap) =>
buildSubagentResultMessage({
id: snap.id,
title: snap.title,
status: snap.status,
errorText: snap.errorText,
output: truncatedOutput(snap),
}),
)
.join("\n\n"),
display: true,
details:
snaps.length === 1
? {
id: snaps[0]!.id,
title: snaps[0]!.title,
status: snaps[0]!.status,
}
: {
count: snaps.length,
results: snaps.map((snap) => ({
id: snap.id,
title: snap.title,
status: snap.status,
})),
},
},
resultDeliveryOptions(wake),
);
dispatchResults(snaps, wake);
};

const flushResults = (wake: boolean) => {
Expand Down Expand Up @@ -1059,52 +1137,28 @@ export default function (pi: ExtensionAPI) {
pi.registerMessageRenderer(
"subagent-result",
(message, { expanded }, theme) => {
const details = (message.details ?? {}) as {
id?: string;
title?: string;
status?: string;
};
const failed = details.status === "error";
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
const header =
`${icon} ` +
theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
theme.fg(
"muted",
` · ${details.title ?? ""} · ${failed ? "failed" : "finished"}`,
);

const content =
typeof message.content === "string" ? message.content : "";
// Remove only the summary line. The following Error line (when present)
// is part of the actual result and must remain visible.
const body = content.split("\n").slice(1).join("\n").trim();

if (expanded || loadSetupConfig().ui.subagentResultDisplay === "full") {
const md = new Markdown(`${body}`, 0, 0, getMarkdownTheme());
const container = new Text(header, 0, 0);
return {
render: (width: number) => [
...container.render(width),
...md.render(width),
],
invalidate: () => {
container.invalidate();
md.invalidate();
},
};
}

const previewLines = body.split("\n").slice(0, 8);
let text = header;
for (const line of previewLines)
text += `\n${theme.fg("toolOutput", line)}`;
if (body.split("\n").length > 8)
text += `\n${theme.fg("dim", `... (${keyHint("app.tools.expand", "to expand")})`)}`;
return new Text(text, 0, 0);
return renderSubagentResult(
content,
(message.details ?? {}) as SubagentResultDetails,
expanded,
theme,
);
},
);

pi.registerEntryRenderer<SubagentResultEntryData>(
"subagent-result",
(entry, { expanded }, theme) =>
renderSubagentResult(
entry.data?.content ?? "",
entry.data?.details ?? {},
expanded,
theme,
),
);

pi.registerEntryRenderer<SubagentFinishedData>(
"subagent-finished",
(entry, _options, theme) => {
Expand Down
Loading