From 244aaca7637b3ae051ea4cf6f78a52d90173a396 Mon Sep 17 00:00:00 2001 From: benclawbot Date: Mon, 13 Jul 2026 00:58:41 +0200 Subject: [PATCH] fix(runtime): compact actual outbound context --- src/App.tsx | 53 ++++++++++++-------------- src/components/StatusBar.test.tsx | 6 +++ src/components/StatusBar.tsx | 6 +-- src/providers/context.test.ts | 22 ++++++++++- src/providers/context.ts | 21 +++++++++- src/providers/minimalCodeSkill.test.ts | 1 + src/providers/minimalCodeSkill.ts | 4 +- src/providers/planner.test.ts | 2 + src/providers/planner.ts | 1 + src/views/HomeView.tsx | 2 +- 10 files changed, 81 insertions(+), 37 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index db84336..538b9c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,7 +21,7 @@ import { dispatchChat } from "./providers/registry"; import { isTauriRuntime } from "./providers/minimax"; import { listSkills, loadSkill, type SkillDetail, type SkillSummary } from "./providers/skills"; import { useSlashMenu, type SlashItem } from "./providers/slash"; -import { buildContextMessages, type UiChatBubble } from "./providers/context"; +import { buildContextMessages, compactChatHistory, type UiChatBubble } from "./providers/context"; import { generatePlanSteps, isSubstantiveObjective, summarizeObjectiveLine, summarizeSessionTitle } from "./providers/planner"; import { normalizeTokenUsage } from "./providers/tokenUsage"; import type { RuntimePlan, RuntimePlanStep } from "./agentRuntimeDeepLoop"; @@ -1228,23 +1228,21 @@ const livePromptTokens = useMemo(() => { function detachSkill() { setActiveSkillId(null); } - function compactContext() { + function compactContext(source: ChatMessage[] = chatRef.current) { // Anchor the LLM-context window at the first kept entry's id. Future // turns send only entries with id >= compactFromId, so the dropped // turns never re-enter the model's context window. - let firstKeptId: number | null = null; - let nextChat: ChatMessage[] = []; - setChat((entries) => { - const recent = entries.slice(-COMPACT_KEEP_LAST); - firstKeptId = recent.length > 0 ? recent[0].id : null; - const note: ChatMessage = { id: nextMessageId(), role: "zeus", text: `Context compacted. Kept the last ${recent.length} turn(s).` }; - nextChat = [...recent, note]; - return nextChat; - }); - setCompactFromId(firstKeptId); - // Persist after the next tick so the `setChat` updater above has - // already produced the new array (we read `nextChat` from closure). - setTimeout(() => persistActiveSession({ compactFromId: firstKeptId, chat: nextChat }), 0); + const compacted = compactChatHistory(source, COMPACT_KEEP_LAST); + const completed = compacted.entries.filter((entry) => entry.thinking !== true) as ChatMessage[]; + const pending = compacted.entries.filter((entry) => entry.thinking === true) as ChatMessage[]; + const note: ChatMessage = { id: nextMessageId(), role: "zeus", text: `Context compacted. Kept the last ${completed.length} turn(s).` }; + const nextChat = [...completed, note, ...pending]; + chatRef.current = nextChat; + compactFromIdRef.current = compacted.compactFromId; + setChat(nextChat); + setCompactFromId(compacted.compactFromId); + setTimeout(() => persistActiveSession({ compactFromId: compacted.compactFromId, chat: nextChat }), 0); + return compacted; } function stopRun() { @@ -1401,7 +1399,10 @@ const livePromptTokens = useMemo(() => { // Snapshot the chat at dispatch time so the history we send reflects // exactly what the user saw, even if a concurrent setter updates chat // while the await is in flight. - const contextMessages = buildContextMessages(historySnapshot, compactFromId); + // The current user turn is appended explicitly below. Context contains + // only prior turns; including historySnapshot here would send the long + // prompt twice and can push an otherwise safe request over the limit. + const contextMessages = buildContextMessages(chat, compactFromId); // Build the outbound content for the user message. Image attachments // travel as multimodal blocks so the model can actually see them; // non-image file names are appended to the text prompt so the model @@ -1435,28 +1436,22 @@ const livePromptTokens = useMemo(() => { { role: "user", content: userOutboundContent }, ]; const decision = decideAutoCompact(projectedMessages, providerModel, activeProviderId, triggerRatio); + let dispatchHistory = chat; + let dispatchCompactFromId = compactFromId; if (decision.shouldCompact) { // Persist a copy of the chat so we can mention what we lost in // the notice, then call compactContext (which already mutates // `chat` and `compactFromId` and re-saves the session). - const droppedCount = chat.filter((entry) => entry.thinking !== true && (compactFromId === null || entry.id < compactFromId)).length; - compactContext(); + const compacted = compactContext(historySnapshot as ChatMessage[]); + dispatchHistory = compacted.entries.filter((entry) => entry.id !== userMessage.id && entry.thinking !== true) as ChatMessage[]; + dispatchCompactFromId = compacted.compactFromId; // Build the auto-compact notice after the state has settled so // the user sees what just happened. We do this through a setTimeout // to keep the order of side-effects predictable (the actual // compact already queued its own persistActiveSession). setTimeout(() => { - appendZeusMessage(`${formatCompactNotice(decision)} Dropped ${droppedCount} earlier turn(s).`); + appendZeusMessage(`${formatCompactNotice(decision)} Dropped ${compacted.droppedCount} earlier turn(s).`); }, 0); - // Re-build contextMessages from the freshly-compacted chat. - const freshSnapshot = [...chatRef.current, userMessage, thinkingMessage] as UiChatBubble[]; - const freshContext = buildContextMessages(freshSnapshot, compactFromIdRef.current); - projectedMessages.length = 0; - projectedMessages.push( - { role: "system", content: SYSTEM_PROMPT + projectHint }, - ...freshContext, - { role: "user", content: userOutboundContent }, - ); } // Build the final system prompt by appending the active terse and // minimal-code skill bodies. The terse skill is on by default @@ -1468,7 +1463,7 @@ const livePromptTokens = useMemo(() => { // Seed messages: system prompt + compact context + the user's prompt. const seedMessages: ChatRequestMessage[] = [ { role: "system", content: augmentedSystem }, - ...buildContextMessages([...chat, userMessage, thinkingMessage] as UiChatBubble[], compactFromId), + ...buildContextMessages(dispatchHistory, dispatchCompactFromId), { role: "user", content: userOutboundContent }, ]; diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 8c478e7..6fdb576 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -44,6 +44,12 @@ describe("StatusBar", () => { expect(band).not.toBeNull(); }); + it("does not claim an actual over-target turn is currently compacting", () => { + render(); + expect(screen.getByText("over target")).toBeInTheDocument(); + expect(screen.queryByText("compacting")).not.toBeInTheDocument(); + }); + it("applies the amber band at 75% of the threshold", () => { // 30K / 128K = 23.4% — green // 40K / 128K = 31.3% — amber (75% of 40% = 30%) diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 7a68ea7..296c121 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -38,9 +38,9 @@ interface BandResult { className: string; } -function ratioBand(ratio: number, threshold: number): BandResult { +function ratioBand(ratio: number, threshold: number, isActual: boolean): BandResult { if (ratio <= 0) return { label: "idle", className: "status-bar-band idle" }; - if (ratio >= threshold) return { label: "compacting", className: "status-bar-band red" }; + if (ratio >= threshold) return { label: isActual ? "over target" : "compact on send", className: "status-bar-band red" }; if (ratio >= threshold * 0.75) return { label: "watch", className: "status-bar-band amber" }; return { label: "ok", className: "status-bar-band green" }; } @@ -58,7 +58,7 @@ export function StatusBar(props: StatusBarProps): React.ReactElement { const triggerRatio = props.triggerRatio ?? DEFAULT_COMPACT_TRIGGER_RATIO; const contextWindow = lookupContextWindow(modelId, providerId); const ratio = contextWindowUsage(displayedTokens, modelId, providerId); - const band = ratioBand(ratio, triggerRatio); + const band = ratioBand(ratio, triggerRatio, actualPromptTokens !== undefined); const percentText = `${(ratio * 100).toFixed(1)}%`; const thresholdText = `${Math.round(triggerRatio * 100)}%`; const promptDisplay = formatTokens(displayedTokens); diff --git a/src/providers/context.test.ts b/src/providers/context.test.ts index 966772e..50aa44b 100644 --- a/src/providers/context.test.ts +++ b/src/providers/context.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildContextMessages, chatEntryToProviderMessage, type UiChatBubble } from "./context"; +import { buildContextMessages, chatEntryToProviderMessage, compactChatHistory, type UiChatBubble } from "./context"; function bubble(id: number, role: "user" | "zeus", text: string, extra: Partial = {}): UiChatBubble { return { id, role, text, ...extra }; @@ -26,6 +26,24 @@ describe("chatEntryToProviderMessage", () => { }); }); +describe("compactChatHistory", () => { + it("keeps the current user turn while dropping older turns", () => { + const chat = [ + bubble(1, "user", "old question"), + bubble(2, "zeus", "old answer"), + bubble(3, "user", "recent question"), + bubble(4, "zeus", "recent answer"), + bubble(5, "user", "current long prompt"), + bubble(6, "zeus", "", { thinking: true }), + ]; + const compacted = compactChatHistory(chat, 3); + expect(compacted.compactFromId).toBe(3); + expect(compacted.entries.map((entry) => entry.id)).toEqual([3, 4, 5, 6]); + expect(buildContextMessages(compacted.entries, compacted.compactFromId).map((message) => message.content)) + .toEqual(["recent question", "recent answer", "current long prompt"]); + }); +}); + describe("buildContextMessages", () => { it("returns an empty array for an empty chat", () => { expect(buildContextMessages([], null)).toEqual([]); @@ -77,4 +95,4 @@ describe("buildContextMessages", () => { // empty content — that's the exact regression we're guarding against. expect(result.some((msg) => msg.content === "")).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/src/providers/context.ts b/src/providers/context.ts index 6c7b89e..eea289d 100644 --- a/src/providers/context.ts +++ b/src/providers/context.ts @@ -10,6 +10,25 @@ export interface UiChatBubble { skillId?: string; } +export interface CompactedChatHistory { + entries: UiChatBubble[]; + compactFromId: number | null; + droppedCount: number; +} + +/** Keep the newest completed messages plus any in-flight placeholder. */ +export function compactChatHistory(chat: UiChatBubble[], keepLast: number): CompactedChatHistory { + const completed = chat.filter((entry) => entry.thinking !== true); + const keptCompleted = completed.slice(-Math.max(0, keepLast)); + const keptIds = new Set(keptCompleted.map((entry) => entry.id)); + const entries = chat.filter((entry) => entry.thinking === true || keptIds.has(entry.id)); + return { + entries, + compactFromId: keptCompleted[0]?.id ?? null, + droppedCount: completed.length - keptCompleted.length, + }; +} + /** * Map a frontend chat entry into the shape the LLM provider expects. * - `zeus` becomes `assistant` (it's the model speaking). @@ -39,4 +58,4 @@ export function buildContextMessages(chat: UiChatBubble[], compactFromId: number .filter((entry) => compactFromId === null || entry.id >= compactFromId) .map(chatEntryToProviderMessage) .filter((msg): msg is { role: "user" | "assistant"; content: string } => msg !== null); -} \ No newline at end of file +} diff --git a/src/providers/minimalCodeSkill.test.ts b/src/providers/minimalCodeSkill.test.ts index b175d0d..057133f 100644 --- a/src/providers/minimalCodeSkill.test.ts +++ b/src/providers/minimalCodeSkill.test.ts @@ -23,6 +23,7 @@ describe("getMinimalCodeInstructions", () => { expect(block).toContain("work through these checks in order"); // Lite should NOT include the audit comment guidance. expect(block).not.toContain("Auditability"); + expect(block).toContain("single self-contained HTML file"); }); it("returns the full block for `full`", () => { diff --git a/src/providers/minimalCodeSkill.ts b/src/providers/minimalCodeSkill.ts index b068831..7aaf995 100644 --- a/src/providers/minimalCodeSkill.ts +++ b/src/providers/minimalCodeSkill.ts @@ -43,7 +43,9 @@ const LITE_BLOCK = `Code-generation discipline — minimal (lite): Before writing new code, work through these checks in order. Stop at the first "yes": 1. Does this need to exist at all? If the task can be satisfied without new code (a config change, an existing flag, deleting something instead of adding), do that instead. 2. Does the codebase already solve this? Search the codebase first. Reuse an existing utility / component / pattern rather than reimplementing it, even partially. -3. Does the platform / language / runtime already solve this natively? (e.g. a native , a stdlib function, a built-in language feature) — before reaching for a dependency.`; +3. Does the platform / language / runtime already solve this natively? (e.g. a native , a stdlib function, a built-in language feature) — before reaching for a dependency. + +For a simple page, mockup, prototype, or local browser tool with no explicit framework requirement, default to a single self-contained HTML file with inline CSS and JavaScript. Do not scaffold Vite, add a data layer, or make it a PWA unless the requested behavior actually requires those choices.`; /** * `full` — full ladder + exemption list + audit comments on. diff --git a/src/providers/planner.test.ts b/src/providers/planner.test.ts index 46497ce..493fe75 100644 --- a/src/providers/planner.test.ts +++ b/src/providers/planner.test.ts @@ -136,6 +136,8 @@ describe("planner.generatePlanSteps", () => { baseUrl: "https://api.example.com/v1", temperature: 0.2, })); + const request = vi.mocked(registry.dispatchChat).mock.calls[0][0]; + expect(JSON.stringify(request.messages)).toContain("single self-contained HTML file"); }); }); diff --git a/src/providers/planner.ts b/src/providers/planner.ts index f9b5ceb..303f6a2 100644 --- a/src/providers/planner.ts +++ b/src/providers/planner.ts @@ -19,6 +19,7 @@ Rules: - Each step is a TIGHT LABEL of 2-5 words. Imperative verb + object. No explanations, no clauses, no punctuation at the end. - Examples of good labels: "Read package.json", "Add SettingsPanel.tsx", "Wire into sidebar", "Run tsc + vitest", "Commit with feat()". - Do NOT include generic steps like "Understand objective" or "Verify output". Only steps specific to THIS objective. +- Keep the architecture proportional to the request. For a simple page, mockup, prototype, or local browser tool with no explicit framework requirement, plan a single self-contained HTML file with inline CSS and JavaScript. Do not introduce Vite, a data layer, a PWA, or a framework unless the objective requires it. - Return ONLY a JSON array of strings. No prose, no markdown fences, no commentary. Example response: diff --git a/src/views/HomeView.tsx b/src/views/HomeView.tsx index b479bdc..f0eebd3 100644 --- a/src/views/HomeView.tsx +++ b/src/views/HomeView.tsx @@ -239,7 +239,7 @@ export function HomeView({ modelId={modelId} providerId={activeProviderId} promptTokens={livePromptTokens} - actualPromptTokens={actualPromptTokens} + actualPromptTokens={runState === "running" ? undefined : actualPromptTokens} triggerRatio={DEFAULT_COMPACT_TRIGGER_RATIO} onOpenSettings={onOpenSettings} />