diff --git a/src/components/StatusLine.active.test.tsx b/src/components/StatusLine.active.test.tsx index 9f8e06dda..e0bb95635 100644 --- a/src/components/StatusLine.active.test.tsx +++ b/src/components/StatusLine.active.test.tsx @@ -15,7 +15,9 @@ import { import type { Message } from '../types/message.js' import type { StatusLineCommandInput } from '../types/statusLine.js' import type { executeStatusLineCommand } from '../utils/hooks.js' -import { StatusLine } from './StatusLine.js' +import { createUserMessage } from '../utils/messages.js' +import { createCompactBoundaryMessage } from '../utils/messages/systemFactories.js' +import { getLastAssistantMessageId, StatusLine } from './StatusLine.js' type MacroGlobal = typeof globalThis & { MACRO?: { VERSION: string } } const macroGlobal = globalThis as MacroGlobal @@ -253,3 +255,87 @@ test('cancels hidden statusline work and refreshes once reactivated', async () = setSessionTrustAccepted(originalTrust) } }) + +test('refreshes mounted statusline with null usage after full compaction', async () => { + const stdout = new PassThrough() + ;(stdout as unknown as { columns: number }).columns = 80 + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + patchConsole: false, + }) + const originalTrust = getSessionTrustAccepted() + const calls: StatusLineCommandInput[] = [] + const archived = assistantMessage('archived-response') + if (archived.type !== 'assistant') throw new Error('Expected assistant fixture') + archived.message.usage.input_tokens = 90000 + const boundary = createCompactBoundaryMessage('manual', 90000) + const summary = createUserMessage({ + content: 'Compacted conversation summary.', + isCompactSummary: true, + }) + const messagesRef: RefObject = { current: [archived] } + const initialState: AppState = { + ...getDefaultAppState(), + settings: { + statusLine: { type: 'command', command: 'test-statusline' }, + }, + } + let latestState = initialState + let debounceTimer: ReturnType | undefined + const executeCommand: typeof executeStatusLineCommand = async input => { + calls.push(input) + return input.context_window.current_usage === null ? 'compacted' : 'archived' + } + const render = (messages: Message[]) => { + messagesRef.current = messages + root.render( + { + latestState = newState + }} + > + + , + ) + } + + setSessionTrustAccepted(true) + try { + render([archived]) + await waitFor( + () => calls.length === 1 && latestState.statusLineText === 'archived', + 'initial statusline with archived usage', + ) + expect(calls[0]!.context_window.current_usage?.input_tokens).toBe(90000) + + const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout') + render([archived, boundary, summary]) + await waitFor( + () => setTimeoutSpy.mock.calls.some(call => call[1] === 300), + 'debounced compact statusline refresh', + ) + const timerIndex = setTimeoutSpy.mock.calls.findIndex(call => call[1] === 300) + debounceTimer = setTimeoutSpy.mock.results[timerIndex]! + .value as ReturnType + expect(calls).toHaveLength(1) + + await waitFor( + () => calls.length === 2 && latestState.statusLineText === 'compacted', + 'statusline execution after full compaction', + ) + expect(calls[1]!.context_window.current_usage).toBeNull() + expect(calls[1]!.context_window.used_percentage).toBeNull() + expect(calls[1]!.context_window.remaining_percentage).toBeNull() + } finally { + root.unmount() + if (debounceTimer !== undefined) clearTimeout(debounceTimer) + jest.restoreAllMocks() + stdout.end() + setSessionTrustAccepted(originalTrust) + } +}) diff --git a/src/components/StatusLine.test.ts b/src/components/StatusLine.test.ts index 1edca633a..e556e61c5 100644 --- a/src/components/StatusLine.test.ts +++ b/src/components/StatusLine.test.ts @@ -1,8 +1,10 @@ import { beforeEach, describe, expect, it } from 'bun:test' import { resetStateForTests } from '../cost-tracker.js' import { getUnreportedSessionUsage } from '../utils/tokens.js' +import { createCompactBoundaryMessage } from '../utils/messages/systemFactories.js' import { buildStatusLineCommandInput, + getLastAssistantMessageId, resolveStatusLineUpdateAction, resolveStatusLineTokenTotals, } from './StatusLine.js' @@ -48,6 +50,23 @@ const userMessage = (content: string): Message => }, }) as Message +describe('getLastAssistantMessageId', () => { + it('invalidates the status line at compaction before a new response arrives', () => { + const archived = assistantMessage('Archived response.') + const boundary = createCompactBoundaryMessage('manual', 90000) + const summary = userMessage('Compacted summary.') + + expect(getLastAssistantMessageId([archived])).toBe(archived.uuid) + expect(getLastAssistantMessageId([archived, boundary, summary])).toBeNull() + + const reply: AssistantMessage = { + ...assistantMessage('Fresh response.'), + uuid: '00000000-0000-0000-0000-000000000003', + } + expect(getLastAssistantMessageId([archived, boundary, summary, reply])).toBe(reply.uuid) + }) +}) + describe('resolveStatusLineTokenTotals', () => { it('keeps reported usage totals unchanged', () => { expect(resolveStatusLineTokenTotals(1200, 300, null)).toEqual({ diff --git a/src/components/StatusLine.tsx b/src/components/StatusLine.tsx index 4e881403a..91da6f6ff 100644 --- a/src/components/StatusLine.tsx +++ b/src/components/StatusLine.tsx @@ -21,7 +21,7 @@ import { getCwd } from '../utils/cwd.js'; import { logForDebugging } from '../utils/debug.js'; import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; import { createBaseHookInput, executeStatusLineCommand } from '../utils/hooks.js'; -import { getLastAssistantMessage } from '../utils/messages.js'; +import { getLastAssistantMessage, getMessagesAfterCompactBoundary } from '../utils/messages.js'; import { getRuntimeMainLoopModel, type ModelName, renderModelName } from '../utils/model/model.js'; import { getCurrentSessionTitle } from '../utils/sessionStorage.js'; import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage, getUnreportedSessionUsage, type SessionUsage } from '../utils/tokens.js'; @@ -199,7 +199,9 @@ type Props = { executeCommand?: typeof executeStatusLineCommand; }; export function getLastAssistantMessageId(messages: Message[]): string | null { - return getLastAssistantMessage(messages)?.uuid ?? null; + // Returning null at a new compact boundary also invalidates the built-in + // and custom status lines before the next assistant response arrives. + return getLastAssistantMessage(getMessagesAfterCompactBoundary(messages))?.uuid ?? null; } function StatusLineInner({ messagesRef, diff --git a/src/utils/tokens.test.ts b/src/utils/tokens.test.ts index b01771067..26fee7c9e 100644 --- a/src/utils/tokens.test.ts +++ b/src/utils/tokens.test.ts @@ -6,6 +6,7 @@ import { import { getCurrentUsage, getUnreportedSessionUsage } from './tokens.js' import { IncrementalTokenCounter } from './incrementalTokenCounter.js' import type { AssistantMessage, Message } from '../types/message.js' +import { createCompactBoundaryMessage } from './messages/systemFactories.js' interface FakeUsage { input_tokens: number @@ -66,6 +67,58 @@ describe('tokens', () => { }) }) + it('does not report pre-compact usage before the next assistant response', () => { + const archived = assistantMessage({ input_tokens: 90000, output_tokens: 500 }) + const boundary = createCompactBoundaryMessage('manual', 90500) + const summary = userMessage('Compacted conversation summary.') + + expect(getCurrentUsage([archived, boundary, summary])).toBeNull() + }) + + it('estimates only the active context after compaction for all-zero usage', () => { + const archived = userMessage('Archived context. '.repeat(1000)) + const boundary = createCompactBoundaryMessage('manual', 90000) + const summary = userMessage('Compacted conversation summary.') + const prompt = userMessage('Continue from the summary.') + const reply = assistantMessage({ input_tokens: 0, output_tokens: 0 }) + const activeMessages = [boundary, summary, prompt, reply] + + expect(getCurrentUsage([archived, ...activeMessages])).toEqual( + getCurrentUsage(activeMessages), + ) + expect(getCurrentUsage(activeMessages)?.input_tokens).toBe( + roughTokenCountEstimationForMessages([summary, prompt]), + ) + }) + + it('uses the latest compact boundary after repeated compaction', () => { + const firstBoundary = createCompactBoundaryMessage('manual', 90000) + const oldSummary = userMessage('Previous summary. '.repeat(1000)) + const oldReply = assistantMessage({ input_tokens: 0, output_tokens: 0 }, 'Old reply.', 'old') + const latestBoundary = createCompactBoundaryMessage('auto', 50000) + const summary = userMessage('Latest summary.') + const reply = assistantMessage({ input_tokens: 0, output_tokens: 0 }, 'Latest reply.', 'latest') + const activeMessages = [latestBoundary, summary, reply] + + expect(getCurrentUsage([firstBoundary, oldSummary, oldReply, ...activeMessages])).toEqual( + getCurrentUsage(activeMessages), + ) + }) + + it('preserves reported usage after compaction', () => { + const archived = assistantMessage({ input_tokens: 90000, output_tokens: 500 }, 'Archived.', 'old') + const boundary = createCompactBoundaryMessage('manual', 90500) + const summary = userMessage('Summary.') + const reply = assistantMessage({ input_tokens: 800, output_tokens: 30, cache_read_input_tokens: 200 }, 'Fresh.', 'fresh') + + expect(getCurrentUsage([archived, boundary, summary, reply])).toEqual({ + input_tokens: 800, + output_tokens: 30, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 200, + }) + }) + it('estimates current context when the latest provider usage is all zero', () => { const prompt = userMessage('Please summarize this repository structure.') const reply = assistantMessage( diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 29cfd9ad3..ce5a58335 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -5,7 +5,11 @@ import { roughTokenCountEstimationForMessages, } from '../services/tokenEstimation.js' import type { AssistantMessage, Message } from '../types/message.js' -import { SYNTHETIC_MESSAGES, SYNTHETIC_MODEL } from './messages.js' +import { + getMessagesAfterCompactBoundary, + SYNTHETIC_MESSAGES, + SYNTHETIC_MODEL, +} from './messages.js' import { jsonStringify } from './slowOperations.js' import { IncrementalTokenCounter } from './incrementalTokenCounter.js' @@ -276,6 +280,9 @@ export function messageTokenCountFromLastAPIResponse( } export function getCurrentUsage(messages: Message[]): CurrentUsage | null { + // Scrollback retains pre-compact messages, but current usage must describe + // only the context sent to the model, including all-zero usage estimates. + messages = getMessagesAfterCompactBoundary(messages) for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] const usage = message ? getTokenUsage(message) : undefined