diff --git a/electron/ai-edition/chat-compaction.test.ts b/electron/ai-edition/chat-compaction.test.ts index 5c9c17bd3..838458dda 100644 --- a/electron/ai-edition/chat-compaction.test.ts +++ b/electron/ai-edition/chat-compaction.test.ts @@ -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( @@ -51,18 +51,9 @@ 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", () => { @@ -70,11 +61,21 @@ describe("shouldCompact", () => { 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(); }); }); diff --git a/electron/ai-edition/chat-compaction.ts b/electron/ai-edition/chat-compaction.ts index 1b11b5e32..5be23d87b 100644 --- a/electron/ai-edition/chat-compaction.ts +++ b/electron/ai-edition/chat-compaction.ts @@ -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"; @@ -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; @@ -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; } /** diff --git a/electron/ai-edition/chat-service.compaction.test.ts b/electron/ai-edition/chat-service.compaction.test.ts index ee9427844..2489398c5 100644 --- a/electron/ai-edition/chat-service.compaction.test.ts +++ b/electron/ai-edition/chat-service.compaction.test.ts @@ -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(() => { @@ -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. @@ -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()); @@ -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 diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index eec200364..89c1d78e2 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -27,8 +27,8 @@ import { buildCompactionPrompt, COMPACTION_SYSTEM_PROMPT, compactionReducesHistory, + compactionSplitIndex, DEFAULT_BUDGET_TOKENS, - shouldCompact, } from "./chat-compaction"; import type { CursorTelemetryReader } from "./deep-agent/service"; import type { DocumentService } from "./document-service"; @@ -133,10 +133,6 @@ export interface ChatSession { /** Main-process bookkeeping: the compaction boundary, not part of the * transcript. See `modelMessages`. */ compaction?: SessionCompaction; - /** Set when a summarize call came back no smaller than what it replaced. - * Auto-compaction then stops trying — otherwise every following turn pays - * for the same useless summarizer call. */ - compactionBlocked?: boolean; } /** The message list compaction hands the model: summary first, then everything @@ -368,22 +364,14 @@ export async function runChat( const editsAllowed = config.allowAgentEdits !== false; - // P3.7 — context compaction: when what we send the model grows past the - // heuristic budget, summarize the older half into a single "Earlier - // context" assistant message. The current user turn stays uncompacted, so - // the model still sees the request verbatim. - const plan = session.compactionBlocked ? null : planCompaction(session); - if (plan) { - await tryCompactSession({ - session, - plan, - apiKey: apiKey ?? "", - provider: config.provider, - model: config.model, - baseUrl: config.baseUrl, - reasoningEffort: config.reasoningEffort, - }); - } + // ponytail: NO automatic compaction here. A turn used to first check 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 cannot ask a provider how big its context window is, so that + // budget was a number someone picked — wrong by an order of magnitude for a + // 1M-token Gemini, and silently discarding context the model could have + // held. Compaction is now only ever what the user asked for by pressing the + // button. See chat-compaction.ts. const history = modelHistory(session).map((m) => ({ role: m.role as "user" | "assistant" | "system", @@ -608,9 +596,9 @@ export async function compactSessionNow( const credential = llmConfig.getCredential(def.id, def.envKeys); const apiKey = credential?.value ?? ""; - // The button is an explicit request, so it ignores `compactionBlocked` — - // the user knows they are spending a summarizer call, and a success clears - // the flag for the automatic path too. + // Pressing the button IS the decision — nothing here second-guesses it + // against a budget. It only declines when there is genuinely nothing to + // fold (fewer than 4 messages), which is the one refusal left. const plan = planCompaction(session); if (!plan) return null; @@ -729,25 +717,21 @@ interface CompactionPlan { } /** - * Decide whether the next turn should compact, measuring the payload rather - * than the transcript. Measuring the transcript would re-trip on every turn - * for the rest of the session, since compaction no longer shrinks it. + * Where a compaction would cut, measured against the payload rather than the + * transcript — compaction never rewrites the transcript, so the split index + * has to be an index into the list it will actually be applied to. * * A second compaction folds the previous summary into the new one: it sits at * `payload[0]`, so it is part of the prefix being summarized. */ function planCompaction(session: ChatSession): CompactionPlan | null { const payload = modelMessages(session); - const decision = shouldCompact(payload); - if (!decision?.compact || decision.splitIndex <= 0) return null; + const splitIndex = compactionSplitIndex(payload); + if (splitIndex === null || splitIndex <= 0) return null; // Payload index → transcript index. With a summary in front, payload[i] // is transcript message `coveredCount + i - 1`. const offset = session.compaction ? session.compaction.coveredCount - 1 : 0; - return { - payload, - splitIndex: decision.splitIndex, - coveredCount: decision.splitIndex + offset, - }; + return { payload, splitIndex, coveredCount: splitIndex + offset }; } /** @@ -804,15 +788,14 @@ async function tryCompactSession(opts: { const summaryMessage = compacted[0]; if (!summaryMessage) return null; if (!compactionReducesHistory(plan.payload, compacted)) { - // ponytail: the model handed back a summary at least as long as the - // messages it replaced. Adopting it would grow the payload, and - // retrying next turn just buys the same answer again — so stop asking - // until the user compacts by hand. - session.compactionBlocked = true; + // The model handed back a summary at least as long as the messages it + // replaced: adopting it would grow the payload. Refuse it and leave the + // session alone. (There is no "stop trying" flag any more — nothing + // retries on its own, so the only next attempt is another button press, + // which is the user asking again knowingly.) return null; } session.compaction = { summary: summaryMessage, coveredCount: plan.coveredCount }; - session.compactionBlocked = false; return { summaryMessageId: summaryMessage.id, summary, diff --git a/src/components/ai-edition/chatBudget.ts b/src/components/ai-edition/chatBudget.ts index 594352555..912c91877 100644 --- a/src/components/ai-edition/chatBudget.ts +++ b/src/components/ai-edition/chatBudget.ts @@ -1,10 +1,12 @@ // Renderer-side budget helper. Mirrors `electron/ai-edition/chat-compaction.ts` // but inline so we don't drag electron/ into the renderer bundle. // -// ponytail: 4 chars per token is plenty accurate for a "should we compact yet" -// gate. The estimation lives in the main process too (chat-service -// `getSessionBudget`); this duplicate is a small price for keeping the -// renderer free of electron/ imports. +// This feeds the context pill and NOTHING else — no code decides anything from +// it. `DEFAULT_CHAT_BUDGET_TOKENS` is a made-up denominator (the app has no way +// to ask a provider how big its context window is), which is exactly why the +// automatic compaction that used to branch on the main-process twin is gone. +// Read the pill as "the conversation is about this big", never as "you are this +// close to a limit", and do not let this number regain a decision. const CHARS_PER_TOKEN = 4;