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
+1
View File
@@ -25,6 +25,7 @@
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。 - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。 - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。 - [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计。 - [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计。
+101
View File
@@ -5,6 +5,7 @@ import {
} from '../../shared/contracts' } from '../../shared/contracts'
import { import {
estimateTextTokens, estimateTextTokens,
planPrefixCompression,
planContextCompression planContextCompression
} from './context-compression' } from './context-compression'
@@ -39,6 +40,39 @@ describe('context compression planning', () => {
).toBeUndefined() ).toBeUndefined()
}) })
it('does not compress small history because of transient completed-call context', () => {
const history = [
{ role: 'user' as const, content: 'Earlier question' },
{ role: 'assistant' as const, content: 'Earlier answer' }
]
const plan = planContextCompression({
history,
prompt: '',
settings: compressionSettings({ triggerTokens: 20_000 }),
triggerContextTokens: 21_000,
allowCompressLatestTurn: true
})
expect(plan).toBeUndefined()
})
it('reports the conversation estimate when completed-call usage only triggers planning', () => {
const history = [
{ role: 'user' as const, content: 'a'.repeat(20_000) },
{ role: 'assistant' as const, content: 'b'.repeat(20_000) }
]
const plan = planContextCompression({
history,
prompt: '',
settings: compressionSettings({ triggerTokens: 20_000 }),
triggerContextTokens: 21_000,
allowCompressLatestTurn: true
})
expect(plan?.earlierMessages).toEqual(history)
expect(plan?.estimatedInputTokens).toBeLessThan(21_000)
})
it('preserves recent complete turns within the raw token budget', () => { it('preserves recent complete turns within the raw token budget', () => {
const history = [ const history = [
{ role: 'user' as const, content: `old-user-${'a'.repeat(8_000)}` }, { role: 'user' as const, content: `old-user-${'a'.repeat(8_000)}` },
@@ -70,6 +104,73 @@ describe('context compression planning', () => {
expect(plan?.recentMessages).toEqual(history.slice(4)) expect(plan?.recentMessages).toEqual(history.slice(4))
}) })
it('keeps the newest atomic unit when planning a generic prefix', () => {
const units = [
{ id: 'round-1', tokens: 6_000 },
{ id: 'round-2', tokens: 6_000 },
{ id: 'round-3', tokens: 6_000 }
]
const plan = planPrefixCompression({
units,
estimatedInputTokens: 22_000,
effectiveTriggerTokens: 20_000,
recentRawTokens: 5_000,
estimateUnitTokens: (unit) => unit.tokens
})
expect(plan?.earlierUnits).toEqual(units.slice(0, 2))
expect(plan?.recentUnits).toEqual(units.slice(2))
})
it('does not split the only available atomic unit', () => {
expect(
planPrefixCompression({
units: [{ id: 'round-1', tokens: 25_000 }],
estimatedInputTokens: 30_000,
effectiveTriggerTokens: 20_000,
recentRawTokens: 5_000,
estimateUnitTokens: (unit) => unit.tokens
})
).toBeUndefined()
})
it('can compress the latest atomic unit after a completed response', () => {
const unit = { id: 'completed-turn', tokens: 25_000 }
const plan = planPrefixCompression({
units: [unit],
estimatedInputTokens: 30_000,
effectiveTriggerTokens: 20_000,
recentRawTokens: 5_000,
estimateUnitTokens: (candidate) => candidate.tokens,
allowCompressLatestUnit: true
})
expect(plan?.earlierUnits).toEqual([unit])
expect(plan?.recentUnits).toEqual([])
})
it('uses the remaining payload budget when preserving recent units', () => {
const units = [
{ id: 'round-1', tokens: 8_000 },
{ id: 'round-2', tokens: 8_000 },
{ id: 'round-3', tokens: 8_000 }
]
const plan = planPrefixCompression({
units,
estimatedInputTokens: 36_000,
effectiveTriggerTokens: 32_000,
recentRawTokens: 20_000,
estimateUnitTokens: (unit) => unit.tokens,
maximumRecentRawTokens: 10_000
})
expect(plan?.earlierUnits).toEqual(units.slice(0, 2))
expect(plan?.recentUnits).toEqual(units.slice(2))
})
it('uses an optional model context limit as an earlier trigger', () => { it('uses an optional model context limit as an earlier trigger', () => {
const history = [ const history = [
{ role: 'user' as const, content: 'a'.repeat(16_000) }, { role: 'user' as const, content: 'a'.repeat(16_000) },
+101 -24
View File
@@ -22,6 +22,15 @@ export type ContextCompressionPlan = {
effectiveTriggerTokens: number effectiveTriggerTokens: number
} }
export type PrefixCompressionPlan<T> = {
earlierUnits: T[]
recentUnits: T[]
estimatedInputTokens: number
effectiveTriggerTokens: number
}
export const contextSummaryTokenBudget = 8_192
function groupConversationTurns( function groupConversationTurns(
messages: readonly CompressibleConversationMessage[] messages: readonly CompressibleConversationMessage[]
): CompressibleConversationMessage[][] { ): CompressibleConversationMessage[][] {
@@ -40,54 +49,122 @@ function groupConversationTurns(
return turns return turns
} }
export function planPrefixCompression<T>(input: {
units: readonly T[]
estimatedInputTokens: number
effectiveTriggerTokens: number
recentRawTokens: number
estimateUnitTokens: (unit: T) => number
allowCompressLatestUnit?: boolean
maximumRecentRawTokens?: number
}): PrefixCompressionPlan<T> | undefined {
if (
input.estimatedInputTokens < input.effectiveTriggerTokens ||
input.units.length === 0 ||
(input.units.length < 2 && !input.allowCompressLatestUnit)
) {
return undefined
}
const recentRawTokenBudget = Math.min(
input.recentRawTokens,
Math.max(0, input.maximumRecentRawTokens ?? Number.MAX_SAFE_INTEGER)
)
if (input.units.length === 1 && input.allowCompressLatestUnit) {
if (
input.estimateUnitTokens(input.units[0]!) <=
recentRawTokenBudget
) {
return undefined
}
return {
earlierUnits: [...input.units],
recentUnits: [],
estimatedInputTokens: input.estimatedInputTokens,
effectiveTriggerTokens: input.effectiveTriggerTokens
}
}
const earlierUnits = [...input.units]
const recentUnits: T[] = []
let recentTokens = 0
while (earlierUnits.length > 0) {
const unit = earlierUnits.at(-1)!
const unitTokens = input.estimateUnitTokens(unit)
if (
(recentUnits.length > 0 || input.allowCompressLatestUnit) &&
recentTokens + unitTokens > recentRawTokenBudget
) {
break
}
recentUnits.unshift(earlierUnits.pop()!)
recentTokens += unitTokens
}
if (earlierUnits.length === 0) {
return undefined
}
return {
earlierUnits,
recentUnits,
estimatedInputTokens: input.estimatedInputTokens,
effectiveTriggerTokens: input.effectiveTriggerTokens
}
}
export function planContextCompression(input: { export function planContextCompression(input: {
history: readonly CompressibleConversationMessage[] history: readonly CompressibleConversationMessage[]
prompt: string prompt: string
summaryTokens?: number summaryTokens?: number
settings: ContextCompressionSettings settings: ContextCompressionSettings
contextWindowTokens?: number contextWindowTokens?: number
allowCompressLatestTurn?: boolean
effectiveTriggerTokens?: number
triggerContextTokens?: number
}): ContextCompressionPlan | undefined { }): ContextCompressionPlan | undefined {
const estimatedInputTokens = estimateContextInputTokens({ const estimatedInputTokens = estimateContextInputTokens({
history: input.history, history: input.history,
prompt: input.prompt, prompt: input.prompt,
summaryTokens: input.summaryTokens summaryTokens: input.summaryTokens
}) })
const effectiveTriggerTokens = getEffectiveContextTriggerTokens({ const effectiveTriggerTokens =
input.effectiveTriggerTokens ??
getEffectiveContextTriggerTokens({
triggerTokens: input.settings.triggerTokens, triggerTokens: input.settings.triggerTokens,
contextWindowTokens: input.contextWindowTokens contextWindowTokens: input.contextWindowTokens
}) })
if (estimatedInputTokens < effectiveTriggerTokens) { const planningInputTokens = Math.max(
estimatedInputTokens,
input.triggerContextTokens ?? 0
)
if (planningInputTokens < effectiveTriggerTokens) {
return undefined return undefined
} }
const fixedContextTokens = estimateContextInputTokens({
history: [],
prompt: input.prompt,
summaryTokens: contextSummaryTokenBudget
})
const turns = groupConversationTurns(input.history) const turns = groupConversationTurns(input.history)
const recentTurns: CompressibleConversationMessage[][] = [] const plan = planPrefixCompression({
const recentRawTokenBudget = Math.min( units: turns,
input.settings.recentRawTokens, estimatedInputTokens: planningInputTokens,
Math.max(4_000, effectiveTriggerTokens - 8_000) effectiveTriggerTokens,
recentRawTokens: input.settings.recentRawTokens,
estimateUnitTokens: estimateMessagesTokens,
allowCompressLatestUnit: input.allowCompressLatestTurn,
maximumRecentRawTokens: Math.max(
0,
effectiveTriggerTokens - fixedContextTokens
) )
let recentTokens = 0 })
while (turns.length > 0) { if (!plan) {
const turn = turns.at(-1)!
const turnTokens = estimateMessagesTokens(turn)
if (
recentTurns.length > 0 &&
recentTokens + turnTokens > recentRawTokenBudget
) {
break
}
recentTurns.unshift(turns.pop()!)
recentTokens += turnTokens
}
const earlierMessages = turns.flat()
if (earlierMessages.length === 0) {
return undefined return undefined
} }
return { return {
earlierMessages, earlierMessages: plan.earlierUnits.flat(),
recentMessages: recentTurns.flat(), recentMessages: plan.recentUnits.flat(),
estimatedInputTokens, estimatedInputTokens,
effectiveTriggerTokens effectiveTriggerTokens: plan.effectiveTriggerTokens
} }
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+356 -8
View File
@@ -165,6 +165,75 @@ class RealModelConfigToolProvider implements ModelToolProviderLike {
} }
} }
class RealLongAgentToolProvider implements ModelToolProviderLike {
readonly completedSteps: number[] = []
async listTools(): Promise<ModelToolDefinition[]> {
const expectedStep = this.completedSteps.length + 1
return [
{
name: 'record_progress',
displayName: 'Record progress',
description:
expectedStep <= 3
? `Record required progress step ${expectedStep}. Call exactly once with step ${expectedStep} before continuing.`
: 'All required progress is recorded. Do not call this tool again.',
inputSchema: {
type: 'object',
properties: {
step: {
type: 'integer',
const: expectedStep
}
},
required: ['step'],
additionalProperties: false
},
source: 'builtin'
}
]
}
getApproval() {
return {
scopeKey: 'real-long-agent-test',
title: 'Record test progress',
description: 'Record deterministic E2E progress',
allowPermanent: false
}
}
async callTool(
name: string,
argumentsValue: Record<string, unknown>,
signal: AbortSignal
): Promise<ModelToolResult> {
signal.throwIfAborted()
const expectedStep = this.completedSteps.length + 1
if (
name !== 'record_progress' ||
argumentsValue.step !== expectedStep ||
expectedStep > 3
) {
throw new Error(
`Unexpected progress call: ${name} ${JSON.stringify(argumentsValue)}`
)
}
this.completedSteps.push(expectedStep)
const text = [
`STEP_${expectedStep}_RECORDED`,
`evidence-${expectedStep} `.repeat(4_000)
].join('\n')
return {
parts: [{ type: 'text', text }],
contextBytes: Buffer.byteLength(text)
}
}
async releaseConversation(): Promise<void> {}
async dispose(): Promise<void> {}
}
describe.runIf(enabled)('runtime end-to-end', () => { describe.runIf(enabled)('runtime end-to-end', () => {
let workspace = '' let workspace = ''
@@ -214,6 +283,104 @@ describe.runIf(enabled)('runtime end-to-end', () => {
120_000 120_000
) )
it(
'counts a real image in provider-reported input usage',
async () => {
const runtime = new ModelAgentRuntime({
apiKey,
baseUrl,
model: modelName,
protocol,
authentication: 'api-key',
supportsImageInput: true,
maxOutputTokens: 128,
contextCompression: {
settings: {
...defaultContextCompressionSettings,
enabled: true
},
contextWindowTokens: 32_000
}
})
const baselineEvents: RuntimeEvent[] = []
const imageEvents: RuntimeEvent[] = []
const prompt =
'Return exactly this text and nothing else: IMAGE_USAGE_E2E_OK'
try {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
workMode: 'ask',
prompt
},
new AbortController().signal
)) {
baselineEvents.push(event)
}
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
workMode: 'ask',
prompt,
images: [
{
name: 'goodbuddy-icon.png',
mediaType: 'image/png',
data: await readFile(
join(process.cwd(), 'build', 'icon.png'),
'base64'
)
}
]
},
new AbortController().signal
)) {
imageEvents.push(event)
}
} finally {
await runtime.dispose()
}
const baselineUsage = baselineEvents.find(
(
event
): event is Extract<RuntimeEvent, { type: 'model-usage' }> =>
event.type === 'model-usage'
)
const imageUsage = imageEvents.find(
(
event
): event is Extract<RuntimeEvent, { type: 'model-usage' }> =>
event.type === 'model-usage'
)
expect(baselineUsage).toBeDefined()
expect(imageUsage).toBeDefined()
expect(imageUsage!.inputTokens).toBeGreaterThan(
baselineUsage!.inputTokens
)
expect(
imageEvents.filter(
(event) => event.type === 'context-metrics'
)
).toEqual([
expect.objectContaining({
source: 'provider',
contextTokens:
imageUsage!.inputTokens +
imageUsage!.outputTokens +
(protocol === 'anthropic-messages'
? imageUsage!.cacheReadTokens +
imageUsage!.cacheWriteTokens
: 0)
})
])
},
180_000
)
it( it(
'compresses real direct-model history and preserves earlier and recent facts', 'compresses real direct-model history and preserves earlier and recent facts',
async () => { async () => {
@@ -247,7 +414,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
content: [ content: [
'The project codename is ORBIT-739.', 'The project codename is ORBIT-739.',
'Background notes:', 'Background notes:',
'alpha '.repeat(5_000) 'alpha '.repeat(8_000)
].join('\n') ].join('\n')
}, },
{ {
@@ -255,7 +422,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
content: [ content: [
'I will remember the project codename.', 'I will remember the project codename.',
'Acknowledgement notes:', 'Acknowledgement notes:',
'gamma '.repeat(4_000) 'gamma '.repeat(6_500)
].join('\n') ].join('\n')
}, },
{ {
@@ -263,7 +430,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
content: [ content: [
'The deploy region is AP-SOUTH-7.', 'The deploy region is AP-SOUTH-7.',
'Recent notes:', 'Recent notes:',
'beta '.repeat(3_000) 'beta '.repeat(5_000)
].join('\n') ].join('\n')
}, },
{ {
@@ -312,6 +479,172 @@ describe.runIf(enabled)('runtime end-to-end', () => {
120_000 120_000
) )
it(
'compresses context after a real completed response reaches the threshold',
async () => {
const expectedOutput = [
'POST_RESPONSE_COMPRESSION_E2E_OK_',
'SAFE'.repeat(16)
].join('')
const prompt = `Return exactly this text and nothing else: ${expectedOutput}`
const history = [
{
role: 'user' as const,
content: `baseline\n${'alpha '.repeat(8_500)}`
},
{
role: 'assistant' as const,
content: 'ack'
}
]
const runtime = new ModelAgentRuntime({
apiKey,
baseUrl,
model: modelName,
protocol,
authentication: 'api-key',
maxOutputTokens: 128,
contextCompression: {
settings: {
...defaultContextCompressionSettings,
enabled: true,
triggerTokens: 8_000,
recentRawTokens: 4_000
},
contextWindowTokens: 32_000
}
})
const events: RuntimeEvent[] = []
try {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
workMode: 'ask',
prompt,
history
},
new AbortController().signal
)) {
events.push(event)
}
} finally {
await runtime.dispose()
}
const output = events
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
)
.join('')
const lastTextIndex = events.reduce(
(lastIndex, event, index) =>
event.type === 'text' ? index : lastIndex,
-1
)
const postResponseCompressionIndex = events.findIndex(
(event) =>
event.type === 'context-compression' &&
event.scope === 'conversation' &&
event.state === 'started'
)
expect(output).toContain(expectedOutput)
expect(lastTextIndex).toBeGreaterThanOrEqual(0)
expect(postResponseCompressionIndex).toBeGreaterThan(
lastTextIndex
)
expect(
events
.filter((event) => event.type === 'context-metrics')
.at(-1)
).toMatchObject({
type: 'context-metrics',
source: 'provider'
})
expect(events).not.toContainEqual(
expect.objectContaining({
type: 'context-metrics',
source: 'estimated'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
},
180_000
)
it(
'compacts a real multi-round Agent run and continues to completion',
async () => {
const toolProvider = new RealLongAgentToolProvider()
const runtime = new ModelAgentRuntime({
apiKey,
baseUrl,
model: modelName,
protocol,
authentication: 'api-key',
toolProvider,
contextCompression: {
settings: {
...defaultContextCompressionSettings,
enabled: true,
triggerTokens: 8_000,
recentRawTokens: 4_000
},
contextWindowTokens: 32_000
}
})
const events: RuntimeEvent[] = []
try {
for await (const event of runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
workMode: 'execute',
prompt:
'Call record_progress sequentially for steps 1, 2, and 3. Wait for each result before calling the next step. After all three results, do not call tools again and reply with LONG_AGENT_COMPRESSION_E2E_OK.'
},
new AbortController().signal,
async () => 'once'
)) {
events.push(event)
}
} finally {
await runtime.dispose()
}
const output = events
.flatMap((event) =>
event.type === 'text' ? [event.delta] : []
)
.join('')
expect(toolProvider.completedSteps).toEqual([1, 2, 3])
expect(events).toContainEqual(
expect.objectContaining({
type: 'context-compression',
scope: 'agent-run',
state: 'completed'
})
)
expect(output).toContain('LONG_AGENT_COMPRESSION_E2E_OK')
expect(
events
.filter((event) => event.type === 'context-metrics')
.at(-1)
).toMatchObject({ source: 'provider' })
expect(events).not.toContainEqual(
expect.objectContaining({
type: 'context-metrics',
source: 'estimated'
})
)
expect(events.at(-1)).toMatchObject({ type: 'done' })
},
240_000
)
it( it(
'discovers and plans GoodBuddy configuration through a real model', 'discovers and plans GoodBuddy configuration through a real model',
async () => { async () => {
@@ -399,13 +732,21 @@ describe.runIf(enabled)('runtime end-to-end', () => {
baseUrl, baseUrl,
model: modelName, model: modelName,
protocol, protocol,
authentication: 'api-key' authentication: 'api-key',
contextCompression: {
settings: {
...defaultContextCompressionSettings,
enabled: true
},
contextWindowTokens: 32_000
}
}) })
const abortController = new AbortController() const abortController = new AbortController()
const events: RuntimeEvent[] = []
try { try {
const result = collectText( const result = (async () => {
runtime.run( for await (const event of runtime.run(
{ {
requestId: crypto.randomUUID(), requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
@@ -414,12 +755,19 @@ describe.runIf(enabled)('runtime end-to-end', () => {
'Write a detailed technical essay of at least 3000 words.' 'Write a detailed technical essay of at least 3000 words.'
}, },
abortController.signal abortController.signal
) )) {
) events.push(event)
}
})()
setTimeout(() => abortController.abort(), 50) setTimeout(() => abortController.abort(), 50)
await expect(result).rejects.toMatchObject({ await expect(result).rejects.toMatchObject({
name: 'AbortError' name: 'AbortError'
}) })
expect(events).not.toContainEqual(
expect.objectContaining({
type: 'context-metrics'
})
)
} finally { } finally {
await runtime.dispose() await runtime.dispose()
} }
+79 -5
View File
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
database.close() database.close()
}) })
it('migrates existing databases to schema version 19', async () => { it('migrates existing databases to schema version 20', async () => {
const directory = await mkdtemp( const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-') join(tmpdir(), 'goodbuddy-assistant-migration-')
) )
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
user_version: number user_version: number
} }
).user_version ).user_version
).toBe(19) ).toBe(20)
expect( expect(
current current
.prepare( .prepare(
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
user_version: number user_version: number
} }
).user_version ).user_version
).toBe(19) ).toBe(20)
expect( expect(
current current
.prepare( .prepare(
@@ -1281,6 +1281,12 @@ describe('AssistantDatabase', () => {
], ],
createdAt: 1_775_000_001_000, createdAt: 1_775_000_001_000,
state: 'streaming', state: 'streaming',
contextCompression: {
state: 'completed',
scope: 'conversation',
estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000
},
artifactIds: [ artifactIds: [
'00000000-0000-4000-8000-000000000216' '00000000-0000-4000-8000-000000000216'
], ],
@@ -1342,6 +1348,12 @@ describe('AssistantDatabase', () => {
state: 'error', state: 'error',
status: expect.stringContaining('意外中断'), status: expect.stringContaining('意外中断'),
reasoning: '先分析发布范围', reasoning: '先分析发布范围',
contextCompression: {
state: 'completed',
scope: 'conversation',
estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000
},
blocks: [ blocks: [
expect.objectContaining({ expect.objectContaining({
type: 'reasoning', type: 'reasoning',
@@ -1451,6 +1463,24 @@ describe('AssistantDatabase', () => {
header: { header: {
id: conversationId, id: conversationId,
projectId: project.id, projectId: project.id,
contextMetrics: {
runtimeSelectionKey: `model:${channelDefaultProfileId}`,
contextTokens: 9_000,
effectiveTriggerTokens: 20_000,
contextWindowTokens: 32_000,
compressionEnabled: true,
source: 'estimated' as const,
basis: 'conversation' as const
},
contextCompressionState: {
coveredHistoryDigest: 'a'.repeat(64),
coveredMessageCount: 2,
coveredFromMessageId:
'00000000-0000-4000-8000-000000000503',
coveredThroughMessageId:
'00000000-0000-4000-8000-000000000504',
summary: '持久化摘要'
},
title: '增量对话(已完成)', title: '增量对话(已完成)',
updatedAt: 1_775_000_001_000 updatedAt: 1_775_000_001_000
}, },
@@ -1461,7 +1491,22 @@ describe('AssistantDatabase', () => {
content: '生成完成', content: '生成完成',
createdAt: 1_775_000_000_001, createdAt: 1_775_000_000_001,
state: 'complete' as const, state: 'complete' as const,
status: '已完成' status: '已完成',
contextCompressions: [
{
state: 'completed' as const,
scope: 'agent-run' as const,
estimatedBeforeTokens: 24_000,
estimatedAfterTokens: 11_000,
compressionCount: 2
},
{
state: 'completed' as const,
scope: 'conversation' as const,
estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000
}
]
}, },
{ {
id: newMessageId, id: newMessageId,
@@ -1478,12 +1523,41 @@ describe('AssistantDatabase', () => {
expect(database.getConversation(conversationId)).toMatchObject({ expect(database.getConversation(conversationId)).toMatchObject({
title: '增量对话(已完成)', title: '增量对话(已完成)',
contextMetrics: {
contextTokens: 9_000,
source: 'estimated',
basis: 'conversation'
},
contextCompressionState: {
coveredHistoryDigest: 'a'.repeat(64),
coveredMessageCount: 2,
coveredFromMessageId:
'00000000-0000-4000-8000-000000000503',
coveredThroughMessageId:
'00000000-0000-4000-8000-000000000504',
summary: '持久化摘要'
},
messages: [ messages: [
{ {
id: streamingMessageId, id: streamingMessageId,
content: '生成完成', content: '生成完成',
state: 'complete', state: 'complete',
status: '已完成' status: '已完成',
contextCompressions: [
{
state: 'completed',
scope: 'agent-run',
estimatedBeforeTokens: 24_000,
estimatedAfterTokens: 11_000,
compressionCount: 2
},
{
state: 'completed',
scope: 'conversation',
estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000
}
]
}, },
{ {
id: newMessageId, id: newMessageId,
+87 -22
View File
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite' import { DatabaseSync } from 'node:sqlite'
import { import {
conversationSnapshotSchema,
expertCreateSchema, expertCreateSchema,
normalizeInteractiveWorkMode normalizeInteractiveWorkMode
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
@@ -107,6 +108,7 @@ type ConversationRow = {
project_id: string | null project_id: string | null
runtime_selection_json: string | null runtime_selection_json: string | null
knowledge_retrieval_mode: 'auto' | 'always' | null knowledge_retrieval_mode: 'auto' | 'always' | null
context_state_json: string | null
title: string title: string
channel: ProjectChannel | null channel: ProjectChannel | null
external_account_id: string | null external_account_id: string | null
@@ -173,6 +175,8 @@ type MessageMetadata = {
status?: string status?: string
reasoning?: ConversationSnapshot['messages'][number]['reasoning'] reasoning?: ConversationSnapshot['messages'][number]['reasoning']
blocks?: ConversationSnapshot['messages'][number]['blocks'] blocks?: ConversationSnapshot['messages'][number]['blocks']
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
tools?: ConversationSnapshot['messages'][number]['tools'] tools?: ConversationSnapshot['messages'][number]['tools']
sources?: string[] sources?: string[]
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences'] sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
@@ -752,6 +756,45 @@ function interruptActiveToolBlocks(
) )
} }
const conversationContextStateSchema = conversationSnapshotSchema.pick({
contextMetrics: true,
contextCompressionState: true
})
function parseConversationContextState(
value: string | null
): Pick<
ConversationSnapshot,
'contextMetrics' | 'contextCompressionState'
> {
if (!value) {
return {}
}
try {
const parsed = conversationContextStateSchema.safeParse(
JSON.parse(value)
)
return parsed.success ? parsed.data : {}
} catch {
return {}
}
}
function serializeConversationContextState(
conversation: Pick<
ConversationSnapshot,
'contextMetrics' | 'contextCompressionState'
>
): string | null {
return conversation.contextMetrics ||
conversation.contextCompressionState
? JSON.stringify({
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState
})
: null
}
function toConversationSnapshot( function toConversationSnapshot(
conversation: ConversationRow, conversation: ConversationRow,
messages: MessageRow[] messages: MessageRow[]
@@ -764,6 +807,7 @@ function toConversationSnapshot(
), ),
knowledgeRetrievalMode: knowledgeRetrievalMode:
conversation.knowledge_retrieval_mode ?? undefined, conversation.knowledge_retrieval_mode ?? undefined,
...parseConversationContextState(conversation.context_state_json),
...(conversation.channel && ...(conversation.channel &&
conversation.conversation_type && conversation.conversation_type &&
conversation.account_display conversation.account_display
@@ -796,6 +840,8 @@ function toConversationSnapshot(
status: interrupted status: interrupted
? interruptedMessageStatus ? interruptedMessageStatus
: metadata.status, : metadata.status,
contextCompression: metadata.contextCompression,
contextCompressions: metadata.contextCompressions,
tools: interrupted tools: interrupted
? interruptActiveTools(metadata.tools) ? interruptActiveTools(metadata.tools)
: metadata.tools, : metadata.tools,
@@ -817,6 +863,8 @@ function serializeConversationMessageMetadata(
status: message.status, status: message.status,
reasoning: message.reasoning, reasoning: message.reasoning,
blocks: message.blocks, blocks: message.blocks,
contextCompression: message.contextCompression,
contextCompressions: message.contextCompressions,
tools: message.tools, tools: message.tools,
sources: message.sources, sources: message.sources,
sourceReferences: message.sourceReferences, sourceReferences: message.sourceReferences,
@@ -1334,7 +1382,7 @@ export class AssistantDatabase {
const conversations = database const conversations = database
.prepare( .prepare(
`SELECT id, project_id, runtime_selection_json, `SELECT id, project_id, runtime_selection_json,
knowledge_retrieval_mode, title, channel, knowledge_retrieval_mode, context_state_json, title, channel,
external_account_id, external_conversation_id, external_account_id, external_conversation_id,
conversation_type, account_display, updated_at conversation_type, account_display, updated_at
FROM conversations FROM conversations
@@ -1369,7 +1417,7 @@ export class AssistantDatabase {
const conversation = database const conversation = database
.prepare( .prepare(
`SELECT id, project_id, runtime_selection_json, `SELECT id, project_id, runtime_selection_json,
knowledge_retrieval_mode, title, channel, knowledge_retrieval_mode, context_state_json, title, channel,
external_account_id, external_conversation_id, external_account_id, external_conversation_id,
conversation_type, account_display, updated_at conversation_type, account_display, updated_at
FROM conversations FROM conversations
@@ -1497,8 +1545,9 @@ export class AssistantDatabase {
const insertConversation = database.prepare( const insertConversation = database.prepare(
`INSERT INTO conversations `INSERT INTO conversations
(id, project_id, runtime_selection_json, knowledge_retrieval_mode, (id, project_id, runtime_selection_json, knowledge_retrieval_mode,
work_mode, title, status, created_at, updated_at) context_state_json, work_mode, title, status, created_at,
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)` updated_at)
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
) )
const insertMessage = database.prepare( const insertMessage = database.prepare(
`INSERT INTO messages `INSERT INTO messages
@@ -1518,6 +1567,7 @@ export class AssistantDatabase {
? JSON.stringify(conversation.runtimeSelection) ? JSON.stringify(conversation.runtimeSelection)
: null, : null,
conversation.knowledgeRetrievalMode ?? null, conversation.knowledgeRetrievalMode ?? null,
serializeConversationContextState(conversation),
conversation.title, conversation.title,
updatedAt, updatedAt,
updatedAt updatedAt
@@ -1532,18 +1582,7 @@ export class AssistantDatabase {
message.content, message.content,
message.state, message.state,
sequence, sequence,
JSON.stringify({ serializeConversationMessageMetadata(message),
createdAt: message.createdAt,
status: message.status,
reasoning: message.reasoning,
blocks: message.blocks,
tools: message.tools,
sources: message.sources,
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
artifactIds: message.artifactIds,
attachments: message.attachments
}),
new Date(message.createdAt).toISOString() new Date(message.createdAt).toISOString()
) )
} }
@@ -1563,14 +1602,15 @@ export class AssistantDatabase {
const insertConversation = database.prepare( const insertConversation = database.prepare(
`INSERT INTO conversations `INSERT INTO conversations
(id, project_id, runtime_selection_json, knowledge_retrieval_mode, (id, project_id, runtime_selection_json, knowledge_retrieval_mode,
work_mode, title, status, created_at, updated_at) context_state_json, work_mode, title, status, created_at,
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)` updated_at)
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
) )
const updateConversation = database.prepare( const updateConversation = database.prepare(
`UPDATE conversations `UPDATE conversations
SET project_id = ?, runtime_selection_json = ?, SET project_id = ?, runtime_selection_json = ?,
knowledge_retrieval_mode = ?, title = ?, status = 'active', knowledge_retrieval_mode = ?, context_state_json = ?,
updated_at = ? title = ?, status = 'active', updated_at = ?
WHERE id = ? AND channel IS NULL` WHERE id = ? AND channel IS NULL`
) )
const findMessage = database.prepare( const findMessage = database.prepare(
@@ -1623,6 +1663,7 @@ export class AssistantDatabase {
? JSON.stringify(header.runtimeSelection) ? JSON.stringify(header.runtimeSelection)
: null, : null,
header.knowledgeRetrievalMode ?? null, header.knowledgeRetrievalMode ?? null,
serializeConversationContextState(header),
header.title, header.title,
updatedAt, updatedAt,
header.id header.id
@@ -1638,6 +1679,7 @@ export class AssistantDatabase {
? JSON.stringify(header.runtimeSelection) ? JSON.stringify(header.runtimeSelection)
: null, : null,
header.knowledgeRetrievalMode ?? null, header.knowledgeRetrievalMode ?? null,
serializeConversationContextState(header),
header.title, header.title,
updatedAt, updatedAt,
updatedAt updatedAt
@@ -4718,12 +4760,12 @@ export class AssistantDatabase {
const version = database const version = database
.prepare('PRAGMA user_version') .prepare('PRAGMA user_version')
.get() as { user_version: number } .get() as { user_version: number }
if (version.user_version > 19) { if (version.user_version > 20) {
throw new Error( throw new Error(
` GoodBuddy ${version.user_version}` ` GoodBuddy ${version.user_version}`
) )
} }
if (version.user_version === 19) { if (version.user_version === 20) {
return return
} }
if (version.user_version < 1) { if (version.user_version < 1) {
@@ -4750,6 +4792,7 @@ export class AssistantDatabase {
knowledge_retrieval_mode IS NULL OR knowledge_retrieval_mode IS NULL OR
knowledge_retrieval_mode IN ('auto', 'always') knowledge_retrieval_mode IN ('auto', 'always')
), ),
context_state_json TEXT,
work_mode TEXT NOT NULL DEFAULT 'ask' work_mode TEXT NOT NULL DEFAULT 'ask'
CHECK(work_mode IN ('ask', 'execute')), CHECK(work_mode IN ('ask', 'execute')),
title TEXT NOT NULL, title TEXT NOT NULL,
@@ -5633,6 +5676,28 @@ export class AssistantDatabase {
throw error throw error
} }
} }
if (version.user_version < 20) {
database.exec('BEGIN IMMEDIATE')
try {
const conversationColumns = new Set(
(
database
.prepare('PRAGMA table_info(conversations)')
.all() as Array<{ name: string }>
).map((column) => column.name)
)
if (!conversationColumns.has('context_state_json')) {
database.exec(`
ALTER TABLE conversations
ADD COLUMN context_state_json TEXT;
`)
}
database.exec('PRAGMA user_version = 20; COMMIT;')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
} }
private requireDatabase(): DatabaseSync { private requireDatabase(): DatabaseSync {
@@ -100,7 +100,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
).count ).count
check.close() check.close()
migrated.close() migrated.close()
expect(version).toBe(19) expect(version).toBe(20)
expect(heartbeatTableCount).toBe(3) expect(heartbeatTableCount).toBe(3)
}) })
+335 -23
View File
@@ -2331,7 +2331,7 @@ describe('App', () => {
expect(screen.getByText('正在分析真实推理内容')).toBeVisible() 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() const settings = await api.settings.getRuntime()
vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({
...settings, ...settings,
@@ -2350,14 +2350,12 @@ describe('App', () => {
}) })
render(<App />) render(<App />)
await screen.findByLabelText('向 GoodBuddy 提问')
expect( expect(
await screen.findByText(/ .+ \/ 32\.0K · \d+%/u) screen.queryByRole('progressbar', {
).toBeInTheDocument()
expect(
screen.getByRole('progressbar', {
name: '当前上下文使用量' name: '当前上下文使用量'
}) })
).toBeInTheDocument() ).not.toBeInTheDocument()
expect( expect(
screen.queryByText('只读问答,不修改文件') screen.queryByText('只读问答,不修改文件')
).not.toBeInTheDocument() ).not.toBeInTheDocument()
@@ -2369,8 +2367,10 @@ describe('App', () => {
target: { value: '中'.repeat(1_000) } target: { value: '中'.repeat(1_000) }
}) })
expect( expect(
screen.getByText(/ 5\.\dK \/ 32\.0K/u) screen.queryByRole('progressbar', {
).toBeInTheDocument() name: '当前上下文使用量'
})
).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('发送')) fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce()) await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0] const request = run.mock.calls[0]?.[0]
@@ -2378,10 +2378,26 @@ describe('App', () => {
throw new Error('Missing request') 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(() => { act(() => {
agentListener?.({ agentListener?.({
requestId: request.requestId, requestId: request.requestId,
type: 'context-compression', type: 'context-compression',
scope: 'conversation',
state: 'started', state: 'started',
estimatedBeforeTokens: 22_000, estimatedBeforeTokens: 22_000,
effectiveTriggerTokens: 20_000, effectiveTriggerTokens: 20_000,
@@ -2410,6 +2426,7 @@ describe('App', () => {
agentListener?.({ agentListener?.({
requestId: request.requestId, requestId: request.requestId,
type: 'context-compression', type: 'context-compression',
scope: 'conversation',
state: 'completed', state: 'completed',
estimatedBeforeTokens: 22_000, estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000, estimatedAfterTokens: 9_000,
@@ -2419,25 +2436,105 @@ describe('App', () => {
coveredMessageCount: 2, coveredMessageCount: 2,
summaryTokens: 1_000 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( expect(
screen.getByText('已压缩较早对话 · ≈22.0K → ≈9.0K') screen.getByText(
'已压缩较早对话(估算) · ≈22.0K → ≈9.0K'
)
).toBeInTheDocument() ).toBeInTheDocument()
expect( expect(
screen.getByText('上下文 ≈9.0K / 32.0K · 28%') screen.getByText(
'压缩后对话估算 ≈9.0K / 32.0K · 28%'
)
).toBeInTheDocument() ).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(() => { act(() => {
agentListener?.({ agentListener?.({
requestId: request.requestId, requestId: request.requestId,
@@ -2445,10 +2542,183 @@ describe('App', () => {
}) })
}) })
expect( expect(
screen.getByText('已压缩较早对话 · ≈22.0K → ≈9.0K') screen.getByText(
'Agent 执行期间已压缩上下文 2 次(估算) · ≈24.0K → ≈11.0K'
)
).toBeInTheDocument() ).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 () => { it('keeps a tool failure in details and hides retry after continuing', async () => {
render(<App />) 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 () => { it('keeps sent documents and images in conversation history', async () => {
const documentAttachment = { const documentAttachment = {
id: '00000000-0000-4000-8000-000000000301', 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 () => { it('terminalizes tools and activity when a request is cancelled', async () => {
+190 -118
View File
@@ -61,19 +61,13 @@ import type {
BrowserLiveState, BrowserLiveState,
ContextAttachment, ContextAttachment,
ContextFileSelectionProgress, ContextFileSelectionProgress,
KnowledgeRetrievalMode,
KnowledgeSearchReference, KnowledgeSearchReference,
KnowledgeSnapshot, KnowledgeSnapshot,
RuntimeSettings RuntimeSettings
} from '../../shared/contracts' } from '../../shared/contracts'
import { import {
defaultContextCompressionSettings,
maximumPastedImageBytes maximumPastedImageBytes
} from '../../shared/contracts' } from '../../shared/contracts'
import {
estimateContextInputTokens,
getEffectiveContextTriggerTokens
} from '../../shared/context-window'
import { import {
agentRuntimeSelectionKey, agentRuntimeSelectionKey,
agentRuntimeSelectionSchema, agentRuntimeSelectionSchema,
@@ -98,6 +92,7 @@ import type {
ConversationMessage, ConversationMessage,
ConversationSnapshot, ConversationSnapshot,
ConversationAttachment, ConversationAttachment,
ConversationContextCompressionMarker,
ConversationMessageBlock, ConversationMessageBlock,
LocalConversationHeader, LocalConversationHeader,
LocalConversationSaveBatch, LocalConversationSaveBatch,
@@ -108,6 +103,7 @@ import type {
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import { import {
conversationAttachmentSchema, conversationAttachmentSchema,
conversationContextCompressionMarkerSchema,
conversationMessageBlocksSchema, conversationMessageBlocksSchema,
interactiveWorkModes, interactiveWorkModes,
normalizeInteractiveWorkMode, normalizeInteractiveWorkMode,
@@ -174,6 +170,7 @@ import { ReleaseNotesDialog } from './ReleaseNotesDialog'
import { scheduleIdleRoutePreload } from './idle-route-preload' import { scheduleIdleRoutePreload } from './idle-route-preload'
import { createPreloadableComponent } from './preloadable-component' import { createPreloadableComponent } from './preloadable-component'
import { formatTime, type TimeFormatLocale } from './time-format' import { formatTime, type TimeFormatLocale } from './time-format'
import { formatCompactTokens } from './token-format'
import { import {
pruneKeepAliveEntries, pruneKeepAliveEntries,
touchKeepAliveEntry, touchKeepAliveEntry,
@@ -432,14 +429,7 @@ function supportsSubagentSmartRouting(
return workMode === 'ask' return workMode === 'ask'
} }
type Conversation = { type Conversation = Omit<ConversationSnapshot, 'messages'> & {
id: string
projectId?: string
runtimeSelection?: AgentRuntimeSelection
knowledgeRetrievalMode?: KnowledgeRetrievalMode
remote?: ConversationSnapshot['remote']
title: string
updatedAt: number
messages: Message[] messages: Message[]
} }
@@ -450,13 +440,6 @@ type ActiveRun = {
runtimeSelectionKey: string runtimeSelectionKey: string
} }
type ConversationContextMetrics = Omit<
Extract<AgentEvent, { type: 'context-metrics' }>,
'requestId' | 'type'
> & {
runtimeSelectionKey: string
}
type WorkspaceView = type WorkspaceView =
| 'chat' | 'chat'
| 'magic-notes' | 'magic-notes'
@@ -1068,14 +1051,17 @@ function isConversation(value: unknown): value is Conversation {
entry.state === 'complete' || entry.state === 'complete' ||
entry.state === 'error') && entry.state === 'error') &&
(entry.contextCompression === undefined || (entry.contextCompression === undefined ||
(typeof entry.contextCompression === 'object' && conversationContextCompressionMarkerSchema.safeParse(
entry.contextCompression !== null && entry.contextCompression
['compressing', 'completed', 'failed'].includes( ).success) &&
String( (entry.contextCompressions === undefined ||
( (Array.isArray(entry.contextCompressions) &&
entry.contextCompression as Record<string, unknown> entry.contextCompressions.length <= 2 &&
).state entry.contextCompressions.every(
) (compression) =>
conversationContextCompressionMarkerSchema.safeParse(
compression
).success
))) && ))) &&
(entry.artifactIds === undefined || (entry.artifactIds === undefined ||
(Array.isArray(entry.artifactIds) && (Array.isArray(entry.artifactIds) &&
@@ -1103,6 +1089,8 @@ function toConversationSnapshots(
projectId: conversation.projectId, projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection, runtimeSelection: conversation.runtimeSelection,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
title: conversation.title, title: conversation.title,
updatedAt: conversation.updatedAt, updatedAt: conversation.updatedAt,
messages: conversation.messages messages: conversation.messages
@@ -1122,6 +1110,7 @@ function toConversationMessage(message: Message): ConversationMessage {
state: message.state, state: message.state,
status: message.status, status: message.status,
contextCompression: message.contextCompression, contextCompression: message.contextCompression,
contextCompressions: message.contextCompressions,
tools: message.tools, tools: message.tools,
sources: message.sources, sources: message.sources,
sourceReferences: message.sourceReferences, sourceReferences: message.sourceReferences,
@@ -1139,6 +1128,8 @@ function toLocalConversationHeader(
projectId: conversation.projectId, projectId: conversation.projectId,
runtimeSelection: conversation.runtimeSelection, runtimeSelection: conversation.runtimeSelection,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
title: conversation.title, title: conversation.title,
updatedAt: conversation.updatedAt updatedAt: conversation.updatedAt
} }
@@ -1307,14 +1298,6 @@ function formatAttachmentSize(size: number): string {
return `${Math.max(1, Math.ceil(size / 1024))} KB` 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 composerTextareaMinHeight = 72
const composerTextareaMaxHeight = 220 const composerTextareaMaxHeight = 220
@@ -1778,8 +1761,6 @@ function App(): React.JSX.Element {
const [runtime, setRuntime] = useState<AgentRuntimeStatus>() const [runtime, setRuntime] = useState<AgentRuntimeStatus>()
const [runtimeStatusKey, setRuntimeStatusKey] = useState('') const [runtimeStatusKey, setRuntimeStatusKey] = useState('')
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>() const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
const [contextMetricsByConversation, setContextMetricsByConversation] =
useState<Record<string, ConversationContextMetrics>>({})
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false) const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
const [composerMenuOpen, setComposerMenuOpen] = useState< const [composerMenuOpen, setComposerMenuOpen] = useState<
'expert' | 'mode' | undefined 'expert' | 'mode' | undefined
@@ -3138,25 +3119,103 @@ function App(): React.JSX.Element {
const { requestId: _requestId, type: _type, ...metrics } = event const { requestId: _requestId, type: _type, ...metrics } = event
void _requestId void _requestId
void _type void _type
setContextMetricsByConversation((current) => ({ setConversations((current) =>
...current, current.map((conversation) =>
[run.conversationId]: { conversation.id === run.conversationId
? {
...conversation,
contextMetrics: {
...metrics, ...metrics,
basis: 'model-call',
runtimeSelectionKey: run.runtimeSelectionKey runtimeSelectionKey: run.runtimeSelectionKey
} }
})) }
: conversation
)
)
} else if (event.type === 'context-compression') { } else if (event.type === 'context-compression') {
updateMessage(run.conversationId, run.messageId, (message) => ({ const estimatedAfterTokens = event.estimatedAfterTokens
...message, const conversationScoped = event.scope !== 'agent-run'
contextCompression: { const scope = event.scope ?? 'conversation'
const marker: ConversationContextCompressionMarker = {
state: state:
event.state === 'started' event.state === 'started'
? 'compressing' ? 'compressing'
: 'completed', : event.state,
scope,
estimatedBeforeTokens: event.estimatedBeforeTokens, 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') { } else if (event.type === 'status') {
updateMessage(run.conversationId, run.messageId, (message) => ({ updateMessage(run.conversationId, run.messageId, (message) => ({
...message, ...message,
@@ -3457,6 +3516,17 @@ function App(): React.JSX.Element {
state: 'failed' as const state: 'failed' as const
} }
: message.contextCompression, : message.contextCompression,
contextCompressions:
event.type === 'error'
? message.contextCompressions?.map((compression) =>
compression.state === 'compressing'
? {
...compression,
state: 'failed' as const
}
: compression
)
: message.contextCompressions,
approval: undefined, approval: undefined,
question: undefined, question: undefined,
tools: toolTerminalState tools: toolTerminalState
@@ -4965,6 +5035,12 @@ function App(): React.JSX.Element {
const conversationId = activeConversation.id const conversationId = activeConversation.id
const attachmentSnapshot = attachments.slice(0, 8) const attachmentSnapshot = attachments.slice(0, 8)
const historySnapshot = activeConversation.messages const historySnapshot = activeConversation.messages
const retainedHistorySnapshot = historySnapshot
.filter(
(message) =>
message.state === 'complete' && message.content.trim()
)
.slice(-500)
const projectIdSnapshot = activeProjectId || undefined const projectIdSnapshot = activeProjectId || undefined
const knowledgeRetrievalModeSnapshot = const knowledgeRetrievalModeSnapshot =
activeConversation.knowledgeRetrievalMode ?? 'auto' activeConversation.knowledgeRetrievalMode ?? 'auto'
@@ -4976,6 +5052,8 @@ function App(): React.JSX.Element {
const selectedExpertSnapshot = const selectedExpertSnapshot =
runtime.capability === 'image-generation' ? '' : selectedExpertId runtime.capability === 'image-generation' ? '' : selectedExpertId
const workModeSnapshot = effectiveWorkMode const workModeSnapshot = effectiveWorkMode
setComposerMenuOpen(undefined)
setRuntimeMenuOpen(false)
preparingConversations.current.add(conversationId) preparingConversations.current.add(conversationId)
setConversationActivity(conversationId, true) setConversationActivity(conversationId, true)
setInput('') setInput('')
@@ -5097,16 +5175,17 @@ function App(): React.JSX.Element {
contextIds: attachmentSnapshot.map( contextIds: attachmentSnapshot.map(
(attachment) => attachment.id (attachment) => attachment.id
), ),
history: historySnapshot contextCompressionState:
.filter( activeConversation.contextCompressionState,
(message) => history: retainedHistorySnapshot.map((message) => ({
message.state === 'complete' && message.content.trim()
)
.slice(-500)
.map((message) => ({
role: message.role, role: message.role,
content: message.content content: message.content
})) })),
historyMessageIds: retainedHistorySnapshot.map(
(message) => message.id
),
currentUserMessageId: userMessage.id,
currentAssistantMessageId: assistantMessage.id
}) })
for (const attachment of attachmentSnapshot) { for (const attachment of attachmentSnapshot) {
void window.goodbuddy.context.remove(attachment.id) void window.goodbuddy.context.remove(attachment.id)
@@ -5651,55 +5730,38 @@ function App(): React.JSX.Element {
) { ) {
return undefined return undefined
} }
const compression = const latest = activeConversation.contextMetrics
runtimeSettings.contextCompression ??
defaultContextCompressionSettings
const latest = contextMetricsByConversation[activeConversation.id]
const applicableLatest = const applicableLatest =
latest?.runtimeSelectionKey === activeRuntimeSelectionKey latest?.runtimeSelectionKey === activeRuntimeSelectionKey
? latest ? latest
: undefined : undefined
const history = activeConversation.messages if (!applicableLatest) {
.filter( return undefined
(message) => }
message.state === 'complete' && message.content.trim() const contextTokens = applicableLatest.contextTokens
)
.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
})
const effectiveTriggerTokens = const effectiveTriggerTokens =
getEffectiveContextTriggerTokens({ applicableLatest.effectiveTriggerTokens
triggerTokens: compression.triggerTokens,
contextWindowTokens: profile.contextWindowTokens
})
const denominatorTokens = const denominatorTokens =
profile.contextWindowTokens ?? applicableLatest.contextWindowTokens
(compression.enabled ? effectiveTriggerTokens : undefined)
const percentage = const percentage =
denominatorTokens === undefined denominatorTokens === undefined
? undefined ? undefined
: Math.round( : Math.round(
(estimatedInputTokens / denominatorTokens) * 100 (contextTokens / denominatorTokens) * 100
) )
return { return {
estimatedInputTokens, contextTokens,
effectiveTriggerTokens, effectiveTriggerTokens,
contextWindowTokens: profile.contextWindowTokens, contextWindowTokens: applicableLatest.contextWindowTokens,
compressionEnabled: compression.enabled, compressionEnabled: applicableLatest.compressionEnabled,
source: applicableLatest.source,
basis:
applicableLatest.basis ??
(applicableLatest.source === 'estimated' &&
activeConversation.contextCompressionState
? 'conversation'
: 'model-call'),
denominatorTokens, denominatorTokens,
percentage percentage
} }
@@ -5707,9 +5769,6 @@ function App(): React.JSX.Element {
activeConversation, activeConversation,
activeRuntimeSelection, activeRuntimeSelection,
activeRuntimeSelectionKey, activeRuntimeSelectionKey,
contextMetricsByConversation,
input,
isRunning,
runtimeSettings runtimeSettings
]) ])
@@ -6658,6 +6717,7 @@ function App(): React.JSX.Element {
ariaLabel={t('composer.expertLabel')} ariaLabel={t('composer.expertLabel')}
className="composer-picker--expert" className="composer-picker--expert"
disabled={ disabled={
isRunning ||
runtime?.capability === 'image-generation' runtime?.capability === 'image-generation'
} }
icon={<Bot aria-hidden="true" size={15} />} icon={<Bot aria-hidden="true" size={15} />}
@@ -6670,6 +6730,7 @@ function App(): React.JSX.Element {
<ComposerMenuSelect <ComposerMenuSelect
ariaLabel={t('composer.modeLabel')} ariaLabel={t('composer.modeLabel')}
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`} className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
disabled={isRunning}
icon={ icon={
effectiveWorkMode === 'execute' ? ( effectiveWorkMode === 'execute' ? (
<ShieldCheck aria-hidden="true" size={15} /> <ShieldCheck aria-hidden="true" size={15} />
@@ -6979,7 +7040,7 @@ function App(): React.JSX.Element {
title={ title={
composerContextMetrics.compressionEnabled composerContextMetrics.compressionEnabled
? t('composer.context.compressionTrigger', { ? t('composer.context.compressionTrigger', {
tokens: formatCompactContextTokens( tokens: formatCompactTokens(
composerContextMetrics.effectiveTriggerTokens composerContextMetrics.effectiveTriggerTokens
) )
}) })
@@ -6988,33 +7049,44 @@ function App(): React.JSX.Element {
> >
<span className="composer-context-meter__summary"> <span className="composer-context-meter__summary">
{composerContextMetrics.denominatorTokens === undefined {composerContextMetrics.denominatorTokens === undefined
? t('composer.context.tokenCount', { ? t(
used: formatCompactContextTokens( composerContextMetrics.basis === 'conversation'
composerContextMetrics.estimatedInputTokens ? 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(
? t('composer.context.thresholdUsage', { composerContextMetrics.basis === 'conversation'
used: formatCompactContextTokens( ? 'composer.context.conversationWindowUsage'
composerContextMetrics.estimatedInputTokens : composerContextMetrics.source === 'provider'
? 'composer.context.confirmedWindowUsage'
: 'composer.context.windowUsage',
{
used: formatCompactTokens(
composerContextMetrics.contextTokens
), ),
total: formatCompactContextTokens( total: formatCompactTokens(
composerContextMetrics.denominatorTokens composerContextMetrics.denominatorTokens
), ),
percentage: percentage:
composerContextMetrics.percentage ?? 0 composerContextMetrics.percentage ?? 0
}) }
: t('composer.context.windowUsage', { )}
used: formatCompactContextTokens(
composerContextMetrics.estimatedInputTokens
),
total: formatCompactContextTokens(
composerContextMetrics.denominatorTokens
),
percentage:
composerContextMetrics.percentage ?? 0
})}
</span> </span>
{composerContextMetrics.denominatorTokens !== undefined && ( {composerContextMetrics.denominatorTokens !== undefined && (
<div <div
@@ -7024,7 +7096,7 @@ function App(): React.JSX.Element {
} }
aria-valuemin={0} aria-valuemin={0}
aria-valuenow={Math.min( aria-valuenow={Math.min(
composerContextMetrics.estimatedInputTokens, composerContextMetrics.contextTokens,
composerContextMetrics.denominatorTokens composerContextMetrics.denominatorTokens
)} )}
className="composer-context-meter__track" className="composer-context-meter__track"
+24 -6
View File
@@ -86,7 +86,7 @@ describe('ChatTimeline', () => {
expect(unchangedDetails).toHaveAttribute('open') 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[] = [ const messages: Message[] = [
{ {
id: 'user-message', id: 'user-message',
@@ -99,11 +99,21 @@ describe('ChatTimeline', () => {
id: 'assistant-message', id: 'assistant-message',
role: 'assistant', role: 'assistant',
content: 'Answer', content: 'Answer',
contextCompression: { contextCompressions: [
{
state: 'completed', state: 'completed',
scope: 'agent-run',
estimatedBeforeTokens: 24_000,
estimatedAfterTokens: 11_000,
compressionCount: 2
},
{
state: 'completed',
scope: 'conversation',
estimatedBeforeTokens: 22_000, estimatedBeforeTokens: 22_000,
estimatedAfterTokens: 9_000 estimatedAfterTokens: 9_000
}, }
],
createdAt: 1_775_000_001_000, createdAt: 1_775_000_001_000,
state: 'complete' state: 'complete'
} }
@@ -128,11 +138,19 @@ describe('ChatTimeline', () => {
) )
expect(children.map((element) => element.className)).toEqual([ expect(children.map((element) => element.className)).toEqual([
'message message--user', 'message message--user',
'message message--assistant',
'context-compression-event context-compression-event--completed', 'context-compression-event context-compression-event--completed',
'message message--assistant' 'context-compression-event context-compression-event--completed'
]) ])
expect( expect(
screen.getByRole('status') screen.getByText(
).toHaveTextContent('已压缩较早对话 · ≈22.0K → ≈9.0K') '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 { import type {
AssistantArtifact, AssistantArtifact,
ConversationAttachment, ConversationAttachment,
ConversationContextCompressionMarker,
ConversationMessage,
ConversationMessageBlock, ConversationMessageBlock,
ConversationToolActivity ConversationToolActivity
} from '../../shared/assistant-contracts' } from '../../shared/assistant-contracts'
import { AgentQuestionCard } from './AgentQuestionCard' import { AgentQuestionCard } from './AgentQuestionCard'
import { MarkdownRenderer } from './MarkdownRenderer' import { MarkdownRenderer } from './MarkdownRenderer'
import { formatTime, type TimeFormatLocale } from './time-format' import { formatTime, type TimeFormatLocale } from './time-format'
import { formatCompactTokens } from './token-format'
export type ToolActivity = ConversationToolActivity export type ToolActivity = ConversationToolActivity
@@ -51,11 +54,8 @@ export type Message = {
createdAt: number createdAt: number
state: 'streaming' | 'complete' | 'error' state: 'streaming' | 'complete' | 'error'
status?: string status?: string
contextCompression?: { contextCompression?: ConversationMessage['contextCompression']
state: 'compressing' | 'completed' | 'failed' contextCompressions?: ConversationMessage['contextCompressions']
estimatedBeforeTokens: number
estimatedAfterTokens?: number
}
tools?: ToolActivity[] tools?: ToolActivity[]
subagents?: SubagentActivity[] subagents?: SubagentActivity[]
approval?: { approval?: {
@@ -79,14 +79,6 @@ export type ImageViewerItem = {
title: string 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 = type MessageBlockRenderItem =
| { | {
kind: 'block' kind: 'block'
@@ -281,43 +273,42 @@ function ChatMessageRowView({
retryContent retryContent
}: ChatMessageRowProps): React.JSX.Element { }: ChatMessageRowProps): React.JSX.Element {
const { t } = useTranslation('app') 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 ( 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 <article
className={`message message--${message.role}`} className={`message message--${message.role}`}
ref={(element) => onArticleRef(message.id, element)} ref={(element) => onArticleRef(message.id, element)}
@@ -803,6 +794,29 @@ function ChatMessageRowView({
)} )}
</div> </div>
</article> </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: { contextCompression: {
compressing: 'Compressing earlier conversation…', compressing: 'Compressing earlier conversation…',
completed: completed:
'Earlier conversation compressed · ≈{{before}} → ≈{{after}}', 'Earlier conversation compressed (estimated) · ≈{{before}} → ≈{{after}}',
failed: 'Earlier conversation compression failed' 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}}', sources: 'Sources: {{sources}}',
citations: { citations: {
@@ -322,10 +326,22 @@ export const app = {
sendTitle: 'Send message', sendTitle: 'Send message',
shortcut: 'Quick access: ', shortcut: 'Quick access: ',
context: { context: {
tokenCount: 'Context {{used}}', confirmedTokenCount: 'Latest call {{used}}',
windowUsage: 'Context ≈{{used}} / {{total}} · {{percentage}}%', 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: 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', progressLabel: 'Current context usage',
compressionTrigger: 'Automatic compression at ≈{{tokens}}' compressionTrigger: 'Automatic compression at ≈{{tokens}}'
}, },
+21 -5
View File
@@ -185,8 +185,13 @@ export const app = {
}, },
contextCompression: { contextCompression: {
compressing: '正在压缩较早对话…', compressing: '正在压缩较早对话…',
completed: '已压缩较早对话 · ≈{{before}} → ≈{{after}}', completed:
failed: '较早对话压缩失败' '已压缩较早对话(估算) · ≈{{before}} → ≈{{after}}',
failed: '较早对话压缩失败',
agentCompressing: '正在整理 Agent 执行上下文…',
agentCompleted:
'Agent 执行期间已压缩上下文 {{count}} 次(估算) · ≈{{before}} → ≈{{after}}',
agentFailed: 'Agent 执行上下文压缩失败'
}, },
sources: '来源:{{sources}}', sources: '来源:{{sources}}',
citations: { citations: {
@@ -313,10 +318,21 @@ export const app = {
sendTitle: '发送消息', sendTitle: '发送消息',
shortcut: '快捷唤起:', shortcut: '快捷唤起:',
context: { context: {
tokenCount: '上下文 ≈{{used}}', confirmedTokenCount: '本次调用 {{used}}',
windowUsage: '上下文 ≈{{used}} / {{total}} · {{percentage}}%', tokenCount: '本次调用估算 ≈{{used}}',
confirmedWindowUsage:
'本次调用 {{used}} / {{total}} · {{percentage}}%',
windowUsage:
'本次调用估算 ≈{{used}} / {{total}} · {{percentage}}%',
confirmedThresholdUsage:
'本次调用 {{used}} · 压缩线 {{total}}',
thresholdUsage: thresholdUsage:
'距压缩阈值 ≈{{used}} / {{total}} · {{percentage}}%', '本次调用估算 ≈{{used}} · 压缩线 {{total}}',
conversationTokenCount: '压缩后对话估算 ≈{{used}}',
conversationWindowUsage:
'压缩后对话估算 ≈{{used}} / {{total}} · {{percentage}}%',
conversationThresholdUsage:
'压缩后对话估算 ≈{{used}} · 压缩线 {{total}}',
progressLabel: '当前上下文使用量', progressLabel: '当前上下文使用量',
compressionTrigger: '自动压缩线:≈{{tokens}}' 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`
}
+61 -7
View File
@@ -137,6 +137,20 @@ export type ConversationMessageBlock = z.infer<
typeof conversationMessageBlockSchema typeof conversationMessageBlockSchema
> >
export const conversationContextCompressionMarkerSchema = z
.object({
state: z.enum(['compressing', 'completed', 'failed']),
scope: z.enum(['conversation', 'agent-run']).optional(),
estimatedBeforeTokens: z.number().int().nonnegative(),
estimatedAfterTokens: z.number().int().nonnegative().optional(),
compressionCount: z.number().int().positive().optional()
})
.strict()
export type ConversationContextCompressionMarker = z.infer<
typeof conversationContextCompressionMarkerSchema
>
export const conversationMessageSchema = z export const conversationMessageSchema = z
.object({ .object({
id: assistantIdSchema, id: assistantIdSchema,
@@ -147,13 +161,11 @@ export const conversationMessageSchema = z
createdAt: z.number().int().nonnegative(), createdAt: z.number().int().nonnegative(),
state: z.enum(['streaming', 'complete', 'error']), state: z.enum(['streaming', 'complete', 'error']),
status: z.string().max(4_000).optional(), status: z.string().max(4_000).optional(),
contextCompression: z contextCompression:
.object({ conversationContextCompressionMarkerSchema.optional(),
state: z.enum(['compressing', 'completed', 'failed']), contextCompressions: z
estimatedBeforeTokens: z.number().int().nonnegative(), .array(conversationContextCompressionMarkerSchema)
estimatedAfterTokens: z.number().int().nonnegative().optional() .max(2)
})
.strict()
.optional(), .optional(),
tools: z.array(conversationToolActivitySchema).max(100).optional(), tools: z.array(conversationToolActivitySchema).max(100).optional(),
sources: z.array(z.string().max(8_192)).max(100).optional(), sources: z.array(z.string().max(8_192)).max(100).optional(),
@@ -222,12 +234,54 @@ export type ConversationMessage = z.infer<
typeof conversationMessageSchema typeof conversationMessageSchema
> >
export const conversationContextMetricsSchema = z
.object({
runtimeSelectionKey: z.string().trim().min(1).max(1_000),
contextTokens: z.number().int().nonnegative().max(50_000_000),
effectiveTriggerTokens: z
.number()
.int()
.nonnegative()
.max(10_000_000),
contextWindowTokens: z
.number()
.int()
.nonnegative()
.max(10_000_000)
.optional(),
compressionEnabled: z.boolean(),
source: z.enum(['provider', 'estimated']),
basis: z.enum(['model-call', 'conversation']).optional()
})
.strict()
export type ConversationContextMetrics = z.infer<
typeof conversationContextMetricsSchema
>
export const conversationContextCompressionStateSchema = z
.object({
coveredHistoryDigest: z.string().regex(/^[0-9a-f]{64}$/u),
coveredMessageCount: z.number().int().nonnegative().max(500),
coveredFromMessageId: assistantIdSchema.optional(),
coveredThroughMessageId: assistantIdSchema.optional(),
summary: z.string().trim().min(1).max(100_000)
})
.strict()
export type ConversationContextCompressionState = z.infer<
typeof conversationContextCompressionStateSchema
>
export const conversationSnapshotSchema = z export const conversationSnapshotSchema = z
.object({ .object({
id: assistantIdSchema, id: assistantIdSchema,
projectId: assistantIdSchema.optional(), projectId: assistantIdSchema.optional(),
runtimeSelection: agentRuntimeSelectionSchema.optional(), runtimeSelection: agentRuntimeSelectionSchema.optional(),
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(), knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
contextMetrics: conversationContextMetricsSchema.optional(),
contextCompressionState:
conversationContextCompressionStateSchema.optional(),
remote: z remote: z
.object({ .object({
channel: projectChannelSchema, channel: projectChannelSchema,
+26 -7
View File
@@ -17,6 +17,7 @@ import type {
} from './capability-contracts' } from './capability-contracts'
import { import {
assistantIdSchema, assistantIdSchema,
conversationContextCompressionStateSchema,
legacyWorkModeSchema, legacyWorkModeSchema,
type AssistantProject, type AssistantProject,
type AssistantArtifact, type AssistantArtifact,
@@ -30,6 +31,7 @@ import {
type TokenUsageSummary, type TokenUsageSummary,
type ConversationSnapshot, type ConversationSnapshot,
type ConversationAttachment, type ConversationAttachment,
type ConversationContextCompressionState,
type LocalConversationSaveBatch, type LocalConversationSaveBatch,
type WorkspaceChanges, type WorkspaceChanges,
type WorkspaceDirectoryListing, type WorkspaceDirectoryListing,
@@ -220,7 +222,12 @@ export const agentRequestSchema = z
.strict() .strict()
) )
.max(500) .max(500)
.optional() .optional(),
historyMessageIds: z.array(z.string().uuid()).max(500).optional(),
currentUserMessageId: z.string().uuid().optional(),
currentAssistantMessageId: z.string().uuid().optional(),
contextCompressionState:
conversationContextCompressionStateSchema.optional()
}) })
.strict() .strict()
.superRefine((request, context) => { .superRefine((request, context) => {
@@ -236,6 +243,17 @@ export const agentRequestSchema = z
message: '会话历史总长度不能超过 2,000,000 个字符' message: '会话历史总长度不能超过 2,000,000 个字符'
}) })
} }
if (
request.historyMessageIds &&
request.historyMessageIds.length !==
(request.history?.length ?? 0)
) {
context.addIssue({
code: 'custom',
path: ['historyMessageIds'],
message: '会话历史消息 ID 必须与历史消息一一对应'
})
}
}) })
export type AgentRequest = z.input<typeof agentRequestSchema> export type AgentRequest = z.input<typeof agentRequestSchema>
@@ -919,25 +937,26 @@ export type AgentEvent =
| { | {
requestId: string requestId: string
type: 'context-metrics' type: 'context-metrics'
estimatedInputTokens: number contextTokens: number
effectiveTriggerTokens: number effectiveTriggerTokens: number
contextWindowTokens?: number contextWindowTokens?: number
compressionEnabled: boolean compressionEnabled: boolean
recentRawTokens: number source: 'provider' | 'estimated'
coveredMessageCount: number
summaryTokens: number
} }
| { | {
requestId: string requestId: string
type: 'context-compression' type: 'context-compression'
state: 'started' | 'completed' scope?: 'conversation' | 'agent-run'
state: 'started' | 'completed' | 'failed'
estimatedBeforeTokens: number estimatedBeforeTokens: number
estimatedAfterTokens?: number estimatedAfterTokens?: number
effectiveTriggerTokens: number effectiveTriggerTokens: number
contextWindowTokens?: number contextWindowTokens?: number
recentRawTokens: number recentRawTokens: number
coveredMessageCount: number coveredMessageCount?: number
compressionCount?: number
summaryTokens?: number summaryTokens?: number
conversationState?: ConversationContextCompressionState
} }
| { | {
requestId: string requestId: string