diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
index e86ce913c7..d82d9c6705 100644
--- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
+++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
@@ -32,8 +32,10 @@
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import Markdown from 'svelte-exmarkdown'
import { twMerge } from 'tailwind-merge'
- import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
+ import { AIAutonomyMode, AIMode, getTrimThreshold } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
+ import { getModelContextWindow } from '../lib'
+ import { tryGetCurrentModel } from '$lib/aiStore'
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
import AIChatInput from './AIChatInput.svelte'
import { getModifierKey } from '$lib/utils'
@@ -288,6 +290,34 @@
return aiChatManager.appAiChatHelpers.getSelectedContext()
})
+ function formatTokenCount(n: number): string {
+ if (n < 1000) return `${Math.round(n)}`
+ const k = n / 1000
+ if (k < 1000) return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`
+ const m = n / 1_000_000
+ return `${m < 10 ? m.toFixed(1) : Math.round(m)}M`
+ }
+
+ // Accurate "current context size" badge. Hidden until a real provider-reported count
+ // exists (fresh chat, or a chat saved before this feature). Colour shifts as the
+ // context fills toward the threshold where older messages start being dropped.
+ const contextUsage = $derived.by(() => {
+ const tokens = aiChatManager.contextTokens
+ if (tokens == null) return undefined
+ const window = getModelContextWindow(tryGetCurrentModel()?.model ?? '')
+ const threshold = getTrimThreshold(window)
+ const colorClass =
+ tokens >= threshold
+ ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
+ : tokens >= threshold * 0.8
+ ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300'
+ : 'bg-surface-secondary text-tertiary'
+ return {
+ label: `${formatTokenCount(tokens)} / ${formatTokenCount(window)}`,
+ colorClass
+ }
+ })
+
const yoloBypassedTools = $derived.by(() => {
return aiChatManager.tools
.filter((tool) => tool.requiresConfirmation === true)
@@ -643,6 +673,18 @@
{/if}
+ {#if contextUsage}
+
+ {contextUsage.label}
+
+ {/if}
+
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if appContext.inspectorElement}
('')
displayMessages = $state
([])
messages = $state([])
+ // Accurate "current context size" of the conversation, taken from the provider's
+ // reported usage on the last turn's final iteration (prompt + completion).
+ // Undefined until a turn completes (or a chat saved before this existed is restored).
+ // Doubles as the trim gate's accurate baseline (see checkTokenUsageOverLimit).
+ contextTokens = $state(undefined)
+ // Crude chars÷4 estimate measured over the message set at the moment contextTokens
+ // was last set. Used to calibrate the crude estimator against the real API count.
+ #anchorCrude: number | undefined = undefined
autonomyMode = $state(getPersistedAutonomyMode())
autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode))
autoAcceptEditsActive = $derived(
@@ -239,27 +257,44 @@ export class AIChatManager {
open = $derived(chatState.size > 0)
- checkTokenUsageOverLimit = (messages: ChatCompletionMessageParam[]) => {
- const estimatedTokens = messages.reduce((acc, message) => {
- // one token is ~ 4 characters
- const tokenPerCharacter = 4
- // handle content
+ // Crude chars÷4 estimate of the tokens a request carries: every message's content +
+ // tool_calls, PLUS the system message and tool schemas (which the API counts but the
+ // per-message loop above used to ignore — the source of context overflows in agentic mode).
+ #crudeEstimate = (messages: ChatCompletionMessageParam[]) => {
+ const tokenPerCharacter = 4
+ let estimate = messages.reduce((acc, message) => {
if (message.content) {
acc += message.content.length / tokenPerCharacter
}
- // Handle tool calls
if (message.role === 'assistant' && message.tool_calls) {
acc += JSON.stringify(message.tool_calls).length / tokenPerCharacter
}
return acc
}, 0)
- const model = getCurrentModel()
- const modelContextWindow = getModelContextWindow(model.model)
- return (
- estimatedTokens >
- modelContextWindow -
- Math.max(modelContextWindow * MAX_TOKENS_THRESHOLD_PERCENTAGE, MAX_TOKENS_HARD_LIMIT)
- )
+ estimate += (this.systemMessage?.content?.length ?? 0) / tokenPerCharacter
+ if (this.tools.length > 0) {
+ estimate += JSON.stringify(this.tools.map((t) => t.def)).length / tokenPerCharacter
+ }
+ return estimate
+ }
+
+ // Best estimate of the context size for a given message set. When we have a real
+ // provider-reported anchor (contextTokens) we scale the crude estimate by the
+ // anchor/crude ratio measured at anchor time — so the figure is accurate at the
+ // anchor and shrinks proportionally as messages are dropped during trimming.
+ // Falls back to the crude estimate (which now includes system + tools) on the first
+ // turn or for chats restored without a stored count.
+ #estimateContextTokens = (messages: ChatCompletionMessageParam[]) => {
+ const crude = this.#crudeEstimate(messages)
+ if (this.contextTokens != null && this.#anchorCrude && this.#anchorCrude > 0) {
+ return crude * (this.contextTokens / this.#anchorCrude)
+ }
+ return crude
+ }
+
+ checkTokenUsageOverLimit = (messages: ChatCompletionMessageParam[]) => {
+ const modelContextWindow = getModelContextWindow(tryGetCurrentModel()?.model ?? '')
+ return this.#estimateContextTokens(messages) > getTrimThreshold(modelContextWindow)
}
deleteOldestMessage = (messages: ChatCompletionMessageParam[], maxDepth: number = 10) => {
@@ -519,8 +554,7 @@ export class AIChatManager {
this.tools = globalToolsFor({ sessionPreview: this.isSessionChat })
this.helpers = {
...(this.isSessionChat ? { sessionId: this.sessionId } : {}),
- testActiveFlow: async (args?: Record) =>
- this.flowAiChatHelpers?.testFlow(args)
+ testActiveFlow: async (args?: Record) => this.flowAiChatHelpers?.testFlow(args)
} satisfies GlobalToolHelpers
} else if (mode === AIMode.APP) {
const customPrompt = getCombinedCustomPrompt(mode)
@@ -721,6 +755,14 @@ export class AIChatManager {
}
}
})
+ // Anchor the accurate context size on the provider-reported usage of the final
+ // iteration (prompt already holds the fully-grown context; completion is the
+ // final reply). Record the crude estimate over the same message set so the trim
+ // gate can calibrate against this anchor.
+ if (result.lastIterationUsage?.total) {
+ this.contextTokens = result.lastIterationUsage.total
+ this.#anchorCrude = this.#crudeEstimate([...messages, ...result.addedMessages])
+ }
return result.addedMessages
} catch (err) {
console.log('chatRequest error', err)
@@ -966,7 +1008,7 @@ export class AIChatManager {
}
this.messages.push(userMessage)
- await this.historyManager.saveChat(this.displayMessages, this.messages)
+ await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextTokens)
this.currentReply = ''
@@ -1054,7 +1096,7 @@ export class AIChatManager {
if (this.autoAcceptEditsActive) {
this.acceptPendingFlowEdits()
}
- await this.historyManager.saveChat(this.displayMessages, this.messages)
+ await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextTokens)
if (isFirstUserTurn && this.afterFirstTurnSaved) {
void Promise.resolve(this.afterFirstTurnSaved()).catch((e) => {
console.error('AIChatManager afterFirstTurnSaved hook failed', e)
@@ -1152,9 +1194,11 @@ export class AIChatManager {
saveAndClear = async () => {
this.cancel('saveAndClear')
- await this.historyManager.save(this.displayMessages, this.messages)
+ await this.historyManager.save(this.displayMessages, this.messages, this.contextTokens)
this.displayMessages = []
this.messages = []
+ this.contextTokens = undefined
+ this.#anchorCrude = undefined
}
loadPastChat = async (id: string) => {
@@ -1162,6 +1206,11 @@ export class AIChatManager {
if (chat) {
this.displayMessages = chat.displayMessages
this.messages = chat.actualMessages
+ this.contextTokens = chat.contextTokens
+ // Re-derive the calibration baseline so the trim gate is accurate on the next
+ // check even before a new turn. Undefined for chats saved before this existed.
+ this.#anchorCrude =
+ chat.contextTokens != null ? this.#crudeEstimate(chat.actualMessages) : undefined
this.#automaticScroll = true
}
}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts
index b1c77df84d..dafd36f1ee 100644
--- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts
+++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts
@@ -217,3 +217,72 @@ describe('AIChatManager persisted autonomy default', () => {
expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.DEFAULT)
})
})
+
+describe('AIChatManager context trim gate', () => {
+ // Mocked window is 128000; threshold = 128000 - max(128000*0.05, 5000) = 121600 tokens.
+ // chars÷4, so ~486400 chars to exceed.
+ const big = (chars: number) => 'x'.repeat(chars)
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('counts the system message in the estimate (closes the overflow blind spot)', () => {
+ const manager = new AIChatManager()
+ const messages = [{ role: 'user', content: 'hi' }] as any
+
+ // Small messages alone are well under the limit...
+ expect(manager.checkTokenUsageOverLimit(messages)).toBe(false)
+
+ // ...but a large system message must push it over (the old gate ignored it).
+ manager.systemMessage = { role: 'system', content: big(520_000) }
+ expect(manager.checkTokenUsageOverLimit(messages)).toBe(true)
+ })
+
+ it('counts the tool schemas in the estimate', () => {
+ const manager = new AIChatManager()
+ const messages = [{ role: 'user', content: 'hi' }] as any
+
+ expect(manager.checkTokenUsageOverLimit(messages)).toBe(false)
+
+ manager.tools = [{ def: { name: 'huge', schema: big(520_000) } }] as any
+ expect(manager.checkTokenUsageOverLimit(messages)).toBe(true)
+ })
+
+ it('drops an assistant tool-call message together with its tool response', () => {
+ const manager = new AIChatManager()
+ const messages = [
+ { role: 'assistant', content: null, tool_calls: [{ id: 't1', function: { name: 'f' } }] },
+ { role: 'tool', tool_call_id: 't1', content: big(300_000) },
+ { role: 'user', content: big(300_000) }
+ ] as any
+
+ // 3 messages (~600k chars = 150k tokens) exceed the 121.6k threshold; the lone
+ // user message (~300k chars / 4 = 75k tokens) is under it.
+ expect(manager.checkTokenUsageOverLimit(messages)).toBe(true)
+
+ const trimmed = manager.deleteOldestMessage([...messages])
+
+ // The assistant+tool pair is removed together — never a dangling tool at the front.
+ expect(trimmed.map((m: any) => m.role)).toEqual(['user'])
+ expect(manager.checkTokenUsageOverLimit(trimmed)).toBe(false)
+ })
+
+ it('calibrates the estimate to the accurate anchor when one exists', () => {
+ const manager = new AIChatManager()
+ const messages = [{ role: 'user', content: big(200_000) }] as any // crude ≈ 50k tokens
+
+ // Without an anchor the crude estimate (~50k) is under the 121.6k threshold.
+ expect(manager.checkTokenUsageOverLimit(messages)).toBe(false)
+
+ // Restoring a chat whose real context was ~130k tokens sets the anchor; the gate
+ // scales the crude estimate by anchor/crude and now reports over-limit.
+ ;(manager.historyManager as any).loadPastChat = () => ({
+ displayMessages: [],
+ actualMessages: messages,
+ contextTokens: 130_000
+ })
+ manager.loadPastChat('id')
+ expect(manager.checkTokenUsageOverLimit(messages)).toBe(true)
+ })
+})
diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts
index d07f6943bd..33766c97b1 100644
--- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts
+++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts
@@ -12,6 +12,10 @@ interface ChatSchema extends IDBSchema {
title: string
lastModified: number
sessionId?: string
+ // Accurate context size (tokens) at the last saved turn. Optional: chats saved
+ // before this field existed read back as undefined (no migration needed — IndexedDB
+ // stores plain objects).
+ contextTokens?: number
}
}
}
@@ -28,6 +32,7 @@ export default class HistoryManager {
id: string
lastModified: number
sessionId?: string
+ contextTokens?: number
}
> = $state({})
@@ -104,7 +109,11 @@ export default class HistoryManager {
return Object.values(this.savedChats)
}
- async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) {
+ async saveChat(
+ displayMessages: DisplayMessage[],
+ messages: ChatCompletionMessageParam[],
+ contextTokens?: number
+ ) {
if (displayMessages.length > 0) {
// we don't want to save the snapshot in the history
const updatedChat = {
@@ -116,6 +125,7 @@ export default class HistoryManager {
title: displayMessages[0].content.slice(0, 50),
id: this.currentChatId,
lastModified: Date.now(),
+ ...(contextTokens != null ? { contextTokens } : {}),
...(this.sessionId ? { sessionId: this.sessionId } : {})
}
this.savedChats = {
@@ -129,8 +139,12 @@ export default class HistoryManager {
}
}
- async save(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) {
- await this.saveChat(displayMessages, messages)
+ async save(
+ displayMessages: DisplayMessage[],
+ messages: ChatCompletionMessageParam[],
+ contextTokens?: number
+ ) {
+ await this.saveChat(displayMessages, messages, contextTokens)
this.currentChatId = createLongHash()
}
diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts
index a624698cc9..6f3e1cbf15 100644
--- a/frontend/src/lib/components/copilot/chat/chatLoop.ts
+++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts
@@ -8,16 +8,9 @@ import type {
import type { AIProviderModel } from '$lib/gen'
import { getCompletion, parseOpenAICompletion } from '../lib'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
-import {
- getOpenAIResponsesCompletion,
- parseOpenAIResponsesCompletion
-} from './openai-responses'
+import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses'
import type { Tool, ToolCallbacks } from './shared'
-import {
- addChatTokenUsage,
- emptyChatTokenUsage,
- type ChatTokenUsage
-} from './tokenUsage'
+import { addChatTokenUsage, emptyChatTokenUsage, type ChatTokenUsage } from './tokenUsage'
export interface ChatClients {
openai: OpenAI
@@ -55,6 +48,15 @@ export interface ChatLoopConfig {
export interface ChatLoopResult {
addedMessages: ChatCompletionMessageParam[]
tokenUsage: ChatTokenUsage
+ /**
+ * Usage of the final loop iteration only (last write wins). Its prompt already
+ * includes the fully-grown context (original messages + every tool result this
+ * turn), and its completion is the final assistant reply now appended — so
+ * `prompt + completion` approximates the whole conversation's token size after
+ * the turn. Used as the accurate "current context size" anchor, distinct from
+ * the cumulative `tokenUsage` above (which sums every iteration = total billed).
+ */
+ lastIterationUsage: ChatTokenUsage
hitMaxIterations: boolean
}
@@ -74,6 +76,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise