@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,299 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE,
|
|
9
|
+
RAREBIT_SUMMARY_PROMPT_VERSION,
|
|
10
|
+
composeRarebitSummaryDerivationInput,
|
|
11
|
+
composeRarebitSummaryPrompt,
|
|
12
|
+
normalizeRarebitSummaryPrompt,
|
|
13
|
+
rarebitJobIdentity,
|
|
14
|
+
selectRarebits,
|
|
15
|
+
sha256,
|
|
16
|
+
} from "../src/rarebit-core.mjs";
|
|
17
|
+
import { processRarebitSummary } from "../src/rarebit-service.mjs";
|
|
18
|
+
import { extractRarebitSynthesisReceipt } from "../src/rarebit-model.mjs";
|
|
19
|
+
import { resolveRarebitSettings } from "../src/rarebit-settings.mjs";
|
|
20
|
+
import {
|
|
21
|
+
RAREBIT_SETTINGS_FIELDS,
|
|
22
|
+
readRarebitSettingsDocument,
|
|
23
|
+
saveRarebitSettingsDocument,
|
|
24
|
+
} from "../src/rarebit-settings-ui.mjs";
|
|
25
|
+
import {
|
|
26
|
+
createRarebitRecapController,
|
|
27
|
+
RAREBIT_RECAP_WIDGET_KEY,
|
|
28
|
+
} from "../src/rarebit-recap.mjs";
|
|
29
|
+
|
|
30
|
+
const entry = (id, message) => ({
|
|
31
|
+
type: "message",
|
|
32
|
+
id,
|
|
33
|
+
timestamp: "2026-09-27T00:00:00.000Z",
|
|
34
|
+
message,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function branch() {
|
|
38
|
+
return [
|
|
39
|
+
entry("u1", { role: "user", content: "Investigate the deployment issue." }),
|
|
40
|
+
entry("a1", { role: "assistant", stopReason: "stop", content: "I found the failing check." }),
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function fixture() {
|
|
45
|
+
const root = await mkdtemp(join(tmpdir(), "rarebit-v020-prompt-"));
|
|
46
|
+
await mkdir(join(root, "agent"), { recursive: true });
|
|
47
|
+
await writeFile(join(root, "agent", "settings.json"), "{}\n");
|
|
48
|
+
const sessionRoot = join(root, "sessions");
|
|
49
|
+
const rarebitRoot = join(root, "rarebit");
|
|
50
|
+
await mkdir(sessionRoot, { recursive: true });
|
|
51
|
+
const sessionFile = join(sessionRoot, "trace.jsonl");
|
|
52
|
+
await writeFile(sessionFile, '{"type":"session","id":"prompt-contract"}\n');
|
|
53
|
+
return { root, sessionRoot, rarebitRoot, sessionFile };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function recapContext(summary, setWidget) {
|
|
57
|
+
const branch = [
|
|
58
|
+
entry("recap-u", { role: "user", content: "Read the current deployment state." }),
|
|
59
|
+
entry("recap-a", { role: "assistant", stopReason: "stop", content: "The deployment is complete." }),
|
|
60
|
+
];
|
|
61
|
+
const selection = selectRarebits(branch);
|
|
62
|
+
const observedAt = "2026-09-27T00:00:00.000Z";
|
|
63
|
+
const model = { provider: "fixture", id: "summary" };
|
|
64
|
+
const receipt = {
|
|
65
|
+
schemaVersion: 4, type: "rarebit_summary", status: "ok", jobId: sha256("recap-v020-job"),
|
|
66
|
+
sessionId: "recap-v020-session",
|
|
67
|
+
branch: { leafId: "recap-a", entryCount: branch.length, pathHash: sha256(branch.map((item) => item.id)) },
|
|
68
|
+
selection: { manifestHash: selection.manifestHash, selectorVersion: selection.manifest.selectorVersion,
|
|
69
|
+
occurrenceCount: selection.occurrences.length, uniquePayloadCount: selection.payloads.length,
|
|
70
|
+
latestUserSourceEntryId: "recap-u" },
|
|
71
|
+
sessionStatus: "finished", statusReason: "all_requests_accomplished", observedAt, summary,
|
|
72
|
+
lifecycleBoundary: "agent_settled", implementationVersion: "hc-rarebit-summary-v7", synthesisMode: "forced",
|
|
73
|
+
inputCoveragePolicy: { strategy: "newest_suffix_with_explicit_omission", maxPromptChars: 10_000 },
|
|
74
|
+
promptVersion: "rarebit-summary-v8", model, modelProvenance: { source: "test", status: "resolved" },
|
|
75
|
+
inputCoverage: { totalMessageCount: selection.occurrences.length, includedMessageCount: selection.occurrences.length,
|
|
76
|
+
omittedMessageCount: 0, omittedTextChars: 0, promptChars: 500, complete: true },
|
|
77
|
+
synthesis: extractRarebitSynthesisReceipt({}, { requestedModel: model, startedAt: observedAt, completedAt: observedAt, durationMs: 0 }),
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
ctx: {
|
|
81
|
+
mode: "tui",
|
|
82
|
+
hasUI: true,
|
|
83
|
+
ui: { setWidget },
|
|
84
|
+
sessionManager: {
|
|
85
|
+
getHeader: () => ({ id: receipt.sessionId }),
|
|
86
|
+
getSessionFile: () => "/tmp/rarebit-v020-recap-session.jsonl",
|
|
87
|
+
getBranch: () => branch,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
current: {
|
|
91
|
+
receipt,
|
|
92
|
+
artifactState: {
|
|
93
|
+
syncState: "assessment_current",
|
|
94
|
+
applicability: "exact_selection",
|
|
95
|
+
receiptRef: { jobId: receipt.jobId },
|
|
96
|
+
projection: { status: receipt.sessionStatus },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test("the default summary prompt keeps the current style and has no custom identity", () => {
|
|
103
|
+
const selection = selectRarebits(branch());
|
|
104
|
+
const normalized = normalizeRarebitSummaryPrompt();
|
|
105
|
+
const derivation = composeRarebitSummaryDerivationInput(selection);
|
|
106
|
+
|
|
107
|
+
assert.equal(normalized.guidance, DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE);
|
|
108
|
+
assert.equal(normalized.promptIdentity, null);
|
|
109
|
+
assert.equal(derivation.promptIdentity, null);
|
|
110
|
+
assert.match(derivation.prompt, new RegExp(DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE));
|
|
111
|
+
assert.equal(derivation.prompt, composeRarebitSummaryPrompt(selection));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a custom summary_prompt changes prose and job identity", () => {
|
|
115
|
+
const selection = selectRarebits(branch());
|
|
116
|
+
const summaryPrompt = "Use short factual bullets and state uncertainty.";
|
|
117
|
+
const normalized = normalizeRarebitSummaryPrompt(summaryPrompt);
|
|
118
|
+
const custom = composeRarebitSummaryDerivationInput(selection, { summaryPrompt });
|
|
119
|
+
const defaultIdentity = rarebitJobIdentity({
|
|
120
|
+
operation: "summary",
|
|
121
|
+
sessionId: "prompt-contract",
|
|
122
|
+
branch: { leafId: "a1", entryCount: 2, pathHash: "path" },
|
|
123
|
+
selection,
|
|
124
|
+
promptVersion: RAREBIT_SUMMARY_PROMPT_VERSION,
|
|
125
|
+
promptIdentity: null,
|
|
126
|
+
});
|
|
127
|
+
const customIdentity = rarebitJobIdentity({
|
|
128
|
+
operation: "summary",
|
|
129
|
+
sessionId: "prompt-contract",
|
|
130
|
+
branch: { leafId: "a1", entryCount: 2, pathHash: "path" },
|
|
131
|
+
selection,
|
|
132
|
+
promptVersion: RAREBIT_SUMMARY_PROMPT_VERSION,
|
|
133
|
+
promptIdentity: custom.promptIdentity,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
assert.equal(normalized.promptIdentity, custom.promptIdentity);
|
|
137
|
+
assert.ok(custom.promptIdentity);
|
|
138
|
+
assert.match(custom.prompt, /Use short factual bullets and state uncertainty/);
|
|
139
|
+
assert.notEqual(custom.prompt, composeRarebitSummaryPrompt(selection));
|
|
140
|
+
assert.notEqual(customIdentity, defaultIdentity);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("summary_prompt accepts only a nonblank scalar string", () => {
|
|
144
|
+
assert.throws(() => normalizeRarebitSummaryPrompt({ guidance: "custom" }), /summary_prompt/);
|
|
145
|
+
assert.throws(() => normalizeRarebitSummaryPrompt(" "), /summary_prompt/);
|
|
146
|
+
assert.throws(() => normalizeRarebitSummaryPrompt(42), /summary_prompt/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("settings resolve and round-trip the scalar summary_prompt without changing other namespaces", async () => {
|
|
150
|
+
const f = await fixture();
|
|
151
|
+
await writeFile(f.root + "/agent/settings.json", JSON.stringify({
|
|
152
|
+
theme: "dark",
|
|
153
|
+
other_extension: { keep: true },
|
|
154
|
+
rarebit: { model: "fixture/summary", summary_prompt: "Use an incident timeline." },
|
|
155
|
+
}, null, 2));
|
|
156
|
+
const resolved = resolveRarebitSettings({
|
|
157
|
+
rarebit: { model: "fixture/summary", summary_prompt: "Use an incident timeline." },
|
|
158
|
+
});
|
|
159
|
+
assert.equal(resolved.summaryPrompt, "Use an incident timeline.");
|
|
160
|
+
assert.equal(
|
|
161
|
+
RAREBIT_SETTINGS_FIELDS.some((field) => field.path?.join(".") === "summary_prompt"),
|
|
162
|
+
true,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const document = await readRarebitSettingsDocument({ agentDir: f.root + "/agent" });
|
|
166
|
+
await saveRarebitSettingsDocument(document, {
|
|
167
|
+
...document.namespace,
|
|
168
|
+
summary_prompt: "Use an incident timeline with explicit uncertainty.",
|
|
169
|
+
});
|
|
170
|
+
const saved = JSON.parse(await readFile(f.root + "/agent/settings.json", "utf8"));
|
|
171
|
+
assert.equal(saved.theme, "dark");
|
|
172
|
+
assert.deepEqual(saved.other_extension, { keep: true });
|
|
173
|
+
assert.equal(saved.rarebit.summary_prompt, "Use an incident timeline with explicit uncertainty.");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("invalid summary_prompt input fails before the provider or durable receipt", async () => {
|
|
177
|
+
const f = await fixture();
|
|
178
|
+
const calls = [];
|
|
179
|
+
const ctx = {
|
|
180
|
+
sessionManager: {
|
|
181
|
+
getHeader: () => ({ id: "prompt-contract" }),
|
|
182
|
+
getSessionFile: () => f.sessionFile,
|
|
183
|
+
getBranch: branch,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
await assert.rejects(
|
|
188
|
+
processRarebitSummary(ctx, {
|
|
189
|
+
sessionRoot: f.sessionRoot,
|
|
190
|
+
rarebitRoot: f.rarebitRoot,
|
|
191
|
+
forceSynthesis: true,
|
|
192
|
+
model: { provider: "fixture", id: "summary" },
|
|
193
|
+
summaryPrompt: { guidance: " " },
|
|
194
|
+
modelClient: { complete: async (request) => { calls.push(request); return "{}"; } },
|
|
195
|
+
}),
|
|
196
|
+
/summary_prompt/,
|
|
197
|
+
);
|
|
198
|
+
assert.equal(calls.length, 0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("custom summary_prompt reaches the provider and prevents reuse of the default receipt", async () => {
|
|
202
|
+
const f = await fixture();
|
|
203
|
+
const requests = [];
|
|
204
|
+
const ctx = {
|
|
205
|
+
sessionManager: {
|
|
206
|
+
getHeader: () => ({ id: "prompt-contract" }),
|
|
207
|
+
getSessionFile: () => f.sessionFile,
|
|
208
|
+
getBranch: branch,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
const common = {
|
|
212
|
+
sessionRoot: f.sessionRoot,
|
|
213
|
+
rarebitRoot: f.rarebitRoot,
|
|
214
|
+
forceSynthesis: true,
|
|
215
|
+
model: { provider: "fixture", id: "summary" },
|
|
216
|
+
modelClient: {
|
|
217
|
+
complete: async (request) => {
|
|
218
|
+
requests.push(request);
|
|
219
|
+
return {
|
|
220
|
+
text: JSON.stringify({
|
|
221
|
+
summary: "The deployment issue is recorded.",
|
|
222
|
+
sessionStatus: "finished",
|
|
223
|
+
statusReason: "all_requests_accomplished",
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
const first = await processRarebitSummary(ctx, common);
|
|
230
|
+
const second = await processRarebitSummary(ctx, {
|
|
231
|
+
...common,
|
|
232
|
+
summaryPrompt: "Use a terse incident report with explicit uncertainty.",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
assert.equal(first.record.status, "ok");
|
|
236
|
+
assert.equal(second.record.status, "ok");
|
|
237
|
+
assert.equal(requests.length, 2);
|
|
238
|
+
assert.doesNotMatch(requests[0].prompt, /terse incident report/);
|
|
239
|
+
assert.match(requests[1].prompt, /terse incident report/);
|
|
240
|
+
assert.notEqual(first.record.jobId, second.record.jobId);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("Recap renders the full multiline Summary through a themed human-only widget", async () => {
|
|
244
|
+
const summary = Array.from({ length: 24 }, (_, index) => `line-${index + 1} FULL_RECAP_TAIL_${index + 1}`).join("\n");
|
|
245
|
+
const widgets = [];
|
|
246
|
+
const { ctx, current } = recapContext(summary, (...args) => widgets.push(args));
|
|
247
|
+
let modelCalls = 0;
|
|
248
|
+
let sessionWrites = 0;
|
|
249
|
+
const controller = createRarebitRecapController({
|
|
250
|
+
readCurrent: async () => current,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const shown = await controller.showExisting({
|
|
254
|
+
...ctx,
|
|
255
|
+
modelClient: { complete: async () => { modelCalls += 1; } },
|
|
256
|
+
sessionManager: {
|
|
257
|
+
...ctx.sessionManager,
|
|
258
|
+
appendMessage: () => { sessionWrites += 1; },
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
assert.equal(shown.shown, true);
|
|
262
|
+
assert.equal(widgets[0][0], RAREBIT_RECAP_WIDGET_KEY);
|
|
263
|
+
assert.equal(typeof widgets[0][1], "function");
|
|
264
|
+
|
|
265
|
+
const themed = widgets[0][1]({}, {
|
|
266
|
+
bold: (value) => value,
|
|
267
|
+
fg: (tone, value) => `[${tone}]${value}`,
|
|
268
|
+
});
|
|
269
|
+
const rendered = themed.render(200).join("\n");
|
|
270
|
+
assert.match(rendered, /Recap/);
|
|
271
|
+
assert.match(rendered, /line-1 FULL_RECAP_TAIL_1/);
|
|
272
|
+
assert.match(rendered, /line-24 FULL_RECAP_TAIL_24/);
|
|
273
|
+
assert.doesNotMatch(rendered, /recap expand/i);
|
|
274
|
+
assert.match(rendered, /\[muted\]|\[dim\]/);
|
|
275
|
+
assert.equal(modelCalls, 0);
|
|
276
|
+
assert.equal(sessionWrites, 0);
|
|
277
|
+
|
|
278
|
+
const refreshed = widgets[0][1]({}, {
|
|
279
|
+
bold: (value) => value,
|
|
280
|
+
fg: (tone, value) => `{${tone}}${value}`,
|
|
281
|
+
});
|
|
282
|
+
assert.match(refreshed.render(200).join("\n"), /\{muted\}|\{dim\}/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("Recap drops an async display when the Session changes before the widget factory resolves", async () => {
|
|
286
|
+
const widgets = [];
|
|
287
|
+
const first = recapContext("first Summary", (...args) => widgets.push(args));
|
|
288
|
+
const second = recapContext("second Summary", (...args) => widgets.push(args));
|
|
289
|
+
let releaseRead;
|
|
290
|
+
const readCurrent = new Promise((resolve) => { releaseRead = resolve; });
|
|
291
|
+
const controller = createRarebitRecapController({ readCurrent: async () => readCurrent });
|
|
292
|
+
const pending = controller.showExisting(first.ctx);
|
|
293
|
+
controller.invalidate(second.ctx, 2);
|
|
294
|
+
releaseRead(first.current);
|
|
295
|
+
const result = await pending;
|
|
296
|
+
assert.equal(result.shown, false);
|
|
297
|
+
assert.equal(result.reason, "stale");
|
|
298
|
+
assert.equal(widgets.some((call) => call[0] === RAREBIT_RECAP_WIDGET_KEY && call[1]), false);
|
|
299
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { createRarebitRecapController } from "../src/rarebit-recap.mjs";
|
|
9
|
+
import { recapIsRead, recapReadCheckpoint } from "../src/rarebit-read-checkpoint.mjs";
|
|
10
|
+
import { selectRarebits, composeRarebitSummaryDerivationInput, sha256 } from "../src/rarebit-core.mjs";
|
|
11
|
+
import { extractRarebitSynthesisReceipt } from "../src/rarebit-model.mjs";
|
|
12
|
+
import { processRarebitSummary } from "../src/rarebit-service.mjs";
|
|
13
|
+
|
|
14
|
+
test("read validation uses native-entry chronology when non-Rarebit records separate occurrences", () => {
|
|
15
|
+
const entries = [
|
|
16
|
+
{ id: "u1", type: "message", message: { role: "user", content: "first" } },
|
|
17
|
+
{ id: "tool", type: "message", message: { role: "toolResult", content: "ignored" } },
|
|
18
|
+
{ id: "u2", type: "message", message: { role: "user", content: "second" } },
|
|
19
|
+
];
|
|
20
|
+
const selection = selectRarebits(entries);
|
|
21
|
+
const hash = selectRarebits(entries).manifestHash;
|
|
22
|
+
const branch = [...entries,
|
|
23
|
+
{ id: "recap", type: "custom", customType: "rarebit-recap", data: { version: 1, jobId: "job", coveredEntryId: "u2", selectionHash: hash } },
|
|
24
|
+
{ id: "read", type: "custom", customType: "rarebit-recap-read", data: { version: 1, recapEntryId: "recap", jobId: "job", coveredEntryId: "u2", selectionHash: hash } },
|
|
25
|
+
];
|
|
26
|
+
assert.equal(recapReadCheckpoint(branch, selection).coveredEntryId, "u2");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("forged and out-of-order read markers do not acknowledge a Recap", () => {
|
|
30
|
+
const evidence = { id: "u", type: "message", message: { role: "user", content: "request" } };
|
|
31
|
+
const selection = selectRarebits([evidence]);
|
|
32
|
+
const recap = { id: "recap", type: "custom", customType: "rarebit-recap", data: {
|
|
33
|
+
version: 1, jobId: "job", coveredEntryId: "u", selectionHash: selection.manifestHash,
|
|
34
|
+
} };
|
|
35
|
+
const invalid = [
|
|
36
|
+
{ ...recap, id: "read", customType: "rarebit-recap-read", data: { ...recap.data, recapEntryId: "recap" } },
|
|
37
|
+
evidence,
|
|
38
|
+
recap,
|
|
39
|
+
];
|
|
40
|
+
assert.equal(recapIsRead(invalid, recap), false);
|
|
41
|
+
assert.equal(recapReadCheckpoint(invalid, selection), null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("scrollable Recap persists read coverage across resume without acknowledging later updates", async () => {
|
|
45
|
+
const root = await mkdtemp(join(tmpdir(), "rarebit-read-test-"));
|
|
46
|
+
try {
|
|
47
|
+
const manager = SessionManager.create(root, root);
|
|
48
|
+
manager.appendMessage({ role: "user", content: "Synthetic request: repair the renderer", timestamp: 1 });
|
|
49
|
+
manager.appendMessage({ role: "assistant", content: [{ type: "text", text: "Renderer repaired. Approval still required." }],
|
|
50
|
+
stopReason: "stop", api: "openai-completions", provider: "fixture", model: "fixture", timestamp: 2,
|
|
51
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } });
|
|
52
|
+
const selection = selectRarebits(manager.getBranch());
|
|
53
|
+
const observedAt = "1970-01-01T00:00:00.000Z";
|
|
54
|
+
const latestUser = selection.occurrences.filter((item) => item.role === "user").at(-1);
|
|
55
|
+
const receipt = {
|
|
56
|
+
schemaVersion: 4, type: "rarebit_summary", status: "ok", jobId: sha256("synthetic-recap"),
|
|
57
|
+
sessionId: manager.getHeader().id,
|
|
58
|
+
branch: { leafId: manager.getBranch().at(-1).id, entryCount: manager.getBranch().length, pathHash: sha256(manager.getBranch().map((item) => item.id)) },
|
|
59
|
+
observedAt, selection: { manifestHash: selection.manifestHash, selectorVersion: selection.manifest.selectorVersion,
|
|
60
|
+
occurrenceCount: selection.occurrences.length, uniquePayloadCount: selection.payloads.length, latestUserSourceEntryId: latestUser.sourceEntryId },
|
|
61
|
+
lifecycleBoundary: "agent_settled", implementationVersion: "hc-rarebit-summary-v7", synthesisMode: "forced",
|
|
62
|
+
inputCoveragePolicy: { strategy: "newest_suffix_with_explicit_omission", maxPromptChars: 10_000 },
|
|
63
|
+
promptVersion: "rarebit-summary-v8", model: { provider: "fixture", id: "summary" },
|
|
64
|
+
modelProvenance: { source: "test", status: "resolved" },
|
|
65
|
+
summary: "๐งช็ Renderer repaired; approval needed.", sessionStatus: "needs_attention", statusReason: "approval",
|
|
66
|
+
inputCoverage: { totalMessageCount: selection.occurrences.length, includedMessageCount: selection.occurrences.length,
|
|
67
|
+
omittedMessageCount: 0, omittedTextChars: 0, promptChars: 100, complete: true },
|
|
68
|
+
synthesis: extractRarebitSynthesisReceipt({}, { requestedModel: { provider: "fixture", id: "summary" },
|
|
69
|
+
startedAt: observedAt, completedAt: observedAt, durationMs: 0 }),
|
|
70
|
+
};
|
|
71
|
+
const widgets = [];
|
|
72
|
+
const ctx = { mode: "tui", ui: { setWidget: (...args) => widgets.push(args) }, sessionManager: manager };
|
|
73
|
+
const controller = createRarebitRecapController({
|
|
74
|
+
appendEntry: (type, data) => manager.appendCustomEntry(type, data),
|
|
75
|
+
readCurrent: async () => ({ receipt, artifactState: { syncState: "assessment_current", applicability: "exact_selection",
|
|
76
|
+
receiptRef: { jobId: receipt.jobId }, projection: { status: receipt.sessionStatus } } }),
|
|
77
|
+
});
|
|
78
|
+
assert.equal((await controller.showExisting(ctx)).shown, true);
|
|
79
|
+
assert.ok(!widgets.some(([, content]) => content), "no pinned recap widget");
|
|
80
|
+
const recap = manager.getBranch().at(-1);
|
|
81
|
+
const component = controller.renderEntry(recap, {}, { fg: (_color, text) => text });
|
|
82
|
+
const wideRender = component.render(80);
|
|
83
|
+
assert.match(wideRender.join("\n"), /๐งช็ Renderer repaired; approval needed/);
|
|
84
|
+
assert.match(wideRender.join("\n"), /got it/);
|
|
85
|
+
assert.equal(receipt.summary, "๐งช็ Renderer repaired; approval needed.", "rendering does not trim stored Summary prose");
|
|
86
|
+
for (const width of [0, 1, 2, 4, 8])
|
|
87
|
+
assert.ok(component.render(width).every((line) => visibleWidth(line) <= width), `Recap rows stay within ${width} columns`);
|
|
88
|
+
manager.appendMessage({ role: "user", content: "Synthetic later update: also fix resize", timestamp: 3 });
|
|
89
|
+
controller.updateContext(ctx);
|
|
90
|
+
assert.equal(controller.acknowledge(ctx).acknowledged, true);
|
|
91
|
+
assert.equal(controller.acknowledge(ctx).reason, "already_read");
|
|
92
|
+
assert.match(component.render(80).join("\n"), /โ got it/);
|
|
93
|
+
|
|
94
|
+
const resumed = SessionManager.open(manager.getSessionFile(), root);
|
|
95
|
+
const branch = resumed.getBranch();
|
|
96
|
+
const loadedSelection = selectRarebits(branch);
|
|
97
|
+
const checkpoint = recapReadCheckpoint(branch, loadedSelection);
|
|
98
|
+
assert.equal(checkpoint.coveredEntryId, selection.occurrences.at(-1).sourceEntryId);
|
|
99
|
+
const input = composeRarebitSummaryDerivationInput(loadedSelection, { readCheckpoint: checkpoint });
|
|
100
|
+
const lines = input.prompt.split("BEGIN_RAREBIT_MESSAGES_JSONL\n")[1].split("\nEND_RAREBIT_MESSAGES_JSONL")[0].split("\n").map(JSON.parse);
|
|
101
|
+
assert.equal(lines.findIndex((line) => line.type === "recap_read"), 2);
|
|
102
|
+
assert.match(lines[3].text, /also fix resize/);
|
|
103
|
+
assert.match(input.prompt, /Acknowledgement does not resolve requests/);
|
|
104
|
+
assert.match(input.prompt, /at most 300 characters/);
|
|
105
|
+
assert.equal(loadedSelection.occurrences.length, 3, "UI state stays out of conversation prose");
|
|
106
|
+
|
|
107
|
+
let providerPrompt;
|
|
108
|
+
const result = await processRarebitSummary({ sessionManager: resumed }, {
|
|
109
|
+
sessionRoot: root, rarebitRoot: join(root, "receipts"), forceSynthesis: true,
|
|
110
|
+
model: { provider: "fixture", id: "fixture" },
|
|
111
|
+
modelClient: { complete: async ({ prompt }) => {
|
|
112
|
+
providerPrompt = prompt;
|
|
113
|
+
return JSON.stringify({ summary: "Resize repair newly requested; renderer approval remains open.", sessionStatus: "needs_attention", statusReason: "approval" });
|
|
114
|
+
} },
|
|
115
|
+
});
|
|
116
|
+
assert.equal(result.record.status, "ok");
|
|
117
|
+
assert.match(providerPrompt, /"type":"recap_read"/);
|
|
118
|
+
assert.ok(result.record.summary.length <= 300);
|
|
119
|
+
|
|
120
|
+
// Navigating away from the read marker and into a sibling branch must not
|
|
121
|
+
// carry the checkpoint from a different conversation path.
|
|
122
|
+
resumed.branch(selection.occurrences.at(-1).sourceEntryId);
|
|
123
|
+
resumed.appendMessage({ role: "user", content: "Synthetic sibling update", timestamp: 4 });
|
|
124
|
+
assert.equal(recapReadCheckpoint(resumed.getBranch(), selectRarebits(resumed.getBranch())), null);
|
|
125
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
126
|
+
});
|
|
@@ -108,7 +108,7 @@ test("v4 synthesis validates legal status/reason combinations without rigid sect
|
|
|
108
108
|
}),
|
|
109
109
|
),
|
|
110
110
|
{
|
|
111
|
-
summary: "Any free-form prose
|
|
111
|
+
summary: "Any free-form prose\nwith controls removed",
|
|
112
112
|
sessionStatus: "needs_attention",
|
|
113
113
|
statusReason: "unfinished",
|
|
114
114
|
},
|