mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
617 lines
25 KiB
TypeScript
617 lines
25 KiB
TypeScript
/**
|
|
* print-mode-runner.ts — a headless ("print mode") host runner for driving the
|
|
* pi-subagents extension through REAL end-to-end subagent runs.
|
|
*
|
|
* WHY THIS EXISTS
|
|
* ---------------
|
|
* The other e2e suites (agent-runner-e2e, ext-templates-e2e) assert on the
|
|
* *gated tool set captured at construction* — they never drive a turn, so they
|
|
* never actually spawn a subagent or exercise the background hold condition.
|
|
* This runner closes that gap: it boots a real headless pi session with the
|
|
* pi-subagents extension loaded, drives a real assistant turn that calls the
|
|
* `Agent` tool, and lets the extension spawn a real child session through the
|
|
* real `runAgent` path — then waits for it to finish exactly like a production
|
|
* print-mode host does.
|
|
*
|
|
* It is the pi-subagents analogue of pi-chonky-step's `src/agent.ts` headless
|
|
* runner: same shape (DefaultResourceLoader → createAgentSession → prompt loop),
|
|
* and crucially it replicates pi-chonky-step's SUBAGENT HOLD CONDITION — the
|
|
* `dequeueFollowUpMessages` monkey-patch that blocks the parent agent loop until
|
|
* background subagents complete (via the `Symbol.for("pi-subagents:manager")`
|
|
* global the extension publishes). Without that patch, `session.prompt()`
|
|
* resolves and the parent finishes before background children report back.
|
|
*
|
|
* MODEL BACKEND (faux default, real opt-in)
|
|
* -----------------------------------------
|
|
* - Faux (default): a scripted `registerFauxProvider` model drives both the
|
|
* parent and the spawned child deterministically — no network, CI-safe. You
|
|
* supply a `respond(context)` function (or raw `steps`) that emits the
|
|
* `Agent` tool call on the parent and a reply on the child. `routeBySession`
|
|
* does the parent/child branching for the common single-spawn case.
|
|
* - Live (opt-in): set `PI_E2E_LIVE=1` or pass `live: {provider, model}`. A real
|
|
* model drives the turn; `respond`/`steps` are ignored. Non-deterministic,
|
|
* needs creds. With no explicit model pin, it resolves the model from your
|
|
* local `pi` config (settings default → first authed model), so a logged-in
|
|
* `pi` is picked up automatically — no PI_PROVIDER/PI_MODEL needed.
|
|
*
|
|
* ONE PARAMETERIZED RUNNER
|
|
* ------------------------
|
|
* The same `runPrintMode()` covers built-in agent types, `.pi/agents/*.md` /
|
|
* `.agents/agents/*.md` frontmatter agents, and inline-instruction agents — the difference is purely
|
|
* what you register in `beforeRun` and which `subagent_type` the `Agent` call
|
|
* names. See `test/subagents-print-mode-e2e.test.ts` for usage.
|
|
*/
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
type AssistantMessage,
|
|
type Context,
|
|
type FauxContentBlock,
|
|
type FauxResponseStep,
|
|
fauxAssistantMessage,
|
|
fauxText,
|
|
fauxToolCall,
|
|
type Model,
|
|
type ToolCall,
|
|
} from "@earendil-works/pi-ai";
|
|
import {
|
|
type AgentSession,
|
|
type AgentSessionEvent,
|
|
createAgentSession,
|
|
DefaultResourceLoader,
|
|
getAgentDir,
|
|
SessionManager,
|
|
SettingsManager,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import { fauxModelBackend } from "./faux-model-backend.js";
|
|
import { getModel, registerFauxProvider } from "./pi-ai.js";
|
|
|
|
/** Path to the pi-subagents extension entrypoint (repo `src/index.ts`). */
|
|
const EXTENSION_PATH = fileURLToPath(new URL("../../src/index.ts", import.meta.url));
|
|
|
|
/** The cross-package handle the extension publishes on a global Symbol. */
|
|
const MANAGER_KEY = Symbol.for("pi-subagents:manager");
|
|
|
|
export interface ManagerHandle {
|
|
waitForAll(): Promise<void>;
|
|
hasRunning(): boolean;
|
|
getRecord(id: string): unknown;
|
|
}
|
|
|
|
/** A faux reply in any convenient shape; normalized to an AssistantMessage. */
|
|
export type FauxReply = string | FauxContentBlock | FauxContentBlock[] | AssistantMessage;
|
|
|
|
/**
|
|
* A context-branching responder. Invoked once per model call (parent OR child)
|
|
* with that call's own `Context`, so it can decide what to emit from the prompt
|
|
* it sees — order-independent, unlike a flat FIFO `steps` list.
|
|
*/
|
|
export type FauxResponder = (
|
|
context: Context,
|
|
state: { callCount: number },
|
|
) => FauxReply | Promise<FauxReply>;
|
|
|
|
export interface RunPrintModeOptions {
|
|
/** The user prompt that kicks off the parent turn. */
|
|
prompt: string;
|
|
/**
|
|
* Working directory for the run. Defaults to a fresh temp dir that `dispose()`
|
|
* removes. Pass a fixtures dir to make project custom agents discoverable.
|
|
*/
|
|
cwd?: string;
|
|
/** Parent host system prompt. Default: a minimal orchestrator prompt. */
|
|
systemPrompt?: string;
|
|
/**
|
|
* Faux mode: context-branching responder (padded to `maxModelCalls` calls).
|
|
* Ignored in live mode. Mutually exclusive with `steps` (steps wins).
|
|
*/
|
|
respond?: FauxResponder;
|
|
/** Faux mode: explicit FIFO response steps. Overrides `respond`. */
|
|
steps?: FauxResponseStep[];
|
|
/** Faux mode: how many model calls to pad the queue for. Default 16. */
|
|
maxModelCalls?: number;
|
|
/**
|
|
* Honor the subagent hold condition — block the parent agent loop until
|
|
* background subagents finish (the pi-chonky-step monkey-patch). Default true.
|
|
*/
|
|
hold?: boolean;
|
|
/**
|
|
* Run before the parent turn, after globals are isolated — e.g.
|
|
* `registerAgents(loadCustomAgents(cwd))` to install frontmatter agents.
|
|
*/
|
|
beforeRun?: () => void | Promise<void>;
|
|
/**
|
|
* Isolate global discovery (PI_CODING_AGENT_DIR + HOME → temp) so the dev's
|
|
* real agents/extensions can't bleed into the run. Default true in faux mode,
|
|
* false in live mode (so real auth/config resolve). Restored on `dispose()`.
|
|
*/
|
|
isolateGlobals?: boolean;
|
|
/** Wall-clock guard for the whole run. Default 30_000ms. */
|
|
timeoutMs?: number;
|
|
/** Abort the parent (and forwarded children) externally. */
|
|
signal?: AbortSignal;
|
|
/**
|
|
* Force live mode against a specific provider/model (overrides PI_E2E_LIVE).
|
|
* When omitted, live mode is on iff `PI_E2E_LIVE` is truthy. In live mode, if
|
|
* neither this nor `PI_PROVIDER`+`PI_MODEL` is set, the model is left for pi to
|
|
* resolve from your local config (settings default → first authed model) — i.e.
|
|
* it picks up whatever your `pi` install is logged into, no env required.
|
|
*
|
|
* `false` pins the run faux even under `PI_E2E_LIVE=1`. A suite whose whole
|
|
* point is a scripted response — a provider error with no content, a
|
|
* three-level delegation chain — has nothing to gain from a real model and
|
|
* cannot assert anything once one answers instead. Without this, running the
|
|
* documented pre-publish smoke turns those suites red on a healthy tree,
|
|
* which is worse than not running them: it hides a real regression in noise.
|
|
*/
|
|
live?: { provider: string; model: string } | false;
|
|
}
|
|
|
|
export interface PrintModeRun {
|
|
/** Last assistant text the parent produced (the "printed" answer). */
|
|
responseText: string;
|
|
/** The live parent session (history, tool calls, etc.). */
|
|
parentSession: AgentSession;
|
|
/** The extension's manager handle (undefined if the extension didn't load). */
|
|
manager: ManagerHandle | undefined;
|
|
/** Snapshot of all subagent records the manager knew about at the end. */
|
|
subagents: Array<Record<string, unknown>>;
|
|
/** Faux model call count (0 in live mode). */
|
|
modelCalls: number;
|
|
/**
|
|
* Tear down: emit session_shutdown (so extensions clear timers), dispose the
|
|
* session, unregister faux, restore cwd/env, rm temp dir. Async — await it.
|
|
*/
|
|
dispose: () => Promise<void>;
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Faux scripting helpers
|
|
// --------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build an `Agent` tool call for a faux assistant turn. `subagent_type` defaults
|
|
* to "general-purpose"; everything else is passed straight through as tool args.
|
|
*/
|
|
export function agentCall(
|
|
args: {
|
|
prompt: string;
|
|
description: string;
|
|
subagent_type?: string;
|
|
run_in_background?: boolean;
|
|
[k: string]: unknown;
|
|
},
|
|
opts?: { id?: string },
|
|
): ToolCall {
|
|
return fauxToolCall("Agent", { subagent_type: "general-purpose", ...args }, opts);
|
|
}
|
|
|
|
function resolveReply(
|
|
reply: FauxReply | ((ctx: Context) => FauxReply),
|
|
ctx: Context,
|
|
): FauxReply {
|
|
return typeof reply === "function" ? (reply as (c: Context) => FauxReply)(ctx) : reply;
|
|
}
|
|
|
|
/**
|
|
* The common single-spawn flow as a responder. Routes by inspecting the calling
|
|
* session's own context:
|
|
* - PARENT (its tool set includes `Agent`):
|
|
* · `parentInitial` until an `Agent` tool result is in history (the spawn),
|
|
* · then `parentFinal` (the answer after the child reports back).
|
|
* - SUBAGENT (no `Agent` tool): `subagent`.
|
|
* Each route may be a value or a `(ctx) => value` function.
|
|
*/
|
|
export function routeBySession(routes: {
|
|
parentInitial: FauxReply | ((ctx: Context) => FauxReply);
|
|
parentFinal?: FauxReply | ((ctx: Context) => FauxReply);
|
|
subagent: FauxReply | ((ctx: Context) => FauxReply);
|
|
}): FauxResponder {
|
|
return (context) => {
|
|
const isParent = (context.tools ?? []).some((t) => t.name === "Agent");
|
|
if (!isParent) return resolveReply(routes.subagent, context);
|
|
const spawned = context.messages.some(
|
|
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
|
|
);
|
|
if (spawned) {
|
|
return routes.parentFinal != null
|
|
? resolveReply(routes.parentFinal, context)
|
|
: "Done.";
|
|
}
|
|
return resolveReply(routes.parentInitial, context);
|
|
};
|
|
}
|
|
|
|
/** Normalize any FauxReply into a faux AssistantMessage (tool calls ⇒ stopReason "toolUse"). */
|
|
function toAssistantMessage(reply: FauxReply): AssistantMessage {
|
|
if (reply && typeof reply === "object" && "role" in reply) {
|
|
return reply as AssistantMessage;
|
|
}
|
|
const content: FauxContentBlock[] =
|
|
typeof reply === "string" ? [fauxText(reply)] : Array.isArray(reply) ? reply : [reply];
|
|
const hasToolCall = content.some((b) => (b as { type?: string }).type === "toolCall");
|
|
return fauxAssistantMessage(content, { stopReason: hasToolCall ? "toolUse" : "stop" });
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// The runner
|
|
// --------------------------------------------------------------------------
|
|
|
|
const DEFAULT_SYSTEM_PROMPT =
|
|
"You are a headless orchestrator. Use the Agent tool to delegate, then report the result.";
|
|
|
|
function isLive(options: RunPrintModeOptions): boolean {
|
|
// An explicit `false` wins over the env var — the env var is a blanket switch,
|
|
// and a suite that pins itself faux is stating something the switch can't know.
|
|
if (options.live === false) return false;
|
|
return Boolean(options.live) || /^(1|true|yes)$/i.test(process.env.PI_E2E_LIVE ?? "");
|
|
}
|
|
|
|
export async function runPrintMode(options: RunPrintModeOptions): Promise<PrintModeRun> {
|
|
const live = isLive(options);
|
|
const isolateGlobals = options.isolateGlobals ?? !live;
|
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
|
|
// --- working dir (own it only if we created it) ---
|
|
const ownsCwd = options.cwd == null;
|
|
const cwd = options.cwd ?? mkdtempSync(join(tmpdir(), "subagents-print-"));
|
|
|
|
// chdir into cwd: the extension discovers project custom agents from process.cwd()
|
|
// (not ctx.cwd), and re-reads them on every Agent invocation — so a custom agent
|
|
// is only spawnable if process.cwd() points at the dir holding it. Restored on
|
|
// dispose. (Vitest isolates test files per process, so this doesn't race.)
|
|
const prevCwd = process.cwd();
|
|
process.chdir(cwd);
|
|
|
|
// --- isolate global discovery so the dev env can't bleed in ---
|
|
const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
const prevHome = process.env.HOME;
|
|
let hermeticDir: string | undefined;
|
|
if (isolateGlobals) {
|
|
hermeticDir = mkdtempSync(join(tmpdir(), "subagents-print-home-"));
|
|
process.env.PI_CODING_AGENT_DIR = hermeticDir;
|
|
process.env.HOME = hermeticDir;
|
|
}
|
|
|
|
// --- model backend ---
|
|
let faux: ReturnType<typeof registerFauxProvider> | undefined;
|
|
let model: Model<string> | undefined;
|
|
let modelRegistry: unknown;
|
|
let modelRuntime: unknown;
|
|
if (live) {
|
|
// Explicit pin wins (options.live or PI_PROVIDER + PI_MODEL). Otherwise leave
|
|
// `model` undefined: createAgentSession then calls findInitialModel() against
|
|
// the real, auth-backed registry + your local settings default — i.e. it
|
|
// picks up whatever your `pi` install is logged into, no env needed.
|
|
// `live: false` never reaches here (isLive returned false), but narrow it
|
|
// away rather than asserting: the pin is a plain option, not a type-level fact.
|
|
const pin = options.live || undefined;
|
|
const provider = pin?.provider ?? process.env.PI_PROVIDER;
|
|
const modelId = pin?.model ?? process.env.PI_MODEL;
|
|
if (provider && modelId) {
|
|
// getModel's overloads need the concrete provider literal; cast through.
|
|
// Since pi-ai 0.80 it is a static builtin-catalog lookup that returns
|
|
// undefined for unknown models — fail fast instead of letting
|
|
// createAgentSession silently substitute another model.
|
|
model = (getModel as (p: string, m: string) => Model<string> | undefined)(provider, modelId);
|
|
if (!model) {
|
|
throw new Error(
|
|
`runPrintMode (live mode): model "${provider}/${modelId}" not found in the builtin catalog`,
|
|
);
|
|
}
|
|
}
|
|
// Let createAgentSession build the real, auth-backed registry/runtime.
|
|
modelRegistry = undefined;
|
|
modelRuntime = undefined;
|
|
} else {
|
|
if (!options.steps && !options.respond) {
|
|
throw new Error("runPrintMode (faux mode): provide `respond` or `steps`");
|
|
}
|
|
faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] });
|
|
model = faux.getModel();
|
|
// Structural faux registry + runtime (see faux-model-backend.ts): the parent
|
|
// session uses `model` directly; subagents inherit it via ctx.model since
|
|
// resolveDefaultModel falls back to the parent model when no model is pinned.
|
|
({ modelRegistry, modelRuntime } = fauxModelBackend(model));
|
|
|
|
// Pad the response queue: one context-branching responder per expected model
|
|
// call. The queue is a single FIFO shared by parent + child, but every entry
|
|
// is the same responder that decides from its own context, so interleaving
|
|
// order doesn't matter.
|
|
if (options.steps) {
|
|
faux.setResponses(options.steps);
|
|
} else {
|
|
const respond = options.respond;
|
|
if (!respond) {
|
|
throw new Error("runPrintMode (faux mode): provide `respond` or `steps`");
|
|
}
|
|
const max = options.maxModelCalls ?? 16;
|
|
const factory: FauxResponseStep = async (context, _opts, state) =>
|
|
toAssistantMessage(await respond(context, state));
|
|
faux.setResponses(Array.from({ length: max }, () => factory));
|
|
}
|
|
}
|
|
|
|
// --- build the parent host session with the extension loaded ---
|
|
// Resolved after globals are isolated, so it honors the hermetic dir.
|
|
const agentDir = getAgentDir();
|
|
const loader = new DefaultResourceLoader({
|
|
cwd,
|
|
agentDir,
|
|
additionalExtensionPaths: [EXTENSION_PATH],
|
|
systemPromptOverride: () => options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
|
|
appendSystemPromptOverride: () => [],
|
|
noPromptTemplates: true,
|
|
noThemes: true,
|
|
noContextFiles: true,
|
|
});
|
|
await loader.reload();
|
|
|
|
// Run any test-supplied registration (e.g. loadCustomAgents) now that globals
|
|
// are isolated but before the parent turn spawns anything.
|
|
await options.beforeRun?.();
|
|
|
|
const { session } = await createAgentSession({
|
|
cwd,
|
|
agentDir,
|
|
model,
|
|
// Structural faux registry/runtime in faux mode; undefined in live mode (defaults).
|
|
modelRegistry: modelRegistry as any,
|
|
modelRuntime: modelRuntime as any,
|
|
resourceLoader: loader,
|
|
sessionManager: SessionManager.inMemory(cwd),
|
|
// Live: real settings so an omitted model resolves to your local default
|
|
// (settingsManager.getDefaultModel) and retries/compaction match your config.
|
|
// Faux: in-memory, deterministic, no disk.
|
|
settingsManager: live
|
|
? SettingsManager.create(cwd, agentDir)
|
|
: SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } }),
|
|
});
|
|
session.setSessionName("print-mode-host");
|
|
|
|
// Binding fires session_start so the extension initializes and publishes its
|
|
// manager on the global Symbol.
|
|
await session.bindExtensions({});
|
|
|
|
const manager = (globalThis as Record<symbol, unknown>)[MANAGER_KEY] as
|
|
| ManagerHandle
|
|
| undefined;
|
|
|
|
// --- subagent hold condition (the pi-chonky-step monkey-patch) ---
|
|
// Block the parent agent loop while background subagents are still running, so
|
|
// their completion nudges land before the parent's final turn.
|
|
const hold = options.hold ?? true;
|
|
if (hold && manager) {
|
|
// dequeueFollowUpMessages is internal — reach through with a cast.
|
|
const agent = (session as any).agent;
|
|
if (agent?.dequeueFollowUpMessages) {
|
|
const original = agent.dequeueFollowUpMessages.bind(agent);
|
|
agent.dequeueFollowUpMessages = function patched() {
|
|
const messages = original();
|
|
if (messages.length > 0) return messages;
|
|
if (manager.hasRunning()) {
|
|
// Returning a Promise is auto-unwrapped by the async loop config —
|
|
// the loop blocks here until all subagents finish and queue nudges.
|
|
return manager.waitForAll().then(() => original());
|
|
}
|
|
return messages;
|
|
};
|
|
}
|
|
}
|
|
|
|
// --- collect the parent's last assistant text ---
|
|
let responseText = "";
|
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
if (event.type === "message_start") responseText = "";
|
|
if (
|
|
event.type === "message_update" &&
|
|
event.assistantMessageEvent.type === "text_delta"
|
|
) {
|
|
responseText += event.assistantMessageEvent.delta;
|
|
}
|
|
});
|
|
|
|
// --- forward external abort ---
|
|
const onAbort = () => session.abort();
|
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
|
|
const dispose = async () => {
|
|
// Emit session_shutdown FIRST so extensions tear down cleanly — in live mode
|
|
// the real env loads global extensions (e.g. a status-bar) whose background
|
|
// timers would otherwise fire after dispose() invalidates the ctx and surface
|
|
// as unhandled "stale ctx" rejections. dispose() itself does the invalidation,
|
|
// so shutdown has to happen before it.
|
|
try {
|
|
await session.extensionRunner?.emit({ type: "session_shutdown", reason: "quit" });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
session.dispose?.();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
faux?.unregister();
|
|
delete (globalThis as Record<symbol, unknown>)[MANAGER_KEY];
|
|
// Restore cwd before removing the temp dir (can't rm the dir you're in).
|
|
try {
|
|
process.chdir(prevCwd);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (isolateGlobals) {
|
|
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
|
|
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
|
|
if (prevHome == null) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
if (hermeticDir) rmSync(hermeticDir, { recursive: true, force: true });
|
|
}
|
|
if (ownsCwd) rmSync(cwd, { recursive: true, force: true });
|
|
};
|
|
|
|
// --- drive the turn under a wall-clock guard ---
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let failed = false;
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
const stillRunning = manager?.hasRunning() ? " (background subagents still running)" : "";
|
|
reject(new Error(`print-mode runner timed out after ${timeoutMs}ms${stillRunning}`));
|
|
}, timeoutMs);
|
|
});
|
|
try {
|
|
await Promise.race([
|
|
(async () => {
|
|
await session.prompt(options.prompt);
|
|
// Fallback for when the hold patch is unavailable: catch any subagents
|
|
// still running after prompt() returns and process their results.
|
|
if (hold) {
|
|
while (!failed && manager?.hasRunning()) {
|
|
await manager.waitForAll();
|
|
// prompt() resolves (not rejects) on abort, so after a timeout this
|
|
// orphaned race arm keeps running — never re-prompt a torn-down session.
|
|
if (failed) break;
|
|
await session.prompt("Background agents have completed. Process their results.");
|
|
}
|
|
}
|
|
})(),
|
|
timeout,
|
|
]);
|
|
} catch (err) {
|
|
// On timeout (or any turn failure) we throw, so the caller never receives
|
|
// the dispose handle — without this, a live session and its background
|
|
// subagents would keep streaming after the test already failed. Subagents
|
|
// are aborted by dispose()'s session_shutdown emit (the extension's
|
|
// shutdown handler calls manager.abortAll()).
|
|
failed = true;
|
|
try {
|
|
session.abort();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
await dispose();
|
|
} catch {
|
|
/* ignore — the turn error below is the diagnostic that matters */
|
|
}
|
|
throw err;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
unsubscribe();
|
|
options.signal?.removeEventListener("abort", onAbort);
|
|
}
|
|
|
|
if (!responseText.trim()) {
|
|
responseText = lastAssistantText(session);
|
|
}
|
|
|
|
// Snapshot subagent records (manager exposes them via the extension session,
|
|
// but the cross-package handle only exposes getRecord — read listAgents off
|
|
// the underlying manager if reachable, else fall back to an empty list).
|
|
const subagents = snapshotSubagents(manager);
|
|
|
|
return {
|
|
responseText: responseText.trim(),
|
|
parentSession: session,
|
|
manager,
|
|
subagents,
|
|
modelCalls: faux?.state.callCount ?? 0,
|
|
dispose,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract the text of every `Agent` tool result in a session's history. This is
|
|
* the real end-to-end observable: for a foreground spawn it contains the child's
|
|
* own output; for a background spawn it's the "started in background" envelope.
|
|
*/
|
|
export function agentToolResults(session: AgentSession): string[] {
|
|
const out: string[] = [];
|
|
for (const msg of session.messages) {
|
|
if (msg.role !== "toolResult") continue;
|
|
if ((msg as { toolName?: string }).toolName !== "Agent") continue;
|
|
const text = (msg.content as Array<{ type?: string; text?: string }>)
|
|
.map((b) => (b.type === "text" ? (b.text ?? "") : ""))
|
|
.join("");
|
|
out.push(text);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* All text across the whole conversation — assistant turns, user/nudge messages,
|
|
* and every tool result. Use this to assert a child's output *materialized
|
|
* somewhere* (a foreground tool result, a get_subagent_result result, a held
|
|
* nudge), rather than only in the parent's final message which may summarize it.
|
|
*/
|
|
export function conversationText(session: AgentSession): string {
|
|
const parts: string[] = [];
|
|
for (const msg of session.messages) {
|
|
const content = (msg as { content?: unknown }).content;
|
|
if (!Array.isArray(content)) continue;
|
|
for (const block of content as Array<{ type?: string; text?: string }>) {
|
|
if (block.type === "text" && block.text) parts.push(block.text);
|
|
}
|
|
}
|
|
return parts.join("\n");
|
|
}
|
|
|
|
/** Names of every tool the assistant actually invoked (in order). */
|
|
export function invokedToolNames(session: AgentSession): string[] {
|
|
const out: string[] = [];
|
|
for (const msg of session.messages) {
|
|
if (msg.role !== "assistant") continue;
|
|
for (const block of msg.content as Array<{ type?: string; name?: string }>) {
|
|
if (block.type === "toolCall" && block.name) out.push(block.name);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The arguments of every `Agent` tool call the model actually made — lets a live
|
|
* smoke assert which feature was exercised (e.g. `run_in_background`,
|
|
* `subagent_type`) rather than just that *some* spawn happened.
|
|
*/
|
|
export function agentToolCalls(session: AgentSession): Array<Record<string, unknown>> {
|
|
const out: Array<Record<string, unknown>> = [];
|
|
for (const msg of session.messages) {
|
|
if (msg.role !== "assistant") continue;
|
|
for (const block of msg.content as Array<{ type?: string; name?: string; arguments?: unknown }>) {
|
|
if (block.type === "toolCall" && block.name === "Agent") {
|
|
out.push((block.arguments ?? {}) as Record<string, unknown>);
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Walk session history backward for the last non-empty assistant text. */
|
|
function lastAssistantText(session: AgentSession): string {
|
|
const messages = session.messages;
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const msg = messages[i];
|
|
if (msg.role !== "assistant") continue;
|
|
const text = msg.content
|
|
.map((b) => ((b as { type?: string; text?: string }).type === "text" ? (b as { text?: string }).text ?? "" : ""))
|
|
.join("")
|
|
.trim();
|
|
if (text) return text;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/** Best-effort snapshot of the manager's agent records for assertions. */
|
|
function snapshotSubagents(manager: ManagerHandle | undefined): Array<Record<string, unknown>> {
|
|
if (!manager) return [];
|
|
// The published handle is minimal; the real manager (with listAgents) is the
|
|
// same object the extension constructed. Try listAgents if present.
|
|
const m = manager as unknown as { listAgents?: () => Array<Record<string, unknown>> };
|
|
try {
|
|
return m.listAgents ? m.listAgents() : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|