Files
hapi/cli/src/pi/runPi.test.ts
T
KorenKritaandGitHub abf9cb02a5 fix(pi): resume archived sessions safely (#1308)
* fix(pi): resume archived sessions safely

* fix(pi): harden native resume startup

* fix(pi): harden resume termination evidence

* fix(runner): persist resume process evidence

* fix(runner): track resume process generations

* fix(runner): verify full session tree shutdown

* fix(pi): block pre-mapping resume dedup
2026-08-02 21:15:52 +08:00

200 lines
6.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
type TransportOptions = { command: string; args: string[]; cwd: string };
type LifecycleOptions = { stopKeepAlive: () => void };
const harness = vi.hoisted(() => ({
transportOptions: null as TransportOptions | null,
sent: [] as unknown[],
throwOnGetCommands: true,
onError: null as ((error: Error) => void) | null,
killCount: 0,
cleanupCount: 0,
session: {
keepAlive: vi.fn(),
onUserMessage: vi.fn(),
onCancelQueuedMessage: vi.fn(),
rpcHandlerManager: { registerHandler: vi.fn() },
},
}));
vi.mock('@/agent/sessionFactory', () => ({
bootstrapSession: vi.fn(async () => ({ api: {}, session: harness.session })),
bootstrapExistingSession: vi.fn(async () => ({ api: {}, session: harness.session })),
}));
vi.mock('@/agent/runnerLifecycle', () => ({
createRunnerLifecycle: vi.fn((options: LifecycleOptions) => {
return {
registerProcessHandlers: vi.fn(),
cleanupAndExit: vi.fn(async () => {
harness.cleanupCount += 1;
options.stopKeepAlive();
}),
markCrash: vi.fn(),
setExitCode: vi.fn(),
setArchiveReason: vi.fn(),
setSessionEndReason: vi.fn(),
hasExplicitSessionEndReason: vi.fn(() => true),
};
}),
createModeChangeHandler: vi.fn(() => vi.fn()),
setControlledByUser: vi.fn(),
}));
vi.mock('@/ui/logger', () => ({
logger: {
debug: vi.fn(),
getLogPath: vi.fn(() => '/tmp/hapi.log'),
},
}));
vi.mock('./piTransport', () => ({
PiTransport: class {
constructor(options: TransportOptions) {
harness.transportOptions = options;
}
onError(callback: (error: Error) => void): void {
harness.onError = callback;
}
onClose(): void {}
onEvent(): void {}
start(): void {}
send(command: unknown): void {
harness.sent.push(command);
if (harness.throwOnGetCommands && (command as { type?: string }).type === 'get_commands') {
throw new Error('stop test transport');
}
}
kill(): void {
harness.killCount += 1;
}
},
}));
import { buildPiCommandInventory, formatPiUserMessage, rewritePiSkillPrompt, runPi } from './runPi';
import { bootstrapExistingSession } from '@/agent/sessionFactory';
import { PiSession } from './session';
describe('Pi command namespaces', () => {
const commands = [
{ name: 'session-name', description: 'Rename session', source: 'extension' as const },
{ name: 'fix-tests', description: 'Fix tests', source: 'prompt' as const },
{ name: 'skill:brave-search', description: 'Search the web', source: 'skill' as const },
];
it('exposes native skills through $ and keeps them out of slash completion', () => {
expect(buildPiCommandInventory(commands)).toEqual({
skills: [
{ name: 'brave-search', description: 'Search the web' },
],
slashCommands: [
{ name: 'session-name', description: 'Rename session', source: 'plugin' },
{ name: 'fix-tests', description: 'Fix tests', source: 'user' },
],
});
});
it('rewrites HAPI $ skills to Pi native skill commands', () => {
expect(rewritePiSkillPrompt('$brave-search latest news', commands))
.toBe('/skill:brave-search latest news');
expect(rewritePiSkillPrompt('$new-skill now', [])).toBe('/skill:new-skill now');
expect(rewritePiSkillPrompt('$PATH', commands)).toBe('$PATH');
});
it('keeps the native skill command first when the message has attachments', () => {
expect(formatPiUserMessage('$brave-search', [{
id: 'attachment-1',
filename: 'query.txt',
mimeType: 'text/plain',
size: 5,
path: '/tmp/query.txt',
}], commands)).toBe('/skill:brave-search\n\n@/tmp/query.txt');
});
});
describe('runPi startup', () => {
beforeEach(() => {
harness.transportOptions = null;
harness.sent.length = 0;
harness.throwOnGetCommands = true;
harness.onError = null;
harness.killCount = 0;
harness.cleanupCount = 0;
vi.useRealTimers();
});
it('lets Pi create a fresh session when no resume ID is provided', async () => {
await runPi({ workingDirectory: '/work' });
expect(harness.transportOptions).toEqual({
command: 'pi',
args: ['--mode', 'rpc'],
cwd: '/work',
});
expect(harness.sent).toEqual([
{ type: 'get_state' },
{ type: 'get_available_models' },
{ type: 'get_commands' },
]);
});
it('resumes with --session and keeps the session selected by Pi', async () => {
await runPi({
workingDirectory: '/work',
resumeSessionId: 'pi-session-123',
});
expect(harness.transportOptions).toEqual({
command: 'pi',
args: ['--mode', 'rpc', '--session', 'pi-session-123'],
cwd: '/work',
});
expect(harness.sent).toEqual([
{ type: 'get_state' },
{ type: 'get_available_models' },
{ type: 'get_commands' },
]);
});
it('bootstraps the existing HAPI row for runner native resume', async () => {
await runPi({
workingDirectory: '/work',
existingSessionId: 'hapi-session-pi-1',
resumeSessionId: 'pi-session-1',
startedBy: 'runner',
});
expect(bootstrapExistingSession).toHaveBeenCalledWith({
sessionId: 'hapi-session-pi-1',
flavor: 'pi',
startedBy: 'runner',
workingDirectory: '/work',
});
});
it.each([
['fresh', undefined, 1, 0],
['resume', 'pi-session-1', 0, 1],
] as const)('applies the startup fallback only to %s sessions', async (_label, resumeSessionId, expectedCalls, expectedKills) => {
vi.useFakeTimers();
harness.throwOnGetCommands = false;
const markReady = vi.spyOn(PiSession.prototype, 'markReady');
const running = runPi({ workingDirectory: '/work', resumeSessionId });
await vi.advanceTimersByTimeAsync(31_000);
expect(markReady).toHaveBeenCalledTimes(expectedCalls);
expect(harness.cleanupCount).toBe(expectedKills);
harness.onError?.(new Error('stop test transport'));
await running;
markReady.mockRestore();
});
});