Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions electron/ai-edition/chat-compaction.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import type { AiEditionChatMessage } from "../../src/native/contracts";
import {
applyCompaction,
budgetSnapshot,
buildCompactionPrompt,
compactionReducesHistory,
compactionSplitIndex,
estimateHistoryTokens,
shouldCompact,
} from "./chat-compaction";

function msg(
Expand Down Expand Up @@ -51,30 +51,31 @@ describe("budgetSnapshot", () => {
});
});

describe("shouldCompact", () => {
beforeEach(() => undefined);

describe("compactionSplitIndex", () => {
it("returns null for very short histories", () => {
expect(shouldCompact([msg("user", "hi")])).toBeNull();
});

it("returns null when under the threshold", () => {
const small = Array.from({ length: 6 }, (_, i) =>
msg(i % 2 ? "assistant" : "user", `short-${i}`),
);
expect(shouldCompact(small, 1_000_000)).toBeNull();
expect(compactionSplitIndex([msg("user", "hi")])).toBeNull();
});

it("compacts on a user-message boundary near the midpoint", () => {
const msgs: AiEditionChatMessage[] = [];
for (let i = 0; i < 10; i += 1) {
msgs.push(msg(i % 2 ? "assistant" : "user", `turn-${i}-${"x".repeat(800)}`));
}
const out = shouldCompact(msgs, 50);
const out = compactionSplitIndex(msgs);
expect(out).not.toBeNull();
expect(out?.compact).toBe(true);
// boundary must be a user message
expect(msgs[out!.splitIndex]?.role).toBe("user");
expect(msgs[out as number]?.role).toBe("user");
});

it("does not consult any token budget — a tiny history still splits", () => {
// The regression this pins: compaction used to refuse below 70% of a
// guessed 80k-token budget, which gated the manual button too, so
// pressing Compact on a short conversation did nothing at all and said
// nothing about why. The app cannot know a model's context window, so
// there is no threshold left to be wrong about.
const tiny = Array.from({ length: 6 }, (_, i) => msg(i % 2 ? "assistant" : "user", "hi"));
expect(estimateHistoryTokens(tiny)).toBeLessThan(100);
expect(compactionSplitIndex(tiny)).not.toBeNull();
});
});

Expand Down
45 changes: 25 additions & 20 deletions electron/ai-edition/chat-compaction.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
// Context-budget heuristic + message-history compaction. Mirrors axcut's
// "compact-on-overflow" approach in spirit (sliding window with summary),
// but kept simple: char-based token estimate + a manual Compact button.
// Message-history compaction: fold the older half of a conversation into one
// "Earlier context" summary. The summary is an LLM call (no tools, plain text)
// using the active provider.
//
// Chat-service calls `shouldCompact` before each new turn; when the heuristic
// trips, `compactHistory` summarizes the older half of the conversation and
// returns a new history list to feed the model. The summary itself is an
// LLM call (no tools, plain text → JSON summary) using the active provider.
// ponytail: compaction is MANUAL ONLY, and there is no overflow heuristic.
// There used to be one — compact automatically once the history passed 70% of
// `DEFAULT_BUDGET_TOKENS = 80_000`. That 80k was invented: the app has no
// per-model context window, so the number could not be right for anything. It
// was far too small for Gemini's 1M window (throwing away context at 5% fill,
// and paying a blocking summarizer call to do it) and would be too large for
// something small. It is the same mistake `getTranscript` made with its 800
// segments, and it gets the same answer: a guessed limit is deleted, not
// retuned. Until the app can ask a provider for the real window, the only
// honest trigger is a person deciding they want it, which is the button.

import type { AiEditionChatMessage } from "../../src/native/contracts";

Expand All @@ -22,8 +28,10 @@ export interface CompactionBudget {
}

/**
* Default budget. Real providers run 100k+ contexts, but we leave headroom
* for tool-call payload + system prompt. Adjust per provider if needed.
* The denominator of the context pill in the chat panel, and nothing else —
* no code branches on it any more. It is still a made-up number, so it must
* never regain a decision: read it as "the conversation is about this big",
* not as "you are this close to a limit".
*/
export const DEFAULT_BUDGET_TOKENS = 80_000;

Expand Down Expand Up @@ -52,25 +60,22 @@ export function budgetSnapshot(
}

/**
* Decide whether the chat history should be compacted before the next turn.
* Returns the boundary index where compaction should cut (older half).
* Where a compaction should cut, or `null` when there is nothing to fold.
*
* The only refusal left is "fewer than 4 messages": that is not a guess about
* anyone's context window, it is that summarizing one exchange into a summary
* cannot make it shorter. Everything else is the caller's decision.
*/
export function shouldCompact(
messages: AiEditionChatMessage[],
budgetTokens: number = DEFAULT_BUDGET_TOKENS,
thresholdRatio = 0.7,
): { compact: boolean; splitIndex: number } | null {
export function compactionSplitIndex(messages: AiEditionChatMessage[]): number | null {
if (messages.length < 4) return null;
const budget = budgetSnapshot(messages, budgetTokens);
if (budget.ratio < thresholdRatio) return null;

// Split roughly in half. Snap to a user-message boundary so the model
// doesn't see a half-turn after compaction.
const split = Math.floor(messages.length / 2);
for (let i = split; i < messages.length; i += 1) {
if (messages[i]?.role === "user") return { compact: true, splitIndex: i };
if (messages[i]?.role === "user") return i;
}
return { compact: true, splitIndex: split };
return split;
}

/**
Expand Down
103 changes: 74 additions & 29 deletions electron/ai-edition/chat-service.compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ function stubSummarizer(reply: string) {
return invoke;
}

// Long enough that four of them clear the 70%-of-80k-tokens trip point, short
// enough that three of them do not.
// Deliberately huge: these used to be sized against the 70%-of-80k trip point,
// and they stay huge for the opposite reason — a history this big is the case
// that USED to compact itself, so it is the one that proves nothing does now.
const LONG = "x".repeat(60_000);

beforeEach(() => {
Expand All @@ -63,13 +64,36 @@ beforeEach(() => {
});
});

describe("auto-compaction", () => {
it("leaves the transcript whole and compacts only what the model is given", async () => {
describe("compaction", () => {
it("NEVER runs on its own, however big the history gets", async () => {
// The headline rule. A turn used to measure the history against a
// guessed 80k-token budget and, past 70% of it, block on a whole extra
// summarizer call before the user's request was even sent. The app has
// no way to ask a provider how big its context window is, so that number
// could not be right for anything — it threw away context at 5% fill on
// a 1M-token Gemini. Six turns here estimate at ~90k tokens, comfortably
// past the old trip point.
const summarizer = stubSummarizer("EARLIER CONTEXT");
const session = createSession("proj_no_auto");
for (let i = 0; i < 6; i += 1) {
await runChat("proj_no_auto", session.id, `${LONG}#${i}`, stubConfig());
}

expect(getSessionContextUsage("proj_no_auto", session.id)?.usedTokens).toBeGreaterThan(56_000);
expect(summarizer).not.toHaveBeenCalled();
// And nothing was folded away behind the user's back.
const history = histories.at(-1) ?? [];
expect(history.some((m) => m.content === `${LONG}#0`)).toBe(true);
expect(history.at(-1)?.content).toBe(`${LONG}#5`);
});

it("compacts on the button, and leaves the transcript whole", async () => {
stubSummarizer("EARLIER CONTEXT");
const session = createSession("proj_compact_transcript");
for (let i = 0; i < 4; i += 1) {
await runChat("proj_compact_transcript", session.id, `${LONG}#${i}`, stubConfig());
}
await compactSessionNow("proj_compact_transcript", session.id, stubConfig());

// Four user turns, four replies, nothing deleted: this array is what the
// renderer shows, and the user never asked for half of it to go away.
Expand All @@ -78,26 +102,46 @@ describe("auto-compaction", () => {
expect(transcript[0]?.content).toBe(`${LONG}#0`);
expect(transcript.filter((m) => m.role === "user")).toHaveLength(4);

// The fourth turn is the one that tripped the budget: the model got the
// summary in place of the older half, not the whole conversation.
// The next turn gets the summary in place of the older half.
await runChat("proj_compact_transcript", session.id, "and then?", stubConfig());
const history = histories.at(-1) ?? [];
expect(history[0]?.content).toBe("EARLIER CONTEXT");
expect(history).toHaveLength(4);
expect(history.some((m) => m.content === `${LONG}#0`)).toBe(false);
expect(history.at(-1)?.content).toBe(`${LONG}#3`);
expect(history.at(-1)?.content).toBe("and then?");

// The context pill measures the payload, so compaction actually shows up:
// the whole transcript estimates at ~60k tokens, the payload at half.
// The context pill measures the payload, so compaction shows up there.
const usage = getSessionContextUsage("proj_compact_transcript", session.id);
expect(usage?.usedTokens).toBeLessThan(40_000);
});

it("compacts an ORDINARY conversation — the button is not gated by a budget", async () => {
// The same guessed budget gated the manual path: `compactSessionNow`
// went through the same heuristic, so below 70% of 80k the button did
// nothing at all, silently. This session is ~3k tokens — a perfectly
// normal chat, roughly 5% of the old trip point, and exactly the size at
// which the button used to be a no-op. Pressing it is the decision now;
// there is no number left to overrule it.
const summarizer = stubSummarizer("EARLIER CONTEXT");
const session = createSession("proj_compact_short");
const paragraph = "a".repeat(2_000);
for (let i = 0; i < 3; i += 1) {
await runChat("proj_compact_short", session.id, `${paragraph}#${i}`, stubConfig());
}
const used = getSessionContextUsage("proj_compact_short", session.id)?.usedTokens ?? 0;
expect(used).toBeLessThan(56_000 / 10);

const manual = await compactSessionNow("proj_compact_short", session.id, stubConfig());
expect(summarizer).toHaveBeenCalledTimes(1);
expect(manual?.summary).toBe("EARLIER CONTEXT");
});

it("keeps the summary in the payload when the tail is longer than the window", async () => {
stubSummarizer("EARLIER CONTEXT");
const session = createSession("proj_compact_window");
for (let i = 0; i < 30; i += 1) {
await runChat("proj_compact_window", session.id, `turn ${i}`, stubConfig());
}
await compactSessionNow("proj_compact_window", session.id, stubConfig());
const huge = LONG.repeat(4);
await runChat("proj_compact_window", session.id, huge, stubConfig());

Expand All @@ -109,47 +153,48 @@ describe("auto-compaction", () => {
expect(history.at(-1)?.content).toBe(huge);
});

it("stops retrying after a summary that does not shrink the payload", async () => {
it("refuses a summary that does not shrink the payload, and keeps the session", async () => {
const oversized = stubSummarizer("z".repeat(400_000));
const session = createSession("proj_compact_blocked");
for (let i = 0; i < 5; i += 1) {
await runChat("proj_compact_blocked", session.id, `${LONG}#${i}`, stubConfig());
}

// Two more turns tripped the heuristic after the failure; neither paid
// for another summarizer call.
// Adopting a summary longer than what it replaces would grow the payload.
// The session is left exactly as it was. (There is no "stop retrying"
// flag any more: nothing retries on its own, so the only next attempt is
// another press, which is the user asking again knowingly.)
expect(await compactSessionNow("proj_compact_blocked", session.id, stubConfig())).toBeNull();
expect(oversized).toHaveBeenCalledTimes(1);
const history = histories.at(-1) ?? [];
expect(history.some((m) => m.content === "EARLIER CONTEXT")).toBe(false);
expect(selectSession("proj_compact_blocked", session.id)?.messages).toHaveLength(10);

// The Compact button is an explicit request, so it tries again — and a
// success unblocks the automatic path.
await runChat("proj_compact_blocked", session.id, "and then?", stubConfig());
expect(histories.at(-1)?.some((m) => m.content === "EARLIER CONTEXT")).toBe(false);

// A second press with a usable summary lands.
const usable = stubSummarizer("EARLIER CONTEXT");
const manual = await compactSessionNow("proj_compact_blocked", session.id, stubConfig());
expect(usable).toHaveBeenCalledTimes(1);
expect(manual?.summary).toBe("EARLIER CONTEXT");
expect(manual?.session.messages).toHaveLength(10);
expect(manual?.session.messages).toHaveLength(12);

await runChat("proj_compact_blocked", session.id, "and then?", stubConfig());
await runChat("proj_compact_blocked", session.id, "and after that?", stubConfig());
expect(histories.at(-1)?.[0]?.content).toBe("EARLIER CONTEXT");
});

// The regression this guards is `planCompaction` measuring the wrong list.
// `splitIndex` comes back from `shouldCompact` as an index INTO WHAT IT WAS
// GIVEN, and it is then applied to the payload. Measure the transcript
// instead — which never shrinks, so it keeps tripping — and the index runs
// off the end of the much shorter payload, so `payload.slice(0, splitIndex)`
// swallows the whole thing, current user turn included. The model is then
// asked to answer a question it was never shown.
//
// Three turns is not enough to see it: the collapse needs a payload that has
// already been compacted at least once, so the two lists have diverged.
// `splitIndex` comes back as an index INTO WHAT WAS MEASURED, and it is then
// applied to the payload. Measure the transcript instead — which compaction
// never shrinks — and the index runs off the end of the much shorter
// payload, so `payload.slice(0, splitIndex)` swallows the whole thing, the
// current user turn included, and the model is asked to answer a question it
// was never shown. Repeated compactions are what make the two lists diverge,
// so the button is pressed between every turn here.
it("never summarizes away the turn the user just sent", async () => {
stubSummarizer("EARLIER CONTEXT");
const session = createSession("proj_compact_current_turn");
for (let i = 0; i < 10; i += 1) {
await runChat("proj_compact_current_turn", session.id, `${LONG}#${i}`, stubConfig());
await compactSessionNow("proj_compact_current_turn", session.id, stubConfig());
}

// Every turn, not just the last: the collapse is intermittent, so a
Expand Down
Loading
Loading