Files
hapi/cli/src/pi/piTransport.test.ts
T
KorenKritaandGitHub 021b5c194b feat(pi): complete RPC parity, native steer, and history controls (#1353)
* feat(pi): complete RPC interaction parity

* feat(pi): integrate native conversation history

* fix(pi): harden RPC lifecycle boundaries

* fix(pi): address review lifecycle and upload boundaries

* fix(pi): release history transaction on rollback deadline

* fix(pi): isolate preflight and timed-out mutations

* fix(pi): preserve retry and editor boundaries

* fix(pi): disable unavailable history synchronization

* fix(pi): gate fallback readiness on history baseline

* fix(pi): bind uploads and retire extension requests

* fix(pi): preserve canceled and legacy stream boundaries

* fix(pi): preserve native fork runtime state

* fix(pi): persist dialogs and preserve select values

* fix(pi): keep upload authorization path-stable

* feat(pi): preserve native steer semantics

Route ordinary sends during an active Pi main turn through native steer while keeping explicit queue delivery on the existing composer gestures. Persist the delivery contract across Hub replay and Web retries, and guard stale steer dispatch with streaming generations and ordered prompt fallback.

* fix(pi): queue deferred steer deliveries

Keep native steer only for the initial live emit. Reconnect replay, CLI backfill, clear-gate release, and mature delivery now downgrade turn-scoped steer intent to the durable HAPI queue without mutating stored provenance.

* fix(pi): retain abort guard through preflight miss

Treat an immediate no-active abort rejection as a possible async-preflight race. Keep the existing abort boundary alive so a late agent_start receives the compensating abort before queued work is released.

* fix(pi): queue stale steer retries

A failed send no longer reuses turn-scoped steer intent after its original Pi generation is lost. Text restoration, attachment retry, and legacy retry provenance all enter the durable HAPI queue while fresh ordinary sends retain native steer behavior.

* fix(pi): invalidate rejected abort generation

After a no-active preflight abort waits through late-start compensation, mark the target stream idle while the runtime mutation lease is still held. Waiting native steers therefore fall back instead of entering the aborted generation.

* fix(pi): queue idempotent steer retries

Track whether a localId insert created a new row. Initial inserts may retain live Pi steer, while duplicate-localId retries deliver a queue-safe view of the stored row without overwriting its original provenance.

* fix(pi): sync command-only history before fallback

Read the Pi append log before retiring a successful prompt that produced no agent lifecycle. Preserve FIFO history associations across missing entry events, and fail the wrapper closed if that mandatory synchronization cannot be completed.
2026-08-04 11:01:00 +08:00

225 lines
8.4 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { EventEmitter } from 'node:events';
const mockSpawn = vi.fn();
vi.mock('node:child_process', () => ({
get spawn() { return mockSpawn; }
}));
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn(),
warn: vi.fn(),
info: vi.fn()
}
}));
function createMockProcess(): ChildProcessWithoutNullStreams & EventEmitter {
const emitter = new EventEmitter() as ChildProcessWithoutNullStreams & EventEmitter;
const stdin = new EventEmitter() as any;
stdin.write = vi.fn().mockReturnValue(true);
stdin.end = vi.fn();
const stdout = new EventEmitter() as any;
stdout.setEncoding = vi.fn();
const stderr = new EventEmitter() as any;
stderr.setEncoding = vi.fn();
emitter.stdin = stdin;
emitter.stdout = stdout;
emitter.stderr = stderr;
emitter.kill = vi.fn().mockReturnValue(true);
// pid is read-only in ChildProcess, use type assertion for mock
(emitter as any).pid = 12345;
return emitter;
}
const { PiTransport } = await import('./piTransport');
describe('PiTransport', () => {
let mockProcess: ReturnType<typeof createMockProcess>;
beforeEach(() => {
vi.clearAllMocks();
mockProcess = createMockProcess();
mockSpawn.mockReturnValue(mockProcess);
});
describe('start()', () => {
it('should spawn pi with correct args', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
expect(mockSpawn).toHaveBeenCalledWith('pi', ['--mode', 'rpc'], expect.objectContaining({
cwd: '/work',
stdio: ['pipe', 'pipe', 'pipe']
}));
});
it('should emit error event on ENOENT', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
const errorSpy = vi.fn();
transport.onError(errorSpy);
const spawnError = new Error('spawn pi ENOENT') as NodeJS.ErrnoException;
spawnError.code = 'ENOENT';
mockProcess.emit('error', spawnError);
expect(errorSpy).toHaveBeenCalledWith(expect.any(Error));
expect(errorSpy.mock.calls[0][0].message).toContain('not found');
});
it('should ignore double-start call', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
expect(mockSpawn).toHaveBeenCalledTimes(1);
transport.start();
expect(mockSpawn).toHaveBeenCalledTimes(1);
});
});
describe('send()', () => {
it('should write JSON to stdin', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
transport.send({ type: 'prompt', message: 'hello' });
expect(mockProcess.stdin.write).toHaveBeenCalledWith(
JSON.stringify({ type: 'prompt', message: 'hello' }) + '\n'
);
});
it('should handle EPIPE gracefully without throwing', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
mockProcess.stdin.write = vi.fn().mockImplementation(() => {
const err = new Error('write EPIPE') as NodeJS.ErrnoException;
err.code = 'EPIPE';
throw err;
});
expect(() => transport.send({ type: 'prompt', message: 'test' })).not.toThrow();
});
});
describe('onEvent()', () => {
it('should parse valid JSONL from stdout and call handler', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
const handler = vi.fn();
transport.onEvent(handler);
const event = { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hello' } };
mockProcess.stdout.emit('data', JSON.stringify(event) + '\n');
expect(handler).toHaveBeenCalledWith(event);
});
it('should skip malformed JSON and not crash', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
const handler = vi.fn();
transport.onEvent(handler);
mockProcess.stdout.emit('data', 'not-json\n');
expect(handler).not.toHaveBeenCalled();
});
it('should handle multiple JSONL lines in one chunk', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
const handler = vi.fn();
transport.onEvent(handler);
const event1 = { type: 'turn_start' };
const event2 = { type: 'turn_end', message: {} };
mockProcess.stdout.emit('data', JSON.stringify(event1) + '\n' + JSON.stringify(event2) + '\n');
expect(handler).toHaveBeenCalledTimes(2);
expect(handler).toHaveBeenCalledWith(event1);
expect(handler).toHaveBeenCalledWith(event2);
});
it('should buffer and reassemble split JSONL across chunks', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
const handler = vi.fn();
transport.onEvent(handler);
const event = { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'hello' } };
const fullLine = JSON.stringify(event) + '\n';
// Split the line into two chunks — no newline in first chunk
mockProcess.stdout.emit('data', fullLine.slice(0, 20));
expect(handler).not.toHaveBeenCalled();
// Send the rest with newline
mockProcess.stdout.emit('data', fullLine.slice(20));
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(event);
});
});
describe('kill()', () => {
it('should send SIGTERM to the process', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
transport.kill();
expect(mockProcess.kill).toHaveBeenCalledWith('SIGTERM');
});
it('should be a no-op when process is not running', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
expect(() => transport.kill()).not.toThrow();
});
});
describe('onClose()', () => {
it('does not report stdout end as a synthetic close before the real exit code', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
const closeHandler = vi.fn();
transport.onClose(closeHandler);
mockProcess.stdout.emit('end');
expect(closeHandler).not.toHaveBeenCalled();
mockProcess.emit('close', 7, null);
mockProcess.emit('close', 7, null);
expect(closeHandler).toHaveBeenCalledTimes(1);
expect(closeHandler).toHaveBeenCalledWith(7, null);
});
it('should call handler when subprocess exits', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
const closeHandler = vi.fn();
transport.onClose(closeHandler);
mockProcess.emit('close', 1, null);
expect(closeHandler).toHaveBeenCalledWith(1, null);
});
it('should call handler with signal when killed by signal', () => {
const transport = new PiTransport({ command: 'pi', args: ['--mode', 'rpc'], cwd: '/work' });
transport.start();
const closeHandler = vi.fn();
transport.onClose(closeHandler);
mockProcess.emit('close', null, 'SIGTERM');
expect(closeHandler).toHaveBeenCalledWith(null, 'SIGTERM');
});
});
});