@jaybeeuu/agent-cortex 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +173 -0
- package/README.md +26 -3
- package/agents/README.md +2 -2
- package/agents/guppi/agent.md +162 -0
- package/agents/guppi/claude/frontmatter.json +6 -0
- package/agents/guppi/pi/frontmatter.json +6 -0
- package/agents/plan/agent.md +6 -0
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/installers/claude.mjs +1 -20
- package/bin/installers/pi.mjs +8 -2
- package/extensions/interrupt-bookmark/README.md +129 -0
- package/extensions/interrupt-bookmark/index.test.ts +717 -0
- package/extensions/interrupt-bookmark/index.ts +354 -0
- package/extensions/interrupt-bookmark/package.json +20 -0
- package/extensions/skill-stats/README.md +84 -3
- package/extensions/skill-stats/index.ts +62 -3
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/skill-stats/rollup.test.ts +643 -0
- package/extensions/skill-stats/rollup.ts +415 -0
- package/extensions/skill-stats/timeline.ts +101 -0
- package/package.json +1 -1
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/review/review-defects/EXAMPLES.md +80 -4
- package/skills/review/review-defects/SKILL.md +45 -15
- package/skills/review/review-report/EXAMPLES.md +114 -0
- package/skills/review/review-report/SKILL.md +138 -0
- package/skills/review/review-report/scripts/report.mjs +1032 -0
- package/skills/workflow/hitl-queue/SKILL.md +134 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
- package/skills/workflow/plan/SKILL.md +13 -0
- package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
- package/skills/workflow/prioritise-work/SKILL.md +150 -0
- package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
- package/skills/workflow/score-bead/FORMAT.md +73 -0
- package/skills/workflow/score-bead/SKILL.md +112 -0
- package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
- package/skills/workflow/using-agent-skills/SKILL.md +6 -1
- package/token-map.json +8 -2
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import interruptBookmarkExtension, {
|
|
6
|
+
CHECKPOINT_TOOL,
|
|
7
|
+
RESUME_TOOL,
|
|
8
|
+
REWIND_ATTEMPTS,
|
|
9
|
+
REWIND_COMMAND,
|
|
10
|
+
REWIND_ENTRY,
|
|
11
|
+
SUMMARY_LABEL,
|
|
12
|
+
summariserInstructions,
|
|
13
|
+
} from "./index.ts";
|
|
14
|
+
|
|
15
|
+
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** A session entry, as the extension reads it. */
|
|
18
|
+
interface FakeEntry {
|
|
19
|
+
id: string;
|
|
20
|
+
type: "message" | "branch_summary" | "label" | "custom";
|
|
21
|
+
parentId: string | null;
|
|
22
|
+
/** The branch a summary abandoned, as pi records it. */
|
|
23
|
+
fromId?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What the fake navigation does on its next attempt. */
|
|
27
|
+
type NavigationBehaviour = "succeed" | "fail" | "cancel";
|
|
28
|
+
|
|
29
|
+
interface FakeSession {
|
|
30
|
+
/** The position the session is on. */
|
|
31
|
+
leafId: string | null;
|
|
32
|
+
/** Append-only, exactly as a session file is. */
|
|
33
|
+
entries: FakeEntry[];
|
|
34
|
+
/** Labels applied to entries, in order. */
|
|
35
|
+
labels: Array<{ id: string; label: string }>;
|
|
36
|
+
/** Consumed one per navigation attempt; "succeed" once exhausted. */
|
|
37
|
+
behaviour: NavigationBehaviour[];
|
|
38
|
+
/** True while an agent response is active; only a wait for idle clears it. */
|
|
39
|
+
streaming: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface FakeUi {
|
|
43
|
+
notifications: Array<{ message: string; level: string | undefined }>;
|
|
44
|
+
notify(message: string, level?: string): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every navigation attempt, with the options the extension passed. */
|
|
48
|
+
interface Attempt {
|
|
49
|
+
targetId: string;
|
|
50
|
+
options: Record<string, unknown> | undefined;
|
|
51
|
+
/** Whether a wait for idle preceded this attempt. */
|
|
52
|
+
waited: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Calls {
|
|
56
|
+
tools: string[];
|
|
57
|
+
commands: string[];
|
|
58
|
+
/** Ordered record of the waits and the navigations. */
|
|
59
|
+
order: string[];
|
|
60
|
+
attempts: Attempt[];
|
|
61
|
+
entries: Array<{ customType: string; data: unknown }>;
|
|
62
|
+
/** Anything sent to the model, and how. */
|
|
63
|
+
userMessages: Array<{ content: unknown; options: unknown }>;
|
|
64
|
+
messages: unknown[];
|
|
65
|
+
/** Every session-manager member the extension named, in order, repeats kept. */
|
|
66
|
+
sessionManagerMembers: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface FakeInstance {
|
|
70
|
+
pi: ExtensionAPI;
|
|
71
|
+
session: FakeSession;
|
|
72
|
+
/** The session surface the extension reaches, exactly as the double provides it. */
|
|
73
|
+
sessionManager: {
|
|
74
|
+
getLeafId(): string | null;
|
|
75
|
+
getEntry(id: string): FakeEntry | undefined;
|
|
76
|
+
getEntries(): FakeEntry[];
|
|
77
|
+
};
|
|
78
|
+
calls: Calls;
|
|
79
|
+
ui: FakeUi;
|
|
80
|
+
/** Run a registered tool the way pi does, via Reflect.apply so the exact
|
|
81
|
+
* signature does not leak into the fake. */
|
|
82
|
+
runTool(name: string, params: Record<string, unknown>): Promise<ToolOutcome>;
|
|
83
|
+
runCommand(args: string): Promise<void>;
|
|
84
|
+
fireSettled(): Promise<void>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface ToolOutcome {
|
|
88
|
+
text: string;
|
|
89
|
+
details: unknown;
|
|
90
|
+
terminate: boolean | undefined;
|
|
91
|
+
error: Error | undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function entry(id: string, parentId: string | null, type: FakeEntry["type"] = "message"): FakeEntry {
|
|
95
|
+
return { id, parentId, type };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A session that already holds a task turn, an interruption and its follow-up:
|
|
100
|
+
*
|
|
101
|
+
* m1 → m2 → m3 → m4
|
|
102
|
+
*
|
|
103
|
+
* `m2` is the checkpoint; `m3` and `m4` are the interrupt's turns.
|
|
104
|
+
*/
|
|
105
|
+
function interruptedSession(overrides: Partial<FakeSession> = {}): FakeSession {
|
|
106
|
+
return {
|
|
107
|
+
leafId: "m4",
|
|
108
|
+
entries: [entry("m1", null), entry("m2", "m1"), entry("m3", "m2"), entry("m4", "m3")],
|
|
109
|
+
labels: [],
|
|
110
|
+
behaviour: [],
|
|
111
|
+
streaming: false,
|
|
112
|
+
...overrides,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a full `ExtensionAPI` test double.
|
|
118
|
+
*
|
|
119
|
+
* The literal is complete, so `tsc` fails when a pi release grows the interface.
|
|
120
|
+
* Every method that would reach a provider or the user is recorded, so a test can
|
|
121
|
+
* assert that the extension made no model call of its own.
|
|
122
|
+
*/
|
|
123
|
+
function createFakeInstance(session: FakeSession): FakeInstance {
|
|
124
|
+
const calls: Calls = {
|
|
125
|
+
tools: [],
|
|
126
|
+
commands: [],
|
|
127
|
+
order: [],
|
|
128
|
+
attempts: [],
|
|
129
|
+
entries: [],
|
|
130
|
+
userMessages: [],
|
|
131
|
+
messages: [],
|
|
132
|
+
sessionManagerMembers: [],
|
|
133
|
+
};
|
|
134
|
+
const ui: FakeUi = {
|
|
135
|
+
notifications: [],
|
|
136
|
+
notify(message, level) {
|
|
137
|
+
this.notifications.push({ message, level });
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const state = session;
|
|
142
|
+
const handlers = new Map<string, Array<(...args: never[]) => unknown>>();
|
|
143
|
+
const tools = new Map<string, { execute: (...args: never[]) => Promise<unknown> }>();
|
|
144
|
+
let commandHandler: ((args: string, ctx: unknown) => Promise<void>) | undefined;
|
|
145
|
+
let waitedSinceLastAttempt = false;
|
|
146
|
+
let summaryCount = 0;
|
|
147
|
+
|
|
148
|
+
/** The session surface the double provides. Reads only: no delete or truncate member. */
|
|
149
|
+
const sessionReads = {
|
|
150
|
+
getLeafId: () => state.leafId,
|
|
151
|
+
getEntry: (id: string) => state.entries.find((candidate) => candidate.id === id),
|
|
152
|
+
getEntries: () => state.entries,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The same surface, recording every member the extension names. A member the
|
|
157
|
+
* double does not implement is recorded and then treated as a no-op, so a call
|
|
158
|
+
* the extension starts making is reported by name from the assertion rather
|
|
159
|
+
* than crashing inside a tool call.
|
|
160
|
+
*/
|
|
161
|
+
const sessionManager = new Proxy(sessionReads, {
|
|
162
|
+
get(target, property, receiver) {
|
|
163
|
+
const member: unknown = Reflect.get(target, property, receiver);
|
|
164
|
+
if (typeof property !== "string") return member;
|
|
165
|
+
calls.sessionManagerMembers.push(property);
|
|
166
|
+
return member ?? (() => undefined);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** The command context pi builds for command handlers, and for nothing else. */
|
|
171
|
+
const commandContext = {
|
|
172
|
+
ui,
|
|
173
|
+
cwd: "/workspace",
|
|
174
|
+
sessionManager,
|
|
175
|
+
waitForIdle: async () => {
|
|
176
|
+
calls.order.push("waitForIdle");
|
|
177
|
+
waitedSinceLastAttempt = true;
|
|
178
|
+
state.streaming = false;
|
|
179
|
+
},
|
|
180
|
+
navigateTree: async (targetId: string, options?: Record<string, unknown>) => {
|
|
181
|
+
calls.order.push("navigateTree");
|
|
182
|
+
calls.attempts.push({ targetId, options, waited: waitedSinceLastAttempt });
|
|
183
|
+
waitedSinceLastAttempt = false;
|
|
184
|
+
|
|
185
|
+
// pi rejects navigation while a response is active, exactly as the session
|
|
186
|
+
// does: the active branch is left unchanged and the promise rejects.
|
|
187
|
+
if (state.streaming) {
|
|
188
|
+
throw new Error("Wait for the current response to finish before navigating the session tree.");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const behaviour = state.behaviour.shift() ?? "succeed";
|
|
192
|
+
if (behaviour === "fail") {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"Wait for the current compaction or tree navigation to finish before navigating the session tree.",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (behaviour === "cancel") {
|
|
198
|
+
return { cancelled: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
summaryCount += 1;
|
|
202
|
+
const summaryId = `summary-${summaryCount}`;
|
|
203
|
+
const fromId = state.leafId;
|
|
204
|
+
state.entries.push({ ...entry(summaryId, targetId, "branch_summary"), fromId });
|
|
205
|
+
state.leafId = summaryId;
|
|
206
|
+
|
|
207
|
+
// pi appends a label entry on top of the summary, so the leaf after a
|
|
208
|
+
// labelled navigation is the label entry, not the summary. Verified
|
|
209
|
+
// against the real SessionManager (see README, "Proven against pi").
|
|
210
|
+
const label = options?.label;
|
|
211
|
+
if (typeof label === "string") {
|
|
212
|
+
state.labels.push({ id: summaryId, label });
|
|
213
|
+
const labelId = `label-${state.labels.length}`;
|
|
214
|
+
state.entries.push(entry(labelId, summaryId, "label"));
|
|
215
|
+
state.leafId = labelId;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { cancelled: false, editorText: undefined, summaryEntry: { id: summaryId, fromId } };
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const pi: ExtensionAPI = {
|
|
223
|
+
on: (event: string, handler: (...args: never[]) => unknown) => {
|
|
224
|
+
const list = handlers.get(event) ?? [];
|
|
225
|
+
list.push(handler);
|
|
226
|
+
handlers.set(event, list);
|
|
227
|
+
return () => {};
|
|
228
|
+
},
|
|
229
|
+
registerFlag: () => {},
|
|
230
|
+
getFlag: () => undefined,
|
|
231
|
+
getActiveTools: () => [],
|
|
232
|
+
setActiveTools: () => {},
|
|
233
|
+
registerCommand: (name, options) => {
|
|
234
|
+
calls.commands.push(name);
|
|
235
|
+
commandHandler = options.handler as typeof commandHandler;
|
|
236
|
+
},
|
|
237
|
+
registerShortcut: () => {},
|
|
238
|
+
registerTool: (definition) => {
|
|
239
|
+
calls.tools.push(definition.name);
|
|
240
|
+
tools.set(
|
|
241
|
+
definition.name,
|
|
242
|
+
definition as unknown as { execute: (...args: never[]) => Promise<unknown> },
|
|
243
|
+
);
|
|
244
|
+
},
|
|
245
|
+
registerMessageRenderer: () => {},
|
|
246
|
+
registerMarkdownTransformer: () => {},
|
|
247
|
+
registerEntryRenderer: () => {},
|
|
248
|
+
registerToolRenderer: () => {},
|
|
249
|
+
registerProvider: () => {},
|
|
250
|
+
unregisterProvider: () => {},
|
|
251
|
+
registerMcpServer: () => {},
|
|
252
|
+
unregisterMcpServer: () => {},
|
|
253
|
+
unregisterVirtualModel: () => {},
|
|
254
|
+
registerVirtualModel: () => {},
|
|
255
|
+
getMcpServers: () => [],
|
|
256
|
+
sendMessage: (message) => {
|
|
257
|
+
calls.messages.push(message);
|
|
258
|
+
},
|
|
259
|
+
sendUserMessage: (content, options) => {
|
|
260
|
+
calls.userMessages.push({ content, options });
|
|
261
|
+
},
|
|
262
|
+
appendEntry: (customType, data) => {
|
|
263
|
+
calls.entries.push({ customType, data });
|
|
264
|
+
const id = `custom-${calls.entries.length}`;
|
|
265
|
+
state.entries.push(entry(id, state.leafId, "custom"));
|
|
266
|
+
state.leafId = id;
|
|
267
|
+
},
|
|
268
|
+
setSessionName: () => {},
|
|
269
|
+
getSessionName: () => undefined,
|
|
270
|
+
setLabel: (id, label) => {
|
|
271
|
+
if (label !== undefined) state.labels.push({ id, label });
|
|
272
|
+
},
|
|
273
|
+
exec: async () => {
|
|
274
|
+
throw new Error("not implemented");
|
|
275
|
+
},
|
|
276
|
+
getAllTools: () => [],
|
|
277
|
+
getSettings: () => {
|
|
278
|
+
throw new Error("not implemented");
|
|
279
|
+
},
|
|
280
|
+
getCommands: () => [],
|
|
281
|
+
setModel: async () => true,
|
|
282
|
+
getThinkingLevel: () => "medium",
|
|
283
|
+
setThinkingLevel: () => {},
|
|
284
|
+
events: { on: () => () => {}, emit: () => {} },
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const instance: FakeInstance = {
|
|
288
|
+
pi,
|
|
289
|
+
session: state,
|
|
290
|
+
sessionManager,
|
|
291
|
+
calls,
|
|
292
|
+
ui,
|
|
293
|
+
async runTool(name, params) {
|
|
294
|
+
const tool = tools.get(name);
|
|
295
|
+
assert.ok(tool, `${name} must be registered while the extension loads`);
|
|
296
|
+
|
|
297
|
+
const toolContext = { ui, cwd: "/workspace", sessionManager };
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const result = (await Reflect.apply(tool.execute, tool, [
|
|
301
|
+
"call-1",
|
|
302
|
+
params,
|
|
303
|
+
undefined,
|
|
304
|
+
undefined,
|
|
305
|
+
toolContext,
|
|
306
|
+
])) as {
|
|
307
|
+
content: Array<{ type: string; text?: string }>;
|
|
308
|
+
details: unknown;
|
|
309
|
+
terminate?: boolean;
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
text: result.content.map((part) => part.text ?? "").join(""),
|
|
313
|
+
details: result.details,
|
|
314
|
+
terminate: result.terminate,
|
|
315
|
+
error: undefined,
|
|
316
|
+
};
|
|
317
|
+
} catch (error) {
|
|
318
|
+
return { text: "", details: undefined, terminate: undefined, error: error as Error };
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
async runCommand(args) {
|
|
322
|
+
assert.ok(commandHandler, `${REWIND_COMMAND} must be registered while the extension loads`);
|
|
323
|
+
await Reflect.apply(commandHandler, undefined, [args, commandContext]);
|
|
324
|
+
},
|
|
325
|
+
async fireSettled() {
|
|
326
|
+
for (const handler of handlers.get("agent_settled") ?? []) {
|
|
327
|
+
await Reflect.apply(handler, undefined, [{ type: "agent_settled", aborted: false }, commandContext]);
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
interruptBookmarkExtension(pi);
|
|
333
|
+
return instance;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The ids a session holds, in order. */
|
|
337
|
+
function ids(session: FakeSession): string[] {
|
|
338
|
+
return session.entries.map((held) => held.id);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ─── Registration ────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
describe("interruptBookmarkExtension", () => {
|
|
344
|
+
it("registers both tools and the rewind command from the factory", () => {
|
|
345
|
+
const instance = createFakeInstance(interruptedSession());
|
|
346
|
+
|
|
347
|
+
assert.deepEqual(instance.calls.tools, [CHECKPOINT_TOOL, RESUME_TOOL]);
|
|
348
|
+
assert.deepEqual(instance.calls.commands, [REWIND_COMMAND]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("makes no model call: nothing is sent with a trigger, no message is sent", async () => {
|
|
352
|
+
const instance = createFakeInstance(interruptedSession());
|
|
353
|
+
|
|
354
|
+
await instance.runTool(CHECKPOINT_TOOL, {});
|
|
355
|
+
await instance.runTool(RESUME_TOOL, { checkpointId: "m2", summary: "Meeting note" });
|
|
356
|
+
await instance.fireSettled();
|
|
357
|
+
await instance.runCommand("m2");
|
|
358
|
+
|
|
359
|
+
assert.deepEqual(instance.calls.messages, [], "no custom message is sent");
|
|
360
|
+
// The single user message is the command dispatch, which runs a command
|
|
361
|
+
// instead of prompting the model.
|
|
362
|
+
assert.deepEqual(
|
|
363
|
+
instance.calls.userMessages.map((sent) => sent.options),
|
|
364
|
+
[{ expandPromptTemplates: true }],
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ─── interrupt_checkpoint ────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
describe(`${CHECKPOINT_TOOL}`, () => {
|
|
372
|
+
it("returns the session's current leaf id as the resume point", async () => {
|
|
373
|
+
const instance = createFakeInstance(interruptedSession());
|
|
374
|
+
|
|
375
|
+
const result = await instance.runTool(CHECKPOINT_TOOL, {});
|
|
376
|
+
|
|
377
|
+
assert.equal(result.error, undefined);
|
|
378
|
+
assert.deepEqual(result.details, { checkpointId: "m4" });
|
|
379
|
+
assert.match(result.text, /m4/);
|
|
380
|
+
assert.deepEqual(ids(instance.session), ["m1", "m2", "m3", "m4"], "capturing writes nothing");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("reports that an empty session has no position to record", async () => {
|
|
384
|
+
const instance = createFakeInstance(interruptedSession({ leafId: null, entries: [] }));
|
|
385
|
+
|
|
386
|
+
const result = await instance.runTool(CHECKPOINT_TOOL, {});
|
|
387
|
+
|
|
388
|
+
assert.equal(result.error, undefined);
|
|
389
|
+
assert.deepEqual(result.details, { checkpointId: null });
|
|
390
|
+
assert.match(result.text, /no entries/);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ─── interrupt_resume: refusal ───────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
describe(`${RESUME_TOOL} refusal`, () => {
|
|
397
|
+
it("names an unknown checkpoint id and leaves the session untouched", async () => {
|
|
398
|
+
const session = interruptedSession();
|
|
399
|
+
const version = structuredClone(session);
|
|
400
|
+
const instance = createFakeInstance(session);
|
|
401
|
+
|
|
402
|
+
const result = await instance.runTool(RESUME_TOOL, {
|
|
403
|
+
checkpointId: "missing-7",
|
|
404
|
+
summary: "Slack todo",
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
assert.ok(result.error, "an unknown checkpoint fails");
|
|
408
|
+
assert.match(result.error.message, /missing-7/);
|
|
409
|
+
assert.deepEqual(session, version, "no entry, label or leaf changed");
|
|
410
|
+
assert.deepEqual(instance.calls.entries, [], "nothing was written");
|
|
411
|
+
assert.deepEqual(instance.calls.attempts, [], "nothing was navigated");
|
|
412
|
+
|
|
413
|
+
// The refusal must not leave a rewind queued for the next settle.
|
|
414
|
+
await instance.fireSettled();
|
|
415
|
+
assert.deepEqual(instance.calls.userMessages, [], "no rewind was dispatched");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("refuses a checkpoint the session is already on, and says why", async () => {
|
|
419
|
+
const instance = createFakeInstance(interruptedSession({ leafId: "m2" }));
|
|
420
|
+
|
|
421
|
+
const result = await instance.runTool(RESUME_TOOL, { checkpointId: "m2", summary: "Meeting note" });
|
|
422
|
+
|
|
423
|
+
assert.ok(result.error);
|
|
424
|
+
assert.match(result.error.message, /m2/);
|
|
425
|
+
assert.match(result.error.message, /no interrupt turns/);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("refuses a second rewind while one is pending", async () => {
|
|
429
|
+
const instance = createFakeInstance(interruptedSession());
|
|
430
|
+
await instance.runTool(RESUME_TOOL, { checkpointId: "m2", summary: "Meeting note" });
|
|
431
|
+
|
|
432
|
+
const result = await instance.runTool(RESUME_TOOL, { checkpointId: "m1", summary: "Slack todo" });
|
|
433
|
+
|
|
434
|
+
assert.ok(result.error);
|
|
435
|
+
assert.match(result.error.message, /already scheduled/);
|
|
436
|
+
assert.match(result.error.message, /m2/);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ─── interrupt_resume → command bridge ───────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
describe(`${RESUME_TOOL} scheduling`, () => {
|
|
443
|
+
it("schedules the rewind and ends the turn", async () => {
|
|
444
|
+
const instance = createFakeInstance(interruptedSession());
|
|
445
|
+
|
|
446
|
+
const result = await instance.runTool(RESUME_TOOL, { checkpointId: "m2", summary: "Meeting note" });
|
|
447
|
+
|
|
448
|
+
assert.equal(result.error, undefined);
|
|
449
|
+
assert.equal(result.terminate, true, "the rewind needs the turn to end");
|
|
450
|
+
assert.deepEqual(result.details, { checkpointId: "m2", scheduled: true });
|
|
451
|
+
assert.match(result.text, new RegExp(SUMMARY_LABEL));
|
|
452
|
+
assert.deepEqual(instance.calls.attempts, [], "navigation waits for the turn to settle");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("dispatches the command once, when the turn settles", async () => {
|
|
456
|
+
const instance = createFakeInstance(interruptedSession());
|
|
457
|
+
await instance.runTool(RESUME_TOOL, { checkpointId: "m2", summary: "Meeting note" });
|
|
458
|
+
|
|
459
|
+
await instance.fireSettled();
|
|
460
|
+
await instance.fireSettled();
|
|
461
|
+
|
|
462
|
+
assert.deepEqual(instance.calls.userMessages, [
|
|
463
|
+
{ content: `/${REWIND_COMMAND} m2`, options: { expandPromptTemplates: true } },
|
|
464
|
+
]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("dispatches nothing when no rewind was requested", async () => {
|
|
468
|
+
const instance = createFakeInstance(interruptedSession());
|
|
469
|
+
|
|
470
|
+
await instance.fireSettled();
|
|
471
|
+
|
|
472
|
+
assert.deepEqual(instance.calls.userMessages, []);
|
|
473
|
+
assert.deepEqual(instance.calls.attempts, []);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// ─── The rewind itself ───────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
/** Schedule a rewind to `m2` and run the dispatched command, as pi would. */
|
|
480
|
+
async function rewindThrough(instance: FakeInstance, checkpointId = "m2"): Promise<void> {
|
|
481
|
+
await instance.runTool(RESUME_TOOL, { checkpointId, summary: "A meeting note arrived" });
|
|
482
|
+
await instance.fireSettled();
|
|
483
|
+
await instance.runCommand(checkpointId);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
describe(`${REWIND_COMMAND}`, () => {
|
|
487
|
+
it("waits for idle, then navigates with a summary labelled interrupt", async () => {
|
|
488
|
+
const instance = createFakeInstance(interruptedSession());
|
|
489
|
+
|
|
490
|
+
await rewindThrough(instance);
|
|
491
|
+
|
|
492
|
+
assert.deepEqual(instance.calls.order, ["waitForIdle", "navigateTree"]);
|
|
493
|
+
assert.deepEqual(instance.calls.attempts, [
|
|
494
|
+
{
|
|
495
|
+
targetId: "m2",
|
|
496
|
+
options: {
|
|
497
|
+
summarize: true,
|
|
498
|
+
customInstructions: summariserInstructions("A meeting note arrived"),
|
|
499
|
+
label: SUMMARY_LABEL,
|
|
500
|
+
},
|
|
501
|
+
waited: true,
|
|
502
|
+
},
|
|
503
|
+
]);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("keeps the abandoned branch and labels the summary it leaves behind", async () => {
|
|
507
|
+
const instance = createFakeInstance(interruptedSession());
|
|
508
|
+
|
|
509
|
+
await rewindThrough(instance);
|
|
510
|
+
|
|
511
|
+
for (const original of ["m1", "m2", "m3", "m4"]) {
|
|
512
|
+
assert.ok(
|
|
513
|
+
ids(instance.session).includes(original),
|
|
514
|
+
`${original} must survive the rewind`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const summary = instance.session.entries.find((held) => held.type === "branch_summary");
|
|
519
|
+
assert.ok(summary, "a branch summary entry is written");
|
|
520
|
+
assert.deepEqual(
|
|
521
|
+
instance.session.labels,
|
|
522
|
+
[{ id: summary.id, label: SUMMARY_LABEL }],
|
|
523
|
+
`the summary entry carries the ${SUMMARY_LABEL} label`,
|
|
524
|
+
);
|
|
525
|
+
assert.equal(summary.parentId, "m2", "the summary sits at the checkpoint position");
|
|
526
|
+
// pi appends a label entry on top of the summary; the outcome record is
|
|
527
|
+
// appended after that, so the summary stays in the live context.
|
|
528
|
+
const leaf = instance.session.entries.at(-1);
|
|
529
|
+
assert.equal(leaf?.type, "custom");
|
|
530
|
+
const labelEntry = instance.session.entries.at(-2);
|
|
531
|
+
assert.equal(labelEntry?.type, "label");
|
|
532
|
+
assert.equal(labelEntry?.parentId, summary.id);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("reports the summary entry and writes the outcome to the session", async () => {
|
|
536
|
+
const instance = createFakeInstance(interruptedSession());
|
|
537
|
+
|
|
538
|
+
await rewindThrough(instance);
|
|
539
|
+
|
|
540
|
+
const summaryId = instance.session.entries.find((held) => held.type === "branch_summary")?.id;
|
|
541
|
+
assert.deepEqual(instance.calls.entries, [
|
|
542
|
+
{
|
|
543
|
+
customType: REWIND_ENTRY,
|
|
544
|
+
data: {
|
|
545
|
+
checkpointId: "m2",
|
|
546
|
+
attempts: 1,
|
|
547
|
+
ok: true,
|
|
548
|
+
cancelled: false,
|
|
549
|
+
summaryEntryId: summaryId,
|
|
550
|
+
reason: undefined,
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
]);
|
|
554
|
+
assert.equal(instance.ui.notifications.length, 1);
|
|
555
|
+
assert.equal(instance.ui.notifications[0].level, "info");
|
|
556
|
+
assert.match(instance.ui.notifications[0].message, new RegExp(summaryId ?? "summary"));
|
|
557
|
+
assert.match(instance.ui.notifications[0].message, /kept/);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("waits out a session that is still streaming instead of failing", async () => {
|
|
561
|
+
// Mid-turn: the session rejects navigation until the wait for idle clears it.
|
|
562
|
+
// An attempt that skipped the wait would fail for both attempts.
|
|
563
|
+
const instance = createFakeInstance(interruptedSession({ streaming: true }));
|
|
564
|
+
|
|
565
|
+
await rewindThrough(instance);
|
|
566
|
+
|
|
567
|
+
assert.deepEqual(instance.calls.attempts.map((attempt) => attempt.waited), [true]);
|
|
568
|
+
assert.equal(instance.ui.notifications.at(-1)?.level, "info");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("retries exactly once, then reports the reason and leaves the branch alone", async () => {
|
|
572
|
+
const instance = createFakeInstance(interruptedSession());
|
|
573
|
+
await instance.runTool(RESUME_TOOL, { checkpointId: "m2", summary: "Meeting note" });
|
|
574
|
+
await instance.fireSettled();
|
|
575
|
+
const entriesBefore = structuredClone(instance.session.entries);
|
|
576
|
+
instance.session.behaviour = ["fail", "fail"];
|
|
577
|
+
|
|
578
|
+
await instance.runCommand("m2");
|
|
579
|
+
|
|
580
|
+
assert.equal(instance.calls.attempts.length, REWIND_ATTEMPTS);
|
|
581
|
+
assert.deepEqual(
|
|
582
|
+
instance.calls.attempts.map((attempt) => attempt.waited),
|
|
583
|
+
[true, true],
|
|
584
|
+
"each attempt waits for idle first",
|
|
585
|
+
);
|
|
586
|
+
assert.deepEqual(
|
|
587
|
+
instance.session.entries.filter((held) => held.type === "branch_summary"),
|
|
588
|
+
[],
|
|
589
|
+
"no summary is written for a failed rewind",
|
|
590
|
+
);
|
|
591
|
+
assert.deepEqual(
|
|
592
|
+
instance.session.entries.slice(0, entriesBefore.length),
|
|
593
|
+
entriesBefore,
|
|
594
|
+
"the existing entries survive a failed rewind",
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const notification = instance.ui.notifications.at(-1);
|
|
598
|
+
assert.equal(notification?.level, "error");
|
|
599
|
+
assert.match(notification?.message ?? "", /failed after 2 attempts/);
|
|
600
|
+
assert.match(notification?.message ?? "", /compaction or tree navigation/);
|
|
601
|
+
assert.match(notification?.message ?? "", /unchanged/);
|
|
602
|
+
|
|
603
|
+
const recorded = instance.calls.entries.at(-1)?.data as { ok: boolean; reason: string };
|
|
604
|
+
assert.equal(recorded.ok, false, "the failure is recorded");
|
|
605
|
+
assert.match(recorded.reason, /compaction or tree navigation/);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("succeeds on the retry after one rejected attempt", async () => {
|
|
609
|
+
const instance = createFakeInstance(interruptedSession());
|
|
610
|
+
|
|
611
|
+
await instance.runTool(RESUME_TOOL, { checkpointId: "m2", summary: "Meeting note" });
|
|
612
|
+
await instance.fireSettled();
|
|
613
|
+
instance.session.behaviour = ["fail", "succeed"];
|
|
614
|
+
|
|
615
|
+
await instance.runCommand("m2");
|
|
616
|
+
|
|
617
|
+
assert.equal(instance.calls.attempts.length, REWIND_ATTEMPTS);
|
|
618
|
+
const recorded = instance.calls.entries.at(-1)?.data as { ok: boolean; attempts: number };
|
|
619
|
+
assert.equal(recorded.ok, true);
|
|
620
|
+
assert.equal(recorded.attempts, 2);
|
|
621
|
+
assert.equal(instance.ui.notifications.at(-1)?.level, "info");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it("reports a cancelled rewind without claiming the branch was summarised", async () => {
|
|
625
|
+
const instance = createFakeInstance(interruptedSession());
|
|
626
|
+
|
|
627
|
+
await instance.runTool(RESUME_TOOL, { checkpointId: "m2", summary: "Meeting note" });
|
|
628
|
+
await instance.fireSettled();
|
|
629
|
+
instance.session.behaviour = ["cancel"];
|
|
630
|
+
|
|
631
|
+
await instance.runCommand("m2");
|
|
632
|
+
|
|
633
|
+
const notification = instance.ui.notifications.at(-1);
|
|
634
|
+
assert.equal(notification?.level, "warning");
|
|
635
|
+
assert.match(notification?.message ?? "", /cancelled/);
|
|
636
|
+
assert.match(notification?.message ?? "", /unchanged/);
|
|
637
|
+
assert.doesNotMatch(notification?.message ?? "", /kept/);
|
|
638
|
+
assert.deepEqual(
|
|
639
|
+
instance.session.entries.filter((held) => held.type === "branch_summary"),
|
|
640
|
+
[],
|
|
641
|
+
"a cancelled rewind summarises nothing, so no branch_summary entry is written",
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
const recorded = instance.calls.entries.at(-1)?.data as { cancelled: boolean; ok: boolean };
|
|
645
|
+
assert.deepEqual({ ok: recorded.ok, cancelled: recorded.cancelled }, { ok: true, cancelled: true });
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("reports that nothing is pending when invoked without a request", async () => {
|
|
649
|
+
const instance = createFakeInstance(interruptedSession());
|
|
650
|
+
|
|
651
|
+
await instance.runCommand("m2");
|
|
652
|
+
|
|
653
|
+
assert.deepEqual(instance.calls.attempts, []);
|
|
654
|
+
assert.equal(instance.ui.notifications.at(-1)?.level, "warning");
|
|
655
|
+
assert.match(instance.ui.notifications.at(-1)?.message ?? "", /no interrupt rewind is pending/i);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("drops a replaced request, so only the newest checkpoint navigates", async () => {
|
|
659
|
+
const instance = createFakeInstance(interruptedSession());
|
|
660
|
+
|
|
661
|
+
// The first request settles, but its dispatched command never runs.
|
|
662
|
+
await instance.runTool(RESUME_TOOL, { checkpointId: "m2", summary: "Meeting note" });
|
|
663
|
+
await instance.fireSettled();
|
|
664
|
+
|
|
665
|
+
// A second request replaces it before either command has run.
|
|
666
|
+
await instance.runTool(RESUME_TOOL, { checkpointId: "m1", summary: "Slack todo" });
|
|
667
|
+
await instance.fireSettled();
|
|
668
|
+
|
|
669
|
+
// The replaced request is gone: its command finds no handoff and navigates nothing.
|
|
670
|
+
await instance.runCommand("m2");
|
|
671
|
+
assert.deepEqual(
|
|
672
|
+
instance.calls.attempts.map((attempt) => attempt.targetId),
|
|
673
|
+
[],
|
|
674
|
+
"the replaced checkpoint navigates nothing",
|
|
675
|
+
);
|
|
676
|
+
assert.match(
|
|
677
|
+
instance.ui.notifications.at(-1)?.message ?? "",
|
|
678
|
+
/no interrupt rewind is pending for m2/i,
|
|
679
|
+
);
|
|
680
|
+
assert.deepEqual(
|
|
681
|
+
instance.session.entries.filter((held) => held.type === "branch_summary"),
|
|
682
|
+
[],
|
|
683
|
+
"the replaced request wrote no summary",
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
// The newest request still rewinds, exactly once.
|
|
687
|
+
await instance.runCommand("m1");
|
|
688
|
+
assert.deepEqual(
|
|
689
|
+
instance.calls.attempts.map((attempt) => attempt.targetId),
|
|
690
|
+
["m1"],
|
|
691
|
+
"the newest checkpoint navigates once",
|
|
692
|
+
);
|
|
693
|
+
assert.equal(instance.ui.notifications.at(-1)?.level, "info");
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// ─── The abandoned branch ────────────────────────────────────────────────────
|
|
698
|
+
|
|
699
|
+
describe("the session surface the extension uses", () => {
|
|
700
|
+
it("is read-only apart from navigation, so nothing can be deleted", async () => {
|
|
701
|
+
const instance = createFakeInstance(interruptedSession());
|
|
702
|
+
|
|
703
|
+
await rewindThrough(instance);
|
|
704
|
+
|
|
705
|
+
// The members the extension actually named, deduplicated. A member it starts
|
|
706
|
+
// calling — a write, a delete, anything that is not navigation — lands here
|
|
707
|
+
// and fails this assertion by name.
|
|
708
|
+
assert.deepEqual(
|
|
709
|
+
[...new Set(instance.calls.sessionManagerMembers)].sort(),
|
|
710
|
+
["getEntries", "getEntry", "getLeafId"],
|
|
711
|
+
"the extension reads the session manager and never writes to it",
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
const removed = ["m1", "m2", "m3", "m4"].filter((id) => !ids(instance.session).includes(id));
|
|
715
|
+
assert.deepEqual(removed, [], "every original entry is still in the session");
|
|
716
|
+
});
|
|
717
|
+
});
|