fix(statusline): reset context usage after full compaction (#2275)

* fix(statusline): reset context usage after full compaction

* test(statusline): cover mounted refresh after full compaction
This commit is contained in:
Ricardo-M-L
2026-10-14 08:33:37 +08:00
committed by GitHub
parent 86649c0cde
commit fc826031ae
5 changed files with 171 additions and 4 deletions
+87 -1
View File
@@ -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<Message[]> = { current: [archived] }
const initialState: AppState = {
...getDefaultAppState(),
settings: {
statusLine: { type: 'command', command: 'test-statusline' },
},
}
let latestState = initialState
let debounceTimer: ReturnType<typeof setTimeout> | 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(
<AppStateProvider
initialState={initialState}
onChangeAppState={({ newState }) => {
latestState = newState
}}
>
<StatusLine
messagesRef={messagesRef}
lastAssistantMessageId={getLastAssistantMessageId(messages)}
executeCommand={executeCommand}
/>
</AppStateProvider>,
)
}
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<typeof setTimeout>
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)
}
})
+19
View File
@@ -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({
+4 -2
View File
@@ -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,
+53
View File
@@ -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(
+8 -1
View File
@@ -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