v1.11: opencode-style compaction port
- compaction.ts: usable/isOverflow/estimate/turns/select/buildPrompt/process
- compaction-prompt.ts: SUMMARY_TEMPLATE verbatim from opencode
- schema: messages.{compacted_at,summary,tail_start_id} + chats.needs_compaction
- inference: auto-trigger on overflow, pre-fetch compaction before next turn
- /compact slash command rewired to new path
- WS: chat_status working/idle around compaction + compacted frame
- frontend: SummaryCard + sonner toast on compacted
- 24 unit tests for pure functions
This commit is contained in:
258
apps/server/src/services/__tests__/compaction.test.ts
Normal file
258
apps/server/src/services/__tests__/compaction.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
usable,
|
||||
isOverflow,
|
||||
estimate,
|
||||
turns,
|
||||
select,
|
||||
buildPrompt,
|
||||
type CompactionMessage,
|
||||
} from '../compaction.js';
|
||||
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
|
||||
|
||||
// ---- fixture ----------------------------------------------------------------
|
||||
// Tiny constructor for the message shape `compaction.ts` consumes. Default
|
||||
// values match the post-CP1 schema (summary=false, kind='message', complete).
|
||||
// Tests that need a summary row pass `summary: true`.
|
||||
|
||||
let counter = 0;
|
||||
function mkMsg(
|
||||
role: CompactionMessage['role'],
|
||||
content: string,
|
||||
overrides: Partial<CompactionMessage> = {},
|
||||
): CompactionMessage {
|
||||
counter += 1;
|
||||
return {
|
||||
id: `m${counter}`,
|
||||
role,
|
||||
content,
|
||||
kind: 'message',
|
||||
summary: false,
|
||||
status: 'complete',
|
||||
tool_calls: null,
|
||||
tool_results: null,
|
||||
metadata: null,
|
||||
created_at: new Date(counter * 1000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- usable -----------------------------------------------------------------
|
||||
|
||||
describe('usable', () => {
|
||||
it('returns 0 when contextLimit is 0', () => {
|
||||
expect(usable(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when contextLimit is below the 20k buffer', () => {
|
||||
// Math.max(0, x - 20000) clamps the subtraction so we never report
|
||||
// negative headroom. A 10k-context model reports 0 usable, which makes
|
||||
// isOverflow short-circuit to false (correct — we can't size the
|
||||
// compaction with no headroom).
|
||||
expect(usable(10_000)).toBe(0);
|
||||
expect(usable(19_999)).toBe(0);
|
||||
expect(usable(20_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('subtracts the 20k buffer from a normal-sized context window', () => {
|
||||
expect(usable(100_000)).toBe(80_000);
|
||||
expect(usable(32_768)).toBe(12_768);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- isOverflow -------------------------------------------------------------
|
||||
|
||||
describe('isOverflow', () => {
|
||||
it('returns false when usable is 0 (unknown / sub-buffer context)', () => {
|
||||
expect(isOverflow({ prompt_tokens: 999_999, completion_tokens: 0 }, 0)).toBe(false);
|
||||
expect(isOverflow({ prompt_tokens: 0, completion_tokens: 999_999 }, 10_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false at 50% of usable', () => {
|
||||
// usable(100k) = 80k → 50% = 40k.
|
||||
expect(isOverflow({ prompt_tokens: 30_000, completion_tokens: 10_000 }, 100_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false just under usable', () => {
|
||||
expect(isOverflow({ prompt_tokens: 79_000, completion_tokens: 999 }, 100_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true exactly at usable (>=, not strict >)', () => {
|
||||
expect(isOverflow({ prompt_tokens: 80_000, completion_tokens: 0 }, 100_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true above usable', () => {
|
||||
expect(isOverflow({ prompt_tokens: 50_000, completion_tokens: 40_000 }, 100_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- estimate ---------------------------------------------------------------
|
||||
|
||||
describe('estimate', () => {
|
||||
it('returns a tiny value for an empty array (JSON.stringify([]) is "[]")', () => {
|
||||
// Math.ceil('[]'.length / 4) = 1. Documented here so the next reader
|
||||
// doesn't think "0" is the expected baseline — char-count/4 will never
|
||||
// be exactly 0 for any JSON-serializable input.
|
||||
expect(estimate([])).toBe(1);
|
||||
});
|
||||
|
||||
it('scales roughly with content length', () => {
|
||||
const tiny = estimate([mkMsg('user', 'hi')]);
|
||||
const big = estimate([mkMsg('user', 'x'.repeat(4000))]);
|
||||
expect(big).toBeGreaterThan(tiny);
|
||||
expect(big).toBeGreaterThanOrEqual(1000); // 4000 chars / 4 = 1000 floor
|
||||
});
|
||||
|
||||
it('is deterministic across repeated calls', () => {
|
||||
const msgs = [mkMsg('user', 'one'), mkMsg('assistant', 'two')];
|
||||
expect(estimate(msgs)).toBe(estimate(msgs));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- turns ------------------------------------------------------------------
|
||||
|
||||
describe('turns', () => {
|
||||
it('returns [] for an empty message list', () => {
|
||||
expect(turns([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns one turn for a single user message', () => {
|
||||
const u = mkMsg('user', 'hi');
|
||||
const result = turns([u]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({ start: 0, end: 1, id: u.id });
|
||||
});
|
||||
|
||||
it('returns two turns for user/assistant/user/assistant', () => {
|
||||
const u1 = mkMsg('user', 'q1');
|
||||
const a1 = mkMsg('assistant', 'a1');
|
||||
const u2 = mkMsg('user', 'q2');
|
||||
const a2 = mkMsg('assistant', 'a2');
|
||||
const result = turns([u1, a1, u2, a2]);
|
||||
expect(result).toEqual([
|
||||
{ start: 0, end: 2, id: u1.id },
|
||||
{ start: 2, end: 4, id: u2.id },
|
||||
]);
|
||||
});
|
||||
|
||||
it('extends the final turn end to include trailing non-user messages', () => {
|
||||
// Spec wording: "user/assistant + trailing system → trailing included
|
||||
// in last turn's range". Single-turn variant: [user, assistant, system]
|
||||
// should produce one turn with end=3 (covers all three indices).
|
||||
const u = mkMsg('user', 'q');
|
||||
const a = mkMsg('assistant', 'a');
|
||||
const s = mkMsg('system', 'note');
|
||||
const result = turns([u, a, s]);
|
||||
expect(result).toEqual([{ start: 0, end: 3, id: u.id }]);
|
||||
});
|
||||
|
||||
it('skips user rows flagged as summary (anchored-rolling rows)', () => {
|
||||
// Defense-in-depth — process() pre-filters summary rows, but turns()
|
||||
// also skips them so a misuse from another caller doesn't create a
|
||||
// bogus turn boundary on the summary row itself.
|
||||
const u1 = mkMsg('user', 'q1');
|
||||
const a1 = mkMsg('assistant', 'a1');
|
||||
const sum = mkMsg('user', 'rolled-up', { summary: true });
|
||||
const u2 = mkMsg('user', 'q2');
|
||||
const result = turns([u1, a1, sum, u2]);
|
||||
expect(result.map((t) => t.id)).toEqual([u1.id, u2.id]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- select -----------------------------------------------------------------
|
||||
|
||||
describe('select', () => {
|
||||
it('returns empty head + undefined tail for an empty message list', () => {
|
||||
const result = select([], 100_000);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.tail_start_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('full-preserves when there are fewer turns than tail_turns', () => {
|
||||
// 1 turn but tail_turns=2: keep === turn0 → keep.start === 0 →
|
||||
// sentinel-return path that signals "no compaction this round".
|
||||
const u = mkMsg('user', 'only');
|
||||
const a = mkMsg('assistant', 'a');
|
||||
const result = select([u, a], 100_000, 2);
|
||||
expect(result.head).toEqual([u, a]);
|
||||
expect(result.tail_start_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the last tail_turns turns when they all fit the budget', () => {
|
||||
// 3 turns, all small. tail_turns=2 means keep the last 2; head =
|
||||
// messages[0..turn2.start] = just turn1's content.
|
||||
const u1 = mkMsg('user', 'q1');
|
||||
const a1 = mkMsg('assistant', 'a1');
|
||||
const u2 = mkMsg('user', 'q2');
|
||||
const a2 = mkMsg('assistant', 'a2');
|
||||
const u3 = mkMsg('user', 'q3');
|
||||
const a3 = mkMsg('assistant', 'a3');
|
||||
const msgs = [u1, a1, u2, a2, u3, a3];
|
||||
const result = select(msgs, 100_000, 2);
|
||||
// Turn boundaries: [0,2), [2,4), [4,6). slice(-2) = turns at 2 and 4.
|
||||
// Walking backward: u3 fits, then u2 fits → keep={start:2, id:u2.id}.
|
||||
expect(result.tail_start_id).toBe(u2.id);
|
||||
expect(result.head).toEqual([u1, a1]);
|
||||
});
|
||||
|
||||
it('splits a turn mid-stream when the whole turn would overflow the budget', () => {
|
||||
// tail_turns=1 so we look only at the most recent turn. Stuff it past
|
||||
// 8k of content (max preserve budget) and the splitter walks forward
|
||||
// looking for the largest suffix that fits.
|
||||
const u1 = mkMsg('user', 'q1');
|
||||
const a1 = mkMsg('assistant', 'a1');
|
||||
const u2 = mkMsg('user', 'q2 with a giant payload');
|
||||
const huge = mkMsg('assistant', 'X'.repeat(40_000)); // ~10k tokens
|
||||
const smallTail = mkMsg('assistant', 'short answer');
|
||||
const msgs = [u1, a1, u2, huge, smallTail];
|
||||
const result = select(msgs, 100_000, 1);
|
||||
// The split walks from turn.start+1 forward; the first index whose
|
||||
// [i, end) slice fits the budget becomes the new keep. We don't assert
|
||||
// a specific id (depends on character math), only that compaction was
|
||||
// triggered (tail_start_id set, head non-empty) and that the head
|
||||
// doesn't include the final small message.
|
||||
expect(result.tail_start_id).toBeDefined();
|
||||
expect(result.head.length).toBeGreaterThan(0);
|
||||
expect(result.head).not.toContain(smallTail);
|
||||
});
|
||||
|
||||
it('full-preserves when no split point fits', () => {
|
||||
// Single oversized turn; splitTurn walks but each suffix is still too
|
||||
// big. After the loop, keep is undefined → full-preserve sentinel.
|
||||
// Force this with a sub-buffer context so budget is the floor (2k),
|
||||
// and a single 40k-char message.
|
||||
const u = mkMsg('user', 'oversized');
|
||||
const a = mkMsg('assistant', 'Y'.repeat(40_000));
|
||||
const result = select([u, a], 30_000, 1);
|
||||
// usable(30k) = 10k → budget = min(8k, max(2k, floor(10k*0.25))) =
|
||||
// min(8k, max(2k, 2500)) = 2500. 40k chars ≈ 10k tokens. Can't fit.
|
||||
expect(result.tail_start_id).toBeUndefined();
|
||||
expect(result.head).toEqual([u, a]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPrompt ------------------------------------------------------------
|
||||
|
||||
describe('buildPrompt', () => {
|
||||
it('opens with the "create new" anchor when previousSummary is undefined', () => {
|
||||
const out = buildPrompt(undefined, []);
|
||||
expect(out.startsWith('Create a new anchored summary')).toBe(true);
|
||||
expect(out).toContain(SUMMARY_TEMPLATE);
|
||||
expect(out).not.toContain('<previous-summary>');
|
||||
});
|
||||
|
||||
it('opens with the "update" anchor and embeds previousSummary verbatim', () => {
|
||||
const prev = '## Goal\n- finish v1.11 compaction';
|
||||
const out = buildPrompt(prev, []);
|
||||
expect(out.startsWith('Update the anchored summary')).toBe(true);
|
||||
expect(out).toContain('<previous-summary>');
|
||||
expect(out).toContain(prev);
|
||||
expect(out).toContain('</previous-summary>');
|
||||
expect(out).toContain(SUMMARY_TEMPLATE);
|
||||
});
|
||||
|
||||
it('appends extra context strings after the template (reserved for plugin injection)', () => {
|
||||
const out = buildPrompt(undefined, ['extra-context-line']);
|
||||
expect(out.endsWith('extra-context-line')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user