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});