mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* 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
200 lines
6.6 KiB
TypeScript
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();
|
|
});
|
|
});
|