From b16259dc4cc912bf491e8d2cb71986c61fa33d67 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sun, 9 Aug 2026 00:04:57 +0530 Subject: [PATCH 1/5] fix(ai): report compacted model context --- src/components/ai-edition/LeftPanel.tsx | 10 +- src/components/ai-edition/chatBudget.ts | 16 ++- .../ai-edition/useChatBudget.test.ts | 107 ++++++++++++++++++ src/components/ai-edition/useChatBudget.ts | 45 ++++++++ 4 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 src/components/ai-edition/useChatBudget.test.ts create mode 100644 src/components/ai-edition/useChatBudget.ts diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 1942d1da2..3695a938d 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -27,11 +27,11 @@ import { } from "../../../electron/ai-edition/provider-registry"; import { ChatWelcome } from "./ChatWelcome"; import { canSendChat } from "./chatAvailability"; -import { computeBudget } from "./chatBudget"; import { ChatHistoryModal, SourceTranscriptModal } from "./Modals"; import styles from "./NewEditorShell.module.css"; import { ProviderSettings } from "./ProviderSettings"; import { TranscriptionStatusDot } from "./TranscriptionStatus"; +import { useChatBudget } from "./useChatBudget"; export type LeftTab = "chat" | "media"; @@ -1141,10 +1141,10 @@ function ChatStripPanel() { }); }, [llmConfig]); - // Real context usage — feeds the badge in the chat strip and gates the - // auto-compact heuristic on the main side. Recomputed on every messages - // change so the % tracks the live history. - const budget = computeBudget(messages); + // Prefer the main process's model-message budget so manual compaction can + // shrink this meter while the complete transcript remains visible. The hook + // falls back to a renderer estimate in browser/shim or bridge-failure cases. + const budget = useChatBudget({ projectId, sessionId: activeSessionId, messages }); const [compactNowPending, setCompactNowPending] = useState(false); const compactNow = useCallback(async () => { diff --git a/src/components/ai-edition/chatBudget.ts b/src/components/ai-edition/chatBudget.ts index 912c91877..9f76190d9 100644 --- a/src/components/ai-edition/chatBudget.ts +++ b/src/components/ai-edition/chatBudget.ts @@ -1,12 +1,10 @@ // Renderer-side budget helper. Mirrors `electron/ai-edition/chat-compaction.ts` // but inline so we don't drag electron/ into the renderer bundle. // -// 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. +// This is the renderer fallback for the context pill while native usage is +// loading or unavailable. Desktop builds replace it with the main process's +// model-message estimate, which understands compaction; no code makes an +// automatic compaction decision from either value. const CHARS_PER_TOKEN = 4; @@ -18,12 +16,12 @@ export interface ChatBudget { const DEFAULT_CHAT_BUDGET_TOKENS = 80_000; -interface RenderableChatMessage { +export interface RenderableChatMessage { content: string; toolCalls?: Array<{ name?: string; summary?: string }>; } -function estimateTokens(messages: RenderableChatMessage[]): number { +function estimateTokens(messages: readonly RenderableChatMessage[]): number { let chars = 0; for (const m of messages) { chars += m.content.length; @@ -35,7 +33,7 @@ function estimateTokens(messages: RenderableChatMessage[]): number { } export function computeBudget( - messages: RenderableChatMessage[], + messages: readonly RenderableChatMessage[], budgetTokens: number = DEFAULT_CHAT_BUDGET_TOKENS, ): ChatBudget { const used = estimateTokens(messages); diff --git a/src/components/ai-edition/useChatBudget.test.ts b/src/components/ai-edition/useChatBudget.test.ts new file mode 100644 index 000000000..33718d22e --- /dev/null +++ b/src/components/ai-edition/useChatBudget.test.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useChatBudget } from "./useChatBudget"; + +const chatBudgetMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/native/client", () => ({ + nativeBridgeClient: { + aiEdition: { chatBudget: chatBudgetMock }, + }, +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +const message = (content: string) => [{ content }]; + +describe("useChatBudget", () => { + beforeEach(() => chatBudgetMock.mockReset()); + + it("uses the transcript estimate until native model-context usage arrives", async () => { + const native = deferred<{ + usedTokens: number; + budgetTokens: number; + ratio: number; + fillPercent: number; + }>(); + chatBudgetMock.mockReturnValue(native.promise); + const visibleMessages = message("x".repeat(400)); + + const { result } = renderHook(() => + useChatBudget({ projectId: "project_1", sessionId: "session_1", messages: visibleMessages }), + ); + expect(result.current.usedTokens).toBe(100); + + act(() => + native.resolve({ usedTokens: 12, budgetTokens: 80_000, ratio: 0.00015, fillPercent: 0.015 }), + ); + await waitFor(() => expect(result.current.usedTokens).toBe(12)); + }); + + it("ignores a late response from the previously selected session", async () => { + const first = deferred<{ + usedTokens: number; + budgetTokens: number; + ratio: number; + fillPercent: number; + }>(); + const second = deferred<{ + usedTokens: number; + budgetTokens: number; + ratio: number; + fillPercent: number; + }>(); + chatBudgetMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const visibleMessages = message("visible transcript"); + + const { result, rerender } = renderHook( + ({ sessionId }) => + useChatBudget({ projectId: "project_1", sessionId, messages: visibleMessages }), + { initialProps: { sessionId: "session_1" } }, + ); + rerender({ sessionId: "session_2" }); + act(() => + second.resolve({ usedTokens: 20, budgetTokens: 80_000, ratio: 0.00025, fillPercent: 0.025 }), + ); + await waitFor(() => expect(result.current.usedTokens).toBe(20)); + + act(() => + first.resolve({ usedTokens: 999, budgetTokens: 80_000, ratio: 0.012, fillPercent: 1.2 }), + ); + await act(async () => Promise.resolve()); + expect(result.current.usedTokens).toBe(20); + }); + + it("refreshes native usage when compaction returns a new transcript array", async () => { + chatBudgetMock + .mockResolvedValueOnce({ + usedTokens: 500, + budgetTokens: 80_000, + ratio: 0.00625, + fillPercent: 0.625, + }) + .mockResolvedValueOnce({ + usedTokens: 40, + budgetTokens: 80_000, + ratio: 0.0005, + fillPercent: 0.05, + }); + const visibleMessages = message("the transcript remains visible"); + const { result, rerender } = renderHook( + ({ messages }) => useChatBudget({ projectId: "project_1", sessionId: "session_1", messages }), + { initialProps: { messages: visibleMessages } }, + ); + await waitFor(() => expect(result.current.usedTokens).toBe(500)); + + rerender({ messages: [...visibleMessages] }); + await waitFor(() => expect(result.current.usedTokens).toBe(40)); + expect(chatBudgetMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/components/ai-edition/useChatBudget.ts b/src/components/ai-edition/useChatBudget.ts new file mode 100644 index 000000000..01c0ae9ef --- /dev/null +++ b/src/components/ai-edition/useChatBudget.ts @@ -0,0 +1,45 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { nativeBridgeClient } from "@/native/client"; +import { type ChatBudget, computeBudget, type RenderableChatMessage } from "./chatBudget"; + +interface NativeBudgetState { + sessionKey: string; + budget: ChatBudget; +} + +export function useChatBudget(options: { + projectId: string | null; + sessionId: string | null; + messages: readonly RenderableChatMessage[]; +}): ChatBudget { + const { projectId, sessionId, messages } = options; + const fallback = useMemo(() => computeBudget(messages), [messages]); + const sessionKey = projectId && sessionId ? `${projectId}\0${sessionId}` : null; + const [nativeState, setNativeState] = useState(null); + const requestIdRef = useRef(0); + + useEffect(() => { + const requestId = ++requestIdRef.current; + if (!projectId || !sessionId || !sessionKey) { + setNativeState(null); + return; + } + + void nativeBridgeClient.aiEdition + .chatBudget(projectId, sessionId) + .then((budget) => { + if (requestIdRef.current !== requestId) return; + setNativeState({ sessionKey, budget: budget ?? fallback }); + }) + .catch(() => { + if (requestIdRef.current !== requestId) return; + setNativeState({ sessionKey, budget: fallback }); + }); + + return () => { + if (requestIdRef.current === requestId) requestIdRef.current++; + }; + }, [projectId, sessionId, sessionKey, fallback]); + + return nativeState?.sessionKey === sessionKey ? nativeState.budget : fallback; +} From afc22a04c4dcb11e3cf7d00c5823a2f0f14fe6b2 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Mon, 10 Aug 2026 05:08:58 +0530 Subject: [PATCH 2/5] test(editor): cover chat budget fallbacks --- .../ai-edition/useChatBudget.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/components/ai-edition/useChatBudget.test.ts b/src/components/ai-edition/useChatBudget.test.ts index 33718d22e..e87206fef 100644 --- a/src/components/ai-edition/useChatBudget.test.ts +++ b/src/components/ai-edition/useChatBudget.test.ts @@ -45,6 +45,40 @@ describe("useChatBudget", () => { await waitFor(() => expect(result.current.usedTokens).toBe(12)); }); + it("keeps the transcript estimate when no session is selected", () => { + const visibleMessages = message("x".repeat(400)); + const { result } = renderHook(() => + useChatBudget({ projectId: "project_1", sessionId: null, messages: visibleMessages }), + ); + + expect(result.current.usedTokens).toBe(100); + expect(chatBudgetMock).not.toHaveBeenCalled(); + }); + + it("keeps the transcript estimate when native usage is unavailable", async () => { + chatBudgetMock.mockResolvedValue(undefined); + const visibleMessages = message("x".repeat(400)); + const { result } = renderHook(() => + useChatBudget({ projectId: "project_1", sessionId: "session_1", messages: visibleMessages }), + ); + + await waitFor(() => expect(chatBudgetMock).toHaveBeenCalledTimes(1)); + await act(async () => Promise.resolve()); + expect(result.current.usedTokens).toBe(100); + }); + + it("keeps the transcript estimate when native usage rejects", async () => { + chatBudgetMock.mockRejectedValue(new Error("native budget unavailable")); + const visibleMessages = message("x".repeat(400)); + const { result } = renderHook(() => + useChatBudget({ projectId: "project_1", sessionId: "session_1", messages: visibleMessages }), + ); + + await waitFor(() => expect(chatBudgetMock).toHaveBeenCalledTimes(1)); + await act(async () => Promise.resolve()); + expect(result.current.usedTokens).toBe(100); + }); + it("ignores a late response from the previously selected session", async () => { const first = deferred<{ usedTokens: number; From 8fab81da760da1f2e75b6b8c0b42fc8c75e5fb6e Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Mon, 10 Aug 2026 05:13:53 +0530 Subject: [PATCH 3/5] test(editor): defer budget rejection --- src/components/ai-edition/useChatBudget.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/ai-edition/useChatBudget.test.ts b/src/components/ai-edition/useChatBudget.test.ts index e87206fef..f7d2b625d 100644 --- a/src/components/ai-edition/useChatBudget.test.ts +++ b/src/components/ai-edition/useChatBudget.test.ts @@ -13,10 +13,12 @@ vi.mock("@/native/client", () => ({ function deferred() { let resolve!: (value: T) => void; - const promise = new Promise((done) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((done, fail) => { resolve = done; + reject = fail; }); - return { promise, resolve }; + return { promise, resolve, reject }; } const message = (content: string) => [{ content }]; @@ -68,14 +70,15 @@ describe("useChatBudget", () => { }); it("keeps the transcript estimate when native usage rejects", async () => { - chatBudgetMock.mockRejectedValue(new Error("native budget unavailable")); + const native = deferred(); + chatBudgetMock.mockReturnValue(native.promise); const visibleMessages = message("x".repeat(400)); const { result } = renderHook(() => useChatBudget({ projectId: "project_1", sessionId: "session_1", messages: visibleMessages }), ); await waitFor(() => expect(chatBudgetMock).toHaveBeenCalledTimes(1)); - await act(async () => Promise.resolve()); + await act(async () => native.reject(new Error("native budget unavailable"))); expect(result.current.usedTokens).toBe(100); }); From 62995dff4a2375537f18479ec7ab6c4916da2787 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Mon, 10 Aug 2026 05:23:06 +0530 Subject: [PATCH 4/5] test(editor): await rejected budget fallback --- .../ai-edition/useChatBudget.test.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/components/ai-edition/useChatBudget.test.ts b/src/components/ai-edition/useChatBudget.test.ts index f7d2b625d..2c4c87064 100644 --- a/src/components/ai-edition/useChatBudget.test.ts +++ b/src/components/ai-edition/useChatBudget.test.ts @@ -69,17 +69,24 @@ describe("useChatBudget", () => { expect(result.current.usedTokens).toBe(100); }); - it("keeps the transcript estimate when native usage rejects", async () => { - const native = deferred(); - chatBudgetMock.mockReturnValue(native.promise); - const visibleMessages = message("x".repeat(400)); - const { result } = renderHook(() => - useChatBudget({ projectId: "project_1", sessionId: "session_1", messages: visibleMessages }), + it("falls back to the transcript estimate when native usage rejects", async () => { + chatBudgetMock + .mockResolvedValueOnce({ + usedTokens: 12, + budgetTokens: 80_000, + ratio: 0.00015, + fillPercent: 0.015, + }) + .mockRejectedValueOnce(new Error("native budget unavailable")); + const { result, rerender } = renderHook( + ({ messages }) => useChatBudget({ projectId: "project_1", sessionId: "session_1", messages }), + { initialProps: { messages: message("x".repeat(400)) } }, ); + await waitFor(() => expect(result.current.usedTokens).toBe(12)); - await waitFor(() => expect(chatBudgetMock).toHaveBeenCalledTimes(1)); - await act(async () => native.reject(new Error("native budget unavailable"))); - expect(result.current.usedTokens).toBe(100); + rerender({ messages: message("x".repeat(800)) }); + await waitFor(() => expect(result.current.usedTokens).toBe(200)); + expect(chatBudgetMock).toHaveBeenCalledTimes(2); }); it("ignores a late response from the previously selected session", async () => { From d7253968b552ab04f3ea2151bd79be90c5f9d783 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 12:18:59 +0200 Subject: [PATCH 5/5] fix(ai): measure the history the model is actually sent The plumbing was right; the number was measuring the wrong thing. `getSessionContextUsage` billed `modelMessages` -- the whole transcript after the compaction boundary -- while `runChat` sends `modelHistory`, windowed to the last 20 messages. On a 40-message session the model got 20 and the pill reported 40. Press Compact and the pill roughly halved, while `modelHistory` went from "last 20 raw" to "summary + last 19", which is essentially unchanged. The meter dropped by half for a compaction that changed almost nothing about the payload -- the same class of wrong number this change was written to remove, just relocated to the main process. Its own comment, "measured on what the model is given", was false above 20 messages. Both budget entry points use `modelHistory` now. Tool-call names and summaries were billed too, and `chat-service` strips them before sending. They no longer count, on either side of the bridge. Three things about the renderer fallback, which was substituting a different quantity rather than a degraded one: On a rejected or empty refresh the hook installed the raw-transcript estimate. After a compaction that is roughly double the native number, so one failed IPC made the compaction visibly un-happen, with nothing saying anything had failed, and it stayed that way. The last native number for that session is kept instead. It is tagged with its session so it cannot linger onto the next conversation. Caching the fallback into state also made the pill lag one round-trip behind on every message while the bridge was failing: the freshly computed estimate was discarded in favour of the one already in state, and if the bridge hung it never moved at all. Nothing writes the fallback into state now, which removes the redundant state and the request-id counter with it. And the rejection was swallowed with no log. Rename the `chat.budget` action and every desktop user would see the wrong quantity forever with no toast, no console line and no failing test. The policy note deleted from `chatBudget.ts` comes back. Understanding compaction does not make the 80,000 denominator any less invented, and without that sentence the file reads as authoritative enough for someone to re-add the auto-compact-at-70% gate that was deliberately removed. Co-Authored-By: Claude Opus 5 --- electron/ai-edition/chat-compaction.test.ts | 14 +++-- electron/ai-edition/chat-compaction.ts | 8 ++- .../chat-service.compaction.test.ts | 27 ++++++++ electron/ai-edition/chat-service.ts | 12 +++- src/components/ai-edition/LeftPanel.tsx | 8 ++- src/components/ai-edition/chatBudget.ts | 22 ++++--- .../ai-edition/useChatBudget.test.ts | 58 +++++++++++++++--- src/components/ai-edition/useChatBudget.ts | Bin 1486 -> 2558 bytes 8 files changed, 117 insertions(+), 32 deletions(-) diff --git a/electron/ai-edition/chat-compaction.test.ts b/electron/ai-edition/chat-compaction.test.ts index 838458dda..758136c66 100644 --- a/electron/ai-edition/chat-compaction.test.ts +++ b/electron/ai-edition/chat-compaction.test.ts @@ -27,19 +27,21 @@ describe("estimateHistoryTokens", () => { expect(tokens).toBe(100); }); - it("adds 4 tokens per tool call", () => { - const base = estimateHistoryTokens([msg("user", "hi")]); + it("does not bill tool calls, which are never sent", () => { + // `chat-service` maps the history to `{role, content}` before handing it to the + // provider, so a message's tool-call names and summaries are rendered in the + // chat and then dropped. Counting them inflated the context pill with text + // nobody pays for. const withTool = estimateHistoryTokens([ { id: "a", role: "assistant", - content: "done", + content: "x".repeat(400), createdAt: "2026-01-01T00:00:00.000Z", - toolCalls: [{ name: "addTrim", summary: "skip 5-8s" }], + toolCalls: [{ name: "addTrim", summary: "y".repeat(400) }], }, ]); - // tool adds roughly: 16 + 4-chars-per-token of name+summary - expect(withTool).toBeGreaterThan(base); + expect(withTool).toBe(100); }); }); diff --git a/electron/ai-edition/chat-compaction.ts b/electron/ai-edition/chat-compaction.ts index 5be23d87b..87554d697 100644 --- a/electron/ai-edition/chat-compaction.ts +++ b/electron/ai-edition/chat-compaction.ts @@ -39,11 +39,13 @@ export const DEFAULT_BUDGET_TOKENS = 80_000; export function estimateHistoryTokens(messages: AiEditionChatMessage[]): number { let chars = 0; for (const m of messages) { - // 4 chars per token + 4 tokens per message overhead (rough). + // Content only. `chat-service` maps the history to `{role, content}` before it + // sends it, so a message's tool-call names and summaries never reach the model -- + // billing them here inflated the pill with text nobody pays for. The two + // compaction comparisons are unaffected: both sides were counted the same way. chars += m.content.length; - for (const tc of m.toolCalls ?? []) - chars += (tc.name?.length ?? 0) + (tc.summary?.length ?? 0) + 16; } + // 4 chars per token (rough). return Math.ceil(chars / CHARS_PER_TOKEN); } diff --git a/electron/ai-edition/chat-service.compaction.test.ts b/electron/ai-edition/chat-service.compaction.test.ts index 2489398c5..37f715b35 100644 --- a/electron/ai-edition/chat-service.compaction.test.ts +++ b/electron/ai-edition/chat-service.compaction.test.ts @@ -114,6 +114,33 @@ describe("compaction", () => { expect(usage?.usedTokens).toBeLessThan(40_000); }); + it("measures the WINDOW the model is sent, not the whole post-compaction list", async () => { + // The model gets `MODEL_HISTORY_WINDOW` (20) messages, not everything after the + // compaction boundary. Measuring the wider list made the pill report roughly + // double on a long session, and made pressing Compact halve the number while + // what actually reaches the provider barely moved -- the same class of wrong + // number this pill exists to avoid. + const session = createSession("proj_window"); + // 12 turns = 24 messages, so 4 fall outside the window. + for (let i = 0; i < 12; i += 1) { + await runChat("proj_window", session.id, `${LONG}#${i}`, stubConfig()); + } + + const transcript = selectSession("proj_window", session.id)?.messages ?? []; + expect(transcript).toHaveLength(24); + const sentToTheModel = histories.at(-1) ?? []; + // The last turn's payload is what the pill has to describe. + const sentChars = sentToTheModel.reduce((acc, m) => acc + m.content.length, 0); + const transcriptChars = transcript.reduce((acc, m) => acc + m.content.length, 0); + expect(sentChars).toBeLessThan(transcriptChars); + + const usage = getSessionContextUsage("proj_window", session.id); + // Within one turn of the payload (the pill is read after the reply lands, the + // payload was built before it), and nowhere near the whole transcript. + expect(usage?.usedTokens).toBeLessThan(Math.ceil(transcriptChars / 4)); + expect(usage?.usedTokens).toBeGreaterThanOrEqual(Math.ceil(sentChars / 4)); + }); + 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 diff --git a/electron/ai-edition/chat-service.ts b/electron/ai-edition/chat-service.ts index 89c1d78e2..4dcac3423 100644 --- a/electron/ai-edition/chat-service.ts +++ b/electron/ai-edition/chat-service.ts @@ -630,10 +630,15 @@ export function getSessionContextUsage( ): { usedTokens: number; budgetTokens: number; ratio: number; fillPercent: number } | null { const session = sessionsByProject.get(projectId)?.get(sessionId); if (!session) return null; - // Measured on what the model is given, not on the transcript — after a + // Measured on what the model is given, not on the transcript -- after a // compaction those differ, and the number that matters is the one that // fills the context window. - const snap = budgetSnapshot(modelMessages(session), budgetTokens); + // + // `modelHistory`, NOT `modelMessages`: the model gets a 20-message window, so on + // a 40-message session `modelMessages` reports double what is sent, and pressing + // Compact halves the reported number while what actually reaches the provider + // barely moves. That is the same class of wrong number this pill exists to avoid. + const snap = budgetSnapshot(modelHistory(session), budgetTokens); const fillPercent = Math.min(100, Math.round(snap.ratio * 100)); return { usedTokens: snap.usedTokens, @@ -666,7 +671,8 @@ export function getSessionBudget( ): SessionBudgetSnapshot | null { const s = sessionsByProject.get(projectId)?.get(sessionId); if (!s) return null; - const snap = budgetSnapshot(modelMessages(s), budgetTokens); + // Same window the model is actually sent — see `getSessionContextUsage`. + const snap = budgetSnapshot(modelHistory(s), budgetTokens); return { usedTokens: snap.usedTokens, budgetTokens: snap.budgetTokens, diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 3695a938d..f7ea5a950 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -1141,9 +1141,11 @@ function ChatStripPanel() { }); }, [llmConfig]); - // Prefer the main process's model-message budget so manual compaction can - // shrink this meter while the complete transcript remains visible. The hook - // falls back to a renderer estimate in browser/shim or bridge-failure cases. + // Prefer the main process's estimate of the windowed history it actually sends, so + // manual compaction can shrink this meter while the complete transcript remains + // visible. The renderer estimate is only shown until the first answer arrives -- + // including in web builds, where the shim answers with its own transcript estimate + // rather than leaving the fallback in place. const budget = useChatBudget({ projectId, sessionId: activeSessionId, messages }); const [compactNowPending, setCompactNowPending] = useState(false); diff --git a/src/components/ai-edition/chatBudget.ts b/src/components/ai-edition/chatBudget.ts index 9f76190d9..918347623 100644 --- a/src/components/ai-edition/chatBudget.ts +++ b/src/components/ai-edition/chatBudget.ts @@ -1,10 +1,17 @@ // Renderer-side budget helper. Mirrors `electron/ai-edition/chat-compaction.ts` // but inline so we don't drag electron/ into the renderer bundle. // -// This is the renderer fallback for the context pill while native usage is -// loading or unavailable. Desktop builds replace it with the main process's -// model-message estimate, which understands compaction; no code makes an -// automatic compaction decision from either value. +// This is the renderer fallback for the context pill while native usage is loading +// or has never arrived. Desktop builds replace it with the main process's estimate +// of the windowed history it actually sends, which understands compaction. +// +// Either way it feeds the 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. Understanding compaction does not +// make the denominator any less invented. const CHARS_PER_TOKEN = 4; @@ -18,16 +25,15 @@ const DEFAULT_CHAT_BUDGET_TOKENS = 80_000; export interface RenderableChatMessage { content: string; - toolCalls?: Array<{ name?: string; summary?: string }>; } +// Content only, matching `estimateHistoryTokens` in the main process: tool-call +// names and summaries are rendered in the chat but stripped before the history is +// sent, so counting them here would bill text the model never sees. function estimateTokens(messages: readonly RenderableChatMessage[]): number { let chars = 0; for (const m of messages) { chars += m.content.length; - for (const tc of m.toolCalls ?? []) { - chars += (tc.name?.length ?? 0) + (tc.summary?.length ?? 0) + 16; - } } return Math.ceil(chars / CHARS_PER_TOKEN); } diff --git a/src/components/ai-edition/useChatBudget.test.ts b/src/components/ai-edition/useChatBudget.test.ts index 2c4c87064..839440767 100644 --- a/src/components/ai-edition/useChatBudget.test.ts +++ b/src/components/ai-edition/useChatBudget.test.ts @@ -57,19 +57,31 @@ describe("useChatBudget", () => { expect(chatBudgetMock).not.toHaveBeenCalled(); }); - it("keeps the transcript estimate when native usage is unavailable", async () => { + it("tracks the transcript while native usage has never arrived", async () => { + // The transcript has to keep MOVING, not just happen to match: asserting 100 + // before and after a null answer cannot tell "fell back correctly" apart from + // "the effect never ran". chatBudgetMock.mockResolvedValue(undefined); - const visibleMessages = message("x".repeat(400)); - const { result } = renderHook(() => - useChatBudget({ projectId: "project_1", sessionId: "session_1", messages: visibleMessages }), + const { result, rerender } = renderHook( + ({ messages }) => useChatBudget({ projectId: "project_1", sessionId: "session_1", messages }), + { initialProps: { messages: message("x".repeat(400)) } }, ); - await waitFor(() => expect(chatBudgetMock).toHaveBeenCalledTimes(1)); - await act(async () => Promise.resolve()); expect(result.current.usedTokens).toBe(100); + + rerender({ messages: message("x".repeat(800)) }); + await waitFor(() => expect(result.current.usedTokens).toBe(200)); + expect(chatBudgetMock).toHaveBeenCalledTimes(2); }); - it("falls back to the transcript estimate when native usage rejects", async () => { + it("keeps the last native number when a refresh fails, instead of swapping quantity", async () => { + // The two numbers are not interchangeable. After a compaction the native one is + // roughly half the transcript estimate, so silently substituting the transcript + // on a failed refresh made the compaction visibly un-happen -- the PR's own + // premise turned against it. A stale native number is the honest answer here. + const warn = vi.spyOn(console, "warn").mockImplementation(() => { + // swallowed: the point is that it is CALLED, not what it prints + }); chatBudgetMock .mockResolvedValueOnce({ usedTokens: 12, @@ -85,8 +97,36 @@ describe("useChatBudget", () => { await waitFor(() => expect(result.current.usedTokens).toBe(12)); rerender({ messages: message("x".repeat(800)) }); - await waitFor(() => expect(result.current.usedTokens).toBe(200)); - expect(chatBudgetMock).toHaveBeenCalledTimes(2); + await waitFor(() => expect(chatBudgetMock).toHaveBeenCalledTimes(2)); + await act(async () => Promise.resolve()); + expect(result.current.usedTokens).toBe(12); + // And it is not silent: renaming the bridge action must leave a trace. + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it("drops a native number when the session changes", async () => { + // The kept-on-failure number belongs to one conversation. Session 2's first + // refresh never answering must not leave session 1's number on screen. + const pending = deferred(); + chatBudgetMock + .mockResolvedValueOnce({ + usedTokens: 12, + budgetTokens: 80_000, + ratio: 0.00015, + fillPercent: 0.015, + }) + .mockReturnValueOnce(pending.promise); + const visibleMessages = message("x".repeat(400)); + const { result, rerender } = renderHook( + ({ sessionId }) => + useChatBudget({ projectId: "project_1", sessionId, messages: visibleMessages }), + { initialProps: { sessionId: "session_1" } }, + ); + await waitFor(() => expect(result.current.usedTokens).toBe(12)); + + rerender({ sessionId: "session_2" }); + expect(result.current.usedTokens).toBe(100); }); it("ignores a late response from the previously selected session", async () => { diff --git a/src/components/ai-edition/useChatBudget.ts b/src/components/ai-edition/useChatBudget.ts index 01c0ae9efd7bea9a85ccabc16217a0a050bea3f4..2fd64657ebe4a89e17dbad05995aeb42c2fddb52 100644 GIT binary patch literal 2558 zcmZuz+j84B5bdk<6&p_`r82hkF4sw$G!LFOmub=`k7o>#OA$2%umEUUV{0ZK(J$*&}h_2t$(-5 z1RXSc%9WuKFGzmG>UvKe z<$KDyO6L`|(uxmCev)2sOIx!zCHz8)Pz%o4O_JQPlgLuYo%R|iP;kol-03C^VGE1G zjSY*XG`=V0m_B^`LK*-#;A6Hd_|e%JsS-btl9cXIgEo6|w%t~!y;5~4kGzcq zOcIYMXiwJQDx*;HmF9fIPy2d)5NNgquPR1M2D zAVM`zcEF@`>?_<=K|+^G*Pd>f8y}0Rl@CLjL@_yqek3}m^eDsx;h|Gaqs51P2l5`LZ~r zAvOl|g8EWXO?p&9vm2G);;5hgEL+gUtBA!onb{Bn%DoysUoQ}4wW2R-y9K|U4ni6a zsbwe%7TsvQD_$yRZqwFi}w z>isubr~y9<2QexrRcOmVxE}6>uZWE@Fh`^|whhYxls@Kfn30PksPy8+(A#A}uj%Ib z!wC4{hvyF?y@wlG51c2IuhLiuE#X6S&g%qtblIOChPcQ$$mX0j(Ida%`p}E%RVI=G z{;{!*wmUx4TVr7Z=sB!%>l{RApsJPl`+cQa*w`Ka%8gi#$>Dr!x*4dv5gdfOarm4D zsgC~r`j0Tjos6;CLra+Kcyr+oOoYfq};&ZNR$mRkvt3AuXoz+FJ z$>|zfD*dj&X0Qf#W=r(eFRN_KJ(;dWyo9DrmL;$vzlc(`&bZw6QBC6P^K zaN5&P2br?j?2t0$D&$p`v2)f>fU5_1(^DLcbU_(M zHx&0u_65a`E{J|j+-#lGA;4lVO%_Iu%@p0}`#sm=$M^V0AkBsR^AY1>K=RxF%?cn) zb0&*l4CpD}lAU<@>8-3~Q=DOoG-)>i=pK&bC$E=Nr@FY9*a-Y7-C6cfug4~_LFWGf DZJcot literal 1486 zcmbtU!EW0y3_aUl!4ySdBZ1R%vNS`B9)_VibjLJ66`4-7Id&$?#e&5DK1#A=ZOLsH zTe3v*@x4cj{h^i$PS7^^TolOFL@!@(Uz^1%7Uun3F@?}UA?rQdNMu~y%wyrAE2hK; zK1z{q@u?J8DS!9#)E-W``5s28;{oAm$JAp>aa5Vt`$MbHZO!>BRyj(xDN)~i!KPu` z0ZBIHKAuv?qEaXe#u2_*NG7vIe~P1q!bM$u#p4VbB}KKJ$I-^znFUDZaTmw<+fY-q z6;~9aX&JC4^+A7ZW`RwxD0nb%c$gsZ)-)&wUF z;pv*3ifi3`?Fsl=6L7gng$49b35^XZSXpiu|4B!^)Jc-U;^A-l6PF{Mz{;)Hsn0S{ zNqIa9xVsy0z!KhXPyVcXyT3hoklj1X97`70${NLprMpp9LbG36ux9<~Qd-ORL!wz9 zAWHnzVne!+i)}nDl6$%kN8ay!??h=&kB_2|@!0PuG~=z5E35PG#zE0&arr8IKQ4W-zqu5~yY`@IH Wv7-9HcG;W4%s|#tJ@ZWKf7Tz=8ue5F