Parent directory

handoff.test.ts

6915 bytes
  1import assert from "node:assert/strict";
  2import { mkdtemp, rm } from "node:fs/promises";
  3import { tmpdir } from "node:os";
  4import { dirname, join } from "node:path";
  5import { fileURLToPath } from "node:url";
  6import test from "node:test";
  7
  8import { InMemoryCredentialStore, type UserMessage } from "@earendil-works/pi-ai";
  9import {
 10	discoverAndLoadExtensions,
 11	ExtensionRunner,
 12	ModelRegistry,
 13	ModelRuntime,
 14	SessionManager,
 15	type ExtensionCommandContextActions,
 16} from "@earendil-works/pi-coding-agent";
 17
 18const extensionPath = join(dirname(fileURLToPath(import.meta.url)), "..", "index.ts");
 19
 20function userMessage(text: string): UserMessage {
 21	return {
 22		role: "user",
 23		content: [{ type: "text", text }],
 24		timestamp: Date.now(),
 25	};
 26}
 27
 28async function createHandoffHarness(cwd: string) {
 29	const credentials = new InMemoryCredentialStore();
 30	const modelRuntime = await ModelRuntime.create({
 31		credentials,
 32		modelsPath: null,
 33		allowModelNetwork: false,
 34	});
 35	const model = modelRuntime.getModels()[0];
 36	assert.ok(model, "the bundled model catalog should not be empty");
 37	await credentials.modify(model.provider, async () => ({ type: "api_key", key: "test-key" }));
 38
 39	const sessionManager = SessionManager.create(cwd, join(cwd, ".sessions"));
 40	const loaded = await discoverAndLoadExtensions([extensionPath], cwd, join(cwd, ".agent"));
 41	assert.deepEqual(loaded.errors, []);
 42
 43	const modelRegistry = new ModelRegistry(modelRuntime);
 44	const runner = new ExtensionRunner(
 45		loaded.extensions,
 46		loaded.runtime,
 47		cwd,
 48		sessionManager,
 49		modelRegistry,
 50	);
 51	const sentUserMessages: Array<{
 52		readonly content: string;
 53		readonly options?: {
 54			readonly deliverAs?: "steer" | "followUp";
 55			readonly expandPromptTemplates?: boolean;
 56		};
 57	}> = [];
 58	loaded.runtime.sendUserMessage = (content, options) => {
 59		if (typeof content !== "string") {
 60			throw new Error("Handoff test received unexpected image content");
 61		}
 62		sentUserMessages.push({ content, ...(options ? { options } : {}) });
 63		if (content.startsWith("/skill:handoff")) {
 64			queueMicrotask(() => void runner.emit({ type: "agent_start" }));
 65		}
 66	};
 67
 68	runner.bindCore(loaded.runtime, {
 69		getModel: () => model,
 70		getScopedModels: () => [],
 71		isIdle: () => true,
 72		isProjectTrusted: () => true,
 73		getSignal: () => undefined,
 74		abort: () => undefined,
 75		hasPendingMessages: () => false,
 76		shutdown: () => undefined,
 77		getContextUsage: () => undefined,
 78		compact: () => undefined,
 79		getSystemPrompt: () => "",
 80	});
 81
 82	const navigations: Array<{
 83		readonly targetId: string;
 84		readonly options?: {
 85			readonly summarize?: boolean;
 86			readonly customInstructions?: string;
 87			readonly replaceInstructions?: boolean;
 88			readonly label?: string;
 89		};
 90	}> = [];
 91	let waitForIdleCalls = 0;
 92	const commandActions: ExtensionCommandContextActions = {
 93		waitForIdle: async () => {
 94			waitForIdleCalls += 1;
 95		},
 96		newSession: async () => ({ cancelled: false }),
 97		fork: async () => ({ cancelled: false }),
 98		navigateTree: async (targetId, options) => {
 99			navigations.push({ targetId, ...(options ? { options } : {}) });
100			return { cancelled: false };
101		},
102		switchSession: async () => ({ cancelled: false }),
103		reload: async () => undefined,
104	};
105	runner.bindCommandContext(commandActions);
106
107	const editorValues: string[] = [];
108	const notifications: Array<{ readonly message: string; readonly type?: "info" | "warning" | "error" }> = [];
109	runner.setUIContext({
110		...runner.getUIContext(),
111		notify: (message, type) => notifications.push({ message, ...(type ? { type } : {}) }),
112		setEditorText: (value) => editorValues.push(value),
113	});
114
115	return {
116		editorValues,
117		navigations,
118		notifications,
119		runner,
120		sentUserMessages,
121		sessionManager,
122		waitForIdleCalls: () => waitForIdleCalls,
123	};
124}
125
126test("/handoff runs the handoff skill, summarizes to the first message, and continues", async () => {
127	const cwd = await mkdtemp(join(tmpdir(), "pi-handoff-"));
128
129	try {
130		const harness = await createHandoffHarness(cwd);
131		const firstUserMessageEntryId = harness.sessionManager.appendMessage(
132			userMessage("Implement the feature"),
133		);
134		const sourceLeafEntryId = harness.sessionManager.appendMessage(userMessage("Use tests"));
135
136		const command = harness.runner.getCommand("handoff");
137		assert.ok(command, "/handoff should be registered");
138		await command.handler("focus on error recovery", harness.runner.createCommandContext());
139
140		const sessionFile = harness.sessionManager.getSessionFile();
141		assert.ok(sessionFile);
142		assert.deepEqual(harness.sentUserMessages, [
143			{
144				content: "/skill:handoff focus on error recovery",
145				options: { expandPromptTemplates: true },
146			},
147			{
148				content: `Open the handoff document identified in the branch summary. Resume the work by performing its next unfinished step.\n\nThe source branch ends at session tree entry ${JSON.stringify(sourceLeafEntryId)} in ${JSON.stringify(sessionFile)}. If the handoff leaves a blocking ambiguity, inspect that JSONL with read or bash. Reconstruct the source branch by following parentId links from the entry ID; append order may include other branches. Recover the needed context, then resume.`,
149			},
150		]);
151		assert.deepEqual(harness.navigations, [
152			{
153				targetId: firstUserMessageEntryId,
154				options: {
155					summarize: true,
156					customInstructions:
157						"The source branch produced a handoff document. Include its exact absolute path so the next turn can open it. Keep the document as the source of truth; use the branch summary to orient the next turn toward continuing the work. The next turn's focus is: focus on error recovery",
158				},
159			},
160		]);
161		assert.equal(harness.waitForIdleCalls(), 2);
162		assert.deepEqual(harness.editorValues, [""]);
163	} finally {
164		await rm(cwd, { recursive: true, force: true });
165	}
166});
167
168test("/handoff accepts no focus prompt", async () => {
169	const cwd = await mkdtemp(join(tmpdir(), "pi-handoff-"));
170
171	try {
172		const harness = await createHandoffHarness(cwd);
173		harness.sessionManager.appendMessage(userMessage("Implement the feature"));
174
175		const command = harness.runner.getCommand("handoff");
176		assert.ok(command);
177		await command.handler("   ", harness.runner.createCommandContext());
178
179		assert.equal(harness.sentUserMessages[0]?.content, "/skill:handoff");
180	} finally {
181		await rm(cwd, { recursive: true, force: true });
182	}
183});
184
185test("/handoff warns when there is no conversation", async () => {
186	const cwd = await mkdtemp(join(tmpdir(), "pi-handoff-"));
187
188	try {
189		const harness = await createHandoffHarness(cwd);
190		const command = harness.runner.getCommand("handoff");
191		assert.ok(command);
192		await command.handler("", harness.runner.createCommandContext());
193
194		assert.deepEqual(harness.sentUserMessages, []);
195		assert.deepEqual(harness.navigations, []);
196		assert.deepEqual(harness.notifications, [
197			{ message: "There is no conversation to hand off", type: "warning" },
198		]);
199	} finally {
200		await rm(cwd, { recursive: true, force: true });
201	}
202});