@hypercarrier/rarebit 0.1.0-alpha.6 → 0.2.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 +13 -3
- package/README.md +72 -19
- package/RUNBOOK.md +16 -9
- package/docs/examples/rarebit.settings.json +19 -0
- package/package.json +9 -2
- package/scripts/e2e-recap-pi.mjs +456 -0
- package/scripts/e2e-settings-pi.mjs +267 -0
- package/scripts/e2e-v020-pi.mjs +269 -0
- package/scripts/verify-package.mjs +14 -8
- package/src/extension.mjs +183 -13
- package/src/lifecycle.mjs +32 -7
- package/src/rarebit-cli.mjs +2 -0
- package/src/rarebit-command.mjs +21 -1
- package/src/rarebit-core.mjs +87 -9
- package/src/rarebit-recap.mjs +422 -0
- package/src/rarebit-service.mjs +60 -7
- package/src/rarebit-settings-ui.mjs +479 -0
- package/src/rarebit-settings.mjs +90 -7
- package/src/types.d.ts +60 -3
- package/test/lifecycle-contract-independent.test.mjs +2 -0
- package/test/package-artifact-identity.test.mjs +54 -0
- package/test/rarebit-command.test.mjs +8 -4
- package/test/rarebit-recap.test.mjs +513 -0
- package/test/rarebit-settings-ui.test.mjs +388 -0
- package/test/rarebit-v020.test.mjs +294 -0
- package/test/summary-attention-independent.test.mjs +1 -1
|
@@ -0,0 +1,388 @@
|
|
|
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_MAX_INPUT_TOKENS,
|
|
9
|
+
DEFAULT_RAREBIT_RECAP_TIMEZONE,
|
|
10
|
+
normalizeRarebitRecapTimezone,
|
|
11
|
+
resolveRarebitSettings,
|
|
12
|
+
} from "../src/rarebit-settings.mjs";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_RAREBIT_MAX_PROMPT_CHARS,
|
|
15
|
+
processRarebitSummary,
|
|
16
|
+
} from "../src/rarebit-service.mjs";
|
|
17
|
+
import { createRarebitRecapController } from "../src/rarebit-recap.mjs";
|
|
18
|
+
import { selectRarebits } from "../src/rarebit-core.mjs";
|
|
19
|
+
import registerPiRarebit, { readConfiguredRarebitSettings } from "../src/extension.mjs";
|
|
20
|
+
import {
|
|
21
|
+
RAREBIT_SETTINGS_TABS,
|
|
22
|
+
openRarebitPalette,
|
|
23
|
+
openRarebitSettings,
|
|
24
|
+
readRarebitSettingsDocument,
|
|
25
|
+
saveRarebitSettingsDocument,
|
|
26
|
+
} from "../src/rarebit-settings-ui.mjs";
|
|
27
|
+
import { parseRarebitCommand } from "../src/rarebit-command.mjs";
|
|
28
|
+
|
|
29
|
+
async function fixture(initial = {}) {
|
|
30
|
+
const root = await mkdtemp(join(tmpdir(), "rarebit-settings-ui-"));
|
|
31
|
+
const cwd = join(root, "project");
|
|
32
|
+
const agentDir = join(root, "agent");
|
|
33
|
+
const settingsFile = join(agentDir, "settings.json");
|
|
34
|
+
const projectSettings = join(cwd, ".pi", "settings.json");
|
|
35
|
+
await mkdir(agentDir, { recursive: true });
|
|
36
|
+
await mkdir(join(cwd, ".pi"), { recursive: true });
|
|
37
|
+
await writeFile(settingsFile, `${JSON.stringify(initial, null, 2)}\n`);
|
|
38
|
+
return { root, cwd, agentDir, settingsFile, projectSettings };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function theme() {
|
|
42
|
+
return {
|
|
43
|
+
bold: (value) => value,
|
|
44
|
+
fg: (_role, value) => value,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function context(box, ui, overrides = {}) {
|
|
49
|
+
return {
|
|
50
|
+
mode: "tui",
|
|
51
|
+
hasUI: true,
|
|
52
|
+
cwd: box.cwd,
|
|
53
|
+
isProjectTrusted: () => true,
|
|
54
|
+
ui,
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("resolved settings keep diagnostics quiet by default and retain global values through project partial overrides", () => {
|
|
60
|
+
const resolved = resolveRarebitSettings(
|
|
61
|
+
{
|
|
62
|
+
rarebit: {
|
|
63
|
+
model: "fixture/summary",
|
|
64
|
+
diagnostics: { summary_triggered: true, summary_updated: false },
|
|
65
|
+
max_input_tokens: 64_000,
|
|
66
|
+
recap: { enabled: false, delay_ms: 10_000, timezone: "Asia/Hong_Kong" },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{ rarebit: { recap: { delay_ms: 2_000 } } },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
assert.equal(resolved.diagnostics.summaryTriggered, true);
|
|
73
|
+
assert.equal(resolved.diagnostics.summaryUpdated, false);
|
|
74
|
+
assert.equal(resolved.recap.enabled, false);
|
|
75
|
+
assert.equal(resolved.recap.delayMs, 2_000);
|
|
76
|
+
assert.equal(resolved.recap.timezone, "Asia/Hong_Kong");
|
|
77
|
+
assert.equal(resolved.maxInputTokens, DEFAULT_RAREBIT_MAX_INPUT_TOKENS);
|
|
78
|
+
|
|
79
|
+
const defaults = resolveRarebitSettings({ rarebit: { model: "fixture/summary" } });
|
|
80
|
+
assert.deepEqual(defaults.diagnostics, { summaryTriggered: false, summaryUpdated: false });
|
|
81
|
+
assert.equal(defaults.recap.timezone, DEFAULT_RAREBIT_RECAP_TIMEZONE);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the real settings loader preserves a global disabled Recap through a partial trusted-project override", async () => {
|
|
85
|
+
const box = await fixture({
|
|
86
|
+
rarebit: {
|
|
87
|
+
model: "fixture/summary",
|
|
88
|
+
diagnostics: { summary_triggered: false, summary_updated: false },
|
|
89
|
+
max_input_tokens: 64_000,
|
|
90
|
+
recap: { enabled: false, delay_ms: 60_000, timezone: "Asia/Hong_Kong" },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
await writeFile(box.projectSettings, JSON.stringify({
|
|
94
|
+
other_extension: { preserve: true },
|
|
95
|
+
rarebit: { recap: { delay_ms: 2_000 } },
|
|
96
|
+
}, null, 2));
|
|
97
|
+
|
|
98
|
+
const effective = await readConfiguredRarebitSettings({
|
|
99
|
+
cwd: box.cwd,
|
|
100
|
+
agentDir: box.agentDir,
|
|
101
|
+
projectTrusted: true,
|
|
102
|
+
model: { provider: "fixture", id: "summary" },
|
|
103
|
+
});
|
|
104
|
+
assert.equal(effective.maxInputTokens, 64_000);
|
|
105
|
+
assert.deepEqual(effective.diagnostics, { summaryTriggered: false, summaryUpdated: false });
|
|
106
|
+
assert.equal(effective.recap.enabled, false);
|
|
107
|
+
assert.equal(effective.recap.delayMs, 2_000);
|
|
108
|
+
assert.equal(effective.recap.timezone, "Asia/Hong_Kong");
|
|
109
|
+
|
|
110
|
+
const projectDocument = await readRarebitSettingsDocument({
|
|
111
|
+
cwd: box.cwd,
|
|
112
|
+
agentDir: box.agentDir,
|
|
113
|
+
scope: "project",
|
|
114
|
+
projectTrusted: true,
|
|
115
|
+
});
|
|
116
|
+
await saveRarebitSettingsDocument(projectDocument, {
|
|
117
|
+
...projectDocument.namespace,
|
|
118
|
+
recap: { ...projectDocument.namespace.recap, timezone: "UTC" },
|
|
119
|
+
});
|
|
120
|
+
const global = JSON.parse(await readFile(box.settingsFile, "utf8"));
|
|
121
|
+
const project = JSON.parse(await readFile(box.projectSettings, "utf8"));
|
|
122
|
+
assert.equal(global.rarebit.recap.enabled, false);
|
|
123
|
+
assert.equal(global.rarebit.recap.timezone, "Asia/Hong_Kong");
|
|
124
|
+
assert.deepEqual(project.other_extension, { preserve: true });
|
|
125
|
+
assert.equal(project.rarebit.recap.delay_ms, 2_000);
|
|
126
|
+
assert.equal(project.rarebit.recap.timezone, "UTC");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("recap timezone accepts an IANA zone and falls back to host for invalid input", () => {
|
|
130
|
+
assert.equal(normalizeRarebitRecapTimezone("Asia/Hong_Kong"), "Asia/Hong_Kong");
|
|
131
|
+
assert.equal(normalizeRarebitRecapTimezone("not/a-timezone"), DEFAULT_RAREBIT_RECAP_TIMEZONE);
|
|
132
|
+
assert.equal(normalizeRarebitRecapTimezone(""), DEFAULT_RAREBIT_RECAP_TIMEZONE);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("summary service maps the configurable input-token limit to the persisted character cap", async () => {
|
|
136
|
+
const branch = [
|
|
137
|
+
{ id: "u1", type: "message", message: { role: "user", content: [{ type: "text", text: "A request" }] } },
|
|
138
|
+
{ id: "a1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "A response" }] } },
|
|
139
|
+
];
|
|
140
|
+
const result = await processRarebitSummary(
|
|
141
|
+
{ sessionManager: { getBranch: () => branch } },
|
|
142
|
+
{ branch, maxInputTokens: 32_000, forceSynthesis: true },
|
|
143
|
+
);
|
|
144
|
+
assert.equal(result.record.inputCoveragePolicy.maxPromptChars, 128_000);
|
|
145
|
+
assert.equal(DEFAULT_RAREBIT_MAX_PROMPT_CHARS, DEFAULT_RAREBIT_MAX_INPUT_TOKENS * 4);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("recap renders the human label and converts the receipt timestamp to the configured timezone", async () => {
|
|
149
|
+
const branch = [
|
|
150
|
+
{ id: "u1", type: "message", message: { role: "user", content: [{ type: "text", text: "A request" }] } },
|
|
151
|
+
{ id: "a1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "A response" }] } },
|
|
152
|
+
];
|
|
153
|
+
const selection = selectRarebits(branch);
|
|
154
|
+
const widgets = [];
|
|
155
|
+
const context = {
|
|
156
|
+
mode: "tui",
|
|
157
|
+
hasUI: true,
|
|
158
|
+
sessionManager: {
|
|
159
|
+
getHeader: () => ({ id: "session-1" }),
|
|
160
|
+
getSessionFile: () => "/tmp/rarebit-settings-ui-session.jsonl",
|
|
161
|
+
getBranch: () => branch,
|
|
162
|
+
},
|
|
163
|
+
ui: { setWidget: (_key, lines) => widgets.push(lines) },
|
|
164
|
+
};
|
|
165
|
+
const controller = createRarebitRecapController({
|
|
166
|
+
timezone: "Asia/Hong_Kong",
|
|
167
|
+
readCurrent: async () => ({
|
|
168
|
+
receipt: {
|
|
169
|
+
status: "ok",
|
|
170
|
+
jobId: "job-1",
|
|
171
|
+
sessionId: "session-1",
|
|
172
|
+
observedAt: "2026-01-01T00:00:00.000Z",
|
|
173
|
+
sessionStatus: "needs_attention",
|
|
174
|
+
summary: "A current summary.",
|
|
175
|
+
selection: {
|
|
176
|
+
manifestHash: selection.manifestHash,
|
|
177
|
+
selectorVersion: selection.manifest.selectorVersion,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
artifactState: {
|
|
181
|
+
syncState: "assessment_current",
|
|
182
|
+
applicability: "exact_selection",
|
|
183
|
+
receiptRef: { jobId: "job-1" },
|
|
184
|
+
projection: { status: "needs_attention" },
|
|
185
|
+
},
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
const result = await controller.showExisting(context);
|
|
189
|
+
assert.equal(result.shown, true);
|
|
190
|
+
const heading = widgets[0]({ requestRender() {} }, { fg: (_name, text) => text }).render(200).find((line) => line.includes("Recap ·")).trim();
|
|
191
|
+
assert.match(heading, /^(?:◆! )?Recap · needs you · as of /);
|
|
192
|
+
assert.match(heading, /Asia\/Hong_Kong/);
|
|
193
|
+
assert.doesNotMatch(heading, /Rarebit Summary/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("quiet Summary diagnostics still report materialization failures", async () => {
|
|
197
|
+
const root = await mkdtemp(join(tmpdir(), "rarebit-settings-failure-"));
|
|
198
|
+
const handlers = new Map();
|
|
199
|
+
const commands = new Map();
|
|
200
|
+
const notices = [];
|
|
201
|
+
registerPiRarebit(
|
|
202
|
+
{
|
|
203
|
+
on: (event, handler) => handlers.set(event, handler),
|
|
204
|
+
registerCommand: (name, command) => commands.set(name, command),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
model: { provider: "fixture", id: "summary" },
|
|
208
|
+
summaryPolicy: { minTotalLength: 0, maxRarebitRatio: 1 },
|
|
209
|
+
sessionRoot: root,
|
|
210
|
+
rarebitRoot: join(root, "rarebit"),
|
|
211
|
+
queryAutomaticSummaryPolicy: async () => ({ decision: "abstain", queryStatus: "test" }),
|
|
212
|
+
modelClient: { complete: async () => ({ text: "not valid JSON" }) },
|
|
213
|
+
diagnostics: { summaryTriggered: false, summaryUpdated: false },
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
const branch = [
|
|
217
|
+
{ id: "u1", type: "message", message: { role: "user", content: [{ type: "text", text: "A request" }] } },
|
|
218
|
+
{ id: "a1", type: "message", message: { role: "assistant", content: [{ type: "text", text: "A response" }] } },
|
|
219
|
+
];
|
|
220
|
+
const context = {
|
|
221
|
+
mode: "tui",
|
|
222
|
+
cwd: root,
|
|
223
|
+
hasUI: true,
|
|
224
|
+
isProjectTrusted: () => true,
|
|
225
|
+
sessionManager: {
|
|
226
|
+
getHeader: () => ({ id: "session-failure" }),
|
|
227
|
+
getSessionFile: () => join(root, "session.jsonl"),
|
|
228
|
+
getBranch: () => branch,
|
|
229
|
+
},
|
|
230
|
+
ui: { notify: (text, level) => notices.push({ text, level }) },
|
|
231
|
+
};
|
|
232
|
+
handlers.get("session_start")({}, context);
|
|
233
|
+
await commands.get("rarebit").handler("summarize", context);
|
|
234
|
+
const deadline = Date.now() + 2_000;
|
|
235
|
+
while (Date.now() < deadline && !notices.some(({ text }) => /Summary failed/.test(text)))
|
|
236
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
237
|
+
assert.ok(notices.some(({ text, level }) => level === "error" && /Summary failed/.test(text)));
|
|
238
|
+
assert.equal(notices.some(({ text }) => /Summary triggered/.test(text)), false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("settings document round-trip preserves Pi and other extension namespaces", async () => {
|
|
242
|
+
const box = await fixture({
|
|
243
|
+
theme: "dark",
|
|
244
|
+
another_extension: { enabled: true },
|
|
245
|
+
rarebit: {
|
|
246
|
+
model: "fixture/summary",
|
|
247
|
+
recap: { enabled: false, delay_ms: 60_000, timezone: "UTC" },
|
|
248
|
+
max_input_tokens: 64_000,
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const document = await readRarebitSettingsDocument({ cwd: box.cwd, agentDir: box.agentDir, scope: "global" });
|
|
252
|
+
const saved = await saveRarebitSettingsDocument(document, {
|
|
253
|
+
...document.namespace,
|
|
254
|
+
max_input_tokens: 32_000,
|
|
255
|
+
recap: { ...document.namespace.recap, timezone: "Asia/Hong_Kong" },
|
|
256
|
+
});
|
|
257
|
+
const parsed = JSON.parse(await readFile(box.settingsFile, "utf8"));
|
|
258
|
+
assert.equal(parsed.theme, "dark");
|
|
259
|
+
assert.deepEqual(parsed.another_extension, { enabled: true });
|
|
260
|
+
assert.equal(parsed.rarebit.max_input_tokens, 32_000);
|
|
261
|
+
assert.equal(parsed.rarebit.recap.timezone, "Asia/Hong_Kong");
|
|
262
|
+
assert.notEqual(saved.revision, document.revision);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("settings cancel and invalid input leave the file unchanged and do not enter the Session", async () => {
|
|
266
|
+
const box = await fixture({
|
|
267
|
+
unrelated: { keep: "yes" },
|
|
268
|
+
rarebit: { model: "fixture/summary", max_input_tokens: 64_000 },
|
|
269
|
+
});
|
|
270
|
+
const before = await readFile(box.settingsFile, "utf8");
|
|
271
|
+
let customCalls = 0;
|
|
272
|
+
let modelCalls = 0;
|
|
273
|
+
let sessionWrites = 0;
|
|
274
|
+
const notifications = [];
|
|
275
|
+
const ui = {
|
|
276
|
+
custom: async (factory) => {
|
|
277
|
+
customCalls += 1;
|
|
278
|
+
if (customCalls > 2) return undefined;
|
|
279
|
+
let selected;
|
|
280
|
+
const component = factory({ requestRender() {} }, theme(), {}, (value) => { selected = value; });
|
|
281
|
+
assert.match(component.render(120).join("\n"), /Actions.*Summary.*Recap.*Session/);
|
|
282
|
+
if (customCalls === 1) {
|
|
283
|
+
component.handleInput("\t");
|
|
284
|
+
component.handleInput("\x1b[B");
|
|
285
|
+
component.handleInput("\x1b[B");
|
|
286
|
+
component.handleInput("\r");
|
|
287
|
+
return selected;
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
},
|
|
291
|
+
input: async () => "not/a-timezone",
|
|
292
|
+
select: async () => undefined,
|
|
293
|
+
confirm: async () => false,
|
|
294
|
+
notify: (message, level) => notifications.push({ message, level }),
|
|
295
|
+
};
|
|
296
|
+
await openRarebitSettings(context(box, ui, {
|
|
297
|
+
sendUserMessage: () => { modelCalls += 1; },
|
|
298
|
+
sessionManager: { appendMessage: () => { sessionWrites += 1; } },
|
|
299
|
+
}), { agentDir: box.agentDir, initialScope: "global" });
|
|
300
|
+
assert.equal(await readFile(box.settingsFile, "utf8"), before);
|
|
301
|
+
assert.equal(modelCalls, 0);
|
|
302
|
+
assert.equal(sessionWrites, 0);
|
|
303
|
+
assert.equal(notifications.length, 1);
|
|
304
|
+
assert.equal(notifications[0].level, "error");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("settings confirmation cancel leaves the source file unchanged", async () => {
|
|
308
|
+
const box = await fixture({ rarebit: { model: "fixture/summary" } });
|
|
309
|
+
const before = await readFile(box.settingsFile, "utf8");
|
|
310
|
+
let customCalls = 0;
|
|
311
|
+
const ui = {
|
|
312
|
+
custom: async (factory) => {
|
|
313
|
+
customCalls += 1;
|
|
314
|
+
if (customCalls > 2) return undefined;
|
|
315
|
+
let selected;
|
|
316
|
+
const component = factory({ requestRender() {} }, theme(), {}, (value) => { selected = value; });
|
|
317
|
+
if (customCalls === 1) component.handleInput("\r");
|
|
318
|
+
return selected;
|
|
319
|
+
},
|
|
320
|
+
select: async () => "on",
|
|
321
|
+
confirm: async () => false,
|
|
322
|
+
notify: () => {},
|
|
323
|
+
};
|
|
324
|
+
await openRarebitSettings(context(box, ui), { agentDir: box.agentDir });
|
|
325
|
+
assert.equal(await readFile(box.settingsFile, "utf8"), before);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("settings UI exposes the tabbed Summary and Recap controls and saves a confirmed edit", async () => {
|
|
329
|
+
const box = await fixture({ rarebit: { model: "fixture/summary" } });
|
|
330
|
+
const rendered = [];
|
|
331
|
+
let customCalls = 0;
|
|
332
|
+
const ui = {
|
|
333
|
+
custom: async (factory) => {
|
|
334
|
+
customCalls += 1;
|
|
335
|
+
if (customCalls > 2) return undefined;
|
|
336
|
+
let selected;
|
|
337
|
+
const component = factory({ requestRender() {} }, theme(), {}, (value) => { selected = value; });
|
|
338
|
+
rendered.push(component.render(120).join("\n"));
|
|
339
|
+
if (customCalls === 1) {
|
|
340
|
+
component.handleInput("\r");
|
|
341
|
+
}
|
|
342
|
+
return selected;
|
|
343
|
+
},
|
|
344
|
+
select: async () => "on",
|
|
345
|
+
confirm: async () => true,
|
|
346
|
+
notify: () => {},
|
|
347
|
+
};
|
|
348
|
+
await openRarebitSettings(context(box, ui), { agentDir: box.agentDir });
|
|
349
|
+
assert.deepEqual(RAREBIT_SETTINGS_TABS, ["Actions", "Summary", "Recap", "Session"]);
|
|
350
|
+
assert.match(rendered[0], /Rarebit Settings/);
|
|
351
|
+
assert.match(rendered[0], /Show Summary triggered/);
|
|
352
|
+
const parsed = JSON.parse(await readFile(box.settingsFile, "utf8"));
|
|
353
|
+
assert.equal(parsed.rarebit?.diagnostics?.summary_triggered, true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("the Rarebit palette is a human-only TUI surface and includes settings and Recap", async () => {
|
|
357
|
+
const box = await fixture({});
|
|
358
|
+
let modelCalls = 0;
|
|
359
|
+
let sessionWrites = 0;
|
|
360
|
+
let rendered = "";
|
|
361
|
+
const ui = {
|
|
362
|
+
custom: async (factory) => {
|
|
363
|
+
let selected;
|
|
364
|
+
const component = factory({ requestRender() {} }, theme(), {}, (value) => { selected = value; });
|
|
365
|
+
rendered = component.render(120).join("\n");
|
|
366
|
+
component.handleInput("\x1b");
|
|
367
|
+
return selected;
|
|
368
|
+
},
|
|
369
|
+
notify: () => {},
|
|
370
|
+
};
|
|
371
|
+
const selection = await openRarebitPalette(context(box, ui, {
|
|
372
|
+
sendUserMessage: () => { modelCalls += 1; },
|
|
373
|
+
sessionManager: { appendMessage: () => { sessionWrites += 1; } },
|
|
374
|
+
}));
|
|
375
|
+
assert.equal(selection, undefined);
|
|
376
|
+
assert.match(rendered, /Rarebit/);
|
|
377
|
+
assert.match(rendered, /Summary/);
|
|
378
|
+
assert.match(rendered, /Recap/);
|
|
379
|
+
assert.match(rendered, /Settings/);
|
|
380
|
+
assert.equal(modelCalls, 0);
|
|
381
|
+
assert.equal(sessionWrites, 0);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("/rarebit keeps menu and settings under one command surface", () => {
|
|
385
|
+
assert.deepEqual(parseRarebitCommand(""), { ok: true, subcommand: "menu", arguments: [] });
|
|
386
|
+
assert.deepEqual(parseRarebitCommand("settings"), { ok: true, subcommand: "settings", arguments: [] });
|
|
387
|
+
assert.deepEqual(parseRarebitCommand("settings project"), { ok: true, subcommand: "settings", arguments: ["project"] });
|
|
388
|
+
});
|
|
@@ -0,0 +1,294 @@
|
|
|
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
|
+
} from "../src/rarebit-core.mjs";
|
|
16
|
+
import { processRarebitSummary } from "../src/rarebit-service.mjs";
|
|
17
|
+
import { resolveRarebitSettings } from "../src/rarebit-settings.mjs";
|
|
18
|
+
import {
|
|
19
|
+
RAREBIT_SETTINGS_FIELDS,
|
|
20
|
+
readRarebitSettingsDocument,
|
|
21
|
+
saveRarebitSettingsDocument,
|
|
22
|
+
} from "../src/rarebit-settings-ui.mjs";
|
|
23
|
+
import {
|
|
24
|
+
createRarebitRecapController,
|
|
25
|
+
RAREBIT_RECAP_WIDGET_KEY,
|
|
26
|
+
} from "../src/rarebit-recap.mjs";
|
|
27
|
+
|
|
28
|
+
const entry = (id, message) => ({
|
|
29
|
+
type: "message",
|
|
30
|
+
id,
|
|
31
|
+
timestamp: "2026-09-27T00:00:00.000Z",
|
|
32
|
+
message,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function branch() {
|
|
36
|
+
return [
|
|
37
|
+
entry("u1", { role: "user", content: "Investigate the deployment issue." }),
|
|
38
|
+
entry("a1", { role: "assistant", stopReason: "stop", content: "I found the failing check." }),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function fixture() {
|
|
43
|
+
const root = await mkdtemp(join(tmpdir(), "rarebit-v020-prompt-"));
|
|
44
|
+
await mkdir(join(root, "agent"), { recursive: true });
|
|
45
|
+
await writeFile(join(root, "agent", "settings.json"), "{}\n");
|
|
46
|
+
const sessionRoot = join(root, "sessions");
|
|
47
|
+
const rarebitRoot = join(root, "rarebit");
|
|
48
|
+
await mkdir(sessionRoot, { recursive: true });
|
|
49
|
+
const sessionFile = join(sessionRoot, "trace.jsonl");
|
|
50
|
+
await writeFile(sessionFile, '{"type":"session","id":"prompt-contract"}\n');
|
|
51
|
+
return { root, sessionRoot, rarebitRoot, sessionFile };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function recapContext(summary, setWidget) {
|
|
55
|
+
const branch = [
|
|
56
|
+
entry("recap-u", { role: "user", content: "Read the current deployment state." }),
|
|
57
|
+
entry("recap-a", { role: "assistant", stopReason: "stop", content: "The deployment is complete." }),
|
|
58
|
+
];
|
|
59
|
+
const selection = selectRarebits(branch);
|
|
60
|
+
const receipt = {
|
|
61
|
+
type: "rarebit_summary",
|
|
62
|
+
status: "ok",
|
|
63
|
+
jobId: "recap-v020-job",
|
|
64
|
+
sessionId: "recap-v020-session",
|
|
65
|
+
branch: { leafId: "recap-a", pathHash: "recap-v020-path" },
|
|
66
|
+
selection: {
|
|
67
|
+
manifestHash: selection.manifestHash,
|
|
68
|
+
selectorVersion: selection.manifest.selectorVersion,
|
|
69
|
+
},
|
|
70
|
+
sessionStatus: "finished",
|
|
71
|
+
observedAt: "2026-09-27T00:00:00.000Z",
|
|
72
|
+
summary,
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
ctx: {
|
|
76
|
+
mode: "tui",
|
|
77
|
+
hasUI: true,
|
|
78
|
+
ui: { setWidget },
|
|
79
|
+
sessionManager: {
|
|
80
|
+
getHeader: () => ({ id: receipt.sessionId }),
|
|
81
|
+
getSessionFile: () => "/tmp/rarebit-v020-recap-session.jsonl",
|
|
82
|
+
getBranch: () => branch,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
current: {
|
|
86
|
+
receipt,
|
|
87
|
+
artifactState: {
|
|
88
|
+
syncState: "assessment_current",
|
|
89
|
+
applicability: "exact_selection",
|
|
90
|
+
receiptRef: { jobId: receipt.jobId },
|
|
91
|
+
projection: { status: receipt.sessionStatus },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
test("the default summary prompt keeps the current style and has no custom identity", () => {
|
|
98
|
+
const selection = selectRarebits(branch());
|
|
99
|
+
const normalized = normalizeRarebitSummaryPrompt();
|
|
100
|
+
const derivation = composeRarebitSummaryDerivationInput(selection);
|
|
101
|
+
|
|
102
|
+
assert.equal(normalized.guidance, DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE);
|
|
103
|
+
assert.equal(normalized.promptIdentity, null);
|
|
104
|
+
assert.equal(derivation.promptIdentity, null);
|
|
105
|
+
assert.match(derivation.prompt, new RegExp(DEFAULT_RAREBIT_SUMMARY_PROMPT_GUIDANCE));
|
|
106
|
+
assert.equal(derivation.prompt, composeRarebitSummaryPrompt(selection));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a custom summary_prompt changes prose and job identity", () => {
|
|
110
|
+
const selection = selectRarebits(branch());
|
|
111
|
+
const summaryPrompt = "Use short factual bullets and state uncertainty.";
|
|
112
|
+
const normalized = normalizeRarebitSummaryPrompt(summaryPrompt);
|
|
113
|
+
const custom = composeRarebitSummaryDerivationInput(selection, { summaryPrompt });
|
|
114
|
+
const defaultIdentity = rarebitJobIdentity({
|
|
115
|
+
operation: "summary",
|
|
116
|
+
sessionId: "prompt-contract",
|
|
117
|
+
branch: { leafId: "a1", entryCount: 2, pathHash: "path" },
|
|
118
|
+
selection,
|
|
119
|
+
promptVersion: RAREBIT_SUMMARY_PROMPT_VERSION,
|
|
120
|
+
promptIdentity: null,
|
|
121
|
+
});
|
|
122
|
+
const customIdentity = rarebitJobIdentity({
|
|
123
|
+
operation: "summary",
|
|
124
|
+
sessionId: "prompt-contract",
|
|
125
|
+
branch: { leafId: "a1", entryCount: 2, pathHash: "path" },
|
|
126
|
+
selection,
|
|
127
|
+
promptVersion: RAREBIT_SUMMARY_PROMPT_VERSION,
|
|
128
|
+
promptIdentity: custom.promptIdentity,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
assert.equal(normalized.promptIdentity, custom.promptIdentity);
|
|
132
|
+
assert.ok(custom.promptIdentity);
|
|
133
|
+
assert.match(custom.prompt, /Use short factual bullets and state uncertainty/);
|
|
134
|
+
assert.notEqual(custom.prompt, composeRarebitSummaryPrompt(selection));
|
|
135
|
+
assert.notEqual(customIdentity, defaultIdentity);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("summary_prompt accepts only a nonblank scalar string", () => {
|
|
139
|
+
assert.throws(() => normalizeRarebitSummaryPrompt({ guidance: "custom" }), /summary_prompt/);
|
|
140
|
+
assert.throws(() => normalizeRarebitSummaryPrompt(" "), /summary_prompt/);
|
|
141
|
+
assert.throws(() => normalizeRarebitSummaryPrompt(42), /summary_prompt/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("settings resolve and round-trip the scalar summary_prompt without changing other namespaces", async () => {
|
|
145
|
+
const f = await fixture();
|
|
146
|
+
await writeFile(f.root + "/agent/settings.json", JSON.stringify({
|
|
147
|
+
theme: "dark",
|
|
148
|
+
other_extension: { keep: true },
|
|
149
|
+
rarebit: { model: "fixture/summary", summary_prompt: "Use an incident timeline." },
|
|
150
|
+
}, null, 2));
|
|
151
|
+
const resolved = resolveRarebitSettings({
|
|
152
|
+
rarebit: { model: "fixture/summary", summary_prompt: "Use an incident timeline." },
|
|
153
|
+
});
|
|
154
|
+
assert.equal(resolved.summaryPrompt, "Use an incident timeline.");
|
|
155
|
+
assert.equal(
|
|
156
|
+
RAREBIT_SETTINGS_FIELDS.some((field) => field.path?.join(".") === "summary_prompt"),
|
|
157
|
+
true,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const document = await readRarebitSettingsDocument({ agentDir: f.root + "/agent" });
|
|
161
|
+
await saveRarebitSettingsDocument(document, {
|
|
162
|
+
...document.namespace,
|
|
163
|
+
summary_prompt: "Use an incident timeline with explicit uncertainty.",
|
|
164
|
+
});
|
|
165
|
+
const saved = JSON.parse(await readFile(f.root + "/agent/settings.json", "utf8"));
|
|
166
|
+
assert.equal(saved.theme, "dark");
|
|
167
|
+
assert.deepEqual(saved.other_extension, { keep: true });
|
|
168
|
+
assert.equal(saved.rarebit.summary_prompt, "Use an incident timeline with explicit uncertainty.");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("invalid summary_prompt input fails before the provider or durable receipt", async () => {
|
|
172
|
+
const f = await fixture();
|
|
173
|
+
const calls = [];
|
|
174
|
+
const ctx = {
|
|
175
|
+
sessionManager: {
|
|
176
|
+
getHeader: () => ({ id: "prompt-contract" }),
|
|
177
|
+
getSessionFile: () => f.sessionFile,
|
|
178
|
+
getBranch: branch,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
await assert.rejects(
|
|
183
|
+
processRarebitSummary(ctx, {
|
|
184
|
+
sessionRoot: f.sessionRoot,
|
|
185
|
+
rarebitRoot: f.rarebitRoot,
|
|
186
|
+
forceSynthesis: true,
|
|
187
|
+
model: { provider: "fixture", id: "summary" },
|
|
188
|
+
summaryPrompt: { guidance: " " },
|
|
189
|
+
modelClient: { complete: async (request) => { calls.push(request); return "{}"; } },
|
|
190
|
+
}),
|
|
191
|
+
/summary_prompt/,
|
|
192
|
+
);
|
|
193
|
+
assert.equal(calls.length, 0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("custom summary_prompt reaches the provider and prevents reuse of the default receipt", async () => {
|
|
197
|
+
const f = await fixture();
|
|
198
|
+
const requests = [];
|
|
199
|
+
const ctx = {
|
|
200
|
+
sessionManager: {
|
|
201
|
+
getHeader: () => ({ id: "prompt-contract" }),
|
|
202
|
+
getSessionFile: () => f.sessionFile,
|
|
203
|
+
getBranch: branch,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const common = {
|
|
207
|
+
sessionRoot: f.sessionRoot,
|
|
208
|
+
rarebitRoot: f.rarebitRoot,
|
|
209
|
+
forceSynthesis: true,
|
|
210
|
+
model: { provider: "fixture", id: "summary" },
|
|
211
|
+
modelClient: {
|
|
212
|
+
complete: async (request) => {
|
|
213
|
+
requests.push(request);
|
|
214
|
+
return {
|
|
215
|
+
text: JSON.stringify({
|
|
216
|
+
summary: "The deployment issue is recorded.",
|
|
217
|
+
sessionStatus: "finished",
|
|
218
|
+
statusReason: "all_requests_accomplished",
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
const first = await processRarebitSummary(ctx, common);
|
|
225
|
+
const second = await processRarebitSummary(ctx, {
|
|
226
|
+
...common,
|
|
227
|
+
summaryPrompt: "Use a terse incident report with explicit uncertainty.",
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
assert.equal(first.record.status, "ok");
|
|
231
|
+
assert.equal(second.record.status, "ok");
|
|
232
|
+
assert.equal(requests.length, 2);
|
|
233
|
+
assert.doesNotMatch(requests[0].prompt, /terse incident report/);
|
|
234
|
+
assert.match(requests[1].prompt, /terse incident report/);
|
|
235
|
+
assert.notEqual(first.record.jobId, second.record.jobId);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("Recap renders the full multiline Summary through a themed human-only widget", async () => {
|
|
239
|
+
const summary = Array.from({ length: 24 }, (_, index) => `line-${index + 1} FULL_RECAP_TAIL_${index + 1}`).join("\n");
|
|
240
|
+
const widgets = [];
|
|
241
|
+
const { ctx, current } = recapContext(summary, (...args) => widgets.push(args));
|
|
242
|
+
let modelCalls = 0;
|
|
243
|
+
let sessionWrites = 0;
|
|
244
|
+
const controller = createRarebitRecapController({
|
|
245
|
+
readCurrent: async () => current,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const shown = await controller.showExisting({
|
|
249
|
+
...ctx,
|
|
250
|
+
modelClient: { complete: async () => { modelCalls += 1; } },
|
|
251
|
+
sessionManager: {
|
|
252
|
+
...ctx.sessionManager,
|
|
253
|
+
appendMessage: () => { sessionWrites += 1; },
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
assert.equal(shown.shown, true);
|
|
257
|
+
assert.equal(widgets[0][0], RAREBIT_RECAP_WIDGET_KEY);
|
|
258
|
+
assert.equal(typeof widgets[0][1], "function");
|
|
259
|
+
|
|
260
|
+
const themed = widgets[0][1]({}, {
|
|
261
|
+
bold: (value) => value,
|
|
262
|
+
fg: (tone, value) => `[${tone}]${value}`,
|
|
263
|
+
});
|
|
264
|
+
const rendered = themed.render(200).join("\n");
|
|
265
|
+
assert.match(rendered, /Recap/);
|
|
266
|
+
assert.match(rendered, /line-1 FULL_RECAP_TAIL_1/);
|
|
267
|
+
assert.match(rendered, /line-24 FULL_RECAP_TAIL_24/);
|
|
268
|
+
assert.doesNotMatch(rendered, /recap expand/i);
|
|
269
|
+
assert.match(rendered, /\[muted\]|\[dim\]/);
|
|
270
|
+
assert.equal(modelCalls, 0);
|
|
271
|
+
assert.equal(sessionWrites, 0);
|
|
272
|
+
|
|
273
|
+
const refreshed = widgets[0][1]({}, {
|
|
274
|
+
bold: (value) => value,
|
|
275
|
+
fg: (tone, value) => `{${tone}}${value}`,
|
|
276
|
+
});
|
|
277
|
+
assert.match(refreshed.render(200).join("\n"), /\{muted\}|\{dim\}/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("Recap drops an async display when the Session changes before the widget factory resolves", async () => {
|
|
281
|
+
const widgets = [];
|
|
282
|
+
const first = recapContext("first Summary", (...args) => widgets.push(args));
|
|
283
|
+
const second = recapContext("second Summary", (...args) => widgets.push(args));
|
|
284
|
+
let releaseRead;
|
|
285
|
+
const readCurrent = new Promise((resolve) => { releaseRead = resolve; });
|
|
286
|
+
const controller = createRarebitRecapController({ readCurrent: async () => readCurrent });
|
|
287
|
+
const pending = controller.showExisting(first.ctx);
|
|
288
|
+
controller.invalidate(second.ctx, 2);
|
|
289
|
+
releaseRead(first.current);
|
|
290
|
+
const result = await pending;
|
|
291
|
+
assert.equal(result.shown, false);
|
|
292
|
+
assert.equal(result.reason, "stale");
|
|
293
|
+
assert.equal(widgets.some((call) => call[0] === RAREBIT_RECAP_WIDGET_KEY && call[1]), false);
|
|
294
|
+
});
|
|
@@ -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
|
},
|