Files
open-design/apps/daemon/tests/pi-rpc.test.ts
75693e95f4 [OPEND-3114] feat(od-next): open registered agents and CLI BYOK (#8702)
* feat(od-next): open registered runtimes and CLI BYOK

Use the effective runtime registry for OD Next admission and remove retired
capability qualification. Keep native observation and existing resume/cold
recovery, with the evidence graph owned by observability contracts.

Add run-scoped BYOK connection flags with caller-selected environment key input
and reuse the existing provider bridge. Accept equivalent frozen Skill JSON
and Base64 representations while preserving content and ownership boundaries.

Validated with focused contracts, runtime, public Run and CLI regressions;
eight real native agents and a real BYOK request/production pair; guard and
workspace typecheck. Refs #8701.

* fix(web): describe BYOK project file tools

Explain that API-provider project runs use OpenCode file tools and require
OpenCode to be available. Update the existing notice in all 19 locales and
its component/browser regressions.

Validated with red/green notice checks, real settings and preview follow-ups,
guard and workspace typecheck. Refs #8701.

* fix(od-next): preserve hidden prototype form feedback

Keep HTML hidden semantics within the shared layout layer without overriding
authored product CSS or hidden-until-found behavior. This fixes name errors
remaining visible after a successful registration.

Validated with a red/green Chromium regression, three resource-boundary
checks, a real Claude follow-up with desktop/mobile form interaction, guard
and workspace typecheck. Refs #8701.

* test(od-next): make Run fixtures select their execution path

Explicitly select ordinary Runs for retry persistence, plain stdout replay
and the compatibility single-run telemetry case. Keep task hierarchy and
terminal fallback telemetry cases active, and assert the selected path.

Restore owner write permission to the leftover-agent fixture's frozen
directories only during teardown so recursive deletion works on POSIX.
Skip symlinks and preserve cleanup errors and every original behavior check.

Reproduced all four CI failures before the fix. Focused daemon and E2E
checks passed (12 tests), along with guard, workspace typecheck and focused
LSP diagnostics. Refs #8701.

* fix(daemon): avoid passing Pi resource directories as prompt files

Pi reads existing --append-system-prompt paths as files. Stop passing skills, design systems and task input directories there; their resource paths already travel in the composed RPC prompt.

Validated with 104 focused runtime/RPC tests, a real Pi 1.1.0 offline startup red/green probe, workspace typecheck and guard.

* fix(web): coalesce streamed calls in generation progress

Fold in-flight snapshots and terminal inputs through the existing tool-event reducer before rendering the Design Files progress feed. Keep the latest input, stable call identity and the limit of twelve distinct calls.

Reproduced the duplicate React key warning before the fix. Runtime and progress-feed regressions now pass alongside existing in-flight tool-row tests.

* fix(web): preserve forms and browser storage in prototype previews

Permit client-side form submission in opaque interactive previews. Route Web Storage artifacts through the existing isolated powered preview, including daemon hints for large files, and retain guards when isolation is unavailable.

Verified URL and srcDoc form interaction, persisted values after reload, and host isolation with focused Chromium regressions. Focused viewer/runtime and daemon tests, workspace typecheck and guard passed.

* test(web): keep srcDoc fixtures on the opaque preview path

Use an existing non-storage sandbox-shim signal in transport lifecycle fixtures now that Web Storage artifacts select the isolated preview origin. Preserve every reload, activation, recovery and manual-save assertion.

Validated all 338 cases in the three affected FileViewer test files, workspace guard and typecheck, and focused diagnostics.

* fix(byok): accept keyless IPv6 loopback providers

Recognize the bracketed IPv6 hostname returned by WHATWG URL parsing in both settings validation and the OpenCode BYOK runtime. Keep remote hosts key-required.

Validated with focused daemon and Web tests, settings browser checks, workspace typecheck and guard.

* fix(daemon): restore Pi sessions and preserve completed RPC turns

Load prior Pi history with switch_session and capture the authoritative sessionFile through nonblocking get_state. Treat a host-requested SIGTERM after a clean completed turn as shutdown, including Pi builds that report exit 143, while preserving real errors and cancellation.

Validated with focused protocol and public Run lifecycle tests, a real Pi generation and native resumed edit, workspace typecheck and guard.

* fix(preview): isolate project storage and preserve hidden controls

Give powered previews stable per-project loopback origins and reserve those hosts for matching powered GETs before API or static exceptions. Advertise the capability so newer Web clients retain the opaque fallback with older daemons.

Reuse the active powered transport in Present. Share a low-specificity hidden-control guard across URL and srcDoc previews without overriding explicit author rules or until-found; preserve doctype mode when synthesizing a missing head.

Validated with actual two-project storage, Workers, reload, Present and API isolation browser witnesses, form/hidden regressions, focused Web and daemon checks, workspace typecheck and guard.

* test(e2e): align public canaries with best-effort OD Next

Read the remaining recipe identity marker without the retired serialized package hash. Make the fake OpenCode help probe finite. Exercise the current public question-form action, completed question turns/new answer tasks, and no-artifact normal endings while retaining cancellation, artifact and deck assertions.

Validated with six fake-runtime cases and focused active/off public browser canaries with explicit rollout flags; no production completion policy or timeout was relaxed.

* fix(od-next): guide focus restoration after list updates

Prefer native modal lifecycle and restore focus to a current control after list rerenders, falling back to the primary action when a filtered item disappears. Real OpenCode and Pi prototypes exposed detached-trigger focus loss.

Keep this as best-effort build guidance, without output contracts or automatic repair rounds. Validated the actual artifacts through normal public edit tasks and browser interaction checks; profile resource tests passed.

* fix(od-next): retain focus through inline task updates

Extend the existing live-control guidance to inline list actions. Real Copilot and Qoder boards also lost keyboard focus when status updates rebuilt their cards; hidden filtered items need a visible fallback.

* fix: align Discovery integration tests and presentation storage

* test(daemon): tolerate transient memory origin cleanup races

---------

Co-authored-by: zhouzheng <zhouzheng@refly.ai>
2026-10-22 05:14:59 +00:00

1343 lines
50 KiB
TypeScript

import { test } from 'vitest';
import assert from 'node:assert/strict';
import path from 'node:path';
import { parsePiModels, mapPiRpcEvent, attachPiRpcSession } from '../src/agent-protocol/index.js';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import type { ChildProcess } from 'node:child_process';
import type { Writable } from 'node:stream';
// ─── parsePiModels ─────────────────────────────────────────────────────────
test('parsePiModels parses TSV table with default option prepended', () => {
const input =
'provider model context max-out thinking images\n' +
'anthropic claude-sonnet-4-5 200K 64K yes yes\n' +
'openai gpt-5 128K 16K yes yes\n';
const result = parsePiModels(input);
assert.ok(result);
assert.equal(result.length, 3);
assert.deepEqual(result[0], { id: 'default', label: 'Default (CLI config)' });
assert.equal(result[1]?.id, 'anthropic/claude-sonnet-4-5');
assert.equal(result[2]?.id, 'openai/gpt-5');
});
test('parsePiModels deduplicates identical provider/model pairs', () => {
const input =
'provider model context max-out thinking images\n' +
'openrouter claude-sonnet-4-5 200K 64K yes yes\n' +
'openrouter claude-sonnet-4-5 200K 64K yes yes\n';
const result = parsePiModels(input);
assert.ok(result);
assert.equal(result.length, 2); // default + 1 unique
assert.equal(result[1]?.id, 'openrouter/claude-sonnet-4-5');
});
test('parsePiModels returns null for empty input', () => {
assert.equal(parsePiModels(''), null);
assert.equal(parsePiModels(null), null);
assert.equal(parsePiModels(undefined), null);
});
test('parsePiModels returns null for header-only input (no model rows)', () => {
const input =
'provider model context max-out thinking images\n';
assert.equal(parsePiModels(input), null);
});
test('parsePiModels skips lines with fewer than 2 columns', () => {
const input =
'provider model context max-out thinking images\n' +
'solo-field\n' +
'anthropic claude-sonnet-4-5 200K 64K yes yes\n';
const result = parsePiModels(input);
assert.ok(result);
assert.equal(result.length, 2); // default + 1 valid
assert.equal(result[1]?.id, 'anthropic/claude-sonnet-4-5');
});
test('parsePiModels handles comment lines', () => {
const input =
'# this is a comment\n' +
'provider model context max-out thinking images\n' +
'anthropic claude-sonnet-4-5 200K 64K yes yes\n';
const result = parsePiModels(input);
assert.ok(result);
assert.equal(result.length, 2);
assert.equal(result[1]?.id, 'anthropic/claude-sonnet-4-5');
});
test('parsePiModels handles large model lists', () => {
const header = 'provider model context max-out thinking images\n';
const rows = Array.from({ length: 600 }, (_, i) =>
`provider${i % 5} model-${i} 128K 16K yes no\n`,
).join('');
const input = header + rows;
const result = parsePiModels(input);
assert.ok(result);
assert.equal(result[0]?.id, 'default');
assert.equal(result.length, 601); // default + 600
});
test('parsePiModels skips duplicate default id', () => {
const input =
'provider model context max-out thinking images\n' +
'default some-model 128K 16K yes no\n' +
'anthropic claude-sonnet-4-5 200K 64K yes yes\n';
const result = parsePiModels(input);
assert.ok(result);
assert.equal(result.length, 3); // synthetic default + default/some-model + anthropic/claude-sonnet-4-5
assert.equal(result[0]?.id, 'default');
assert.equal(result[1]?.id, 'default/some-model');
});
// ─── RPC event translation (mapPiRpcEvent) ────────────────────────────────
//
// We test the pure event mapper directly — no child process, no stdin.
// This catches regressions like tool event ordering bugs.
import { createJsonLineStream } from '../src/agent-protocol/index.js';
type JsonRecord = Record<string, unknown>;
type TestAgentEvent = JsonRecord & { type?: string; label?: string; message?: string; delta?: string };
type TestSentEvent = TestAgentEvent & { channel?: string };
type MockWritable = Pick<Writable, 'write'>;
type MockChildProcess = EventEmitter & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killed: boolean;
kill: (signal?: NodeJS.Signals | number) => boolean;
};
function asRecord(value: unknown): JsonRecord {
assert.ok(value && typeof value === 'object');
return value as JsonRecord;
}
function parseJsonRecord(line: string): JsonRecord {
return asRecord(JSON.parse(line) as unknown);
}
function eventAt(events: TestAgentEvent[], index: number): TestAgentEvent {
const event = events[index];
assert.ok(event, `expected event at index ${index}`);
return event;
}
function usageOf(event: TestAgentEvent): JsonRecord {
return asRecord(event.usage);
}
function imagesOf(parsed: JsonRecord): JsonRecord[] {
assert.ok(Array.isArray(parsed.images));
return parsed.images.map((image) => asRecord(image));
}
function simulateRpcSession(rpcLines: JsonRecord[]): TestAgentEvent[] {
const events: TestAgentEvent[] = [];
const send = (_channel: string, payload: JsonRecord) => {
events.push(payload);
};
const ctx = { runStartedAt: Date.now(), sentFirstToken: { value: false } };
const parser = createJsonLineStream((raw: unknown) => {
if (!raw || typeof raw !== 'object') return;
const event = raw as JsonRecord;
// Skip non-agent events that mapPiRpcEvent doesn't handle.
if (event.type === 'extension_ui_request') return;
if (event.type === 'response') return;
mapPiRpcEvent(event, send, ctx);
});
const input = rpcLines.map((l) => JSON.stringify(l)).join('\n') + '\n';
parser.feed(input);
parser.flush();
return events;
}
test('pi RPC: text streaming from message_update events', () => {
const events = simulateRpcSession([
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Hello ' },
},
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'world' },
},
]);
assert.deepEqual(events, [
{ type: 'status', label: 'working' },
{ type: 'status', label: 'thinking' },
{ type: 'status', label: 'streaming', ttftMs: eventAt(events, 2).ttftMs },
{ type: 'text_delta', delta: 'Hello ' },
{ type: 'text_delta', delta: 'world' },
]);
});
test('pi RPC: thinking events are mapped correctly', () => {
const events = simulateRpcSession([
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'message_update',
assistantMessageEvent: { type: 'thinking_start', contentIndex: 0 },
},
{
type: 'message_update',
assistantMessageEvent: { type: 'thinking_delta', contentIndex: 0, delta: 'hmm...' },
},
{
type: 'message_update',
assistantMessageEvent: { type: 'thinking_end', contentIndex: 0 },
},
]);
assert.deepEqual(events, [
{ type: 'status', label: 'working' },
{ type: 'status', label: 'thinking' },
{ type: 'thinking_start' },
{ type: 'thinking_delta', delta: 'hmm...' },
{ type: 'thinking_end' },
]);
});
test('pi RPC: usage extracted from turn_end', () => {
const events = simulateRpcSession([
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'turn_end',
message: {
role: 'assistant',
usage: { input: 100, output: 50, cacheRead: 20, cacheWrite: 5, totalTokens: 175 },
},
},
]);
assert.equal(events.length, 3);
assert.equal(eventAt(events, 2).type, 'usage');
assert.deepEqual(eventAt(events, 2).usage, {
input_tokens: 100,
output_tokens: 50,
cached_read_tokens: 20,
cached_write_tokens: 5,
total_tokens: 175,
});
});
test('pi RPC: turn_end assistant error maps to visible error event', () => {
const events = simulateRpcSession([
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'turn_end',
message: {
role: 'assistant',
content: [],
api: 'openai-completions',
provider: 'openai-compatible-provider',
model: 'example-model',
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 },
stopReason: 'error',
errorMessage: 'Upstream provider rejected the request',
},
},
]);
const errorEvent = events.find((e) => e.type === 'error');
assert.ok(errorEvent, 'turn_end stopReason=error should fail visibly instead of completing empty');
assert.equal(errorEvent.message, 'Upstream provider rejected the request');
});
test('pi RPC: tool execution events mapped correctly', () => {
const events = simulateRpcSession([
{ type: 'tool_execution_start', toolCallId: 'tc-1', toolName: 'read', args: { path: 'foo.txt' } },
{
type: 'tool_execution_end',
toolCallId: 'tc-1',
toolName: 'read',
result: { content: [{ type: 'text', text: 'file contents here' }] },
isError: false,
},
]);
assert.deepEqual(events, [
{ type: 'tool_use', id: 'tc-1', name: 'read', input: { path: 'foo.txt' } },
{ type: 'tool_result', toolUseId: 'tc-1', content: 'file contents here', isError: false },
]);
});
test('pi RPC: tool error results flagged correctly', () => {
const events = simulateRpcSession([
{
type: 'tool_execution_end',
toolCallId: 'tc-2',
toolName: 'bash',
result: { content: [{ type: 'text', text: 'command not found' }] },
isError: true,
},
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).isError, true);
});
test('pi RPC: compaction and retry status events', () => {
const events = simulateRpcSession([
{ type: 'compaction_start' },
{ type: 'auto_retry_start' },
]);
assert.deepEqual(events, [
{ type: 'status', label: 'compacting' },
{ type: 'status', label: 'retrying' },
]);
});
test('pi RPC: extension UI fire-and-forget events are silently consumed', () => {
const events = simulateRpcSession([
{ type: 'extension_ui_request', id: 'ui-1', method: 'setStatus', statusKey: 'foo', statusText: 'bar' },
{ type: 'extension_ui_request', id: 'ui-2', method: 'setWidget', widgetKey: 'baz' },
{ type: 'agent_start' },
]);
// Only agent_start should produce an event; the UI requests are consumed.
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).type, 'status');
assert.equal(eventAt(events, 0).label, 'working');
});
test('pi RPC: response events are silently consumed', () => {
const events = simulateRpcSession([
{ type: 'response', command: 'prompt', success: true },
{ type: 'agent_start' },
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).label, 'working');
});
test('pi RPC: full multi-turn session with tools and usage', () => {
const events = simulateRpcSession([
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Let me check.' },
},
{ type: 'tool_execution_start', toolCallId: 'tc-1', toolName: 'bash', args: { command: 'ls' } },
{
type: 'tool_execution_end',
toolCallId: 'tc-1',
toolName: 'bash',
result: { content: [{ type: 'text', text: 'file1.txt\nfile2.txt' }] },
isError: false,
},
{
type: 'turn_end',
message: {
role: 'assistant',
usage: { input: 200, output: 30, cacheRead: 0, cacheWrite: 0, totalTokens: 230 },
},
},
{ type: 'turn_start' },
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Done!' },
},
{
type: 'turn_end',
message: {
role: 'assistant',
usage: { input: 300, output: 5, cacheRead: 100, cacheWrite: 0, totalTokens: 405 },
},
},
]);
// 2 turns with text, tool_use/tool_result, and usage
assert.ok(events.some((e) => e.type === 'text_delta' && e.delta === 'Let me check.'));
assert.ok(events.some((e) => e.type === 'tool_use' && e.id === 'tc-1' && e.name === 'bash'));
assert.ok(events.some((e) => e.type === 'tool_result' && e.toolUseId === 'tc-1'));
assert.ok(events.some((e) => e.type === 'text_delta' && e.delta === 'Done!'));
// Usage from both turns
const usageEvents = events.filter((e) => e.type === 'usage');
assert.equal(usageEvents.length, 2);
assert.equal(usageOf(eventAt(usageEvents, 0)).input_tokens, 200);
assert.equal(usageOf(eventAt(usageEvents, 1)).cached_read_tokens, 100);
});
test('pi RPC: tool_use arrives before tool_result in event order', () => {
// Regression: tool_use must be emitted from tool_execution_start,
// not message_end, so the UI can pair it with the later tool_result.
const events = simulateRpcSession([
{ type: 'agent_start' },
{ type: 'turn_start' },
{ type: 'tool_execution_start', toolCallId: 'tc-1', toolName: 'read', args: { path: 'a.txt' } },
{ type: 'tool_execution_end', toolCallId: 'tc-1', toolName: 'read', result: { content: [{ type: 'text', text: 'ok' }] }, isError: false },
]);
const toolUseIdx = events.findIndex((e) => e.type === 'tool_use');
const toolResultIdx = events.findIndex((e) => e.type === 'tool_result');
assert.ok(toolUseIdx !== -1, 'tool_use event should exist');
assert.ok(toolResultIdx !== -1, 'tool_result event should exist');
assert.ok(toolUseIdx < toolResultIdx, 'tool_use must arrive before tool_result');
});
// ─── sendCommand format ─────────────────────────────────────────────────────
test('pi RPC: sendCommand writes well-formed pi command JSON', async () => {
// We test the wire format by capturing what gets written to a mock writable.
const written: string[] = [];
const mockWritable = {
write(data: string) {
written.push(data);
return true;
},
};
// Inline the sendCommand logic (same as in pi-rpc.js)
let nextId = 1;
function sendCommand(writable: MockWritable, type: string, params: JsonRecord = {}) {
const id = nextId++;
writable.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
}
const id = sendCommand(mockWritable, 'prompt', { message: 'hello' });
assert.equal(id, 1);
assert.equal(written.length, 1);
const parsed = parseJsonRecord(written[0] ?? '');
assert.equal(parsed.type, 'prompt');
assert.equal(parsed.id, 1);
assert.equal(parsed.message, 'hello');
});
test('pi RPC: sendCommand increments ids across calls', () => {
const written: string[] = [];
const mockWritable = { write(data: string) { written.push(data); return true; } };
let nextId = 1;
function sendCommand(writable: MockWritable, type: string, params: JsonRecord = {}) {
const id = nextId++;
writable.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
}
const id1 = sendCommand(mockWritable, 'prompt', { message: 'a' });
const id2 = sendCommand(mockWritable, 'steer', { message: 'b' });
assert.equal(id1, 1);
assert.equal(id2, 2);
const p1 = parseJsonRecord(written[0] ?? '');
const p2 = parseJsonRecord(written[1] ?? '');
assert.equal(p1.type, 'prompt');
assert.equal(p2.type, 'steer');
});
test('pi RPC: concurrent sessions get independent id sequences', () => {
// Each session has its own nextRpcId counter, so two sessions
// spawned at the same time get non-colliding ids.
const written1: string[] = [];
const written2: string[] = [];
const mock1 = { write(data: string) { written1.push(data); return true; } };
const mock2 = { write(data: string) { written2.push(data); return true; } };
// Session 1
let nextId1 = 1;
function send1(w: MockWritable, type: string, params: JsonRecord = {}) {
const id = nextId1++;
w.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
}
// Session 2
let nextId2 = 1;
function send2(w: MockWritable, type: string, params: JsonRecord = {}) {
const id = nextId2++;
w.write(`${JSON.stringify({ id, type, ...params })}\n`);
return id;
}
const id1 = send1(mock1, 'prompt', { message: 'hello' });
const id2 = send2(mock2, 'prompt', { message: 'world' });
assert.equal(id1, 1);
assert.equal(id2, 1); // independent counter
const p1 = parseJsonRecord(written1[0] ?? '');
const p2 = parseJsonRecord(written2[0] ?? '');
assert.equal(p1.id, 1);
assert.equal(p2.id, 1);
});
test('pi RPC: no duplicate usage when both message_end and turn_end carry usage', () => {
// Regression: pi emits both message_end and turn_end per turn,
// both carrying usage. We must only emit from turn_end to avoid
// double-counting. See Copilot review PR #117.
const events = simulateRpcSession([
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'message_end',
message: {
role: 'assistant',
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 150 },
},
},
{
type: 'turn_end',
message: {
role: 'assistant',
usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, totalTokens: 150 },
},
},
]);
const usageEvents = events.filter((e) => e.type === 'usage');
assert.equal(usageEvents.length, 1, 'should emit exactly one usage event per turn');
assert.equal(usageOf(eventAt(usageEvents, 0)).input_tokens, 100);
});
// ─── attachPiRpcSession integration tests ──────────────────────────────────
//
// These exercise the real attachPiRpcSession against a mock child process
// so regressions in the actual function (wrong events, missing model
// normalization, abort not writing to stdin, etc.) are caught.
function createMockChild(): MockChildProcess {
const child = new EventEmitter() as MockChildProcess;
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.killed = false;
child.kill = (signal?: NodeJS.Signals | number) => {
child.killed = true;
child.emit('close', null, signal);
return true;
};
return child;
}
function createSession(childOpts: { model?: string | null } = {}) {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const model = childOpts.model ?? null;
const child = createMockChild();
const session = attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'test prompt',
cwd: '/tmp',
model,
send,
});
return { child, session, events, send };
}
function feedStdoutLines(child: MockChildProcess, lines: JsonRecord[]) {
const input = lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
child.stdout.write(input);
}
function closeStdout(child: MockChildProcess) {
child.stdout.end();
child.stdin.end();
}
test('attachPiRpcSession emits status:initializing with model name', () => {
const { events } = createSession({ model: 'anthropic/claude-sonnet-4-5' });
const init = events.find(
(e) => e.channel === 'agent' && e.type === 'status' && e.label === 'initializing',
);
assert.ok(init, 'should emit status:initializing');
assert.equal(init.model, 'anthropic/claude-sonnet-4-5');
});
test('attachPiRpcSession emits status:initializing with null model when model is null', () => {
const { events } = createSession({ model: null });
const init = events.find(
(e) => e.channel === 'agent' && e.type === 'status' && e.label === 'initializing',
);
assert.ok(init, 'should emit status:initializing');
assert.equal(init.model, null);
});
test('attachPiRpcSession sends prompt command on stdin', () => {
const { child } = createSession();
// Read what was written to stdin — the first line should be a prompt command.
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
// stdin already received the prompt write; PassThrough buffers it.
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command on stdin');
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.type, 'prompt');
assert.equal(parsed.message, 'test prompt');
});
test('attachPiRpcSession abort() writes well-formed abort command to stdin', () => {
const { child, session } = createSession();
// Drain any buffered stdin data (the prompt command) before abort.
child.stdin.read();
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
session.abort();
// Read the abort command from stdin buffer.
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const abortLine = lines.find((l) => {
try { return parseJsonRecord(l).type === 'abort'; } catch { return false; }
});
assert.ok(abortLine, 'should send an abort command on stdin');
const parsed = parseJsonRecord(abortLine);
assert.equal(parsed.type, 'abort');
assert.equal(typeof parsed.id, 'number');
});
test('attachPiRpcSession abort() is idempotent and no-op after stdin close', () => {
const { child, session } = createSession();
// Drain buffered data.
child.stdin.read();
// Close stdin (simulates pi process exiting).
child.stdin.end();
child.stdin.emit('close');
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
// abort() should be a no-op because finished is already true or stdin is closed.
session.abort();
session.abort(); // idempotent
const buffered = child.stdin.read();
assert.equal(buffered, null, 'no bytes should be written after abort on closed stdin');
});
// ─── extension_error event handling ─────────────────────────────────────────
test('pi RPC: extension_error maps to error event', () => {
const events = simulateRpcSession([
{ type: 'extension_error', extensionPath: '/path/to/ext.ts', event: 'tool_call', error: 'Something broke' },
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Something broke');
});
test('pi RPC: extension_error with non-string error uses fallback', () => {
const events = simulateRpcSession([
{ type: 'extension_error', extensionPath: '/path/to/ext.ts', error: { message: 'nested' } },
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Extension error');
});
test('pi RPC: extension_error with missing error uses fallback', () => {
const events = simulateRpcSession([
{ type: 'extension_error', extensionPath: '/path/to/ext.ts' },
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Extension error');
});
// ─── message_update error delta handling ────────────────────────────────────
test('pi RPC: message_update with error delta maps to error event', () => {
const events = simulateRpcSession([
{
type: 'message_update',
assistantMessageEvent: { type: 'error', reason: 'aborted' },
},
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'aborted');
});
test('pi RPC: message_update error delta falls back to delta text', () => {
const events = simulateRpcSession([
{
type: 'message_update',
assistantMessageEvent: { type: 'error', delta: 'Connection reset' },
},
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Connection reset');
});
test('pi RPC: message_update error delta with no reason or delta uses fallback', () => {
const events = simulateRpcSession([
{
type: 'message_update',
assistantMessageEvent: { type: 'error' },
},
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Agent error');
});
test('pi RPC: message_update error after partial output still emits error', () => {
// Even after text deltas have been emitted (agentProducedOutput = true
// on the server side), a subsequent error delta should still surface
// so the run flips to failed rather than succeeding with a partial
// response.
const events = simulateRpcSession([
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Partial output' },
},
{
type: 'message_update',
assistantMessageEvent: { type: 'error', reason: 'context_overflow' },
},
]);
// status:working, status:thinking, status:streaming, text_delta, error
assert.equal(events.length, 5);
const errorEvent = events.find((e) => e.type === 'error');
assert.ok(errorEvent, 'should emit an error event after partial output');
assert.equal(errorEvent.message, 'context_overflow');
});
// ─── auto_retry_end failure event handling ────────────────────────────────────
test('pi RPC: auto_retry_end with success=false maps to error event', () => {
const events = simulateRpcSession([
{ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 1000, errorMessage: 'overloaded' },
{ type: 'auto_retry_end', success: false, attempt: 3, finalError: '529 overloaded_error: Overloaded' },
]);
// auto_retry_start → status:retrying, auto_retry_end → error
assert.equal(events.length, 2);
assert.equal(eventAt(events, 0).type, 'status');
assert.equal(eventAt(events, 0).label, 'retrying');
assert.equal(eventAt(events, 1).type, 'error');
assert.equal(eventAt(events, 1).message, '529 overloaded_error: Overloaded');
});
test('pi RPC: auto_retry_end with success=true does not emit error', () => {
const events = simulateRpcSession([
{ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 1000, errorMessage: 'overloaded' },
{ type: 'auto_retry_end', success: true, attempt: 2 },
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).type, 'status');
assert.equal(eventAt(events, 0).label, 'retrying');
});
test('pi RPC: auto_retry_end failure with missing finalError uses fallback', () => {
const events = simulateRpcSession([
{ type: 'auto_retry_end', success: false, attempt: 3 },
]);
assert.equal(events.length, 1);
assert.equal(eventAt(events, 0).type, 'error');
assert.equal(eventAt(events, 0).message, 'Auto-retry exhausted');
});
// ─── imagePaths forwarding in attachPiRpcSession ─────────────────────────────
test('attachPiRpcSession sends prompt with images when imagePaths provided', async () => {
const { child } = createSession();
// Create a small test image file.
const tmpDir = await import('node:os').then((m) => m.tmpdir());
const tmpFile = path.join(tmpDir, `pi-rpc-test-${Date.now()}.png`);
await import('node:fs/promises').then((fsp) =>
fsp.writeFile(tmpFile, Buffer.from('iVBORw0KGgo=', 'base64')),
);
try {
const events2: TestSentEvent[] = [];
const send2 = (channel: string, payload: JsonRecord) => events2.push({ channel, ...payload });
const child2 = createMockChild();
attachPiRpcSession({
child: child2 as unknown as ChildProcess,
prompt: 'describe this image',
cwd: '/tmp',
model: null,
send: send2,
imagePaths: [tmpFile],
});
// Read the stdin data to find the prompt command.
const chunks: string[] = [];
child2.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child2.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command');
const parsed = parseJsonRecord(promptLine);
const images = imagesOf(parsed);
assert.equal(images.length, 1);
assert.equal(images[0]?.type, 'image');
assert.equal(images[0]?.mimeType, 'image/png');
assert.ok(typeof images[0]?.data === 'string' && images[0].data.length > 0);
} finally {
await import('node:fs/promises').then((fsp) => fsp.unlink(tmpFile).catch(() => {}));
}
});
test('attachPiRpcSession sends prompt without images when imagePaths is empty', () => {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'hello',
cwd: '/tmp',
model: null,
send,
imagePaths: [],
});
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command');
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, 'prompt should not include images when none provided');
});
test('attachPiRpcSession skips unreadable image paths gracefully', () => {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'check this',
cwd: '/tmp',
model: null,
send,
imagePaths: ['/nonexistent/path/fake-image.png'],
});
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine, 'should send a prompt command');
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, 'prompt should not include images for unreadable paths');
});
test('attachPiRpcSession rejects non-file image paths (directories)', async () => {
const fsp = await import('node:fs/promises');
const tmpDir = await import('node:os').then((m) => m.tmpdir());
const dirPath = path.join(tmpDir, `pi-rpc-test-dir-${Date.now()}`);
await fsp.mkdir(dirPath);
try {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'check this dir',
cwd: '/tmp',
model: null,
send,
imagePaths: [dirPath],
});
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, 'directories should not be forwarded as images');
} finally {
await fsp.rmdir(dirPath);
}
});
test('attachPiRpcSession rejects disallowed image extensions', async () => {
const fsp = await import('node:fs/promises');
const tmpDir = await import('node:os').then((m) => m.tmpdir());
const tmpFile = path.join(tmpDir, `pi-rpc-test-${Date.now()}.txt`);
await fsp.writeFile(tmpFile, 'not an image');
try {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'what is this',
cwd: '/tmp',
model: null,
send,
imagePaths: [tmpFile],
});
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, '.txt files should not be forwarded as images');
} finally {
await fsp.unlink(tmpFile);
}
});
test('attachPiRpcSession rejects symlink escape outside uploadRoot', async () => {
const fsp = await import('node:fs/promises');
const tmpDir = await import('node:os').then((m) => m.tmpdir());
// Create a real image file outside the upload root.
const outsideDir = path.join(tmpDir, `pi-rpc-test-outside-${Date.now()}`);
await fsp.mkdir(outsideDir);
const outsideFile = path.join(outsideDir, 'real.jpg');
await fsp.writeFile(outsideFile, Buffer.from('fake-jpg-content'));
// Create the upload root and a symlink inside it pointing outside.
const uploadRoot = path.join(tmpDir, `pi-rpc-test-uploads-${Date.now()}`);
await fsp.mkdir(uploadRoot);
const symlinkPath = path.join(uploadRoot, 'escape.jpg');
await fsp.symlink(outsideFile, symlinkPath);
try {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'check this',
cwd: '/tmp',
model: null,
send,
imagePaths: [symlinkPath],
uploadRoot,
});
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.equal(parsed.images, undefined, 'symlinks resolving outside uploadRoot should not be forwarded as images');
} finally {
await fsp.unlink(symlinkPath);
await fsp.rmdir(uploadRoot);
await fsp.unlink(outsideFile);
await fsp.rmdir(outsideDir);
}
});
test('attachPiRpcSession allows symlink inside uploadRoot', async () => {
const fsp = await import('node:fs/promises');
const tmpDir = await import('node:os').then((m) => m.tmpdir());
// Create a real image file inside the upload root.
const uploadRoot = path.join(tmpDir, `pi-rpc-test-uploads-in-${Date.now()}`);
await fsp.mkdir(uploadRoot);
const realFile = path.join(uploadRoot, 'real.png');
// Minimal valid PNG header + IHDR so the content isn't empty.
await fsp.writeFile(realFile, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
// Create a symlink inside the same root pointing to the real file.
const symlinkPath = path.join(uploadRoot, 'link.png');
await fsp.symlink(realFile, symlinkPath);
try {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'check this',
cwd: '/tmp',
model: null,
send,
imagePaths: [symlinkPath],
uploadRoot,
});
const chunks: string[] = [];
child.stdin.on('data', (chunk: Buffer) => chunks.push(chunk.toString()));
const buffered = child.stdin.read();
if (buffered) chunks.push(buffered.toString());
const lines = chunks.join('').trim().split('\n');
const promptLine = lines.find((l) => {
try { return parseJsonRecord(l).type === 'prompt'; } catch { return false; }
});
assert.ok(promptLine);
const parsed = parseJsonRecord(promptLine);
assert.ok(Array.isArray(parsed.images), 'symlink inside uploadRoot should be forwarded as image');
assert.equal(parsed.images.length, 1);
assert.equal(parsed.images[0].type, 'image');
assert.equal(parsed.images[0].mimeType, 'image/png');
} finally {
await fsp.unlink(symlinkPath);
await fsp.unlink(realFile);
await fsp.rmdir(uploadRoot);
}
});
// ─── original test continues ────────────────────────────────────────────────
test('attachPiRpcSession: no agent events emitted after abort()', () => {
const { child, events, session } = createSession();
// Feed normal session events.
feedStdoutLines(child, [
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Thinking...' },
},
]);
const beforeCount = events.length;
assert.ok(beforeCount > 0, 'should have events before abort');
// Abort — sets finished = true, gates further stdout events.
session.abort();
// Feed more agent events that arrive during the abort grace window.
feedStdoutLines(child, [
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Should not appear' },
},
{ type: 'tool_execution_start', toolCallId: 'tc-1', toolName: 'bash', args: { command: 'ls' } },
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'More text' },
},
{
type: 'turn_end',
message: {
role: 'assistant',
usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15 },
},
},
{ type: 'agent_end' },
]);
closeStdout(child);
// No new agent events should have been emitted after abort.
assert.equal(events.length, beforeCount, 'no events should be emitted after abort');
assert.ok(
events.every((e) => e.delta !== 'Should not appear' && e.delta !== 'More text'),
'post-abort text must not appear in events',
);
});
// ─── parentSession (conversational continuity) ─────────────────────────
test('attachPiRpcSession loads history with switch_session before prompt when parentSession is provided', () => {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
const session = attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'test',
send,
parentSession: '/path/to/prior-session.jsonl',
});
// Use only read() to read stdin (not on('data')). attachPiRpcSession writes
// synchronously to the PassThrough, so all data is buffered and available
// via read(). Mixing on('data') + read() in flowing mode duplicates lines.
const chunks: string[] = [];
let buffered = child.stdin.read();
while (buffered) {
chunks.push(buffered.toString());
buffered = child.stdin.read();
}
const lines = chunks.join('').trim().split('\n').filter(Boolean);
// First line should load the existing session, and prompt should
// wait until pi confirms it loaded the parent session.
assert.equal(lines.length, 1, 'should wait for switch_session response before prompt');
const first = parseJsonRecord(lines[0] ?? '');
assert.equal(first.type, 'switch_session', 'new_session only tracks ancestry and starts empty');
assert.equal(first.sessionPath, '/path/to/prior-session.jsonl', 'sessionPath should match');
feedStdoutLines(child, [{ type: 'response', id: first.id, success: true }]);
const promptChunk = child.stdin.read();
const promptLines = promptChunk ? promptChunk.toString().trim().split('\n').filter(Boolean) : [];
assert.equal(promptLines.length, 1, 'should send prompt after switch_session succeeds');
const second = parseJsonRecord(promptLines[0] ?? '');
assert.equal(second.type, 'prompt', 'second cmd should be prompt');
assert.ok((first.id as number) < (second.id as number), 'switch_session id should be less than prompt id');
// getLastSessionPath should return null initially (no real session dir).
assert.equal(session.getLastSessionPath(), null, 'no session path before agent_end');
});
test('attachPiRpcSession fails and does not send prompt when parentSession is rejected', () => {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'trimmed latest turn',
send,
parentSession: '/path/to/missing-session.jsonl',
});
const firstChunk = child.stdin.read();
const firstLines = firstChunk ? firstChunk.toString().trim().split('\n').filter(Boolean) : [];
assert.equal(firstLines.length, 1, 'should only send switch_session initially');
const first = parseJsonRecord(firstLines[0] ?? '');
assert.equal(first.type, 'switch_session');
feedStdoutLines(child, [{ type: 'response', id: first.id, success: false, error: 'missing session' }]);
const promptChunk = child.stdin.read();
assert.equal(promptChunk, null, 'must not send trimmed prompt after parent session failure');
assert.deepEqual(
events.find((event) => event.channel === 'error'),
{
channel: 'error',
message: 'parent session rejected: missing session',
code: 'PI_PARENT_SESSION_FAILED',
},
);
});
test('attachPiRpcSession does not switch sessions when parentSession is omitted', () => {
const events: TestSentEvent[] = [];
const send = (channel: string, payload: JsonRecord) => events.push({ channel, ...payload });
const child = createMockChild();
child.stdout.end();
attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'test',
send,
});
// Use only read() to avoid streaming-mode duplication.
const chunks: string[] = [];
let buffered = child.stdin.read();
while (buffered) {
chunks.push(buffered.toString());
buffered = child.stdin.read();
}
const lines = chunks.join('').trim().split('\n').filter(Boolean);
const types = lines.map((l) => parseJsonRecord(l).type);
assert.ok(!types.includes('switch_session'), 'should NOT send switch_session');
assert.ok(types.includes('prompt'), 'should send prompt');
});
test('attachPiRpcSession does not send a trimmed prompt when an extension cancels session loading', () => {
const events: TestSentEvent[] = [];
const child = createMockChild();
const session = attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'trimmed latest turn',
parentSession: '/path/to/prior-session.jsonl',
send: (channel, payload: JsonRecord) => events.push({ channel, ...payload }),
});
const first = parseJsonRecord(child.stdin.read().toString().trim());
feedStdoutLines(child, [{ type: 'response', id: first.id, success: true, data: { cancelled: true } }]);
assert.equal(child.stdin.read(), null, 'a canceled switch did not restore the conversation');
assert.equal(session.hasFatalError(), true);
assert.deepEqual(events.find(event => event.channel === 'error'), {
channel: 'error', message: 'Parent session loading was canceled by a Pi extension.', code: 'PI_PARENT_SESSION_FAILED',
});
});
test('attachPiRpcSession captures the native sessionFile reported by its own get_state response', () => {
const child = createMockChild();
const session = attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'test',
cwd: '/project-without-local-pi-sessions',
send: () => {},
});
const prompt = parseJsonRecord(child.stdin.read().toString().trim());
feedStdoutLines(child, [{ type: 'response', id: prompt.id, success: true }]);
const stateChunk = child.stdin.read();
assert.ok(stateChunk, 'query the running Pi process rather than guessing its storage home');
const state = parseJsonRecord(stateChunk.toString().trim());
assert.equal(state.type, 'get_state');
feedStdoutLines(child, [
{ type: 'response', id: (state.id as number) + 1, success: true, data: { sessionFile: '/other/session.jsonl' } },
{ type: 'response', id: state.id, success: true, data: { sessionFile: '/agent-home/sessions/current.jsonl' } },
]);
assert.equal(session.getLastSessionPath(), null, 'do not publish a handle before the turn completes');
feedStdoutLines(child, [{ type: 'agent_end' }]);
assert.equal(session.getLastSessionPath(), '/agent-home/sessions/current.jsonl');
closeStdout(child);
});
test('attachPiRpcSession keeps generation non-blocking when get_state is unsupported or unanswered', () => {
for (const answer of [false, undefined]) {
const events: TestSentEvent[] = [];
const child = createMockChild();
const session = attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'test',
send: (channel, payload: JsonRecord) => events.push({ channel, ...payload }),
});
const prompt = parseJsonRecord(child.stdin.read().toString().trim());
feedStdoutLines(child, [{ type: 'response', id: prompt.id, success: true }]);
const stateChunk = child.stdin.read();
assert.ok(stateChunk, 'get_state should be a best-effort metadata query');
const state = parseJsonRecord(stateChunk.toString().trim());
if (answer !== undefined) {
feedStdoutLines(child, [{ type: 'response', id: state.id, success: answer, error: 'unsupported' }]);
}
feedStdoutLines(child, [
{ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'Generated output' } },
{ type: 'agent_end' },
]);
assert.equal(session.hasFatalError(), false);
assert.ok(events.some(event => event.delta === 'Generated output'));
assert.ok(!events.some(event => event.channel === 'error'));
assert.equal(session.getLastSessionPath(), null);
closeStdout(child);
}
});
test('attachPiRpcSession getLastSessionPath returns only the session changed during this run', async () => {
const tmpDir = await import('node:os').then((m) => m.tmpdir());
const piDir = path.join(tmpDir, `.pi-test-${Date.now()}`);
// pi session capture scans <cwd>/.pi/sessions/, NOT <cwd>/sessions/.
const sessionsDir = path.join(piDir, '.pi', 'sessions');
const fsp = await import('node:fs/promises');
await fsp.mkdir(sessionsDir, { recursive: true });
// Existing unrelated sessions, even with a newer mtime, must not be
// captured as this run's parent.
const unrelatedSessionFile = path.join(sessionsDir, '2026-06-01T00-00-00_unrelated.jsonl');
await fsp.writeFile(unrelatedSessionFile, '{"msg":"unrelated"}\n');
const future = new Date(Date.now() + 60_000);
await fsp.utimes(unrelatedSessionFile, future, future);
try {
const send = (_channel: string, _payload: JsonRecord) => {};
const child = createMockChild();
const session = attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'test',
cwd: piDir,
send,
});
const sessionFile = path.join(sessionsDir, '2026-06-01T00-00-01_current.jsonl');
await fsp.writeFile(sessionFile, '{"msg":"current"}\n');
// Feed agent_end to trigger session path capture.
feedStdoutLines(child, [
{ type: 'agent_start' },
{ type: 'turn_start' },
{
type: 'message_update',
assistantMessageEvent: { type: 'text_delta', contentIndex: 0, delta: 'Hello' },
},
{ type: 'agent_end' },
]);
closeStdout(child);
// Wait a tick for the async capture to complete.
await new Promise((resolve) => setTimeout(resolve, 10));
const captured = session.getLastSessionPath();
assert.equal(captured, sessionFile, 'should capture only the session file changed after attach');
} finally {
await fsp.rm(piDir, { recursive: true, force: true }).catch(() => {});
}
});
test('attachPiRpcSession getLastSessionPath returns null when multiple session files changed', async () => {
const tmpDir = await import('node:os').then((m) => m.tmpdir());
const piDir = path.join(tmpDir, `.pi-test-${Date.now()}-ambiguous`);
const sessionsDir = path.join(piDir, '.pi', 'sessions');
const fsp = await import('node:fs/promises');
await fsp.mkdir(sessionsDir, { recursive: true });
try {
const send = (_channel: string, _payload: JsonRecord) => {};
const child = createMockChild();
const session = attachPiRpcSession({
child: child as unknown as ChildProcess,
prompt: 'test',
cwd: piDir,
send,
});
await fsp.writeFile(path.join(sessionsDir, 'current.jsonl'), '{"msg":"current"}\n');
await fsp.writeFile(path.join(sessionsDir, 'other.jsonl'), '{"msg":"other"}\n');
feedStdoutLines(child, [{ type: 'agent_end' }]);
closeStdout(child);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(session.getLastSessionPath(), null, 'ambiguous session changes should not be captured');
} finally {
await fsp.rm(piDir, { recursive: true, force: true }).catch(() => {});
}
});