Files
Mert Koseoglu bebc7529d3 fix(pi): keep the foreground MCP bridge alive + route diagnostics off the TUI (#868)
On Pi, context-mode loads in-process and the MCP bridge wrote diagnostics to
process.stderr — which IS Pi's raw-mode TUI terminal. The #854 idle-reaper's
"self-shutdown" line rendered into the editor box and blocked typing (reported
by @carolitascl, confirmed by @frsswq). Two coupled defects, both fixed:

1. Diagnostics on the wrong channel. All pi-adapter stderr writes (the
   forwarded MCP-child stderr + the bridge's own warnings) now route to
   pi.logger (Pi's rotating ~/.omp/logs), never process.stderr — the documented
   channel for in-process extensions. #472's crash-diagnostic intent is
   preserved (captured to the log, not swallowed). Helpers makeBridgeDiag +
   splitDiagLines (regex-free; replaces the old split(/\r?\n/)).

2. The foreground session shouldn't drop at all. The #854 reaper reaped the
   ACTIVE interactive session's child after 180s idle — a human pause must not
   drop ctx_* tools. The foreground child is now spawned with the reaper
   disabled (CONTEXT_MODE_BRIDGE_IDLE_MS=0), decided from ctx.hasUI on
   before_agent_start; subagents (hasUI:false) keep the reaper so abandoned
   children still can't accumulate (#854). The foreground child is still reaped
   on real parent death by the ppid/signal watchdog. Deciding on the first
   before_agent_start is provably safe (Pi wires uiContext before the first
   prompt) — documented as an invariant, no runtime latch needed.

Also DX-tuned the reaper notice (idleReapMessage): human units, reassures
auto-reconnect, drops the "self-shutdown" jargon.

Scope verified across all 17 adapters: only pi forwards child stderr into a
raw-mode TUI; omp (separate MCP subprocess), opencode/openclaw (embedded mode +
host logger), and the 13 stdio-subprocess hosts isolate MCP stderr — none
affected, none need a fix.

TDD RGR; two Pi-Architect design reviews + one adversarial implementation
review; no-regex; typecheck + pi/lifecycle suites green (169 tests).
2026-06-23 09:41:45 +03:00

1932 lines
76 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "./setup-home";
/**
* Pi Extension Tests — TDD vertical slices.
*
* The Pi extension (src/adapters/pi/extension.ts) is a default-exported function that
* receives a Pi API object and registers event handlers. Since we cannot test
* against a real Pi runtime, we mock the Pi API to capture registered handlers
* and invoke them with simulated events.
*
* Test slices:
* 1. Tool name mapping (Pi names → context-mode canonical names)
* 2. Event extraction from tool_result
* 3. PreToolUse routing enforcement (tool_call)
* 4. Session lifecycle
* 5. Resume injection (before_agent_start)
* 6. Stats command (/ctx-stats)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto";
import { mkdtempSync, rmSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { SessionDB } from "../src/session/db.js";
// ── Mock Pi API ──────────────────────────────────────────────
type HandlerFn = (...args: any[]) => any;
interface MockCommandOpts {
description?: string;
handler?: HandlerFn;
[key: string]: unknown;
}
function createMockPiApi() {
const handlers: Record<string, HandlerFn[]> = {};
const commands: Record<string, MockCommandOpts> = {};
return {
on: (event: string, handler: HandlerFn) => {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(handler);
},
registerCommand: (name: string, opts: MockCommandOpts) => {
commands[name] = opts;
},
registerTool: vi.fn(),
sendMessage: vi.fn(),
exec: vi.fn(),
// ── Test helpers ──
_trigger: async (event: string, ...args: any[]) => {
for (const h of handlers[event] ?? []) {
const result = await h(...args);
if (result) return result;
}
},
_getCommand: (name: string) => commands[name],
_handlers: handlers,
_commands: commands,
};
}
// ── Shared state ────────────────────────────────────────────
let tempDir: string;
let api: ReturnType<typeof createMockPiApi>;
// ── Dynamic import helper ───────────────────────────────────
async function registerPiExtension(
mockApi: ReturnType<typeof createMockPiApi>,
opts?: { projectDir?: string },
) {
// Set environment variable so the extension uses our temp directory
const projectDir = opts?.projectDir ?? tempDir;
process.env.PI_PROJECT_DIR = projectDir;
process.env.CLAUDE_PROJECT_DIR = projectDir;
const mod = await import("../src/adapters/pi/extension.js");
const register = mod.default;
await register(mockApi);
return mockApi;
}
// ── Tests ───────────────────────────────────────────────────
describe("Pi Extension", () => {
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "pi-ext-test-"));
mkdirSync(tempDir, { recursive: true });
api = createMockPiApi();
});
afterEach(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
/* cleanup best effort */
}
delete process.env.PI_PROJECT_DIR;
delete process.env.CLAUDE_PROJECT_DIR;
});
// ═══════════════════════════════════════════════════════════
// Slice 1: Tool name mapping
// ═══════════════════════════════════════════════════════════
describe("Slice 1: Tool name mapping", () => {
it("maps Pi 'bash' to context-mode 'Bash'", async () => {
await registerPiExtension(api);
// Trigger a tool_result event with Pi's "bash" tool name
// and verify it gets mapped correctly for event extraction
await api._trigger("tool_result", {
tool_name: "bash",
tool_input: { command: "git status" },
tool_result: "On branch main\nnothing to commit",
});
// The handler should not throw — successful mapping means
// extractEvents recognized "Bash" and produced git events
});
it("maps Pi 'read' to context-mode 'Read'", async () => {
await registerPiExtension(api);
await api._trigger("tool_result", {
tool_name: "read",
tool_input: { file_path: "/src/app.ts" },
tool_result: "export default {}",
});
// Should not throw — "Read" mapping enables file_read event extraction
});
it("passes unknown tool names through unchanged", async () => {
await registerPiExtension(api);
// Unknown tools should pass through without error
await api._trigger("tool_result", {
tool_name: "SomeCustomTool",
tool_input: { data: "test" },
tool_result: "ok",
});
});
it("maps 'context_mode_' prefix to 'mcp__context_mode__' so MCP tool calls get extracted", async () => {
await registerPiExtension(api);
await api._trigger("session_start", {
session_id: "test-fix1",
project_dir: tempDir,
});
// Pi prefixes MCP-registered tools with "context_mode_". Without
// normalisation the extract functions (P, F) silently drop all
// events because they gate on e.startsWith("mcp__").
await api._trigger("tool_result", {
tool_name: "context_mode_ctx_execute",
params: { language: "javascript", code: "console.log(1)" },
result: "1",
});
// Verify via ctx-stats that events include "mcp" category
// (meaning they were extracted, not just the generic fallback).
const stats = (await api._getCommand("ctx-stats")!.handler!({})) as {
text: string;
};
expect(stats.text).toContain("mcp");
});
});
// ═══════════════════════════════════════════════════════════
// Slice 2: Event extraction from tool_result
// ═══════════════════════════════════════════════════════════
describe("Slice 2: Event extraction from tool_result", () => {
it("extracts file and git events from bash command", async () => {
await registerPiExtension(api);
// Bash with git command should produce git events
await api._trigger("tool_result", {
tool_name: "bash",
tool_input: { command: "git commit -m 'initial'" },
tool_result: "[main abc1234] initial\n 1 file changed",
});
// No throw = events extracted successfully
});
it("extracts file_read event from read tool", async () => {
await registerPiExtension(api);
await api._trigger("tool_result", {
tool_name: "read",
tool_input: { file_path: "/src/index.ts" },
tool_result: "export const hello = 'world';",
});
});
it("extracts file_read event when Pi passes 'path' instead of 'file_path'", async () => {
await registerPiExtension(api);
await api._trigger("session_start", {
session_id: "test-fix2",
project_dir: tempDir,
});
// Pi's native Read tool sends { path: "..." } rather than
// { file_path: "..." } (Claude Code convention). Without
// normalisation the extractor reads n.file_path → undefined,
// producing file_read events with an empty path.
await api._trigger("tool_result", {
tool_name: "read",
params: { path: "/src/index.ts" },
result: "export const hello = 'world';",
});
// Verify the file_read event was captured by checking
// ctx-stats shows "file" category events.
const stats = (await api._getCommand("ctx-stats")!.handler!({})) as {
text: string;
};
expect(stats.text).toContain("file");
});
it("extracts cwd event from cd command", async () => {
await registerPiExtension(api);
await api._trigger("tool_result", {
tool_name: "bash",
tool_input: { command: "cd /tmp/workspace && ls" },
tool_result: "file1.ts\nfile2.ts",
});
});
it("extracts error event from failed tool result", async () => {
await registerPiExtension(api);
await api._trigger("tool_result", {
tool_name: "bash",
tool_input: { command: "npm test" },
tool_result: "Error: test failed with exit code 1",
is_error: true,
});
});
it("handles missing tool_result gracefully", async () => {
await registerPiExtension(api);
await api._trigger("tool_result", {
tool_name: "bash",
tool_input: { command: "echo hello" },
});
});
it("handles empty event gracefully", async () => {
await registerPiExtension(api);
await api._trigger("tool_result", {});
});
});
// ═══════════════════════════════════════════════════════════
// Slice 3: PreToolUse routing enforcement (tool_call)
// ═══════════════════════════════════════════════════════════
describe("Slice 3: PreToolUse routing enforcement", () => {
it("blocks bash with curl", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: { command: "curl https://example.com" },
});
expect(result).toBeDefined();
expect(result.block).toBe(true);
});
it("blocks bash with wget", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: { command: "wget https://example.com -O out.html" },
});
expect(result).toBeDefined();
expect(result.block).toBe(true);
});
it("allows bash with git status", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: { command: "git status" },
});
// git status should NOT be blocked — result is undefined (passthrough)
// or an allow/context action (not deny/blocked)
if (result) {
expect(result.blocked).not.toBe(true);
}
});
it("allows read tool (no blocking)", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "read",
input: { file_path: "/src/app.ts" },
});
// Read should never be blocked — at most it gets routing guidance
if (result) {
expect(result.blocked).not.toBe(true);
}
});
it("handles missing tool_name gracefully", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {});
// Should not throw, and should passthrough
if (result) {
expect(result.blocked).not.toBe(true);
}
});
// ── Issue #625 — escape hatches + quoted-string false positives ──
//
// The original BLOCKED_BASH_PATTERNS blocked every curl/wget unconditionally
// and matched inside quoted CLI arguments. Two consequences:
//
// 1. False positive: `gh issue list --search "...curl..."` was blocked
// because the literal word `curl` appeared inside the quoted search
// argument. The reporter literally could not file the issue via `gh`
// until they rephrased the query.
//
// 2. Unrecoverable trap: when the MCP bridge dies, the agent's only
// escape hatch is a disk-buffered HTTP download (curl -s -o file).
// With every curl/wget invocation blocked, there is no way to fetch
// a URL until the user restarts Pi entirely.
//
// Fix mirrors hooks/core/routing.mjs:660–722:
// - Strip quoted content before regex matching
// - Split on chain operators (&&, ||, ;)
// - Allow segments that are: silent (-s/-q) + file output (-o/-O or >)
// + no verbose (-v) + no stdout alias (-o -, -o /dev/stdout)
//
// This preserves the original "do not flood context" intent while letting
// the agent gracefully self-recover when MCP is unreachable.
it("does NOT block gh command with 'curl' inside quoted --search argument (Issue #625 bonus)", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: {
command: 'gh issue list --search "BLOCKED_BASH_PATTERNS curl wget block"',
},
});
// The literal word "curl" appears only inside a quoted argument; the
// command itself is `gh`, which has nothing to do with HTTP fetching.
// Stripping quoted content before matching must allow this through.
if (result) {
expect(result.block).not.toBe(true);
}
});
it("does NOT block echo with 'wget' inside a quoted log message (Issue #625 bonus)", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: {
command: 'echo "users tried to wget the bundle and it failed"',
},
});
if (result) {
expect(result.block).not.toBe(true);
}
});
it("allows curl with silent + file output for MCP-down recovery (Issue #625)", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: { command: "curl -s -o /tmp/data.json https://example.com/api" },
});
// -s (silent) + -o file means the body never enters context. This must
// remain available as an escape hatch when ctx_fetch_and_index is
// unreachable (e.g. MCP bridge dead between requests).
if (result) {
expect(result.block).not.toBe(true);
}
});
it("allows wget with quiet + output-document for MCP-down recovery (Issue #625)", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: { command: "wget -q -O /tmp/data.json https://example.com/api" },
});
if (result) {
expect(result.block).not.toBe(true);
}
});
it("still blocks curl that would flood context (no file output)", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: { command: "curl https://example.com/big-response" },
});
// No -o flag → body goes straight to stdout → straight into context.
// Must remain blocked.
expect(result).toBeDefined();
expect(result.block).toBe(true);
});
it("still blocks curl -o - (explicit stdout alias)", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: { command: "curl -s -o - https://example.com/api" },
});
// -o - means stdout, which feeds context — must remain blocked even
// when silent flag is present.
expect(result).toBeDefined();
expect(result.block).toBe(true);
});
it("still blocks verbose curl (floods stderr → context)", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: { command: "curl -v -s -o /tmp/x.json https://example.com" },
});
// -v dumps request/response headers to stderr — flooding context.
expect(result).toBeDefined();
expect(result.block).toBe(true);
});
it("blocks chained command if ANY segment is unsafe curl", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: {
command:
"curl -s -o /tmp/a.json https://example.com/a && curl https://example.com/b",
},
});
// First segment safe, second unsafe → must block the whole chain.
expect(result).toBeDefined();
expect(result.block).toBe(true);
});
it("allows chained command where every curl segment is safe", async () => {
await registerPiExtension(api);
const result = await api._trigger("tool_call", {
toolName: "bash",
input: {
command:
"curl -s -o /tmp/a.json https://example.com/a && curl -s -o /tmp/b.json https://example.com/b",
},
});
// Both segments: silent + file output + no stdout alias + no verbose.
// The chain must pass.
if (result) {
expect(result.block).not.toBe(true);
}
});
});
// ═══════════════════════════════════════════════════════════
// Slice 4: Session lifecycle
// ═══════════════════════════════════════════════════════════
describe("Slice 4: Session lifecycle", () => {
it("session_start initializes session in DB", async () => {
await registerPiExtension(api);
// session_start should register without error
await api._trigger("session_start", {
session_id: "test-session-abc123",
project_dir: tempDir,
});
});
it("session_start uses Pi context arg for stable session ID", async () => {
await registerPiExtension(api);
const sessionFile = join(tempDir, "stable-session.jsonl");
const expectedSessionId = createHash("sha256")
.update(sessionFile)
.digest("hex")
.slice(0, 8);
await api._trigger(
"session_start",
{ type: "session_start", reason: "startup" },
{ sessionManager: { getSessionFile: () => sessionFile } },
);
const result = await api._getCommand("ctx-stats")!.handler!({});
expect((result as { text: string }).text).toContain(
`Session: \`${expectedSessionId}`,
);
});
it("session_before_compact builds resume snapshot", async () => {
await registerPiExtension(api);
// First capture some events
await api._trigger("tool_result", {
tool_name: "read",
tool_input: { file_path: "/src/index.ts" },
tool_result: "export default {}",
});
// Then trigger compaction
const result = await api._trigger("session_before_compact", {});
// Should return a snapshot string or undefined if no events
if (result !== undefined) {
expect(typeof result).toBe("string");
if (typeof result === "string" && result.length > 0) {
expect(result).toContain("session_resume");
}
}
});
it("session_compact increments compact counter", async () => {
await registerPiExtension(api);
// Capture events first
await api._trigger("tool_result", {
tool_name: "read",
tool_input: { file_path: "/src/app.ts" },
tool_result: "code here",
});
// Build snapshot
await api._trigger("session_before_compact", {});
// Increment counter
await api._trigger("session_compact", {});
// No throw = success
});
it("session_shutdown cleans up", async () => {
await registerPiExtension(api);
await api._trigger("session_start", {
session_id: "cleanup-session-xyz",
project_dir: tempDir,
});
// Shutdown should clean up without error
await api._trigger("session_shutdown", {});
});
it("session_start receives (event, ctx) — ctx.sessionManager is used for session ID", async () => {
await registerPiExtension(api);
// The handler signature must be (event, ctx), not (ctx).
// Pass a real-looking ctx as the second argument with sessionManager;
// if the signature were wrong, sessionManager would be on the event
// object and deriveSessionId would fall back to pi-<timestamp>.
const sessionFile = `/fake/session-${Date.now()}.jsonl`;
await api._trigger("session_start",
{ reason: "startup" }, // event (1st arg)
{ sessionManager: { getSessionFile: () => sessionFile } }, // ctx (2nd arg)
);
// Verify the session was initialised with the file-derived ID by checking
// that before_agent_start doesn't blow up (it needs a valid _sessionId).
// In the new behavior, before_agent_start no longer returns systemPrompt;
// it stores context in _pendingContext for the context hook to inject.
const result = await api._trigger("before_agent_start", {
systemPrompt: "Base.",
});
// before_agent_start may or may not return a value — the key is it doesn't throw
expect(result?.systemPrompt ?? null).toBe(null); // systemPrompt is no longer returned
});
it("handles session lifecycle in correct order", async () => {
await registerPiExtension(api);
// Full lifecycle: start → events → compact → more events → shutdown
await api._trigger("session_start", {
session_id: "lifecycle-test",
project_dir: tempDir,
});
await api._trigger("tool_result", {
tool_name: "bash",
tool_input: { command: "git status" },
tool_result: "On branch main",
});
await api._trigger("session_before_compact", {});
await api._trigger("session_compact", {});
await api._trigger("tool_result", {
tool_name: "read",
tool_input: { file_path: "/src/file.ts" },
tool_result: "content",
});
await api._trigger("session_shutdown", {});
});
});
// ═══════════════════════════════════════════════════════════
// Slice 5: Resume injection (before_agent_start)
// ═══════════════════════════════════════════════════════════
describe("Slice 5: Resume injection", () => {
it("delivers resume snapshot through context hook, not systemPrompt", async () => {
await registerPiExtension(api);
// Build up session state: capture events → compact → build resume
await api._trigger("session_start", {
session_id: "resume-test-1",
project_dir: tempDir,
});
await api._trigger("tool_result", {
tool_name: "read",
tool_input: { file_path: "/src/main.ts" },
tool_result: "import express from 'express';",
});
await api._trigger("tool_result", {
tool_name: "bash",
tool_input: { command: "git commit -m 'feat: add express'" },
tool_result: "[main abc1234] feat: add express",
});
// Trigger compaction to build resume snapshot
await api._trigger("session_before_compact", {});
await api._trigger("session_compact", {});
// before_agent_start should prepare context without mutating systemPrompt
const result = await api._trigger("before_agent_start", {
systemPrompt: "You are a helpful assistant.",
});
expect(result?.systemPrompt ?? null).toBe(null);
// The context hook should deliver the resume as a trailing user message.
const messages = [{ role: "system", content: "You are a helpful assistant." }];
const ctxResult = await api._trigger("context", { messages });
expect(ctxResult?.messages).toBe(messages);
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual({
role: "system",
content: "You are a helpful assistant.",
});
expect(messages[1].role).toBe("user");
expect(String(messages[1].content)).toContain("session_resume");
expect(String(messages[1].content)).not.toContain("You are a helpful assistant.");
});
it("appends resume and active context after existing messages", async () => {
await registerPiExtension(api);
await api._trigger("session_start", {
session_id: "resume-test-2",
project_dir: tempDir,
});
await api._trigger("tool_result", {
tool_name: "read",
tool_input: { file_path: "/src/main.ts" },
tool_result: "export const value = 1;",
});
await api._trigger("tool_result", {
tool_name: "write",
tool_input: { file_path: "/src/main.ts", content: "export const value = 2;" },
tool_result: "wrote /src/main.ts",
});
await api._trigger("session_before_compact", {});
await api._trigger("session_compact", {});
const result = await api._trigger("before_agent_start", {
systemPrompt: "Stable system prompt.",
prompt: "Continue with the refactor and avoid lodash.",
});
expect(result?.systemPrompt ?? null).toBe(null);
const messages = [
{ role: "system", content: "Stable system prompt." },
{ role: "user", content: "Continue with the refactor." },
];
const ctxResult = await api._trigger("context", { messages });
expect(ctxResult?.messages).toBe(messages);
expect(messages).toHaveLength(3);
expect(messages[0]).toEqual({ role: "system", content: "Stable system prompt." });
expect(messages[1]).toEqual({ role: "user", content: "Continue with the refactor." });
expect(messages[2].role).toBe("user");
const trailing = String(messages[2].content);
expect(trailing).toContain("context-mode active");
expect(trailing).toContain("session_resume");
expect(trailing).toContain("how_to_search");
expect(trailing).not.toContain("Stable system prompt.");
});
it("returns nothing when no resume exists", async () => {
await registerPiExtension(api);
const result = await api._trigger("before_agent_start", {
systemPrompt: "You are a helpful assistant.",
});
// No resume → no modification (undefined or original prompt)
if (result?.systemPrompt) {
expect(result.systemPrompt).not.toContain("session_resume");
}
});
it("extracts user prompt events", async () => {
await registerPiExtension(api);
// User prompt with decision-like content should extract events
await api._trigger("user_prompt", {
message: "Don't use lodash, use native Array methods instead",
});
// Should not throw — user events are silently captured
});
it("handles missing systemPrompt gracefully", async () => {
await registerPiExtension(api);
const result = await api._trigger("before_agent_start", {});
// Should not throw
if (result) {
expect(result).toBeDefined();
}
});
});
// ═══════════════════════════════════════════════════════════
// Slice 6: Stats command (/ctx-stats)
// ═══════════════════════════════════════════════════════════
describe("Slice 6: Stats command", () => {
it("registers /ctx-stats command", async () => {
await registerPiExtension(api);
const cmd = api._getCommand("ctx-stats");
expect(cmd).toBeDefined();
});
it("/ctx-stats returns formatted stats text", async () => {
await registerPiExtension(api);
// Capture some events first
await api._trigger("tool_result", {
tool_name: "read",
tool_input: { file_path: "/src/app.ts" },
tool_result: "export default {}",
});
await api._trigger("tool_result", {
tool_name: "bash",
tool_input: { command: "git status" },
tool_result: "On branch main",
});
const cmd = api._getCommand("ctx-stats");
expect(cmd).toBeDefined();
expect(cmd!.handler).toBeDefined();
const result = await cmd!.handler!({});
// Stats should contain formatted text with session info
expect(result).toBeDefined();
if (typeof result === "object" && result !== null && "text" in result) {
const text = (result as { text: string }).text;
expect(typeof text).toBe("string");
expect(text.length).toBeGreaterThan(0);
// Should contain typical stats output
expect(text).toMatch(/stat|session|event/i);
} else if (typeof result === "string") {
expect(result.length).toBeGreaterThan(0);
expect(result).toMatch(/stat|session|event/i);
}
});
it("/ctx-stats works with empty session", async () => {
await registerPiExtension(api);
const cmd = api._getCommand("ctx-stats");
expect(cmd).toBeDefined();
expect(cmd!.handler).toBeDefined();
// Should not throw even with no events
const result = await cmd!.handler!({});
expect(result).toBeDefined();
});
it("/ctx-stats treats SQLite started_at as UTC", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-09T12:30:00Z"));
const originalTZ = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const sessionFile = join(tempDir, "stats-utc-session.jsonl");
const sessionId = createHash("sha256")
.update(sessionFile)
.digest("hex")
.slice(0, 16);
try {
await registerPiExtension(api);
await api._trigger(
"session_start",
{ type: "session_start", reason: "startup" },
{ sessionManager: { getSessionFile: () => sessionFile } },
);
// Issue #645 — Pi extension now resolves the SessionDB file
// through `resolveSessionDbPath`, the same helper the MCP
// server uses. The shared "context-mode.db" literal is gone;
// the file lives at <canonical-hash>.db (case-folded on
// darwin/win32, worktree-suffixed when applicable). Mirror
// the production resolver here so this test reads from the
// exact path the extension just wrote to.
const { resolveSessionDbPath } = await import("../src/session/db.js");
const sessionsDir = join(
process.env.HOME!,
".pi",
"context-mode",
"sessions",
);
const dbPath = resolveSessionDbPath({
projectDir: process.env.PI_PROJECT_DIR!,
sessionsDir,
});
const db = new SessionDB({ dbPath });
try {
// SQLite datetime('now') stores UTC as "YYYY-MM-DD HH:MM:SS".
// If parsed as local time in America/Los_Angeles, this would be
// 2026-05-09T19:00:00Z and the age would not be 30 minutes.
db.db
.prepare(
"UPDATE session_meta SET started_at = ? WHERE session_id = ?",
)
.run("2026-05-09 12:00:00", sessionId);
} finally {
db.close();
}
const result = await api._getCommand("ctx-stats")!.handler!({});
expect((result as { text: string }).text).toContain(
"- Session age: 30m",
);
} finally {
if (originalTZ === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTZ;
}
vi.useRealTimers();
}
});
});
// ═══════════════════════════════════════════════════════════
// Slice 7: Pi-1 lightweight routing anchor injection
// ═══════════════════════════════════════════════════════════
describe("Slice 7: Routing block injection", () => {
it("injects lightweight routing anchor via context hook on first before_agent_start", async () => {
await registerPiExtension(api);
await api._trigger("session_start", {}, {
sessionManager: { getSessionFile: () => `routing-1-${Date.now()}-${Math.random()}` },
});
// before_agent_start sets _pendingContext (was previously modifying systemPrompt)
await api._trigger("before_agent_start", {
systemPrompt: "Base prompt.",
});
// context hook now injects the routing anchor as a user message at message end
const messages: any[] = [];
const ctxResult = await api._trigger("context", { messages });
expect(ctxResult?.messages).toBeDefined();
expect(ctxResult.messages.length).toBe(1);
expect(ctxResult.messages[0].role).toBe("user");
expect(ctxResult.messages[0].content).toContain("context-mode active");
expect(ctxResult.messages[0].content).toContain("ctx_batch_execute > ctx_execute > ctx_execute_file");
});
it("re-injects the anchor via context hook on every subsequent call", async () => {
await registerPiExtension(api);
await api._trigger("session_start", {}, {
sessionManager: { getSessionFile: () => `routing-2-${Date.now()}-${Math.random()}` },
});
// Unlike Claude Code where the SessionStart hook persists context for the whole
// session, Pi rebuilds context fresh every turn. The anchor must reach the LLM
// on every call or the LLM loses MCP tool awareness after turn 1.
const ANCHOR = "context-mode active";
for (let call = 0; call < 3; call++) {
await api._trigger("before_agent_start", { systemPrompt: "Base." });
const ctxResult = await api._trigger("context", { messages: [] });
expect(ctxResult?.messages).toBeDefined();
expect(ctxResult.messages[0]?.content).toContain(ANCHOR);
}
});
});
// ═══════════════════════════════════════════════════════════
// Slice 8: before_provider_response (Pi-2)
// ═══════════════════════════════════════════════════════════
describe("Slice 8: before_provider_response handler", () => {
it("registers a before_provider_response handler", async () => {
await registerPiExtension(api);
expect(api._handlers["before_provider_response"]).toBeDefined();
expect(api._handlers["before_provider_response"].length).toBeGreaterThan(0);
});
it("invokes the handler without throwing on metadata payloads", async () => {
await registerPiExtension(api);
await api._trigger("session_start", {
session_id: "provider-1",
project_dir: tempDir,
});
await expect(
api._trigger("before_provider_response", {
model: "pi-1",
provider: "pi",
latencyMs: 42,
usage: { prompt: 10, completion: 20 },
}),
).resolves.not.toThrow();
});
it("handles empty payload gracefully", async () => {
await registerPiExtension(api);
await expect(
api._trigger("before_provider_response", {}),
).resolves.not.toThrow();
});
});
// ═══════════════════════════════════════════════════════════
// Slice 9: active_memory always-on + token cap (Pi-3, Pi-4)
// ═══════════════════════════════════════════════════════════
describe("Slice 9: active_memory injection", () => {
it("injects context every turn via context hook even when compact_count is 0", async () => {
await registerPiExtension(api);
await api._trigger("session_start", {
sessionManager: { getSessionFile: () => `active-mem-1-${Date.now()}-${Math.random()}` },
});
// Seed user prompt with role pattern (priority 3).
await api._trigger("before_agent_start", {
prompt: "You are a senior staff engineer reviewing this codebase.",
systemPrompt: "Base.",
});
// Second call rebuilds context (always-on, not just post-compaction).
await api._trigger("before_agent_start", {
systemPrompt: "Base 2.",
});
// context hook injects the pending context as a user message
const ctxResult = await api._trigger("context", { messages: [] });
expect(ctxResult?.messages).toBeDefined();
expect(ctxResult.messages.length).toBe(1);
expect(ctxResult.messages[0].role).toBe("user");
// The always-on injection path fires every turn — the routing anchor
// proves context reaches the model even with compact_count 0.
const content = String(ctxResult.messages[0].content);
expect(content).toContain("context-mode active");
// Issue #856 — the role MUST NOT be pinned as a standing directive.
expect(content).not.toContain("<behavioral_directive>");
});
it("caps active_memory at ≤ 2000 characters (via context hook)", async () => {
await registerPiExtension(api);
await api._trigger("session_start", {
sessionManager: { getSessionFile: () => `active-mem-2-${Date.now()}-${Math.random()}` },
});
// Flood with very long role-pattern prompts (priority 3).
const longText = "You are a senior staff engineer. " + "x".repeat(500);
for (let i = 0; i < 20; i++) {
await api._trigger("before_agent_start", {
prompt: `${longText} #${i}`,
systemPrompt: "Base.",
});
}
await api._trigger("before_agent_start", {
systemPrompt: "Base final.",
});
const ctxResult = await api._trigger("context", { messages: [] });
const content = String(ctxResult?.messages?.[0]?.content ?? "");
// Issue #856 — flooding with role prompts must NOT accumulate any
// behavioral_directive, and the per-turn injection must stay bounded
// (it is now just the routing anchor; roles are filtered out entirely).
expect(content).not.toContain("<behavioral_directive>");
// Bounded: the anchor block is small and fixed — 20 × 500-char role
// prompts cannot blow the per-turn injection past the cap.
expect(content.length).toBeLessThanOrEqual(2200);
});
});
// ═══════════════════════════════════════════════════════════
// Slice 9b (#856): role is NOT re-injected as a standing
// behavioral_directive every turn (do-nothing-loop fix)
// ═══════════════════════════════════════════════════════════
describe("Slice 9b (#856): role not re-injected as behavioral_directive", () => {
it("never emits <behavioral_directive> in the per-turn context injection, even with a stored role", async () => {
await registerPiExtension(api);
const sessionFile = `role-noreinject-${Date.now()}-${Math.random()}`;
await api._trigger("session_start", {
sessionManager: { getSessionFile: () => sessionFile },
});
// Drive a genuine persona role prompt — extracted into the DB as a
// priority-3 `role` event. Pre-fix this froze as <behavioral_directive>
// and was replayed every turn.
await api._trigger("before_agent_start", {
prompt: "You are a senior staff engineer reviewing this codebase.",
systemPrompt: "Base.",
});
// Subsequent turn rebuilds context.
await api._trigger("before_agent_start", { systemPrompt: "Base 2." });
const ctxResult = await api._trigger("context", { messages: [] });
const content = String(ctxResult?.messages?.[0]?.content ?? "");
// The stale role must NOT be pinned as a standing behavioral_directive.
expect(content).not.toContain("<behavioral_directive>");
});
it("is surgical: drops the role directive but keeps the rest of the injection (routing anchor)", async () => {
await registerPiExtension(api);
const sessionFile = `role-filter-surgical-${Date.now()}-${Math.random()}`;
await api._trigger("session_start", {
sessionManager: { getSessionFile: () => sessionFile },
});
await api._trigger("before_agent_start", {
prompt: "You are a senior staff engineer reviewing this codebase.",
systemPrompt: "Base.",
});
await api._trigger("before_agent_start", { systemPrompt: "Base 2." });
const ctxResult = await api._trigger("context", { messages: [] });
const content = String(ctxResult?.messages?.[0]?.content ?? "");
// Role filtered out…
expect(content).not.toContain("<behavioral_directive>");
// …but injection itself is NOT disabled — the always-on routing anchor
// still reaches the model every turn (filter is role-specific, not a
// blanket drop of the whole context injection).
expect(content).toContain("context-mode active");
});
});
// ═══════════════════════════════════════════════════════════
// Registration integrity
// ═══════════════════════════════════════════════════════════
describe("Registration integrity", () => {
it("registers expected event handlers", async () => {
await registerPiExtension(api);
// The extension should register handlers for key lifecycle events
const registeredEvents = Object.keys(api._handlers);
expect(registeredEvents.length).toBeGreaterThan(0);
// At minimum, tool_call and tool_result should be handled
const hasToolCall = registeredEvents.includes("tool_call");
const hasToolResult = registeredEvents.includes("tool_result");
// At least one of the core event types should be registered
expect(hasToolCall || hasToolResult).toBe(true);
});
it("does not throw during registration", async () => {
await expect(registerPiExtension(api)).resolves.not.toThrow();
});
it("can be registered multiple times without error", async () => {
const api1 = createMockPiApi();
const api2 = createMockPiApi();
await registerPiExtension(api1, { projectDir: join(tempDir, "reg1") });
await registerPiExtension(api2, { projectDir: join(tempDir, "reg2") });
});
});
});
// ────────────────────────────────────────────────────────────────────────
// MCP bridge — bridges context-mode MCP tools into Pi's pi.registerTool()
// surface so the LLM can actually reach ctx_execute / ctx_search / etc.
// (#426). Pi 0.73.x has no native MCP support; without this bridge the
// routing block tells the LLM about tools it cannot call.
// ────────────────────────────────────────────────────────────────────────
describe("Pi MCP bridge (#426)", () => {
let mcpScratch: string;
beforeEach(() => {
mcpScratch = mkdtempSync(join(tmpdir(), "ctx-pi-bridge-"));
});
afterEach(() => {
try {
rmSync(mcpScratch, { recursive: true, force: true });
} catch {
/* best effort */
}
});
function writeFakeServer(source: string): string {
const fs = require("node:fs") as typeof import("node:fs");
const path = join(mcpScratch, `fake-mcp-${Date.now()}-${Math.random()}.mjs`);
fs.writeFileSync(path, source, "utf-8");
return path;
}
// ── Unit: MCPStdioClient framing & lifecycle ──────────────────────
describe("MCPStdioClient", () => {
it("matches request id to response result over newline-delimited JSON", async () => {
const fakePath = writeFakeServer(`
let buf = "";
process.stdin.on("data", (chunk) => {
buf += chunk.toString("utf-8");
let idx;
while ((idx = buf.indexOf("\\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.id == null) continue;
process.stdout.write(JSON.stringify({
jsonrpc: "2.0", id: msg.id,
result: { echoed: msg.method, params: msg.params },
}) + "\\n");
}
});
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
try {
const r1 = await client.request("tools/list", { foo: 1 });
expect(r1).toEqual({ echoed: "tools/list", params: { foo: 1 } });
const r2 = await client.request("tools/call", { bar: 2 });
expect(r2).toEqual({ echoed: "tools/call", params: { bar: 2 } });
} finally {
client.shutdown();
}
});
it("matches concurrent in-flight requests by id (out-of-order responses)", async () => {
// Reverse delays so the slowest goes first — exercises the id-map
// dispatch, not just FIFO ordering.
const fakePath = writeFakeServer(`
let buf = "";
process.stdin.on("data", (chunk) => {
buf += chunk.toString("utf-8");
let idx;
while ((idx = buf.indexOf("\\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.id == null) continue;
const delay = msg.params?.delay ?? 0;
setTimeout(() => {
process.stdout.write(JSON.stringify({
jsonrpc: "2.0", id: msg.id, result: { id: msg.id },
}) + "\\n");
}, delay);
}
});
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
try {
const promises = [50, 40, 30, 20, 10].map((delay, i) =>
client.request<{ id: number }>("probe", { delay, idx: i }),
);
const results = await Promise.all(promises);
expect(results).toHaveLength(5);
for (const r of results) expect(typeof r.id).toBe("number");
} finally {
client.shutdown();
}
});
it("rejects in-flight requests when the child exits", async () => {
const fakePath = writeFakeServer(`
process.stdin.once("data", () => process.exit(0));
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
const promise = client.request("tools/list", {});
await expect(promise).rejects.toThrow(/exited|MCP/);
client.shutdown();
});
it("times out instead of hanging on a silent server", async () => {
const fakePath = writeFakeServer(`
process.stdin.on("data", () => {});
setInterval(() => {}, 1000);
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
try {
await expect(
client.request("tools/list", {}, 200),
).rejects.toThrow(/timeout/);
} finally {
client.shutdown();
}
});
it("ignores non-JSON stdout lines without crashing the parser", async () => {
const fakePath = writeFakeServer(`
process.stdout.write("[some startup banner]\\n");
process.stdout.write("not valid json {{{\\n");
let buf = "";
process.stdin.on("data", (chunk) => {
buf += chunk.toString("utf-8");
let idx;
while ((idx = buf.indexOf("\\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
if (msg.id == null) continue;
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } }) + "\\n");
}
});
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
try {
const r = await client.request<{ ok: boolean }>("ping", {});
expect(r.ok).toBe(true);
} finally {
client.shutdown();
}
});
});
// ── Integration: bootstrapMCPTools + real MCP server ──────────────
describe("bootstrapMCPTools — registers every ctx_* tool with Pi", () => {
// Lifted out of each `it` so the path resolution lives in one place
// and a future MCP-entrypoint move only has to change one line.
const path = require("node:path") as typeof import("node:path");
const url = require("node:url") as typeof import("node:url");
const here = path.dirname(url.fileURLToPath(import.meta.url));
const mcpEntry = path.resolve(here, "..", "start.mjs");
const mcpEnv = { ...process.env, CONTEXT_MODE_DISABLE_VERSION_CHECK: "1" };
let bridge: { tools: string[]; shutdown: () => void } | null = null;
afterEach(() => {
if (bridge) {
bridge.shutdown();
bridge = null;
}
});
it("registers the canonical ctx_* tool set", async () => {
const registered: Array<{ name: string; label: string; description: string; parameters: unknown; execute: Function }> = [];
const fakePi = {
registerTool: (tool: any) => {
registered.push(tool);
},
};
const { bootstrapMCPTools } = await import("../src/adapters/pi/mcp-bridge.js");
bridge = await bootstrapMCPTools(fakePi, mcpEntry, { env: mcpEnv });
// Pin the canonical names — adding new MCP tools is fine
// (arrayContaining), but losing one of these is the bug regression.
expect(bridge.tools).toEqual(
expect.arrayContaining([
"ctx_execute",
"ctx_execute_file",
"ctx_search",
"ctx_index",
"ctx_batch_execute",
"ctx_fetch_and_index",
"ctx_doctor",
"ctx_stats",
"ctx_purge",
]),
);
// Each registration must satisfy the Pi contract.
for (const reg of registered) {
expect(reg.name).toMatch(/^ctx_/);
expect(reg.label).toBe(reg.name);
expect(typeof reg.description).toBe("string");
expect(reg.parameters).toBeTruthy();
expect(typeof reg.execute).toBe("function");
}
}, 30_000);
it("execute() round-trips through tools/call to the MCP server", async () => {
const registered: any[] = [];
const fakePi = {
registerTool: (tool: any) => registered.push(tool),
};
const { bootstrapMCPTools } = await import("../src/adapters/pi/mcp-bridge.js");
bridge = await bootstrapMCPTools(fakePi, mcpEntry, { env: mcpEnv });
const indexTool = registered.find((t) => t.name === "ctx_index");
expect(indexTool).toBeDefined();
const marker = `pi-bridge-marker-${process.pid}-${Date.now()}`;
const result = await indexTool.execute("test-call-1", {
content: `# heading\n\n${marker}\n`,
source: "pi-bridge-smoke",
});
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
// Server returns "Indexed N sections … from: pi-bridge-smoke" on
// success — pin the source label so a regression in tools/call
// arg-passing also fails this test.
expect(result.content[0].text).toMatch(/pi-bridge-smoke/);
expect(result.isError).toBeFalsy();
}, 30_000);
});
// ── Wiring: before_agent_start must bootstrap MCP tools
//
// This is the regression that the rest of the suite does NOT catch: if
// a future refactor drops the `bootstrapMCPTools(pi, …)` call from the
// Pi lifecycle wiring but keeps the bridge module intact, every other
// bridge test stays green and the bug silently re-enters. The bridge is
// intentionally lazy now (#809): extension discovery alone must not spawn a
// child, but `before_agent_start` must register at least the canonical ctx_*
// set before the model call.
describe("pi-extension.ts wiring (#426 regression guard)", () => {
it("before_agent_start bootstraps and registers ctx_* via pi.registerTool", async () => {
const wireApi = createMockPiApi();
// PI_PROJECT_DIR / CLAUDE_PROJECT_DIR set inside registerPiExtension.
await registerPiExtension(wireApi, { projectDir: tempDir });
// Lazy bootstrap: no tool should be registered during extension discovery.
expect((wireApi.registerTool as any).mock.calls.length).toBe(0);
const mod = await import("../src/adapters/pi/extension.js");
await wireApi._trigger("before_agent_start", {
prompt: "tool registration smoke",
systemPrompt: "",
});
await mod._mcpBridgeReady;
const calls = (wireApi.registerTool as any).mock.calls as Array<[any]>;
const registeredNames = calls.map(([t]) => t?.name).filter(Boolean);
// Same canonical pin as the bridge integration test — but reached
// through the Pi lifecycle hook instead of bootstrapMCPTools directly,
// so dropping the wiring fails even when the bridge module still works.
expect(registeredNames).toEqual(
expect.arrayContaining([
"ctx_execute",
"ctx_search",
"ctx_index",
"ctx_batch_execute",
"ctx_fetch_and_index",
]),
);
// Cleanup: SIGTERM the bridge child the hook spawned so it does
// not leak past this test.
const sd = mod.default as any;
void sd; // silence unused
await wireApi._trigger("session_shutdown");
}, 30_000);
// Race regression — comment 4412197109 on PR #472.
//
// Each Pi subagent spawns a fresh `pi --mode json -p --no-session`
// process that loads context-mode and then immediately fires
// `before_agent_start` to dispatch the LLM call. The lazy MCP bridge
// bootstrap (spawn server.bundle.mjs → initialize → tools/list →
// pi.registerTool × N) starts in that hook, so without an explicit await
// the LLM call would go out with an empty ctx_* tool registry and the
// routing block (~2.5K tokens) would become dead weight — the LLM is told
// to call ctx_execute / ctx_search / etc. but Pi has not yet registered
// them.
//
// This test pins the contract: by the time `before_agent_start`
// resolves, the canonical ctx_* tools MUST have been registered
// through pi.registerTool. The pre-trigger assertion confirms the
// race window is open (otherwise the test would pass for the wrong
// reason — bridge happened to win the race).
it("before_agent_start awaits MCP bridge bootstrap so ctx_* are registered before LLM call", async () => {
const wireApi = createMockPiApi();
await registerPiExtension(wireApi, { projectDir: tempDir });
// Establish a session so before_agent_start does real work
// (the handler early-returns when `!_sessionId`).
await wireApi._trigger(
"session_start",
{},
{ session_id: "race-test", project_dir: tempDir },
);
// Sanity: lazy bootstrap has not started during extension/session setup.
// If this ever fails, the bridge became eager again and #809 can regress.
const preCalls = (wireApi.registerTool as any).mock.calls.length;
expect(preCalls).toBe(0);
// The race: trigger before_agent_start now. Pi will dispatch the
// LLM call as soon as this resolves — so the handler MUST block
// until the bridge has registered ctx_* tools.
await wireApi._trigger("before_agent_start", {
sessionID: "race-test",
prompt: "anything",
systemPrompt: "",
});
const calls = (wireApi.registerTool as any).mock.calls as Array<[any]>;
const registeredNames = calls.map(([t]) => t?.name).filter(Boolean);
expect(registeredNames).toEqual(
expect.arrayContaining([
"ctx_execute",
"ctx_search",
"ctx_index",
"ctx_batch_execute",
"ctx_fetch_and_index",
]),
);
await wireApi._trigger("session_shutdown");
}, 30_000);
});
// ── Pi bridge resilience (#472 round-3) ───────────────────────────
//
// Three reliability gaps verified on PR #472 round-3 review:
// 1. stdio[2] = "ignore" silently swallows server crash logs — when
// the MCP child dies during bootstrap the user sees nothing in
// stderr, only "ctx_* tools will not be callable from this
// session" with no diagnostic of WHY.
// 2. shutdown() sends SIGTERM and immediately nulls the child handle.
// A child that ignores SIGTERM (or hangs on cleanup) becomes a
// zombie because no SIGKILL fallback ever fires.
// 3. session_shutdown does not await `_mcpBridgeReady`. If shutdown
// fires while bootstrap is still in flight, `_mcpBridge` is null
// → the freshly-spawned MCP child is orphaned once bootstrap
// eventually resolves.
//
// These tests pin the resilience contract: stderr piped, SIGKILL
// bounded at 5s, shutdown awaits bridge bootstrap up to 2s.
describe("Pi bridge resilience (#472 round-3)", () => {
it("routes child stderr to pi.logger, NOT the TUI terminal (case 1: crash diagnostics; #472 intent / #868)", async () => {
// Server writes a diagnostic line to stderr then exits non-zero,
// mimicking a real crash during initialize. With stdio[2] = "ignore"
// the diagnostic vanishes (#472). We keep capturing it — but route it
// through `diag` (Pi's file logger), NEVER process.stderr, because Pi's
// raw-mode TUI renders any console write into the editor box (#868).
const fakePath = writeFakeServer(`
process.stderr.write("FAKE_MCP_CRASH_DIAG: bundle corrupted at line 42\\n");
process.exit(1);
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const logged: string[] = [];
const client = new MCPStdioClient(fakePath, process.env, null, (line) =>
logged.push(line),
);
// Guard: nothing the child writes may reach process.stderr (Pi's TUI).
const stderrCaptured: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
// @ts-expect-error monkeypatch
process.stderr.write = (chunk: any, ...rest: any[]) => {
stderrCaptured.push(typeof chunk === "string" ? chunk : chunk.toString("utf-8"));
return origWrite(chunk, ...rest);
};
try {
client.start();
// Give the child time to write its diag line and exit.
await new Promise((r) => setTimeout(r, 300));
} finally {
// @ts-expect-error restore
process.stderr.write = origWrite;
client.shutdown();
}
const all = logged.join("\n");
// #472 intent preserved: the crash diagnostic is captured (now in the log)…
expect(all.includes("FAKE_MCP_CRASH_DIAG")).toBe(true);
// …with the [mcp-bridge] prefix so ops can grep ~/.omp/logs.
expect(all.includes("[mcp-bridge]")).toBe(true);
// #868: it must NOT have been written to the terminal.
expect(stderrCaptured.join("").includes("FAKE_MCP_CRASH_DIAG")).toBe(false);
}, 10_000);
it.skipIf(process.platform === "win32")("escalates to SIGKILL when SIGTERM is ignored (case 2: bounded shutdown)", async () => {
// Windows has no POSIX signal delivery: child.kill('SIGTERM') maps to
// TerminateProcess (unblockable), and child.kill('SIGKILL') hits the
// same syscall. The fake server's `process.on('SIGTERM', ...)` handler
// never fires, so the SIGKILL-escalation contract that this test pins
// is fundamentally unobservable on win32. POSIX (Linux/macOS) coverage
// is sufficient for the resilience guarantee.
// Child explicitly ignores SIGTERM and refuses to exit. Without a
// SIGKILL fallback the process leaks indefinitely.
// Also ignore stdin 'end'/'close' so closing the pipe doesn't end
// the process — only SIGKILL should be able to terminate this.
// Emit "READY" on stdout once handlers are registered so the test
// does not race the spawn (otherwise SIGTERM arrives during Node
// bootstrap before our handler is registered → default terminate).
const fakePath = writeFakeServer(`
process.on("SIGTERM", () => { /* deliberately ignore */ });
process.on("SIGINT", () => { /* deliberately ignore */ });
process.on("SIGHUP", () => { /* deliberately ignore */ });
process.stdin.on("end", () => { /* stay alive */ });
process.stdin.on("close", () => { /* stay alive */ });
process.stdin.on("data", () => {});
setInterval(() => {}, 1000);
process.stdout.write("READY\\n");
`);
const { MCPStdioClient } = await import("../src/adapters/pi/mcp-bridge.js");
const client = new MCPStdioClient(fakePath);
client.start();
// Reach into the private child handle for the assertion. Tests
// already do this elsewhere and the field is the only way to
// observe the OS-level lifecycle without a polling sentinel.
const childRef: any = (client as any).child;
expect(childRef).toBeTruthy();
expect(childRef.killed).toBe(false);
// Wait for "READY" — confirms SIGTERM handler is installed.
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("child never READY")), 3000);
childRef.stdout.on("data", (chunk: Buffer) => {
if (chunk.toString("utf-8").includes("READY")) {
clearTimeout(timer);
resolve();
}
});
});
const exitPromise = new Promise<{ code: number | null; signal: string | null }>((resolve) => {
childRef.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
resolve({ code, signal });
});
});
const t0 = Date.now();
client.shutdown();
// Bounded by the 5s SIGKILL timer — give it a small grace window.
const result = await Promise.race([
exitPromise.then((r) => ({ ...r, timedOut: false })),
new Promise<{ code: null; signal: null; timedOut: true }>((r) =>
setTimeout(() => r({ code: null, signal: null, timedOut: true }), 7500),
),
]);
const elapsedMs = Date.now() - t0;
// Must NOT have timed out — that would mean no SIGKILL fallback.
expect(result.timedOut).toBe(false);
// Killed by signal (SIGKILL) — Node's behavior across platforms:
// signal field populated when killed by signal.
expect(result.signal).toBe("SIGKILL");
// Bounded at ~5s — assert lower bound (escalation, not immediate)
// and upper bound (not hung beyond the 5s ceiling + grace).
expect(elapsedMs).toBeGreaterThanOrEqual(4500);
expect(elapsedMs).toBeLessThan(7000);
}, 15_000);
it.skipIf(process.platform === "win32")("session_shutdown awaits bridge bootstrap so child is not orphaned (case 3: race)", async () => {
// Windows ChildProcess.killed/exitCode/signalCode lifecycle for
// tsx-spawned MCP children races with TerminateProcess in CI in a way
// that produces a false-negative for the dead-child assertion. The
// shutdown-await contract this test pins is platform-agnostic logic
// (the await on _mcpBridgeReady) — POSIX coverage exercises the race.
// Repro: trigger session_shutdown WHILE bootstrap is still in
// flight. Without an await on `_mcpBridgeReady`, _mcpBridge is
// null at shutdown time → the bootstrap eventually resolves and
// a live MCP child stays orphaned for the rest of the process.
//
// Instrument bootstrapMCPTools by patching the bridge module's
// `bootstrapMCPTools` to capture the resolved BridgeHandle, then
// check its underlying client.child after shutdown completes.
const wireApi = createMockPiApi();
const bridgeMod = await import("../src/adapters/pi/mcp-bridge.js");
const handles: any[] = [];
const origBootstrap = bridgeMod.bootstrapMCPTools;
let markBootstrapEntered!: () => void;
const bootstrapEntered = new Promise<void>((resolve) => {
markBootstrapEntered = resolve;
});
let releaseBootstrap!: () => void;
const bootstrapRelease = new Promise<void>((resolve) => {
releaseBootstrap = resolve;
});
const spy = vi
.spyOn(bridgeMod, "bootstrapMCPTools")
.mockImplementation(async (...args: any[]) => {
markBootstrapEntered();
const handle = await (origBootstrap as any)(...args);
handles.push(handle);
await bootstrapRelease;
return handle;
});
try {
await registerPiExtension(wireApi, { projectDir: tempDir });
const mod = await import("../src/adapters/pi/extension.js");
// Lazy bootstrap now starts from before_agent_start. Fire shutdown while
// that bootstrap promise is still pending, then release it so shutdown's
// bounded await can see and terminate the real handle.
const agentStart = wireApi._trigger("before_agent_start", {
prompt: "race",
systemPrompt: "",
});
await bootstrapEntered;
const shutdown = wireApi._trigger("session_shutdown");
releaseBootstrap();
await shutdown;
await agentStart;
// Wait for the in-flight bootstrap to settle. By this point the
// child has been spawned. If session_shutdown did NOT await
// _mcpBridgeReady, the bridge handle resolves AFTER shutdown
// returned → the child is alive and orphaned.
await mod._mcpBridgeReady;
await new Promise((r) => setTimeout(r, 300));
expect(handles.length).toBeGreaterThan(0);
for (const handle of handles) {
const child = (handle.client as any).child;
// After session_shutdown, the bridge child must be dead OR
// the bridge handle's internal child reference must be null
// (already cleaned via shutdown()). If it's a live ChildProcess
// with no exit signal, the orphan bug is present.
if (child) {
const dead =
child.killed ||
child.exitCode !== null ||
child.signalCode !== null;
expect(dead).toBe(true);
}
}
} finally {
spy.mockRestore();
for (const handle of handles) {
try {
handle.shutdown();
} catch {
/* best effort */
}
}
}
}, 30_000);
it("shuts down a bridge handle that resolves after the shutdown timeout", async () => {
const wireApi = createMockPiApi();
const bridgeMod = await import("../src/adapters/pi/mcp-bridge.js");
const staleShutdown = vi.fn();
const staleHandle = {
tools: [],
shutdown: staleShutdown,
client: { _spawnEnv: null } as unknown as InstanceType<
typeof bridgeMod.MCPStdioClient
>,
};
let resolveBootstrap!: (handle: typeof staleHandle) => void;
const bootstrapPromise = new Promise<typeof staleHandle>((resolve) => {
resolveBootstrap = resolve;
});
const spy = vi
.spyOn(bridgeMod, "bootstrapMCPTools")
.mockImplementation(async () => bootstrapPromise as any);
try {
await registerPiExtension(wireApi, { projectDir: tempDir });
const agentStart = wireApi._trigger("before_agent_start", {
prompt: "late-bootstrap-race",
systemPrompt: "",
});
await Promise.resolve();
expect(spy).toHaveBeenCalledTimes(1);
// Let session_shutdown hit its 2s ceiling while bootstrap is still
// pending. The late bootstrap resolution below must not publish a stale
// handle; it must self-shutdown the handle instead.
await wireApi._trigger("session_shutdown");
expect(staleShutdown).not.toHaveBeenCalled();
resolveBootstrap(staleHandle);
await agentStart;
await Promise.resolve();
expect(staleShutdown).toHaveBeenCalledTimes(1);
} finally {
spy.mockRestore();
}
}, 10_000);
});
});
// ────────────────────────────────────────────────────────────────────────
// #473 round-3: extension MUST route session dir through PiAdapter
// rather than re-encoding the ~/.pi/context-mode/sessions literal. Any
// drift (e.g., PiAdapter changes the segment list) would silently desync
// otherwise — extension keeps writing to ~/.pi while the rest of the
// harness reads from the new location.
// ────────────────────────────────────────────────────────────────────────
describe("Pi extension respects PiAdapter session dir (#473 round-3)", () => {
let scratch: string;
let mockedSessionDir: string;
beforeEach(() => {
scratch = mkdtempSync(join(tmpdir(), "pi-ext-r3-"));
mockedSessionDir = join(scratch, "custom-pi-sess");
mkdirSync(mockedSessionDir, { recursive: true });
vi.resetModules();
});
afterEach(() => {
try {
rmSync(scratch, { recursive: true, force: true });
} catch {
/* best effort */
}
vi.doUnmock("../src/adapters/pi/index.js");
vi.resetModules();
});
it("opens SessionDB at the path returned by PiAdapter.getSessionDir, not at ~/.pi literal", async () => {
vi.doMock("../src/adapters/pi/index.js", () => {
class MockPiAdapter {
getSessionDir() {
return mockedSessionDir;
}
}
return { PiAdapter: MockPiAdapter };
});
const projectDir = join(scratch, "project");
mkdirSync(projectDir, { recursive: true });
process.env.PI_PROJECT_DIR = projectDir;
process.env.CLAUDE_PROJECT_DIR = projectDir;
const localApi = createMockPiApi();
const mod = await import("../src/adapters/pi/extension.js");
await mod.default(localApi);
await localApi._trigger("session_start", {}, {});
// DB file must be created under the mocked dir — proof that the
// extension routes through PiAdapter rather than the hardcoded
// ~/.pi/context-mode/sessions literal. Issue #645: the filename
// inside that dir is the canonical per-project `<hash>.db`, the
// same one the MCP server reads via `resolveSessionDbPath`.
const { resolveSessionDbPath } = await import("../src/session/db.js");
const expectedDbPath = resolveSessionDbPath({
projectDir,
sessionsDir: mockedSessionDir,
});
const { existsSync: fileExists } = await import("node:fs");
expect(fileExists(expectedDbPath)).toBe(true);
// Also assert the doctor command surfaces the mocked path so
// future contributors do not silently regress (it reads getDBPath()).
const doctor = localApi._getCommand("ctx-doctor");
expect(doctor?.handler).toBeDefined();
const result = await doctor!.handler!({}, { hasUI: false });
const text = String((result as { text?: string } | undefined)?.text ?? "");
expect(text).toContain(mockedSessionDir);
await localApi._trigger("session_shutdown");
delete process.env.PI_PROJECT_DIR;
delete process.env.CLAUDE_PROJECT_DIR;
});
});
// ═══════════════════════════════════════════════════════════
// Issue #645 — Pi extension opens SessionDB at the canonical
// per-project hash path (`<hash>.db`), the same file the MCP
// server reads via `resolveSessionDbPath`. The shared
// `context-mode.db` literal is divergent — `ctx_stats` and
// `ctx_search(sort: "timeline")` silently degrade because the
// MCP server resolves a `<hash>.db` that does not exist.
// Regression test pins the contract: Pi must use the canonical
// helper, NOT the hardcoded literal.
// ═══════════════════════════════════════════════════════════
describe("Pi extension SessionDB path matches MCP server's canonical resolver (#645)", () => {
let scratch: string;
let mockedSessionDir: string;
beforeEach(() => {
scratch = mkdtempSync(join(tmpdir(), "pi-ext-645-"));
mockedSessionDir = join(scratch, "pi-sess");
mkdirSync(mockedSessionDir, { recursive: true });
vi.resetModules();
});
afterEach(() => {
try {
rmSync(scratch, { recursive: true, force: true });
} catch {
/* best effort */
}
vi.doUnmock("../src/adapters/pi/index.js");
vi.resetModules();
delete process.env.PI_PROJECT_DIR;
delete process.env.CLAUDE_PROJECT_DIR;
});
it("writes SessionDB to resolveSessionDbPath({projectDir, sessionsDir}), not the shared 'context-mode.db' literal", async () => {
vi.doMock("../src/adapters/pi/index.js", () => {
class MockPiAdapter {
getSessionDir() {
return mockedSessionDir;
}
}
return { PiAdapter: MockPiAdapter };
});
const projectDir = join(scratch, "project");
mkdirSync(projectDir, { recursive: true });
process.env.PI_PROJECT_DIR = projectDir;
process.env.CLAUDE_PROJECT_DIR = projectDir;
const localApi = createMockPiApi();
const mod = await import("../src/adapters/pi/extension.js");
await mod.default(localApi);
await localApi._trigger("session_start", {}, {});
// The MCP server (src/server.ts) resolves the SessionDB path via
// resolveSessionDbPath({ projectDir, sessionsDir }). The Pi
// extension MUST write to the same file. Compute the canonical
// path with the SAME helper the server uses and require it to
// exist on disk after session_start.
const { resolveSessionDbPath } = await import("../src/session/db.js");
const canonicalPath = resolveSessionDbPath({
projectDir,
sessionsDir: mockedSessionDir,
});
const { existsSync: fileExists } = await import("node:fs");
expect(fileExists(canonicalPath)).toBe(true);
// The shared literal must NOT be created — that was the bug.
const buggyLiteralPath = join(mockedSessionDir, "context-mode.db");
// Only fail the "no literal" check when canonical ≠ literal.
// (They differ for any real projectDir → 16-hex hash.)
if (canonicalPath !== buggyLiteralPath) {
expect(fileExists(buggyLiteralPath)).toBe(false);
}
// ctx-doctor must surface the same canonical path so users
// diagnosing degraded ctx_stats see the actual on-disk file.
const doctor = localApi._getCommand("ctx-doctor");
expect(doctor?.handler).toBeDefined();
const result = await doctor!.handler!({}, { hasUI: false });
const text = String((result as { text?: string } | undefined)?.text ?? "");
expect(text).toContain(canonicalPath);
await localApi._trigger("session_shutdown");
});
});