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:
@@ -25,6 +25,7 @@
|
||||
- [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。
|
||||
- [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。
|
||||
- [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Chat Completions、OpenAI Images 和无认证本机模型。
|
||||
- [x] **上下文用量与自动压缩**:直连模型按每次成功调用更新供应商用量,图片与工具轮次使用同一口径,供应商缺失 usage 时才回退估算;界面明确区分“本次模型调用”和“压缩后对话估算”,压缩标识的前后值使用同一估算口径,运行记录仍保留各次模型调用的供应商 usage。对话与多轮工具 Agent 可在已完成调用越过阈值后自动重复压缩,规划时先为固定提示、工具定义和摘要预留预算;同一回复会分别保留 Agent 工具上下文与对话历史的压缩标识,并在应用重启或较早消息滚出本地历史窗口后继续复用摘要。
|
||||
- [x] **Main-only 凭据保护**:API Key 使用系统安全存储加密,不暴露给 Renderer。
|
||||
- [ ] **可执行 Subagent**(规划中):提供显式 Execute 委派,限制嵌套、并行、Token、时间和工具权限,并保留父子任务审计。
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '../../shared/contracts'
|
||||
import {
|
||||
estimateTextTokens,
|
||||
planPrefixCompression,
|
||||
planContextCompression
|
||||
} from './context-compression'
|
||||
|
||||
@@ -39,6 +40,39 @@ describe('context compression planning', () => {
|
||||
).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', () => {
|
||||
const history = [
|
||||
{ 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))
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const history = [
|
||||
{ role: 'user' as const, content: 'a'.repeat(16_000) },
|
||||
|
||||
@@ -22,6 +22,15 @@ export type ContextCompressionPlan = {
|
||||
effectiveTriggerTokens: number
|
||||
}
|
||||
|
||||
export type PrefixCompressionPlan<T> = {
|
||||
earlierUnits: T[]
|
||||
recentUnits: T[]
|
||||
estimatedInputTokens: number
|
||||
effectiveTriggerTokens: number
|
||||
}
|
||||
|
||||
export const contextSummaryTokenBudget = 8_192
|
||||
|
||||
function groupConversationTurns(
|
||||
messages: readonly CompressibleConversationMessage[]
|
||||
): CompressibleConversationMessage[][] {
|
||||
@@ -40,54 +49,122 @@ function groupConversationTurns(
|
||||
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: {
|
||||
history: readonly CompressibleConversationMessage[]
|
||||
prompt: string
|
||||
summaryTokens?: number
|
||||
settings: ContextCompressionSettings
|
||||
contextWindowTokens?: number
|
||||
allowCompressLatestTurn?: boolean
|
||||
effectiveTriggerTokens?: number
|
||||
triggerContextTokens?: number
|
||||
}): ContextCompressionPlan | undefined {
|
||||
const estimatedInputTokens = estimateContextInputTokens({
|
||||
history: input.history,
|
||||
prompt: input.prompt,
|
||||
summaryTokens: input.summaryTokens
|
||||
})
|
||||
const effectiveTriggerTokens = getEffectiveContextTriggerTokens({
|
||||
triggerTokens: input.settings.triggerTokens,
|
||||
contextWindowTokens: input.contextWindowTokens
|
||||
})
|
||||
if (estimatedInputTokens < effectiveTriggerTokens) {
|
||||
const effectiveTriggerTokens =
|
||||
input.effectiveTriggerTokens ??
|
||||
getEffectiveContextTriggerTokens({
|
||||
triggerTokens: input.settings.triggerTokens,
|
||||
contextWindowTokens: input.contextWindowTokens
|
||||
})
|
||||
const planningInputTokens = Math.max(
|
||||
estimatedInputTokens,
|
||||
input.triggerContextTokens ?? 0
|
||||
)
|
||||
if (planningInputTokens < effectiveTriggerTokens) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const fixedContextTokens = estimateContextInputTokens({
|
||||
history: [],
|
||||
prompt: input.prompt,
|
||||
summaryTokens: contextSummaryTokenBudget
|
||||
})
|
||||
const turns = groupConversationTurns(input.history)
|
||||
const recentTurns: CompressibleConversationMessage[][] = []
|
||||
const recentRawTokenBudget = Math.min(
|
||||
input.settings.recentRawTokens,
|
||||
Math.max(4_000, effectiveTriggerTokens - 8_000)
|
||||
)
|
||||
let recentTokens = 0
|
||||
while (turns.length > 0) {
|
||||
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) {
|
||||
const plan = planPrefixCompression({
|
||||
units: turns,
|
||||
estimatedInputTokens: planningInputTokens,
|
||||
effectiveTriggerTokens,
|
||||
recentRawTokens: input.settings.recentRawTokens,
|
||||
estimateUnitTokens: estimateMessagesTokens,
|
||||
allowCompressLatestUnit: input.allowCompressLatestTurn,
|
||||
maximumRecentRawTokens: Math.max(
|
||||
0,
|
||||
effectiveTriggerTokens - fixedContextTokens
|
||||
)
|
||||
})
|
||||
if (!plan) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
earlierMessages,
|
||||
recentMessages: recentTurns.flat(),
|
||||
earlierMessages: plan.earlierUnits.flat(),
|
||||
recentMessages: plan.recentUnits.flat(),
|
||||
estimatedInputTokens,
|
||||
effectiveTriggerTokens
|
||||
effectiveTriggerTokens: plan.effectiveTriggerTokens
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+673
-99
File diff suppressed because it is too large
Load Diff
@@ -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', () => {
|
||||
let workspace = ''
|
||||
|
||||
@@ -214,6 +283,104 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
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(
|
||||
'compresses real direct-model history and preserves earlier and recent facts',
|
||||
async () => {
|
||||
@@ -247,7 +414,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
content: [
|
||||
'The project codename is ORBIT-739.',
|
||||
'Background notes:',
|
||||
'alpha '.repeat(5_000)
|
||||
'alpha '.repeat(8_000)
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
@@ -255,7 +422,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
content: [
|
||||
'I will remember the project codename.',
|
||||
'Acknowledgement notes:',
|
||||
'gamma '.repeat(4_000)
|
||||
'gamma '.repeat(6_500)
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
@@ -263,7 +430,7 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
content: [
|
||||
'The deploy region is AP-SOUTH-7.',
|
||||
'Recent notes:',
|
||||
'beta '.repeat(3_000)
|
||||
'beta '.repeat(5_000)
|
||||
].join('\n')
|
||||
},
|
||||
{
|
||||
@@ -312,6 +479,172 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
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(
|
||||
'discovers and plans GoodBuddy configuration through a real model',
|
||||
async () => {
|
||||
@@ -399,13 +732,21 @@ describe.runIf(enabled)('runtime end-to-end', () => {
|
||||
baseUrl,
|
||||
model: modelName,
|
||||
protocol,
|
||||
authentication: 'api-key'
|
||||
authentication: 'api-key',
|
||||
contextCompression: {
|
||||
settings: {
|
||||
...defaultContextCompressionSettings,
|
||||
enabled: true
|
||||
},
|
||||
contextWindowTokens: 32_000
|
||||
}
|
||||
})
|
||||
const abortController = new AbortController()
|
||||
const events: RuntimeEvent[] = []
|
||||
|
||||
try {
|
||||
const result = collectText(
|
||||
runtime.run(
|
||||
const result = (async () => {
|
||||
for await (const event of runtime.run(
|
||||
{
|
||||
requestId: 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.'
|
||||
},
|
||||
abortController.signal
|
||||
)
|
||||
)
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
})()
|
||||
setTimeout(() => abortController.abort(), 50)
|
||||
await expect(result).rejects.toMatchObject({
|
||||
name: 'AbortError'
|
||||
})
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'context-metrics'
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ describe('AssistantDatabase', () => {
|
||||
database.close()
|
||||
})
|
||||
|
||||
it('migrates existing databases to schema version 19', async () => {
|
||||
it('migrates existing databases to schema version 20', async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), 'goodbuddy-assistant-migration-')
|
||||
)
|
||||
@@ -127,7 +127,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(19)
|
||||
).toBe(20)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -231,7 +231,7 @@ describe('AssistantDatabase', () => {
|
||||
user_version: number
|
||||
}
|
||||
).user_version
|
||||
).toBe(19)
|
||||
).toBe(20)
|
||||
expect(
|
||||
current
|
||||
.prepare(
|
||||
@@ -1281,6 +1281,12 @@ describe('AssistantDatabase', () => {
|
||||
],
|
||||
createdAt: 1_775_000_001_000,
|
||||
state: 'streaming',
|
||||
contextCompression: {
|
||||
state: 'completed',
|
||||
scope: 'conversation',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000
|
||||
},
|
||||
artifactIds: [
|
||||
'00000000-0000-4000-8000-000000000216'
|
||||
],
|
||||
@@ -1342,6 +1348,12 @@ describe('AssistantDatabase', () => {
|
||||
state: 'error',
|
||||
status: expect.stringContaining('意外中断'),
|
||||
reasoning: '先分析发布范围',
|
||||
contextCompression: {
|
||||
state: 'completed',
|
||||
scope: 'conversation',
|
||||
estimatedBeforeTokens: 22_000,
|
||||
estimatedAfterTokens: 9_000
|
||||
},
|
||||
blocks: [
|
||||
expect.objectContaining({
|
||||
type: 'reasoning',
|
||||
@@ -1451,6 +1463,24 @@ describe('AssistantDatabase', () => {
|
||||
header: {
|
||||
id: conversationId,
|
||||
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: '增量对话(已完成)',
|
||||
updatedAt: 1_775_000_001_000
|
||||
},
|
||||
@@ -1461,7 +1491,22 @@ describe('AssistantDatabase', () => {
|
||||
content: '生成完成',
|
||||
createdAt: 1_775_000_000_001,
|
||||
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,
|
||||
@@ -1478,12 +1523,41 @@ describe('AssistantDatabase', () => {
|
||||
|
||||
expect(database.getConversation(conversationId)).toMatchObject({
|
||||
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: [
|
||||
{
|
||||
id: streamingMessageId,
|
||||
content: '生成完成',
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import {
|
||||
conversationSnapshotSchema,
|
||||
expertCreateSchema,
|
||||
normalizeInteractiveWorkMode
|
||||
} from '../../shared/assistant-contracts'
|
||||
@@ -107,6 +108,7 @@ type ConversationRow = {
|
||||
project_id: string | null
|
||||
runtime_selection_json: string | null
|
||||
knowledge_retrieval_mode: 'auto' | 'always' | null
|
||||
context_state_json: string | null
|
||||
title: string
|
||||
channel: ProjectChannel | null
|
||||
external_account_id: string | null
|
||||
@@ -173,6 +175,8 @@ type MessageMetadata = {
|
||||
status?: string
|
||||
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
||||
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
||||
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
|
||||
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||
sources?: string[]
|
||||
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(
|
||||
conversation: ConversationRow,
|
||||
messages: MessageRow[]
|
||||
@@ -764,6 +807,7 @@ function toConversationSnapshot(
|
||||
),
|
||||
knowledgeRetrievalMode:
|
||||
conversation.knowledge_retrieval_mode ?? undefined,
|
||||
...parseConversationContextState(conversation.context_state_json),
|
||||
...(conversation.channel &&
|
||||
conversation.conversation_type &&
|
||||
conversation.account_display
|
||||
@@ -796,6 +840,8 @@ function toConversationSnapshot(
|
||||
status: interrupted
|
||||
? interruptedMessageStatus
|
||||
: metadata.status,
|
||||
contextCompression: metadata.contextCompression,
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: interrupted
|
||||
? interruptActiveTools(metadata.tools)
|
||||
: metadata.tools,
|
||||
@@ -817,6 +863,8 @@ function serializeConversationMessageMetadata(
|
||||
status: message.status,
|
||||
reasoning: message.reasoning,
|
||||
blocks: message.blocks,
|
||||
contextCompression: message.contextCompression,
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
@@ -1334,7 +1382,7 @@ export class AssistantDatabase {
|
||||
const conversations = database
|
||||
.prepare(
|
||||
`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,
|
||||
conversation_type, account_display, updated_at
|
||||
FROM conversations
|
||||
@@ -1369,7 +1417,7 @@ export class AssistantDatabase {
|
||||
const conversation = database
|
||||
.prepare(
|
||||
`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,
|
||||
conversation_type, account_display, updated_at
|
||||
FROM conversations
|
||||
@@ -1497,8 +1545,9 @@ export class AssistantDatabase {
|
||||
const insertConversation = database.prepare(
|
||||
`INSERT INTO conversations
|
||||
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||
work_mode, title, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
context_state_json, work_mode, title, status, created_at,
|
||||
updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
)
|
||||
const insertMessage = database.prepare(
|
||||
`INSERT INTO messages
|
||||
@@ -1518,6 +1567,7 @@ export class AssistantDatabase {
|
||||
? JSON.stringify(conversation.runtimeSelection)
|
||||
: null,
|
||||
conversation.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(conversation),
|
||||
conversation.title,
|
||||
updatedAt,
|
||||
updatedAt
|
||||
@@ -1532,18 +1582,7 @@ export class AssistantDatabase {
|
||||
message.content,
|
||||
message.state,
|
||||
sequence,
|
||||
JSON.stringify({
|
||||
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
|
||||
}),
|
||||
serializeConversationMessageMetadata(message),
|
||||
new Date(message.createdAt).toISOString()
|
||||
)
|
||||
}
|
||||
@@ -1563,14 +1602,15 @@ export class AssistantDatabase {
|
||||
const insertConversation = database.prepare(
|
||||
`INSERT INTO conversations
|
||||
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||
work_mode, title, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
context_state_json, work_mode, title, status, created_at,
|
||||
updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, 'active', ?, ?)`
|
||||
)
|
||||
const updateConversation = database.prepare(
|
||||
`UPDATE conversations
|
||||
SET project_id = ?, runtime_selection_json = ?,
|
||||
knowledge_retrieval_mode = ?, title = ?, status = 'active',
|
||||
updated_at = ?
|
||||
knowledge_retrieval_mode = ?, context_state_json = ?,
|
||||
title = ?, status = 'active', updated_at = ?
|
||||
WHERE id = ? AND channel IS NULL`
|
||||
)
|
||||
const findMessage = database.prepare(
|
||||
@@ -1623,6 +1663,7 @@ export class AssistantDatabase {
|
||||
? JSON.stringify(header.runtimeSelection)
|
||||
: null,
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
header.title,
|
||||
updatedAt,
|
||||
header.id
|
||||
@@ -1638,6 +1679,7 @@ export class AssistantDatabase {
|
||||
? JSON.stringify(header.runtimeSelection)
|
||||
: null,
|
||||
header.knowledgeRetrievalMode ?? null,
|
||||
serializeConversationContextState(header),
|
||||
header.title,
|
||||
updatedAt,
|
||||
updatedAt
|
||||
@@ -4718,12 +4760,12 @@ export class AssistantDatabase {
|
||||
const version = database
|
||||
.prepare('PRAGMA user_version')
|
||||
.get() as { user_version: number }
|
||||
if (version.user_version > 19) {
|
||||
if (version.user_version > 20) {
|
||||
throw new Error(
|
||||
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
||||
)
|
||||
}
|
||||
if (version.user_version === 19) {
|
||||
if (version.user_version === 20) {
|
||||
return
|
||||
}
|
||||
if (version.user_version < 1) {
|
||||
@@ -4750,6 +4792,7 @@ export class AssistantDatabase {
|
||||
knowledge_retrieval_mode IS NULL OR
|
||||
knowledge_retrieval_mode IN ('auto', 'always')
|
||||
),
|
||||
context_state_json TEXT,
|
||||
work_mode TEXT NOT NULL DEFAULT 'ask'
|
||||
CHECK(work_mode IN ('ask', 'execute')),
|
||||
title TEXT NOT NULL,
|
||||
@@ -5633,6 +5676,28 @@ export class AssistantDatabase {
|
||||
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 {
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('AssistantDatabase heartbeat persistence', () => {
|
||||
).count
|
||||
check.close()
|
||||
migrated.close()
|
||||
expect(version).toBe(19)
|
||||
expect(version).toBe(20)
|
||||
expect(heartbeatTableCount).toBe(3)
|
||||
})
|
||||
|
||||
|
||||
+335
-23
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}}'
|
||||
},
|
||||
|
||||
@@ -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}}'
|
||||
},
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -137,6 +137,20 @@ export type ConversationMessageBlock = z.infer<
|
||||
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
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
@@ -147,13 +161,11 @@ export const conversationMessageSchema = z
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
state: z.enum(['streaming', 'complete', 'error']),
|
||||
status: z.string().max(4_000).optional(),
|
||||
contextCompression: z
|
||||
.object({
|
||||
state: z.enum(['compressing', 'completed', 'failed']),
|
||||
estimatedBeforeTokens: z.number().int().nonnegative(),
|
||||
estimatedAfterTokens: z.number().int().nonnegative().optional()
|
||||
})
|
||||
.strict()
|
||||
contextCompression:
|
||||
conversationContextCompressionMarkerSchema.optional(),
|
||||
contextCompressions: z
|
||||
.array(conversationContextCompressionMarkerSchema)
|
||||
.max(2)
|
||||
.optional(),
|
||||
tools: z.array(conversationToolActivitySchema).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
|
||||
>
|
||||
|
||||
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
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
projectId: assistantIdSchema.optional(),
|
||||
runtimeSelection: agentRuntimeSelectionSchema.optional(),
|
||||
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
||||
contextMetrics: conversationContextMetricsSchema.optional(),
|
||||
contextCompressionState:
|
||||
conversationContextCompressionStateSchema.optional(),
|
||||
remote: z
|
||||
.object({
|
||||
channel: projectChannelSchema,
|
||||
|
||||
+26
-7
@@ -17,6 +17,7 @@ import type {
|
||||
} from './capability-contracts'
|
||||
import {
|
||||
assistantIdSchema,
|
||||
conversationContextCompressionStateSchema,
|
||||
legacyWorkModeSchema,
|
||||
type AssistantProject,
|
||||
type AssistantArtifact,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
type TokenUsageSummary,
|
||||
type ConversationSnapshot,
|
||||
type ConversationAttachment,
|
||||
type ConversationContextCompressionState,
|
||||
type LocalConversationSaveBatch,
|
||||
type WorkspaceChanges,
|
||||
type WorkspaceDirectoryListing,
|
||||
@@ -220,7 +222,12 @@ export const agentRequestSchema = z
|
||||
.strict()
|
||||
)
|
||||
.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()
|
||||
.superRefine((request, context) => {
|
||||
@@ -236,6 +243,17 @@ export const agentRequestSchema = z
|
||||
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>
|
||||
@@ -919,25 +937,26 @@ export type AgentEvent =
|
||||
| {
|
||||
requestId: string
|
||||
type: 'context-metrics'
|
||||
estimatedInputTokens: number
|
||||
contextTokens: number
|
||||
effectiveTriggerTokens: number
|
||||
contextWindowTokens?: number
|
||||
compressionEnabled: boolean
|
||||
recentRawTokens: number
|
||||
coveredMessageCount: number
|
||||
summaryTokens: number
|
||||
source: 'provider' | 'estimated'
|
||||
}
|
||||
| {
|
||||
requestId: string
|
||||
type: 'context-compression'
|
||||
state: 'started' | 'completed'
|
||||
scope?: 'conversation' | 'agent-run'
|
||||
state: 'started' | 'completed' | 'failed'
|
||||
estimatedBeforeTokens: number
|
||||
estimatedAfterTokens?: number
|
||||
effectiveTriggerTokens: number
|
||||
contextWindowTokens?: number
|
||||
recentRawTokens: number
|
||||
coveredMessageCount: number
|
||||
coveredMessageCount?: number
|
||||
compressionCount?: number
|
||||
summaryTokens?: number
|
||||
conversationState?: ConversationContextCompressionState
|
||||
}
|
||||
| {
|
||||
requestId: string
|
||||
|
||||
Reference in New Issue
Block a user