From 8bde91378438e6c4d5b0fefe74f9634edd6b2d42 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:04:54 +0800 Subject: [PATCH] refactor(compaction): replay the derived system head as a message The session already derives the protected system head as a Message, and both adapters accept leading system history. Passing it through a separate SummarizationInput.system string unnecessarily flattens that value and rebuilds the same wire message in the adapter. Prepend the derived head to messages and remove textContent, the separate field, and GenerateOptions.system plumbing from the summarizer. Keep range selection, shadowed seq accounting, session head protection, routed tools, image references, target policy, and the model-visible compaction instruction unchanged. Empty-content heads still derive to null and contribute no request message, but their surface node remains protected. Update subclass consumers/tests, EN/ZH package and subsystem prose, and the existing system-prompt surface owning note with refreshed pairing records. Evidence: pnpm exec vitest run packages/compaction/compaction-basic/tests packages/llm/llm-deepseek/tests/serialize.spec.ts packages/llm/llm-pi-ai/tests/context.spec.ts --coverage --coverage.include='packages/compaction/compaction-basic/src/region.ts' --coverage.include='packages/compaction/compaction-basic/src/summarizer.ts' passed 203 tests in 6 files; both changed sources have 100% statements, branches, functions, and lines. Region-to-default-summarizer cases pin exact prefix and tools for nonempty Unicode/multiline, empty, and absent heads. DeepSeek JSON byte equality and pi-ai context equality pin leading-message vs separate-system equivalence on text and image-capable conversion paths. pnpm run doc-sync passed all 33 gates including doc-typecheck, documentation build, translation pairing and model-experience checks. git diff --check passed. Own dependencies installed with pnpm install --frozen-lockfile. An initial test iteration used a nonexistent ctx.dispose teardown on the in-memory fixture; corrected to its existing fixture lifecycle and reran successfully. No runtime/model behavior, normalizer marker, main worktree, push, or rebase changes. --- ...02-system-prompt-as-surface-node.i18n.yaml | 4 +- ...026-09-02-system-prompt-as-surface-node.md | 6 +-- ...-09-02-system-prompt-as-surface-node.zh.md | 6 +-- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 2 +- docs/subsystems/llm-streaming.zh.md | 2 +- .../compaction-basic/README.i18n.yaml | 4 +- .../compaction/compaction-basic/README.md | 2 +- .../compaction/compaction-basic/README.zh.md | 2 +- .../compaction/compaction-basic/src/region.ts | 15 ++---- .../compaction-basic/src/summarizer.ts | 5 +- .../tests/compaction-basic.spec.ts | 53 +++++++++++++------ .../llm/llm-deepseek/tests/serialize.spec.ts | 6 ++- packages/llm/llm-pi-ai/tests/context.spec.ts | 4 ++ 14 files changed, 66 insertions(+), 49 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml index 3f408a10df..0fcebe73e4 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md -2026-09-02-system-prompt-as-surface-node.md: 635eeeb119214fb0206d6f3d2fea2dcab014f722 -2026-09-02-system-prompt-as-surface-node.zh.md: 253bf6177a8c38b6056e44e93bedf8bdb720c9e0 +2026-09-02-system-prompt-as-surface-node.md: 0c79d557e54375bdada040488e30403c879c1170 +2026-09-02-system-prompt-as-surface-node.zh.md: b14d662cf2b41235d07da431b15d45156fe6c2da diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md index 635eeeb119..0c79d557e5 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md @@ -43,9 +43,9 @@ In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `r | Consumer | Reads | |---|---| -| DeepSeek serializers (`serializeRequest`, `serializeRequestWithImages`) | `options.messages`, passing the `role: 'system'` history message through as wire message 0; `GenerateOptions.system` remains for direct one-shot callers such as the compaction summarizer and title providers | +| DeepSeek serializers (`serializeRequest`, `serializeRequestWithImages`) | `options.messages`, passing the `role: 'system'` history message through as wire message 0; `GenerateOptions.system` remains for direct one-shot callers such as title providers | | `dsh-llm-pi-ai` | a leading system history message maps to pi-ai's `systemPrompt` | -| `compaction-basic` `buildSummarizationInput` | node 0's text as the summarizer `system`, followed by the region messages, so the summarizer request is a genuine prefix of the routed request | +| `compaction-basic` `buildSummarizationInput` | node 0's derived message prepended to the region in `SummarizationInput.messages`, with no separate `system` field; an empty-content head projects to no message while staying protected from compaction | | `compaction-basic` `selectCompactableRange` | anchors at the first non-system node; node 0 is never inside a compaction range | | `dsh-token-meter` | the system node is priced as a surface node under the `systemTokens` breakdown | | Web request-prompt card, trajectory request node, request inspection | the `system/message` node; a replaced node 0 is shown as a prompt change in a collapsed inspectable card, never a chat bubble | @@ -81,5 +81,5 @@ In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `r - `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on first render, the no-op on an unchanged prompt, the replacement of the retained node on change, restoration from the log, and the tail append after a replacement shadowed a non-head system node. - `packages/core/agent-loop/tests/request-reconstruction.spec.ts` (`a system-prompt change replaces surface node 0 and starts a new series under the same header`) pins the `series` header that follows a prompt replacement. - `packages/core/agent-loop/tests/invariant.spec.ts` pins the companion's rejection of a loop request carrying a `system` field and its `messages` equality check against the boundary derivation. -- `packages/llm/llm-deepseek/tests/serialize.spec.ts` (`serializes a leading system message byte-for-byte like the same prompt passed as options.system`) pins wire identity. +- `packages/llm/llm-deepseek/tests/serialize.spec.ts` (`serializes a leading system message byte-for-byte like the same prompt passed as options.system`) pins wire identity. `packages/llm/llm-pi-ai/tests/context.spec.ts` compares both system sources on text and image paths. `packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` pins the derived prefix, routed tools, absent separate `system` option, and protected non-empty or empty head through the region transaction and default summarizer. - The recorded snapshots under `snapshots/` pin the model-visible wire request of every shipped profile; a recorded session that renders a prompt carries the `system/message` event at surface node 0 in its `session.jsonl`, and a session with a mid-session prompt change carries the replacement of node 0. diff --git a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md index 253bf6177a..b14d662cf2 100644 --- a/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md @@ -43,9 +43,9 @@ Status: implemented | 消费方 | 读取内容 | |---|---| -| DeepSeek 序列化器(`serializeRequest`、`serializeRequestWithImages`) | `options.messages`,把 `role: 'system'` 的历史消息作为协议消息 0 透传;`GenerateOptions.system` 为压缩摘要器、标题提供方等直接单次调用方保留 | +| DeepSeek 序列化器(`serializeRequest`、`serializeRequestWithImages`) | `options.messages`,把 `role: 'system'` 的历史消息作为协议消息 0 透传;`GenerateOptions.system` 为标题提供方等直接单次调用方保留 | | `dsh-llm-pi-ai` | 开头的 system 历史消息映射为 pi-ai 的 `systemPrompt` | -| `compaction-basic` 的 `buildSummarizationInput` | 第 0 号节点的文本作为摘要器的 `system`,其后是区域消息,因此摘要器请求是已路由请求的真实前缀 | +| `compaction-basic` 的 `buildSummarizationInput` | 第 0 号节点的派生消息前置于 `SummarizationInput.messages` 中的区域消息,无单独的 `system` 字段;空内容头节点不投影为消息,但仍受保护而不能被压缩 | | `compaction-basic` 的 `selectCompactableRange` | 锚定在首个非系统节点;第 0 号节点永不落入压缩范围 | | `dsh-token-meter` | 系统节点作为 surface 节点计价,归入 `systemTokens` 明细 | | Web 请求提示词卡片、轨迹请求节点、请求检视 | `system/message` 节点;被替换的第 0 号节点以折叠可检视的卡片显示为提示词变更,永不作为聊天气泡 | @@ -81,5 +81,5 @@ Status: implemented - `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住首次渲染时的追加、提示词未变时的无操作、变更时对所保留节点的替换、从日志恢复,以及替换遮蔽了非头部系统节点之后的尾部追加。 - `packages/core/agent-loop/tests/request-reconstruction.spec.ts`(`a system-prompt change replaces surface node 0 and starts a new series under the same header`)钉住提示词替换之后跟随的 `series` header。 - `packages/core/agent-loop/tests/invariant.spec.ts` 钉住伴随组件对携带 `system` 字段的循环请求的拒绝,以及其 `messages` 与边界派生结果的相等性检查。 -- `packages/llm/llm-deepseek/tests/serialize.spec.ts`(`serializes a leading system message byte-for-byte like the same prompt passed as options.system`)钉住协议一致性。 +- `packages/llm/llm-deepseek/tests/serialize.spec.ts`(`serializes a leading system message byte-for-byte like the same prompt passed as options.system`)钉住协议一致性。 `packages/llm/llm-pi-ai/tests/context.spec.ts` 在文本与图片路径上比较两种系统提示词来源。`packages/compaction/compaction-basic/tests/compaction-basic.spec.ts` 通过区域事务与默认摘要器钉住派生前缀、已路由工具、不携带单独 `system` 选项,以及非空或空头节点的保护。 - `snapshots/` 下的录制快照钉住每个随发 profile 的模型可见协议请求;渲染了提示词的录制会话在其 `session.jsonl` 中于 surface 第 0 号节点携带 `system/message` 事件,会话中途发生提示词变更的会话则携带对第 0 号节点的替换。 diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 181c9baf52..9cf98c857e 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 441f1c3ad52e382f3395fe02f2c5f23083a8b31f -llm-streaming.zh.md: beb02ffbd7268843893145080112295e26789d9c +llm-streaming.md: 8248d05e3cb8412970e14fdbdb59284284c9464d +llm-streaming.zh.md: 5d8914390e2f2c22d45c7cff8d3595a4ace582da diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 441f1c3ad5..8248d05e3c 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -701,7 +701,7 @@ The loop builds each request from logged state. `EpochHeader` records call confi `agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus the fields supplied by adapter defaults under the turn signal. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. -On the wire, a loop-built request is the derived history alone: the rendered prompt travels as the leading `system`-role message (surface node 0, a `system/message` event), and the request's `system` field is unset — `GenerateOptions.system` serves direct one-shot callers such as the compaction summarizer and title providers. The logged request ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request and rejects a loop request carrying a `system` field. +On the wire, a loop-built request is the derived history alone: the rendered prompt travels as the leading `system`-role message (surface node 0, a `system/message` event), and the request's `system` field is unset — `GenerateOptions.system` serves direct one-shot callers such as title providers. The logged request ends with the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request and rejects a loop request carrying a `system` field. FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index beb02ffbd7..5d8914390e 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -707,7 +707,7 @@ interface LlmDiscoveredModel { `agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall(瀑布式事件)开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置以及由适配器默认值提供的字段。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。 -在协议中,循环构建的请求只有派生历史:渲染后的提示词作为开头的 `system` 角色消息(surface 第 0 号节点,即一个 `system/message` 事件)传输,请求的 `system` 字段不设置——`GenerateOptions.system` 服务于压缩(compaction)摘要器、标题提供方等直接单次调用方。已记录的请求会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。开发不变式针对每个循环构建的请求精确重算此等式,并拒绝携带 `system` 字段的循环请求。 +在协议中,循环构建的请求只有派生历史:渲染后的提示词作为开头的 `system` 角色消息(surface 第 0 号节点,即一个 `system/message` 事件)传输,请求的 `system` 字段不设置——`GenerateOptions.system` 服务于标题提供方等直接单次调用方。已记录的请求会以最新的 `user/message`(轮次首步)或上一步的工具结果(后续步骤)结尾。开发不变式针对每个循环构建的请求精确重算此等式,并拒绝携带 `system` 字段的循环请求。 FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(`model` 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。 diff --git a/packages/compaction/compaction-basic/README.i18n.yaml b/packages/compaction/compaction-basic/README.i18n.yaml index fd9e6263fe..dd031e4822 100644 --- a/packages/compaction/compaction-basic/README.i18n.yaml +++ b/packages/compaction/compaction-basic/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/compaction/compaction-basic/README.md -README.md: 445cfa0f2c339e8dc32185ac921919eb1ed6e952 -README.zh.md: 393eb07cb73dbaa7174426f37bf9a3a109dbb198 +README.md: 21e3e847e6da54e8c954da9f0f21cfaa15ab7fd6 +README.zh.md: 31cc33af2f37a8623f14e93097d17aa3bb1abcd9 diff --git a/packages/compaction/compaction-basic/README.md b/packages/compaction/compaction-basic/README.md index 445cfa0f2c..21e3e847e6 100644 --- a/packages/compaction/compaction-basic/README.md +++ b/packages/compaction/compaction-basic/README.md @@ -115,7 +115,7 @@ Pressure policy resolves capacity from the adapter that owns the durable route. ### Summarization mechanics -A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the `AgentOptions` pair, without running the loop-only `agent/request` extension point. The call replays the conversation's own system prompt (the text of the `system/message` at surface node 0), the header's tools, and the shadowed-region messages verbatim — including image references, which the selected adapter must resolve or explicitly reject — and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The call sets `GenerateOptions.purpose` to `compaction`; only returned text enters the checkpoint, excluding reasoning and tool calls. Image output fails with `UNSUPPORTED_CONTENT` rather than disappearing. The replacement user message frames the summary with `` tags; the raw summary remains on the `compaction/summary` event. +A direct `ctx.llm.stream()` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the `AgentOptions` pair, without running the loop-only `agent/request` extension point. The call replays the derived `system/message` at surface node 0 as the leading entry of `messages`, followed by the shadowed-region messages, and carries the header's tools verbatim — including image references, which the selected adapter must resolve or explicitly reject — and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. An empty-content system head contributes no message but remains outside the compacted range. The call sets `GenerateOptions.purpose` to `compaction`; only returned text enters the checkpoint, excluding reasoning and tool calls. Image output fails with `UNSUPPORTED_CONTENT` rather than disappearing. The replacement user message frames the summary with `` tags; the raw summary remains on the `compaction/summary` event. ### The region transaction diff --git a/packages/compaction/compaction-basic/README.zh.md b/packages/compaction/compaction-basic/README.zh.md index 393eb07cb7..31cc33af2f 100644 --- a/packages/compaction/compaction-basic/README.zh.md +++ b/packages/compaction/compaction-basic/README.zh.md @@ -115,7 +115,7 @@ kind: "package-reference" ### 摘要机制 -直接 `ctx.llm.stream()` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 `AgentOptions` 对,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用逐字回放会话自身的系统提示词(surface 节点 0 处 `system/message` 的文本)、header 的工具与已遮蔽区域消息——包括所选适配器必须解析或明确拒绝的图片引用——并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。调用将 `GenerateOptions.purpose` 设为 `compaction`;只有返回文本进入检查点,推理与工具调用都会被排除。图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。替换 user 消息用 `` 标签框定摘要;原始摘要保留在 `compaction/summary` 事件上。 +直接 `ctx.llm.stream()` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 `AgentOptions` 对,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用将 surface 节点 0 处派生的 `system/message` 作为 `messages` 的首项回放,后接已遮蔽区域消息,并逐字携带 header 的工具——包括所选适配器必须解析或明确拒绝的图片引用——并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。空内容系统头节点不贡献消息,但仍处于压缩范围之外。调用将 `GenerateOptions.purpose` 设为 `compaction`;只有返回文本进入检查点,推理与工具调用都会被排除。图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。替换 user 消息用 `` 标签框定摘要;原始摘要保留在 `compaction/summary` 事件上。 ### 区域事务 diff --git a/packages/compaction/compaction-basic/src/region.ts b/packages/compaction/compaction-basic/src/region.ts index d34e737fba..ad389a8596 100644 --- a/packages/compaction/compaction-basic/src/region.ts +++ b/packages/compaction/compaction-basic/src/region.ts @@ -17,7 +17,7 @@ import { 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 type { ContentBlock, Message, UserMessage } 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' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -520,7 +520,7 @@ function completeCompaction( * order. The summarizer appends only the compaction instruction after this, so * the call is a genuine prefix of the conversation and reuses the provider's * KV cache. A surface without a system head, or whose head projects to no - * message, yields no `system`. + * message, contributes no leading system message. * @param session - session supplying the surface head, request header, and per-node projection. * @param shadowedSeqs - the surface-node seqs, in order, being compacted. * @returns the replayed conversation prefix to condense. @@ -540,20 +540,11 @@ function buildSummarizationInput( .map(seq => session.deriveEventMessage(session.eventAt(seq)!)) .filter((message): message is Message => message !== null) return { - ...system === null ? {} : { system: textContent(system) }, ...header?.tools === undefined ? {} : { tools: header.tools }, - messages: regionMessages, + messages: system === null ? regionMessages : [system, ...regionMessages], } } -/** Join a message's text blocks into the one string the summarizer's `system` field carries. */ -function textContent(message: Message): string { - return message.content - .filter((block): block is Extract => block.type === 'text') - .map(block => block.text) - .join('\n') -} - /** Inspect open-turn, unmatched-compaction, and latest seed-boundary state independently. */ function inspectCompactionEntryState(session: Session): CompactionEntryState { let openTurn: number | null = null diff --git a/packages/compaction/compaction-basic/src/summarizer.ts b/packages/compaction/compaction-basic/src/summarizer.ts index 3d3fa4c2e1..5fe259a756 100644 --- a/packages/compaction/compaction-basic/src/summarizer.ts +++ b/packages/compaction/compaction-basic/src/summarizer.ts @@ -76,11 +76,9 @@ const CHECKPOINT_PREAMBLE = * compaction instruction is then the only novel input. */ export interface SummarizationInput { - /** Text of the `system/message` at surface node 0, reused for prefix-cache alignment; absent without a system prompt. */ - readonly system?: string /** The conversation's tool schemas, reused for prefix-cache alignment; absent when the request carried none. */ readonly tools?: readonly ToolSchema[] - /** The shadowed region, in surface order, that precedes the compaction instruction. */ + /** The derived system head, when present, followed by the shadowed region in surface order. */ readonly messages: readonly Message[] } @@ -154,7 +152,6 @@ export async function summarizeWithLlm( provider: target.provider, model: target.model, messages, - ...input.system === undefined ? {} : { system: input.system }, ...input.tools === undefined ? {} : { tools: [...input.tools] }, maxTokens: config.maxTokens, sessionId: agent.session.id, diff --git a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts index 9c0ff0d255..bd6a48af66 100644 --- a/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-basic.spec.ts @@ -660,8 +660,8 @@ describe('pressure measurement and retention', () => { role: 'system', content: [{ type: 'text', text: 'SYSTEM HEAD' }], }) - expect(compact.calls[0]!.input.system).toBe('SYSTEM HEAD') - expect(summarizedText(compact.calls[0]!.input)).not.toContain('SYSTEM HEAD') + expect(compact.calls[0]!.input.messages[0]).toEqual(session.deriveMessages()[0]) + expect(compact.calls[0]!.input.messages.filter(message => message.role === 'system')).toHaveLength(1) }) it('declines when only the system head precedes the retained tail', () => { @@ -947,13 +947,11 @@ describe('compaction region transaction', () => { reason: 'resume', }) const nodes = session.surface.nodes + const prefix = session.deriveMessages().slice(0, 3) await compact.compactRegion(nodes[1]!, nodes[2]!, agent(session, MODEL), SIGNAL) const { input } = compact.calls[0]! - expect(input.system).toBe('CONVERSATION SYSTEM') - expect(input.tools).toEqual(tools) - expect(summarizedText(input)).toContain('fixture user 1') - expect(summarizedText(input)).not.toContain('CONVERSATION SYSTEM') + expect(input).toEqual({ messages: prefix, tools }) }) it('omits the summarizer system prompt for an empty system head or a system-less surface', async () => { @@ -962,12 +960,14 @@ describe('compaction region transaction', () => { const emptyNodes = emptyHead.surface.nodes expect(emptyHead.eventAt(emptyNodes[0]!)?.type).toBe('system/message') await compact.compactRegion(emptyNodes[1]!, emptyNodes[2]!, agent(emptyHead, MODEL), SIGNAL) - expect(compact.calls[0]!.input).not.toHaveProperty('system') + expect(compact.calls[0]!.input.messages[0]).toMatchObject({ role: 'user' }) + expect(compact.calls[0]!.input.messages.some(message => message.role === 'system')).toBe(false) + expect(emptyHead.surface.nodes[0]).toBe(emptyNodes[0]) const headless = conversation(3) const nodes = headless.surface.nodes await compact.compactRegion(nodes[0]!, nodes[1]!, agent(headless, MODEL), SIGNAL) - expect(compact.calls[1]!.input).not.toHaveProperty('system') + expect(compact.calls[1]!.input.messages.some(message => message.role === 'system')).toBe(false) expect(compact.calls[1]!.input.messages[0]).toMatchObject({ role: 'user' }) }) @@ -1259,6 +1259,27 @@ async function summarizerHarness( } describe('default one-shot summarizer', () => { + it.each([undefined, '', 'SYSTEM HEAD\n精确前缀\n'])('preserves the routed prefix through region summarization with system %j', async (system) => { + const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) + const session = conversation(3, undefined, system) + const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] + session.append('request/header', { + header: { config: { provider: MODEL, model: MODEL }, tools }, + reason: 'resume', + }) + const nodes = [...session.surface.nodes] + const start = system === undefined ? 0 : 1 + const prefix = session.deriveMessages().slice(0, system ? 3 : 2) + const result = await compact.compactRegion(nodes[start]!, nodes[start + 1]!, agent(session, MODEL), SIGNAL) + + expect(adapter.lastOptions).not.toHaveProperty('system') + expect(adapter.lastOptions?.tools).toEqual(tools) + expect(adapter.lastOptions?.messages.slice(0, -1)).toEqual(prefix) + expect(adapter.lastOptions?.messages.at(-1)).toMatchObject({ role: 'user' }) + expect(result.shadowedSeqs).toEqual(nodes.slice(start, start + 2)) + if (system !== undefined) expect(session.surface.nodes[0]).toBe(nodes[0]) + }) + it('requires complete raw output when a subclass marks one local LLM stream call', () => { expectTypeOf<{ summary: ContentBlock[] @@ -1327,16 +1348,16 @@ describe('default one-shot summarizer', () => { ], source: { kind: 'plugin', plugin: 'test' }, }) + const system = createSystemMessage('REPLAYED SYSTEM', SYSTEM_PROMPT_PLUGIN) await compact.runSummarize({ - system: 'REPLAYED SYSTEM', tools, - messages: [prefix], + messages: [system, prefix], }, agent(conversation(1), MODEL)) - expect(adapter.lastOptions?.system).toBe('REPLAYED SYSTEM') + expect(adapter.lastOptions).not.toHaveProperty('system') expect(adapter.lastOptions?.tools).toEqual(tools) const messages = adapter.lastOptions?.messages ?? [] - expect(messages[0]).toEqual(prefix) + expect(messages.slice(0, -1)).toEqual([system, prefix]) const last = messages.at(-1)?.content[0] const lastText = last?.type === 'text' ? last.text : '' expect(lastText).toContain('Write concise English engineering prose.') @@ -1368,9 +1389,9 @@ describe('default one-shot summarizer', () => { source: { kind: 'plugin', plugin: 'test' }, }) + const system = createSystemMessage('WARM SYSTEM', SYSTEM_PROMPT_PLUGIN) const output = await compact.runSummarize({ - system: 'WARM SYSTEM', - messages: [prefix], + messages: [system, prefix], }, agent(conversation(1), 'fallback')) expect(output).toMatchObject({ @@ -1382,9 +1403,9 @@ describe('default one-shot summarizer', () => { provider: 'policy-summary', model: 'policy-summary', maxTokens: 222, - system: 'WARM SYSTEM', }) - expect(policyAdapter.lastOptions?.messages[0]).toEqual(prefix) + expect(policyAdapter.lastOptions).not.toHaveProperty('system') + expect(policyAdapter.lastOptions?.messages.slice(0, -1)).toEqual([system, prefix]) }) it('resolves the latest routed provider/model before the AgentOptions pair', async () => { diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 2cc2617446..b47072142b 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -249,7 +249,7 @@ describe('serializeRequest', () => { expect(wire.messages[1]).toEqual({ role: 'user', content: 'hi' }) }) - it('serializes a leading system message byte-for-byte like the same prompt passed as options.system', () => { + it('serializes a leading system message byte-for-byte like the same prompt passed as options.system', async () => { const systemMessage = createMessage({ role: 'system', content: [{ type: 'text', text: 'be helpful' }], @@ -260,6 +260,10 @@ describe('serializeRequest', () => { const fromOption = serializeRequest(request({ messages: history, system: 'be helpful', tools })) expect(fromHistory.messages[0]).toEqual({ role: 'system', content: 'be helpful' }) expect(JSON.stringify(fromHistory)).toBe(JSON.stringify(fromOption)) + const images = imageOptions([]) + const imageHistory = await serializeRequestWithImages(request({ messages: [systemMessage, ...history], tools }), images) + const imageOption = await serializeRequestWithImages(request({ messages: history, system: 'be helpful', tools }), images) + expect(JSON.stringify(imageHistory)).toBe(JSON.stringify(imageOption)) }) it('maps sampling params and stop sequences', () => { diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index ee4fa4f531..106db31a2d 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -480,6 +480,10 @@ describe('pi-ai system prompt source', () => { } expect(toPiContext(options)).toEqual(expected) await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) + const fromOption: GenerateOptions = { ...base, system: 'lead rule', messages: [question] } + expect(toPiContext(options)).toEqual(toPiContext(fromOption)) + expect(await toPiContext(options, imageContext(attachments))) + .toEqual(await toPiContext(fromOption, imageContext(attachments))) }) it('sends no systemPrompt for an empty leading system message on both conversion paths', async () => {