@hypercarrier/rarebit 0.1.0-alpha.6 → 0.2.1
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 +33 -3
- package/README.md +296 -122
- package/RUNBOOK.md +16 -9
- package/docs/examples/rarebit.settings.json +19 -0
- package/package.json +9 -2
- package/schemas/rarebit.schema.json +2 -1
- package/scripts/e2e-recap-pi.mjs +458 -0
- package/scripts/e2e-settings-pi.mjs +267 -0
- package/scripts/e2e-v020-pi.mjs +271 -0
- package/scripts/experiment-recap-fullscreen.mjs +56 -0
- package/scripts/verify-package.mjs +14 -8
- package/src/extension.mjs +246 -24
- package/src/lifecycle.mjs +32 -7
- package/src/rarebit-artifact-state.mjs +8 -12
- package/src/rarebit-cli.mjs +2 -0
- package/src/rarebit-command.mjs +26 -1
- package/src/rarebit-core.mjs +122 -23
- package/src/rarebit-read-checkpoint.mjs +59 -0
- package/src/rarebit-recap.mjs +479 -0
- package/src/rarebit-service.mjs +65 -9
- package/src/rarebit-settings-ui.mjs +479 -0
- package/src/rarebit-settings.mjs +90 -7
- package/src/types.d.ts +65 -5
- package/test/lifecycle-contract-independent.test.mjs +2 -0
- package/test/package-artifact-identity.test.mjs +54 -0
- package/test/rarebit-artifact-state.test.mjs +10 -4
- package/test/rarebit-command.test.mjs +11 -4
- package/test/rarebit-core.test.mjs +50 -0
- package/test/rarebit-recap.test.mjs +662 -0
- package/test/rarebit-settings-ui.test.mjs +400 -0
- package/test/rarebit-sidecar-protocol.test.mjs +20 -0
- package/test/rarebit-v020.test.mjs +299 -0
- package/test/recap-read-checkpoint.test.mjs +126 -0
- package/test/summary-attention-independent.test.mjs +1 -1
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import { selectRarebits, sha256 } from "../src/rarebit-core.mjs";
|
|
9
|
+
import registerPiRarebit from "../src/extension.mjs";
|
|
10
|
+
import { readConfiguredRarebitSettings } from "../src/extension.mjs";
|
|
11
|
+
import { processRarebitSummary } from "../src/rarebit-service.mjs";
|
|
12
|
+
import { readRarebitSession } from "../src/rarebit-session.mjs";
|
|
13
|
+
import { readRarebitCurrent } from "../src/rarebit-store.mjs";
|
|
14
|
+
import {
|
|
15
|
+
createRarebitRecapController,
|
|
16
|
+
DEFAULT_RAREBIT_RECAP_DELAY_MS,
|
|
17
|
+
RAREBIT_RECAP_WIDGET_KEY,
|
|
18
|
+
} from "../src/rarebit-recap.mjs";
|
|
19
|
+
|
|
20
|
+
function branchFor(id = "assistant-1") {
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
type: "message",
|
|
24
|
+
id: "owner-1",
|
|
25
|
+
message: { role: "user", content: "synthetic owner request" },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
type: "message",
|
|
29
|
+
id,
|
|
30
|
+
message: {
|
|
31
|
+
role: "assistant",
|
|
32
|
+
stopReason: "stop",
|
|
33
|
+
content: "synthetic completed response",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function contextFor({ sessionId = "session-1", sessionFile = "/tmp/session-1.jsonl", branch = branchFor(), setWidget }) {
|
|
40
|
+
return {
|
|
41
|
+
mode: "tui",
|
|
42
|
+
hasUI: true,
|
|
43
|
+
ui: { setWidget },
|
|
44
|
+
sessionManager: {
|
|
45
|
+
getHeader: () => ({ id: sessionId }),
|
|
46
|
+
getSessionFile: () => sessionFile,
|
|
47
|
+
getBranch: () => branch,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function receiptFor(ctx, jobId = "job-1") {
|
|
53
|
+
const branch = ctx.sessionManager.getBranch();
|
|
54
|
+
const selection = selectRarebits(branch);
|
|
55
|
+
const observedAt = "2026-09-27T00:00:00.000Z";
|
|
56
|
+
const latestUser = selection.occurrences.filter((item) => item.role === "user").at(-1);
|
|
57
|
+
return {
|
|
58
|
+
schemaVersion: 4,
|
|
59
|
+
type: "rarebit_summary",
|
|
60
|
+
status: "ok",
|
|
61
|
+
jobId: /^[a-f0-9]{64}$/.test(jobId) ? jobId : "1".repeat(64),
|
|
62
|
+
sessionId: ctx.sessionManager.getHeader().id,
|
|
63
|
+
implementationVersion: "hc-rarebit-summary-v7",
|
|
64
|
+
synthesisMode: "forced",
|
|
65
|
+
lifecycleBoundary: "agent_settled",
|
|
66
|
+
inputCoveragePolicy: { strategy: "newest_suffix_with_explicit_omission", maxPromptChars: 10_000 },
|
|
67
|
+
promptVersion: "rarebit-summary-v8",
|
|
68
|
+
model: { provider: "test", id: "model" },
|
|
69
|
+
modelProvenance: { source: "test", status: "resolved" },
|
|
70
|
+
observedAt,
|
|
71
|
+
branch: { leafId: branch.at(-1)?.id ?? null, entryCount: branch.length, pathHash: sha256(branch.map((item) => item.id)) },
|
|
72
|
+
selection: {
|
|
73
|
+
manifestHash: selection.manifestHash,
|
|
74
|
+
selectorVersion: selection.manifest.selectorVersion,
|
|
75
|
+
occurrenceCount: selection.occurrences.length,
|
|
76
|
+
uniquePayloadCount: selection.payloads.length,
|
|
77
|
+
latestUserSourceEntryId: latestUser?.sourceEntryId ?? null,
|
|
78
|
+
},
|
|
79
|
+
sessionStatus: "finished",
|
|
80
|
+
statusReason: "all_requests_accomplished",
|
|
81
|
+
inputCoverage: { totalMessageCount: selection.occurrences.length, includedMessageCount: selection.occurrences.length, omittedMessageCount: 0, omittedTextChars: 0, promptChars: 500, complete: true },
|
|
82
|
+
summary: "RECAP_SENTINEL: display-only current Summary",
|
|
83
|
+
synthesis: {
|
|
84
|
+
schemaVersion: 1, kind: "rarebit_model_synthesis", outcome: "complete",
|
|
85
|
+
requestedModel: { provider: "test", id: "model" },
|
|
86
|
+
timing: { startedAt: observedAt, completedAt: observedAt, durationMs: 0, provenance: "local_monotonic_clock" },
|
|
87
|
+
provider: Object.fromEntries(["responseProvider", "responseProviderSource", "responseModel", "responseModelSource", "responseId", "responseIdSource", "requestId", "requestIdSource"].map((key) => [key, null])),
|
|
88
|
+
usage: { availability: "unavailable", ...Object.fromEntries(["inputTokens", "outputTokens", "totalTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens", "estimatedCostUsd"].map((key) => [key, null])), provenance: Object.fromEntries(["inputTokens", "outputTokens", "totalTokens", "cacheReadTokens", "cacheWriteTokens", "reasoningTokens", "estimatedCostUsd"].map((key) => [key, null])) },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function currentFor(receipt) {
|
|
94
|
+
return {
|
|
95
|
+
receipt,
|
|
96
|
+
artifactState: {
|
|
97
|
+
syncState: "assessment_current",
|
|
98
|
+
applicability: "exact_selection",
|
|
99
|
+
receiptRef: { jobId: receipt.jobId },
|
|
100
|
+
projection: { status: receipt.sessionStatus },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test("request-prefix Recap stores only the receipt evidence cut and its hash", async () => {
|
|
106
|
+
const branch = branchFor("later-assistant");
|
|
107
|
+
branch[1].message.stopReason = "toolUse";
|
|
108
|
+
const ctx = contextFor({ branch, setWidget() {} });
|
|
109
|
+
const prefix = selectRarebits(branch.slice(0, 1));
|
|
110
|
+
const receipt = {
|
|
111
|
+
...receiptFor(ctx), lifecycleBoundary: "owner_request", sessionStatus: "user_requested",
|
|
112
|
+
statusReason: "owner_request_recorded",
|
|
113
|
+
selection: {
|
|
114
|
+
manifestHash: prefix.manifestHash,
|
|
115
|
+
selectorVersion: prefix.manifest.selectorVersion,
|
|
116
|
+
occurrenceCount: prefix.occurrences.length,
|
|
117
|
+
uniquePayloadCount: prefix.payloads.length,
|
|
118
|
+
latestUserSourceEntryId: "owner-1",
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const saved = [];
|
|
122
|
+
const controller = createRarebitRecapController({
|
|
123
|
+
delayMs: 0,
|
|
124
|
+
appendEntry: (type, data) => {
|
|
125
|
+
const entry = { type: "custom", customType: type, id: `entry-${saved.length}`, data };
|
|
126
|
+
saved.push(entry);
|
|
127
|
+
branch.push(entry);
|
|
128
|
+
},
|
|
129
|
+
readCurrent: async () => ({ receipt, artifactState: {
|
|
130
|
+
syncState: "request_current", applicability: "request_generation",
|
|
131
|
+
receiptRef: { jobId: receipt.jobId }, projection: { status: receipt.sessionStatus },
|
|
132
|
+
} }),
|
|
133
|
+
});
|
|
134
|
+
const result = await controller.showExisting(ctx);
|
|
135
|
+
assert.equal(result.shown, true);
|
|
136
|
+
assert.equal(saved.length, 1);
|
|
137
|
+
assert.equal(saved[0].data.coveredEntryId, "owner-1");
|
|
138
|
+
assert.equal(saved[0].data.selectionHash, prefix.manifestHash);
|
|
139
|
+
assert.equal((await controller.showExisting(ctx)).shown, true);
|
|
140
|
+
assert.equal(saved.length, 1, "redisplay reuses the active-branch Recap identity");
|
|
141
|
+
const rendered = controller.renderEntry(saved[0], {}, { fg: (_tone, text) => text }).render(15);
|
|
142
|
+
assert.ok(rendered.some((line) => line.includes("Recap·") && line.endsWith("[got it]")), "ANSI-aware truncation reserves the inline action width");
|
|
143
|
+
assert.equal(rendered.some((line) => line.trim() === "[got it]"), false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function fakeClock() {
|
|
147
|
+
let now = 0;
|
|
148
|
+
let nextId = 1;
|
|
149
|
+
const timers = new Map();
|
|
150
|
+
return {
|
|
151
|
+
setTimeout(callback, delay) {
|
|
152
|
+
const id = nextId++;
|
|
153
|
+
timers.set(id, { at: now + delay, callback });
|
|
154
|
+
return id;
|
|
155
|
+
},
|
|
156
|
+
clearTimeout(id) {
|
|
157
|
+
timers.delete(id);
|
|
158
|
+
},
|
|
159
|
+
async advance(milliseconds) {
|
|
160
|
+
const target = now + milliseconds;
|
|
161
|
+
while (true) {
|
|
162
|
+
const due = [...timers.entries()]
|
|
163
|
+
.filter(([, timer]) => timer.at <= target)
|
|
164
|
+
.sort(([, left], [, right]) => left.at - right.at)
|
|
165
|
+
.at(0);
|
|
166
|
+
if (!due) break;
|
|
167
|
+
const [id, timer] = due;
|
|
168
|
+
timers.delete(id);
|
|
169
|
+
now = timer.at;
|
|
170
|
+
await timer.callback();
|
|
171
|
+
}
|
|
172
|
+
now = target;
|
|
173
|
+
},
|
|
174
|
+
get pending() {
|
|
175
|
+
return timers.size;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
test("a request-prefix Recap rejects a live assistant stop while settlement is pending", async () => {
|
|
181
|
+
const branch = branchFor();
|
|
182
|
+
const ctx = contextFor({ branch, setWidget() {} });
|
|
183
|
+
const prefix = selectRarebits(branch.slice(0, 1));
|
|
184
|
+
const receipt = { ...receiptFor(ctx), lifecycleBoundary: "owner_request", sessionStatus: "user_requested",
|
|
185
|
+
statusReason: "owner_request_recorded", selection: {
|
|
186
|
+
manifestHash: prefix.manifestHash, selectorVersion: prefix.manifest.selectorVersion,
|
|
187
|
+
occurrenceCount: 1, uniquePayloadCount: prefix.payloads.length,
|
|
188
|
+
latestUserSourceEntryId: "owner-1",
|
|
189
|
+
} };
|
|
190
|
+
const controller = createRarebitRecapController({ readCurrent: async () => ({ receipt, artifactState: {
|
|
191
|
+
syncState: "request_current", applicability: "request_generation",
|
|
192
|
+
receiptRef: { jobId: receipt.jobId }, projection: { status: receipt.sessionStatus },
|
|
193
|
+
} }) });
|
|
194
|
+
const result = await controller.showExisting(ctx);
|
|
195
|
+
assert.equal(result.shown, false);
|
|
196
|
+
assert.equal(result.reason, "settlement_pending");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("Recap reprojects the live branch after asynchronous renderer imports", async () => {
|
|
200
|
+
const branch = branchFor();
|
|
201
|
+
const ctx = contextFor({ branch, setWidget() {} });
|
|
202
|
+
const receipt = receiptFor(ctx);
|
|
203
|
+
let reads = 0;
|
|
204
|
+
const saved = [];
|
|
205
|
+
const controller = createRarebitRecapController({
|
|
206
|
+
appendEntry: (type, data) => saved.push({ type, data }),
|
|
207
|
+
readCurrent: async () => {
|
|
208
|
+
reads += 1;
|
|
209
|
+
if (reads === 2) branch.push({ id: "new-user", type: "message", message: { role: "user", content: "new request" } });
|
|
210
|
+
return currentFor(receipt);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
const result = await controller.showExisting(ctx);
|
|
214
|
+
assert.equal(reads, 2);
|
|
215
|
+
assert.equal(result.shown, false);
|
|
216
|
+
assert.equal(saved.length, 0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("default recap delay is one minute and a current receipt renders only in the TUI widget", async () => {
|
|
220
|
+
assert.equal(DEFAULT_RAREBIT_RECAP_DELAY_MS, 60_000);
|
|
221
|
+
const clock = fakeClock();
|
|
222
|
+
const widgets = [];
|
|
223
|
+
const ctx = contextFor({
|
|
224
|
+
setWidget: (...args) => widgets.push(args),
|
|
225
|
+
});
|
|
226
|
+
const receipt = receiptFor(ctx);
|
|
227
|
+
let reads = 0;
|
|
228
|
+
const controller = createRarebitRecapController({
|
|
229
|
+
readCurrent: async () => {
|
|
230
|
+
reads += 1;
|
|
231
|
+
return currentFor(receipt);
|
|
232
|
+
},
|
|
233
|
+
setTimeoutFn: clock.setTimeout,
|
|
234
|
+
clearTimeoutFn: clock.clearTimeout,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
controller.updateContext(ctx);
|
|
238
|
+
const token = controller.captureMaterialization(ctx);
|
|
239
|
+
assert.equal(
|
|
240
|
+
controller.offer({ ctx, result: { record: receipt }, token }),
|
|
241
|
+
true,
|
|
242
|
+
);
|
|
243
|
+
await clock.advance(59_999);
|
|
244
|
+
assert.equal(widgets.length, 0);
|
|
245
|
+
assert.equal(reads, 0);
|
|
246
|
+
assert.equal(clock.pending, 1);
|
|
247
|
+
|
|
248
|
+
await clock.advance(1);
|
|
249
|
+
await Promise.resolve();
|
|
250
|
+
await Promise.resolve();
|
|
251
|
+
assert.equal(reads, 2, "the live projection is rechecked after renderer imports");
|
|
252
|
+
assert.equal(widgets.length, 1);
|
|
253
|
+
assert.equal(widgets[0][0], RAREBIT_RECAP_WIDGET_KEY);
|
|
254
|
+
assert.match(widgetText(widgets[0][1]), /RECAP_SENTINEL/);
|
|
255
|
+
assert.deepEqual(widgets[0][2], { placement: "aboveEditor" });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("typing does not clear the recap, but submitted input and a new turn do", async () => {
|
|
259
|
+
const clock = fakeClock();
|
|
260
|
+
const widgets = [];
|
|
261
|
+
const ctx = contextFor({ setWidget: (...args) => widgets.push(args) });
|
|
262
|
+
const receipt = receiptFor(ctx);
|
|
263
|
+
const controller = createRarebitRecapController({
|
|
264
|
+
delayMs: 1,
|
|
265
|
+
readCurrent: async () => currentFor(receipt),
|
|
266
|
+
setTimeoutFn: clock.setTimeout,
|
|
267
|
+
clearTimeoutFn: clock.clearTimeout,
|
|
268
|
+
});
|
|
269
|
+
controller.updateContext(ctx);
|
|
270
|
+
controller.offer({
|
|
271
|
+
ctx,
|
|
272
|
+
result: { record: receipt },
|
|
273
|
+
token: controller.captureMaterialization(ctx),
|
|
274
|
+
});
|
|
275
|
+
await clock.advance(1);
|
|
276
|
+
await Promise.resolve();
|
|
277
|
+
await Promise.resolve();
|
|
278
|
+
assert.equal(widgets.length, 1);
|
|
279
|
+
|
|
280
|
+
// Pi has no event for each editor keystroke. The recap remains visible while
|
|
281
|
+
// the owner types; only a submitted input or lifecycle transition clears it.
|
|
282
|
+
assert.equal(widgets.length, 1);
|
|
283
|
+
controller.invalidate(ctx, 1);
|
|
284
|
+
assert.equal(widgets.at(-1)[1], undefined);
|
|
285
|
+
|
|
286
|
+
controller.offer({
|
|
287
|
+
ctx,
|
|
288
|
+
result: { record: receipt },
|
|
289
|
+
token: controller.captureMaterialization(ctx),
|
|
290
|
+
});
|
|
291
|
+
controller.invalidate(ctx, 2);
|
|
292
|
+
await clock.advance(1);
|
|
293
|
+
assert.equal(widgets.filter((call) => call[1] !== undefined).length, 1);
|
|
294
|
+
assert.equal(clock.pending, 0);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("session or branch changes cancel a detached read and cannot render stale completion", async () => {
|
|
298
|
+
const clock = fakeClock();
|
|
299
|
+
const widgets = [];
|
|
300
|
+
const firstBranch = branchFor("assistant-1");
|
|
301
|
+
const first = contextFor({
|
|
302
|
+
sessionId: "session-1",
|
|
303
|
+
sessionFile: "/tmp/session-1.jsonl",
|
|
304
|
+
branch: firstBranch,
|
|
305
|
+
setWidget: (...args) => widgets.push(args),
|
|
306
|
+
});
|
|
307
|
+
const secondBranch = branchFor("assistant-2");
|
|
308
|
+
const second = contextFor({
|
|
309
|
+
sessionId: "session-2",
|
|
310
|
+
sessionFile: "/tmp/session-2.jsonl",
|
|
311
|
+
branch: secondBranch,
|
|
312
|
+
setWidget: (...args) => widgets.push(args),
|
|
313
|
+
});
|
|
314
|
+
const receipt = receiptFor(first);
|
|
315
|
+
let resolveRead;
|
|
316
|
+
const readPending = new Promise((resolve) => {
|
|
317
|
+
resolveRead = resolve;
|
|
318
|
+
});
|
|
319
|
+
const controller = createRarebitRecapController({
|
|
320
|
+
delayMs: 1,
|
|
321
|
+
readCurrent: async () => readPending,
|
|
322
|
+
setTimeoutFn: clock.setTimeout,
|
|
323
|
+
clearTimeoutFn: clock.clearTimeout,
|
|
324
|
+
});
|
|
325
|
+
controller.updateContext(first);
|
|
326
|
+
controller.offer({
|
|
327
|
+
ctx: first,
|
|
328
|
+
result: { record: receipt },
|
|
329
|
+
token: controller.captureMaterialization(first),
|
|
330
|
+
});
|
|
331
|
+
const advancing = clock.advance(1);
|
|
332
|
+
await Promise.resolve();
|
|
333
|
+
controller.invalidate(second, 2);
|
|
334
|
+
resolveRead(currentFor(receipt));
|
|
335
|
+
await advancing;
|
|
336
|
+
await Promise.resolve();
|
|
337
|
+
await Promise.resolve();
|
|
338
|
+
assert.equal(
|
|
339
|
+
widgets.some((call) => call[0] === RAREBIT_RECAP_WIDGET_KEY && call[1]),
|
|
340
|
+
false,
|
|
341
|
+
);
|
|
342
|
+
assert.equal(widgets.at(-1)[1], undefined);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("showExisting accepts current metadata-only branch drift and rejects changed selection", async () => {
|
|
346
|
+
const root = await mkdtemp(join(tmpdir(), "rarebit-recap-branch-drift-"));
|
|
347
|
+
const sessionRoot = join(root, "sessions");
|
|
348
|
+
const rarebitRoot = join(root, "rarebit");
|
|
349
|
+
const manager = SessionManager.create(root, sessionRoot);
|
|
350
|
+
const timestamp = Date.now();
|
|
351
|
+
manager.appendMessage({
|
|
352
|
+
role: "user",
|
|
353
|
+
content: [{ type: "text", text: "fixture owner request" }],
|
|
354
|
+
timestamp,
|
|
355
|
+
});
|
|
356
|
+
manager.appendMessage({
|
|
357
|
+
role: "assistant",
|
|
358
|
+
content: [{ type: "text", text: "fixture completed response" }],
|
|
359
|
+
api: "openai-completions",
|
|
360
|
+
provider: "fixture",
|
|
361
|
+
model: "summary",
|
|
362
|
+
usage: {
|
|
363
|
+
input: 1,
|
|
364
|
+
output: 1,
|
|
365
|
+
cacheRead: 0,
|
|
366
|
+
cacheWrite: 0,
|
|
367
|
+
totalTokens: 2,
|
|
368
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
369
|
+
},
|
|
370
|
+
stopReason: "stop",
|
|
371
|
+
timestamp: timestamp + 1,
|
|
372
|
+
});
|
|
373
|
+
const sessionFile = manager.getSessionFile();
|
|
374
|
+
const baseContext = {
|
|
375
|
+
cwd: root,
|
|
376
|
+
mode: "tui",
|
|
377
|
+
hasUI: true,
|
|
378
|
+
model: { provider: "fixture", id: "summary" },
|
|
379
|
+
sessionManager: manager,
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
const materialized = await processRarebitSummary(baseContext, {
|
|
383
|
+
sessionRoot,
|
|
384
|
+
rarebitRoot,
|
|
385
|
+
model: { provider: "fixture", id: "summary" },
|
|
386
|
+
summaryPolicy: { minTotalLength: 0, maxRarebitRatio: 1 },
|
|
387
|
+
lifecycleBoundary: "agent_settled",
|
|
388
|
+
queryAutomaticSummaryPolicy: async () => ({ decision: "abstain" }),
|
|
389
|
+
modelClient: {
|
|
390
|
+
complete: async () => ({
|
|
391
|
+
text: JSON.stringify({
|
|
392
|
+
summary: "fixture current Summary",
|
|
393
|
+
sessionStatus: "finished",
|
|
394
|
+
statusReason: "all_requests_accomplished",
|
|
395
|
+
}),
|
|
396
|
+
}),
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
assert.equal(materialized.record.status, "ok");
|
|
400
|
+
|
|
401
|
+
manager.appendCustomEntry("fixture.metadata", { source: "session-ui" });
|
|
402
|
+
const active = SessionManager.open(sessionFile, sessionRoot);
|
|
403
|
+
const loaded = await readRarebitSession(sessionFile, { sessionRoot });
|
|
404
|
+
const current = await readRarebitCurrent({
|
|
405
|
+
sessionFile,
|
|
406
|
+
sessionRoot,
|
|
407
|
+
rarebitRoot,
|
|
408
|
+
});
|
|
409
|
+
assert.equal(loaded.branch.at(-1)?.id, active.getLeafId());
|
|
410
|
+
assert.equal(loaded.branch.at(-1)?.type, "custom");
|
|
411
|
+
assert.notEqual(materialized.record.branch.leafId, active.getLeafId());
|
|
412
|
+
assert.equal(
|
|
413
|
+
loaded.selection.manifestHash,
|
|
414
|
+
materialized.record.selection.manifestHash,
|
|
415
|
+
);
|
|
416
|
+
assert.equal(current.artifactState.syncState, "assessment_current");
|
|
417
|
+
assert.equal(current.artifactState.applicability, "exact_selection");
|
|
418
|
+
assert.equal(
|
|
419
|
+
current.artifactState.receiptRef.jobId,
|
|
420
|
+
materialized.record.jobId,
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const widgets = [];
|
|
424
|
+
const controller = createRarebitRecapController({ delayMs: 0 });
|
|
425
|
+
const activeContext = {
|
|
426
|
+
...baseContext,
|
|
427
|
+
ui: { setWidget: (...args) => widgets.push(args) },
|
|
428
|
+
sessionManager: active,
|
|
429
|
+
};
|
|
430
|
+
const shown = await controller.showExisting(activeContext, {
|
|
431
|
+
sessionRoot,
|
|
432
|
+
rarebitRoot,
|
|
433
|
+
});
|
|
434
|
+
assert.equal(shown.shown, true);
|
|
435
|
+
assert.equal(shown.reason, "current_summary");
|
|
436
|
+
assert.equal(widgets.at(-1)[0], RAREBIT_RECAP_WIDGET_KEY);
|
|
437
|
+
|
|
438
|
+
active.appendMessage({
|
|
439
|
+
role: "user",
|
|
440
|
+
content: [{ type: "text", text: "selection changed" }],
|
|
441
|
+
timestamp: timestamp + 2,
|
|
442
|
+
});
|
|
443
|
+
const changed = SessionManager.open(sessionFile, sessionRoot);
|
|
444
|
+
const changedLoaded = await readRarebitSession(sessionFile, {
|
|
445
|
+
sessionRoot,
|
|
446
|
+
});
|
|
447
|
+
assert.notEqual(
|
|
448
|
+
changedLoaded.selection.manifestHash,
|
|
449
|
+
loaded.selection.manifestHash,
|
|
450
|
+
);
|
|
451
|
+
const rejected = await controller.showExisting(
|
|
452
|
+
{ ...activeContext, sessionManager: changed },
|
|
453
|
+
{ sessionRoot, rarebitRoot },
|
|
454
|
+
);
|
|
455
|
+
assert.equal(rejected.shown, false);
|
|
456
|
+
assert.equal(rejected.reason, "materialization_pending");
|
|
457
|
+
assert.equal(widgets.at(-1)[1], undefined);
|
|
458
|
+
} finally {
|
|
459
|
+
await rm(root, { recursive: true, force: true });
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("extension lifecycle materializes a real sidecar and arms the widget without Session prose", async () => {
|
|
464
|
+
const root = await mkdtemp(join(tmpdir(), "rarebit-recap-extension-"));
|
|
465
|
+
const sessionRoot = join(root, "sessions");
|
|
466
|
+
const rarebitRoot = join(root, "rarebit");
|
|
467
|
+
const sessionFile = join(sessionRoot, "session.jsonl");
|
|
468
|
+
const branch = branchFor().map((entry, index, entries) => ({
|
|
469
|
+
...entry,
|
|
470
|
+
parentId: index === 0 ? null : entries[index - 1].id,
|
|
471
|
+
timestamp: `2026-09-27T00:00:0${index}.000Z`,
|
|
472
|
+
}));
|
|
473
|
+
await (await import("node:fs/promises")).mkdir(sessionRoot, { recursive: true });
|
|
474
|
+
await writeFile(
|
|
475
|
+
sessionFile,
|
|
476
|
+
`${[
|
|
477
|
+
{
|
|
478
|
+
type: "session",
|
|
479
|
+
version: 3,
|
|
480
|
+
id: "session-1",
|
|
481
|
+
cwd: root,
|
|
482
|
+
},
|
|
483
|
+
...branch,
|
|
484
|
+
]
|
|
485
|
+
.map((entry) => JSON.stringify(entry))
|
|
486
|
+
.join("\n")}\n`,
|
|
487
|
+
{ mode: 0o600 },
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const handlers = new Map();
|
|
491
|
+
const commands = new Map();
|
|
492
|
+
const widgets = [];
|
|
493
|
+
const notices = [];
|
|
494
|
+
let providerCalls = 0;
|
|
495
|
+
let releaseDelayedProvider;
|
|
496
|
+
let markProviderStarted;
|
|
497
|
+
const delayedProviderStarted = new Promise((resolve) => { markProviderStarted = resolve; });
|
|
498
|
+
const pi = {
|
|
499
|
+
on: (event, handler) => handlers.set(event, handler),
|
|
500
|
+
registerCommand: (name, command) => commands.set(name, command),
|
|
501
|
+
getSessionName: () => "synthetic recap session",
|
|
502
|
+
events: { emit() {} },
|
|
503
|
+
};
|
|
504
|
+
registerPiRarebit(pi, {
|
|
505
|
+
sessionRoot,
|
|
506
|
+
rarebitRoot,
|
|
507
|
+
model: { provider: "test", id: "summary-model" },
|
|
508
|
+
summaryPolicy: { minTotalLength: 0, maxRarebitRatio: 1 },
|
|
509
|
+
recap: { delayMs: 1 },
|
|
510
|
+
settingsLoader: async () => ({
|
|
511
|
+
autoTitle: false,
|
|
512
|
+
recap: { enabled: true, delayMs: 1 },
|
|
513
|
+
summaryPolicy: { minTotalLength: 0, maxRarebitRatio: 1 },
|
|
514
|
+
}),
|
|
515
|
+
queryAutomaticSummaryPolicy: async () => ({
|
|
516
|
+
decision: "abstain",
|
|
517
|
+
queryStatus: "test",
|
|
518
|
+
}),
|
|
519
|
+
modelClient: {
|
|
520
|
+
complete: async () => {
|
|
521
|
+
providerCalls += 1;
|
|
522
|
+
if (providerCalls === 3) {
|
|
523
|
+
markProviderStarted();
|
|
524
|
+
return new Promise((resolve) => { releaseDelayedProvider = () => resolve({
|
|
525
|
+
text: JSON.stringify({ summary: "Delayed synthetic assessment", sessionStatus: "finished", statusReason: "all_requests_accomplished" }),
|
|
526
|
+
}); });
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
text: JSON.stringify({
|
|
530
|
+
summary: "RECAP_SENTINEL: generated only for the sidecar",
|
|
531
|
+
sessionStatus: "finished",
|
|
532
|
+
statusReason: "all_requests_accomplished",
|
|
533
|
+
}),
|
|
534
|
+
};
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
activityReporter: {
|
|
538
|
+
start() {},
|
|
539
|
+
update() {},
|
|
540
|
+
stop() {},
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
const ctx = {
|
|
544
|
+
cwd: root,
|
|
545
|
+
mode: "tui",
|
|
546
|
+
hasUI: true,
|
|
547
|
+
sessionId: "session-1",
|
|
548
|
+
model: { provider: "test", id: "summary-model" },
|
|
549
|
+
isProjectTrusted: () => true,
|
|
550
|
+
ui: {
|
|
551
|
+
setWidget: (...args) => widgets.push(args),
|
|
552
|
+
notify: (text, level) => notices.push({ text, level }),
|
|
553
|
+
},
|
|
554
|
+
sessionManager: {
|
|
555
|
+
getHeader: () => ({ id: "session-1" }),
|
|
556
|
+
getSessionFile: () => sessionFile,
|
|
557
|
+
getBranch: () => branch,
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
try {
|
|
561
|
+
handlers.get("session_start")({}, ctx);
|
|
562
|
+
handlers.get("agent_start")({}, ctx);
|
|
563
|
+
handlers.get("agent_end")(
|
|
564
|
+
{ messages: [branch.at(-1).message] },
|
|
565
|
+
ctx,
|
|
566
|
+
);
|
|
567
|
+
handlers.get("agent_settled")({}, ctx);
|
|
568
|
+
const deadline = Date.now() + 2_000;
|
|
569
|
+
while (!widgets.some((call) => call[1] !== undefined)) {
|
|
570
|
+
if (Date.now() > deadline) throw new Error("recap widget did not render");
|
|
571
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
572
|
+
}
|
|
573
|
+
assert.equal(providerCalls, 1);
|
|
574
|
+
const rendered = widgets.find((call) => call[1] !== undefined);
|
|
575
|
+
assert.equal(rendered[0], RAREBIT_RECAP_WIDGET_KEY);
|
|
576
|
+
assert.match(widgetText(rendered[1]), /RECAP_SENTINEL/);
|
|
577
|
+
assert.deepEqual(rendered[2], { placement: "aboveEditor" });
|
|
578
|
+
|
|
579
|
+
const native = await readFile(sessionFile, "utf8");
|
|
580
|
+
assert.doesNotMatch(native, /RECAP_SENTINEL/);
|
|
581
|
+
await commands.get("rarebit").handler("recap", ctx);
|
|
582
|
+
assert.equal(providerCalls, 1, "displaying an existing recap must not synthesize");
|
|
583
|
+
await commands.get("rarebit").handler("summarize", ctx);
|
|
584
|
+
const manualDeadline = Date.now() + 2_000;
|
|
585
|
+
while (!notices.some((item) => /updated and current/.test(item.text))) {
|
|
586
|
+
if (Date.now() > manualDeadline) throw new Error("manual Summary result was not reported");
|
|
587
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
588
|
+
}
|
|
589
|
+
assert.equal(providerCalls, 2);
|
|
590
|
+
await commands.get("rarebit").handler("summarize", ctx);
|
|
591
|
+
const duplicateDeadline = Date.now() + 2_000;
|
|
592
|
+
while (!notices.some((item) => /already current/.test(item.text))) {
|
|
593
|
+
if (Date.now() > duplicateDeadline) throw new Error("duplicate Summary result was not reported");
|
|
594
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
595
|
+
}
|
|
596
|
+
assert.equal(providerCalls, 2);
|
|
597
|
+
|
|
598
|
+
branch.push({ type: "message", id: "owner-2", parentId: branch.at(-1).id,
|
|
599
|
+
timestamp: "2026-09-27T00:00:03.000Z", message: { role: "user", content: "Synthetic request for delayed currentness check" } });
|
|
600
|
+
await writeFile(sessionFile, `${[{ type: "session", version: 3, id: "session-1", cwd: root }, ...branch].map((entry) => JSON.stringify(entry)).join("\n")}\n`, { mode: 0o600 });
|
|
601
|
+
await commands.get("rarebit").handler("summarize", ctx);
|
|
602
|
+
await delayedProviderStarted;
|
|
603
|
+
const nextContext = {
|
|
604
|
+
...ctx,
|
|
605
|
+
sessionId: "session-2",
|
|
606
|
+
sessionManager: {
|
|
607
|
+
getHeader: () => ({ id: "session-2" }),
|
|
608
|
+
getSessionFile: () => join(sessionRoot, "session-2.jsonl"),
|
|
609
|
+
getBranch: () => branchFor("session-2-assistant"),
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
handlers.get("session_start")({}, nextContext);
|
|
613
|
+
releaseDelayedProvider();
|
|
614
|
+
const staleDeadline = Date.now() + 2_000;
|
|
615
|
+
while (!notices.some((item) => /generated but is not current/.test(item.text))) {
|
|
616
|
+
if (Date.now() > staleDeadline) throw new Error("session-switched Summary result was not reported as non-current");
|
|
617
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
618
|
+
}
|
|
619
|
+
assert.equal(notices.at(-1).level, "warning");
|
|
620
|
+
assert.equal(providerCalls, 3);
|
|
621
|
+
} finally {
|
|
622
|
+
handlers.get("session_shutdown")({}, ctx);
|
|
623
|
+
await rm(root, { recursive: true, force: true });
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("settings preserve a global recap disable when a trusted project overrides only the delay", async () => {
|
|
628
|
+
const root = await mkdtemp(join(tmpdir(), "rarebit-recap-settings-"));
|
|
629
|
+
const agentDir = join(root, "agent");
|
|
630
|
+
const projectDir = join(root, "project");
|
|
631
|
+
await mkdir(agentDir, { recursive: true });
|
|
632
|
+
await mkdir(join(projectDir, ".pi"), { recursive: true });
|
|
633
|
+
await writeFile(
|
|
634
|
+
join(agentDir, "settings.json"),
|
|
635
|
+
JSON.stringify({
|
|
636
|
+
rarebit: {
|
|
637
|
+
model: "test/summary-model",
|
|
638
|
+
recap: { enabled: false },
|
|
639
|
+
},
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
await writeFile(
|
|
643
|
+
join(projectDir, ".pi", "settings.json"),
|
|
644
|
+
JSON.stringify({ rarebit: { recap: { delay_ms: 123 } } }),
|
|
645
|
+
);
|
|
646
|
+
try {
|
|
647
|
+
const settings = await readConfiguredRarebitSettings({
|
|
648
|
+
cwd: projectDir,
|
|
649
|
+
projectTrusted: true,
|
|
650
|
+
model: { provider: "test", id: "summary-model" },
|
|
651
|
+
agentDir,
|
|
652
|
+
});
|
|
653
|
+
assert.equal(settings.recap.enabled, false);
|
|
654
|
+
assert.equal(settings.recap.delayMs, 123);
|
|
655
|
+
} finally {
|
|
656
|
+
await rm(root, { recursive: true, force: true });
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
function widgetText(factory) {
|
|
661
|
+
return factory({ requestRender() {} }, { fg: (_name, text) => text }).render(160).join("\n");
|
|
662
|
+
}
|