Compare commits
6
Commits
b3884f6c35
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3cd328ede | ||
|
|
0b7af601a3 | ||
|
|
208cd6f4e7 | ||
|
|
bc3e3d6049 | ||
|
|
9a8169523b | ||
|
|
4105b03981 |
@@ -1,28 +1,31 @@
|
|||||||
|
## Forked from [amosblomqvist/pi-interactive-subagents](https://github.com/amosblomqvist/pi-interactive-subagents)
|
||||||
|
|
||||||
# pi-interactive-subagents
|
# pi-interactive-subagents
|
||||||
|
|
||||||
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking.
|
Async subagents for [pi](https://github.com/badlogic/pi-mono), running in Herdr tabs, Orca terminal tabs, or tmux panes. Spawn a sub-agent, keep working in the main session, and get the result steered back when it finishes. Fully non-blocking.
|
||||||
|
|
||||||
The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), otherwise tmux is used when available.
|
The backend is selected automatically: Herdr is used when Pi runs in a Herdr-managed pane (`HERDR_ENV=1`), then Orca when Pi runs in an Orca-managed terminal (`ORCA_TERMINAL_HANDLE`), otherwise tmux is used when available.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
`subagent()` returns immediately. In Herdr, the sub-agent runs in a new unfocused tab in the current workspace. In tmux, it runs in a right split off the parent Pi pane. A live widget above the input tracks every running sub-agent, and when one finishes, its result is steered into the main session as a notification that triggers a new turn.
|
`subagent()` returns immediately. In Herdr, the sub-agent runs in a new unfocused tab in the current workspace. In Orca, it runs in a new unfocused terminal tab in the current worktree, started in the parent Pi's working directory. In tmux, it runs in a right split off the parent Pi pane. A live widget above the input tracks every running sub-agent, and when one finishes, its result is steered into the main session as a notification that triggers a new turn.
|
||||||
|
|
||||||
```
|
```
|
||||||
╭─ Subagents ──────────────────────────── 2 running ─╮
|
Subagents 2 running
|
||||||
│ 00:23 scout active · bash 7m │
|
⟳ 00:23 scout active · bash 7m
|
||||||
│ 00:45 scout-2 waiting 2m │
|
○ 00:45 scout-2 waiting 2m
|
||||||
╰────────────────────────────────────────────────────╯
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
Spawn several in parallel — they run concurrently and steer results back independently as each finishes.
|
||||||
|
|
||||||
tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr subagents use separate tabs, so they do not resize the parent tab.
|
Running subagents survive a parent `/reload`. Their terminal sessions and completion watchers stay active, then rebind result delivery and the status widget to the fresh Pi extension runtime. Replacing or closing the parent session still cancels its running subagents. This lifecycle behavior requires Pi 0.68 or newer.
|
||||||
|
|
||||||
|
tmux panes are kept evenly sized: the extension re-applies an `even-horizontal` layout after every spawn and exit (debounced). The layout is a single constant, `SUBAGENT_TMUX_LAYOUT` in `pi-extension/subagents/tmux.ts` — change it to any named tmux layout (`main-vertical`, `tiled`, …). Herdr and Orca subagents use separate tabs, so they do not resize the parent tab.
|
||||||
|
|
||||||
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
|
If your shell startup is slow and launch commands get dropped before the prompt is ready, raise the delay:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, tmux 500
|
export PI_SUBAGENT_SHELL_READY_DELAY_MS=2500 # defaults: Herdr 1000, Orca 500, tmux 500
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
@@ -144,7 +147,23 @@ Access is **whitelist-only**. Every sub-agent process is launched with `--no-ext
|
|||||||
|
|
||||||
Spawns must name a known agent at **every** depth. A top-level session may spawn anything discoverable; a sub-agent may only spawn the agents in its `subagent_agents` list (enforced via `PI_SUBAGENT_ALLOWED`). There is no agentless spawn route, so a child can never escalate to a full-toolset profile by omitting its agent.
|
Spawns must name a known agent at **every** depth. A top-level session may spawn anything discoverable; a sub-agent may only spawn the agents in its `subagent_agents` list (enforced via `PI_SUBAGENT_ALLOWED`). There is no agentless spawn route, so a child can never escalate to a full-toolset profile by omitting its agent.
|
||||||
|
|
||||||
Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global.
|
Extensions can register additional tools for sub-agents at runtime via `registerToolExtension(name, path)` on the `__pi_interactive_subagents` process global. Hosts that compose widgets can optionally use its reload-safe `statusWidget` surface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { statusWidget } = globalThis.__pi_interactive_subagents;
|
||||||
|
const onChange = () => rerender();
|
||||||
|
const release = statusWidget.claim(onChange);
|
||||||
|
|
||||||
|
statusWidget.hasContent(); // whether any subagent rows exist
|
||||||
|
statusWidget.getBodyRowCount(); // uncapped number of running agents
|
||||||
|
statusWidget.render(width, maxBodyRows, ctx.ui.theme); // compact unboxed lines; cap includes an overflow row
|
||||||
|
statusWidget.isClaimed(onChange); // true only for the current provider claim
|
||||||
|
release(); // restore the standalone subagent-status widget
|
||||||
|
```
|
||||||
|
|
||||||
|
`render(width, maxBodyRows?, theme?)` accepts Pi's current theme as an optional structural value. The standalone widget receives and uses its own Pi theme automatically. When no theme is passed, a legacy ANSI fallback keeps status colors usable. The header is a compact unboxed `Subagents <count> running` row; body rows remain width-safe, and `maxBodyRows` counts body rows including the overflow summary, not the header. Semantic row colors match Pi's Todo conventions: accent header, muted count, warning active/running, error stalled, dim starting/provider tags/elapsed time, and accent waiting.
|
||||||
|
|
||||||
|
`claim()` replaces any prior claim, and an old release function cannot release a newer claim. Standalone-widget clearing/restoration is deferred to a microtask, so hosts may release and immediately re-claim without a transient fallback widget. `isClaimed(onChange)` checks both callback identity and the current runtime ownership, so hosts can detect invalidation after reload/session rebind. Claims are cleared during session shutdown, including `/reload`, so a fresh host runtime must claim again rather than retaining a stale callback. Calling `render()` directly does not claim or change the standalone widget.
|
||||||
|
|
||||||
## Role folders
|
## Role folders
|
||||||
|
|
||||||
@@ -180,10 +199,13 @@ Status display is configured via `config.json` in the extension directory (copy
|
|||||||
- [pi](https://github.com/badlogic/pi-mono)
|
- [pi](https://github.com/badlogic/pi-mono)
|
||||||
- One supported terminal manager:
|
- One supported terminal manager:
|
||||||
- [Herdr](https://herdr.dev), with Pi launched inside Herdr
|
- [Herdr](https://herdr.dev), with Pi launched inside Herdr
|
||||||
|
- [Orca](https://orca.build), with Pi launched inside an Orca-managed terminal
|
||||||
- [tmux](https://github.com/tmux/tmux)
|
- [tmux](https://github.com/tmux/tmux)
|
||||||
|
|
||||||
Herdr requires no extension configuration once its Pi integration is installed. The extension detects `HERDR_ENV=1` and creates subagents as unfocused tabs in the current workspace.
|
Herdr requires no extension configuration once its Pi integration is installed. The extension detects `HERDR_ENV=1` and creates subagents as unfocused tabs in the current workspace.
|
||||||
|
|
||||||
|
Orca requires no extension configuration either. The extension detects `ORCA_TERMINAL_HANDLE` and creates subagents as unfocused terminal tabs in the current worktree via the `orca` CLI (`ORCA_CLI_COMMAND` is honored when set). New tabs start in the parent Pi's working directory with a bash shell.
|
||||||
|
|
||||||
For tmux:
|
For tmux:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: researcher
|
name: researcher
|
||||||
description: Web researcher — searches the web and synthesizes findings
|
description: Web researcher — searches the web and synthesizes findings
|
||||||
tools: web_search, web_fetch, safe_bash
|
tools: safe_bash
|
||||||
model: openai-codex/gpt-5.6-luna
|
model: openai-codex/gpt-5.6-luna
|
||||||
thinking: xhigh
|
thinking: xhigh
|
||||||
system-prompt: append
|
system-prompt: append
|
||||||
@@ -14,9 +14,9 @@ You operate in an isolated context with no knowledge of any prior conversation.
|
|||||||
|
|
||||||
Process:
|
Process:
|
||||||
1. Break the question into 2-4 searchable facets
|
1. Break the question into 2-4 searchable facets
|
||||||
2. Search with `web_search` using varied angles
|
2. Search with the web using varied angles
|
||||||
3. Read the answers. Identify what's well-covered, what has gaps.
|
3. Read the answers. Identify what's well-covered, what has gaps.
|
||||||
4. For the 2-3 most promising source URLs, use `web_fetch` to get full page content
|
4. For the 2-3 most promising source URLs, use firecrawl to get full page content
|
||||||
5. Synthesize everything into a brief that directly answers the question
|
5. Synthesize everything into a brief that directly answers the question
|
||||||
|
|
||||||
Search strategy — always vary your angles:
|
Search strategy — always vary your angles:
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: worker
|
name: worker
|
||||||
description: General-purpose worker — reads, writes, and edits code
|
description: General-purpose worker — reads, writes, and edits code
|
||||||
tools: read, write, edit, bash, web_search, web_fetch
|
tools: read, write, edit, bash
|
||||||
subagent_agents: scout, researcher
|
subagent_agents: scout, researcher
|
||||||
model: openai-codex/gpt-5.6-luna
|
model: openai-codex/gpt-5.6-luna
|
||||||
thinking: xhigh
|
thinking: xhigh
|
||||||
@@ -26,7 +26,7 @@ Your context is finite. Reading large or unfamiliar codebases directly will burn
|
|||||||
|
|
||||||
You can dispatch:
|
You can dispatch:
|
||||||
- **scout** — read-only recon (read, grep, find, ls). Returns a structured map of files, line ranges, and key snippets. Cheap (haiku). Use for *exploring unfamiliar territory*.
|
- **scout** — read-only recon (read, grep, find, ls). Returns a structured map of files, line ranges, and key snippets. Cheap (haiku). Use for *exploring unfamiliar territory*.
|
||||||
- **researcher** — web research (web_search, web_fetch). Returns a sourced brief. Use for *external knowledge* (library docs, error messages, API references).
|
- **researcher** — web research. Returns a sourced brief. Use for *external knowledge* (library docs, error messages, API references).
|
||||||
|
|
||||||
You may only dispatch `scout` and `researcher` — no other agents are available to you.
|
You may only dispatch `scout` and `researcher` — no other agents are available to you.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Lease the status widget to optional compositors
|
||||||
|
|
||||||
|
Subagents exposes its running-status panel through a structural, process-local interface while retaining ownership of the standalone widget. A host claims a callback-identified lease and must verify it before rendering; runtime replacement invalidates the lease and stale releases cannot affect a newer owner. This avoids a package dependency or shared TUI class identity while preserving running subagents across reload and restoring the standalone panel whenever no valid host owns it.
|
||||||
|
|
||||||
|
The composable surface is `statusWidget.render(width, maxBodyRows?, theme?)`. The optional third argument accepts the small structural `{ fg(color, text) }` theme contract, allowing hosted output to use the current Pi theme without importing a shared Theme class. Standalone rendering passes its factory-provided Pi theme. Calls without a theme use a legacy ANSI fallback. Both hosted and standalone output use a compact unboxed `Subagents <count> running` header followed by capped, width-safe body rows; the cap includes an overflow row but not the header. Status colors remain semantic: active/running warning, stalled error, starting dim, waiting accent, with provider tags and elapsed times dimmed.
|
||||||
Generated
+344
-1352
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -17,8 +17,8 @@
|
|||||||
"test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts"
|
"test:integration": "node --test --test-concurrency=1 test/integration/*.test.ts"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@mariozechner/pi-coding-agent": "*",
|
"@mariozechner/pi-coding-agent": ">=0.68.0",
|
||||||
"@mariozechner/pi-tui": "*",
|
"@mariozechner/pi-tui": ">=0.68.0",
|
||||||
"@sinclair/typebox": "*"
|
"@sinclair/typebox": "*"
|
||||||
},
|
},
|
||||||
"pi": {
|
"pi": {
|
||||||
@@ -27,8 +27,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
"@mariozechner/pi-coding-agent": "^0.68.0",
|
||||||
"@mariozechner/pi-tui": "^0.65.0",
|
"@mariozechner/pi-tui": "^0.68.0",
|
||||||
"@sinclair/typebox": "^0.34.49"
|
"@sinclair/typebox": "^0.34.49"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+585
-182
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Orca surface backend. Subagents run in unfocused terminal tabs in the
|
||||||
|
* current Orca worktree.
|
||||||
|
*
|
||||||
|
* Detection relies on ORCA_TERMINAL_HANDLE, which Orca exports inside its
|
||||||
|
* managed terminals. Inside those terminals `orca` resolves to the Orca CLI
|
||||||
|
* shim; outside them bare `orca` on Linux is usually the GNOME screen reader,
|
||||||
|
* so resolution explicitly rejects /usr/bin/orca.
|
||||||
|
*/
|
||||||
|
import { execFile, execFileSync } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
/** GNOME screen reader path — never a valid Orca CLI. */
|
||||||
|
const GNOME_SCREEN_READER = "/usr/bin/orca";
|
||||||
|
|
||||||
|
let cachedCli: string | null | undefined;
|
||||||
|
|
||||||
|
/** Terminal handles created as whole tabs (vs splits), mapped for tab close. */
|
||||||
|
const tabByHandle = new Map<string, string>();
|
||||||
|
|
||||||
|
interface OrcaResponse {
|
||||||
|
ok?: boolean;
|
||||||
|
error?: { message?: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrcaTerminalCreated extends OrcaResponse {
|
||||||
|
result?: {
|
||||||
|
terminal?: { handle?: unknown; tabId?: unknown };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrcaSplitCreated extends OrcaResponse {
|
||||||
|
result?: {
|
||||||
|
split?: { handle?: unknown };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrcaTerminalRead extends OrcaResponse {
|
||||||
|
result?: {
|
||||||
|
terminal?: { tail?: unknown; status?: unknown };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker echoed by the startup command once the new terminal has cd'd into
|
||||||
|
* the target directory and is about to exec bash. Written with a quote split
|
||||||
|
* (`__PI_SURFACE_''READY__`) so the typed command line on screen never
|
||||||
|
* matches, only the echoed output does.
|
||||||
|
*/
|
||||||
|
const READY_MARKER = "__PI_SURFACE_READY__";
|
||||||
|
|
||||||
|
function resolveCli(): string | null {
|
||||||
|
if (cachedCli !== undefined) return cachedCli;
|
||||||
|
|
||||||
|
const override = process.env.ORCA_CLI_COMMAND?.trim();
|
||||||
|
if (override) {
|
||||||
|
cachedCli = override;
|
||||||
|
return cachedCli;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = execFileSync("sh", ["-c", "command -v orca"], { encoding: "utf8" }).trim();
|
||||||
|
cachedCli = resolved && resolved !== GNOME_SCREEN_READER ? "orca" : null;
|
||||||
|
} catch {
|
||||||
|
cachedCli = null;
|
||||||
|
}
|
||||||
|
return cachedCli;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAvailable(): boolean {
|
||||||
|
return !!process.env.ORCA_TERMINAL_HANDLE && resolveCli() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupHint(): string {
|
||||||
|
return "Start pi inside an Orca-managed terminal with the `orca` CLI on PATH.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOrca(): string {
|
||||||
|
const cli = resolveCli();
|
||||||
|
if (!isAvailable() || !cli) {
|
||||||
|
throw new Error(`Orca is required for this surface. ${setupHint()}`);
|
||||||
|
}
|
||||||
|
return cli;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellEscape(value: string): string {
|
||||||
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Startup command that lands the new terminal in pi's cwd with a bash shell. */
|
||||||
|
function startupCommand(): string {
|
||||||
|
return `cd ${shellEscape(process.cwd())} && echo __PI_SURFACE_''READY__ && exec bash`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleepSync(seconds: number): void {
|
||||||
|
try {
|
||||||
|
execFileSync("sleep", [String(seconds)], { stdio: "ignore" });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe marker proving the shell reads and executes typed input. */
|
||||||
|
const SHELL_UP_MARKER = "__PI_SHELL_UP__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block until the new terminal's shell is genuinely interactive.
|
||||||
|
*
|
||||||
|
* Two races exist at startup: input typed before the initial shell execs bash
|
||||||
|
* is swallowed with it, and input typed while bash itself initializes can be
|
||||||
|
* flushed (observed as stray kitty-keyboard residue like `0u` at the prompt).
|
||||||
|
* So first wait for the startup command's ready marker, then repeatedly send a
|
||||||
|
* probe echo until its output appears — an executed probe also clears any
|
||||||
|
* residue from the readline buffer. Falls through after the deadline as
|
||||||
|
* best-effort.
|
||||||
|
*/
|
||||||
|
function waitForSurfaceReady(surface: string, timeoutMs = 15_000): void {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
if (readScreen(surface, 40).includes(READY_MARKER)) break;
|
||||||
|
} catch {}
|
||||||
|
sleepSync(0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
sendCommand(surface, "echo __PI_SHELL_''UP__");
|
||||||
|
} catch {
|
||||||
|
sleepSync(0.25);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const probeDeadline = Math.min(Date.now() + 2_000, deadline);
|
||||||
|
while (Date.now() < probeDeadline) {
|
||||||
|
sleepSync(0.25);
|
||||||
|
try {
|
||||||
|
if (readScreen(surface, 40).includes(SHELL_UP_MARKER)) return;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResponse<T extends OrcaResponse>(command: string, output: string): T {
|
||||||
|
let response: T;
|
||||||
|
try {
|
||||||
|
response = JSON.parse(output) as T;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Unexpected Orca ${command} output: ${output.trim() || "(empty)"}`);
|
||||||
|
}
|
||||||
|
if (response.ok === false) {
|
||||||
|
const message =
|
||||||
|
typeof response.error?.message === "string" ? response.error.message : "unknown error";
|
||||||
|
throw new Error(`Orca ${command} failed: ${message}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireId(value: unknown, command: string, field: string): string {
|
||||||
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
|
throw new Error(`Orca ${command} did not return ${field}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreatedTerminal(output: string): { handle: string; tabId: string } {
|
||||||
|
const response = parseResponse<OrcaTerminalCreated>("terminal create", output);
|
||||||
|
return {
|
||||||
|
handle: requireId(response.result?.terminal?.handle, "terminal create", "result.terminal.handle"),
|
||||||
|
tabId: requireId(response.result?.terminal?.tabId, "terminal create", "result.terminal.tabId"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCreatedSplit(output: string): string {
|
||||||
|
const response = parseResponse<OrcaSplitCreated>("terminal split", output);
|
||||||
|
return requireId(response.result?.split?.handle, "terminal split", "result.split.handle");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseReadTail(output: string): string {
|
||||||
|
const response = parseResponse<OrcaTerminalRead>("terminal read", output);
|
||||||
|
const status = response.result?.terminal?.status;
|
||||||
|
if (typeof status === "string" && status !== "running") {
|
||||||
|
throw new Error(`Orca terminal read: terminal is not running (status: ${status})`);
|
||||||
|
}
|
||||||
|
const tail = response.result?.terminal?.tail;
|
||||||
|
if (!Array.isArray(tail)) {
|
||||||
|
throw new Error("Orca terminal read did not return result.terminal.tail");
|
||||||
|
}
|
||||||
|
return tail.filter((line): line is string => typeof line === "string").join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurface(name: string): string {
|
||||||
|
const cli = requireOrca();
|
||||||
|
// Target the enclosing terminal's worktree explicitly: pi's cwd (which the
|
||||||
|
// CLI would otherwise resolve against) may live outside any Orca worktree.
|
||||||
|
const worktreeId = process.env.ORCA_WORKTREE_ID;
|
||||||
|
const output = execFileSync(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"terminal",
|
||||||
|
"create",
|
||||||
|
...(worktreeId ? ["--worktree", `id:${worktreeId}`] : []),
|
||||||
|
"--title",
|
||||||
|
name,
|
||||||
|
"--command",
|
||||||
|
startupCommand(),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
const { handle, tabId } = parseCreatedTerminal(output);
|
||||||
|
tabByHandle.set(handle, tabId);
|
||||||
|
waitForSurfaceReady(handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurfaceSplit(
|
||||||
|
name: string,
|
||||||
|
direction: "left" | "right" | "up" | "down",
|
||||||
|
fromSurface?: string,
|
||||||
|
): string {
|
||||||
|
void name;
|
||||||
|
const cli = requireOrca();
|
||||||
|
if (direction === "left" || direction === "up") {
|
||||||
|
throw new Error(`Orca terminal splits only support right or down, not ${direction}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = fromSurface ?? process.env.ORCA_TERMINAL_HANDLE!;
|
||||||
|
const output = execFileSync(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"terminal",
|
||||||
|
"split",
|
||||||
|
"--terminal",
|
||||||
|
source,
|
||||||
|
"--direction",
|
||||||
|
direction === "right" ? "horizontal" : "vertical",
|
||||||
|
"--command",
|
||||||
|
startupCommand(),
|
||||||
|
"--json",
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
const handle = parseCreatedSplit(output);
|
||||||
|
waitForSurfaceReady(handle);
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendCommand(surface: string, command: string): void {
|
||||||
|
const cli = requireOrca();
|
||||||
|
const output = execFileSync(
|
||||||
|
cli,
|
||||||
|
["terminal", "send", "--terminal", surface, "--text", command, "--enter", "--json"],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
parseResponse("terminal send", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScreenArgs(surface: string, lines: number): string[] {
|
||||||
|
return [
|
||||||
|
"terminal",
|
||||||
|
"read",
|
||||||
|
"--terminal",
|
||||||
|
surface,
|
||||||
|
"--screen",
|
||||||
|
"--limit",
|
||||||
|
String(Math.max(1, lines)),
|
||||||
|
"--json",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readScreen(surface: string, lines = 50): string {
|
||||||
|
const cli = requireOrca();
|
||||||
|
const output = execFileSync(cli, readScreenArgs(surface, lines), { encoding: "utf8" });
|
||||||
|
return parseReadTail(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||||
|
const cli = requireOrca();
|
||||||
|
const { stdout } = await execFileAsync(cli, readScreenArgs(surface, lines), {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
return parseReadTail(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSurface(surface: string): void {
|
||||||
|
const cli = requireOrca();
|
||||||
|
const args = ["terminal", "close", "--terminal", surface];
|
||||||
|
if (tabByHandle.has(surface)) args.push("--tab");
|
||||||
|
args.push("--json");
|
||||||
|
const output = execFileSync(cli, args, { encoding: "utf8" });
|
||||||
|
parseResponse("terminal close", output);
|
||||||
|
tabByHandle.delete(surface);
|
||||||
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Backend-neutral terminal surface facade.
|
* Backend-neutral terminal surface facade.
|
||||||
*
|
*
|
||||||
* Herdr is selected when Pi is running in a Herdr-managed pane. tmux remains
|
* Herdr is selected when Pi is running in a Herdr-managed pane, Orca when Pi
|
||||||
* the fallback when Pi is running inside tmux. Selection is automatic so a
|
* is running in an Orca-managed terminal. tmux remains the fallback when Pi is
|
||||||
* process uses one backend consistently for its whole lifetime.
|
* running inside tmux. Selection is automatic so a process uses one backend
|
||||||
|
* consistently for its whole lifetime.
|
||||||
*/
|
*/
|
||||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import * as herdr from "./herdr.ts";
|
import * as herdr from "./herdr.ts";
|
||||||
|
import * as orca from "./orca.ts";
|
||||||
import * as tmux from "./tmux.ts";
|
import * as tmux from "./tmux.ts";
|
||||||
|
|
||||||
export type MuxBackend = "herdr" | "tmux";
|
export type MuxBackend = "herdr" | "orca" | "tmux";
|
||||||
type Direction = "left" | "right" | "up" | "down";
|
type Direction = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
type SurfaceBackend = {
|
type SurfaceBackend = {
|
||||||
@@ -25,20 +27,27 @@ type SurfaceBackend = {
|
|||||||
|
|
||||||
export function selectMuxBackend(availability: {
|
export function selectMuxBackend(availability: {
|
||||||
herdr: boolean;
|
herdr: boolean;
|
||||||
|
orca: boolean;
|
||||||
tmux: boolean;
|
tmux: boolean;
|
||||||
}): MuxBackend | null {
|
}): MuxBackend | null {
|
||||||
if (availability.herdr) return "herdr";
|
if (availability.herdr) return "herdr";
|
||||||
|
if (availability.orca) return "orca";
|
||||||
if (availability.tmux) return "tmux";
|
if (availability.tmux) return "tmux";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMuxBackend(): MuxBackend | null {
|
export function getMuxBackend(): MuxBackend | null {
|
||||||
return selectMuxBackend({ herdr: herdr.isAvailable(), tmux: tmux.isAvailable() });
|
return selectMuxBackend({
|
||||||
|
herdr: herdr.isAvailable(),
|
||||||
|
orca: orca.isAvailable(),
|
||||||
|
tmux: tmux.isAvailable(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBackend(): SurfaceBackend {
|
function getBackend(): SurfaceBackend {
|
||||||
const backend = getMuxBackend();
|
const backend = getMuxBackend();
|
||||||
if (backend === "herdr") return herdr;
|
if (backend === "herdr") return herdr;
|
||||||
|
if (backend === "orca") return orca;
|
||||||
if (backend === "tmux") return tmux;
|
if (backend === "tmux") return tmux;
|
||||||
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
|
throw new Error(`No supported terminal multiplexer is available. ${muxSetupHint()}`);
|
||||||
}
|
}
|
||||||
@@ -49,8 +58,9 @@ export function isMuxAvailable(): boolean {
|
|||||||
|
|
||||||
export function muxSetupHint(): string {
|
export function muxSetupHint(): string {
|
||||||
if (process.env.HERDR_ENV === "1") return herdr.setupHint();
|
if (process.env.HERDR_ENV === "1") return herdr.setupHint();
|
||||||
|
if (process.env.ORCA_TERMINAL_HANDLE) return orca.setupHint();
|
||||||
if (process.env.TMUX) return tmux.setupHint();
|
if (process.env.TMUX) return tmux.setupHint();
|
||||||
return "Start pi inside Herdr or tmux.";
|
return "Start pi inside Herdr, Orca, or tmux.";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSurface(name: string): string {
|
export function createSurface(name: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
name: test-fork
|
||||||
|
description: Integration test agent — fork-mode child that inherits the parent session
|
||||||
|
model: anthropic/claude-haiku-4-5
|
||||||
|
tools: read, bash
|
||||||
|
session-mode: fork
|
||||||
|
auto-exit: true
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a test agent. Complete the task given to you immediately. Be direct and concise.
|
||||||
|
When asked to write content to a file, do it right away using the bash tool.
|
||||||
|
Do not ask questions. Do not explain. Just execute the task.
|
||||||
@@ -73,13 +73,14 @@ const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.
|
|||||||
|
|
||||||
// ── Configuration ──
|
// ── Configuration ──
|
||||||
|
|
||||||
/** Model used for integration tests. Override with PI_TEST_MODEL env var. */
|
/**
|
||||||
const activePiModel =
|
* Model used for integration tests. Override with PI_TEST_MODEL env var.
|
||||||
process.env.PI_PROVIDER && process.env.PI_MODEL
|
*
|
||||||
? `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}`
|
* The default must be a provider that exists under `pi -ne`: startPi() launches
|
||||||
: undefined;
|
* with extensions disabled, so extension-registered providers (and therefore
|
||||||
export const TEST_MODEL =
|
* the parent session's active model) may not resolve in the child.
|
||||||
process.env.PI_TEST_MODEL ?? activePiModel ?? "anthropic/claude-haiku-4-5";
|
*/
|
||||||
|
export const TEST_MODEL = process.env.PI_TEST_MODEL ?? "openai-codex/gpt-5.6-luna";
|
||||||
|
|
||||||
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
|
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
|
||||||
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
|
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
|
||||||
@@ -97,7 +98,42 @@ function parseHerdrJson(command: string, args: string[]): any {
|
|||||||
return JSON.parse(output);
|
return JSON.parse(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function orcaCli(): string {
|
||||||
|
return process.env.ORCA_CLI_COMMAND?.trim() || "orca";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrcaJson(args: string[]): any {
|
||||||
|
const output = execFileSync(orcaCli(), args, { encoding: "utf8" });
|
||||||
|
const response = JSON.parse(output);
|
||||||
|
if (response.ok === false) {
|
||||||
|
throw new Error(`Orca ${args.slice(0, 2).join(" ")} failed: ${response.error?.message}`);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-first search of an Orca visual-layout pane tree for the active terminal. */
|
||||||
|
function findActiveOrcaTerminal(node: any): string | null {
|
||||||
|
if (!node || typeof node !== "object") return null;
|
||||||
|
if (node.type === "terminal") return node.active ? node.handle ?? null : null;
|
||||||
|
for (const child of Object.values(node)) {
|
||||||
|
if (Array.isArray(child)) {
|
||||||
|
for (const item of child) {
|
||||||
|
const found = findActiveOrcaTerminal(item);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
} else if (child && typeof child === "object") {
|
||||||
|
const found = findActiveOrcaTerminal(child);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function focusSurface(surface: string): void {
|
export function focusSurface(surface: string): void {
|
||||||
|
if (getMuxBackend() === "orca") {
|
||||||
|
parseOrcaJson(["terminal", "switch", "--terminal", surface, "--json"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (getMuxBackend() === "herdr") {
|
if (getMuxBackend() === "herdr") {
|
||||||
const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
|
const pane = parseHerdrJson("herdr", ["pane", "get", surface]);
|
||||||
const tabId = pane.result?.pane?.tab_id;
|
const tabId = pane.result?.pane?.tab_id;
|
||||||
@@ -110,6 +146,19 @@ export function focusSurface(surface: string): void {
|
|||||||
|
|
||||||
export function getFocusedSurface(): string | null {
|
export function getFocusedSurface(): string | null {
|
||||||
try {
|
try {
|
||||||
|
if (getMuxBackend() === "orca") {
|
||||||
|
const response = parseOrcaJson(["terminal", "list", "--include-visual-layouts", "--json"]);
|
||||||
|
const layouts = response.result?.visualLayouts ?? [];
|
||||||
|
for (const layout of layouts) {
|
||||||
|
const root = layout.root;
|
||||||
|
if (!root) continue;
|
||||||
|
const activeTab = root.tabs?.find((tab: any) => tab.tabId === root.activeTabId);
|
||||||
|
const handle = findActiveOrcaTerminal(activeTab?.panes);
|
||||||
|
if (handle) return handle;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (getMuxBackend() === "herdr") {
|
if (getMuxBackend() === "herdr") {
|
||||||
const workspace = process.env.HERDR_WORKSPACE_ID;
|
const workspace = process.env.HERDR_WORKSPACE_ID;
|
||||||
if (!workspace) return null;
|
if (!workspace) return null;
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Integration tests for the full subagent lifecycle.
|
* Integration tests for the full subagent lifecycle.
|
||||||
*
|
*
|
||||||
* These tests spawn REAL pi sessions with REAL LLM calls (haiku by default).
|
* These tests spawn REAL pi sessions with REAL LLM calls (gpt-5.6-luna by default).
|
||||||
* Each test creates a terminal surface, runs pi with a task that uses the subagent
|
* Each test creates a terminal surface, runs pi with a task that uses the subagent
|
||||||
* tool, and verifies the outcome via marker files and screen output.
|
* tool, and verifies the outcome via marker files and screen output.
|
||||||
*
|
*
|
||||||
* Costs: ~$0.01-0.05 per test run (haiku).
|
* Costs: ~$0.01-0.05 per test run.
|
||||||
* Duration: ~30-90s per test.
|
* Duration: ~30-90s per test.
|
||||||
*
|
*
|
||||||
* Run inside Herdr, or inside tmux:
|
* Run inside Herdr, inside an Orca terminal, or inside tmux:
|
||||||
* tmux new 'npm run test:integration'
|
* tmux new 'npm run test:integration'
|
||||||
*
|
*
|
||||||
* Configuration:
|
* Configuration:
|
||||||
* PI_TEST_MODEL — model for all pi sessions (default: anthropic/claude-haiku-4-5)
|
* PI_TEST_MODEL — model for all pi sessions (default: openai-codex/gpt-5.6-luna)
|
||||||
* PI_TEST_TIMEOUT — per-test timeout in ms (default: 120000)
|
* PI_TEST_TIMEOUT — per-test timeout in ms (default: 120000)
|
||||||
*/
|
*/
|
||||||
import { describe, it, before, after } from "node:test";
|
import { describe, it, before, after } from "node:test";
|
||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
uniqueId,
|
uniqueId,
|
||||||
trackTempFile,
|
trackTempFile,
|
||||||
readScreen,
|
readScreen,
|
||||||
|
sendCommand,
|
||||||
PI_TIMEOUT,
|
PI_TIMEOUT,
|
||||||
type TestEnv,
|
type TestEnv,
|
||||||
} from "./harness.ts";
|
} from "./harness.ts";
|
||||||
@@ -37,8 +38,8 @@ import {
|
|||||||
const backends = getAvailableBackends();
|
const backends = getAvailableBackends();
|
||||||
|
|
||||||
if (backends.length === 0) {
|
if (backends.length === 0) {
|
||||||
console.log("⚠️ Herdr/tmux is not available — skipping subagent lifecycle integration tests");
|
console.log("⚠️ Herdr/Orca/tmux is not available — skipping subagent lifecycle integration tests");
|
||||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const backend of backends) {
|
for (const backend of backends) {
|
||||||
@@ -103,6 +104,57 @@ for (const backend of backends) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Reload preservation ──
|
||||||
|
|
||||||
|
it("keeps a running subagent and fresh tool wrappers across parent extension reload", async () => {
|
||||||
|
const id = uniqueId();
|
||||||
|
const startedFile = `/tmp/pi-integ-reload-start-${id}.txt`;
|
||||||
|
const completedFile = `/tmp/pi-integ-reload-done-${id}.txt`;
|
||||||
|
const followupFile = `/tmp/pi-integ-reload-followup-${id}.txt`;
|
||||||
|
trackTempFile(env, startedFile);
|
||||||
|
trackTempFile(env, completedFile);
|
||||||
|
trackTempFile(env, followupFile);
|
||||||
|
|
||||||
|
const surface = createTrackedSurface(env, `reload-${id}`);
|
||||||
|
await sleep(1000);
|
||||||
|
|
||||||
|
const task = [
|
||||||
|
`Call the subagent tool once with these EXACT parameters:`,
|
||||||
|
` name: "Reload-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` task: "Run: echo 'START_${id}' > '${startedFile}'; sleep 10; echo 'DONE_${id}' > '${completedFile}'"`,
|
||||||
|
`Do not run the command yourself.`,
|
||||||
|
`After that subagent result arrives, call the subagent tool once more with these EXACT parameters:`,
|
||||||
|
` name: "Reload-followup-${id}"`,
|
||||||
|
` agent: "test-echo"`,
|
||||||
|
` task: "Write 'FOLLOWUP_${id}' to '${followupFile}'"`,
|
||||||
|
`After the second subagent result arrives, say RELOAD_SURVIVED_${id}.`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
startPi(surface, env.dir, task);
|
||||||
|
await waitForFile(startedFile, PI_TIMEOUT, /START_/);
|
||||||
|
|
||||||
|
sendCommand(surface, "/reload");
|
||||||
|
|
||||||
|
const completed = await waitForFile(completedFile, PI_TIMEOUT, /DONE_/);
|
||||||
|
assert.ok(completed.includes(`DONE_${id}`), "child should finish after parent reload");
|
||||||
|
const followup = await waitForFile(followupFile, PI_TIMEOUT, /FOLLOWUP_/);
|
||||||
|
assert.ok(
|
||||||
|
followup.includes(`FOLLOWUP_${id}`),
|
||||||
|
"the result-triggered turn should spawn through the fresh tool wrapper",
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultMarker = `RELOAD_SURVIVED_${id}`;
|
||||||
|
const responseLine = new RegExp(`^ ${resultMarker}\\s*$`, "m");
|
||||||
|
const screen = await waitForScreen(surface, responseLine, PI_TIMEOUT, 300);
|
||||||
|
assert.equal(
|
||||||
|
screen.match(new RegExp(`^ ${resultMarker}\\s*$`, "gm"))?.length,
|
||||||
|
1,
|
||||||
|
"the fresh runtime should receive the completion exactly once",
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(screen, /Subagent cancelled|ctx is stale after session replacement or reload/i);
|
||||||
|
});
|
||||||
|
|
||||||
// ── In-progress activity snapshots ──
|
// ── In-progress activity snapshots ──
|
||||||
|
|
||||||
it("keeps a long active tool call from surfacing false stalled status", async () => {
|
it("keeps a long active tool call from surfacing false stalled status", async () => {
|
||||||
@@ -201,9 +253,9 @@ for (const backend of backends) {
|
|||||||
const task = [
|
const task = [
|
||||||
`Call the subagent tool with these EXACT parameters:`,
|
`Call the subagent tool with these EXACT parameters:`,
|
||||||
` name: "Fork-${id}"`,
|
` name: "Fork-${id}"`,
|
||||||
` fork: true`,
|
` agent: "test-fork"`,
|
||||||
` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`,
|
` task: "Run this bash command: echo 'FORK_OK_${id}' > '${markerFile}'"`,
|
||||||
`Do not set the agent parameter. Just set name, fork, and task.`,
|
`Do not do anything else. Just call the subagent tool once.`,
|
||||||
`After you receive the result, say FORK_COMPLETE.`,
|
`After you receive the result, say FORK_COMPLETE.`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Integration tests for the selected terminal-surface backend.
|
* Integration tests for the selected terminal-surface backend.
|
||||||
*
|
*
|
||||||
* These tests exercise real Herdr or tmux operations: creating surfaces,
|
* These tests exercise real Herdr, Orca, or tmux operations: creating surfaces,
|
||||||
* sending commands, reading screen output, and closing panes.
|
* sending commands, reading screen output, and closing panes.
|
||||||
* No LLM calls — fast and free.
|
* No LLM calls — fast and free.
|
||||||
*
|
*
|
||||||
* Run inside Herdr, or inside tmux:
|
* Run inside Herdr, inside an Orca terminal, or inside tmux:
|
||||||
* tmux new 'npm run test:integration'
|
* tmux new 'npm run test:integration'
|
||||||
*/
|
*/
|
||||||
import { describe, it, before, after } from "node:test";
|
import { describe, it, before, after } from "node:test";
|
||||||
@@ -39,8 +39,8 @@ const backends = getAvailableBackends();
|
|||||||
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
|
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
|
||||||
|
|
||||||
if (backends.length === 0) {
|
if (backends.length === 0) {
|
||||||
console.log("⚠️ Herdr/tmux is not available — skipping surface integration tests");
|
console.log("⚠️ Herdr/Orca/tmux is not available — skipping surface integration tests");
|
||||||
console.log(" Run inside Herdr or tmux to enable these tests.");
|
console.log(" Run inside Herdr, Orca, or tmux to enable these tests.");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const backend of backends) {
|
for (const backend of backends) {
|
||||||
|
|||||||
+527
-7
@@ -36,6 +36,11 @@ import {
|
|||||||
shellEscape,
|
shellEscape,
|
||||||
} from "../pi-extension/subagents/surface.ts";
|
} from "../pi-extension/subagents/surface.ts";
|
||||||
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
||||||
|
import {
|
||||||
|
parseCreatedSplit,
|
||||||
|
parseCreatedTerminal,
|
||||||
|
parseReadTail,
|
||||||
|
} from "../pi-extension/subagents/orca.ts";
|
||||||
import {
|
import {
|
||||||
advanceStatusState,
|
advanceStatusState,
|
||||||
capStatusLines,
|
capStatusLines,
|
||||||
@@ -88,16 +93,22 @@ function createMockExtensionApi() {
|
|||||||
const registeredTools: Array<any> = [];
|
const registeredTools: Array<any> = [];
|
||||||
const registeredCommands: Array<any> = [];
|
const registeredCommands: Array<any> = [];
|
||||||
const registeredMessageRenderers: Array<any> = [];
|
const registeredMessageRenderers: Array<any> = [];
|
||||||
|
const eventHandlers = new Map<string, Array<(event: any, ctx: any) => any>>();
|
||||||
const sentUserMessages: string[] = [];
|
const sentUserMessages: string[] = [];
|
||||||
const sentMessages: Array<any> = [];
|
const sentMessages: Array<any> = [];
|
||||||
return {
|
return {
|
||||||
registeredTools,
|
registeredTools,
|
||||||
registeredCommands,
|
registeredCommands,
|
||||||
registeredMessageRenderers,
|
registeredMessageRenderers,
|
||||||
|
eventHandlers,
|
||||||
sentUserMessages,
|
sentUserMessages,
|
||||||
sentMessages,
|
sentMessages,
|
||||||
api: {
|
api: {
|
||||||
on() {},
|
on(event: string, handler: (event: any, ctx: any) => any) {
|
||||||
|
const handlers = eventHandlers.get(event) ?? [];
|
||||||
|
handlers.push(handler);
|
||||||
|
eventHandlers.set(event, handlers);
|
||||||
|
},
|
||||||
registerTool(tool: any) {
|
registerTool(tool: any) {
|
||||||
registeredTools.push(tool);
|
registeredTools.push(tool);
|
||||||
},
|
},
|
||||||
@@ -117,10 +128,55 @@ function createMockExtensionApi() {
|
|||||||
getAllTools() {
|
getAllTools() {
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
getActiveTools() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
} as any,
|
} as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function emitMockEvent(
|
||||||
|
mock: ReturnType<typeof createMockExtensionApi>,
|
||||||
|
event: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
ctx: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
for (const handler of mock.eventHandlers.get(event) ?? []) {
|
||||||
|
await handler({ type: event, ...payload }, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SharedRuntimeTestState = {
|
||||||
|
runningSubagents: Map<string, unknown>;
|
||||||
|
latestCtx: unknown;
|
||||||
|
latestPi: unknown;
|
||||||
|
widgetInterval: ReturnType<typeof setInterval> | null;
|
||||||
|
statusInterval: ReturnType<typeof setInterval> | null;
|
||||||
|
standaloneWidgetSyncScheduled: boolean;
|
||||||
|
pendingDeliveries: { length: number };
|
||||||
|
deliveryFlushScheduled: boolean;
|
||||||
|
activeRuntimeToken: symbol | null;
|
||||||
|
registeredRuntimeToken: symbol | null;
|
||||||
|
statusWidgetClaim: unknown;
|
||||||
|
statusWidget?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetSharedRuntimeForTest(shared: SharedRuntimeTestState): void {
|
||||||
|
if (shared.widgetInterval) clearInterval(shared.widgetInterval);
|
||||||
|
if (shared.statusInterval) clearInterval(shared.statusInterval);
|
||||||
|
shared.widgetInterval = null;
|
||||||
|
shared.statusInterval = null;
|
||||||
|
shared.standaloneWidgetSyncScheduled = false;
|
||||||
|
shared.runningSubagents.clear();
|
||||||
|
shared.latestCtx = null;
|
||||||
|
shared.latestPi = null;
|
||||||
|
shared.statusWidgetClaim = null;
|
||||||
|
shared.pendingDeliveries.length = 0;
|
||||||
|
shared.deliveryFlushScheduled = false;
|
||||||
|
shared.activeRuntimeToken = null;
|
||||||
|
shared.registeredRuntimeToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
function restoreEnvVar(name: string, value: string | undefined) {
|
function restoreEnvVar(name: string, value: string | undefined) {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
delete process.env[name];
|
delete process.env[name];
|
||||||
@@ -1838,6 +1894,79 @@ describe("commands", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reload lifecycle", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||||
|
const ctx = { hasUI: false, ui: { setWidget() {} } };
|
||||||
|
|
||||||
|
beforeEach(() => resetSharedRuntimeForTest(shared));
|
||||||
|
after(() => resetSharedRuntimeForTest(shared));
|
||||||
|
|
||||||
|
it("preserves a live subagent and delivers its queued result through the fresh runtime", async () => {
|
||||||
|
const oldRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(oldRuntime.api);
|
||||||
|
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, ctx);
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const running: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
task: string;
|
||||||
|
surface: string;
|
||||||
|
startTime: number;
|
||||||
|
sessionFile: string;
|
||||||
|
statusState: ReturnType<typeof createStatusState>;
|
||||||
|
interactive: boolean;
|
||||||
|
abortController: AbortController;
|
||||||
|
suppressDelivery?: boolean;
|
||||||
|
} = {
|
||||||
|
id: "reload-child",
|
||||||
|
name: "reload-child",
|
||||||
|
task: "keep running",
|
||||||
|
surface: "surface-1",
|
||||||
|
startTime: Date.now(),
|
||||||
|
sessionFile: "/tmp/reload-child.jsonl",
|
||||||
|
statusState: createStatusState({ source: "pi", startTimeMs: Date.now() }),
|
||||||
|
interactive: false,
|
||||||
|
abortController,
|
||||||
|
};
|
||||||
|
shared.runningSubagents.set(running.id, running);
|
||||||
|
|
||||||
|
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, ctx);
|
||||||
|
assert.equal(abortController.signal.aborted, false, "reload must not cancel the child");
|
||||||
|
assert.equal(shared.runningSubagents.get(running.id), running);
|
||||||
|
|
||||||
|
testApi.sendWhenRuntimeReady((pi: any) => {
|
||||||
|
pi.sendMessage({ customType: "subagent_result", content: "done", display: true });
|
||||||
|
});
|
||||||
|
assert.equal(oldRuntime.sentMessages.length, 0, "the stale runtime must not receive completion");
|
||||||
|
assert.equal(shared.pendingDeliveries.length, 1);
|
||||||
|
|
||||||
|
const freshRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(freshRuntime.api);
|
||||||
|
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, ctx);
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.equal(freshRuntime.sentMessages.length, 1);
|
||||||
|
assert.equal(freshRuntime.sentMessages[0].message.customType, "subagent_result");
|
||||||
|
assert.equal(oldRuntime.sentMessages.length, 0);
|
||||||
|
assert.equal(shared.pendingDeliveries.length, 0);
|
||||||
|
assert.equal(shared.runningSubagents.get(running.id), running);
|
||||||
|
|
||||||
|
testApi.flushPendingDeliveries();
|
||||||
|
assert.equal(freshRuntime.sentMessages.length, 1, "a queued completion must flush exactly once");
|
||||||
|
|
||||||
|
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "quit" }, ctx);
|
||||||
|
assert.equal(abortController.signal.aborted, false, "a stale runtime cannot cancel rebound state");
|
||||||
|
assert.equal(shared.runningSubagents.get(running.id), running);
|
||||||
|
|
||||||
|
await emitMockEvent(freshRuntime, "session_shutdown", { reason: "quit" }, ctx);
|
||||||
|
assert.equal(abortController.signal.aborted, true, "real shutdown still cancels the child");
|
||||||
|
assert.equal(running.suppressDelivery, true);
|
||||||
|
assert.equal(shared.runningSubagents.size, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("tool registration", () => {
|
describe("tool registration", () => {
|
||||||
it("always resumes subagents as autonomous (auto-exit, non-interactive tracking)", () => {
|
it("always resumes subagents as autonomous (auto-exit, non-interactive tracking)", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
@@ -2489,6 +2618,7 @@ describe("subagent startup delay", () => {
|
|||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
|
assert.equal(testApi.defaultShellReadyDelayMs("tmux"), 500);
|
||||||
assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
|
assert.equal(testApi.defaultShellReadyDelayMs("herdr"), 1000);
|
||||||
|
assert.equal(testApi.defaultShellReadyDelayMs("orca"), 500);
|
||||||
assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
|
assert.equal(testApi.defaultShellReadyDelayMs(null), 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2508,6 +2638,71 @@ describe("subagent startup delay", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe("subagents widget rendering", () => {
|
describe("subagents widget rendering", () => {
|
||||||
|
function createThemeSpy() {
|
||||||
|
const calls: Array<{ color: string; text: string }> = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
theme: {
|
||||||
|
fg(color: string, text: string) {
|
||||||
|
calls.push({ color, text });
|
||||||
|
return `\x1b[38;5;${calls.length}m${text}\x1b[0m`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWidgetAgent(
|
||||||
|
id: string,
|
||||||
|
kind: "active" | "running" | "stalled" | "starting" | "waiting",
|
||||||
|
) {
|
||||||
|
const startTime = kind === "starting" ? 1_061_000 - 5_000 : 1_000_000 - 5_000;
|
||||||
|
let statusState = createStatusState({
|
||||||
|
source: kind === "running" ? "claude" : "pi",
|
||||||
|
startTimeMs: startTime,
|
||||||
|
});
|
||||||
|
if (kind === "active" || kind === "waiting") {
|
||||||
|
statusState = observeStatus(
|
||||||
|
statusState,
|
||||||
|
{
|
||||||
|
snapshot: "present",
|
||||||
|
updatedAt: startTime + 1_000,
|
||||||
|
sequence: 1,
|
||||||
|
phase: kind,
|
||||||
|
...(kind === "active"
|
||||||
|
? {
|
||||||
|
active: true,
|
||||||
|
activeScope: "tool",
|
||||||
|
activeSince: startTime + 1_000,
|
||||||
|
activityLabel: "bash",
|
||||||
|
}
|
||||||
|
: { waitingSince: startTime + 1_000 }),
|
||||||
|
},
|
||||||
|
startTime + 1_000,
|
||||||
|
);
|
||||||
|
} else if (kind === "stalled") {
|
||||||
|
statusState = observeStatus(
|
||||||
|
statusState,
|
||||||
|
{ snapshot: "missing" },
|
||||||
|
startTime + 1_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
agent: "worker",
|
||||||
|
task: "",
|
||||||
|
surface: "surface",
|
||||||
|
startTime,
|
||||||
|
sessionFile: `${id}.jsonl`,
|
||||||
|
statusState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActiveWidgetAgent(id: string) {
|
||||||
|
return createWidgetAgent(id, "active");
|
||||||
|
}
|
||||||
|
|
||||||
it("keeps every rendered line within a very narrow width", () => {
|
it("keeps every rendered line within a very narrow width", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||||
@@ -2548,13 +2743,33 @@ describe("subagents widget rendering", () => {
|
|||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
lines.map((line: string) => visibleWidth(line)),
|
lines.map((line: string) => visibleWidth(line)),
|
||||||
[16, 16, 16, 16, 16],
|
[16, 16, 16, 16],
|
||||||
);
|
);
|
||||||
|
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||||
} finally {
|
} finally {
|
||||||
Date.now = originalNow;
|
Date.now = originalNow;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sanitizes whitespace in agent names before rendering rows", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const startTime = Date.now() - 5_000;
|
||||||
|
const lines = testApi.renderSubagentWidgetLines([
|
||||||
|
{
|
||||||
|
id: "unsafe",
|
||||||
|
name: "worker\nwith\ttabs",
|
||||||
|
task: "",
|
||||||
|
surface: "s1",
|
||||||
|
startTime,
|
||||||
|
sessionFile: "sess1",
|
||||||
|
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
|
||||||
|
},
|
||||||
|
], 80);
|
||||||
|
|
||||||
|
assert.ok(lines.every((line: string) => !/[\r\n]/.test(line)));
|
||||||
|
assert.match(lines[1] ?? "", /worker with tabs/);
|
||||||
|
});
|
||||||
|
|
||||||
it("truncates the right-hand status instead of overflowing when it alone is too wide", () => {
|
it("truncates the right-hand status instead of overflowing when it alone is too wide", () => {
|
||||||
const testApi = (subagentsModule as any).__test__;
|
const testApi = (subagentsModule as any).__test__;
|
||||||
assert.ok(testApi, "expected subagents test helpers to be exported");
|
assert.ok(testApi, "expected subagents test helpers to be exported");
|
||||||
@@ -2592,6 +2807,276 @@ describe("subagents widget rendering", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the standalone widget factory theme for semantic status colors", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const widgetCalls: any[] = [];
|
||||||
|
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||||
|
shared.latestCtx = {
|
||||||
|
hasUI: true,
|
||||||
|
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||||
|
};
|
||||||
|
shared.runningSubagents.set("active", createActiveWidgetAgent("active"));
|
||||||
|
|
||||||
|
testApi.updateWidget();
|
||||||
|
const { theme, calls } = createThemeSpy();
|
||||||
|
const component = widgetCalls[0][1](undefined, theme);
|
||||||
|
const lines = withMockedNow(1_000_000, () => component.render(80));
|
||||||
|
|
||||||
|
assert.equal(lines.length, 2, "header plus one body row");
|
||||||
|
assert.equal(calls.find((call) => call.text === "Subagents")?.color, "accent");
|
||||||
|
assert.equal(calls.find((call) => call.text === "1 running")?.color, "muted");
|
||||||
|
assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active")));
|
||||||
|
assert.equal(calls.find((call) => call.text === " (worker)")?.color, "dim");
|
||||||
|
assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05"));
|
||||||
|
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the optional theme passed to hosted rendering", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||||
|
shared.runningSubagents.set("active", createActiveWidgetAgent("active"));
|
||||||
|
const { theme, calls } = createThemeSpy();
|
||||||
|
|
||||||
|
const lines = withMockedNow(1_000_000, () => testApi.statusWidget.render(80, undefined, theme));
|
||||||
|
|
||||||
|
assert.equal(lines.length, 2, "header plus one body row");
|
||||||
|
assert.ok(calls.some((call) => call.color === "accent" && call.text === "Subagents"));
|
||||||
|
assert.ok(calls.some((call) => call.color === "muted" && call.text === "1 running"));
|
||||||
|
assert.ok(calls.some((call) => call.color === "warning" && call.text.includes("active")));
|
||||||
|
assert.ok(calls.some((call) => call.color === "dim" && call.text === "00:05"));
|
||||||
|
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps every status kind to a semantic row color", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||||
|
for (const [id, kind] of [
|
||||||
|
["active", "active"],
|
||||||
|
["running", "running"],
|
||||||
|
["stalled", "stalled"],
|
||||||
|
["starting", "starting"],
|
||||||
|
["waiting", "waiting"],
|
||||||
|
] as const) {
|
||||||
|
shared.runningSubagents.set(id, createWidgetAgent(id, kind));
|
||||||
|
}
|
||||||
|
const { theme, calls } = createThemeSpy();
|
||||||
|
|
||||||
|
withMockedNow(1_061_000, () => testApi.statusWidget.render(120, undefined, theme));
|
||||||
|
|
||||||
|
const expected = {
|
||||||
|
active: "warning",
|
||||||
|
running: "warning",
|
||||||
|
stalled: "error",
|
||||||
|
starting: "dim",
|
||||||
|
waiting: "accent",
|
||||||
|
} as const;
|
||||||
|
for (const [id, kind] of [
|
||||||
|
["active", "active"],
|
||||||
|
["running", "running"],
|
||||||
|
["stalled", "stalled"],
|
||||||
|
["starting", "starting"],
|
||||||
|
["waiting", "waiting"],
|
||||||
|
] as const) {
|
||||||
|
assert.ok(
|
||||||
|
calls.some((call) => call.text === id && call.color === expected[kind]),
|
||||||
|
`${id} should use ${expected[kind]}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("status widget host integration", () => {
|
||||||
|
const testApi = (subagentsModule as any).__test__;
|
||||||
|
const statusWidget = (globalThis as any).__pi_interactive_subagents.statusWidget;
|
||||||
|
const shared = testApi.sharedRuntime as SharedRuntimeTestState;
|
||||||
|
|
||||||
|
function addRunning(id: string) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
shared.runningSubagents.set(id, {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
task: "",
|
||||||
|
surface: "surface",
|
||||||
|
startTime,
|
||||||
|
sessionFile: `${id}.jsonl`,
|
||||||
|
statusState: createStatusState({ source: "pi", startTimeMs: startTime }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => resetSharedRuntimeForTest(shared));
|
||||||
|
after(() => resetSharedRuntimeForTest(shared));
|
||||||
|
|
||||||
|
it("falls back to the standalone widget when unclaimed", () => {
|
||||||
|
const widgetCalls: any[] = [];
|
||||||
|
shared.latestCtx = {
|
||||||
|
hasUI: true,
|
||||||
|
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||||
|
};
|
||||||
|
addRunning("worker");
|
||||||
|
|
||||||
|
testApi.updateWidget();
|
||||||
|
|
||||||
|
assert.equal(widgetCalls.length, 1);
|
||||||
|
assert.equal(widgetCalls[0][0], "subagent-status");
|
||||||
|
assert.equal(typeof widgetCalls[0][1], "function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses, notifies, and restores the standalone widget around a claim", async () => {
|
||||||
|
const widgetCalls: any[] = [];
|
||||||
|
shared.latestCtx = {
|
||||||
|
hasUI: true,
|
||||||
|
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||||
|
};
|
||||||
|
addRunning("worker");
|
||||||
|
testApi.updateWidget();
|
||||||
|
|
||||||
|
let changes = 0;
|
||||||
|
const onChange = () => changes++;
|
||||||
|
const release = statusWidget.claim(onChange);
|
||||||
|
assert.equal(statusWidget.isClaimed(onChange), true);
|
||||||
|
assert.equal(statusWidget.isClaimed(() => {}), false);
|
||||||
|
assert.equal(typeof widgetCalls.at(-1)[1], "function", "claim sync is deferred");
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(widgetCalls.at(-1)[1], undefined);
|
||||||
|
|
||||||
|
testApi.updateWidget();
|
||||||
|
assert.equal(changes, 1);
|
||||||
|
|
||||||
|
release();
|
||||||
|
assert.equal(statusWidget.isClaimed(onChange), false);
|
||||||
|
assert.equal(widgetCalls.at(-1)[1], undefined, "release sync is deferred");
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(typeof widgetCalls.at(-1)[1], "function");
|
||||||
|
release();
|
||||||
|
assert.equal(widgetCalls.length, 4, "release is idempotent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore standalone UI between an immediate release and re-claim", async () => {
|
||||||
|
const widgetCalls: any[] = [];
|
||||||
|
shared.latestCtx = {
|
||||||
|
hasUI: true,
|
||||||
|
ui: { setWidget: (...args: any[]) => widgetCalls.push(args) },
|
||||||
|
};
|
||||||
|
addRunning("worker");
|
||||||
|
testApi.updateWidget();
|
||||||
|
|
||||||
|
const firstChange = () => {};
|
||||||
|
const releaseFirst = statusWidget.claim(firstChange);
|
||||||
|
releaseFirst();
|
||||||
|
const secondChange = () => {};
|
||||||
|
const releaseSecond = statusWidget.claim(secondChange);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.equal(statusWidget.isClaimed(firstChange), false);
|
||||||
|
assert.equal(statusWidget.isClaimed(secondChange), true);
|
||||||
|
assert.equal(widgetCalls.at(-1)[1], undefined);
|
||||||
|
releaseSecond();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(typeof widgetCalls.at(-1)[1], "function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps body rows with an honest overflow row", () => {
|
||||||
|
addRunning("worker");
|
||||||
|
addRunning("scout");
|
||||||
|
addRunning("reviewer");
|
||||||
|
|
||||||
|
const lines = statusWidget.render(40, 2);
|
||||||
|
assert.equal(lines.length, 3, "header + one agent + overflow");
|
||||||
|
assert.ok(lines.every((line: string) => !/[╭╮╰╯│]/.test(line)));
|
||||||
|
assert.match(lines[2], /\+2 more running\./);
|
||||||
|
assert.equal(statusWidget.getBodyRowCount(), 3);
|
||||||
|
assert.equal(statusWidget.hasContent(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps capped output within narrow widths", () => {
|
||||||
|
addRunning("worker");
|
||||||
|
addRunning("scout");
|
||||||
|
|
||||||
|
for (const width of [0, 1, 2, 4, 8]) {
|
||||||
|
for (const line of statusWidget.render(width, 1)) {
|
||||||
|
assert.ok(
|
||||||
|
visibleWidth(line) <= width,
|
||||||
|
`expected line width <= ${width}, got ${visibleWidth(line)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops old claims during reload and allows a fresh runtime to claim", async () => {
|
||||||
|
const oldRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(oldRuntime.api);
|
||||||
|
const oldCtx = {
|
||||||
|
hasUI: true,
|
||||||
|
ui: { setWidget() {} },
|
||||||
|
};
|
||||||
|
await emitMockEvent(oldRuntime, "session_start", { reason: "startup" }, oldCtx);
|
||||||
|
addRunning("worker");
|
||||||
|
|
||||||
|
let oldChanges = 0;
|
||||||
|
const oldOnChange = () => oldChanges++;
|
||||||
|
const oldRelease = statusWidget.claim(oldOnChange);
|
||||||
|
|
||||||
|
// Bind the replacement before the old runtime emits shutdown. The old
|
||||||
|
// shutdown must not undo the fresh binding or retain its old callback.
|
||||||
|
const freshRuntime = createMockExtensionApi();
|
||||||
|
(subagentsModule as any).default(freshRuntime.api);
|
||||||
|
await emitMockEvent(freshRuntime, "session_start", { reason: "reload" }, oldCtx);
|
||||||
|
// A delayed old start must also be ignored once the fresh runtime is bound.
|
||||||
|
await emitMockEvent(oldRuntime, "session_start", { reason: "reload" }, oldCtx);
|
||||||
|
assert.equal(shared.latestPi, freshRuntime.api);
|
||||||
|
assert.equal(statusWidget.isClaimed(oldOnChange), false);
|
||||||
|
await emitMockEvent(oldRuntime, "session_shutdown", { reason: "reload" }, oldCtx);
|
||||||
|
testApi.updateWidget();
|
||||||
|
|
||||||
|
assert.equal(oldChanges, 0);
|
||||||
|
assert.equal(shared.statusWidgetClaim, null);
|
||||||
|
assert.equal(shared.runningSubagents.size, 1);
|
||||||
|
|
||||||
|
let freshChanges = 0;
|
||||||
|
const freshRelease = statusWidget.claim(() => freshChanges++);
|
||||||
|
oldRelease();
|
||||||
|
testApi.updateWidget();
|
||||||
|
assert.equal(freshChanges, 1, "a stale release cannot affect the fresh claim");
|
||||||
|
freshRelease();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces malformed and old widget contracts on a duplicate load", async () => {
|
||||||
|
const globalApi = (globalThis as any).__pi_interactive_subagents;
|
||||||
|
(globalThis as any).__pi_interactive_subagents = "old-api";
|
||||||
|
shared.statusWidgetClaim = { id: 1, onChange: () => {} };
|
||||||
|
await import(`../pi-extension/subagents/index.ts?malformed-global=${Date.now()}`);
|
||||||
|
const repairedApi = (globalThis as any).__pi_interactive_subagents;
|
||||||
|
assert.equal(typeof repairedApi.registerToolExtension, "function");
|
||||||
|
assert.equal(repairedApi.statusWidget, globalApi.statusWidget);
|
||||||
|
|
||||||
|
shared.statusWidgetClaim = { id: 1, onChange: () => {} };
|
||||||
|
await import(`../pi-extension/subagents/index.ts?old-widget-claim=${Date.now()}`);
|
||||||
|
assert.equal(shared.statusWidgetClaim, null);
|
||||||
|
|
||||||
|
const oldContract = {
|
||||||
|
hasContent: () => true,
|
||||||
|
getBodyRowCount: () => 1,
|
||||||
|
render: () => [],
|
||||||
|
claim: () => () => {},
|
||||||
|
};
|
||||||
|
shared.statusWidget = oldContract;
|
||||||
|
(globalThis as any).__pi_interactive_subagents.statusWidget = oldContract;
|
||||||
|
|
||||||
|
await import(`../pi-extension/subagents/index.ts?old-widget-contract=${Date.now()}`);
|
||||||
|
|
||||||
|
const currentApi = (globalThis as any).__pi_interactive_subagents;
|
||||||
|
const current = currentApi.statusWidget;
|
||||||
|
assert.equal(currentApi, repairedApi);
|
||||||
|
assert.notEqual(current, oldContract);
|
||||||
|
assert.equal(typeof current.hasContent, "function");
|
||||||
|
assert.equal(typeof current.getBodyRowCount, "function");
|
||||||
|
assert.equal(typeof current.render, "function");
|
||||||
|
assert.equal(typeof current.claim, "function");
|
||||||
|
assert.equal(typeof current.isClaimed, "function");
|
||||||
|
assert.equal(shared.statusWidget, current);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("subagent display helpers", () => {
|
describe("subagent display helpers", () => {
|
||||||
@@ -2670,11 +3155,13 @@ describe("subagent display helpers", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("surface backend selection", () => {
|
describe("surface backend selection", () => {
|
||||||
it("prefers Herdr and otherwise falls back to tmux", () => {
|
it("prefers Herdr, then Orca, and otherwise falls back to tmux", () => {
|
||||||
assert.equal(selectMuxBackend({ herdr: true, tmux: true }), "herdr");
|
assert.equal(selectMuxBackend({ herdr: true, orca: true, tmux: true }), "herdr");
|
||||||
assert.equal(selectMuxBackend({ herdr: true, tmux: false }), "herdr");
|
assert.equal(selectMuxBackend({ herdr: true, orca: false, tmux: false }), "herdr");
|
||||||
assert.equal(selectMuxBackend({ herdr: false, tmux: true }), "tmux");
|
assert.equal(selectMuxBackend({ herdr: false, orca: true, tmux: true }), "orca");
|
||||||
assert.equal(selectMuxBackend({ herdr: false, tmux: false }), null);
|
assert.equal(selectMuxBackend({ herdr: false, orca: true, tmux: false }), "orca");
|
||||||
|
assert.equal(selectMuxBackend({ herdr: false, orca: false, tmux: true }), "tmux");
|
||||||
|
assert.equal(selectMuxBackend({ herdr: false, orca: false, tmux: false }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses Herdr tab and pane creation responses", () => {
|
it("parses Herdr tab and pane creation responses", () => {
|
||||||
@@ -2693,6 +3180,39 @@ describe("surface backend selection", () => {
|
|||||||
);
|
);
|
||||||
assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/);
|
assert.throws(() => parseCreatedTab("{}"), /result\.root_pane\.pane_id/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses Orca terminal create, split, and read responses", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseCreatedTerminal(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
result: { terminal: { handle: "term_abc", tabId: "tab-1" } },
|
||||||
|
})),
|
||||||
|
{ handle: "term_abc", tabId: "tab-1" },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
parseCreatedSplit(JSON.stringify({ ok: true, result: { split: { handle: "term_def" } } })),
|
||||||
|
"term_def",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
parseReadTail(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
result: { terminal: { status: "running", tail: ["line one", "line two"] } },
|
||||||
|
})),
|
||||||
|
"line one\nline two",
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => parseReadTail(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
result: { terminal: { status: "exited", tail: [] } },
|
||||||
|
})),
|
||||||
|
/not running \(status: exited\)/,
|
||||||
|
);
|
||||||
|
assert.throws(() => parseCreatedTerminal("{}"), /result\.terminal\.handle/);
|
||||||
|
assert.throws(
|
||||||
|
() => parseCreatedTerminal(JSON.stringify({ ok: false, error: { message: "nope" } })),
|
||||||
|
/terminal create failed: nope/,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("subagent completion wrapper", () => {
|
describe("subagent completion wrapper", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user