mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* boot-extension.ts — the mock `pi` / `ctx` pair the wiring tests use to boot the
|
||||
* REAL extension (src/index.ts) and drive its registered tools directly.
|
||||
*
|
||||
* The 14 pre-existing wiring tests each inline their own near-identical copy of
|
||||
* this. They are deliberately NOT migrated: `test/` is outside the tsconfig
|
||||
* `include`, so that churn would be unchecked by `tsc`, and those copies have
|
||||
* small divergences that would have to be reconciled blind. New files use this.
|
||||
*/
|
||||
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export interface BootedPi {
|
||||
pi: any;
|
||||
tools: Map<string, any>;
|
||||
lifecycle: Map<string, any>;
|
||||
}
|
||||
|
||||
/** A mock ExtensionAPI that records every tool and lifecycle handler registered. */
|
||||
export function makePi(): BootedPi {
|
||||
const tools = new Map<string, any>();
|
||||
const lifecycle = new Map<string, any>();
|
||||
const pi = {
|
||||
registerMessageRenderer: vi.fn(),
|
||||
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
|
||||
registerCommand: vi.fn(),
|
||||
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
|
||||
events: {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(() => vi.fn()),
|
||||
},
|
||||
appendEntry: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
} as any;
|
||||
return { pi, tools, lifecycle };
|
||||
}
|
||||
|
||||
/** A mock ExtensionContext — the second half of what a tool's `execute` receives. */
|
||||
export function ctx(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
// The interactive mode extensions normally run in. Set explicitly because
|
||||
// the `@handle` input hook is TUI-only, so an absent mode would make every
|
||||
// mention test exercise the headless fall-through instead.
|
||||
mode: "tui",
|
||||
hasUI: false,
|
||||
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn(), addAutocompleteProvider: vi.fn() },
|
||||
cwd: process.cwd(),
|
||||
model: undefined,
|
||||
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
|
||||
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
|
||||
getSystemPrompt: vi.fn(() => "parent"),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
/** Text of a tool result. */
|
||||
export const textOf = (r: any): string => r.content[0].text;
|
||||
|
||||
/** Let queued microtasks run — enough for the manager's internal chaining. */
|
||||
export const flush = async () => {
|
||||
await new Promise((r) => setImmediate(r));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
};
|
||||
|
||||
export interface Hermetic {
|
||||
dir: string;
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect cwd, `PI_CODING_AGENT_DIR` and `HOME` into a fresh temp dir, so the
|
||||
* developer's real settings and agent files can't reach the extension under
|
||||
* test. Call BEFORE instantiating the extension — settings are read at boot.
|
||||
*/
|
||||
export function hermeticDir(opts: {
|
||||
settings?: Record<string, unknown>;
|
||||
agentFiles?: Record<string, string>;
|
||||
} = {}): Hermetic {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-boot-"));
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "pi-boot-agentdir-"));
|
||||
const prevCwd = process.cwd();
|
||||
const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const prevHome = process.env.HOME;
|
||||
|
||||
mkdirSync(join(dir, ".pi"), { recursive: true });
|
||||
if (opts.settings) {
|
||||
writeFileSync(join(dir, ".pi", "subagents.json"), JSON.stringify(opts.settings));
|
||||
}
|
||||
if (opts.agentFiles) {
|
||||
mkdirSync(join(dir, ".pi", "agents"), { recursive: true });
|
||||
for (const [name, content] of Object.entries(opts.agentFiles)) {
|
||||
writeFileSync(join(dir, ".pi", "agents", `${name}.md`), content);
|
||||
}
|
||||
}
|
||||
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
process.env.HOME = agentDir;
|
||||
process.chdir(dir);
|
||||
|
||||
return {
|
||||
dir,
|
||||
restore() {
|
||||
process.chdir(prevCwd);
|
||||
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;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* faux-model-backend.ts — the model/auth plumbing a faux-provider session needs,
|
||||
* in one place.
|
||||
*
|
||||
* `registerFauxProvider` scripts the *responses*, but a session still has to get
|
||||
* past model lookup and auth before it streams anything, and where that check
|
||||
* lives moved with Pi 0.80.8:
|
||||
* - Pi < 0.80.8: `createAgentSession({ modelRegistry })`, auth via
|
||||
* `getApiKeyAndHeaders()`.
|
||||
* - Pi >= 0.80.8: `createAgentSession({ modelRuntime })` — the registry option
|
||||
* is gone entirely — auth via `getAuth()`/`hasConfiguredAuth()`, and the
|
||||
* turn itself streams through `modelRuntime.streamSimple`.
|
||||
*
|
||||
* Passing BOTH spans the supported range: each Pi ignores the option it no
|
||||
* longer knows. Structural fakes (not real instances) keep the suites hermetic —
|
||||
* no auth.json, no network, no local login state.
|
||||
*/
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import { streamSimple } from "./pi-ai.js";
|
||||
|
||||
/** Both option shapes for `createAgentSession`, for the given faux model. */
|
||||
export function fauxModelBackend(model: Model<string>): {
|
||||
modelRegistry: any;
|
||||
modelRuntime: any;
|
||||
} {
|
||||
return {
|
||||
modelRegistry: {
|
||||
find: () => model,
|
||||
getAll: () => [model],
|
||||
getAvailable: () => [model],
|
||||
hasConfiguredAuth: () => true,
|
||||
isUsingOAuth: () => false,
|
||||
// createAgentSession's injected streamFn checks `auth.ok` and throws
|
||||
// Error(auth.error) otherwise — so the `ok: true` flag is mandatory, not
|
||||
// cosmetic. Without it the turn dies before streaming (empty error message).
|
||||
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "faux", headers: {} }),
|
||||
registerProvider: () => {},
|
||||
unregisterProvider: () => {},
|
||||
},
|
||||
modelRuntime: {
|
||||
getModel: () => model,
|
||||
getModels: () => [model],
|
||||
getProvider: () => undefined,
|
||||
getProviders: () => [],
|
||||
getAvailable: async () => [model],
|
||||
getAvailableSnapshot: () => [model],
|
||||
getError: () => undefined,
|
||||
hasConfiguredAuth: () => true,
|
||||
checkAuth: async () => ({ ok: true }),
|
||||
isUsingOAuth: () => false,
|
||||
isUsingSubscription: () => false,
|
||||
// Shape mirrors ModelRuntime.getAuth: the session reads `auth.apiKey` /
|
||||
// `auth.headers` and throws "No API key found" when both are absent.
|
||||
getAuth: async () => ({ auth: { apiKey: "faux", headers: {} } }),
|
||||
getProviderAuthStatus: () => "configured",
|
||||
getCompatibilityRequestConfig: () => ({}),
|
||||
getRegisteredProviderIds: () => [],
|
||||
getRegisteredProviderConfig: () => undefined,
|
||||
getRegisteredNativeProvider: () => undefined,
|
||||
registerProvider: () => {},
|
||||
registerNativeProvider: () => {},
|
||||
unregisterProvider: () => {},
|
||||
refresh: async () => ({}),
|
||||
// The faux provider registers itself in pi-ai's global api-provider
|
||||
// registry, so compat's dispatcher reaches it by `model.api`.
|
||||
stream: streamSimple,
|
||||
streamSimple,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* pi-ai.ts — single import point for the two test helpers that pi-ai ≥0.80
|
||||
* exports only from the `/compat` subpath (all lived on the package root in
|
||||
* ≤0.75.x). Upstream deletes `/compat` with its coding-agent ModelManager
|
||||
* migration; the replacement then is `fauxProvider()` + `createModels()`.
|
||||
*/
|
||||
export { getModel, registerFauxProvider, streamSimple } from "@earendil-works/pi-ai/compat";
|
||||
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* 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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user