feat: add durable context compression

Long direct-model conversations mixed provider usage with local estimates,
and Agent tool rounds could remain above the configured compression target.
Compression state and status markers also did not reliably survive restarts
or bounded history rollover.

Direct-model calls now prefer provider-reported usage, compact complete
conversation turns and Agent tool rounds within reserved payload budgets,
and persist reusable summaries with scope-specific markers. The chat meter
separates latest-call usage from estimated compressed conversation size,
while failed or cancelled calls retain the last successful measurement.

Release note: 直连模型现可在长对话和多轮工具执行中自动压缩旧上下文,并分别显示本次调用用量与压缩后对话估算;摘要会自动保存并跨重启复用,无需手动操作。
This commit is contained in:
mesalogo
2026-08-16 00:36:11 +08:00
parent b3c753fac2
commit c98fe67f1a
18 changed files with 3202 additions and 420 deletions
+335 -23
View File
@@ -2331,7 +2331,7 @@ describe('App', () => {
expect(screen.getByText('正在分析真实推理内容')).toBeVisible()
})
it('shows live context usage and keeps explicit compression status', async () => {
it('updates context usage after model responses and keeps compression status', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
@@ -2350,14 +2350,12 @@ describe('App', () => {
})
render(<App />)
await screen.findByLabelText('向 GoodBuddy 提问')
expect(
await screen.findByText(/ .+ \/ 32\.0K · \d+%/u)
).toBeInTheDocument()
expect(
screen.getByRole('progressbar', {
screen.queryByRole('progressbar', {
name: '当前上下文使用量'
})
).toBeInTheDocument()
).not.toBeInTheDocument()
expect(
screen.queryByText('只读问答,不修改文件')
).not.toBeInTheDocument()
@@ -2369,8 +2367,10 @@ describe('App', () => {
target: { value: '中'.repeat(1_000) }
})
expect(
screen.getByText(/ 5\.\dK \/ 32\.0K/u)
).toBeInTheDocument()
screen.queryByRole('progressbar', {
name: '当前上下文使用量'
})
).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
@@ -2378,10 +2378,26 @@ describe('App', () => {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'context-metrics',
contextTokens: 22_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true,
source: 'provider'
})
})
expect(
screen.getByText('本次调用 22.0K / 32.0K · 69%')
).toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'context-compression',
scope: 'conversation',
state: 'started',
estimatedBeforeTokens: 22_000,
effectiveTriggerTokens: 20_000,
@@ -2410,6 +2426,7 @@ describe('App', () => {
agentListener?.({
requestId: request.requestId,
type: 'context-compression',
scope: 'conversation',
state: 'completed',
estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000,
@@ -2419,25 +2436,105 @@ describe('App', () => {
coveredMessageCount: 2,
summaryTokens: 1_000
})
agentListener?.({
requestId: request.requestId,
type: 'context-metrics',
estimatedInputTokens: 9_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true,
recentRawTokens: 32_000,
coveredMessageCount: 2,
summaryTokens: 1_000
})
})
expect(
screen.getByText('已压缩较早对话 · ≈22.0K → ≈9.0K')
screen.getByText(
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
)
).toBeInTheDocument()
expect(
screen.getByText('上下文 ≈9.0K / 32.0K · 28%')
screen.getByText(
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
)
).toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'context-compression',
scope: 'agent-run',
state: 'started',
estimatedBeforeTokens: 24_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
recentRawTokens: 4_000,
compressionCount: 1
})
})
expect(
screen.getByText('正在整理 Agent 执行上下文…')
).toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'context-compression',
scope: 'agent-run',
state: 'completed',
estimatedBeforeTokens: 24_000,
estimatedAfterTokens: 11_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
recentRawTokens: 4_000,
compressionCount: 2
})
})
expect(
screen.getByText(
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
)
).toBeInTheDocument()
expect(
screen.getByText(
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
)
).toBeInTheDocument()
expect(
screen.getByText(
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
)
).toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'context-compression',
scope: 'conversation',
state: 'completed',
estimatedBeforeTokens: 24_000,
estimatedAfterTokens: 8_500,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
recentRawTokens: 4_000,
coveredMessageCount: 4
})
})
expect(
screen.getByText(
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
)
).toBeInTheDocument()
expect(
screen.getByText(
'已压缩较早对话(估算) · ≈24.0K → ≈8.5K'
)
).toBeInTheDocument()
expect(
screen.getByText(
'压缩后对话估算 ≈8.5K / 32.0K · 27%'
)
).toBeInTheDocument()
expect(
Array.from(
document.querySelectorAll(
'.context-compression-event__label'
)
).map((element) => element.textContent)
).toEqual([
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K',
'已压缩较早对话(估算) · ≈24.0K → ≈8.5K'
])
act(() => {
agentListener?.({
requestId: request.requestId,
@@ -2445,10 +2542,183 @@ describe('App', () => {
})
})
expect(
screen.getByText('已压缩较早对话 · ≈22.0K → ≈9.0K')
screen.getByText(
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
)
).toBeInTheDocument()
})
it('does not present the compression threshold as a context-window percentage', async () => {
const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'model',
modelProfiles: settings.modelProfiles.map((profile) => ({
...profile,
contextWindowTokens: undefined
})),
contextCompression: {
enabled: true,
triggerTokens: 20_000,
recentRawTokens: 4_000,
modelSource: { kind: 'current' },
summaryPrompt: 'Preserve important facts.'
}
})
render(<App />)
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
target: { value: '检查上下文显示' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'context-metrics',
contextTokens: 22_000,
effectiveTriggerTokens: 20_000,
compressionEnabled: true,
source: 'provider'
})
})
expect(
screen.getByText('本次调用 22.0K · 压缩线 20.0K')
).toBeInTheDocument()
expect(
screen.queryByRole('progressbar', {
name: '当前上下文使用量'
})
).not.toBeInTheDocument()
expect(screen.queryByText('110%')).not.toBeInTheDocument()
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'context-compression',
scope: 'conversation',
state: 'completed',
estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_400,
effectiveTriggerTokens: 20_000,
recentRawTokens: 4_000,
coveredMessageCount: 2
})
})
expect(
screen.getByText(
'压缩后对话估算 ≈9.4K · 压缩线 20.0K'
)
).toBeInTheDocument()
expect(
screen.queryByText('本次调用 22.0K · 压缩线 20.0K')
).not.toBeInTheDocument()
})
it('restores persisted context usage and compression state after restart', async () => {
const settings = await api.settings.getRuntime()
const profile = settings.modelProfiles[0]!
const conversationId =
'00000000-0000-4000-8000-000000000451'
const compressionState = {
coveredHistoryDigest: 'a'.repeat(64),
coveredMessageCount: 2,
coveredFromMessageId:
'00000000-0000-4000-8000-000000000452',
coveredThroughMessageId:
'00000000-0000-4000-8000-000000000453',
summary: 'Persisted conversation summary'
}
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings,
provider: 'model',
defaultModelProfileId: profile.id,
modelProfiles: settings.modelProfiles.map((candidate) => ({
...candidate,
contextWindowTokens: 32_000
}))
})
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: conversationId,
projectId,
runtimeSelection: {
provider: 'model',
profileId: profile.id
},
contextMetrics: {
runtimeSelectionKey: `model:${profile.id}`,
contextTokens: 9_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true,
source: 'estimated',
basis: 'conversation'
},
contextCompressionState: compressionState,
title: '已压缩会话',
updatedAt: 1_775_000_000_000,
messages: [
{
id: '00000000-0000-4000-8000-000000000452',
role: 'user',
content: '此前问题',
createdAt: 1_775_000_000_000,
state: 'complete'
},
{
id: '00000000-0000-4000-8000-000000000453',
role: 'assistant',
content: '此前回答',
createdAt: 1_775_000_000_001,
state: 'complete',
contextCompression: {
state: 'completed',
scope: 'conversation',
estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000
}
}
]
}
])
render(<App />)
expect(
await screen.findByText(
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
)
).toBeInTheDocument()
expect(
screen.getByText(
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
)
).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '继续工作' }
})
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(run.mock.calls[0]?.[0].contextCompressionState).toEqual(
compressionState
)
expect(run.mock.calls[0]?.[0]).toMatchObject({
historyMessageIds: [
'00000000-0000-4000-8000-000000000452',
'00000000-0000-4000-8000-000000000453'
],
currentUserMessageId: expect.any(String),
currentAssistantMessageId: expect.any(String)
})
})
it('keeps a tool failure in details and hides retry after continuing', async () => {
render(<App />)
@@ -2747,6 +3017,48 @@ describe('App', () => {
}
})
it('locks agent context controls while a response is running', async () => {
render(<App />)
const expertButton = composerMenuTrigger('专家角色')
const modeButton = composerMenuTrigger('工作模式')
const runtimeButton = await screen.findByRole('button', {
name: /sonnet-5/u
})
expect(expertButton).toBeEnabled()
expect(modeButton).toBeEnabled()
expect(runtimeButton).toBeEnabled()
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '检查运行上下文锁定' }
})
openComposerMenu('专家角色')
fireEvent.click(await screen.findByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(
screen.queryByRole('menu', { name: '专家角色' })
).not.toBeInTheDocument()
expect(expertButton).toBeDisabled()
expect(modeButton).toBeDisabled()
expect(runtimeButton).toBeDisabled()
const request = run.mock.calls[0]?.[0]
act(() => {
if (!request) {
throw new Error('Missing request')
}
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
await waitFor(() => expect(expertButton).toBeEnabled())
expect(modeButton).toBeEnabled()
expect(runtimeButton).toBeEnabled()
})
it('keeps sent documents and images in conversation history', async () => {
const documentAttachment = {
id: '00000000-0000-4000-8000-000000000301',
@@ -4134,7 +4446,7 @@ describe('App', () => {
})
)
)
expect(mode).toBeEnabled()
expect(mode).toBeDisabled()
})
it('terminalizes tools and activity when a request is cancelled', async () => {
+200 -128
View File
@@ -61,19 +61,13 @@ import type {
BrowserLiveState,
ContextAttachment,
ContextFileSelectionProgress,
KnowledgeRetrievalMode,
KnowledgeSearchReference,
KnowledgeSnapshot,
RuntimeSettings
} from '../../shared/contracts'
import {
defaultContextCompressionSettings,
maximumPastedImageBytes
} from '../../shared/contracts'
import {
estimateContextInputTokens,
getEffectiveContextTriggerTokens
} from '../../shared/context-window'
import {
agentRuntimeSelectionKey,
agentRuntimeSelectionSchema,
@@ -98,6 +92,7 @@ import type {
ConversationMessage,
ConversationSnapshot,
ConversationAttachment,
ConversationContextCompressionMarker,
ConversationMessageBlock,
LocalConversationHeader,
LocalConversationSaveBatch,
@@ -108,6 +103,7 @@ import type {
} from '../../shared/assistant-contracts'
import {
conversationAttachmentSchema,
conversationContextCompressionMarkerSchema,
conversationMessageBlocksSchema,
interactiveWorkModes,
normalizeInteractiveWorkMode,
@@ -174,6 +170,7 @@ import { ReleaseNotesDialog } from './ReleaseNotesDialog'
import { scheduleIdleRoutePreload } from './idle-route-preload'
import { createPreloadableComponent } from './preloadable-component'
import { formatTime, type TimeFormatLocale } from './time-format'
import { formatCompactTokens } from './token-format'
import {
pruneKeepAliveEntries,
touchKeepAliveEntry,
@@ -432,14 +429,7 @@ function supportsSubagentSmartRouting(
return workMode === 'ask'
}
type Conversation = {
id: string
projectId?: string
runtimeSelection?: AgentRuntimeSelection
knowledgeRetrievalMode?: KnowledgeRetrievalMode
remote?: ConversationSnapshot['remote']
title: string
updatedAt: number
type Conversation = Omit<ConversationSnapshot, 'messages'> & {
messages: Message[]
}
@@ -450,13 +440,6 @@ type ActiveRun = {
runtimeSelectionKey: string
}
type ConversationContextMetrics = Omit<
Extract<AgentEvent, { type: 'context-metrics' }>,
'requestId' | 'type'
> & {
runtimeSelectionKey: string
}
type WorkspaceView =
| 'chat'
| 'magic-notes'
@@ -1068,14 +1051,17 @@ function isConversation(value: unknown): value is Conversation {
entry.state === 'complete' ||
entry.state === 'error') &&
(entry.contextCompression === undefined ||
(typeof entry.contextCompression === 'object' &&
entry.contextCompression !== null &&
['compressing', 'completed', 'failed'].includes(
String(
(
entry.contextCompression as Record<string, unknown>
).state
)
conversationContextCompressionMarkerSchema.safeParse(
entry.contextCompression
).success) &&
(entry.contextCompressions === undefined ||
(Array.isArray(entry.contextCompressions) &&
entry.contextCompressions.length <= 2 &&
entry.contextCompressions.every(
(compression) =>
conversationContextCompressionMarkerSchema.safeParse(
compression
).success
))) &&
(entry.artifactIds === undefined ||
(Array.isArray(entry.artifactIds) &&
@@ -1103,6 +1089,8 @@ function toConversationSnapshots(
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
title: conversation.title,
updatedAt: conversation.updatedAt,
messages: conversation.messages
@@ -1122,6 +1110,7 @@ function toConversationMessage(message: Message): ConversationMessage {
state: message.state,
status: message.status,
contextCompression: message.contextCompression,
contextCompressions: message.contextCompressions,
tools: message.tools,
sources: message.sources,
sourceReferences: message.sourceReferences,
@@ -1139,6 +1128,8 @@ function toLocalConversationHeader(
projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
title: conversation.title,
updatedAt: conversation.updatedAt
}
@@ -1307,14 +1298,6 @@ function formatAttachmentSize(size: number): string {
return `${Math.max(1, Math.ceil(size / 1024))} KB`
}
function formatCompactContextTokens(tokens: number): string {
if (tokens < 1_000) {
return tokens.toLocaleString()
}
const value = tokens / 1_000
return `${value >= 100 ? Math.round(value) : value.toFixed(1)}K`
}
const composerTextareaMinHeight = 72
const composerTextareaMaxHeight = 220
@@ -1778,8 +1761,6 @@ function App(): React.JSX.Element {
const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
const [contextMetricsByConversation, setContextMetricsByConversation] =
useState<Record<string, ConversationContextMetrics>>({})
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
const [composerMenuOpen, setComposerMenuOpen] = useState<
'expert' | 'mode' | undefined
@@ -3138,25 +3119,103 @@ function App(): React.JSX.Element {
const { requestId: _requestId, type: _type, ...metrics } = event
void _requestId
void _type
setContextMetricsByConversation((current) => ({
...current,
[run.conversationId]: {
...metrics,
runtimeSelectionKey: run.runtimeSelectionKey
}
}))
setConversations((current) =>
current.map((conversation) =>
conversation.id === run.conversationId
? {
...conversation,
contextMetrics: {
...metrics,
basis: 'model-call',
runtimeSelectionKey: run.runtimeSelectionKey
}
}
: conversation
)
)
} else if (event.type === 'context-compression') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
contextCompression: {
const estimatedAfterTokens = event.estimatedAfterTokens
const conversationScoped = event.scope !== 'agent-run'
const scope = event.scope ?? 'conversation'
const marker: ConversationContextCompressionMarker = {
state:
event.state === 'started'
? 'compressing'
: 'completed',
: event.state,
scope,
estimatedBeforeTokens: event.estimatedBeforeTokens,
estimatedAfterTokens: event.estimatedAfterTokens
estimatedAfterTokens: event.estimatedAfterTokens,
compressionCount: event.compressionCount
}
}))
updateMessage(run.conversationId, run.messageId, (message) => {
const current =
message.contextCompressions ??
(message.contextCompression
? [message.contextCompression]
: [])
const existingIndex = current.findIndex(
(compression) =>
(compression.scope ?? 'conversation') === scope
)
const contextCompressions =
existingIndex >= 0
? [
...current.filter(
(_compression, index) =>
index !== existingIndex
),
marker
]
: [...current, marker]
return {
...message,
contextCompression: undefined,
contextCompressions
}
})
if (
conversationScoped &&
event.state === 'completed' &&
estimatedAfterTokens !== undefined
) {
setConversations((current) =>
current.map((conversation) =>
conversation.id === run.conversationId
? {
...conversation,
contextMetrics: {
runtimeSelectionKey: run.runtimeSelectionKey,
contextTokens: estimatedAfterTokens,
effectiveTriggerTokens:
event.effectiveTriggerTokens,
contextWindowTokens:
event.contextWindowTokens,
compressionEnabled: true,
source: 'estimated',
basis: 'conversation'
},
contextCompressionState:
event.conversationState ??
conversation.contextCompressionState
}
: conversation
)
)
} else if (
conversationScoped &&
event.conversationState
) {
setConversations((current) =>
current.map((conversation) =>
conversation.id === run.conversationId
? {
...conversation,
contextCompressionState: event.conversationState
}
: conversation
)
)
}
} else if (event.type === 'status') {
updateMessage(run.conversationId, run.messageId, (message) => ({
...message,
@@ -3457,6 +3516,17 @@ function App(): React.JSX.Element {
state: 'failed' as const
}
: message.contextCompression,
contextCompressions:
event.type === 'error'
? message.contextCompressions?.map((compression) =>
compression.state === 'compressing'
? {
...compression,
state: 'failed' as const
}
: compression
)
: message.contextCompressions,
approval: undefined,
question: undefined,
tools: toolTerminalState
@@ -4965,6 +5035,12 @@ function App(): React.JSX.Element {
const conversationId = activeConversation.id
const attachmentSnapshot = attachments.slice(0, 8)
const historySnapshot = activeConversation.messages
const retainedHistorySnapshot = historySnapshot
.filter(
(message) =>
message.state === 'complete' && message.content.trim()
)
.slice(-500)
const projectIdSnapshot = activeProjectId || undefined
const knowledgeRetrievalModeSnapshot =
activeConversation.knowledgeRetrievalMode ?? 'auto'
@@ -4976,6 +5052,8 @@ function App(): React.JSX.Element {
const selectedExpertSnapshot =
runtime.capability === 'image-generation' ? '' : selectedExpertId
const workModeSnapshot = effectiveWorkMode
setComposerMenuOpen(undefined)
setRuntimeMenuOpen(false)
preparingConversations.current.add(conversationId)
setConversationActivity(conversationId, true)
setInput('')
@@ -5097,16 +5175,17 @@ function App(): React.JSX.Element {
contextIds: attachmentSnapshot.map(
(attachment) => attachment.id
),
history: historySnapshot
.filter(
(message) =>
message.state === 'complete' && message.content.trim()
)
.slice(-500)
.map((message) => ({
role: message.role,
content: message.content
}))
contextCompressionState:
activeConversation.contextCompressionState,
history: retainedHistorySnapshot.map((message) => ({
role: message.role,
content: message.content
})),
historyMessageIds: retainedHistorySnapshot.map(
(message) => message.id
),
currentUserMessageId: userMessage.id,
currentAssistantMessageId: assistantMessage.id
})
for (const attachment of attachmentSnapshot) {
void window.goodbuddy.context.remove(attachment.id)
@@ -5651,55 +5730,38 @@ function App(): React.JSX.Element {
) {
return undefined
}
const compression =
runtimeSettings.contextCompression ??
defaultContextCompressionSettings
const latest = contextMetricsByConversation[activeConversation.id]
const latest = activeConversation.contextMetrics
const applicableLatest =
latest?.runtimeSelectionKey === activeRuntimeSelectionKey
? latest
: undefined
const history = activeConversation.messages
.filter(
(message) =>
message.state === 'complete' && message.content.trim()
)
.map((message) => ({
role: message.role,
content: message.content
}))
const coveredMessageCount = Math.min(
applicableLatest?.coveredMessageCount ?? 0,
history.length
)
const estimatedInputTokens =
isRunning && applicableLatest
? applicableLatest.estimatedInputTokens
: estimateContextInputTokens({
history: history.slice(coveredMessageCount),
prompt: input,
summaryTokens: applicableLatest?.summaryTokens ?? 0
})
if (!applicableLatest) {
return undefined
}
const contextTokens = applicableLatest.contextTokens
const effectiveTriggerTokens =
getEffectiveContextTriggerTokens({
triggerTokens: compression.triggerTokens,
contextWindowTokens: profile.contextWindowTokens
})
applicableLatest.effectiveTriggerTokens
const denominatorTokens =
profile.contextWindowTokens ??
(compression.enabled ? effectiveTriggerTokens : undefined)
applicableLatest.contextWindowTokens
const percentage =
denominatorTokens === undefined
? undefined
: Math.round(
(estimatedInputTokens / denominatorTokens) * 100
(contextTokens / denominatorTokens) * 100
)
return {
estimatedInputTokens,
contextTokens,
effectiveTriggerTokens,
contextWindowTokens: profile.contextWindowTokens,
compressionEnabled: compression.enabled,
contextWindowTokens: applicableLatest.contextWindowTokens,
compressionEnabled: applicableLatest.compressionEnabled,
source: applicableLatest.source,
basis:
applicableLatest.basis ??
(applicableLatest.source === 'estimated' &&
activeConversation.contextCompressionState
? 'conversation'
: 'model-call'),
denominatorTokens,
percentage
}
@@ -5707,9 +5769,6 @@ function App(): React.JSX.Element {
activeConversation,
activeRuntimeSelection,
activeRuntimeSelectionKey,
contextMetricsByConversation,
input,
isRunning,
runtimeSettings
])
@@ -6658,6 +6717,7 @@ function App(): React.JSX.Element {
ariaLabel={t('composer.expertLabel')}
className="composer-picker--expert"
disabled={
isRunning ||
runtime?.capability === 'image-generation'
}
icon={<Bot aria-hidden="true" size={15} />}
@@ -6670,6 +6730,7 @@ function App(): React.JSX.Element {
<ComposerMenuSelect
ariaLabel={t('composer.modeLabel')}
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
disabled={isRunning}
icon={
effectiveWorkMode === 'execute' ? (
<ShieldCheck aria-hidden="true" size={15} />
@@ -6979,7 +7040,7 @@ function App(): React.JSX.Element {
title={
composerContextMetrics.compressionEnabled
? t('composer.context.compressionTrigger', {
tokens: formatCompactContextTokens(
tokens: formatCompactTokens(
composerContextMetrics.effectiveTriggerTokens
)
})
@@ -6988,33 +7049,44 @@ function App(): React.JSX.Element {
>
<span className="composer-context-meter__summary">
{composerContextMetrics.denominatorTokens === undefined
? t('composer.context.tokenCount', {
used: formatCompactContextTokens(
composerContextMetrics.estimatedInputTokens
? t(
composerContextMetrics.basis === 'conversation'
? composerContextMetrics.compressionEnabled
? 'composer.context.conversationThresholdUsage'
: 'composer.context.conversationTokenCount'
: composerContextMetrics.compressionEnabled
? composerContextMetrics.source === 'provider'
? 'composer.context.confirmedThresholdUsage'
: 'composer.context.thresholdUsage'
: composerContextMetrics.source === 'provider'
? 'composer.context.confirmedTokenCount'
: 'composer.context.tokenCount',
{
used: formatCompactTokens(
composerContextMetrics.contextTokens
),
total: formatCompactTokens(
composerContextMetrics.effectiveTriggerTokens
)
}
)
})
: composerContextMetrics.contextWindowTokens ===
undefined
? t('composer.context.thresholdUsage', {
used: formatCompactContextTokens(
composerContextMetrics.estimatedInputTokens
),
total: formatCompactContextTokens(
composerContextMetrics.denominatorTokens
),
percentage:
composerContextMetrics.percentage ?? 0
})
: t('composer.context.windowUsage', {
used: formatCompactContextTokens(
composerContextMetrics.estimatedInputTokens
),
total: formatCompactContextTokens(
composerContextMetrics.denominatorTokens
),
percentage:
composerContextMetrics.percentage ?? 0
})}
: t(
composerContextMetrics.basis === 'conversation'
? 'composer.context.conversationWindowUsage'
: composerContextMetrics.source === 'provider'
? 'composer.context.confirmedWindowUsage'
: 'composer.context.windowUsage',
{
used: formatCompactTokens(
composerContextMetrics.contextTokens
),
total: formatCompactTokens(
composerContextMetrics.denominatorTokens
),
percentage:
composerContextMetrics.percentage ?? 0
}
)}
</span>
{composerContextMetrics.denominatorTokens !== undefined && (
<div
@@ -7024,7 +7096,7 @@ function App(): React.JSX.Element {
}
aria-valuemin={0}
aria-valuenow={Math.min(
composerContextMetrics.estimatedInputTokens,
composerContextMetrics.contextTokens,
composerContextMetrics.denominatorTokens
)}
className="composer-context-meter__track"
+27 -9
View File
@@ -86,7 +86,7 @@ describe('ChatTimeline', () => {
expect(unchangedDetails).toHaveAttribute('open')
})
it('places compression progress between the user and assistant messages', () => {
it('keeps Agent and conversation compression markers below the assistant message', () => {
const messages: Message[] = [
{
id: 'user-message',
@@ -99,11 +99,21 @@ describe('ChatTimeline', () => {
id: 'assistant-message',
role: 'assistant',
content: 'Answer',
contextCompression: {
state: 'completed',
estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000
},
contextCompressions: [
{
state: 'completed',
scope: 'agent-run',
estimatedBeforeTokens: 24_000,
estimatedAfterTokens: 11_000,
compressionCount: 2
},
{
state: 'completed',
scope: 'conversation',
estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000
}
],
createdAt: 1_775_000_001_000,
state: 'complete'
}
@@ -128,11 +138,19 @@ describe('ChatTimeline', () => {
)
expect(children.map((element) => element.className)).toEqual([
'message message--user',
'message message--assistant',
'context-compression-event context-compression-event--completed',
'message message--assistant'
'context-compression-event context-compression-event--completed'
])
expect(
screen.getByRole('status')
).toHaveTextContent('已压缩较早对话 · ≈22.0K → ≈9.0K')
screen.getByText(
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
)
).toBeInTheDocument()
expect(
screen.getByText(
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
)
).toBeInTheDocument()
})
})
+61 -47
View File
@@ -18,12 +18,15 @@ import type {
import type {
AssistantArtifact,
ConversationAttachment,
ConversationContextCompressionMarker,
ConversationMessage,
ConversationMessageBlock,
ConversationToolActivity
} from '../../shared/assistant-contracts'
import { AgentQuestionCard } from './AgentQuestionCard'
import { MarkdownRenderer } from './MarkdownRenderer'
import { formatTime, type TimeFormatLocale } from './time-format'
import { formatCompactTokens } from './token-format'
export type ToolActivity = ConversationToolActivity
@@ -51,11 +54,8 @@ export type Message = {
createdAt: number
state: 'streaming' | 'complete' | 'error'
status?: string
contextCompression?: {
state: 'compressing' | 'completed' | 'failed'
estimatedBeforeTokens: number
estimatedAfterTokens?: number
}
contextCompression?: ConversationMessage['contextCompression']
contextCompressions?: ConversationMessage['contextCompressions']
tools?: ToolActivity[]
subagents?: SubagentActivity[]
approval?: {
@@ -79,14 +79,6 @@ export type ImageViewerItem = {
title: string
}
function formatCompactTokens(tokens: number): string {
if (tokens < 1_000) {
return tokens.toLocaleString()
}
const value = tokens / 1_000
return `${value >= 100 ? Math.round(value) : value.toFixed(1)}K`
}
type MessageBlockRenderItem =
| {
kind: 'block'
@@ -281,43 +273,42 @@ function ChatMessageRowView({
retryContent
}: ChatMessageRowProps): React.JSX.Element {
const { t } = useTranslation('app')
const compressionMarkers =
message.contextCompressions ??
(message.contextCompression ? [message.contextCompression] : [])
const compressionLabel = (
compression: ConversationContextCompressionMarker
): string =>
compression.state === 'compressing'
? compression.scope === 'agent-run'
? t('chat.contextCompression.agentCompressing')
: t('chat.contextCompression.compressing')
: compression.state === 'completed' &&
compression.estimatedAfterTokens !== undefined
? compression.scope === 'agent-run'
? t('chat.contextCompression.agentCompleted', {
before: formatCompactTokens(
compression.estimatedBeforeTokens
),
after: formatCompactTokens(
compression.estimatedAfterTokens
),
count: compression.compressionCount ?? 1
})
: t('chat.contextCompression.completed', {
before: formatCompactTokens(
compression.estimatedBeforeTokens
),
after: formatCompactTokens(
compression.estimatedAfterTokens
)
})
: compression.scope === 'agent-run'
? t('chat.contextCompression.agentFailed')
: t('chat.contextCompression.failed')
return (
<>
{message.role === 'assistant' && message.contextCompression && (
<div
aria-live="polite"
className={`context-compression-event context-compression-event--${message.contextCompression.state}`}
role="status"
>
<span className="context-compression-event__line" />
<span className="context-compression-event__label">
<span
aria-hidden="true"
className={
message.contextCompression.state === 'compressing'
? 'message__status-dot message__status-dot--active'
: 'message__status-dot'
}
/>
{message.contextCompression.state === 'compressing'
? t('chat.contextCompression.compressing')
: message.contextCompression.state === 'completed' &&
message.contextCompression.estimatedAfterTokens !==
undefined
? t('chat.contextCompression.completed', {
before: formatCompactTokens(
message.contextCompression.estimatedBeforeTokens
),
after: formatCompactTokens(
message.contextCompression.estimatedAfterTokens
)
})
: t('chat.contextCompression.failed')}
</span>
<span className="context-compression-event__line" />
</div>
)}
<article
className={`message message--${message.role}`}
ref={(element) => onArticleRef(message.id, element)}
@@ -803,6 +794,29 @@ function ChatMessageRowView({
)}
</div>
</article>
{message.role === 'assistant' &&
compressionMarkers.map((compression, index) => (
<div
aria-live="polite"
className={`context-compression-event context-compression-event--${compression.state}`}
key={`${compression.scope ?? 'conversation'}:${index}`}
role="status"
>
<span className="context-compression-event__line" />
<span className="context-compression-event__label">
<span
aria-hidden="true"
className={
compression.state === 'compressing'
? 'message__status-dot message__status-dot--active'
: 'message__status-dot'
}
/>
{compressionLabel(compression)}
</span>
<span className="context-compression-event__line" />
</div>
))}
</>
)
}
+21 -5
View File
@@ -191,8 +191,12 @@ export const app = {
contextCompression: {
compressing: 'Compressing earlier conversation…',
completed:
'Earlier conversation compressed · ≈{{before}} → ≈{{after}}',
failed: 'Earlier conversation compression failed'
'Earlier conversation compressed (estimated) · ≈{{before}} → ≈{{after}}',
failed: 'Earlier conversation compression failed',
agentCompressing: 'Compacting Agent execution context…',
agentCompleted:
'Agent context compacted {{count}} time(s) (estimated) · ≈{{before}} → ≈{{after}}',
agentFailed: 'Agent execution context compression failed'
},
sources: 'Sources: {{sources}}',
citations: {
@@ -322,10 +326,22 @@ export const app = {
sendTitle: 'Send message',
shortcut: 'Quick access: ',
context: {
tokenCount: 'Context {{used}}',
windowUsage: 'Context ≈{{used}} / {{total}} · {{percentage}}%',
confirmedTokenCount: 'Latest call {{used}}',
tokenCount: 'Estimated latest call ≈{{used}}',
confirmedWindowUsage:
'Latest call {{used}} / {{total}} · {{percentage}}%',
windowUsage:
'Estimated latest call ≈{{used}} / {{total}} · {{percentage}}%',
confirmedThresholdUsage:
'Latest call {{used}} · Compression at {{total}}',
thresholdUsage:
'Compression threshold ≈{{used}} / {{total}} · {{percentage}}%',
'Estimated latest call ≈{{used}} · Compression at {{total}}',
conversationTokenCount:
'Estimated compressed conversation ≈{{used}}',
conversationWindowUsage:
'Estimated compressed conversation ≈{{used}} / {{total}} · {{percentage}}%',
conversationThresholdUsage:
'Estimated compressed conversation ≈{{used}} · Compression at {{total}}',
progressLabel: 'Current context usage',
compressionTrigger: 'Automatic compression at ≈{{tokens}}'
},
+21 -5
View File
@@ -185,8 +185,13 @@ export const app = {
},
contextCompression: {
compressing: '正在压缩较早对话…',
completed: '已压缩较早对话 · ≈{{before}} → ≈{{after}}',
failed: '较早对话压缩失败'
completed:
'已压缩较早对话(估算) · ≈{{before}} → ≈{{after}}',
failed: '较早对话压缩失败',
agentCompressing: '正在整理 Agent 执行上下文…',
agentCompleted:
'Agent 执行期间已压缩上下文 {{count}} 次(估算) · ≈{{before}} → ≈{{after}}',
agentFailed: 'Agent 执行上下文压缩失败'
},
sources: '来源:{{sources}}',
citations: {
@@ -313,10 +318,21 @@ export const app = {
sendTitle: '发送消息',
shortcut: '快捷唤起:',
context: {
tokenCount: '上下文 ≈{{used}}',
windowUsage: '上下文 ≈{{used}} / {{total}} · {{percentage}}%',
confirmedTokenCount: '本次调用 {{used}}',
tokenCount: '本次调用估算 ≈{{used}}',
confirmedWindowUsage:
'本次调用 {{used}} / {{total}} · {{percentage}}%',
windowUsage:
'本次调用估算 ≈{{used}} / {{total}} · {{percentage}}%',
confirmedThresholdUsage:
'本次调用 {{used}} · 压缩线 {{total}}',
thresholdUsage:
'距压缩阈值 ≈{{used}} / {{total}} · {{percentage}}%',
'本次调用估算 ≈{{used}} · 压缩线 {{total}}',
conversationTokenCount: '压缩后对话估算 ≈{{used}}',
conversationWindowUsage:
'压缩后对话估算 ≈{{used}} / {{total}} · {{percentage}}%',
conversationThresholdUsage:
'压缩后对话估算 ≈{{used}} · 压缩线 {{total}}',
progressLabel: '当前上下文使用量',
compressionTrigger: '自动压缩线:≈{{tokens}}'
},
+7
View File
@@ -0,0 +1,7 @@
export function formatCompactTokens(tokens: number): string {
if (tokens < 1_000) {
return tokens.toLocaleString()
}
const value = tokens / 1_000
return `${value >= 100 ? Math.round(value) : value.toFixed(1)}K`
}