From cf7229e72a8ce51dcfecadfc51e63b5b451ad3d5 Mon Sep 17 00:00:00 2001 From: Shuhan Lin Date: Thu, 22 Oct 2026 22:43:32 +0800 Subject: [PATCH 1/9] fix(compaction): retry oversized summaries with smaller prefixes --- .../compaction-basic/README.i18n.yaml | 12 +- .../compaction/compaction-basic/README.md | 6 +- .../compaction/compaction-basic/README.zh.md | 6 +- .../compaction/compaction-basic/src/index.ts | 25 +- .../compaction/compaction-basic/src/region.ts | 29 +- .../tests/compaction-basic.spec.ts | 16 +- .../tests/summary-overflow-loop.spec.ts | 116 ++++++++ .../tests/summary-overflow.spec.ts | 250 ++++++++++++++++++ snapshots/acp/acp.snapshot.ts | 5 + .../acp/summary-overflow/cordis.snapshot.yml | 58 ++++ snapshots/acp/summary-overflow/cordis.yml | 37 +++ snapshots/acp/summary-overflow/input.json | 67 +++++ .../acp/summary-overflow/session.v4.jsonl | 40 +++ snapshots/acp/summary-overflow/snapshot.yml | 16 ++ .../summary-overflow/stdout.expected.jsonl | 11 + .../acp/summary-overflow/summary-window.mjs | 26 ++ .../system-prompt.expected.md | 1 + 17 files changed, 698 insertions(+), 23 deletions(-) create mode 100644 packages/compaction/compaction-basic/tests/summary-overflow-loop.spec.ts create mode 100644 packages/compaction/compaction-basic/tests/summary-overflow.spec.ts create mode 100644 snapshots/acp/summary-overflow/cordis.snapshot.yml create mode 100644 snapshots/acp/summary-overflow/cordis.yml create mode 100644 snapshots/acp/summary-overflow/input.json create mode 100644 snapshots/acp/summary-overflow/session.v4.jsonl create mode 100644 snapshots/acp/summary-overflow/snapshot.yml create mode 100644 snapshots/acp/summary-overflow/stdout.expected.jsonl create mode 100644 snapshots/acp/summary-overflow/summary-window.mjs create mode 120000 snapshots/acp/summary-overflow/system-prompt.expected.md diff --git a/packages/compaction/compaction-basic/README.i18n.yaml b/packages/compaction/compaction-basic/README.i18n.yaml index 9e67ab2df6..9e3c8f8fcf 100644 --- a/packages/compaction/compaction-basic/README.i18n.yaml +++ b/packages/compaction/compaction-basic/README.i18n.yaml @@ -27,8 +27,8 @@ en: 3446c54c062d7e34 zh: fb502c941e508ae0 /deepseek-ai-dsh-compaction-basic/use-this-package/what-happens-when-condensation-runs: - en: c872a8ef069596f0 - zh: b3a48e309ae830d4 + en: 399bce86e63b8e19 + zh: 727c023210085f5c /deepseek-ai-dsh-compaction-basic/use-this-package/switching-models: en: 48978abc46da1afa zh: 11931af05db7b41f @@ -51,8 +51,8 @@ en: 967531b71453eb88 zh: c7919b46e36763e6 /deepseek-ai-dsh-compaction-basic/understand-the-implementation/the-region-transaction: - en: f4330f6bff22d400 - zh: 46e16c51bb66a0f7 + en: 0492e288c55028cb + zh: 88a85c06c0cc78d2 /deepseek-ai-dsh-compaction-basic/understand-the-implementation/config-resolution: en: 313e019b1a9f8aa8 zh: 3c75d11b02adb693 @@ -90,8 +90,8 @@ en: 39a362ee4890dbdb zh: 3b98699120b1548e /deepseek-ai-dsh-compaction-basic/model-experience/auxiliary-summarizer-request/token-effect: - en: 5e0679cb7cb4291a - zh: c9df8ebc526b05d9 + en: ae2e2b748de72d77 + zh: ba11a49cbc263dca /deepseek-ai-dsh-compaction-basic/model-experience/auxiliary-summarizer-request/kv-cache-effect: en: d31d3e3d46542c91 zh: 5a42f873993e0c79 diff --git a/packages/compaction/compaction-basic/README.md b/packages/compaction/compaction-basic/README.md index 3956c8a57d..6a4485e503 100644 --- a/packages/compaction/compaction-basic/README.md +++ b/packages/compaction/compaction-basic/README.md @@ -83,6 +83,8 @@ Misconfiguration fails fast: unknown settings, duplicate per-model overrides, in The oldest balanced span is replaced by one summary message and the recent tail stays verbatim; the conversation continues from the summary. The operation reports how many history items were condensed and the estimated tokens freed. If nothing can be condensed safely — for example the whole conversation is one indivisible unit — nothing changes and nothing is written to the session log. If no model is available to write the summary (no configured target and no routed request yet), condensation fails with a clear error telling you to configure the summarization provider and model or route one request. +If an automatically selected or `/compact` summary receives `CONTEXT_WINDOW_EXCEEDED`, condensation retries the first half of the selected complete history units, keeping each tool call with its results. Each further overflow halves the remaining units; a single unit that still overflows fails. A successful checkpoint replaces only the prefix actually summarized, leaving the remaining messages and images intact. Explicit `compactRegion(start, end)` calls keep their exact selection and propagate summary overflow. + ### Switching models Before a changed provider/model request is committed, automatic compaction measures the existing history against the destination model's capacity, output reservation, and pressure policy. When condensation is needed, it uses the previous routed model and its summary settings; an explicitly configured summary target still takes precedence. The destination request proceeds only after history falls below its threshold. Failure reports `MODEL_SWITCH_COMPACTION_FAILED` and leaves the previous request header intact; select a larger model or retry after correcting the reported cause. Completed pruning or condensation remains in the session even when a later attempt fails. The check runs when the next request is prepared, and `auto: false` disables it. @@ -130,7 +132,7 @@ A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap ### The region transaction -Failed summary requests dispatch synchronous `compaction/summary-error` after checking cancellation and selection stability. A recovery listener must record a durable input change before requesting retry. The backend re-derives the selected messages and refreshes their token prices and shrink baseline. The image-offload plugin owns image selection; its recorded omissions remain effective if the summary later fails or is cancelled. +Failed summary requests dispatch synchronous `compaction/summary-error` after checking cancellation and selection stability. A recovery listener must record a durable input change before requesting retry. The backend re-derives the selected messages and refreshes their token prices and shrink baseline. The image-offload plugin owns image selection; its recorded omissions remain effective if the summary later fails or is cancelled. When no listener handles a canonical context overflow, self-selected ranges may shrink within the same transaction; only the successful prefix appears in the checkpoint's cited range. The transaction validates the surface span and the durable lock, appends `compaction/start`, summarizes through the hook, revalidates stability (whole-surface for automatic calls, selected-span for manual calls), rejects a summary that does not shrink its source, appends `compaction/summary` plus the replacement `user/message`, and makes exactly one `compaction/end` attempt. A live unmatched start is the durable lock: an unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. A failed close deliberately leaves a blocking orphan. Cancellation remains authoritative after cleanup and durability. @@ -236,7 +238,7 @@ Rules: #### Token effect -This is a separate model call: the replayed conversation prefix plus the fixed instruction as input, with `maxTokens`-capped output. Convergence retries can pay this cost more than once. +Each summary attempt is a separate model call: the replayed conversation prefix plus the fixed instruction as input, with `maxTokens`-capped output. Convergence retries and smaller-prefix retries can pay this cost more than once. #### KV Cache effect diff --git a/packages/compaction/compaction-basic/README.zh.md b/packages/compaction/compaction-basic/README.zh.md index 283a4d19eb..6cebca3e80 100644 --- a/packages/compaction/compaction-basic/README.zh.md +++ b/packages/compaction/compaction-basic/README.zh.md @@ -83,6 +83,8 @@ kind: "package-reference" 最旧的平衡范围会被替换为一条摘要消息,近期尾部保持逐字不变;对话从摘要继续。操作会报告压缩了多少历史项以及估算释放的 token 数。如果没有任何内容可以安全压缩——例如整个对话就是一个不可分单元——则不会有任何改变,也不会向会话日志写入任何内容。如果没有模型可以撰写摘要(既未配置目标,也还没有已路由请求),压缩会失败并给出清晰错误,提示你配置摘要提供方与模型,或先路由一次请求。 +自动选区或 `/compact` 的摘要收到 `CONTEXT_WINDOW_EXCEEDED` 时,压缩会重试所选完整历史单元的前一半,每个工具调用及其结果保持在一起。每次再次超限都会将剩余单元折半;单个单元仍超限时失败。成功检查点只替换实际完成摘要的前缀,其余消息和图片保持不变。显式 `compactRegion(start, end)` 调用保持精确选区,并向调用方报告摘要超限。 + ### 切换模型 在提供方或模型发生变化的请求写入日志前,自动压缩会按目标模型的容量、输出预留和压力策略测量现有历史。需要压缩时,使用原路由模型及其摘要设置;显式配置的摘要目标仍然优先。只有历史降到目标阈值以下,才会继续目标请求。失败会报告 `MODEL_SWITCH_COMPACTION_FAILED`,并保留原请求头;可选择更大窗口的模型,或修正报告的原因后重试。即使后续尝试失败,已经完成的裁剪或压缩仍保留在会话中。检查在下次请求准备时执行,`auto: false` 会禁用它。 @@ -130,7 +132,7 @@ kind: "package-reference" ### 区域事务 -摘要请求失败后,后端先检查取消和选区稳定性,再触发同步的 `compaction/summary-error`。恢复监听器必须先记录持久的输入变更,才能请求重试。后端重新派生所选消息,更新 token 价格及判断摘要是否缩短输入的基准。图片省略插件负责选图;后续摘要失败或取消后,已经记录的省略仍然有效。 +摘要请求失败后,后端先检查取消和选区稳定性,再触发同步的 `compaction/summary-error`。恢复监听器必须先记录持久的输入变更,才能请求重试。后端重新派生所选消息,更新 token 价格及判断摘要是否缩短输入的基准。图片省略插件负责选图;后续摘要失败或取消后,已经记录的省略仍然有效。没有监听器处理规范上下文超限时,自选范围可以在同一事务中缩小;检查点只引用成功摘要的前缀范围。 事务验证表层范围与持久锁,追加 `compaction/start`,通过钩子生成摘要,重新验证稳定性(自动调用要求整个表层、手动调用只要求所选范围),拒绝不缩小源内容的摘要,追加 `compaction/summary` 与替换 `user/message`,并恰好进行一次 `compaction/end` 尝试。活动的未匹配 start 是持久锁:位于较新 `session/end-seed` 之前的未匹配标记是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。闭合失败会有意留下阻塞性的未匹配标记。完成清理与持久化后,取消仍具有最终决定权。 @@ -236,7 +238,7 @@ Rules: #### Token 影响 -这是一次独立模型调用:输入是已回放会话前缀加固定指令,输出受 `maxTokens` 限制。收敛重试可能多次支付这项成本。 +每次摘要尝试都是一次独立模型调用:输入是已回放会话前缀加固定指令,输出受 `maxTokens` 限制。收敛重试和缩小前缀后的重试可能多次支付这项成本。 #### KV Cache 影响 diff --git a/packages/compaction/compaction-basic/src/index.ts b/packages/compaction/compaction-basic/src/index.ts index 096fa72fe4..8b45fb878e 100644 --- a/packages/compaction/compaction-basic/src/index.ts +++ b/packages/compaction/compaction-basic/src/index.ts @@ -304,7 +304,7 @@ export class BasicCompactionEngine extends CompactionEngine { const measurement = this.ctx.tokenMeter.measure(agent.session) const range = selectCompactableRange(agent.session, measurement, 0) if (range === null) return null - return this.compactRegion(range.start, range.end, agent, signal) + return this.compactAutomaticRegion(range.start, range.end, agent, signal) } /* v8 ignore next -- closed-union exhaustiveness guard */ default: @@ -359,7 +359,7 @@ export class BasicCompactionEngine extends CompactionEngine { + `(${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})`, ) } - result = await this.compactRegion(range.start, range.end, agent, signal) + result = await this.compactAutomaticRegion(range.start, range.end, agent, signal) measurement = meter.measure(agent.session, header) if (measurement.totalTokens < spec.thresholdTokens) return result } @@ -370,8 +370,26 @@ export class BasicCompactionEngine extends CompactionEngine { ) } + /** Compact an automatically selected prefix, allowing smaller balanced prefixes on summary overflow. */ + private compactAutomaticRegion( + start: SessionSeq, + end: SessionSeq, + agent: Agent, + signal: AbortSignal, + ): Promise { + return compactSurfaceRegion( + this.regionDependencies(), + agent.session, + start, + end, + agent, + { owner: 'current-turn', stability: 'whole-surface', allowSmallerPrefix: true }, + signal, + ) + } + /** - * Compact one inclusive positional range from the agent-owned surface using + * Compact one exact inclusive positional range from the agent-owned surface using * the effective token meter for all retention and shrink pricing. * @param start - inclusive first surface-node seq. * @param end - inclusive last surface-node seq. @@ -430,6 +448,7 @@ export class BasicCompactionEngine extends CompactionEngine { { owner: null, stability: 'selected-span', + allowSmallerPrefix: true, ...sourceCommandId === undefined ? {} : { sourceCommandId }, flush: async () => { await this.ctx.sessions.flush(agent.session) diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index 7b3d77d299..eb4c7174c5 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -16,7 +16,7 @@ import { } from '@deepseek-ai/dsh-compaction' import type { CompactionResult } from '@deepseek-ai/dsh-compaction' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' -import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { CONTEXT_WINDOW_EXCEEDED_CODE, createUserMessage, errorChain, LlmError } from '@deepseek-ai/dsh-llm' import type { Message, UserMessage } from '@deepseek-ai/dsh-llm' import type { TokenMeasurement, TokenMeter } from '@deepseek-ai/dsh-token-meter' import { SessionSeq, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' @@ -61,6 +61,8 @@ interface CompactionTransactionOptions { readonly flush?: () => Promise /** Manual command that initiated this transaction, when present. */ readonly sourceCommandId?: CommandId + /** Allow a self-selected range to shrink after summary overflow; absent keeps the exact range. */ + readonly allowSmallerPrefix?: true } interface CompactionEntryState { @@ -227,6 +229,7 @@ export async function compactSurfaceRegion( compactionId, options.sourceCommandId, assertStable, + options.allowSmallerPrefix === true, signal, ) if (options.owner === null) signal?.throwIfAborted() @@ -391,6 +394,7 @@ async function summarizeCompaction( compactionId: CompactionResult['compactionId'], sourceCommandId: CommandId | undefined, assertStable: StabilityCheck, + allowSmallerPrefix: boolean, signal?: AbortSignal, ): Promise { let summaryResult: SummaryResult @@ -402,9 +406,16 @@ async function summarizeCompaction( } catch (error: unknown) { if (signal?.aborted === true) throw error assertStable(dependencies, agent.session, prepared) - if (!dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) throw error - prepared = prepareCompaction(dependencies, agent.session, - validateSurfaceRegion(agent.session, prepared.start, prepared.end)) + let selection: SurfaceSelection + if (dependencies.recover(error, agent, prepared.shadowedSeqs, signal)) { + selection = validateSurfaceRegion(agent.session, prepared.start, prepared.end) + } else { + if (!allowSmallerPrefix || !(error instanceof LlmError) || error.code !== CONTEXT_WINDOW_EXCEEDED_CODE) throw error + const smaller = smallerBalancedPrefix(agent.session, prepared) + if (smaller === null) throw error + selection = smaller + } + prepared = prepareCompaction(dependencies, agent.session, selection) } } const checkpointMessage = createUserMessage({ @@ -427,6 +438,16 @@ async function summarizeCompaction( } } +/** Halve complete tool-balanced units; one remaining unit cannot be shortened safely. */ +function smallerBalancedPrefix(session: Session, selection: SurfaceSelection): SurfaceSelection | null { + const ends = selection.shadowedSeqs.filter(seq => toolPairingBalancedAfter(session, seq)) + if (ends.length < 2) return null + // A validated span ends balanced; two or more units leave a nonempty proper prefix. + // oxlint-disable-next-line typescript/no-non-null-assertion + const end = ends[Math.ceil(ends.length / 2) - 1]! + return validateSurfaceRegion(session, selection.start, end) +} + /** Reject a summary prepared against any earlier surface generation. */ function assertWholeSurfaceUnchanged( dependencies: RegionDependencies, diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index cf6780933d..789dd32fdd 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -850,15 +850,19 @@ describe('pressure measurement and retention', () => { expect(await compactIfNeeded(compact, retained)).toBeNull() }) - it('uses one unified measurement for each pressure-and-retention decision', async () => { + it('preserves history when a pressure summary fails', async () => { const ctx = createContext() const compact = service(compactConfig, ctx) - const measure = vi.spyOn(ctx.tokenMeter, 'measure') - const stop = new Error('stop after first decision') - vi.spyOn(compact, 'compactRegion').mockRejectedValueOnce(stop) + const session = conversation(4) + const originalNodes = [...session.surface.nodes] + const stop = new Error('summary failed') + compact.error = stop - await expect(compactIfNeeded(compact, conversation(4))).rejects.toBe(stop) - expect(measure).toHaveBeenCalledTimes(1) + await expect(compactIfNeeded(compact, session)).rejects.toBe(stop) + expect(compact.calls).toHaveLength(1) + expect(session.surface.nodes).toEqual(originalNodes) + expect(session.snapshotEvents().filter(event => event.type === 'compaction/summary')).toEqual([]) + expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'compaction/end', data: { error: 'summary failed' } }) }) it('bounds retries when a shrinking checkpoint remains above threshold', async () => { diff --git a/packages/compaction/compaction-basic/tests/summary-overflow-loop.spec.ts b/packages/compaction/compaction-basic/tests/summary-overflow-loop.spec.ts new file mode 100644 index 0000000000..91f747fc1e --- /dev/null +++ b/packages/compaction/compaction-basic/tests/summary-overflow-loop.spec.ts @@ -0,0 +1,116 @@ +/** Summary overflow recovery through the real agent loop and image projections. */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { + CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter, LlmError, ToolCallId, createUserMessage, +} from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import TokenMeter from '@deepseek-ai/dsh-token-meter' +import * as imageOffload from '@deepseek-ai/dsh-compaction-image-offload' +import BasicCompactionEngine from '../src/index.ts' + +type ImageBlock = Extract + +function image(name: string): ImageBlock { + return { + type: 'image', + attachment: { + attachmentId: `sha256:${'a'.repeat(64)}` as ImageBlock['attachment']['attachmentId'], + name, mediaType: 'image/png', bytes: 1024, width: 1920, height: 1080, + }, + } +} + +function imageNames(options: GenerateOptions): string[] { + return options.messages.flatMap(message => message.content.flatMap(block => + block.type === 'image' && block.offloaded !== true ? [block.attachment.name ?? ''] : [])) +} + +/** Capacity is computed from valid image occurrences; successful calls report exact fixture usage. */ +class ImageCapacityAdapter extends LlmAdapter { + readonly requests: Array<{ purpose: string; images: string[]; overflow: boolean }> = [] + private toolIssued = false + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text', 'image'], context: { contextWindow: 10_000 } }) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + const images = imageNames(options) + const textTokens = options.messages.reduce((total, message) => total + message.content.reduce((sum, block) => + sum + (block.type === 'text' ? Math.ceil(block.text.length / 4) : 0), 4), 0) + const inputTokens = images.length * 2000 + textTokens + const overflow = inputTokens + (options.maxTokens ?? 0) > 10_000 + this.requests.push({ purpose: options.purpose ?? 'conversation', images, overflow }) + if (overflow) throw new LlmError('fixture image context exceeds the model window', CONTEXT_WINDOW_EXCEEDED_CODE) + + const lastText = options.messages.at(-1)?.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') + const callsTool = options.purpose !== 'compaction' && lastText === 'Collect two more screenshots.' && !this.toolIssued + if (callsTool) { + this.toolIssued = true + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('capture'), name: 'capture_images', arguments: '{}' } } + } else { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'block-end', index: 0, block: { type: 'text', text: options.purpose === 'compaction' ? 'Earlier screenshots were reviewed.' : 'Received.' } } + } + yield { type: 'usage', usage: { inputTokens, outputTokens: 10 } } + yield { type: 'finish', reason: { kind: callsTool ? 'tool-calls' : 'stop' } } + } +} + +describe('summary overflow through the agent loop', () => { + it('summarizes a smaller prefix and retries with the untouched image suffix', async () => { + const ctx = new Context() + const adapter = new ImageCapacityAdapter() + try { + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(imageOffload) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(TokenMeter) + ctx.effect(() => ctx.llm.registerAdapter(['mock'], adapter)) + ctx.effect(() => ctx.tools.register(defineContentToolFixture({ + name: 'capture_images', description: 'Return two screenshot references', parameters: {}, + async execute(_args, exec) { + if (exec.agent === undefined) throw new Error('image fixture requires an agent') + // Steering makes the complete tool exchange eligible for prefix compaction. + exec.agent.steer(createUserMessage({ content: [{ type: 'text', text: 'Continue inspecting the screenshots.' }], source: { kind: 'user' } })) + return [image('recent-a'), image('recent-b')] + }, + }))) + await ctx.plugin(BasicCompactionEngine, { headroomTokens: 0, thresholdRatio: 1, retainTokens: 100, maxTokens: 256 }) + const agent = await ctx.agentLoop.create(SessionId('image-summary-overflow'), { provider: 'mock', model: 'vision', maxTokens: 256 }) + for (const label of ['old', 'middle']) { + agent.followup(createUserMessage({ + content: [{ type: 'text', text: `${label}: ${'dashboard details '.repeat(40)}` }, image(`${label}-a`), image(`${label}-b`)], + source: { kind: 'user' }, + })) + await agent.whenIdle() + expect(agent.session.snapshotEvents().at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + } + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Collect two more screenshots.' }], source: { kind: 'user' } })) + await agent.whenIdle() + + const summaries = adapter.requests.filter(request => request.purpose === 'compaction') + expect(summaries).toHaveLength(2) + expect(summaries[0]).toMatchObject({ overflow: true, images: ['old-a', 'old-b', 'middle-a', 'middle-b', 'recent-a', 'recent-b'] }) + expect(summaries[1]).toMatchObject({ overflow: false, images: ['old-a', 'old-b', 'middle-a', 'middle-b'] }) + expect(adapter.requests.at(-1)).toMatchObject({ purpose: 'conversation', overflow: false, images: ['recent-a', 'recent-b'] }) + + const events = agent.session.snapshotEvents() + expect(events.filter(event => event.type === 'compaction/start')).toHaveLength(1) + expect(events.filter(event => event.type === 'compaction/end')).toHaveLength(1) + expect(events.filter(event => event.type === 'compaction/summary')).toHaveLength(1) + expect(events.filter(event => event.type === 'image/offload')).toEqual([]) + expect(events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } }) + expect(agent.session.surface.replaceGeneration).toBe(1) + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/compaction/compaction-basic/tests/summary-overflow.spec.ts b/packages/compaction/compaction-basic/tests/summary-overflow.spec.ts new file mode 100644 index 0000000000..da34b56156 --- /dev/null +++ b/packages/compaction/compaction-basic/tests/summary-overflow.spec.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CONTEXT_WINDOW_EXCEEDED_CODE, createAssistantMessage, createSystemMessage, createToolResultMessage, createUserMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import TokenMeter from '@deepseek-ai/dsh-token-meter' +import { compactSurfaceRegion } from '../src/region.ts' +import type { SummarizationInput, SummaryResult } from '../src/summarizer.ts' + +const contexts: Context[] = [] +afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) }) + +const historyText = 'A retained source fact with enough detail for a smaller checkpoint. '.repeat(80) + +function overflow(): LlmError { + return new LlmError('summary input exceeds the model context', CONTEXT_WINDOW_EXCEEDED_CODE) +} + +function summary(text = 'The older task details are summarized.'): SummaryResult { + return { summary: [{ type: 'text', text }], provider: 'test', model: 'summary' } +} + +/** Each tool unit contains two calls and both results; user units contain one message. */ +function harness(units: readonly ('user' | 'tools')[]) { + const ctx = new Context() + contexts.push(ctx) + new SessionProjectionRegistry(ctx) + const meter = new TokenMeter(ctx) + const session = Session.create(SessionId('summary-overflow')) + session.append('turn/start', { turn: 1 }) + const head = session.append('system/message', { + turn: 1, step: 1, message: createSystemMessage(historyText), + }, { surfaceOp: 'append' }) + for (const [index, unit] of units.entries()) { + if (unit === 'user') { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `${index}: ${historyText}` }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + continue + } + const step = index + 1 + const callIds = [ToolCallId(`call-${index}-a`), ToolCallId(`call-${index}-b`)] + session.append('step/start', { turn: 1, step }) + session.append('assistant/message', { + turn: 1, step, stream: [], + message: createAssistantMessage({ + source: { provider: 'test', model: 'test' }, + content: callIds.map(id => ({ type: 'tool-call', id, name: 'read', arguments: '{}' })), + }), + }, { surfaceOp: 'append' }) + for (const callId of callIds) { + session.append('tool/call', { turn: 1, step, callId, name: 'read', arguments: '{}' }) + session.append('tool/result', { + turn: 1, step, + message: createToolResultMessage({ callId, content: [{ type: 'text', text: historyText }], isError: false }), + }, { surfaceOp: 'append' }) + } + session.append('step/end', { turn: 1, step }) + } + const selected = session.surface.nodes.slice(1) + const start = selected[0] + const end = selected.at(-1) + if (start === undefined || end === undefined) throw new Error('fixture needs a nonempty selection') + const tail = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Keep the newest request verbatim.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const agent = { session, options: { provider: 'test', model: 'test' } } as Agent + const summarize = vi.fn<(input: SummarizationInput) => Promise>() + const recover = vi.fn[0]['recover']>(() => false) + const requests: SummarizationInput[] = [] + const run = (allowSmallerPrefix = true, signal?: AbortSignal, owner: 'current-turn' | null = 'current-turn') => compactSurfaceRegion({ + meter, + summarize(input) { requests.push(input); return summarize(input) }, + recover, + }, session, start, end, agent, { + owner, stability: owner === null ? 'selected-span' : 'whole-surface', + ...allowSmallerPrefix ? { allowSmallerPrefix: true as const } : {}, + }, signal) + return { session, head, tail, selected, requests, summarize, recover, run } +} + +function sourceCount(input: SummarizationInput): number { + return input.messages.filter(message => message.role !== 'system').length +} + +function compactionTypes(session: Session): string[] { + return session.snapshotEvents().filter(event => event.type.startsWith('compaction/')).map(event => event.type) +} + +describe('summary context overflow', () => { + it.each([8, 9])('halves complete prefixes until one fits and preserves the excluded suffix: %s units', async (count) => { + const h = harness(Array.from({ length: count }, () => 'user')) + h.summarize.mockImplementation(async (input) => { + if (sourceCount(input) > 2) throw overflow() + return summary() + }) + const result = await h.run() + expect(h.requests.map(sourceCount)).toEqual(count === 8 ? [8, 4, 2] : [9, 5, 3, 2]) + expect(result.shadowedSeqs).toEqual(h.selected.slice(0, 2)) + expect(h.session.surface.nodes.slice(2)).toEqual([...h.selected.slice(2), h.tail.seq]) + expect(h.session.surface.nodes[0]).toBe(h.head.seq) + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) + }) + + it('counts a tool-call message and all its results as one indivisible unit', async () => { + const h = harness(['tools', 'user', 'user', 'user']) + h.summarize.mockRejectedValueOnce(overflow()).mockResolvedValue(summary()) + const result = await h.run() + expect(h.requests.map(sourceCount)).toEqual([6, 4]) + expect(h.requests[1]?.messages.map(message => message.role)).toEqual(['system', 'assistant', 'tool', 'tool', 'user']) + expect(result.shadowedSeqs).toEqual(h.selected.slice(0, 4)) + expect(h.session.surface.nodes.slice(2)).toEqual([...h.selected.slice(4), h.tail.seq]) + }) + + it.each(['user', 'tools'] as const)('stops at one indivisible %s unit without committing a checkpoint', async (unit) => { + const h = harness([unit]) + const error = overflow() + h.summarize.mockRejectedValue(error) + const original = [...h.session.surface.nodes] + await expect(h.run()).rejects.toBe(error) + expect(h.summarize).toHaveBeenCalledTimes(1) + expect(h.session.surface.nodes).toEqual(original) + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end']) + }) + + it('exhausts progressively smaller prefixes when even the first unit overflows', async () => { + const h = harness(Array.from({ length: 8 }, () => 'user')) + const error = overflow() + h.summarize.mockRejectedValue(error) + const original = [...h.session.surface.nodes] + await expect(h.run()).rejects.toBe(error) + expect(h.requests.map(sourceCount)).toEqual([8, 4, 2, 1]) + expect(h.session.surface.nodes).toEqual(original) + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end']) + }) + + it('keeps an explicitly requested range exact', async () => { + const h = harness(['user', 'user', 'user', 'user']) + const error = overflow() + h.summarize.mockRejectedValue(error) + await expect(h.run(false)).rejects.toBe(error) + expect(h.summarize).toHaveBeenCalledTimes(1) + }) + + it.each([ + new LlmError('credentials expired', 'AUTH'), + new LlmError('provider unavailable', 'SERVER'), + new LlmError('summary truncated', 'MAX_TOKENS'), + Object.assign(new Error('unclassified failure'), { code: CONTEXT_WINDOW_EXCEEDED_CODE }), + ])('does not shorten for other failures: %s', async (error) => { + const h = harness(['user', 'user', 'user', 'user']) + h.summarize.mockRejectedValue(error) + await expect(h.run()).rejects.toBe(error) + expect(h.summarize).toHaveBeenCalledTimes(1) + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end']) + }) + + it('does not retry or invoke recovery after cancellation', async () => { + const h = harness(['user', 'user', 'user', 'user']) + const controller = new AbortController() + const error = overflow() + h.summarize.mockImplementation(async () => { controller.abort('cancelled'); throw error }) + await expect(h.run(true, controller.signal)).rejects.toBe(error) + expect(h.summarize).toHaveBeenCalledTimes(1) + expect(h.recover).not.toHaveBeenCalled() + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end']) + }) + + it('rejects a changed surface before considering a smaller selection', async () => { + const h = harness(['user', 'user', 'user', 'user']) + h.summarize.mockImplementation(async () => { + h.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'concurrent input' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + throw overflow() + }) + await expect(h.run()).rejects.toThrow('session surface changed during summarization') + expect(h.summarize).toHaveBeenCalledTimes(1) + expect(h.recover).not.toHaveBeenCalled() + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end']) + }) + + it.each(['manual', 'automatic'] as const)('retains %s stability rules when the excluded suffix changes after shortening', async (mode) => { + const h = harness(Array.from({ length: 8 }, () => 'user')) + if (mode === 'manual') h.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const replaced = h.selected[6] + if (replaced === undefined) throw new Error('fixture needs a suffix node') + let replacement = replaced + h.summarize.mockRejectedValueOnce(overflow()).mockImplementation(async () => { + replacement = h.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Updated excluded suffix' }], source: { kind: 'user' }, + }), { + surfaceOp: { op: 'replace', startSeq: replaced, endSeq: replaced }, sourceEventSeqs: [replaced], + }).seq + return summary() + }) + const operation = h.run(true, undefined, mode === 'manual' ? null : 'current-turn') + if (mode === 'manual') { + const result = await operation + expect(result.shadowedSeqs).toEqual(h.selected.slice(0, 4)) + expect(h.session.surface.nodes.slice(2)).toEqual([ + ...h.selected.slice(4, 6), replacement, ...h.selected.slice(7), h.tail.seq, + ]) + expect(h.session.snapshotEvents().find(event => event.type === 'compaction/start')?.data).toMatchObject({ turn: null }) + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) + } else { + await expect(operation).rejects.toThrow('session surface changed during summarization') + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end']) + } + expect(h.requests.map(sourceCount)).toEqual([8, 4]) + }) + + it('allows a registered durable repair to retain the entire selection', async () => { + const h = harness(['user', 'user', 'user', 'user']) + h.summarize.mockRejectedValueOnce(overflow()).mockResolvedValue(summary()) + h.recover.mockImplementationOnce(() => { + h.session.append('system/message', { + turn: 1, step: 1, message: createSystemMessage('Reduced system input'), + }, { surfaceOp: { op: 'replace', startSeq: h.head.seq, endSeq: h.head.seq }, sourceEventSeqs: [h.head.seq] }) + return true + }) + const result = await h.run() + expect(h.requests.map(sourceCount)).toEqual([4, 4]) + expect(h.requests[1]?.messages[0]?.content).toEqual([{ type: 'text', text: 'Reduced system input' }]) + expect(result.shadowedSeqs).toEqual(h.selected) + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) + }) + + it('preserves the original surface if a smaller summary fails for another reason', async () => { + const h = harness(['user', 'user', 'user', 'user']) + const error = new LlmError('provider unavailable', 'SERVER') + h.summarize.mockRejectedValueOnce(overflow()).mockRejectedValue(error) + const original = [...h.session.surface.nodes] + await expect(h.run()).rejects.toBe(error) + expect(h.requests.map(sourceCount)).toEqual([4, 2]) + expect(h.session.surface.nodes).toEqual(original) + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end']) + }) + + it('compares the checkpoint with the smaller source span before committing', async () => { + const h = harness(['user', 'user', 'user', 'user']) + h.summarize.mockRejectedValueOnce(overflow()).mockResolvedValue(summary(historyText.repeat(3))) + const original = [...h.session.surface.nodes] + await expect(h.run()).rejects.toThrow('summary is not smaller than the shadowed content') + expect(h.requests.map(sourceCount)).toEqual([4, 2]) + expect(h.session.surface.nodes).toEqual(original) + expect(compactionTypes(h.session)).toEqual(['compaction/start', 'compaction/end']) + }) +}) diff --git a/snapshots/acp/acp.snapshot.ts b/snapshots/acp/acp.snapshot.ts index 1cde2b7a68..a7900854f4 100644 --- a/snapshots/acp/acp.snapshot.ts +++ b/snapshots/acp/acp.snapshot.ts @@ -41,6 +41,11 @@ const controllerCases: readonly { hasModelTurn: true, configPath: join(corpusDir, 'image-compaction', 'cordis.yml'), }, + { + name: 'summary-overflow', + hasModelTurn: true, + configPath: join(corpusDir, 'summary-overflow', 'cordis.yml'), + }, ] as const function localScenarioSource(source: string | undefined): string | undefined { diff --git a/snapshots/acp/summary-overflow/cordis.snapshot.yml b/snapshots/acp/summary-overflow/cordis.snapshot.yml new file mode 100644 index 0000000000..a9e6f599da --- /dev/null +++ b/snapshots/acp/summary-overflow/cordis.snapshot.yml @@ -0,0 +1,58 @@ +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek-api-key' + disabled: true + +- id: acp + name: '@deepseek-ai/dsh-acp' + config: + provider: deepseek-official + model: deepseek-v4-flash-vision-exp + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + compression: none + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + personaPrefix: | + You are a coding assistant powered by the {{model}} model. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + inputModalities: [text] + - id: deepseek-v4-pro + inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] + contextWindow: 1000000 + imageRequestTokens: 384 + +- id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + +- id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + config: + headroomTokens: 0 + maxTokens: 8192 + retainTokens: 100 + +- insert: + - name: './summary-window.mjs' diff --git a/snapshots/acp/summary-overflow/cordis.yml b/snapshots/acp/summary-overflow/cordis.yml new file mode 100644 index 0000000000..eef7dbd2bf --- /dev/null +++ b/snapshots/acp/summary-overflow/cordis.yml @@ -0,0 +1,37 @@ +- id: acp + name: '@deepseek-ai/dsh-acp' + config: + provider: deepseek-official + model: deepseek-v4-flash-vision-exp + +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + compression: none + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + personaPrefix: | + You are a coding assistant powered by the {{model}} model. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + +- id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + +- id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + config: + headroomTokens: 0 + maxTokens: 8192 + retainTokens: 100 + +- insert: + - name: './summary-window.mjs' diff --git a/snapshots/acp/summary-overflow/input.json b/snapshots/acp/summary-overflow/input.json new file mode 100644 index 0000000000..e5115716cf --- /dev/null +++ b/snapshots/acp/summary-overflow/input.json @@ -0,0 +1,67 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "promptContent", + "content": [ + { + "type": "text", + "text": "First group: inspect these three reference screenshots and acknowledge receipt." + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + } + ] + }, + { + "op": "promptContent", + "content": [ + { + "type": "text", + "text": "Second group: inspect these three reference screenshots and acknowledge receipt." + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + }, + { + "type": "image", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "mimeType": "image/png" + } + ] + }, + { + "op": "promptContent", + "content": [ + { + "type": "text", + "text": "Now reply with exactly the single word DONE." + } + ] + } + ] +} diff --git a/snapshots/acp/summary-overflow/session.v4.jsonl b/snapshots/acp/summary-overflow/session.v4.jsonl new file mode 100644 index 0000000000..f4e078caf3 --- /dev/null +++ b/snapshots/acp/summary-overflow/session.v4.jsonl @@ -0,0 +1,40 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783952000000,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"First group: inspect these three reference screenshots and acknowledge receipt."},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"First group: inspect these three reference screenshots and acknowledge receipt."},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":1000000}} +{"type":"session/title","data":{"title":"First group: inspect these three","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Received the first three frames."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1792679887018,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1792679887019,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Received the first three frames."}}},{"type":"chunk","time":1792679887019,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1792679887019,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Second group: inspect these three reference screenshots and acknowledge receipt."},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"}]}} +{"type":"turn/start","data":{"turn":2}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":2,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Second group: inspect these three reference screenshots and acknowledge receipt."},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"user"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"Received the next three frames."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1792679887081,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1792679887081,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Received the next three frames."}}},{"type":"chunk","time":1792679887081,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1792679887081,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":2,"step":1}} +{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"}]}} +{"type":"turn/start","data":{"turn":3}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":3,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} +{"type":"assistant/attempt","data":{"turn":3,"step":1,"stream":[{"type":"chunk","time":1792679887096,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"code":"CONTEXT_WINDOW_EXCEEDED","message":"screenshot history exceeds the authored context window"}}}}]}} +{"type":"compaction/start","data":{"compactionId":"{{id:1}}","turn":3}} +{"type":"compaction/summary","data":{"compactionId":"{{id:1}}","summary":[{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."}],"rawOutput":[{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."}],"llmStreamCall":true,"shadowedRange":{"start":8,"end":13},"shadowedSeqs":[8,9,13],"shadowedTokenCount":319,"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","maxTokens":8192,"usage":{"inputTokens":20,"outputTokens":16}}} +{"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."},{"type":"text","text":""}],"source":{"kind":"compact-checkpoint","compactionId":"{{id:1}}"},"role":"user","id":"{{message:8}}"},"sourceEventSeqs":[30,31,8,9,13],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":13}} +{"type":"compaction/end","data":{"compactionId":"{{id:1}}","turn":3}} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"tools":"{{tools}}"},"reason":"series"}} +{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:10}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1792679887109,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1792679887109,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1792679887109,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1792679887109,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":3,"step":1}} +{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}}} diff --git a/snapshots/acp/summary-overflow/snapshot.yml b/snapshots/acp/summary-overflow/snapshot.yml new file mode 100644 index 0000000000..13495f3a7d --- /dev/null +++ b/snapshots/acp/summary-overflow/snapshot.yml @@ -0,0 +1,16 @@ +version: 1 +scenario: summary-overflow +profile: acp +composition: summary-overflow +recording: authored +header: + class: summary-overflow + pin: true + systemPromptSource: session/read-image + toolSchemasSource: escalation-approved +permission: danger-full-access +input: + attachments: + - id: sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640 + mediaType: image/png + data: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElFTkSuQmCC diff --git a/snapshots/acp/summary-overflow/stdout.expected.jsonl b/snapshots/acp/summary-overflow/stdout.expected.jsonl new file mode 100644 index 0000000000..6b812a367a --- /dev/null +++ b/snapshots/acp/summary-overflow/stdout.expected.jsonl @@ -0,0 +1,11 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Received the first three frames."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":1000000}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Received the next three frames."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":1000000}}} +{"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":"{{usedTokens}}","size":1000000}}} +{"jsonrpc":"2.0","id":5,"result":{"stopReason":"end_turn"}} diff --git a/snapshots/acp/summary-overflow/summary-window.mjs b/snapshots/acp/summary-overflow/summary-window.mjs new file mode 100644 index 0000000000..07913f6f56 --- /dev/null +++ b/snapshots/acp/summary-overflow/summary-window.mjs @@ -0,0 +1,26 @@ +/** Reject summaries containing both screenshot groups without consuming a replay entry. */ +export const name = 'summary-window' +export const inject = ['llm'] + +/** @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local context. */ +export function apply(ctx) { + ctx.on('llm/stream', async function* (options, next) { + const images = options.messages.flatMap(message => message.content) + .filter(block => block.type === 'image') + if (options.purpose === 'compaction' && images.length > 3) { + yield { + type: 'finish', + reason: { kind: 'error', failure: { + code: 'CONTEXT_WINDOW_EXCEEDED', message: 'summary screenshots exceed the authored context window', + } }, + } + return + } + const hasCheckpoint = options.messages.some(message => message.source?.kind === 'compact-checkpoint') + if (options.purpose !== 'compaction' && hasCheckpoint + && (images.length !== 3 || images.some(image => image.offloaded === true))) { + throw new Error('summary recovery must retain the second screenshot group') + } + yield* next() + }) +} diff --git a/snapshots/acp/summary-overflow/system-prompt.expected.md b/snapshots/acp/summary-overflow/system-prompt.expected.md new file mode 120000 index 0000000000..7d5e489c58 --- /dev/null +++ b/snapshots/acp/summary-overflow/system-prompt.expected.md @@ -0,0 +1 @@ +../../session/read-image/system-prompt.expected.md \ No newline at end of file From cd6187f901ef87ef3d2f5e486d3e83ae7163955e Mon Sep 17 00:00:00 2001 From: Shuhan Lin Date: Fri, 23 Oct 2026 09:53:08 +0800 Subject: [PATCH 2/9] test(compaction): stabilize overflow snapshots and cover entry points --- .../compaction-basic/README.i18n.yaml | 8 +-- .../compaction/compaction-basic/README.md | 4 +- .../compaction/compaction-basic/README.zh.md | 4 +- .../tests/compaction-basic.spec.ts | 54 ++++++++++++++++++- .../tests/manual-compaction.spec.ts | 50 +++++++++++++++++ .../session-snapshot/README.i18n.yaml | 4 +- .../test-support/session-snapshot/README.md | 2 + .../session-snapshot/README.zh.md | 2 + .../session-snapshot/src/harness.ts | 22 +++++++- .../session-snapshot/src/suite.ts | 3 ++ .../session-snapshot/tests/harness.spec.ts | 43 +++++++++++++++ .../session-snapshot/tests/suite.spec.ts | 2 + snapshots/acp/acp.snapshot.ts | 2 + .../acp/summary-overflow/session.v4.jsonl | 2 +- snapshots/acp/summary-overflow/snapshot.yml | 2 +- .../system-prompt.expected.md | 1 - 16 files changed, 192 insertions(+), 13 deletions(-) delete mode 120000 snapshots/acp/summary-overflow/system-prompt.expected.md diff --git a/packages/compaction/compaction-basic/README.i18n.yaml b/packages/compaction/compaction-basic/README.i18n.yaml index 9e3c8f8fcf..ffd19547bb 100644 --- a/packages/compaction/compaction-basic/README.i18n.yaml +++ b/packages/compaction/compaction-basic/README.i18n.yaml @@ -27,8 +27,8 @@ en: 3446c54c062d7e34 zh: fb502c941e508ae0 /deepseek-ai-dsh-compaction-basic/use-this-package/what-happens-when-condensation-runs: - en: 399bce86e63b8e19 - zh: 727c023210085f5c + en: 7284ba61343adcdd + zh: efd0113ab8e8e5ce /deepseek-ai-dsh-compaction-basic/use-this-package/switching-models: en: 48978abc46da1afa zh: 11931af05db7b41f @@ -51,8 +51,8 @@ en: 967531b71453eb88 zh: c7919b46e36763e6 /deepseek-ai-dsh-compaction-basic/understand-the-implementation/the-region-transaction: - en: 0492e288c55028cb - zh: 88a85c06c0cc78d2 + en: febc0a9de3eabe5f + zh: a570cc5d171c31b7 /deepseek-ai-dsh-compaction-basic/understand-the-implementation/config-resolution: en: 313e019b1a9f8aa8 zh: 3c75d11b02adb693 diff --git a/packages/compaction/compaction-basic/README.md b/packages/compaction/compaction-basic/README.md index 6a4485e503..40dad00558 100644 --- a/packages/compaction/compaction-basic/README.md +++ b/packages/compaction/compaction-basic/README.md @@ -83,7 +83,7 @@ Misconfiguration fails fast: unknown settings, duplicate per-model overrides, in The oldest balanced span is replaced by one summary message and the recent tail stays verbatim; the conversation continues from the summary. The operation reports how many history items were condensed and the estimated tokens freed. If nothing can be condensed safely — for example the whole conversation is one indivisible unit — nothing changes and nothing is written to the session log. If no model is available to write the summary (no configured target and no routed request yet), condensation fails with a clear error telling you to configure the summarization provider and model or route one request. -If an automatically selected or `/compact` summary receives `CONTEXT_WINDOW_EXCEEDED`, condensation retries the first half of the selected complete history units, keeping each tool call with its results. Each further overflow halves the remaining units; a single unit that still overflows fails. A successful checkpoint replaces only the prefix actually summarized, leaving the remaining messages and images intact. Explicit `compactRegion(start, end)` calls keep their exact selection and propagate summary overflow. +If an automatically selected or `/compact` summary receives `CONTEXT_WINDOW_EXCEEDED`, condensation retries the first half of the selected complete history units, rounded up to a whole unit, keeping each tool call with its results. Each further overflow applies the same reduction; a single unit that still overflows fails. A successful checkpoint replaces only the prefix actually summarized, leaving the remaining messages and images intact. ### Switching models @@ -134,6 +134,8 @@ A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap Failed summary requests dispatch synchronous `compaction/summary-error` after checking cancellation and selection stability. A recovery listener must record a durable input change before requesting retry. The backend re-derives the selected messages and refreshes their token prices and shrink baseline. The image-offload plugin owns image selection; its recorded omissions remain effective if the summary later fails or is cancelled. When no listener handles a canonical context overflow, self-selected ranges may shrink within the same transaction; only the successful prefix appears in the checkpoint's cited range. +Explicit `compactRegion(start, end)` calls keep their exact selection and propagate summary overflow. + The transaction validates the surface span and the durable lock, appends `compaction/start`, summarizes through the hook, revalidates stability (whole-surface for automatic calls, selected-span for manual calls), rejects a summary that does not shrink its source, appends `compaction/summary` plus the replacement `user/message`, and makes exactly one `compaction/end` attempt. A live unmatched start is the durable lock: an unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. A failed close deliberately leaves a blocking orphan. Cancellation remains authoritative after cleanup and durability. ### Config resolution diff --git a/packages/compaction/compaction-basic/README.zh.md b/packages/compaction/compaction-basic/README.zh.md index 6cebca3e80..8f5d532102 100644 --- a/packages/compaction/compaction-basic/README.zh.md +++ b/packages/compaction/compaction-basic/README.zh.md @@ -83,7 +83,7 @@ kind: "package-reference" 最旧的平衡范围会被替换为一条摘要消息,近期尾部保持逐字不变;对话从摘要继续。操作会报告压缩了多少历史项以及估算释放的 token 数。如果没有任何内容可以安全压缩——例如整个对话就是一个不可分单元——则不会有任何改变,也不会向会话日志写入任何内容。如果没有模型可以撰写摘要(既未配置目标,也还没有已路由请求),压缩会失败并给出清晰错误,提示你配置摘要提供方与模型,或先路由一次请求。 -自动选区或 `/compact` 的摘要收到 `CONTEXT_WINDOW_EXCEEDED` 时,压缩会重试所选完整历史单元的前一半,每个工具调用及其结果保持在一起。每次再次超限都会将剩余单元折半;单个单元仍超限时失败。成功检查点只替换实际完成摘要的前缀,其余消息和图片保持不变。显式 `compactRegion(start, end)` 调用保持精确选区,并向调用方报告摘要超限。 +自动选区或 `/compact` 的摘要收到 `CONTEXT_WINDOW_EXCEEDED` 时,压缩会重试所选完整历史单元的前一半(单元数向上取整),每个工具调用及其结果保持在一起。每次再次超限都按同一规则缩小范围;单个单元仍超限时失败。成功检查点只替换实际完成摘要的前缀,其余消息和图片保持不变。 ### 切换模型 @@ -134,6 +134,8 @@ kind: "package-reference" 摘要请求失败后,后端先检查取消和选区稳定性,再触发同步的 `compaction/summary-error`。恢复监听器必须先记录持久的输入变更,才能请求重试。后端重新派生所选消息,更新 token 价格及判断摘要是否缩短输入的基准。图片省略插件负责选图;后续摘要失败或取消后,已经记录的省略仍然有效。没有监听器处理规范上下文超限时,自选范围可以在同一事务中缩小;检查点只引用成功摘要的前缀范围。 +显式 `compactRegion(start, end)` 调用保持精确选区,并向调用方报告摘要超限。 + 事务验证表层范围与持久锁,追加 `compaction/start`,通过钩子生成摘要,重新验证稳定性(自动调用要求整个表层、手动调用只要求所选范围),拒绝不缩小源内容的摘要,追加 `compaction/summary` 与替换 `user/message`,并恰好进行一次 `compaction/end` 尝试。活动的未匹配 start 是持久锁:位于较新 `session/end-seed` 之前的未匹配标记是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。闭合失败会有意留下阻塞性的未匹配标记。完成清理与持久化后,取消仍具有最终决定权。 ### 配置解析 diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 789dd32fdd..9a22f81272 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -13,7 +13,7 @@ import { resolveTargetPolicy, } from '@deepseek-ai/dsh-compaction-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compaction' -import LlmRuntime, { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, createSystemMessage, createToolResultMessage, LlmAdapter , createMessage } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, ToolCallId, CONTEXT_WINDOW_EXCEEDED_CODE, createSystemMessage, createToolResultMessage, LlmAdapter, LlmError, createMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import type { ContentBlock, @@ -865,6 +865,58 @@ describe('pressure measurement and retention', () => { expect(session.snapshotEvents().at(-1)).toMatchObject({ type: 'compaction/end', data: { error: 'summary failed' } }) }) + it('shares one pressure-and-retention measurement before opening the compaction transaction', async () => { + const ctx = createContext() + try { + const compact = service(compactConfig, ctx) + const session = conversation(4) + const measuredRevisions: number[] = [] + const originalMeasure = ctx.tokenMeter.measure.bind(ctx.tokenMeter) + const measure = vi.spyOn(ctx.tokenMeter, 'measure').mockImplementation((measuredSession, header) => { + measuredRevisions.push(measuredSession.seq) + return originalMeasure(measuredSession, header) + }) + const failure = new Error('summary failed') + compact.error = failure + + await expect(compactIfNeeded(compact, session, 'pressure')).rejects.toBe(failure) + + const start = session.snapshotEvents().find(event => event.type === 'compaction/start') + if (start === undefined) throw new Error('pressure must open a compaction transaction') + expect(measuredRevisions.filter(revision => revision <= start.seq)).toHaveLength(1) + expect(measure.mock.calls[0]).toEqual([session, session.requestHeader()]) + expect(measuredRevisions.some(revision => revision > start.seq)).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + + it('recovers a pressure summary overflow below threshold while retaining the excluded suffix', async () => { + const ctx = createContext() + try { + const compact = service({ auto: false, thresholdRatio: 0.65, retainTokens: 180, compactionRetries: 0 }, ctx) + const session = conversation(4) + const originalNodes = [...session.surface.nodes] + const originalMessages = session.deriveMessages() + expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(650) + compact.mutateDuringSummary = () => { + if (compact.calls.length === 1) throw new LlmError('summary input exceeds the model context', CONTEXT_WINDOW_EXCEEDED_CODE) + } + + const result = await compactIfNeeded(compact, session, 'pressure') + + expect(compact.calls.map(call => call.input.messages.length)).toEqual([6, 3]) + expect(result?.shadowedSeqs).toEqual(originalNodes.slice(0, 3)) + expect(session.surface.nodes.slice(1)).toEqual(originalNodes.slice(3)) + expect(session.deriveMessages().slice(1)).toEqual(originalMessages.slice(3)) + expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(650) + expect(session.snapshotEvents().filter(event => event.type.startsWith('compaction/')).map(event => event.type)) + .toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) + } finally { + await ctx.fiber.dispose() + } + }) + it('bounds retries when a shrinking checkpoint remains above threshold', async () => { const compact = service({ auto: false, diff --git a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts index 051ae88d93..6e1c6e8748 100644 --- a/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts +++ b/packages/compaction/compaction-basic/tests/manual-compaction.spec.ts @@ -8,9 +8,11 @@ import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' import { CompactionId, isCompactCheckpointSource, ManualCompactionError } from '@deepseek-ai/dsh-compaction' import type { CompactionResult } from '@deepseek-ai/dsh-compaction' import { + CONTEXT_WINDOW_EXCEEDED_CODE, createAssistantMessage, createUserMessage, LlmAdapter, + LlmError, } from '@deepseek-ai/dsh-llm' import type { ContentBlock, @@ -239,6 +241,54 @@ function compactEvents(session: Session): SessionEvent[] { } describe('compactNow through the real loop', () => { + it('recovers an idle summary overflow and flushes before releasing a queued prompt', async () => { + const harness = await loopHarness() + const { ctx, agent, compact, adapter, log } = harness + const retryEntered = deferred() + const gate = deferred() + let running: Promise | undefined + try { + for (let turn = 0; turn < 3; turn += 1) await seedHistory(harness) + const originalNodes = [...agent.session.surface.nodes] + const originalMessages = agent.session.deriveMessages() + const flush = vi.spyOn(ctx.sessions, 'flush') + compact.duringSummary = () => { + if (compact.calls.length === 1) throw new LlmError('summary input exceeds the model context', CONTEXT_WINDOW_EXCEEDED_CODE) + compact.gate = gate.promise + retryEntered.resolve() + } + + running = compact.compactNow(agent, SIGNAL) + await Promise.race([retryEntered.promise, running]) + expect(compact.calls.map(input => input.messages.length)).toEqual([6, 4]) + expect(log).toEqual(['compaction/start:null']) + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'Continue after the smaller checkpoint.' }], source: { kind: 'user' }, + })) + expect(adapter.requests).toHaveLength(3) + + gate.resolve() + const result = await running + expect(result?.shadowedSeqs).toEqual(originalNodes.slice(1, 4)) + expect(flush).toHaveBeenCalledWith(agent.session) + await agent.whenIdle() + + const events = compactEvents(agent.session) + expect(events.map(event => event.type)).toEqual(['compaction/start', 'compaction/summary', 'compaction/end']) + expect(events[0]?.data).toMatchObject({ turn: null }) + expect(events[2]?.data).toMatchObject({ turn: null }) + expect(log.indexOf('compaction/end:null')).toBeLessThan(log.indexOf('flush')) + expect(log.indexOf('flush')).toBeLessThan(log.indexOf('turn/start')) + expect(adapter.requests).toHaveLength(4) + expect(adapter.requests[3]?.slice(2, -1)).toEqual(originalMessages.slice(4)) + expect(adapter.requests[3]?.at(-1)?.content).toEqual([{ type: 'text', text: 'Continue after the smaller checkpoint.' }]) + } finally { + gate.resolve() + if (running !== undefined) await Promise.allSettled([running]) + await ctx.fiber.dispose() + } + }) + it('passes logged image omissions to the summarizer without changing the original message', async () => { const { ctx, agent, compact } = await loopHarness() try { diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 7df8a50f25..9950ef43d6 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/README.i18n.yaml @@ -27,8 +27,8 @@ en: 6b11d5111e1a3222 zh: de4d3889b1a5ebb4 /deepseek-ai-dsh-session-snapshot/use-this-package/platform-and-composition-variants: - en: 64666ea2894acc4a - zh: 0e78df32bcfd3633 + en: 33c70ff7900f61c7 + zh: b7032a64bf1de40a /deepseek-ai-dsh-session-snapshot/use-this-package/what-can-go-wrong: en: 0b03592947ca43d6 zh: b14a64238c4b5f80 diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index ad26047830..3c7348e777 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -90,6 +90,8 @@ A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.j A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings. Headless manifests use `workspace.parent: outside-temp` when the Session workspace grant itself is under test. The adapter allocates beside the platform temp root when its parent is writable and outside system temporary grants, otherwise under home, and rejects any generated cwd already covered by automatic temporary write grants. +ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength: 192`. The harness pads the canonical temporary cwd to that JSON-quoted length and retains a unique random suffix; it rejects lengths that cannot accommodate the temporary root before allocating directories. Other scenarios keep their ordinary temporary paths. + ### What can go wrong - **A durable wait times out** — `waitForTurnStart`, `waitForTurnEnd`, `waitForSubagentTurnEnd`, `waitForGoalPhase`, `waitForInboxMessage`, `waitForTitleAfterTurnEnd`, and `waitForEventAfterTurnEnd` identify the session or child and deadline even when the first log harvest exceeds that deadline. Child waits also name the requested turn. All retain the underlying failure as the error cause. `waitForTurnStart` rejects malformed turn records immediately. diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index 161ad64cc7..8ca62f1440 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -90,6 +90,8 @@ Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创 需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。headless manifest 在测试 Session workspace 授权本身时使用 `workspace.parent: outside-temp`。适配器在父目录可写且位于系统临时授权之外时,于平台临时根目录旁分配目录,否则使用 home,并拒绝已被自动临时写授权覆盖的生成 cwd。 +持久记录 runtime-context 文本 token 计价的 ACP 场景可设置 `workspaceCwdTextLength: 192`。harness 将规范临时 cwd 填充至该 JSON 引用字符串长度,并保留唯一随机后缀;若指定长度容不下临时根目录,则在分配目录前拒绝。其他场景保留普通临时路径。 + ### 可能出什么问题 - **持久记录等待超时**——即使首次日志收集就超过期限,`waitForTurnStart`、`waitForTurnEnd`、`waitForSubagentTurnEnd`、`waitForGoalPhase`、`waitForInboxMessage`、`waitForTitleAfterTurnEnd` 和 `waitForEventAfterTurnEnd` 也会指出会话或子会话及等待期限。子会话等待还会指出目标轮次。这些等待都通过错误的 cause 保留底层失败。`waitForTurnStart` 会立即拒绝格式错误的轮次记录。 diff --git a/packages/test-support/session-snapshot/src/harness.ts b/packages/test-support/session-snapshot/src/harness.ts index 97b5f4b854..8f154b8f68 100644 --- a/packages/test-support/session-snapshot/src/harness.ts +++ b/packages/test-support/session-snapshot/src/harness.ts @@ -211,6 +211,12 @@ export interface RunOptions { * child, never the supplied parent. */ workspaceParent?: string + /** + * Exact JSON-quoted cwd length for fixtures that persist path-dependent token prices. + * Pads a canonical temporary path while retaining mkdtemp's random suffix; + * an insufficient or non-integer length rejects before directory allocation. + */ + workspaceCwdTextLength?: number /** * Alternate live profile patch (absolute), overriding * {@link AgentUnderTest.configPath} for this run. Its basename still ends @@ -236,6 +242,20 @@ export function snapshotSpillRoot( return `${root}/dsh-acp-snap-${key}` } +/** Keep path-priced fixtures independent of the temporary root without sharing directories. */ +function workspaceTemporaryPrefix(options: RunOptions): string { + const parent = options.workspaceParent ?? tmpdir() + const length = options.workspaceCwdTextLength + if (length === undefined) return join(parent, 'acp-snap-cwd-') + const prefix = join(realpathSync(parent), 'acp-snap-cwd-') + // mkdtemp contributes six characters; runtime-context renders the JSON-quoted cwd. + const minimum = JSON.stringify(`${prefix}XXXXXX`).length + if (!Number.isSafeInteger(length) || length < minimum) { + throw new Error(`snapshot-harness: workspaceCwdTextLength must be an integer of at least ${minimum}`) + } + return prefix + '-'.repeat(length - minimum) +} + /** * Run a scenario end-to-end against a freshly-spawned subprocess. Owns the * child and its generated dirs; always tears them down. Returns the captured stdout @@ -246,7 +266,7 @@ export function snapshotSpillRoot( * @returns The captured stdout/stderr, session id, generated cwd, and harvested logs. */ export async function runScenario(input: InputScript, opts: RunOptions): Promise { - const cwd = await mkdtemp(join(opts.workspaceParent ?? tmpdir(), 'acp-snap-cwd-')) + const cwd = await mkdtemp(workspaceTemporaryPrefix(opts)) const cwdAliases = [...new Set([realpathSync(cwd), realpathSync.native(cwd)])] const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-')) let spillRoot: string | undefined diff --git a/packages/test-support/session-snapshot/src/suite.ts b/packages/test-support/session-snapshot/src/suite.ts index 7546001ed3..d05fdabc7f 100644 --- a/packages/test-support/session-snapshot/src/suite.ts +++ b/packages/test-support/session-snapshot/src/suite.ts @@ -183,6 +183,8 @@ export interface Scenario { * test and the scenario needs an independent project location. */ workspaceParent?: string + /** Exact JSON-quoted cwd length forwarded to the harness for path-priced fixtures. */ + workspaceCwdTextLength?: number /** * Optional final workspace preparation after the committed fixture is * copied. Reserve this for paths that Git cannot represent portably; normal @@ -1337,6 +1339,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...existsSync(workspaceDir) ? { workspaceDir } : {}, ...scenario.prepareWorkspace !== undefined ? { prepareWorkspace: scenario.prepareWorkspace } : {}, ...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {}, + ...scenario.workspaceCwdTextLength === undefined ? {} : { workspaceCwdTextLength: scenario.workspaceCwdTextLength }, // A scenario passes its live profile patch; the launcher derives // the sibling `*cordis.snapshot.yml` for replay. ...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {}, diff --git a/packages/test-support/session-snapshot/tests/harness.spec.ts b/packages/test-support/session-snapshot/tests/harness.spec.ts index 1da0cb76c3..ec8d379986 100644 --- a/packages/test-support/session-snapshot/tests/harness.spec.ts +++ b/packages/test-support/session-snapshot/tests/harness.spec.ts @@ -683,6 +683,49 @@ describe('runScenario', () => { expect(child.startsWith(`..${sep}`)).toBe(false) }) + it('fixes the serialized cwd length across different temporary parents and removes each generated directory', async () => { + const { fixtureFile } = await scenario({}) + const parent = await mkdtemp(join(tmpdir(), 'acp-snap-path-length-')) + tempDirs.push(parent) + const longerParent = join(parent, 'a-longer-parent-directory') + await mkdir(longerParent) + const settled = await Promise.allSettled([parent, longerParent].map(workspaceParent => runScenario( + { steps: boot }, + { + agent: AGENT, mode: 'replay', fixtureFile, workspaceParent, workspaceCwdTextLength: 192, + prepareWorkspace: async (cwd) => { + expect(cwd).toBe(await realpath(cwd)) + expect(JSON.stringify(cwd).length).toBe(192) + }, + }, + ))) + const results = settled.map((result) => { + if (result.status === 'rejected') throw result.reason + return result.value + }) + expect(new Set(results.map(result => result.cwd)).size).toBe(2) + for (const result of results) { + expect(JSON.stringify(result.cwd).length).toBe(192) + expect(result.cwd).toMatch(/acp-snap-cwd-[-a-zA-Z0-9]+$/) + await expect(readdir(result.cwd)).rejects.toMatchObject({ code: 'ENOENT' }) + } + expect(await readdir(parent)).toEqual(['a-longer-parent-directory']) + }) + + it.each([1, 192.5])('rejects unusable serialized cwd length %s before allocating a workspace', async (workspaceCwdTextLength) => { + const { fixtureFile } = await scenario({}) + const allocate = vi.spyOn(fsPromises, 'mkdtemp') + try { + await expect(runScenario( + { steps: boot }, + { agent: AGENT, mode: 'replay', fixtureFile, workspaceCwdTextLength }, + )).rejects.toThrow('workspaceCwdTextLength') + expect(allocate).not.toHaveBeenCalled() + } finally { + allocate.mockRestore() + } + }) + it('promptAndCancel waits for the durable turn start, cancels, and settles the prompt', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel' }) const result = await runScenario( diff --git a/packages/test-support/session-snapshot/tests/suite.spec.ts b/packages/test-support/session-snapshot/tests/suite.spec.ts index d7c2ad8789..9c212fdc19 100644 --- a/packages/test-support/session-snapshot/tests/suite.spec.ts +++ b/packages/test-support/session-snapshot/tests/suite.spec.ts @@ -106,9 +106,11 @@ const REPLAY_SCENARIOS: Scenario[] = [ env: { DSH_PERMISSION_MODE: 'never' }, configPath: AGENT.configPath, workspaceParent: tmpdir(), + workspaceCwdTextLength: 192, pinsChildToolSchemas: [1], pinsChildSystemPrompts: [1], prepareWorkspace: (cwd) => { + expect(JSON.stringify(cwd).length).toBe(192) writeFileSync(join(cwd, 'seed.txt'), 'prepared at runtime') }, }, diff --git a/snapshots/acp/acp.snapshot.ts b/snapshots/acp/acp.snapshot.ts index a7900854f4..bd61115132 100644 --- a/snapshots/acp/acp.snapshot.ts +++ b/snapshots/acp/acp.snapshot.ts @@ -27,6 +27,7 @@ const controllerCases: readonly { readonly name: string readonly hasModelTurn: boolean readonly configPath?: string + readonly workspaceCwdTextLength?: number }[] = [ { name: 'handshake', hasModelTurn: false }, { name: 'reject-extra-dirs', hasModelTurn: false }, @@ -45,6 +46,7 @@ const controllerCases: readonly { name: 'summary-overflow', hasModelTurn: true, configPath: join(corpusDir, 'summary-overflow', 'cordis.yml'), + workspaceCwdTextLength: 192, }, ] as const diff --git a/snapshots/acp/summary-overflow/session.v4.jsonl b/snapshots/acp/summary-overflow/session.v4.jsonl index f4e078caf3..8855d689a8 100644 --- a/snapshots/acp/summary-overflow/session.v4.jsonl +++ b/snapshots/acp/summary-overflow/session.v4.jsonl @@ -30,7 +30,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Now reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:7}}"},"surfaceOp":"append"} {"type":"assistant/attempt","data":{"turn":3,"step":1,"stream":[{"type":"chunk","time":1792679887096,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"code":"CONTEXT_WINDOW_EXCEEDED","message":"screenshot history exceeds the authored context window"}}}}]}} {"type":"compaction/start","data":{"compactionId":"{{id:1}}","turn":3}} -{"type":"compaction/summary","data":{"compactionId":"{{id:1}}","summary":[{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."}],"rawOutput":[{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."}],"llmStreamCall":true,"shadowedRange":{"start":8,"end":13},"shadowedSeqs":[8,9,13],"shadowedTokenCount":319,"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","maxTokens":8192,"usage":{"inputTokens":20,"outputTokens":16}}} +{"type":"compaction/summary","data":{"compactionId":"{{id:1}}","summary":[{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."}],"rawOutput":[{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."}],"llmStreamCall":true,"shadowedRange":{"start":8,"end":13},"shadowedSeqs":[8,9,13],"shadowedTokenCount":349,"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","maxTokens":8192,"usage":{"inputTokens":20,"outputTokens":16}}} {"type":"user/message","data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The first group of three dashboard screenshots was inspected and acknowledged."},{"type":"text","text":""}],"source":{"kind":"compact-checkpoint","compactionId":"{{id:1}}"},"role":"user","id":"{{message:8}}"},"sourceEventSeqs":[30,31,8,9,13],"surfaceOp":{"op":"replace","startSeq":8,"endSeq":13}} {"type":"compaction/end","data":{"compactionId":"{{id:1}}","turn":3}} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"} diff --git a/snapshots/acp/summary-overflow/snapshot.yml b/snapshots/acp/summary-overflow/snapshot.yml index 13495f3a7d..4347a7651c 100644 --- a/snapshots/acp/summary-overflow/snapshot.yml +++ b/snapshots/acp/summary-overflow/snapshot.yml @@ -6,7 +6,7 @@ recording: authored header: class: summary-overflow pin: true - systemPromptSource: session/read-image + systemPromptSource: image-compaction toolSchemasSource: escalation-approved permission: danger-full-access input: diff --git a/snapshots/acp/summary-overflow/system-prompt.expected.md b/snapshots/acp/summary-overflow/system-prompt.expected.md deleted file mode 120000 index 7d5e489c58..0000000000 --- a/snapshots/acp/summary-overflow/system-prompt.expected.md +++ /dev/null @@ -1 +0,0 @@ -../../session/read-image/system-prompt.expected.md \ No newline at end of file From b5b1b5f0e5f0e08df93e2a237f1411c52583016b Mon Sep 17 00:00:00 2001 From: 1tops <60566626+1tops@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:33:51 +0800 Subject: [PATCH 3/9] fix(snapshot): resolve native Windows paths before sizing cwd --- .../session-snapshot/README.i18n.yaml | 4 +- .../test-support/session-snapshot/README.md | 2 +- .../session-snapshot/README.zh.md | 2 +- .../session-snapshot/src/harness.ts | 5 ++- .../session-snapshot/tests/harness.spec.ts | 45 +++++++++++++++++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 9950ef43d6..5efc7d808e 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/README.i18n.yaml @@ -27,8 +27,8 @@ en: 6b11d5111e1a3222 zh: de4d3889b1a5ebb4 /deepseek-ai-dsh-session-snapshot/use-this-package/platform-and-composition-variants: - en: 33c70ff7900f61c7 - zh: b7032a64bf1de40a + en: 6a01d3687551b6b5 + zh: 6715d9aed8a4b9d2 /deepseek-ai-dsh-session-snapshot/use-this-package/what-can-go-wrong: en: 0b03592947ca43d6 zh: b14a64238c4b5f80 diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 3c7348e777..a9adf44c13 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -90,7 +90,7 @@ A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.j A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings. Headless manifests use `workspace.parent: outside-temp` when the Session workspace grant itself is under test. The adapter allocates beside the platform temp root when its parent is writable and outside system temporary grants, otherwise under home, and rejects any generated cwd already covered by automatic temporary write grants. -ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength: 192`. The harness pads the canonical temporary cwd to that JSON-quoted length and retains a unique random suffix; it rejects lengths that cannot accommodate the temporary root before allocating directories. Other scenarios keep their ordinary temporary paths. +ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength: 192`. The harness resolves the parent directory through the native filesystem API before padding the cwd to that JSON-quoted length and retains a unique random suffix; it rejects lengths that cannot accommodate the temporary root before allocating directories. Other scenarios keep their ordinary temporary paths. ### What can go wrong diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index 8ca62f1440..aba1440298 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -90,7 +90,7 @@ Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创 需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。headless manifest 在测试 Session workspace 授权本身时使用 `workspace.parent: outside-temp`。适配器在父目录可写且位于系统临时授权之外时,于平台临时根目录旁分配目录,否则使用 home,并拒绝已被自动临时写授权覆盖的生成 cwd。 -持久记录 runtime-context 文本 token 计价的 ACP 场景可设置 `workspaceCwdTextLength: 192`。harness 将规范临时 cwd 填充至该 JSON 引用字符串长度,并保留唯一随机后缀;若指定长度容不下临时根目录,则在分配目录前拒绝。其他场景保留普通临时路径。 +持久记录 runtime-context 文本 token 计价的 ACP 场景可设置 `workspaceCwdTextLength: 192`。harness 先通过原生文件系统 API 解析父目录,再将 cwd 填充至该 JSON 引用字符串长度,并保留唯一随机后缀;若指定长度容不下临时根目录,则在分配目录前拒绝。其他场景保留普通临时路径。 ### 可能出什么问题 diff --git a/packages/test-support/session-snapshot/src/harness.ts b/packages/test-support/session-snapshot/src/harness.ts index 8f154b8f68..8e1cc03eb7 100644 --- a/packages/test-support/session-snapshot/src/harness.ts +++ b/packages/test-support/session-snapshot/src/harness.ts @@ -213,7 +213,7 @@ export interface RunOptions { workspaceParent?: string /** * Exact JSON-quoted cwd length for fixtures that persist path-dependent token prices. - * Pads a canonical temporary path while retaining mkdtemp's random suffix; + * Pads a natively resolved temporary path while retaining mkdtemp's random suffix; * an insufficient or non-integer length rejects before directory allocation. */ workspaceCwdTextLength?: number @@ -247,7 +247,8 @@ function workspaceTemporaryPrefix(options: RunOptions): string { const parent = options.workspaceParent ?? tmpdir() const length = options.workspaceCwdTextLength if (length === undefined) return join(parent, 'acp-snap-cwd-') - const prefix = join(realpathSync(parent), 'acp-snap-cwd-') + // Native resolution expands Windows short names before their characters are priced. + const prefix = join(realpathSync.native(parent), 'acp-snap-cwd-') // mkdtemp contributes six characters; runtime-context renders the JSON-quoted cwd. const minimum = JSON.stringify(`${prefix}XXXXXX`).length if (!Number.isSafeInteger(length) || length < minimum) { diff --git a/packages/test-support/session-snapshot/tests/harness.spec.ts b/packages/test-support/session-snapshot/tests/harness.spec.ts index ec8d379986..915cb1864d 100644 --- a/packages/test-support/session-snapshot/tests/harness.spec.ts +++ b/packages/test-support/session-snapshot/tests/harness.spec.ts @@ -1,5 +1,6 @@ import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import * as fsPromises from 'node:fs/promises' +import { realpathSync } from 'node:fs' import { once } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join, relative, sep } from 'node:path' @@ -12,8 +13,20 @@ import { launchAcpTestAgent } from '../src/launcher.ts' const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined, spillAllocationFailure: undefined as { error: Error; allocated: string[] } | undefined, + legacyRealpathParent: undefined as string | undefined, })) +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const realpathSync = Object.assign((...args: Parameters) => { + const parent = fsControl.legacyRealpathParent + return parent !== undefined && args[0] === parent && args[1] === undefined + ? parent + : actual.realpathSync(...args) + }, { native: actual.realpathSync.native }) + return { ...actual, realpathSync } +}) + vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { @@ -726,6 +739,38 @@ describe('runScenario', () => { } }) + it('prices the native long parent path when legacy resolution retains a short alias', async () => { + const { fixtureFile } = await scenario({}) + const allocatedRoot = await mkdtemp(join(tmpdir(), 'acp-snap-native-parent-')) + tempDirs.push(allocatedRoot) + const root = await realpath(allocatedRoot) + const longParent = join(root, 'long-parent-directory') + const shortParent = join(root, 'PARENT~1') + await mkdir(longParent) + // The junction keeps allocation and subprocess I/O real while the one parent lookup models Windows 8.3 names. + await symlink(longParent, shortParent, 'junction') + fsControl.legacyRealpathParent = shortParent + try { + expect(realpathSync(shortParent)).toBe(shortParent) + expect(realpathSync.native(shortParent)).toBe(await realpath(longParent)) + const result = await runScenario( + { steps: boot }, + { + agent: AGENT, mode: 'replay', fixtureFile, workspaceParent: shortParent, workspaceCwdTextLength: 192, + prepareWorkspace: async (cwd) => { + expect(cwd).toBe(await realpath(cwd)) + expect(JSON.stringify(cwd).length).toBe(192) + }, + }, + ) + expect(JSON.stringify(result.cwd).length).toBe(192) + expect(await readdir(longParent)).toEqual([]) + await expect(readdir(result.cwd)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + fsControl.legacyRealpathParent = undefined + } + }) + it('promptAndCancel waits for the durable turn start, cancels, and settles the prompt', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel' }) const result = await runScenario( From dd87277afaf8d98ce8c970d3046339c5669c3e72 Mon Sep 17 00:00:00 2001 From: 1tops <60566626+1tops@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:47:56 +0800 Subject: [PATCH 4/9] docs(compaction): clarify summary overflow recovery contracts --- docs/subsystems/compaction.i18n.yaml | 4 ++-- docs/subsystems/compaction.md | 2 ++ docs/subsystems/compaction.zh.md | 2 ++ packages/compaction/compaction-basic/src/region.ts | 3 ++- packages/test-support/session-snapshot/README.i18n.yaml | 4 ++-- packages/test-support/session-snapshot/README.md | 2 +- packages/test-support/session-snapshot/README.zh.md | 2 +- snapshots/acp/acp.snapshot.ts | 1 + 8 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/subsystems/compaction.i18n.yaml b/docs/subsystems/compaction.i18n.yaml index fb62fa8a42..0520ce323c 100644 --- a/docs/subsystems/compaction.i18n.yaml +++ b/docs/subsystems/compaction.i18n.yaml @@ -15,8 +15,8 @@ en: 9dde333b53c14f24 zh: 8959aafefc55f9a4 /compaction/the-service: - en: 6c613669a0a61c16 - zh: e9f23445bc0a5537 + en: 85c4e66d472b4569 + zh: afb47f8bad9efa3a /compaction/tool-result-pruning-outcomes: en: 27091b0a3d454575 zh: e22431e27e1349f2 diff --git a/docs/subsystems/compaction.md b/docs/subsystems/compaction.md index 415c3b06be..54572b585d 100644 --- a/docs/subsystems/compaction.md +++ b/docs/subsystems/compaction.md @@ -100,6 +100,8 @@ type ManualCompactionErrorCode = Pressure compaction runs at `agent/pre-step` against the committed request header and at `agent/request` against a changed destination before its header is logged. Model-switch summaries retain the previous route unless an explicit summary target is configured; a failed switch preserves that header and any completed reductions. Once pressure or canonical overflow qualifies, compaction-basic invokes optional [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.md) before range selection, remeasures through `ctx.tokenMeter`, and can advance the surface without a summary. Failed-request recovery runs through `agent/request-error` after the failed step closes and returns a retry action only when the surface replacement generation advances, even if later summary work throws after pruning; cancellation still wins. Region boundaries preserve tool-call/result pairing but not whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compaction-basic` owns thresholds, retained-tail policy, overflow caps, and failure handling. +When an automatically selected or `/compact` summary receives `LlmError(CONTEXT_WINDOW_EXCEEDED)` and existing recovery listeners decline it, compaction-basic retries a smaller tool-balanced prefix after cancellation and stability checks. Only the successfully summarized prefix is replaced; a single indivisible unit that still overflows fails. Explicit `compactRegion(start, end)` calls retain their exact range. The [package contract](../../packages/compaction/compaction-basic/README.md) defines prefix sizing and retry costs. + The Service Definition exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for the tool-call/result pairing checks before and after a seq. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compaction/compaction/README.md#tool-pairing-boundaries) defines their cache behavior. ## Tool-result pruning outcomes diff --git a/docs/subsystems/compaction.zh.md b/docs/subsystems/compaction.zh.md index 94b9256e05..bf15640564 100644 --- a/docs/subsystems/compaction.zh.md +++ b/docs/subsystems/compaction.zh.md @@ -100,6 +100,8 @@ type ManualCompactionErrorCode = 压力压缩在 `agent/pre-step` 按已提交请求 header 执行,也在 `agent/request` 按已改变的目标执行,先于其 header 记入日志。除非配置了显式摘要目标,模型切换摘要仍使用先前路由;切换失败会保留该 header 与已完成的缩减。一旦压力或规范化溢出满足条件,compaction-basic 会在选择范围前调用可选的 [`ctx.toolResultPruner`](../../packages/compaction/compaction-tool-result-pruner/README.zh.md),再通过 `ctx.tokenMeter` 重新测量,并且可以在不生成摘要的情况下推进 surface。失败请求的恢复在失败的步骤关闭后通过 `agent/request-error` 运行;仅当 surface replacement generation 前进时才返回重试动作,即便后续摘要工作在剪枝后抛异常亦如此;取消仍然优先。区域边界保持工具调用/结果配对,但不保持整个轮次,因此一个过大轮次中较早关闭的步骤可以被压缩。`dsh-compaction-basic` 拥有阈值、保留尾部策略、溢出上限与失败处理。 +自动选区或 `/compact` 的摘要收到 `LlmError(CONTEXT_WINDOW_EXCEEDED)` 且既有恢复监听器未处理时,compaction-basic 在取消与稳定性检查后重试更小的工具配对完整前缀。只替换成功完成摘要的前缀;单个不可拆分单元仍超限时失败。显式 `compactRegion(start, end)` 调用保持精确范围。[包约定](../../packages/compaction/compaction-basic/README.zh.md)定义前缀大小和重试成本。 + 该 Service Definition 导出 `toolPairingBalancedBefore(session, seq)` 与 `toolPairingBalancedAfter(session, seq)`,用于检查 seq 之前与之后的工具调用/结果配对。两者都会验证当前 surface 成员关系,并拒绝缺失的 seq 与遗留结果;[包约定](../../packages/compaction/compaction/README.zh.md#tool-pairing-boundaries)定义其缓存行为。 ## 工具结果剪枝产出 diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index eb4c7174c5..01b05c35e3 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -168,7 +168,8 @@ export function selectCompactableRange( * @param start - inclusive first surface-node seq. * @param end - inclusive last surface-node seq. * @param agent - agent used by the summarizer. - * @param options - bracket owner, stability rule, and optional durability checkpoint. + * @param options - bracket owner, stability rule, self-selected prefix shrink policy, + * optional source-command correlation, and optional durability checkpoint. * @param signal - optional summarization cancellation signal. * @returns the successful durable compaction result. */ diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 5efc7d808e..3d9fb67a69 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/README.i18n.yaml @@ -27,8 +27,8 @@ en: 6b11d5111e1a3222 zh: de4d3889b1a5ebb4 /deepseek-ai-dsh-session-snapshot/use-this-package/platform-and-composition-variants: - en: 6a01d3687551b6b5 - zh: 6715d9aed8a4b9d2 + en: c2e458293560d96d + zh: 882f02e28172f450 /deepseek-ai-dsh-session-snapshot/use-this-package/what-can-go-wrong: en: 0b03592947ca43d6 zh: b14a64238c4b5f80 diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index a9adf44c13..5bff76eb8e 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -90,7 +90,7 @@ A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.j A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings. Headless manifests use `workspace.parent: outside-temp` when the Session workspace grant itself is under test. The adapter allocates beside the platform temp root when its parent is writable and outside system temporary grants, otherwise under home, and rejects any generated cwd already covered by automatic temporary write grants. -ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength: 192`. The harness resolves the parent directory through the native filesystem API before padding the cwd to that JSON-quoted length and retains a unique random suffix; it rejects lengths that cannot accommodate the temporary root before allocating directories. Other scenarios keep their ordinary temporary paths. +ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength` to an exact JSON-quoted cwd length. The harness resolves the parent directory through the native filesystem API before padding the cwd to that length and retains a unique random suffix; it rejects lengths that are not safe integers or cannot accommodate the temporary root and generated basename before allocating directories. Other scenarios keep their ordinary temporary paths. ### What can go wrong diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index aba1440298..14de6d6069 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -90,7 +90,7 @@ Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创 需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。headless manifest 在测试 Session workspace 授权本身时使用 `workspace.parent: outside-temp`。适配器在父目录可写且位于系统临时授权之外时,于平台临时根目录旁分配目录,否则使用 home,并拒绝已被自动临时写授权覆盖的生成 cwd。 -持久记录 runtime-context 文本 token 计价的 ACP 场景可设置 `workspaceCwdTextLength: 192`。harness 先通过原生文件系统 API 解析父目录,再将 cwd 填充至该 JSON 引用字符串长度,并保留唯一随机后缀;若指定长度容不下临时根目录,则在分配目录前拒绝。其他场景保留普通临时路径。 +持久记录 runtime-context 文本 token 计价的 ACP 场景可将 `workspaceCwdTextLength` 设为 cwd 的精确 JSON 引用字符串长度。harness 先通过原生文件系统 API 解析父目录,再将 cwd 填充至该长度,并保留唯一随机后缀;若指定长度不是安全整数,或容不下临时根目录和生成的目录名,则在分配目录前拒绝。其他场景保留普通临时路径。 ### 可能出什么问题 diff --git a/snapshots/acp/acp.snapshot.ts b/snapshots/acp/acp.snapshot.ts index bd61115132..03029f50ab 100644 --- a/snapshots/acp/acp.snapshot.ts +++ b/snapshots/acp/acp.snapshot.ts @@ -46,6 +46,7 @@ const controllerCases: readonly { name: 'summary-overflow', hasModelTurn: true, configPath: join(corpusDir, 'summary-overflow', 'cordis.yml'), + // Pins the runtime-context token price recorded in summary-overflow/session.v4.jsonl. workspaceCwdTextLength: 192, }, ] as const From 595f5a65f3be5224e24a2b64639a190f30083e35 Mon Sep 17 00:00:00 2001 From: 1tops <60566626+1tops@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:27:31 +0800 Subject: [PATCH 5/9] docs(compaction): clarify retry costs and safe integer validation --- packages/compaction/compaction-basic/README.i18n.yaml | 4 ++-- packages/compaction/compaction-basic/README.md | 2 +- packages/compaction/compaction-basic/README.zh.md | 2 +- packages/test-support/session-snapshot/src/harness.ts | 4 ++-- packages/test-support/session-snapshot/tests/harness.spec.ts | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/compaction/compaction-basic/README.i18n.yaml b/packages/compaction/compaction-basic/README.i18n.yaml index ffd19547bb..598df3655b 100644 --- a/packages/compaction/compaction-basic/README.i18n.yaml +++ b/packages/compaction/compaction-basic/README.i18n.yaml @@ -9,8 +9,8 @@ en: 3ba5f9e37ab36328 zh: 2390a55415d4c27b /deepseek-ai-dsh-compaction-basic/summary: - en: 31202d573182cc31 - zh: e0a0c96f67a9c1ba + en: 0facbb90535709b1 + zh: 60b7c46f80bde10c /deepseek-ai-dsh-compaction-basic/table-of-contents: en: d152484eb41ac6b4 zh: bda879a892f240f0 diff --git a/packages/compaction/compaction-basic/README.md b/packages/compaction/compaction-basic/README.md index 40dad00558..5bb81906f4 100644 --- a/packages/compaction/compaction-basic/README.md +++ b/packages/compaction/compaction-basic/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package keeps long agent conversations working near the model's context limit. As token pressure builds, it condenses the oldest history into a summary while preserving recent messages; after a context-overflow error, it condenses and retries. You can also request condensation with `/compact` and optionally trim oversized tool outputs first. Condensation uses one extra model request and retains only its summary text. It cannot reduce the system prompt, tools, or session prefix, or split one indivisible unit such as a single huge tool call. +This package keeps long agent conversations working near the model's context limit. As token pressure builds, it condenses the oldest history into a summary while preserving recent messages; after a context-overflow error, it condenses and retries. You can also request condensation with `/compact` and optionally trim oversized tool outputs first. Each condensation attempt uses an extra model request; only successful summary text is retained. It cannot reduce the system prompt, tools, or session prefix, or split one indivisible unit such as a single huge tool call. ## Table of Contents diff --git a/packages/compaction/compaction-basic/README.zh.md b/packages/compaction/compaction-basic/README.zh.md index 8f5d532102..4760eceeb7 100644 --- a/packages/compaction/compaction-basic/README.zh.md +++ b/packages/compaction/compaction-basic/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包让长时 agent(智能体)会话在接近模型上下文上限时仍能正常工作。token 压力上升时,它会把最旧的历史压缩为摘要并保留近期消息;上下文溢出错误发生后,它会压缩并重试。你也可以通过 `/compact` 按需压缩,并选择先修剪超大工具输出。压缩使用一次额外的模型请求,并且只保留该请求返回的摘要文本。它无法缩减系统提示词、工具或会话前缀,也无法拆分单个不可分单元(例如一次超大工具调用)。 +本包让长时 agent(智能体)会话在接近模型上下文上限时仍能正常工作。token 压力上升时,它会把最旧的历史压缩为摘要并保留近期消息;上下文溢出错误发生后,它会压缩并重试。你也可以通过 `/compact` 按需压缩,并选择先修剪超大工具输出。每次压缩尝试使用一次额外的模型请求;只保留成功返回的摘要文本。它无法缩减系统提示词、工具或会话前缀,也无法拆分单个不可分单元(例如一次超大工具调用)。 ## 目录 diff --git a/packages/test-support/session-snapshot/src/harness.ts b/packages/test-support/session-snapshot/src/harness.ts index 8e1cc03eb7..6ba259ac08 100644 --- a/packages/test-support/session-snapshot/src/harness.ts +++ b/packages/test-support/session-snapshot/src/harness.ts @@ -214,7 +214,7 @@ export interface RunOptions { /** * Exact JSON-quoted cwd length for fixtures that persist path-dependent token prices. * Pads a natively resolved temporary path while retaining mkdtemp's random suffix; - * an insufficient or non-integer length rejects before directory allocation. + * a length below the minimum or not a safe integer rejects before directory allocation. */ workspaceCwdTextLength?: number /** @@ -252,7 +252,7 @@ function workspaceTemporaryPrefix(options: RunOptions): string { // mkdtemp contributes six characters; runtime-context renders the JSON-quoted cwd. const minimum = JSON.stringify(`${prefix}XXXXXX`).length if (!Number.isSafeInteger(length) || length < minimum) { - throw new Error(`snapshot-harness: workspaceCwdTextLength must be an integer of at least ${minimum}`) + throw new Error(`snapshot-harness: workspaceCwdTextLength must be a safe integer of at least ${minimum}`) } return prefix + '-'.repeat(length - minimum) } diff --git a/packages/test-support/session-snapshot/tests/harness.spec.ts b/packages/test-support/session-snapshot/tests/harness.spec.ts index 915cb1864d..8b83579248 100644 --- a/packages/test-support/session-snapshot/tests/harness.spec.ts +++ b/packages/test-support/session-snapshot/tests/harness.spec.ts @@ -725,14 +725,14 @@ describe('runScenario', () => { expect(await readdir(parent)).toEqual(['a-longer-parent-directory']) }) - it.each([1, 192.5])('rejects unusable serialized cwd length %s before allocating a workspace', async (workspaceCwdTextLength) => { + it.each([1, 192.5, 2 ** 53])('rejects unusable serialized cwd length %s before allocating a workspace', async (workspaceCwdTextLength) => { const { fixtureFile } = await scenario({}) const allocate = vi.spyOn(fsPromises, 'mkdtemp') try { await expect(runScenario( { steps: boot }, { agent: AGENT, mode: 'replay', fixtureFile, workspaceCwdTextLength }, - )).rejects.toThrow('workspaceCwdTextLength') + )).rejects.toThrow('workspaceCwdTextLength must be a safe integer of at least') expect(allocate).not.toHaveBeenCalled() } finally { allocate.mockRestore() From 70e678c492bd37e2e0afa94cecdc6d1d286430e6 Mon Sep 17 00:00:00 2001 From: 1tops <60566626+1tops@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:22:41 +0800 Subject: [PATCH 6/9] fix(snapshot): bound padded workspace directory names --- .../session-snapshot/README.i18n.yaml | 4 +- .../test-support/session-snapshot/README.md | 2 +- .../session-snapshot/README.zh.md | 2 +- .../session-snapshot/src/harness.ts | 9 ++-- .../session-snapshot/tests/harness.spec.ts | 52 +++++++++++++++++-- 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 3d9fb67a69..7cf5970321 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/README.i18n.yaml @@ -27,8 +27,8 @@ en: 6b11d5111e1a3222 zh: de4d3889b1a5ebb4 /deepseek-ai-dsh-session-snapshot/use-this-package/platform-and-composition-variants: - en: c2e458293560d96d - zh: 882f02e28172f450 + en: c1ba0e3ae8265924 + zh: b562c8954a92bd66 /deepseek-ai-dsh-session-snapshot/use-this-package/what-can-go-wrong: en: 0b03592947ca43d6 zh: b14a64238c4b5f80 diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 5bff76eb8e..2b7e09314b 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -90,7 +90,7 @@ A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.j A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings. Headless manifests use `workspace.parent: outside-temp` when the Session workspace grant itself is under test. The adapter allocates beside the platform temp root when its parent is writable and outside system temporary grants, otherwise under home, and rejects any generated cwd already covered by automatic temporary write grants. -ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength` to an exact JSON-quoted cwd length. The harness resolves the parent directory through the native filesystem API before padding the cwd to that length and retains a unique random suffix; it rejects lengths that are not safe integers or cannot accommodate the temporary root and generated basename before allocating directories. Other scenarios keep their ordinary temporary paths. +ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength` to an exact JSON-quoted cwd length. The harness resolves the parent directory through the native filesystem API before padding the cwd to that length and retains a unique random suffix. The generated ASCII basename, including its fixed prefix and random suffix, is limited to 255 bytes. Before padding or allocating directories, the harness rejects lengths that are not safe integers, cannot accommodate the temporary root and generated basename, or would exceed that basename limit. Filesystem limits on the full path still apply. Other scenarios keep their ordinary temporary paths. ### What can go wrong diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index 14de6d6069..311b7ef259 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -90,7 +90,7 @@ Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创 需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。headless manifest 在测试 Session workspace 授权本身时使用 `workspace.parent: outside-temp`。适配器在父目录可写且位于系统临时授权之外时,于平台临时根目录旁分配目录,否则使用 home,并拒绝已被自动临时写授权覆盖的生成 cwd。 -持久记录 runtime-context 文本 token 计价的 ACP 场景可将 `workspaceCwdTextLength` 设为 cwd 的精确 JSON 引用字符串长度。harness 先通过原生文件系统 API 解析父目录,再将 cwd 填充至该长度,并保留唯一随机后缀;若指定长度不是安全整数,或容不下临时根目录和生成的目录名,则在分配目录前拒绝。其他场景保留普通临时路径。 +持久记录 runtime-context 文本 token 计价的 ACP 场景可将 `workspaceCwdTextLength` 设为 cwd 的精确 JSON 引用字符串长度。harness 先通过原生文件系统 API 解析父目录,再将 cwd 填充至该长度,并保留唯一随机后缀。生成的 ASCII 目录名(含固定前缀和随机后缀)限制为 255 字节。若指定长度不是安全整数、容不下临时根目录和生成的目录名,或会使目录名超过该上限,harness 会在填充字符串或分配目录前拒绝。文件系统对完整路径的限制仍然适用。其他场景保留普通临时路径。 ### 可能出什么问题 diff --git a/packages/test-support/session-snapshot/src/harness.ts b/packages/test-support/session-snapshot/src/harness.ts index 6ba259ac08..aae4bd3cef 100644 --- a/packages/test-support/session-snapshot/src/harness.ts +++ b/packages/test-support/session-snapshot/src/harness.ts @@ -214,7 +214,8 @@ export interface RunOptions { /** * Exact JSON-quoted cwd length for fixtures that persist path-dependent token prices. * Pads a natively resolved temporary path while retaining mkdtemp's random suffix; - * a length below the minimum or not a safe integer rejects before directory allocation. + * the generated ASCII basename is capped at 255 bytes, including the suffix. + * Lengths outside the resulting safe-integer range reject before padding or allocation. */ workspaceCwdTextLength?: number /** @@ -251,8 +252,10 @@ function workspaceTemporaryPrefix(options: RunOptions): string { const prefix = join(realpathSync.native(parent), 'acp-snap-cwd-') // mkdtemp contributes six characters; runtime-context renders the JSON-quoted cwd. const minimum = JSON.stringify(`${prefix}XXXXXX`).length - if (!Number.isSafeInteger(length) || length < minimum) { - throw new Error(`snapshot-harness: workspaceCwdTextLength must be a safe integer of at least ${minimum}`) + // Cap the generated ASCII component, including mkdtemp's suffix, at 255 bytes. + const maximum = minimum + 255 - basename(prefix).length - 6 + if (!Number.isSafeInteger(length) || length < minimum || length > maximum) { + throw new Error(`snapshot-harness: workspaceCwdTextLength must be a safe integer between ${minimum} and ${maximum}`) } return prefix + '-'.repeat(length - minimum) } diff --git a/packages/test-support/session-snapshot/tests/harness.spec.ts b/packages/test-support/session-snapshot/tests/harness.spec.ts index 8b83579248..5c9184166d 100644 --- a/packages/test-support/session-snapshot/tests/harness.spec.ts +++ b/packages/test-support/session-snapshot/tests/harness.spec.ts @@ -3,7 +3,7 @@ import * as fsPromises from 'node:fs/promises' import { realpathSync } from 'node:fs' import { once } from 'node:events' import { tmpdir } from 'node:os' -import { delimiter, join, relative, sep } from 'node:path' +import { basename, delimiter, join, relative, sep } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterAll, describe, expect, it, vi, type TestContext } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' @@ -725,20 +725,66 @@ describe('runScenario', () => { expect(await readdir(parent)).toEqual(['a-longer-parent-directory']) }) - it.each([1, 192.5, 2 ** 53])('rejects unusable serialized cwd length %s before allocating a workspace', async (workspaceCwdTextLength) => { + it.each([1, 192.5, 2 ** 53, 2 ** 52])('rejects unusable serialized cwd length %s before allocating a workspace', async (workspaceCwdTextLength) => { const { fixtureFile } = await scenario({}) const allocate = vi.spyOn(fsPromises, 'mkdtemp') try { await expect(runScenario( { steps: boot }, { agent: AGENT, mode: 'replay', fixtureFile, workspaceCwdTextLength }, - )).rejects.toThrow('workspaceCwdTextLength must be a safe integer of at least') + )).rejects.toThrow('workspaceCwdTextLength must be a safe integer between') expect(allocate).not.toHaveBeenCalled() } finally { allocate.mockRestore() } }) + it.each([254, 255])('allocates and cleans up a padded cwd with a %s-byte basename', async (nameBytes) => { + const { fixtureFile } = await scenario({}) + const workspaceParent = await mkdtemp(join(tmpdir(), 'acp-snap-name-bound-')) + tempDirs.push(workspaceParent) + const parent = await realpath(workspaceParent) + const workspaceCwdTextLength = JSON.stringify(join(parent, 'a'.repeat(nameBytes))).length + const stop = new Error('workspace inspected') + let allocated = '' + await expect(runScenario( + { steps: boot }, + { + agent: AGENT, mode: 'replay', fixtureFile, workspaceParent, workspaceCwdTextLength, + prepareWorkspace: async (cwd) => { + allocated = cwd + expect(Buffer.byteLength(basename(cwd))).toBe(nameBytes) + expect(JSON.stringify(cwd).length).toBe(workspaceCwdTextLength) + await readdir(cwd) + // Directory-name bounds are independent of subprocess cwd length limits. + throw stop + }, + }, + )).rejects.toBe(stop) + expect(allocated).not.toBe('') + await expect(readdir(allocated)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readdir(parent)).toEqual([]) + }) + + it('rejects a padded cwd one byte beyond the basename limit before allocating a workspace', async () => { + const { fixtureFile } = await scenario({}) + const workspaceParent = await mkdtemp(join(tmpdir(), 'acp-snap-name-overflow-')) + tempDirs.push(workspaceParent) + const parent = await realpath(workspaceParent) + const workspaceCwdTextLength = JSON.stringify(join(parent, 'a'.repeat(256))).length + const allocate = vi.spyOn(fsPromises, 'mkdtemp') + try { + await expect(runScenario( + { steps: boot }, + { agent: AGENT, mode: 'replay', fixtureFile, workspaceParent, workspaceCwdTextLength }, + )).rejects.toThrow('workspaceCwdTextLength must be a safe integer between') + expect(allocate).not.toHaveBeenCalled() + } finally { + allocate.mockRestore() + } + expect(await readdir(parent)).toEqual([]) + }) + it('prices the native long parent path when legacy resolution retains a short alias', async () => { const { fixtureFile } = await scenario({}) const allocatedRoot = await mkdtemp(join(tmpdir(), 'acp-snap-native-parent-')) From a1535572800743f4dca190730de0cf2ab0d8d19e Mon Sep 17 00:00:00 2001 From: 1tops <60566626+1tops@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:01:21 +0800 Subject: [PATCH 7/9] fix(snapshot): allocate long Windows workspace paths natively --- .../session-snapshot/README.i18n.yaml | 4 +- .../test-support/session-snapshot/README.md | 2 +- .../session-snapshot/README.zh.md | 2 +- .../session-snapshot/src/harness.ts | 7 ++- .../session-snapshot/tests/harness.spec.ts | 56 ++++++++++++++++++- 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/packages/test-support/session-snapshot/README.i18n.yaml b/packages/test-support/session-snapshot/README.i18n.yaml index 7cf5970321..9dde47cb85 100644 --- a/packages/test-support/session-snapshot/README.i18n.yaml +++ b/packages/test-support/session-snapshot/README.i18n.yaml @@ -27,8 +27,8 @@ en: 6b11d5111e1a3222 zh: de4d3889b1a5ebb4 /deepseek-ai-dsh-session-snapshot/use-this-package/platform-and-composition-variants: - en: c1ba0e3ae8265924 - zh: b562c8954a92bd66 + en: 5883fbcc1db3e5bf + zh: cf8e589dcd5474c3 /deepseek-ai-dsh-session-snapshot/use-this-package/what-can-go-wrong: en: 0b03592947ca43d6 zh: b14a64238c4b5f80 diff --git a/packages/test-support/session-snapshot/README.md b/packages/test-support/session-snapshot/README.md index 2b7e09314b..09d73f37cc 100644 --- a/packages/test-support/session-snapshot/README.md +++ b/packages/test-support/session-snapshot/README.md @@ -90,7 +90,7 @@ A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.j A scenario requiring a non-Windows host declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere; a scenario whose composition needs a usable `pwsh` declares `pwshOnly`. `workspaceParent` moves the generated child cwd outside the platform temp directory when temporary-directory grants are themselves under test; a scenario's committed `workspace/` is copied into that child first, then `prepareWorkspace` runs against the generated cwd before the agent starts. Default generated workspaces are stored in session fixtures as `{{cwd}}`, so platform temp roots and random basenames do not affect recordings. Headless manifests use `workspace.parent: outside-temp` when the Session workspace grant itself is under test. The adapter allocates beside the platform temp root when its parent is writable and outside system temporary grants, otherwise under home, and rejects any generated cwd already covered by automatic temporary write grants. -ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength` to an exact JSON-quoted cwd length. The harness resolves the parent directory through the native filesystem API before padding the cwd to that length and retains a unique random suffix. The generated ASCII basename, including its fixed prefix and random suffix, is limited to 255 bytes. Before padding or allocating directories, the harness rejects lengths that are not safe integers, cannot accommodate the temporary root and generated basename, or would exceed that basename limit. Filesystem limits on the full path still apply. Other scenarios keep their ordinary temporary paths. +ACP scenarios that persist token prices for runtime-context text can set `workspaceCwdTextLength` to an exact JSON-quoted cwd length. The harness resolves the parent directory through the native filesystem API before padding the cwd to that length and retains a unique random suffix. On Windows, directory allocation uses a namespace-prefixed path; the returned cwd and model requests retain the logical path whose JSON length was priced. The generated ASCII basename, including its fixed prefix and random suffix, is limited to 255 bytes. Before padding or allocating directories, the harness rejects lengths that are not safe integers, cannot accommodate the temporary root and generated basename, or would exceed that basename limit. Filesystem limits on the full path still apply. Other scenarios keep their ordinary temporary paths. ### What can go wrong diff --git a/packages/test-support/session-snapshot/README.zh.md b/packages/test-support/session-snapshot/README.zh.md index 311b7ef259..840dee59d2 100644 --- a/packages/test-support/session-snapshot/README.zh.md +++ b/packages/test-support/session-snapshot/README.zh.md @@ -90,7 +90,7 @@ Headless/ACP 与 SDK 适配器在规范化之前,将原始目录中的子创 需要非 Windows 主机的场景声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件;组合需要可用 `pwsh` 的场景声明 `pwshOnly`。当临时目录授权自身待测时,`workspaceParent` 将生成子级 cwd 移出平台临时区域;场景签入的 `workspace/` 会先复制到该子级,随后 `prepareWorkspace` 在 agent 启动前针对生成 cwd 运行。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录与随机 basename 不影响录制。headless manifest 在测试 Session workspace 授权本身时使用 `workspace.parent: outside-temp`。适配器在父目录可写且位于系统临时授权之外时,于平台临时根目录旁分配目录,否则使用 home,并拒绝已被自动临时写授权覆盖的生成 cwd。 -持久记录 runtime-context 文本 token 计价的 ACP 场景可将 `workspaceCwdTextLength` 设为 cwd 的精确 JSON 引用字符串长度。harness 先通过原生文件系统 API 解析父目录,再将 cwd 填充至该长度,并保留唯一随机后缀。生成的 ASCII 目录名(含固定前缀和随机后缀)限制为 255 字节。若指定长度不是安全整数、容不下临时根目录和生成的目录名,或会使目录名超过该上限,harness 会在填充字符串或分配目录前拒绝。文件系统对完整路径的限制仍然适用。其他场景保留普通临时路径。 +持久记录 runtime-context 文本 token 计价的 ACP 场景可将 `workspaceCwdTextLength` 设为 cwd 的精确 JSON 引用字符串长度。harness 先通过原生文件系统 API 解析父目录,再将 cwd 填充至该长度,并保留唯一随机后缀。在 Windows 上,目录分配使用带命名空间前缀的路径;返回的 cwd 和模型请求仍使用计价时的逻辑路径,保持其 JSON 长度。生成的 ASCII 目录名(含固定前缀和随机后缀)限制为 255 字节。若指定长度不是安全整数、容不下临时根目录和生成的目录名,或会使目录名超过该上限,harness 会在填充字符串或分配目录前拒绝。文件系统对完整路径的限制仍然适用。其他场景保留普通临时路径。 ### 可能出什么问题 diff --git a/packages/test-support/session-snapshot/src/harness.ts b/packages/test-support/session-snapshot/src/harness.ts index aae4bd3cef..71b4dd103a 100644 --- a/packages/test-support/session-snapshot/src/harness.ts +++ b/packages/test-support/session-snapshot/src/harness.ts @@ -20,7 +20,7 @@ import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync, realpathSync } from 'node:fs' import { createHash } from 'node:crypto' import { tmpdir } from 'node:os' -import { basename, dirname, join, delimiter } from 'node:path' +import { basename, dirname, join, delimiter, toNamespacedPath } from 'node:path' import { vi } from 'vitest' import { PROTOCOL_VERSION, @@ -270,7 +270,10 @@ function workspaceTemporaryPrefix(options: RunOptions): string { * @returns The captured stdout/stderr, session id, generated cwd, and harvested logs. */ export async function runScenario(input: InputScript, opts: RunOptions): Promise { - const cwd = await mkdtemp(workspaceTemporaryPrefix(opts)) + const cwdPrefix = workspaceTemporaryPrefix(opts) + // Windows mkdtemp needs an explicit long-path namespace; requests retain the priced logical cwd. + const allocatedCwd = await mkdtemp(toNamespacedPath(cwdPrefix)) + const cwd = join(dirname(cwdPrefix), basename(allocatedCwd)) const cwdAliases = [...new Set([realpathSync(cwd), realpathSync.native(cwd)])] const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-')) let spillRoot: string | undefined diff --git a/packages/test-support/session-snapshot/tests/harness.spec.ts b/packages/test-support/session-snapshot/tests/harness.spec.ts index 5c9184166d..43e9985996 100644 --- a/packages/test-support/session-snapshot/tests/harness.spec.ts +++ b/packages/test-support/session-snapshot/tests/harness.spec.ts @@ -3,7 +3,7 @@ import * as fsPromises from 'node:fs/promises' import { realpathSync } from 'node:fs' import { once } from 'node:events' import { tmpdir } from 'node:os' -import { basename, delimiter, join, relative, sep } from 'node:path' +import { basename, delimiter, dirname, join, relative, sep, toNamespacedPath } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterAll, describe, expect, it, vi, type TestContext } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' @@ -14,8 +14,23 @@ const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined, spillAllocationFailure: undefined as { error: Error; allocated: string[] } | undefined, legacyRealpathParent: undefined as string | undefined, + allocationAlias: undefined as { parent: string; alias: string; allocated: string[] } | undefined, })) +vi.mock('node:path', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toNamespacedPath(path: string): string { + const mapping = fsControl.allocationAlias + const native = mapping !== undefined && actual.dirname(path) === mapping.parent + ? actual.join(mapping.alias, actual.basename(path)) + : path + return actual.toNamespacedPath(native) + }, + } +}) + vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() const realpathSync = Object.assign((...args: Parameters) => { @@ -34,7 +49,12 @@ vi.mock('node:fs/promises', async (importOriginal) => { async mkdtemp(prefix: string): Promise { const failure = fsControl.spillAllocationFailure if (prefix.endsWith('acp-snap-spill-') && failure !== undefined) throw failure.error + const mapping = fsControl.allocationAlias + if (mapping !== undefined && dirname(prefix) === mapping.parent) { + throw Object.assign(new Error('workspace allocation requires its native path'), { code: 'ENOENT' }) + } const path = await actual.mkdtemp(prefix) + if (mapping !== undefined && basename(prefix).startsWith('acp-snap-cwd-')) mapping.allocated.push(path) failure?.allocated.push(path) return path }, @@ -785,6 +805,40 @@ describe('runScenario', () => { expect(await readdir(parent)).toEqual([]) }) + it('allocates through the native path while preserving the priced logical cwd and cleanup', async () => { + const { fixtureFile } = await scenario({}) + const root = await mkdtemp(join(tmpdir(), 'acp-snap-allocation-alias-')) + tempDirs.push(root) + const parent = join(await realpath(root), 'parent') + const alias = join(await realpath(root), 'native-alias') + await mkdir(parent) + await symlink(parent, alias, 'junction') + // A real directory alias models a native allocation spelling without changing the request cwd. + const mapping = { parent, alias, allocated: [] as string[] } + fsControl.allocationAlias = mapping + try { + const result = await runScenario( + { steps: boot }, + { + agent: AGENT, mode: 'replay', fixtureFile, workspaceParent: parent, workspaceCwdTextLength: 192, + prepareWorkspace: async (cwd) => { + expect(dirname(cwd)).toBe(parent) + expect(cwd).toBe(await realpath(cwd)) + expect(JSON.stringify(cwd).length).toBe(192) + }, + }, + ) + expect(mapping.allocated).toEqual([toNamespacedPath(join(alias, basename(result.cwd)))]) + expect(dirname(result.cwd)).toBe(parent) + expect(JSON.stringify(result.cwd).length).toBe(192) + await expect(readdir(result.cwd)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(await readdir(parent)).toEqual([]) + expect(await readdir(alias)).toEqual([]) + } finally { + fsControl.allocationAlias = undefined + } + }) + it('prices the native long parent path when legacy resolution retains a short alias', async () => { const { fixtureFile } = await scenario({}) const allocatedRoot = await mkdtemp(join(tmpdir(), 'acp-snap-native-parent-')) From 8539e4d99c67402095ad3b11adb372b8f3d92b20 Mon Sep 17 00:00:00 2001 From: 1tops <60566626+1tops@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:38:14 +0800 Subject: [PATCH 8/9] fix(build): align Host compiler heap with upstream 8 GiB limit --- package.json | 2 +- scripts/build.spec.ts | 10 ++++++++++ scripts/ci-workflow.spec.ts | 4 ++-- scripts/compile-referenced-projects.ts | 4 ++-- scripts/wine-windows-gates.sh | 2 +- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 5bc1973359..88936c9b3e 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "build:bench": "npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts", "build:official": "tsx scripts/build.ts --profile official", "build:lib": "pnpm run build:lib:host && pnpm run build:lib:client", - "build:lib:host": "tsx scripts/compile-referenced-projects.ts host-libraries && node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -p tsconfig.host.json && pnpm --filter @deepseek-ai/dsh-desktop run bundle", + "build:lib:host": "tsx scripts/compile-referenced-projects.ts host-libraries && node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.host.json && pnpm --filter @deepseek-ai/dsh-desktop run bundle", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --config-loader native --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build", diff --git a/scripts/build.spec.ts b/scripts/build.spec.ts index 157edf8b46..f808f77de6 100644 --- a/scripts/build.spec.ts +++ b/scripts/build.spec.ts @@ -60,6 +60,16 @@ describe('repository build CLI', () => { }) describe('repository build steps', () => { + it.each(['complete', 'check', 'typecheck'] as const)('gives Host compilers an 8 GiB heap in %s scope', (scope) => { + const gates = buildGates(root, scope) + for (const gate of gates.filter(gate => gate.id === 'host-src' || gate.id === 'host-check')) { + expect(gate.args[0]).toBe('--max-old-space-size=8192') + } + for (const gate of gates.filter(gate => gate.id === 'client-src' || gate.id === 'client-check')) { + expect(gate.args.some(arg => arg.startsWith('--max-old-space-size'))).toBe(false) + } + }) + it('keeps artifact steps in serial dependency order', () => { expect(graph(buildGates(root, 'artifacts'))).toEqual(artifactGraph) }) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 637b3432bd..11fa8aeb0b 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -143,7 +143,7 @@ describe('CI workflow', () => { const scripts = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { scripts: Record } const hostSteps = scripts.scripts['build:lib:host']!.split(' && ') expect(hostSteps[0]).toBe('tsx scripts/compile-referenced-projects.ts host-libraries') - expect(hostSteps).toContain('node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -p tsconfig.host.json') + expect(hostSteps).toContain('node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.host.json') expect(hostSteps.at(-1)).toBe('pnpm --filter @deepseek-ai/dsh-desktop run bundle') expect(scripts.scripts['build:lib:client']).toContain('tsc -b tsconfig.client.json') expect(scripts.scripts.typecheck).toBe('tsx scripts/build.ts --scope typecheck') @@ -738,7 +738,7 @@ describe('CI workflow', () => { 'wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host', ) const aggregate = wineGates.indexOf( - 'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false', + 'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=8192 "$tsc_js" -p tsconfig.host.json --pretty false', ) const metadata = wineGates.indexOf('wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts') expect(metadata).toBeGreaterThan(-1) diff --git a/scripts/compile-referenced-projects.ts b/scripts/compile-referenced-projects.ts index fe0751b2d3..f86d500965 100644 --- a/scripts/compile-referenced-projects.ts +++ b/scripts/compile-referenced-projects.ts @@ -48,7 +48,7 @@ export function runPnpmCommand(root: string, args: readonly string[]): void { * @param root - Repository root containing the aggregate tsconfig. * @param face - Independent compiler face whose package projects are emitted. * @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds. - * @returns Arguments for `process.execPath`, including the heap limit the Host face needs. + * @returns Arguments for `process.execPath`, including an 8 GiB old-space heap limit for the Host face. * @throws If the aggregate configuration is invalid, has no references, or can emit files. */ export function referencedProjectsCompilerArgs( @@ -109,7 +109,7 @@ export function aggregateBuildArgs(face: 'host' | 'client'): string[] { } function hostHeapArgs(face: 'host' | 'client'): string[] { - return face === 'host' ? ['--max-old-space-size=4096'] : [] + return face === 'host' ? ['--max-old-space-size=8192'] : [] } /** diff --git a/scripts/wine-windows-gates.sh b/scripts/wine-windows-gates.sh index 3e2c0ef291..edd01072b5 100755 --- a/scripts/wine-windows-gates.sh +++ b/scripts/wine-windows-gates.sh @@ -253,7 +253,7 @@ grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-ga build_gate() { wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts || return $? wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host || return $? - wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false || return $? + wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=8192 "$tsc_js" -p tsconfig.host.json --pretty false || return $? wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $? (cd apps/desktop && wine_node "$scratch/logs/desktop-tsdown.log" "../../$tsdown_js") || return $? wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $? From f242b399059e0e9a4ff58da65a3ecda4ed6c5f90 Mon Sep 17 00:00:00 2001 From: 1tops <60566626+1tops@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:13:05 +0800 Subject: [PATCH 9/9] test(build): clarify artifact dependency ordering --- scripts/build.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build.spec.ts b/scripts/build.spec.ts index f808f77de6..cd99b2c525 100644 --- a/scripts/build.spec.ts +++ b/scripts/build.spec.ts @@ -70,7 +70,7 @@ describe('repository build steps', () => { } }) - it('keeps artifact steps in serial dependency order', () => { + it('finishes Host and Desktop readers before Client compilation rewrites shared outputs', () => { expect(graph(buildGates(root, 'artifacts'))).toEqual(artifactGraph) })