@andares/pi-toolkits 0.3.1 → 0.4.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.
@@ -0,0 +1,661 @@
1
+ /**
2
+ * compact-model feature — tests.
3
+ *
4
+ * Covers:
5
+ * - CompactStore: set/get roundtrip (persisted), corrupted-file
6
+ * degradation, clear persistence
7
+ * - pickCompactModel: no-UI fallback, empty list, clear option, model pick,
8
+ * cancel
9
+ * - session_before_compact handler: unset config → untouched; unavailable
10
+ * model → warning + fallback; success (summary format, passthrough
11
+ * fields, usage, details); empty summary → fallback; stopReason
12
+ * length/error → fallback; abort → silent fallback; split-turn double
13
+ * call + merged summary + combined usage; customInstructions and
14
+ * previousSummary prompt variants
15
+ * - prompt assembly: buildHistoryPromptText / buildTurnPrefixPromptText /
16
+ * computeFileLists / formatFileOperations / combineUsage
17
+ */
18
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22
+ import type {
23
+ ExtensionAPI,
24
+ ExtensionContext,
25
+ ModelRegistry,
26
+ SessionBeforeCompactEvent,
27
+ SessionBeforeCompactResult,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import type { AvailableModel } from "../../lib/model-config.js";
30
+ import { modelKey } from "../../lib/model-config.js";
31
+ import {
32
+ buildHistoryPromptText,
33
+ buildTurnPrefixPromptText,
34
+ combineUsage,
35
+ computeFileLists,
36
+ formatFileOperations,
37
+ SUMMARIZATION_PROMPT,
38
+ UPDATE_SUMMARIZATION_PROMPT,
39
+ type UsageLike,
40
+ } from "./prompt.js";
41
+ import {
42
+ CompactStore,
43
+ getCompactStore,
44
+ setSharedCompactStore,
45
+ } from "./store.js";
46
+ import { pickCompactModel, registerCompact } from "./index.js";
47
+
48
+ // ─── fakes & setup ─────────────────────────────────────────────
49
+
50
+ let tmpDir: string;
51
+ let storeSeq = 0;
52
+
53
+ beforeAll(() => {
54
+ tmpDir = mkdtempSync(join(tmpdir(), "pi-toolkits-compact-"));
55
+ });
56
+
57
+ beforeEach(() => {
58
+ storeSeq += 1;
59
+ setSharedCompactStore(
60
+ new CompactStore(join(tmpDir, `compact-${storeSeq}.json`)),
61
+ );
62
+ });
63
+
64
+ afterAll(() => {
65
+ rmSync(tmpDir, { recursive: true, force: true });
66
+ });
67
+
68
+ function freshStoreFile(): string {
69
+ storeSeq += 1;
70
+ return join(tmpDir, `compact-${storeSeq}.json`);
71
+ }
72
+
73
+ function fakeModel(provider: string, id: string, name = id): AvailableModel {
74
+ return {
75
+ id,
76
+ name,
77
+ provider,
78
+ api: "anthropic-messages",
79
+ baseUrl: "https://api.anthropic.com",
80
+ reasoning: false,
81
+ input: ["text"],
82
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
83
+ contextWindow: 200000,
84
+ maxTokens: 8192,
85
+ } as AvailableModel;
86
+ }
87
+
88
+ const MODELS: AvailableModel[] = [
89
+ fakeModel("google", "gemini-2.5-flash", "Gemini 2.5 Flash"),
90
+ fakeModel("anthropic", "claude-sonnet-4", "Claude Sonnet 4"),
91
+ ];
92
+
93
+ /** The response type ModelRegistry.complete() resolves to (pi's AssistantMessage). */
94
+ type AssistantMessageLike = Awaited<ReturnType<ModelRegistry["complete"]>>;
95
+
96
+ function fakeUsage(over: Partial<UsageLike> = {}): UsageLike {
97
+ return {
98
+ input: 100,
99
+ output: 50,
100
+ cacheRead: 0,
101
+ cacheWrite: 0,
102
+ totalTokens: 150,
103
+ cost: {
104
+ input: 0.001,
105
+ output: 0.002,
106
+ cacheRead: 0,
107
+ cacheWrite: 0,
108
+ total: 0.003,
109
+ },
110
+ ...over,
111
+ };
112
+ }
113
+
114
+ function fakeResponse(
115
+ text: string,
116
+ over: Partial<AssistantMessageLike> = {},
117
+ ): AssistantMessageLike {
118
+ return {
119
+ role: "assistant",
120
+ content: [{ type: "text", text }],
121
+ stopReason: "stop",
122
+ usage: fakeUsage(),
123
+ timestamp: Date.now(),
124
+ ...over,
125
+ } as AssistantMessageLike;
126
+ }
127
+
128
+ interface CtxOverrides {
129
+ models?: AvailableModel[];
130
+ complete?: (
131
+ model: AvailableModel,
132
+ context: { messages: Array<{ role: string; content: unknown }> },
133
+ options: { maxTokens?: number; signal?: AbortSignal },
134
+ ) => Promise<AssistantMessageLike>;
135
+ hasUI?: boolean;
136
+ selectResult?: string | undefined;
137
+ }
138
+
139
+ function fakeCtx(overrides: CtxOverrides = {}) {
140
+ const notify = vi.fn();
141
+ const select = vi.fn(async () => overrides.selectResult);
142
+ const setStatus = vi.fn();
143
+ const complete =
144
+ overrides.complete ??
145
+ (vi.fn(async () => fakeResponse("## Goal\n- test summary")) as NonNullable<
146
+ CtxOverrides["complete"]
147
+ >);
148
+ const ctx = {
149
+ mode: "tui",
150
+ hasUI: overrides.hasUI ?? true,
151
+ isIdle: () => true,
152
+ modelRegistry: {
153
+ getAvailable: () => overrides.models ?? MODELS,
154
+ complete,
155
+ },
156
+ ui: { notify, select, setStatus },
157
+ } as unknown as ExtensionContext;
158
+ return { ctx, notify, select, setStatus, complete };
159
+ }
160
+
161
+ function fakePi() {
162
+ const handlers = new Map<string, (event: never, ctx: ExtensionContext) => unknown>();
163
+ const commands = new Map<string, { handler: (args: string, ctx: ExtensionContext) => Promise<void> }>();
164
+ const on = vi.fn((event: string, handler: (event: never, ctx: ExtensionContext) => unknown) => {
165
+ handlers.set(event, handler);
166
+ });
167
+ const registerCommand = vi.fn(
168
+ (name: string, def: { handler: (args: string, ctx: ExtensionContext) => Promise<void> }) => {
169
+ commands.set(name, def);
170
+ },
171
+ );
172
+ const pi = { on, registerCommand } as unknown as ExtensionAPI;
173
+ return { pi, handlers, commands };
174
+ }
175
+
176
+ function userMessage(text: string) {
177
+ return {
178
+ role: "user" as const,
179
+ content: [{ type: "text" as const, text }],
180
+ timestamp: 1,
181
+ };
182
+ }
183
+
184
+ function fakeEvent(over: Partial<SessionBeforeCompactEvent["preparation"]> = {}) {
185
+ const preparation = {
186
+ firstKeptEntryId: "kept-1",
187
+ messagesToSummarize: [userMessage("hello world")],
188
+ turnPrefixMessages: [],
189
+ isSplitTurn: false,
190
+ tokensBefore: 12345,
191
+ previousSummary: undefined,
192
+ fileOps: {
193
+ read: new Set(["/a/read.ts", "/b/mod.ts"]),
194
+ written: new Set(["/b/mod.ts"]),
195
+ edited: new Set(["/c/edit.ts"]),
196
+ },
197
+ settings: { enabled: true, reserveTokens: 16000, keepRecentTokens: 20000 },
198
+ ...over,
199
+ };
200
+ return {
201
+ type: "session_before_compact",
202
+ preparation,
203
+ branchEntries: [],
204
+ customInstructions: undefined,
205
+ reason: "manual",
206
+ willRetry: false,
207
+ signal: new AbortController().signal,
208
+ } as unknown as SessionBeforeCompactEvent;
209
+ }
210
+
211
+ function registeredHandler() {
212
+ const { pi, handlers, commands } = fakePi();
213
+ registerCompact(pi);
214
+ const handler = handlers.get("session_before_compact");
215
+ expect(handler).toBeTypeOf("function");
216
+ return { handler: handler!, commands };
217
+ }
218
+
219
+ // ─── store ─────────────────────────────────────────────────────
220
+
221
+ describe("CompactStore", () => {
222
+ it("persists set/get roundtrip", () => {
223
+ const file = freshStoreFile();
224
+ const store = new CompactStore(file);
225
+ expect(store.getModel()).toBeUndefined();
226
+ store.setModel("google/gemini-2.5-flash");
227
+ expect(store.getModel()).toBe("google/gemini-2.5-flash");
228
+ const raw = JSON.parse(readFileSync(file, "utf8"));
229
+ expect(raw).toEqual({ model: "google/gemini-2.5-flash" });
230
+ });
231
+
232
+ it("reloads from disk", () => {
233
+ const file = freshStoreFile();
234
+ new CompactStore(file).setModel("anthropic/claude-sonnet-4");
235
+ const second = new CompactStore(file);
236
+ expect(second.getModel()).toBe("anthropic/claude-sonnet-4");
237
+ });
238
+
239
+ it("degrades to unset on corrupted file", () => {
240
+ const file = freshStoreFile();
241
+ writeFileSync(file, "{not json", "utf8");
242
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
243
+ try {
244
+ expect(new CompactStore(file).getModel()).toBeUndefined();
245
+ expect(warn).toHaveBeenCalled();
246
+ } finally {
247
+ warn.mockRestore();
248
+ }
249
+ });
250
+
251
+ it("ignores non-string model values", () => {
252
+ const file = freshStoreFile();
253
+ writeFileSync(file, JSON.stringify({ model: 42 }), "utf8");
254
+ expect(new CompactStore(file).getModel()).toBeUndefined();
255
+ });
256
+
257
+ it("persists clear as null", () => {
258
+ const file = freshStoreFile();
259
+ const store = new CompactStore(file);
260
+ store.setModel("google/gemini-2.5-flash");
261
+ store.setModel(undefined);
262
+ expect(store.getModel()).toBeUndefined();
263
+ const raw = JSON.parse(readFileSync(file, "utf8"));
264
+ expect(raw).toEqual({ model: null });
265
+ });
266
+
267
+ it("notifies subscribers on set", () => {
268
+ const listener = vi.fn();
269
+ const store = new CompactStore(freshStoreFile());
270
+ store.subscribe(listener);
271
+ store.setModel("google/gemini-2.5-flash");
272
+ expect(listener).toHaveBeenCalledTimes(1);
273
+ store.setModel(undefined);
274
+ expect(listener).toHaveBeenCalledTimes(2);
275
+ });
276
+ });
277
+
278
+ // ─── pickCompactModel ──────────────────────────────────────────
279
+
280
+ describe("pickCompactModel", () => {
281
+ it("notifies and returns when there is no dialog UI", async () => {
282
+ const { ctx, notify, select } = fakeCtx({ hasUI: false });
283
+ const model = await pickCompactModel(ctx, getCompactStore());
284
+ expect(model).toBeUndefined();
285
+ expect(select).not.toHaveBeenCalled();
286
+ expect(notify).toHaveBeenCalledWith(
287
+ expect.stringContaining("pi-toolkits-compact.json"),
288
+ "error",
289
+ );
290
+ });
291
+
292
+ it("notifies when no models are available", async () => {
293
+ const { ctx, notify } = fakeCtx({ models: [] });
294
+ await pickCompactModel(ctx, getCompactStore());
295
+ expect(notify).toHaveBeenCalledWith(
296
+ expect.stringContaining("没有可用模型"),
297
+ "error",
298
+ );
299
+ });
300
+
301
+ it("clears config when the clear option is chosen", async () => {
302
+ getCompactStore().setModel("google/gemini-2.5-flash");
303
+ const { ctx, notify } = fakeCtx({
304
+ selectResult: "✕ 清除配置(用 pi 默认压缩)",
305
+ });
306
+ const model = await pickCompactModel(ctx, getCompactStore());
307
+ expect(model).toBeUndefined();
308
+ expect(getCompactStore().getModel()).toBeUndefined();
309
+ expect(notify).toHaveBeenCalledWith(
310
+ expect.stringContaining("已清除"),
311
+ "info",
312
+ );
313
+ });
314
+
315
+ it("persists the picked model", async () => {
316
+ const { ctx } = fakeCtx({
317
+ selectResult: "Gemini 2.5 Flash (google/gemini-2.5-flash)",
318
+ });
319
+ const model = await pickCompactModel(ctx, getCompactStore());
320
+ expect(modelKey(model!)).toBe("google/gemini-2.5-flash");
321
+ expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
322
+ });
323
+
324
+ it("keeps config on cancel", async () => {
325
+ getCompactStore().setModel("google/gemini-2.5-flash");
326
+ const { ctx, notify } = fakeCtx({ selectResult: undefined });
327
+ await pickCompactModel(ctx, getCompactStore());
328
+ expect(getCompactStore().getModel()).toBe("google/gemini-2.5-flash");
329
+ expect(notify).toHaveBeenCalledWith(
330
+ expect.stringContaining("已取消"),
331
+ "info",
332
+ );
333
+ });
334
+ });
335
+
336
+ // ─── session_before_compact handler ────────────────────────────
337
+
338
+ describe("session_before_compact handler", () => {
339
+ afterEach(() => {
340
+ vi.restoreAllMocks();
341
+ });
342
+
343
+ it("returns undefined without calling the model when unset", async () => {
344
+ const { handler } = registeredHandler();
345
+ const { ctx, complete } = fakeCtx();
346
+ const result = (await handler(
347
+ fakeEvent() as never,
348
+ ctx,
349
+ )) as SessionBeforeCompactResult | undefined;
350
+ expect(result).toBeUndefined();
351
+ expect(complete).not.toHaveBeenCalled();
352
+ });
353
+
354
+ it("falls back with a warning when the model is unavailable", async () => {
355
+ getCompactStore().setModel("google/nonexistent");
356
+ const { handler } = registeredHandler();
357
+ const { ctx, notify, complete } = fakeCtx();
358
+ const result = (await handler(
359
+ fakeEvent() as never,
360
+ ctx,
361
+ )) as SessionBeforeCompactResult | undefined;
362
+ expect(result).toBeUndefined();
363
+ expect(complete).not.toHaveBeenCalled();
364
+ expect(notify).toHaveBeenCalledWith(
365
+ expect.stringContaining("google/nonexistent"),
366
+ "warning",
367
+ );
368
+ });
369
+
370
+ it("returns the compaction payload on success", async () => {
371
+ getCompactStore().setModel("google/gemini-2.5-flash");
372
+ const { handler } = registeredHandler();
373
+ const { ctx, complete } = fakeCtx();
374
+ const event = fakeEvent();
375
+ const result = (await handler(
376
+ event as never,
377
+ ctx,
378
+ )) as SessionBeforeCompactResult | undefined;
379
+ expect(result?.compaction).toBeDefined();
380
+ const compaction = result!.compaction!;
381
+ expect(compaction.summary).toContain("## Goal");
382
+ expect(compaction.summary).toContain("<read-files>\n/a/read.ts\n</read-files>");
383
+ expect(compaction.summary).toContain(
384
+ "<modified-files>\n/b/mod.ts\n/c/edit.ts\n</modified-files>",
385
+ );
386
+ expect(compaction.firstKeptEntryId).toBe("kept-1");
387
+ expect(compaction.tokensBefore).toBe(12345);
388
+ expect(compaction.usage).toEqual(fakeUsage());
389
+ expect(compaction.details).toEqual({
390
+ readFiles: ["/a/read.ts"],
391
+ modifiedFiles: ["/b/mod.ts", "/c/edit.ts"],
392
+ });
393
+ expect(complete).toHaveBeenCalledTimes(1);
394
+ const [model, , options] = (complete as ReturnType<typeof vi.fn>).mock
395
+ .calls[0] as [
396
+ AvailableModel,
397
+ unknown,
398
+ { maxTokens: number; signal: AbortSignal },
399
+ ];
400
+ expect(modelKey(model)).toBe("google/gemini-2.5-flash");
401
+ // 0.8 × reserveTokens(16000) = 12800, under the model cap (8192).
402
+ expect(options.maxTokens).toBe(8192);
403
+ expect(options.signal).toBe(event.signal);
404
+ });
405
+
406
+ it("clamps the budget by reserveTokens when the model cap is larger", async () => {
407
+ getCompactStore().setModel("google/gemini-2.5-flash");
408
+ const { handler } = registeredHandler();
409
+ const bigCapModel = { ...MODELS[0], maxTokens: 65536 };
410
+ const { ctx, complete } = fakeCtx({ models: [bigCapModel] });
411
+ await handler(fakeEvent() as never, ctx);
412
+ const [, , options] = (complete as ReturnType<typeof vi.fn>).mock
413
+ .calls[0] as [
414
+ AvailableModel,
415
+ unknown,
416
+ { maxTokens: number },
417
+ ];
418
+ expect(options.maxTokens).toBe(12800);
419
+ });
420
+
421
+ it("falls back with a warning on an empty summary", async () => {
422
+ getCompactStore().setModel("google/gemini-2.5-flash");
423
+ const { handler } = registeredHandler();
424
+ const { ctx, notify } = fakeCtx({
425
+ complete: vi.fn(async () => fakeResponse(" ")),
426
+ });
427
+ const result = (await handler(
428
+ fakeEvent() as never,
429
+ ctx,
430
+ )) as SessionBeforeCompactResult | undefined;
431
+ expect(result).toBeUndefined();
432
+ expect(notify).toHaveBeenCalledWith(
433
+ expect.stringContaining("摘要为空"),
434
+ "warning",
435
+ );
436
+ });
437
+
438
+ it("falls back when the response hit the token cap", async () => {
439
+ getCompactStore().setModel("google/gemini-2.5-flash");
440
+ const { handler } = registeredHandler();
441
+ const { ctx, notify } = fakeCtx({
442
+ complete: vi.fn(async () =>
443
+ fakeResponse("partial", { stopReason: "length" }),
444
+ ),
445
+ });
446
+ const result = (await handler(
447
+ fakeEvent() as never,
448
+ ctx,
449
+ )) as SessionBeforeCompactResult | undefined;
450
+ expect(result).toBeUndefined();
451
+ expect(notify).toHaveBeenCalledWith(
452
+ expect.stringContaining("token cap"),
453
+ "error",
454
+ );
455
+ });
456
+
457
+ it("falls back silently when the user aborted", async () => {
458
+ getCompactStore().setModel("google/gemini-2.5-flash");
459
+ const { handler } = registeredHandler();
460
+ const controller = new AbortController();
461
+ const event = fakeEvent();
462
+ Object.assign(event, { signal: controller.signal });
463
+ controller.abort();
464
+ const { ctx, notify } = fakeCtx({
465
+ complete: vi.fn(async () => {
466
+ throw new Error("aborted mid-flight");
467
+ }),
468
+ });
469
+ const result = (await handler(
470
+ event as never,
471
+ ctx,
472
+ )) as SessionBeforeCompactResult | undefined;
473
+ expect(result).toBeUndefined();
474
+ expect(notify).not.toHaveBeenCalled();
475
+ });
476
+
477
+ it("merges history + turn-prefix summaries on split turns", async () => {
478
+ getCompactStore().setModel("google/gemini-2.5-flash");
479
+ const { handler } = registeredHandler();
480
+ const { ctx, complete } = fakeCtx({
481
+ complete: vi.fn(async (_model, context) => {
482
+ const text = (context.messages[0]?.content as Array<{ text?: string }>)[0]
483
+ ?.text;
484
+ return text?.includes("# Instructions")
485
+ ? fakeResponse("TURN PREFIX CHECKPOINT", { usage: fakeUsage({ input: 7 }) })
486
+ : fakeResponse("HISTORY SUMMARY");
487
+ }),
488
+ });
489
+ const event = fakeEvent({
490
+ turnPrefixMessages: [userMessage("prefix msg")],
491
+ isSplitTurn: true,
492
+ previousSummary: "OLD SUMMARY",
493
+ });
494
+ const result = (await handler(
495
+ event as never,
496
+ ctx,
497
+ )) as SessionBeforeCompactResult | undefined;
498
+ expect(complete).toHaveBeenCalledTimes(2);
499
+ const compaction = result!.compaction!;
500
+ expect(compaction.summary).toContain("HISTORY SUMMARY");
501
+ expect(compaction.summary).toContain("**Turn Context (split turn):**");
502
+ expect(compaction.summary).toContain("TURN PREFIX CHECKPOINT");
503
+ // 100 (history) + 7 (turn prefix) — combined usage.
504
+ expect(compaction.usage?.input).toBe(107);
505
+ });
506
+
507
+ it("keeps the previous summary as history text on split turns with no messages", async () => {
508
+ getCompactStore().setModel("google/gemini-2.5-flash");
509
+ const { handler } = registeredHandler();
510
+ const { ctx, complete } = fakeCtx({
511
+ complete: vi.fn(async () => fakeResponse("TURN PREFIX CHECKPOINT")),
512
+ });
513
+ const event = fakeEvent({
514
+ messagesToSummarize: [],
515
+ turnPrefixMessages: [userMessage("prefix msg")],
516
+ isSplitTurn: true,
517
+ previousSummary: "OLD SUMMARY",
518
+ });
519
+ const result = (await handler(
520
+ event as never,
521
+ ctx,
522
+ )) as SessionBeforeCompactResult | undefined;
523
+ expect(complete).toHaveBeenCalledTimes(1);
524
+ const compaction = result!.compaction!;
525
+ expect(compaction.summary).toContain("OLD SUMMARY");
526
+ expect(compaction.summary).toContain("**Turn Context (split turn):**");
527
+ });
528
+
529
+ it("injects customInstructions as an Additional focus line", async () => {
530
+ getCompactStore().setModel("google/gemini-2.5-flash");
531
+ const { handler } = registeredHandler();
532
+ const prompts: string[] = [];
533
+ const { ctx } = fakeCtx({
534
+ complete: vi.fn(async (_model, context) => {
535
+ prompts.push(
536
+ (context.messages[0]?.content as Array<{ text?: string }>)[0]?.text ??
537
+ "",
538
+ );
539
+ return fakeResponse("## Goal\n- x");
540
+ }),
541
+ });
542
+ const event = fakeEvent();
543
+ Object.assign(event, { customInstructions: "保留命令输出细节" });
544
+ await handler(event as never, ctx);
545
+ expect(prompts[0]).toContain("Additional focus: 保留命令输出细节");
546
+ });
547
+
548
+ it("switches to the update prompt when a previous summary exists", async () => {
549
+ getCompactStore().setModel("google/gemini-2.5-flash");
550
+ const { handler } = registeredHandler();
551
+ const prompts: string[] = [];
552
+ const { ctx } = fakeCtx({
553
+ complete: vi.fn(async (_model, context) => {
554
+ prompts.push(
555
+ (context.messages[0]?.content as Array<{ text?: string }>)[0]?.text ??
556
+ "",
557
+ );
558
+ return fakeResponse("## Goal\n- x");
559
+ }),
560
+ });
561
+ await handler(
562
+ fakeEvent({ previousSummary: "OLD" }) as never,
563
+ ctx,
564
+ );
565
+ expect(prompts[0]).toContain(UPDATE_SUMMARIZATION_PROMPT);
566
+ expect(prompts[0]).not.toContain(SUMMARIZATION_PROMPT);
567
+ expect(prompts[0]).toContain("<previous-summary>\nOLD\n</previous-summary>");
568
+ });
569
+
570
+ it("wraps the serialized conversation in tags", async () => {
571
+ getCompactStore().setModel("google/gemini-2.5-flash");
572
+ const { handler } = registeredHandler();
573
+ const prompts: string[] = [];
574
+ const { ctx } = fakeCtx({
575
+ complete: vi.fn(async (_model, context) => {
576
+ prompts.push(
577
+ (context.messages[0]?.content as Array<{ text?: string }>)[0]?.text ??
578
+ "",
579
+ );
580
+ return fakeResponse("## Goal\n- x");
581
+ }),
582
+ });
583
+ await handler(fakeEvent() as never, ctx);
584
+ expect(prompts[0].startsWith("<conversation>\n")).toBe(true);
585
+ expect(prompts[0]).toContain("hello world");
586
+ expect(prompts[0].endsWith(SUMMARIZATION_PROMPT)).toBe(true);
587
+ });
588
+ });
589
+
590
+ // ─── prompt assembly ───────────────────────────────────────────
591
+
592
+ describe("buildHistoryPromptText", () => {
593
+ it("uses the initial prompt without a previous summary", () => {
594
+ const text = buildHistoryPromptText("CONV", undefined, undefined);
595
+ expect(text).toBe(`<conversation>\nCONV\n</conversation>\n\n${SUMMARIZATION_PROMPT}`);
596
+ });
597
+
598
+ it("embeds the previous summary and uses the update prompt", () => {
599
+ const text = buildHistoryPromptText("CONV", "OLD", undefined);
600
+ expect(text).toContain("<previous-summary>\nOLD\n</previous-summary>");
601
+ expect(text.endsWith(UPDATE_SUMMARIZATION_PROMPT)).toBe(true);
602
+ });
603
+
604
+ it("appends the custom focus after the base prompt", () => {
605
+ const text = buildHistoryPromptText("CONV", undefined, "FOCUS X");
606
+ expect(text).toBe(
607
+ `<conversation>\nCONV\n</conversation>\n\n${SUMMARIZATION_PROMPT}\n\nAdditional focus: FOCUS X`,
608
+ );
609
+ });
610
+ });
611
+
612
+ describe("buildTurnPrefixPromptText", () => {
613
+ it("formats conversation then instructions", () => {
614
+ const text = buildTurnPrefixPromptText("CONV");
615
+ expect(text.startsWith("# Conversation\nCONV\n\n# Instructions\n")).toBe(true);
616
+ });
617
+ });
618
+
619
+ describe("computeFileLists", () => {
620
+ it("splits read-only vs modified and sorts both", () => {
621
+ const { readFiles, modifiedFiles } = computeFileLists({
622
+ read: new Set(["/z.ts", "/a.ts", "/m.ts"]),
623
+ written: new Set(["/w.ts"]),
624
+ edited: new Set(["/e.ts", "/m.ts"]),
625
+ });
626
+ expect(readFiles).toEqual(["/a.ts", "/z.ts"]);
627
+ expect(modifiedFiles).toEqual(["/e.ts", "/m.ts", "/w.ts"]);
628
+ });
629
+ });
630
+
631
+ describe("formatFileOperations", () => {
632
+ it("returns empty when no files", () => {
633
+ expect(formatFileOperations([], [])).toBe("");
634
+ });
635
+
636
+ it("formats both sections joined by a blank line", () => {
637
+ const tail = formatFileOperations(["/a.ts"], ["/b.ts"]);
638
+ expect(tail).toBe(
639
+ "\n\n<read-files>\n/a.ts\n</read-files>\n\n<modified-files>\n/b.ts\n</modified-files>",
640
+ );
641
+ });
642
+ });
643
+
644
+ describe("combineUsage", () => {
645
+ it("adds numeric fields field-wise", () => {
646
+ const a = fakeUsage();
647
+ const combined = combineUsage(a, fakeUsage());
648
+ expect(combined.input).toBe(200);
649
+ expect(combined.output).toBe(100);
650
+ expect(combined.totalTokens).toBe(300);
651
+ expect(combined.cost.total).toBeCloseTo(0.006);
652
+ });
653
+
654
+ it("preserves optional splits only when either side reports them", () => {
655
+ const a = fakeUsage();
656
+ const combined = combineUsage(a, fakeUsage({ cacheWrite1h: 10 }));
657
+ expect(combined.cacheWrite1h).toBe(10);
658
+ const none = combineUsage(fakeUsage(), fakeUsage());
659
+ expect(none.cacheWrite1h).toBeUndefined();
660
+ });
661
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * compact-model feature — constants.
3
+ *
4
+ * The compact-model config lives in `<agent-dir>/pi-toolkits-compact.json`
5
+ * (same agent-dir convention as the review/favorites stores) and holds a
6
+ * single model key `"provider/id"` — see store.ts.
7
+ */
8
+
9
+ /** Config file name inside the pi agent dir. */
10
+ export const COMPACT_FILE = "pi-toolkits-compact.json";
11
+
12
+ /** Footer status key shown while a compact model is configured. */
13
+ export const COMPACT_STATUS_KEY = "compact";