mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 20:58:14 -04:00
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user