refactor(llm): flatten tool results and validate native V4 sessions

Represent tool results as tool-role messages and migrate released wrappers
without losing outer own JSON fields, call identity, error state, or writer
field order. Refuse nested or wrapper-only information that has no lossless
current representation before successor publication.

Validate current event relationships directly instead of projecting V4 data
back into V3. Keep lifecycle, PTC, title/command, compaction, system-field and
retired-syntax checks on catalog restoration and ordinary JSONL opens. The
Worker JSON equality helper must inspect own keys so it cannot admit PTC
arguments that Node rejects.

Keep the source role change, required native admission and consumer fixture
updates in one complete review layer. Explain shared-type changes in the
persistence record and the native reader rationale in its existing note.
Use checked branded fixture identities and discriminant narrowing instead
of unknown double casts; fixture values and assertions remain intact.

This recomposes the original tool-role work by _Kerman with the independently
validated migration and admission repairs by Tianyi Cui. Frozen generations
and snapshot input version selection are retained.

Co-authored-by: Tianyi Cui <[email protected]>
This commit is contained in:
_KermanandTianyi Cui authored and Tianyi Cui committed 2026-09-18 19:05:32 +08:00
1 parent c6e62ff971
commit f4a32dbd0a
257 files changed
+34289 -26825

No files matched your search

@@ -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-06-11-content-block-vocabulary.md
2026-06-11-content-block-vocabulary.md: d7d3f6b43a3f65d1421f026e5b6c2cc1ba1eadd2
2026-06-11-content-block-vocabulary.zh.md: ed4f915dff6dcb6dbc91400f9bfa5384253aea7b
2026-06-11-content-block-vocabulary.md: 917dd5225fdc4ab391e9e4629769abbb68095731
2026-06-11-content-block-vocabulary.zh.md: 9d228eb34dafbdebab44b45dc3b290c9327d3b2b
@@ -10,7 +10,7 @@ The harness needs one internal language for messages that the loop, session log,
## Decision
Own the vocabulary: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
Own the vocabulary: user, assistant, and system messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. Tool results are first-class tool-role messages whose own content holds result blocks; released Session format v3 retains the historical `tool-result` content block inside a user-role wrapper, and the adjacent V3-to-V4 edge lifts that wrapper into the first-class role. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs.
In-session context injection (`context/message`) and mid-turn steering originally rendered as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Both now project as plain user content with no wrapper; see [the injected-content-envelope Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md). Live-adapter validation confirms this rendering for current DeepSeek behavior; a future provider-specific mismatch belongs in that adapter rather than a new canonical role.
@@ -10,7 +10,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循
## 决策
自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。
自主拥有词汇:user、assistant 与 system 消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。Tool result 是一等 tool-role message,结果块属于该 message 自己的 content;已发布的 Session format v3 仍将历史 `tool-result` content block 保存在 user-role 包装中,邻接的 V3 到 V4 边会将该包装提升为一等角色。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。
会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。
@@ -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-07-28-identified-immutable-message-values.md
2026-07-28-identified-immutable-message-values.md: 9d2b22a5a1fc87ea7b0ff8eb24b2e8c4b86543e1
2026-07-28-identified-immutable-message-values.zh.md: 6b36cfec0a24196ca526b0ffb21180967aa640eb
2026-07-28-identified-immutable-message-values.md: 73c9c14479117cf654d10b3da23d155cca77054d
2026-07-28-identified-immutable-message-values.zh.md: 7a5b9d6524beff01ac8a9e2020f75093271b7ab3
@@ -16,7 +16,7 @@ This made identity a routing side effect rather than a message invariant. Produc
`createMessage(input)` is the canonical role-generic creation boundary. It mints a `MessageId`, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. `createUserMessage({ content, source })` fixes the user role for prompt and context producers. `createAssistantMessage({ content, source })` fixes both the assistant role and the model source kind, so model-output producers supply only content plus provider, model, and optional replay state. All creation helpers exclude an input id so callers cannot accidentally present creation as import. `freezeMessage(message)` is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement.
The message helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. They use `dsh-brand`'s stateless `brandString()` constructor for `MessageId` and `dsh-util-values`'s shared `deepFreeze()` implementation after detaching input with `structuredClone()`. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
The message helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. They use `dsh-brand`'s stateless `brandString()` constructor for `MessageId` and `dsh-util-values`'s shared `deepFreeze()` implementation after detaching input with `structuredClone()`. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to a tool-role message carrying the result content, error state, and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Inbox claims and `agent/pre-step` receive that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id.
@@ -16,7 +16,7 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则
`createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将传入的角色、内容和来源与调用方对象解除引用关系,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容,以及提供方、模型和可选的回放状态。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把新消息的创建伪装成已有消息的导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与调用方对象解除引用关系并深度冻结,不会生成替代标识。
消息辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整约定只依赖该词汇。它们使用 `dsh-brand` 的无状态 `brandString()` 构造函数生成 `MessageId`,并在通过 `structuredClone()` 分离输入后使用 `dsh-util-values` 的共享 `deepFreeze()` 实现。`createToolResultMessage()` 与其他创建辅助函数同属此处:它将工具调用 id 与确切的 user-role 工具结果块及来源耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
消息辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整约定只依赖该词汇。它们使用 `dsh-brand` 的无状态 `brandString()` 构造函数生成 `MessageId`,并在通过 `structuredClone()` 分离输入后使用 `dsh-util-values` 的共享 `deepFreeze()` 实现。`createToolResultMessage()` 与其他创建辅助函数同属此处:它将工具调用 id 与携带结果内容、错误状态及来源的 tool-role 消息耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。inbox 领取和 `agent/pre-step` 会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。
@@ -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-06-v3-canonical-session-envelopes.md
2026-09-06-v3-canonical-session-envelopes.md: 17ec331cc1ea21f31d0b51eab86d22725b6deef8
2026-09-06-v3-canonical-session-envelopes.zh.md: a18bb3b4d71c164e3fba7288cc686a1cb7c7b4df
2026-09-06-v3-canonical-session-envelopes.md: 68d4386b9e06025fd44d6d9054a95fb51ec8bc3e
2026-09-06-v3-canonical-session-envelopes.zh.md: d3bfc17fa263f8edb0b6f73ce59c626d772dbc1e
@@ -14,7 +14,7 @@ Session format V3 uses one canonical event envelope. Every `system/message`, `us
`SurfaceOp` is exactly `'append'` or `{ op: 'replace', startSeq, endSeq }`, with `SessionSeq` endpoints and no aliases or extra keys. Both endpoints precede the replacing event and identify an inclusive span in current surface order, not numeric sequence order. Session acceptance additionally verifies current membership, ordered endpoints, complete cited coverage, and content-only single-node tool-result replacement. Compaction payload fields such as `shadowedRange.start/end` and fold-result fields retain their own names; this is not a recursive payload rename.
Current acceptance rejects every `request/header.header.system` and exactly empty `tools: []` or `adapterDefaults: {}`. System prompts belong to `system/message`; `request/header` remains the non-history request snapshot. Writers omit the two empty optionals. Whitespace-only system content, `config.stop: []`, nested header/source/data extras, and nested tool schema values remain intact. A `tool/result` with `data.error` requires `message.content[0].isError === true`; a failed result need not carry error identity. Neither current reads nor migration infer an error outcome from contradictory metadata.
Current acceptance rejects every `request/header.header.system` and exactly empty `tools: []` or `adapterDefaults: {}`. System prompts belong to `system/message`; `request/header` remains the non-history request snapshot. Writers omit the two empty optionals. Whitespace-only system content, `config.stop: []`, nested header/source/data extras, and nested tool schema values remain intact. A `tool/result` with `data.error` requires `message.isError === true`; a failed result need not carry error identity. Neither current reads nor migration infer an error outcome from contradictory metadata.
### Validation ownership
@@ -14,7 +14,7 @@ Session 格式 V3 使用一种规范事件信封。每个 `system/message`、`us
`SurfaceOp` 恰好为 `'append'` 或 `{ op: 'replace', startSeq, endSeq }`,端点使用 `SessionSeq`,不接受别名或额外键。两个端点都早于替换事件,并按当前 surface 顺序而非数值序号顺序标识闭区间。Session 接纳还验证当前成员关系、端点顺序、完整引用覆盖与仅修改内容的单节点工具结果替换。`shadowedRange.start/end` 等压缩(compaction)载荷字段与折叠结果字段保留各自名称;这不是对载荷进行递归重命名。
当前接纳拒绝任何 `request/header.header.system` 以及恰好为空的 `tools: []` 或 `adapterDefaults: {}`。系统提示词属于 `system/message`;`request/header` 仍是请求非历史状态的快照。写入方省略两个空可选字段。仅含空白的系统内容、`config.stop: []`、嵌套 header/source/data 扩展与嵌套工具 schema 值保持原样。带有 `data.error` 的 `tool/result` 要求 `message.content[0].isError === true`;失败结果不必携带错误身份。当前读取与迁移均不会根据矛盾元数据推断错误结果。
当前接纳拒绝任何 `request/header.header.system` 以及恰好为空的 `tools: []` 或 `adapterDefaults: {}`。系统提示词属于 `system/message`;`request/header` 仍是请求非历史状态的快照。写入方省略两个空可选字段。仅含空白的系统内容、`config.stop: []`、嵌套 header/source/data 扩展与嵌套工具 schema 值保持原样。带有 `data.error` 的 `tool/result` 要求 `message.isError === true`;失败结果不必携带错误身份。当前读取与迁移均不会根据矛盾元数据推断错误结果。
### 校验所有权
@@ -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-10-image-offload-events.md
2026-09-10-image-offload-events.md: f540dc98cee8daf3e360b14234a2ac21d0f53aab
2026-09-10-image-offload-events.zh.md: 868fca59ba5fc1941de752127f61b091aedb234f
2026-09-10-image-offload-events.md: e80c4ae17b148d9eb46517b92111a8d768160def
2026-09-10-image-offload-events.zh.md: 2aa2b8b1fbffd117b5d716aba28755a02a155bb8
@@ -14,7 +14,7 @@ A request can exceed an image-capable route's byte or image-count budget. Recomp
Summary requests bypass the agent error waterfall. `dsh-compaction-basic` preserves the full `LlmFailure`, checks cancellation and selection stability, and dispatches synchronous `compaction/summary-error` with the selected event seqs. The same image-offload plugin records omissions only within that selection. Synchronous recovery keeps the stability check and decision adjacent. The backend then re-derives input and pricing, including the shrink baseline. Each retry requires additional omitted occurrences; exhaustion delegates the failure. A later summary failure or cancellation preserves any recorded omissions, so command errors do not claim the conversation is unchanged.
The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including nested tool results and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set.
The event payload is `{ targets: [{ seq, imageIndexes }] }`. Each target identifies a current `user/message` or `tool/result` node. Its strictly increasing, zero-based indexes enumerate every image in depth-first content order, including tool-role result content and previously omitted images. Equal attachment ids remain distinct occurrences. Exact selections remain unambiguous after positional replacements reorder the surface; an attachment id or a raw-log prefix cannot identify the selected set.
The event has no `surfaceOp`. It neither creates a message nor replaces a message node. The plugin's pure projection validates all targets before Session commits the event and rejects missing or shadowed nodes, output images, duplicate targets, invalid indexes, and already omitted occurrences. Reconstruction derives immutable message copies with `ImageBlock.offloaded: true` at the recorded positions. Original events, message ids, sources, and unaffected blocks remain unchanged. Resume, fork, and replay apply the same selections from the log.
@@ -14,7 +14,7 @@ Status: implemented
摘要请求不经过 agent 错误 waterfall。`dsh-compaction-basic` 保留完整的 `LlmFailure`,检查取消和选区稳定性,再携带所选事件序号触发同步的 `compaction/summary-error`。同一个图片省略插件只在选区内记录省略。同步恢复使稳定性检查与决策相邻执行。后端随后重新派生输入并计价,包括判断摘要是否缩短输入的基准。每次重试都必须继续省略图片,无图可省略时委派失败。后续摘要失败或取消仍保留已经记录的省略,因此命令错误不声称会话未变。
事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括嵌套工具结果和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。
事件载荷为 `{ targets: [{ seq, imageIndexes }] }`。每个目标指向当前的 `user/message` 或 `tool/result` 节点。索引从零开始且严格递增,按内容深度优先顺序枚举所有图片,包括 tool-role 结果内容和先前省略的图片。相同附件 ID 的多次出现分别计数。位置替换改变 surface 顺序后,明确选择仍没有歧义,附件 ID 或原始日志前缀无法标识这个集合。
事件不带 `surfaceOp`,不创建消息,也不替换消息节点。插件的纯投影在 Session 提交事件前校验全部目标,拒绝缺失或已被遮蔽的节点、输出图片、重复目标、无效索引和已被省略的位置。重建过程派生不可变消息副本,将记录的位置标为 `ImageBlock.offloaded: true`。原始事件、消息 ID、来源和未受影响的内容块保持不变。恢复、分叉和回放从日志应用相同的选择。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-15-first-class-tool-role-messages.md
2026-09-15-first-class-tool-role-messages.md: c16bc44116b1b2c20c988db4b6977ee31bea4ab4
2026-09-15-first-class-tool-role-messages.zh.md: f9eb581f6c080655145b3c9ab1c55d052fe871b8
@@ -0,0 +1,28 @@
# Agent Note: First-class tool-role messages
Status: implemented
English | [中文](2026-09-15-first-class-tool-role-messages.zh.md)
## Problem
The released Session format stores tool results inside a user-role `tool-result` content block, while the model conversation and provider protocols treat a tool result as a separate role. Keeping both descriptions active makes validation, replay, and adapter projections disagree.
## Decision
Session format V4 stores one tool result as a first-class `role: 'tool'` message. Its `toolCallId`, tool source call id, result content, and optional error flag are validated together. The V3-to-V4 edge lifts exactly one released wrapper and refuses nested wrappers before publication, retaining the source generation. Current V4 decoding is native-only; wrapper rows are accepted only by the adjacent migration edge.
Outer message JSON extensions retain their own properties during lifting, including `__proto__` and `constructor`. An existing `toolCallId` must match the source call; an existing boolean `isError` must match the wrapper's error state, with omission meaning non-error. Unknown wrapper fields and conflicting outer fields refuse migration because the new representation provides no safe interpretation for them. Rebuilding only known message fields would silently delete admitted V3 data; copying wrapper extensions onto the message would invent their scope.
The conversation role map is closed. A model-visible role must have a persisted Session event and an adapter projection, so adapters do not silently turn an unlogged extension role into user text. Provider-specific source kinds remain merge-extensible and are owned by their producers.
## Alternatives considered
- **Keep wrapper-shaped V4 rows** — this preserves the released payload but leaves the canonical role and provider wire formats inconsistent.
- **Accept both wrapper and native V4 rows** — dual current representations make encoding, decoding, recovery, and replay depend on which path produced the artifact.
- **Flatten historical nested results** — leaf text does not retain nested call IDs or error status, and providers did not interpret nested content uniformly. Unsupported migration preserves the original data until a lossless conversion is available.
- **Keep role extensions open** — an extension could reach a model request without a Session event and could not be reconstructed on replay.
## Consequences
The adjacent migration changes the logical message role without changing message identity, tool-call identity, or result content. Native V4 semantic failures are hard admission failures; recoverable decoding still applies to released V3 canonical tails. Adapters map the first-class role to their provider-specific tool-result representation. The duplicated message `toolCallId` and source `callId` remain as an explicit equality-checked provider field and source-identity field pair.
@@ -0,0 +1,28 @@
# Agent Note:一等 tool-role 消息
Status: implemented
[English](2026-09-15-first-class-tool-role-messages.md) | 中文
## 问题
已发布的 Session 格式把工具结果存储在 user-role 的 `tool-result` 内容块中,而模型对话与提供方协议把工具结果视为独立角色。两种描述同时存在会使校验、回放和适配器投影互相不一致。
## 决策
Session format V4 将一次工具结果存储为一等 `role: 'tool'` 消息。它的 `toolCallId`、工具来源调用标识、结果内容和可选错误标记一起校验。V3 到 V4 的边缘迁移只提升一个已发布的包装,并在发布前拒绝嵌套包装,保留源代际。当前 V4 解码仅接受原生形式;包装行只由相邻迁移边缘接受。
外层消息的 JSON 扩展在提升时保留为自有属性,包括 `__proto__` 和 `constructor`。已有 `toolCallId` 必须与 source call 一致;已有布尔 `isError` 必须与 wrapper 的错误状态一致,省略表示非错误。未知 wrapper 字段和冲突的外层字段会拒绝迁移,因为新表示没有安全的解释方式。只重建已知消息字段会静默删除 V3 已接受的数据;将 wrapper 扩展复制到消息上则会凭空改变其作用范围。
对话角色 map 是封闭的。模型可见角色必须有持久 Session 事件和适配器投影,因此适配器不会把未记录的扩展角色静默转换为 user 文本。提供方特有的来源 kind 仍然可合并扩展,并由各生产方拥有。
## 曾考虑的替代方案
- **保留包装形式的 V4 行**——保留已发布载荷,但规范角色与提供方 wire 格式仍不一致。
- **同时接受包装和原生 V4 行**——两种当前表示会让编码、解码、恢复和回放依赖于产出 artifact 的路径。
- **展平历史嵌套结果**——叶子文本无法保留嵌套调用 ID 或错误状态,而且提供方对嵌套内容的解释并不一致。拒绝不支持的迁移会保留原始数据,等待能够保留全部信息的转换。
- **保持角色扩展开放**——扩展角色可能在没有 Session 事件的情况下到达模型请求,也无法在回放时重建。
## 后果
相邻迁移会改变逻辑消息角色,但不改变消息身份、工具调用身份或结果内容。原生 V4 语义失败属于硬准入失败;可恢复解码仍适用于已发布 V3 的规范尾部。适配器把一等角色映射为各提供方自己的工具结果表示。消息 `toolCallId` 与来源 `callId` 的重复仍作为一对显式且经过相等性校验的提供方字段与来源字段保留。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-17-native-v4-read-validation.md
2026-09-17-native-v4-read-validation.md: ab4adc44cda551af91e1690397dd5c0c8a361b9f
2026-09-17-native-v4-read-validation.zh.md: 815bf4de979ec0f5f2d86ee9806c8dec4d4d2f61
@@ -0,0 +1,29 @@
# Agent Note: Mandatory native V4 relationship validation
Status: implemented
English | [中文](2026-09-17-native-v4-read-validation.zh.md)
## Problem
Tool-role results cannot be checked through the released V3 user-role representation without projecting away current fields. Replacing that validation view requires a native reader to retain the earlier lifecycle, retired-syntax, and system-message checks. The representation change and its mandatory admission therefore belong together.
An event can satisfy its TypeScript declaration and message rules while contradicting earlier durable events. A tool result without an advertised call and a checkpoint naming another compaction both contain locally valid fields. Accepting either as a current Session makes later readers depend on damaged relationships, and tightening admission after release can strand already-written histories.
## Decision
The V4 format restorer and current JSONL scanner share one mandatory cross-event relationship pass before exposing a restored artifact or storage handle. It checks turn and step order, advertised tool calls and settlements, PTC parentage and settlement identity, retry chains and request providers, human-message title citations, command completion references, compaction ownership and current spans, and the protected system head. The installed event set controls semantic interpretation, including compaction prepasses and title references. Unknown ignorable records retain their payloads and original positions; known records remain validated even when marked ignorable. The check reads native event fields without altering artifacts or projecting them into a historical format. JSON argument comparisons inspect own properties in both Node and the worker; inherited prototype members cannot satisfy recorded fields. The [tool-role decision](2026-09-15-first-class-tool-role-messages.md) owns result representation.
An unfinished tail may retain an open turn, step, tool call, or compaction. A closing event must settle the relationships it closes. An inherited unfinished compaction ends at its `session/end-seed` marker; it does not constrain the child's lifecycle. Repair message identities retain their historical numeric suffix after earlier migrations change sequence coordinates. Fork repairs retain their separately validated native identities.
Installed Session adoption continues to own event envelopes, message roles and metadata, canonical request headers, and surface replacement reference coverage. Optional runtime invariants provide diagnostics; their installation is not required for durable admission. Native system-message admission also validates coordinates, identity, content blocks, and known image/tool-call fields before physical recovery can discard rows. Additional JSON fields and unknown nonempty content tags remain intact; retired tool-result wrappers are refused. Retired `request/header.header.system` and required `tool/code-dispatch*` tags remain hard refusals before recovery; obsolete ignorable PTC records remain opaque. Physical framing alone does not establish semantic validity.
## Alternatives considered
**Project V4 back into a frozen V3 validation view.** This couples current acceptance to retired representations and invites new fields to disappear in the projection. Frozen generations remain independently readable, while the current restorer interprets its own fields.
**Rely on optional runtime invariants.** Detached readers and restoration paths may not install diagnostic companions. A required relationship must be checked before publication or consumption of the restored Session.
## Consequences
The native restorer maintains one bounded state machine per read and rejects contradictions before successor publication. Frozen V0–V3 validators and committed Session generations remain unchanged. Public catalog regressions cover invalid relationships alongside partial tails, inherited cuts, immutable unknown attribution, and complete tool exchanges; these checks do not require a new Session version because V4 is unreleased.
@@ -0,0 +1,29 @@
# Agent Note: 原生 V4 关系的强制校验
Status: implemented
[English](2026-09-17-native-v4-read-validation.md) | 中文
## Problem
tool 角色结果不能通过已发布的 V3 user 角色表示进行校验,否则投影会丢弃当前字段。替代该校验视图后,原生读取方必须保留原有的生命周期、退役语法和 system 消息检查。因此,表示变更与必需的接纳校验需要一同完成。
事件即使符合 TypeScript 声明和消息规则,也可能与较早的持久化事件矛盾。没有声明调用的工具结果、指向另一压缩事务的检查点,都可以拥有局部有效字段。将它们接纳为当前 Session 会让后续读取器依赖损坏的关系;发布后再收紧准入则可能让已写入的历史无法读取。
## Decision
V4 格式恢复器与当前 JSONL 扫描器共用同一强制跨事件关系校验,在公开恢复产物或存储句柄之前执行。它检查 turn 与 step 顺序、声明的工具调用及其结算、PTC 父子关系与结算身份、重试链与请求 provider、标题的人类消息引用、命令完成引用、压缩归属与当前区间,以及受保护的 system 首节点。已安装事件集合控制语义解释,包括压缩预扫描和标题引用。未知的可忽略记录保留其载荷与原始位置;已知记录即使标记为可忽略仍会接受校验。校验直接读取原生事件字段,不修改产物,也不将其投影为历史格式。Node 和 Worker 中的 JSON 参数比较都检查自有属性,继承的原型成员不能代替记录字段。[工具角色决策](2026-09-15-first-class-tool-role-messages.zh.md)负责结果表示。
未完成的尾部可以保留打开的 turn、step、工具调用或压缩事务。关闭事件必须结清它关闭的关系。继承的未完成压缩事务在其 `session/end-seed` 标记处结束,不约束子 Session 的生命周期。较早迁移改变序号坐标后,修复消息身份仍保留历史数字后缀。fork 修复保留经过独立校验的原生身份。
已安装的 Session 接纳器继续负责事件封装、消息角色与元数据、规范请求头,以及 surface 替换的引用覆盖。可选运行时不变式提供诊断;持久化准入不要求安装它们。原生 system 消息准入还会在物理恢复可能丢弃行之前,校验坐标、身份、内容块及已知 image/tool-call 字段。额外 JSON 字段和非空的未知内容标签保持不变,退役的 tool-result 包装会被拒绝。退役的 `request/header.header.system` 和必需的 `tool/code-dispatch*` 标签仍在恢复前强制拒绝;标记为可忽略的过时 PTC 记录保持不透明。仅通过物理分帧不能证明语义有效。
## Alternatives considered
**将 V4 投影回冻结的 V3 校验视图。** 这会将当前准入与退役表示绑定,并容易让新增字段在投影中消失。冻结代际仍可独立读取,当前恢复器解释自己的字段。
**依赖可选运行时不变式。** 分离读取器和恢复路径可能不安装诊断组件。必需关系必须在发布或消费恢复后的 Session 之前完成校验。
## Consequences
原生恢复器为每次读取维护独立状态机,在发布后继文件之前拒绝矛盾。冻结的 V0–V3 校验器及已提交的 Session 代际保持不变。公开目录回归覆盖无效关系、部分尾部、继承切分点、未知归属的不可变保存,以及完整工具交互;这些检查无需新增 Session 版本,因为 V4 尚未发布。
@@ -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/simplification/2026-09-07-file-content-scan.md
2026-09-07-file-content-scan.md: bf4f03ee5a7134b11c7cbec322b0a37a3f2a6304
2026-09-07-file-content-scan.zh.md: 5efb1589b036f80b46752a80103c080952c82396
2026-09-07-file-content-scan.md: 1ec46e7d685394f5165624f1e7b68622ba9f89d3
2026-09-07-file-content-scan.zh.md: b4ebd835ca8adaf3a7b7fa09150cd5a2bec9f401
@@ -6,11 +6,11 @@ English | [中文](2026-09-07-file-content-scan.zh.md)
## Problem
Every model dispatch checks complete message content for files, including nested tool results. A request-history CPU profile attributes 23.540 ms of self time to `contentHasFile` and 5.584 ms to its callback. This traversal remains necessary even after [loop-owned freeze evidence](2026-09-06-agent-request-freeze-evidence.md) removes repeated request freezing. The hot LLM source is identical at both sampled master revisions and the measured V3 integration revision; these observations do not establish PR causality.
Every model dispatch checks complete message content for files, including tool-role result content. A request-history CPU profile attributes 23.540 ms of self time to `contentHasFile` and 5.584 ms to its callback. This traversal remains necessary even after [loop-owned freeze evidence](2026-09-06-agent-request-freeze-evidence.md) removes repeated request freezing. The hot LLM source is identical at both sampled master revisions and the measured V3 integration revision; these observations do not establish PR causality.
## Decision
[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) uses direct iteration instead of recursive `Array.some` callbacks. It preserves early exit, nested tool-result traversal, and false results for other block kinds. It stores no identities, validation results, or freeze proofs. Image detection, file projection, and request construction keep their existing behavior. The [request-freeze evidence](2026-09-06-agent-request-freeze-evidence.md) owns the request-history budget.
[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) uses direct iteration instead of recursive `Array.some` callbacks. It preserves early exit, tool-role result traversal, and false results for other block kinds. It stores no identities, validation results, or freeze proofs. Image detection, file projection, and request construction keep their existing behavior. The [request-freeze evidence](2026-09-06-agent-request-freeze-evidence.md) owns the request-history budget.
## Measurement evidence
@@ -6,11 +6,11 @@ Status: implemented
## Problem
每次模型分发都检查完整消息内容中的文件,包括嵌套工具结果。请求历史 CPU profile 将 23.540 ms 自身时间归于 `contentHasFile`,将 5.584 ms 归于其回调。即使[循环自有冻结证据](2026-09-06-agent-request-freeze-evidence.zh.md)消除了重复请求冻结,这次遍历仍然必需。两次抽样的 master 修订与实测的 V3 集成修订 的 LLM 热点源码完全相同;这些观察不能证明 PR 因果关系。
每次模型分发都检查完整消息内容中的文件,包括 tool-role 结果内容。请求历史 CPU profile 将 23.540 ms 自身时间归于 `contentHasFile`,将 5.584 ms 归于其回调。即使[循环自有冻结证据](2026-09-06-agent-request-freeze-evidence.zh.md)消除了重复请求冻结,这次遍历仍然必需。两次抽样的 master 修订与实测的 V3 集成修订 的 LLM 热点源码完全相同;这些观察不能证明 PR 因果关系。
## Decision
[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) 使用直接迭代,替代递归的 `Array.some` 回调。它保留提前退出、嵌套工具结果遍历,以及其他块类型返回 false 的行为。它不存储身份、校验结果或冻结证明。图片检测、文件投影和请求构建保持既有行为。[请求冻结证据](2026-09-06-agent-request-freeze-evidence.zh.md)拥有请求历史预算。
[`contentHasFile`](../../../../packages/llm/llm/src/content.ts) 使用直接迭代,替代递归的 `Array.some` 回调。它保留提前退出、tool-role 结果遍历,以及其他块类型返回 false 的行为。它不存储身份、校验结果或冻结证明。图片检测、文件投影和请求构建保持既有行为。[请求冻结证据](2026-09-06-agent-request-freeze-evidence.zh.md)拥有请求历史预算。
## Measurement evidence
@@ -48,7 +48,7 @@ class ControlSurfaceAdapter extends LlmAdapter {
const userText = current.flatMap(message => message.content)
.flatMap(block => block.type === 'text' ? [block.text] : [])
.join('')
const hasToolResult = current.some(message => message.content.some(block => block.type === 'tool-result'))
const hasToolResult = current.some(message => message.role === 'tool')
if (!hasToolResult) {
const callId = ToolCallId(userText.includes('cancel') ? 'control-cancel-add' : 'control-add')
yield { type: 'block-start', index: 0, blockType: 'reasoning' }
@@ -89,7 +89,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0)
expect(events.some(event => event.type === 'tool/result'
&& summaryData.shadowedSeqs.includes(event.seq)
&& event.data.message.content[0].content.some(block => block.type === 'text'
&& event.data.message.content.some(block => block.type === 'text'
&& block.text.includes('This is file number')))).toBe(true)
// The conversation survived compaction: the agent produced a final answer
@@ -12,18 +12,18 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":15,"outputTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_probe","name":"update_goal","args":["{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":15,"outputTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_probe","name":"update_goal","arguments":"{\"goal_id\":\"missing-goal\",\"revision\":1,\"action\":\"pause\",\"objective\":\"\",\"max_goal_rounds\":0,\"blocked_reason\":\"\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Goal tool snapshot","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_probe"},"content":[{"type":"tool-result","toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_goal_probe"},"toolCallId":"call_goal_probe","content":[{"type":"text","text":"Error: no current goal"}],"isError":true,"id":"{{sessionId}}"},"error":{"name":"GoalError","code":"GOAL_NOT_FOUND"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_create","name":"create_goal","args":["{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"goal/change","seq":22,"time":0,"data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"call_goal_create"},"toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"call_goal_get","name":"get_goal","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_get","name":"get_goal","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":27,"time":0,"data":{"turn":1,"step":3,"callId":"call_goal_get","name":"get_goal","arguments":"{}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_goal_get"},"content":[{"type":"tool-result","toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[27],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"call_goal_get"},"toolCallId":"call_goal_get","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[27],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":3}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":4}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":35,"outputTokens":2},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["GOAL READY"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":35,"outputTokens":2}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}}
@@ -13,7 +13,7 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"start-child","name":"subagent","arguments":"{\"description\":\"Return child result\",\"prompt\":\"Reply with exactly CHILD_RESULT and nothing else. Do not call send_message.\"}"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":16,"time":0,"data":{"title":"Subagent settlement","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-first-prompt-llm","model":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"subagent/catalog","seq":17,"time":0,"data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"Return child result"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"tool-result","toolCallId":"start-child","content":[{"type":"text","text":"started subagent {{sessionId}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"start-child"},"content":[{"type":"text","text":"started subagent {{sessionId}}"}],"role":"tool","toolCallId":"start-child","isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":20,"time":0,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{sessionId}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{sessionId}} finished and will do no further work unless you send it more.","senderSessionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"}]}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}}
@@ -28,11 +28,9 @@ function hasTaskAction(messages, action) {
}
function latestToolText(messages) {
const message = messages.findLast(candidate => candidate.content.some(block => block.type === 'tool-result'))
const message = messages.findLast(candidate => candidate.role === 'tool')
if (message === undefined) return ''
return message.content.flatMap(block => block.type === 'tool-result'
? block.content.filter(item => item.type === 'text').map(item => item.text)
: []).join('\n')
return message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
}
function toolChunks(specs) {
@@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas
const results = events.filter(event => event.type === 'tool/result')
const resultTexts = results.flatMap(event =>
event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text))
event.data.message.content.filter(block => block.type === 'text').map(block => block.text))
expect(resultTexts.some(text => text.includes('e2e-ok'))).toBe(true)
expect(finalText(events)).toContain('e2e-ok')
@@ -927,8 +927,7 @@ describe('headless stream-json snapshots', () => {
})
const probeData = probeResult?.data as JsonObject | undefined
const probeMessage = probeData?.message as JsonObject | undefined
const probeContent = probeMessage?.content as JsonObject[] | undefined
expect(probeContent?.[0]?.isError).toBe(true)
expect(probeMessage?.isError).toBe(true)
expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND')
const goalChanges = records.filter(record => record.type === 'goal/change')
expect(goalChanges).toHaveLength(1)
@@ -156,10 +156,11 @@ function assistantText(event: Extract<SessionEvent, { type: 'assistant/message'
}
function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>): string {
return event.data.message.content[0].content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
let text = ''
for (const block of event.data.message.content) {
if (block.type === 'text') text += block.text
}
return text
}
function messageKey(event: SessionEvent<'user/message'>): string {
@@ -315,7 +316,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
})
expect(results[0]?.data.turn).toBe(spec.index)
expect(results[0]?.data.message.source.callId).toBe(spec.callId)
expect(results[0]?.data.message.content[0].isError).toBe(false)
expect(results[0]?.data.message.isError).toBe(false)
expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`)
const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
+3 -3
View File
@@ -1012,9 +1012,9 @@ async function continueConversation(
if (toolResult?.type !== 'tool/result') {
throw new Error(`continued turn ${String(index)} did not log its tool result`)
}
const resultBlock = toolResult.data.message.content.find(block => block.type === 'tool-result')
expect(resultBlock?.isError).toBe(false)
expect(resultBlock?.content
const message = toolResult.data.message
expect(message.isError).toBe(false)
expect(message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')).toContain(spec.toolResultMarker)
+3 -3
View File
@@ -318,9 +318,9 @@ describe('web e2e: generic file upload through the real assembly', () => {
event.type === 'tool/result' && event.data.message.source.callId === readCall.data.callId,
)
if (readResult === undefined) throw new Error('the read call produced no durable result')
const content = readResult.data.message.content[0]
expect(content.isError).toBe(false)
expect(content.content.filter(block => block.type === 'text').map(block => block.text).join(''))
const message = readResult.data.message
expect(message.isError).toBe(false)
expect(message.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toContain('UPLOAD_ROUND_OK')
const turnEnds = sessionEvents.filter(event => event.type === 'turn/end')
+90 -165
View File
@@ -939,25 +939,20 @@
"turn": 52,
"step": 0,
"message": {
"id": "e9bbfe6c-96f3-44b6-b65c-89997783be1e",
"role": "tool",
"toolCallId": "fx-call-52",
"source": {
"kind": "tool",
"callId": "fx-call-52"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-52",
"content": [
{
"type": "text",
"text": "ECHO: TURN 52"
}
],
"isError": false
"type": "text",
"text": "ECHO: TURN 52"
}
],
"role": "user",
"id": "e9bbfe6c-96f3-44b6-b65c-89997783be1e"
"isError": false
}
}
}
@@ -1901,25 +1896,20 @@
"turn": 57,
"step": 0,
"message": {
"id": "652931ce-6eb0-4400-8173-9735b82fdf8e",
"role": "tool",
"toolCallId": "fx-call-57",
"source": {
"kind": "tool",
"callId": "fx-call-57"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-57",
"content": [
{
"type": "text",
"text": "ECHO: TURN 57"
}
],
"isError": false
"type": "text",
"text": "ECHO: TURN 57"
}
],
"role": "user",
"id": "652931ce-6eb0-4400-8173-9735b82fdf8e"
"isError": false
}
}
}
@@ -2559,25 +2549,20 @@
"turn": 60,
"step": 0,
"message": {
"id": "aa4c1898-7bec-4831-8ba1-0700f4ae897e",
"role": "tool",
"toolCallId": "fx-call-60",
"source": {
"kind": "tool",
"callId": "fx-call-60"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-60",
"content": [
{
"type": "text",
"text": "total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt"
}
],
"isError": false
"type": "text",
"text": "total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt"
}
],
"role": "user",
"id": "aa4c1898-7bec-4831-8ba1-0700f4ae897e"
"isError": false
}
}
}
@@ -2763,25 +2748,20 @@
"turn": 61,
"step": 0,
"message": {
"id": "4eedd943-773b-43d3-a104-e13550015670",
"role": "tool",
"toolCallId": "fx-call-61",
"source": {
"kind": "tool",
"callId": "fx-call-61"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-61",
"content": [
{
"type": "text",
"text": "wrote notes/demo.txt"
}
],
"isError": false
"type": "text",
"text": "wrote notes/demo.txt"
}
],
"role": "user",
"id": "4eedd943-773b-43d3-a104-e13550015670"
"isError": false
},
"meta": {
"diffs": [
@@ -2976,25 +2956,20 @@
"turn": 62,
"step": 0,
"message": {
"id": "37d72542-2cbc-4821-8897-329da97fe462",
"role": "tool",
"toolCallId": "fx-call-62",
"source": {
"kind": "tool",
"callId": "fx-call-62"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-62",
"content": [
{
"type": "text",
"text": "已编辑"
}
],
"isError": false
"type": "text",
"text": "已编辑"
}
],
"role": "user",
"id": "37d72542-2cbc-4821-8897-329da97fe462"
"isError": false
},
"meta": {
"diffs": [
@@ -3189,25 +3164,20 @@
"turn": 63,
"step": 0,
"message": {
"id": "0d0c76cf-f5b2-4f77-97cb-b0b66a67d135",
"role": "tool",
"toolCallId": "fx-call-63",
"source": {
"kind": "tool",
"callId": "fx-call-63"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-63",
"content": [
{
"type": "text",
"text": "已写入"
}
],
"isError": false
"type": "text",
"text": "已写入"
}
],
"role": "user",
"id": "0d0c76cf-f5b2-4f77-97cb-b0b66a67d135"
"isError": false
},
"meta": {
"diffs": [
@@ -3402,25 +3372,20 @@
"turn": 64,
"step": 0,
"message": {
"id": "11a489c7-466c-45ba-a5de-228fecd455f2",
"role": "tool",
"toolCallId": "fx-call-64",
"source": {
"kind": "tool",
"callId": "fx-call-64"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-64",
"content": [
{
"type": "text",
"text": "已编辑"
}
],
"isError": false
"type": "text",
"text": "已编辑"
}
],
"role": "user",
"id": "11a489c7-466c-45ba-a5de-228fecd455f2"
"isError": false
},
"meta": {
"diffs": [
@@ -3745,25 +3710,20 @@
"turn": 65,
"step": 0,
"message": {
"id": "ca689e1a-ebfb-4465-b187-43c3490abb9b",
"role": "tool",
"toolCallId": "fx-call-65",
"source": {
"kind": "tool",
"callId": "fx-call-65"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-65",
"content": [
{
"type": "text",
"text": "{\"listing\":\"demo.txt\\nnew-demo.txt\",\"demo\":\"hello fixture\\n\"}"
}
],
"isError": false
"type": "text",
"text": "{\"listing\":\"demo.txt\\nnew-demo.txt\",\"demo\":\"hello fixture\\n\"}"
}
],
"role": "user",
"id": "ca689e1a-ebfb-4465-b187-43c3490abb9b"
"isError": false
}
}
}
@@ -3949,25 +3909,20 @@
"turn": 66,
"step": 0,
"message": {
"id": "0da2afe6-5036-4d36-b9f3-ff562585507f",
"role": "tool",
"toolCallId": "fx-call-66",
"source": {
"kind": "tool",
"callId": "fx-call-66"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-66",
"content": [
{
"type": "text",
"text": "\u001b[1mRunning 4 checks\u001b[0m\n\u001b[32m✓\u001b[0m typecheck 1.82s\n\u001b[32m✓\u001b[0m lint 0.94s\n\u001b[32m✓\u001b[0m duplication 2.10s\n\u001b[31m✗\u001b[0m unit 8.41s\n\n\u001b[90mpackages/client/ui-primitives/tests/terminal-block.client.spec.tsx\u001b[0m\n \u001b[31mFAIL\u001b[0m caps output at the configured line budget\n expected 16 lines, received 24\n\nNAME LINES BRANCHES FUNCTIONS UNCOVERED\nTerminalBlock.tsx 100% 100% 100% -\nansi.ts 100% 100% 100% -\nclipboard.ts 100% 100% 100% -\nCodeBlock.tsx 98.4% 96.2% 100% 41-43\nhighlight.ts 100% 100% 100% -\nPill.tsx 100% 100% 100% -\nStateDot.tsx 100% 100% 100% -\nmarkdown/Markdown.tsx 100% 100% 100% -\n\n\u001b[31m1 of 4 checks failed\u001b[0m\n[exit code: 1]"
}
],
"isError": false
"type": "text",
"text": "\u001b[1mRunning 4 checks\u001b[0m\n\u001b[32m✓\u001b[0m typecheck 1.82s\n\u001b[32m✓\u001b[0m lint 0.94s\n\u001b[32m✓\u001b[0m duplication 2.10s\n\u001b[31m✗\u001b[0m unit 8.41s\n\n\u001b[90mpackages/client/ui-primitives/tests/terminal-block.client.spec.tsx\u001b[0m\n \u001b[31mFAIL\u001b[0m caps output at the configured line budget\n expected 16 lines, received 24\n\nNAME LINES BRANCHES FUNCTIONS UNCOVERED\nTerminalBlock.tsx 100% 100% 100% -\nansi.ts 100% 100% 100% -\nclipboard.ts 100% 100% 100% -\nCodeBlock.tsx 98.4% 96.2% 100% 41-43\nhighlight.ts 100% 100% 100% -\nPill.tsx 100% 100% 100% -\nStateDot.tsx 100% 100% 100% -\nmarkdown/Markdown.tsx 100% 100% 100% -\n\n\u001b[31m1 of 4 checks failed\u001b[0m\n[exit code: 1]"
}
],
"role": "user",
"id": "0da2afe6-5036-4d36-b9f3-ff562585507f"
"isError": false
}
}
}
@@ -4153,25 +4108,20 @@
"turn": 67,
"step": 0,
"message": {
"id": "9a3df388-356c-4440-b0bf-b09e0dda0387",
"role": "tool",
"toolCallId": "fx-call-67",
"source": {
"kind": "tool",
"callId": "fx-call-67"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-67",
"content": [
{
"type": "text",
"text": "Found 9 of 42 matches\n\npackages/client/ui-primitives/src/SearchBlock.tsx\nLine 16: export const DEFAULT_SEARCH_MAX_LINES = 16\nLine 138: export function SearchBlock(props: SearchBlockProps) {\nLine 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())\npackages/client/ui-tool/src/client/tool/models/search-card-model.ts\nLine 45: export const CHAT_SEARCH_MAX_LINES = 8\nLine 130: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {\npackages/client/ui-tool/src/client/tool/toolviews/search-row.tsx\nLine 34: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {\nLine 36: const search = searchCardModel(block)\nLine 56: search={search}\nLine 78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)\n\n(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)"
}
],
"isError": false
"type": "text",
"text": "Found 9 of 42 matches\n\npackages/client/ui-primitives/src/SearchBlock.tsx\nLine 16: export const DEFAULT_SEARCH_MAX_LINES = 16\nLine 138: export function SearchBlock(props: SearchBlockProps) {\nLine 141: const [collapsed, setCollapsed] = useState<ReadonlySet<number>>(() => new Set())\npackages/client/ui-tool/src/client/tool/models/search-card-model.ts\nLine 45: export const CHAT_SEARCH_MAX_LINES = 8\nLine 130: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {\npackages/client/ui-tool/src/client/tool/toolviews/search-row.tsx\nLine 34: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {\nLine 36: const search = searchCardModel(block)\nLine 56: search={search}\nLine 78: yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)\n\n(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)"
}
],
"role": "user",
"id": "9a3df388-356c-4440-b0bf-b09e0dda0387"
"isError": false
},
"meta": {
"shape": "matches",
@@ -4415,25 +4365,20 @@
"turn": 68,
"step": 0,
"message": {
"id": "e6d88943-d180-472b-9c1b-d59a21a50ab9",
"role": "tool",
"toolCallId": "fx-call-68",
"source": {
"kind": "tool",
"callId": "fx-call-68"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-68",
"content": [
{
"type": "text",
"text": "packages/client/ui-primitives/src/SearchBlock.tsx\npackages/client/ui-primitives/src/SearchBlock.module.css\npackages/client/ui-tool/src/client/tool/models/search-card-model.ts\npackages/client/ui-tool/src/client/tool/toolviews/search-row.tsx\npackages/client/ui-tool/tests/search-card.client.spec.tsx\n\n(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)"
}
],
"isError": false
"type": "text",
"text": "packages/client/ui-primitives/src/SearchBlock.tsx\npackages/client/ui-primitives/src/SearchBlock.module.css\npackages/client/ui-tool/src/client/tool/models/search-card-model.ts\npackages/client/ui-tool/src/client/tool/toolviews/search-row.tsx\npackages/client/ui-tool/tests/search-card.client.spec.tsx\n\n(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)"
}
],
"role": "user",
"id": "e6d88943-d180-472b-9c1b-d59a21a50ab9"
"isError": false
},
"meta": {
"shape": "paths",
@@ -4631,25 +4576,20 @@
"turn": 69,
"step": 0,
"message": {
"id": "937c02dd-63d8-4106-9310-a9df2055f0e9",
"role": "tool",
"toolCallId": "fx-call-69",
"source": {
"kind": "tool",
"callId": "fx-call-69"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-69",
"content": [
{
"type": "text",
"text": "<path>packages/client/ui-primitives/src/ReadBlock.tsx</path>\n<type>file</type>\n<content>\n41: export interface ReadBlockProps {\n42: label?: string | undefined\n43: lines: readonly ReadBlockLine[]\n44: totalLines: number\n45: lang?: string | undefined\n46: maxLines?: number | undefined\n47: className?: string | undefined\n48: }\n49: \n50: // A windowed read keeps the file line numbers in the gutter.\n51: const marker = \"fixture read sample\"\n\n(Showing lines 41-51 of 180. Use offset=52 to continue.)\n</content>"
}
],
"isError": false
"type": "text",
"text": "<path>packages/client/ui-primitives/src/ReadBlock.tsx</path>\n<type>file</type>\n<content>\n41: export interface ReadBlockProps {\n42: label?: string | undefined\n43: lines: readonly ReadBlockLine[]\n44: totalLines: number\n45: lang?: string | undefined\n46: maxLines?: number | undefined\n47: className?: string | undefined\n48: }\n49: \n50: // A windowed read keeps the file line numbers in the gutter.\n51: const marker = \"fixture read sample\"\n\n(Showing lines 41-51 of 180. Use offset=52 to continue.)\n</content>"
}
],
"role": "user",
"id": "937c02dd-63d8-4106-9310-a9df2055f0e9"
"isError": false
},
"meta": {
"path": "packages/client/ui-primitives/src/ReadBlock.tsx",
@@ -4887,25 +4827,20 @@
"turn": 70,
"step": 0,
"message": {
"id": "c2ddb04d-74c9-4a2f-83f6-47a48fc6fbc2",
"role": "tool",
"toolCallId": "fx-call-70",
"source": {
"kind": "tool",
"callId": "fx-call-70"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-70",
"content": [
{
"type": "text",
"text": "Search results for deepseek harness architecture."
}
],
"isError": false
"type": "text",
"text": "Search results for deepseek harness architecture."
}
],
"role": "user",
"id": "c2ddb04d-74c9-4a2f-83f6-47a48fc6fbc2"
"isError": false
},
"meta": {
"answer": "DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.",
@@ -5112,25 +5047,20 @@
"turn": 71,
"step": 0,
"message": {
"id": "57cf5cd3-3daf-4e2c-bb16-f8695bc2ab8c",
"role": "tool",
"toolCallId": "fx-call-71",
"source": {
"kind": "tool",
"callId": "fx-call-71"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-71",
"content": [
{
"type": "text",
"text": "# Harness architecture\n\nEverything is a plugin."
}
],
"isError": false
"type": "text",
"text": "# Harness architecture\n\nEverything is a plugin."
}
],
"role": "user",
"id": "57cf5cd3-3daf-4e2c-bb16-f8695bc2ab8c"
"isError": false
},
"meta": {
"url": "https://www.deepseek.com/blog/harness-architecture",
@@ -5701,25 +5631,20 @@
"turn": 74,
"step": 0,
"message": {
"id": "499968fd-a83f-45ad-80fe-b1d73651ff13",
"role": "tool",
"toolCallId": "fx-call-74",
"source": {
"kind": "tool",
"callId": "fx-call-74"
},
"content": [
{
"type": "tool-result",
"toolCallId": "fx-call-74",
"content": [
{
"type": "text",
"text": "Updated todo list: 1 pending, 2 in progress, 1 completed."
}
],
"isError": false
"type": "text",
"text": "Updated todo list: 1 pending, 2 in progress, 1 completed."
}
],
"role": "user",
"id": "499968fd-a83f-45ad-80fe-b1d73651ff13"
"isError": false
}
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ function continuationScript(): ReplayEntry[] {
}
function toolResultText(event: SessionEvent<'tool/result'>): string {
return event.data.message.content[0].content
return event.data.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
+1 -1
View File
@@ -248,7 +248,7 @@ describe('web e2e: dismissed plan history', () => {
expect(call).toBeDefined()
const results = events.filter(event => event.type === 'tool/result')
const result = results.find(event => event.data.message.source.callId === call?.data.callId)
expect(result?.data.message.content[0]).toMatchObject({ type: 'tool-result', isError: true })
expect(result?.data.message).toMatchObject({ role: 'tool', toolCallId: call?.data.callId, isError: true })
expect(JSON.stringify(result)).toContain('dismissed the plan review')
expect(results.some(event => JSON.stringify(event).includes('Plan approved'))).toBe(false)
const modes = events.filter(event => event.type === 'plan/mode')
+1 -1
View File
@@ -106,7 +106,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
}
}
expect(events.some(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'present' && event.data.isError)).toBe(true)
expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError)).toBe(true)
expect(events.some(event => event.type === 'tool/result' && event.data.message.isError)).toBe(true)
}, 200_000)
it('opens current source files after edits and reload, and reports deletion without downloading', async () => {
+4 -5
View File
@@ -280,11 +280,10 @@ describe('web e2e: resident question composer round trip', () => {
answeredSession = sessionId
// World state: the tool result carries the chosen answer, and DONE lands.
const results = sessionEvents.filter(e => e.type === 'tool/result')
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
block.type === 'tool-result'
? block.content.filter(item => item.type === 'text').map(item => item.text)
: [],
)).at(-1)
const answerText = results.flatMap(event => event.data.message.content
.filter(item => item.type === 'text')
.map(item => item.text),
).at(-1)
expect(JSON.parse(answerText ?? '')).toEqual({
answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }],
})
+2 -2
View File
@@ -171,8 +171,8 @@ describe('web e2e: fresh round trip through the real assembly', () => {
const bashResult = sessionEvents.find(event =>
event.type === 'tool/result' && event.data.message.source.callId === bashCall.data.callId)
if (bashResult?.type !== 'tool/result') throw new Error('the bash tool call produced no durable result')
expect(bashResult.data.message.content[0].isError).toBe(false)
expect(bashResult.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text).join(''))
expect(bashResult.data.message.isError).toBe(false)
expect(bashResult.data.message.content.filter(block => block.type === 'text').map(block => block.text).join(''))
.toBe('WEB_E2E_OK\n')
const turnEnds = sessionEvents.filter(e => e.type === 'turn/end')
expect(turnEnds.length).toBe(1)
+5 -6
View File
@@ -101,7 +101,7 @@ class ShippedAutoAdapter extends LlmAdapter {
}))
return
}
if (options.messages.some(message => message.content.some(block => block.type === 'tool-result'))) {
if (options.messages.some(message => message.role === 'tool')) {
yield* textChunks(AUTO_FINAL_TEXT)
return
}
@@ -429,10 +429,9 @@ function toolOutcomes(events: readonly SessionEvent[]): Array<{ name: string; co
}
return events.flatMap((event) => {
if (event.type !== 'tool/result') return []
const block = event.data.message.content.find(item => item.type === 'tool-result')
if (block === undefined) return []
const name = names.get(block.toolCallId)
if (name === undefined) throw new Error(`tool result ${block.toolCallId} has no matching call`)
const { toolCallId } = event.data.message
const name = names.get(toolCallId)
if (name === undefined) throw new Error(`tool result ${toolCallId} has no matching call`)
return [{ name, ...event.data.error === undefined ? {} : { code: event.data.error.code } }]
})
}
@@ -794,7 +793,7 @@ it('routes one browser-authored Auto request through the same model before a rea
expect(prompt).toBeDefined()
const result = events.find((event): event is Extract<SessionEvent, { type: 'tool/result' }> => (
event.type === 'tool/result'
&& event.data.message.content.some(block => block.toolCallId === AUTO_CALL_ID)
&& event.data.message.toolCallId === AUTO_CALL_ID
))
expect(result?.data.error).toEqual({
name: 'AutoReviewDeniedError',
+4 -2
View File
@@ -297,9 +297,11 @@ describe('web e2e: shipped right Sidebar', () => {
step: 1,
message: {
id: 'result-call-write-1',
role: 'user',
role: 'tool',
source: { kind: 'tool', callId: 'call-write-1' },
content: [{ type: 'tool-result', toolCallId: 'call-write-1', content: [{ type: 'text', text: 'ok' }] }],
toolCallId: 'call-write-1',
content: [{ type: 'text', text: 'ok' }],
isError: false,
},
} as never, { surfaceOp: 'append' })
agent.session.append('assistant/message', {
+3 -3
View File
@@ -239,9 +239,9 @@ describe('web e2e: shipped default web search', () => {
event.type === 'tool/result' && event.data.message.source.callId === searchCall.data.callId,
)
if (searchResult === undefined) throw new Error('web_search produced no durable result')
const content = searchResult.data.message.content[0]
expect(content.isError).toBe(false)
const rendered = content.content.filter(block => block.type === 'text').map(block => block.text).join('')
const message = searchResult.data.message
expect(message.isError).toBe(false)
const rendered = message.content.filter(block => block.type === 'text').map(block => block.text).join('')
// The tool interleaves sources from both seam results before applying the
// combined cap, so each query remains represented in model-visible output.
for (const source of KEPT_SOURCES) {
@@ -62,8 +62,8 @@ async function run(root: string): Promise<ProfileReport> {
requests += result.events.filter(event => event.type === 'assistant/message').length
for (const event of result.events) {
if (event.type !== 'tool/result') continue
const result = event.data.message.content[0]
if (result.isError || !result.content.some(block => block.type === 'text' && block.text.includes('export const synthetic = 42;'))) {
const message = event.data.message
if (message.isError || !message.content.some(block => block.type === 'text' && block.text.includes('export const synthetic = 42;'))) {
throw new Error('profile benchmark did not read the synthetic file')
}
toolCalls++
+3 -2
View File
@@ -91,8 +91,9 @@ export function syntheticHistory(turns: number): SessionEvent[] {
session.append('tool/result', {
turn, step,
message: {
id: MessageId('result-' + block.id), role: 'user', source: { kind: 'tool', callId: block.id },
content: [{ type: 'tool-result', toolCallId: block.id, content: [{ type: 'text', text: resultText(turn) }], isError: false }],
id: MessageId('result-' + block.id), role: 'tool', toolCallId: block.id, isError: false,
source: { kind: 'tool', callId: block.id },
content: [{ type: 'text', text: resultText(turn) }],
},
}, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
}
+2 -2
View File
@@ -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/persistence-catalog.md
persistence-catalog.md: ba70d5713012d09708882fe6dbd1fa9356099327
persistence-catalog.zh.md: aa6cc3fe44753dff0408e5271f1e068e2de92ad1
persistence-catalog.md: d17fa6c74408367fa35fd5f251d3168780792cd9
persistence-catalog.zh.md: e1e83dd5f7e5e04b1f975696f2099a53500f3cca
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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/persistence-changes/2026-09-16-session-format-v4.md
2026-09-16-session-format-v4.md: 83dbb637b19ee52163852220448f6593fa22fb47
2026-09-16-session-format-v4.zh.md: 82956ba5363b111fc946dc010aa691d189331098
2026-09-16-session-format-v4.md: a7eb33734a174b1fc7d753edd4ff2f9ca8dcd2d5
2026-09-16-session-format-v4.zh.md: b5eebdf48261b8492bc30d20036766f8419060bb
@@ -9,7 +9,7 @@ English | [中文](2026-09-16-session-format-v4.zh.md)
## Summary
Advances the declared SessionHeader.version from 3 to 4 for the V4 integration writer and adds the forked variant to turn/end.reason.
Advances the declared SessionHeader.version from 3 to 4 for the V4 integration writer, records first-class tool-role results, and adds the forked variant to turn/end.reason.
## Table of Contents
@@ -30,29 +30,65 @@ changes:
previous: "2026-09-11-initial"
after: "1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445"
decision: version-bump
- root: "event:agent/inbox/spliced"
previous: "2026-09-14-image-offload"
after: "cbe418d5a3727fbd3599f90101680dae254ac1d6163b1b8b2ca30f28afb5212a"
decision: version-bump
- root: "event:assistant/attempt"
previous: "2026-09-14-image-offload"
after: "2f1565d92801f4334f8dc34fbb980890eef621e81a7c90ed0fd8d0a6db2ec3b2"
decision: version-bump
- root: "event:assistant/message"
previous: "2026-09-14-image-offload"
after: "f219a9f66fcc92ffec27bbd96fa21ffc2cffbaf2ad1de05757ec37205b1a0b58"
decision: version-bump
- root: "event:compaction/summary"
previous: "2026-09-14-image-offload"
after: "86c1ee9c8ab6c6e160d4ccc00b3d9ad5a6c687938b552d55eec528279baa1559"
decision: version-bump
- root: "event:session/title-llm-request"
previous: "2026-09-14-image-offload"
after: "2a47ef7025d7dcf7e9977aadd738e7408a210b101a7dbbd37f4d87f22166eff0"
decision: version-bump
- root: "event:system/message"
previous: "2026-09-14-image-offload"
after: "1772581b17e1fab970fda49f04ffae2a181b7992ca4efa63bffe5f8b2bd62300"
decision: version-bump
- root: "event:team/message/queued"
previous: "2026-09-14-image-offload"
after: "9a25c2e889855516a531dc906905ec1d88157f7c2651aed27ac4f754bb128240"
decision: version-bump
- root: "event:tool/ptc-dispatch"
previous: "2026-09-14-image-offload"
after: "75f1d59512dd2e6468bed27c6203a721b116ef7869e291e3fee34c977d013185"
decision: version-bump
- root: "event:tool/result"
previous: "2026-09-14-image-offload"
after: "f4c6f3eae6607f412a9023406c8c3bd351a02451848bb78c2436f409447dd60c"
decision: version-bump
- root: "event:turn/end"
previous: "2026-09-14-image-offload"
after: "0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"
decision: version-bump
- root: "event:user/message"
previous: "2026-09-14-image-offload"
after: "5274b395e7bf6660d020fba7e29a1e20ef2e77097fd7597e9debb13e45c540d6"
decision: version-bump
```
<a id="compatibility"></a>
## Compatibility
The [V3-to-V4 migration](../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification) advances the header, preserves every admitted source event and inherited cut, and appends missing parent `subagent/catalog` records from retained direct-child logs in the same persistence root. Historical body restoration requires an explicit child-evidence set, including an empty set when no children are available to backfill. Missing required descriptors or conflicting identities refuse migration without publication; existing catalog facts remain. The linked specification owns descriptor admission and deterministic append order.
The tool-role declaration changes ten event roots because inbox entries, message events, compaction summaries, title requests, team messages, and PTC dispatches embed the shared `Message` or `ContentBlock` declarations. Removing `tool-result` from that union and specializing message roles changes those reachable schemas without adding ten independent event protocols. The `turn/end` change separately records the forked reason; `SessionHeader` records the version increase.
Historical read opens prepare the result in memory. Write opens revalidate child membership and revisions before publishing the current successor beside unchanged predecessor files. Already-written V4 files do not rerun this incoming edge, so unreleased integration uses disposable homes. Delivery-generation checks keep historical acknowledgements from becoming active V4 watermarks. V3 readers refuse the newer generation; the [V3 schema reference](historical-formats/v3.md) preserves the outgoing declarations.
The [native V4 validation decision](../../.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.md) owns the reader admission required by these current fields.
The same unreleased transition adds `forked` to `turn/end.reason`. Exact-cut forks append child-owned error results and closers after the inherited marker. V4 admits checked not-started fork results with deterministic branch-specific IDs and wording; released V0–V3 validators and recorded predecessor generations remain unchanged.
The V3-to-V4 migration lifts released user-role tool results into tool-role messages with a required toolCallId and optional isError. Tool-result wrappers leave the content-block union. The migration preserves every admitted source event and inherited cut, and appends missing parent subagent/catalog records from retained direct-child logs in the same persistence root. Historical body restoration requires an explicit child-evidence set, including an empty set when no children are available to backfill. Missing required descriptors or conflicting identities refuse migration without publication; existing catalog facts remain. Historical read opens prepare the result in memory. Write opens revalidate child membership and revisions before publishing the current successor beside unchanged predecessor files. Delivery-generation checks keep historical acknowledgements from becoming active V4 watermarks. V3 readers refuse the newer generation. The same unreleased transition adds forked to turn/end.reason; exact-cut forks append child-owned error results and closers after the inherited marker. V4 admits checked not-started fork results with deterministic branch-specific IDs and wording; released V0–V3 validators and recorded predecessor generations remain unchanged.
<a id="verification"></a>
## Verification
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests packages/session/session-format-catalog/tests packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts` passed 140 tests across nine files, with one skipped test. The suites cover catalog completion, inherited cuts, child-evidence refusal, and JSONL preparation/publication.
Before the writer change, `pnpm run verify-persistence-catalog` passed and `pnpm run verify-persistence-formats --archive 3` captured the V3 schema. The V4 header schema digest remains unchanged.
The focused Session, agent-loop, Session Controller, V4, chat-view, and compaction suites passed 1,523 tests across 63 files after integration of exact-cut forks. V4 fork tests retain original IDs and text across encoding, decoding, and restoration, reject malformed results, and validate nested inherited cuts.
The focused Session, agent-loop, Session Controller, V4, chat-view, and compaction suites passed 1,523 tests across 63 files after integration of exact-cut forks. V4 fork tests retain original IDs and text across encoding, decoding, and restoration, reject malformed results, and validate nested inherited cuts. The tool-role migration tests and SDK snapshot refresh also passed in the originating change; the built Python runtime sdk-snapshot scenario passed, and focused pi-ai and auto-review coverage passed 351 tests with 100% coverage of the three affected modules.
<a id="dev-note"></a>
## Dev Note
File diff suppressed because it is too large. Load diff
@@ -9,7 +9,7 @@ kind: persistence-change
## 概述
将 V4 集成写入方声明的 SessionHeader.version 从 3 推进到 4,并向 turn/end.reason 添加 forked 变体。
将 V4 集成写入方声明的 SessionHeader.version 从 3 推进到 4,记录一等 tool 角色结果,并向 turn/end.reason 添加 forked 变体。
## 目录
@@ -30,29 +30,65 @@ changes:
previous: "2026-09-11-initial"
after: "1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445"
decision: version-bump
- root: "event:agent/inbox/spliced"
previous: "2026-09-14-image-offload"
after: "cbe418d5a3727fbd3599f90101680dae254ac1d6163b1b8b2ca30f28afb5212a"
decision: version-bump
- root: "event:assistant/attempt"
previous: "2026-09-14-image-offload"
after: "2f1565d92801f4334f8dc34fbb980890eef621e81a7c90ed0fd8d0a6db2ec3b2"
decision: version-bump
- root: "event:assistant/message"
previous: "2026-09-14-image-offload"
after: "f219a9f66fcc92ffec27bbd96fa21ffc2cffbaf2ad1de05757ec37205b1a0b58"
decision: version-bump
- root: "event:compaction/summary"
previous: "2026-09-14-image-offload"
after: "86c1ee9c8ab6c6e160d4ccc00b3d9ad5a6c687938b552d55eec528279baa1559"
decision: version-bump
- root: "event:session/title-llm-request"
previous: "2026-09-14-image-offload"
after: "2a47ef7025d7dcf7e9977aadd738e7408a210b101a7dbbd37f4d87f22166eff0"
decision: version-bump
- root: "event:system/message"
previous: "2026-09-14-image-offload"
after: "1772581b17e1fab970fda49f04ffae2a181b7992ca4efa63bffe5f8b2bd62300"
decision: version-bump
- root: "event:team/message/queued"
previous: "2026-09-14-image-offload"
after: "9a25c2e889855516a531dc906905ec1d88157f7c2651aed27ac4f754bb128240"
decision: version-bump
- root: "event:tool/ptc-dispatch"
previous: "2026-09-14-image-offload"
after: "75f1d59512dd2e6468bed27c6203a721b116ef7869e291e3fee34c977d013185"
decision: version-bump
- root: "event:tool/result"
previous: "2026-09-14-image-offload"
after: "f4c6f3eae6607f412a9023406c8c3bd351a02451848bb78c2436f409447dd60c"
decision: version-bump
- root: "event:turn/end"
previous: "2026-09-14-image-offload"
after: "0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce"
decision: version-bump
- root: "event:user/message"
previous: "2026-09-14-image-offload"
after: "5274b395e7bf6660d020fba7e29a1e20ef2e77097fd7597e9debb13e45c540d6"
decision: version-bump
```
<a id="compatibility"></a>
## 兼容性
[V3 到 V4 迁移](../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)推进 header,保留每个已接纳源事件与继承切点,并根据同一持久化根目录中保留的直接子 Session 日志追加缺失的父级 `subagent/catalog` 记录。历史正文恢复要求显式提供子日志证据集合;没有可供补全的子日志时也须传入空集合。缺少必需的 descriptor 或身份冲突会拒绝迁移,且不发布后继;已有 catalog 事实保持不变。链接中的规范负责 descriptor 准入与确定性追加顺序。
工具角色声明会改变十个事件根,因为 inbox 条目、消息事件、compaction 摘要、标题请求、团队消息和 PTC dispatch 嵌入了共享的 `Message` 或 `ContentBlock` 声明。从联合中移除 `tool-result` 并按角色细化消息,会改变这些可达 schema,并非新增十套独立事件协议。`turn/end` 的变更单独记录 forked reason;`SessionHeader` 记录版本递增。
历史读取打开在内存中准备结果。写入打开在重新校验子项成员与修订后,将当前后继发布到未修改的前代文件旁。已写入的 V4 文件不会重新运行该迁入边,因此未发布集成使用可丢弃的 home。Delivery generation 校验防止历史确认成为有效的 V4 水位。V3 读取方拒绝更新的 generation;[V3 schema 参考](historical-formats/v3.zh.md)保留原声明。
[原生 V4 校验决策](../../.agents/notes/implemented/architecture/2026-09-17-native-v4-read-validation.zh.md) 负责说明这些当前字段所需的读取接纳规则。
同一个未发布迁移向 `turn/end.reason` 添加 `forked`。精确切点的 fork 在继承标记之后追加子会话自有的错误结果和结束事件。V4 接纳经过校验、使用确定性分支 ID 和文案的未启动 fork 结果;已发布的 V0–V3 校验器和已记录的前驱代际保持不变。
V3-to-V4 迁移将已发布的 user 角色工具结果提升为 tool 角色消息,包含必需的 toolCallId 和可选的 isError。工具结果包装不再属于内容块联合。迁移保留每个已接纳源事件和继承切分点,并根据同一持久化根目录中保留的直属子 Session 日志追加缺失的父级 subagent/catalog 记录。历史正文恢复要求显式提供子日志证据集合;没有可供补全的子日志时也须传入空集合。缺少必需的 descriptor 或身份冲突会拒绝迁移且不发布后继;已有 catalog 事实保持不变。历史读取打开在内存中准备结果。写入打开在重新校验子项成员与修订后,将当前后继发布到未修改的前代文件旁。Delivery generation 校验防止历史确认成为有效的 V4 水位。V3 读取方拒绝更新的 generation。同一个未发布迁移向 turn/end.reason 添加 forked。精确切点的 fork 在继承标记之后追加子会话自有的错误结果和结束事件。V4 接纳经过校验、使用确定性分支 ID 和文案的未启动 fork 结果;已发布的 V0–V3 校验器和已记录的前驱代际保持不变。
<a id="verification"></a>
## 验证
`pnpm exec vitest run packages/session/session-format-v3-to-v4/tests packages/session/session-format-catalog/tests packages/session/session-persistence-jsonl/tests/catalog-migration.spec.ts` 的九个文件共 140 个测试通过,一个测试跳过。这些测试覆盖 catalog 补全、继承切点、子日志证据拒绝,以及 JSONL 准备与发布。
写入器变更前,`pnpm run verify-persistence-catalog` 通过,且 `pnpm run verify-persistence-formats --archive 3` 已捕获 V3 schema。V4 header schema digest 保持不变。
精确切点 fork 集成后,聚焦的 Session、agent-loop、Session Controller、V4、chat-view 和 compaction 测试共 63 个文件、1,523 个测试通过。V4 fork 测试确认编码、解码和恢复保留原始 ID 与文本,拒绝格式错误的结果,并验证嵌套继承切点。
精确切点 fork 集成后,聚焦的 Session、agent-loop、Session Controller、V4、chat-view 和 compaction 测试共 63 个文件、1,523 个测试通过。V4 fork 测试确认编码、解码和恢复保留原始 ID 与文本,拒绝格式错误的结果,并验证嵌套继承切点。工具角色迁移测试和 SDK 快照刷新也在原始变更中通过;构建后的 Python runtime sdk-snapshot 场景通过,定向 pi-ai 与 auto-review 覆盖率检查通过 351 个测试,三个受影响模块覆盖率均为 100%。
<a id="dev-note"></a>
## 开发备注
@@ -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/persistence-changes/historical-formats/README.md
README.md: 16ed3d6110315d476dd6075fd2a75973f5971095
README.zh.md: f6b715ffc52e1635daa7488316d34b5608ae9f25
README.md: 89f9a70ea00c6ef5adc61ba57806aa7ed7be6265
README.zh.md: 5b87b61b5a801e349591bdb1b1ca0d263c3c57b6
@@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant.
| 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 61 / 472 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 61 / 468 |
<!-- persistence-format-index:end -->
@@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式
| 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 |
| 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 |
| 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 61 / 472 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 61 / 468 |
<!-- persistence-format-index:end -->
+22841 -24578
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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: 30dd26042b4894b294a67918006d9b607eadc6b2
llm-streaming.zh.md: 6e5f1ff268e0c22a37c64a8f954c134bf71b3b73
llm-streaming.md: a5a42ef50dccebbc40b0c35e0dd4e76f7a8f764b
llm-streaming.zh.md: 653a216de81fd2a11f45d8bbcb04231f7dd3f7fb
+5 -15
View File
@@ -25,11 +25,10 @@ interface ContentBlockMap {
'image': ImageBlock
'file': FileBlock
'tool-call': ToolCallBlock
'tool-result': ToolResultBlock
}
```
The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it.
The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), and `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`). Tool results are first-class `ToolResultMessage` values with `toolCallId`, result `content`, and optional `isError`; they are not content blocks. `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it.
Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`.
@@ -64,17 +63,8 @@ interface AssistantProviderMetadata {
```
```ts type-equiv
/** One immutable message representation shared by delivery, durable history, and model requests. */
interface Message {
/** Stable identity preserved across every representation boundary. */
readonly id: MessageId
/** Provider-neutral conversation role. */
readonly role: 'system' | 'user' | 'assistant'
/** Exact model-facing blocks. */
readonly content: ContentBlock[]
/** Required source fields supplied by the producer. */
readonly source: MessageSource
}
/** Any persisted conversation message, discriminated by its `role`. */
type Message = MessageRoleMap[keyof MessageRoleMap]
```
Where a message came from is itself a merge-extensible sum type:
@@ -415,10 +405,10 @@ declare class BlockAssembler {
get replayState(): ReplayEnvelope | undefined;
/**
* The assembled assistant message.
* @param source - producer attribution for the assembled message.
* @param source - provider/model attribution (without the `kind` tag) for the assembled message.
* @returns a frozen assistant-role message over `blocks()` (same open-block assembly rules).
*/
message(source: MessageSource = { kind: 'plugin', plugin: 'dsh-llm/assembler' }): Message;
message(source: Omit<ModelMessageSource, 'kind'>): AssistantMessage;
}
```
+5 -15
View File
@@ -25,11 +25,10 @@ interface ContentBlockMap {
'image': ImageBlock
'file': FileBlock
'tool-call': ToolCallBlock
'tool-result': ToolResultBlock
}
```
各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。
各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)和 `ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`)。工具结果是一等 `ToolResultMessage`,含有 `toolCallId`、结果 `content` 与可选的 `isError`;它不是内容块。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。
图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。
@@ -64,17 +63,8 @@ interface AssistantProviderMetadata {
```
```ts type-equiv
/** One immutable message representation shared by delivery, durable history, and model requests. */
interface Message {
/** Stable identity preserved across every representation boundary. */
readonly id: MessageId
/** Provider-neutral conversation role. */
readonly role: 'system' | 'user' | 'assistant'
/** Exact model-facing blocks. */
readonly content: ContentBlock[]
/** Required source fields supplied by the producer. */
readonly source: MessageSource
}
/** Any persisted conversation message, discriminated by its `role`. */
type Message = MessageRoleMap[keyof MessageRoleMap]
```
消息来源本身也是一个可合并扩展的和类型:
@@ -421,10 +411,10 @@ declare class BlockAssembler {
get replayState(): ReplayEnvelope | undefined;
/**
* The assembled assistant message.
* @param source - producer attribution for the assembled message.
* @param source - provider/model attribution (without the `kind` tag) for the assembled message.
* @returns a frozen assistant-role message over `blocks()` (same open-block assembly rules).
*/
message(source: MessageSource = { kind: 'plugin', plugin: 'dsh-llm/assembler' }): Message;
message(source: Omit<ModelMessageSource, 'kind'>): AssistantMessage;
}
```
+2 -2
View File
@@ -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/session-telemetry.md
session-telemetry.md: a76eee81ef018cbd2c230debc6e2105e6e727c43
session-telemetry.zh.md: 90b09f4a2394822c9b738430f5c8e0e54cbce508
session-telemetry.md: 18fe935e5e02b7fb26bb9120b031a4685ca603aa
session-telemetry.zh.md: 1b3807d2e38bea141953faba990a6fee460b40b9
+1 -1
View File
@@ -12,7 +12,7 @@ Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/sessi
/**
* Severity of a telemetry record, pre-mapped at capture so a receiver can
* alert with zero configuration: `error` for events whose own outcome flag
* says so (the tool-result block's `isError`, `turn/end` error reasons) and for
* says so (the tool message's `isError`, `turn/end` error reasons) and for
* `agent-error` operational records. Captured events otherwise default to
* `info`; `warn` remains available to `session-telemetry/record` policies and
* backends.
+1 -1
View File
@@ -12,7 +12,7 @@
/**
* Severity of a telemetry record, pre-mapped at capture so a receiver can
* alert with zero configuration: `error` for events whose own outcome flag
* says so (the tool-result block's `isError`, `turn/end` error reasons) and for
* says so (the tool message's `isError`, `turn/end` error reasons) and for
* `agent-error` operational records. Captured events otherwise default to
* `info`; `warn` remains available to `session-telemetry/record` policies and
* backends.
+2 -2
View File
@@ -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/session.md
session.md: a527939295954e0fa1c77dd28a0c6a27bdefc611
session.zh.md: 411914fe7ea4aa9c08cf60471b4074dadb171bbe
session.md: 15bc46fb20820ecdb1b2263027e5daf061ca5664
session.zh.md: 063d018cc249fa7a631a360d26f95556a0ba286c
+5 -5
View File
@@ -11,8 +11,8 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t
The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compaction/start` / `compaction/summary` / `compaction/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` records for a hook bridge. Like `compaction/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site.
```ts type-equiv
/** A user-role specialization of the one shared message representation. */
interface UserMessage extends Message {
/** A user-role specialization of the shared message representation. */
interface UserMessage extends MessageBase {
readonly role: 'user'
}
```
@@ -116,7 +116,7 @@ interface SessionEventMap {
message: ToolResultMessage
/**
* Optional failure identity and raw user-facing reason, outside model content;
* allowed only when the tool-result block has `isError: true`.
* allowed only when the message has `isError: true`.
*/
error?: { name: string; code: string; reason?: string }
meta?: JsonValue
@@ -279,7 +279,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
`SessionEventType = keyof SessionEventMap`. Because `SessionEventMap` is merge-extensible, switches over `SessionEvent` must NOT use `assertNever` — a plugin-added variant is a valid unknown value; handle the known cases and fall through `default`.
Every surface event requires `surfaceOp`; known log-only events forbid both surface metadata fields. Native unknown or obsolete ignorable envelopes remain opaque. `assistant/message` embeds its provider stream and forbids `sourceEventSeqs`. System, user, and tool surface events may cite a complete non-empty set of unique earlier events when source attribution or replacement coverage requires it. A `tool/result` may carry `data.error` only when its tool-result block has `isError: true`; failure identity remains optional for failed results.
Every surface event requires `surfaceOp`; known log-only events forbid both surface metadata fields. Native unknown or obsolete ignorable envelopes remain opaque. `assistant/message` embeds its provider stream and forbids `sourceEventSeqs`. System, user, and tool surface events may cite a complete non-empty set of unique earlier events when source attribution or replacement coverage requires it. A `tool/result` may carry `data.error` only when its message has `isError: true`; failure identity remains optional for failed results.
## Surface types
@@ -650,7 +650,7 @@ declare class Session {
- `user/message` → a user message carrying exact `content`; an optional envelope remains log-only display metadata.
- `assistant/message` → an assistant message with the provider and model that produced it plus optional adapter-private replay state. Its embedded compact stream is replay, usage, and UI evidence rather than a second message. An **empty-content** `assistant/message` is also skipped — a max-tokens step cut off with no content still records an `assistant/message` to hold its stream, usage, provider, and model, but a content-less assistant turn must not enter the provider transcript.
- `tool/result` → a user message carrying a `tool-result` block.
- `tool/result` → a first-class tool-role message carrying its result content and tool-call identity.
- `user/message` (injected context, i.e. non-`user` source) → a user-role message carrying its `content` verbatim at its chronological position; its typed source names the producer and carries any producer-specific data.
Everything else (`turn/*`, `step/*`, `assistant/attempt`, plugin-owned `llm/retry`) is structural and does not project into a message. Token accounting expands the embedded stream on each `assistant/message` or `assistant/attempt`, while the message's top-level `usage` remains the committed-message authority when present. A failed model-request attempt therefore retains its provider usage without fabricating an assistant message. Current logical validation rejects request headers and assistant messages that omit provider/model instead of guessing a route; supported historical representations are normalized and validated by their adjacent format edge before a current Session exists.
+5 -5
View File
@@ -11,8 +11,8 @@
仅追加的事件类型。可通过声明合并扩展:插件通过 declaration merging 声明额外的事件类型。例如[压缩(compaction) seam](compaction.zh.md) 添加了 `compaction/start` / `compaction/summary` / `compaction/end`,`@deepseek-ai/dsh-hook-protocol` 为钩子桥接添加了仅记录日志的 `hook/invoked` / `hook/result` 记录。与 `compaction/*` 一样,这些都不是 `SurfaceEventType`(没有 `surfaceOp`)。生成的[持久化日志事件目录](../persistence-catalog.zh.md)列举了所有成员(核心与合并扩展的),包含其 payload、surface 标记与声明位置。
```ts type-equiv
/** A user-role specialization of the one shared message representation. */
interface UserMessage extends Message {
/** A user-role specialization of the shared message representation. */
interface UserMessage extends MessageBase {
readonly role: 'user'
}
```
@@ -116,7 +116,7 @@ interface SessionEventMap {
message: ToolResultMessage
/**
* Optional failure identity and raw user-facing reason, outside model content;
* allowed only when the tool-result block has `isError: true`.
* allowed only when the message has `isError: true`.
*/
error?: { name: string; code: string; reason?: string }
meta?: JsonValue
@@ -279,7 +279,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
`SessionEventType = keyof SessionEventMap`。由于 `SessionEventMap` 可通过合并扩展,对 `SessionEvent` 的 switch 语句禁止使用 `assertNever`:插件添加的变体是合法的未知值;处理已知 case 后在 `default` 中放行。
每个 surface 事件都要求 `surfaceOp`;已知仅日志事件禁止两个 surface 元数据字段。原生未知或已退役的可忽略信封保持不透明。`assistant/message` 嵌入其提供方 stream,并禁止 `sourceEventSeqs`。System、user 与 tool surface 事件可以在来源归属或替换覆盖需要时引用完整、非空且唯一的较早事件集合。`tool/result` 仅在工具结果块带有 `isError: true` 时可以携带 `data.error`;失败结果的失败身份仍可省略。
每个 surface 事件都要求 `surfaceOp`;已知仅日志事件禁止两个 surface 元数据字段。原生未知或已退役的可忽略信封保持不透明。`assistant/message` 嵌入其提供方 stream,并禁止 `sourceEventSeqs`。System、user 与 tool surface 事件可以在来源归属或替换覆盖需要时引用完整、非空且唯一的较早事件集合。`tool/result` 仅在消息带有 `isError: true` 时可以携带 `data.error`;失败结果的失败身份仍可省略。
<a id="surface-types"></a>
@@ -652,7 +652,7 @@ declare class Session {
- `user/message` → 一条携带确切 `content` 的 user 消息;可选 envelope 仅作为日志中的展示元数据保留。
- `assistant/message` → 一条 assistant 消息,包含生成它的提供方和模型,以及可选的适配器私有回放状态。其嵌入式紧凑 stream 是回放、usage 与 UI 证据,而不是第二条 message。**内容为空的** `assistant/message` 也会跳过:因 max-tokens 而截断且无内容的步骤仍会记录一条 `assistant/message` 来保存 stream、usage、提供方和模型,但无内容的 assistant 轮次不得进入提供方 transcript(文本记录)。
- `tool/result` → 一条携带 `tool-result` 块的 user 消息。
- `tool/result` → 一条一等 tool-role 消息,携带结果内容与工具调用标识。
- `user/message`(注入上下文,即非 `user` 来源)→ 按时间顺序在相应位置生成一条 user-role 消息,并原样承载其 `content`;其类型化 source 标明生产方,并携带所有生产方专用数据。
其余所有事件(`turn/*`、`step/*`、`assistant/attempt`、插件所属的 `llm/retry`)均为结构信息,不会投影为消息。token 记账会展开每个 `assistant/message` 或 `assistant/attempt` 的嵌入式 stream,message 顶层 `usage` 存在时仍是已提交 message 的权威。失败的模型请求 attempt 因此可以保留提供方 usage,而无需虚构 assistant message。当前逻辑校验会拒绝没有提供方/模型的 request header 和 assistant 消息,而不会猜测路由;受支持的历史表示会在当前 Session 存在前,由其相邻格式迁移边归一化并校验。
+2 -2
View File
@@ -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/subagent.md
subagent.md: 7a8bafdb5acbd51f19f42ab6906ccf99f1ee715b
subagent.zh.md: b43a9f9fcefcbef0841e2b01a84eb8e4d2846e9c
subagent.md: d642fe1658926b17cb29f280803f12e61228be60
subagent.zh.md: ff153c7861b4ac46bb058efd00a2f5a50181b6f2
+1 -1
View File
@@ -302,7 +302,7 @@ interface SubagentResult {
* are skipped. Without a non-empty message, the output is its accumulated
* assistant text stream, or `[]` when the child produced neither.
*/
readonly output: ContentBlock[]
readonly output: readonly ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
* satisfied. Requesting a schema does not guarantee presence: a provider can
+1 -1
View File
@@ -304,7 +304,7 @@ interface SubagentResult {
* are skipped. Without a non-empty message, the output is its accumulated
* assistant text stream, or `[]` when the child produced neither.
*/
readonly output: ContentBlock[]
readonly output: readonly ContentBlock[]
/**
* The structured result after a requested `outputSchema` was successfully
* satisfied. Requesting a schema does not guarantee presence: a provider can
+4 -4
View File
@@ -70,16 +70,16 @@ export async function toolResultUpdate(
ctx: Context,
event: SessionEvent<'tool/result'>,
): Promise<SessionUpdate> {
const result = event.data.message.content[0]
const message = event.data.message
const content: ToolCallContent[] = []
for (const block of result.content) {
for (const block of message.content) {
const converted = await assistantBlockToAcp(ctx, block)
if (converted !== undefined) content.push({ type: 'content' as const, content: converted })
}
return {
sessionUpdate: 'tool_call_update',
toolCallId: result.toolCallId,
status: result.isError === true ? 'failed' : 'completed',
toolCallId: message.toolCallId,
status: message.isError === true ? 'failed' : 'completed',
content,
}
}
+1 -1
View File
@@ -304,7 +304,7 @@ describe('automation-only ACP bridge', () => {
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = harness.ctx.agents.get(SessionId(created.sessionId))!
agent.session.append('session/title', { title: 'materialized', messageSeqs: [], source: { kind: 'fallback' } })
agent.session.append('session/title', { title: 'materialized', messageSeqs: [], source: { kind: 'user' } })
await harness.client.closeSession({ sessionId: created.sessionId })
const resumed = await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })
+4 -7
View File
@@ -73,14 +73,11 @@ describe('standard ACP update projection', () => {
step: 1,
message: {
id: MessageId('tool-message'),
role: 'user',
role: 'tool',
toolCallId: ToolCallId('call-bad'),
isError: true,
source: { kind: 'tool', callId: ToolCallId('call-bad') },
content: [{
type: 'tool-result',
toolCallId: ToolCallId('call-bad'),
isError: true,
content: [{ type: 'reasoning', text: 'hidden' }],
}],
content: [{ type: 'reasoning', text: 'hidden' }],
},
},
})
@@ -282,12 +282,12 @@ describe('Remote Agent and Session lookup policy', () => {
content: [{ type: 'text', text: 'survives restart' }],
source: { kind: 'user' },
})
const events = [{
const events: SessionEvent[] = [{
type: 'agent/inbox/spliced',
seq: 0,
seq: SessionSeq(0),
time: 1001,
data: { target: 'next-turn', start: 0, inserted: [message] },
}] as SessionEvent[]
}]
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events }),
@@ -194,7 +194,7 @@ const invalidWireEvents: [string, unknown][] = [
...[false, undefined].map((isError): [string, unknown] => [
`contradictory tool error ${String(isError)}`,
{ ...surfaceEvent('tool/result'), data: {
message: { content: [{ type: 'tool-result', content: [], ...(isError === undefined ? {} : { isError }) }] },
message: { role: 'tool', content: [], ...(isError === undefined ? {} : { isError }) },
error: { name: 'Error', code: 'FAILURE' },
} },
]),
@@ -242,11 +242,11 @@ describe('Session Client stream adapters', () => {
{ ...surfaceEvent(), surfaceOp: { op: 'replace', startSeq: 2, endSeq: 0 }, sourceEventSeqs: [2, 0] },
{ ...surfaceEvent('assistant/message'), data: { turn: 1, step: 1, message: {}, stream: [] } },
{ ...surfaceEvent('tool/result'), data: {
message: { content: [{ type: 'tool-result', content: [], isError: true }] },
message: { role: 'tool', content: [], isError: true },
error: { name: 'Error', code: 'FAILURE' }, meta: { extension: ['retained'] },
} },
{ ...surfaceEvent('tool/result'), sourceEventSeqs: [0], data: {
message: { content: [{ type: 'tool-result', content: [], isError: true }] },
message: { role: 'tool', content: [], isError: true },
} },
{ type: 'request/header', seq: 10, time: 10, data: {
reason: 'initial', header: { config: { provider: 'mock', model: 'mock' } },
+4 -4
View File
@@ -301,12 +301,12 @@ export function projectJsonRun(
// Compaction replaces older results in the surface; those are history,
// not this run's output, and would otherwise duplicate a callId.
if (event.surfaceOp !== 'append') return
const block = event.data.message.content[0]
const message = event.data.message
write({
type: 'tool_result',
callId: block.toolCallId,
status: block.isError === true ? 'error' : 'completed',
result: resultText(block.content),
callId: message.toolCallId,
status: message.isError === true ? 'error' : 'completed',
result: resultText(message.content),
})
return
}
@@ -13,7 +13,7 @@ import type {
ResumeAgentOptions,
} from '@deepseek-ai/dsh-agent'
import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model'
import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, type MessageId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
@@ -263,8 +263,8 @@ describe('headless runner', () => {
const test = await bench({
before(session) {
const setupMessage = {
role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: 'setup',
} as UserMessage
role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: brandString<MessageId>('setup'),
} satisfies UserMessage
appendTurn(session, 0, setupMessage, 'pre-task noise', true)
},
async afterPrompt(session, message) {
@@ -607,8 +607,8 @@ describe('headless runner', () => {
})
const session = test.ctx.sessions.create(brandString<SessionId>('session-exact'), { meta: { cwd: process.cwd() } })
const history = {
role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
} as UserMessage
role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: brandString<MessageId>('history'),
} satisfies UserMessage
appendTurn(session, 0, history, 'earlier answer', true)
const before = session.seq
expect(await test.run()).toMatchObject({ code: 0, out: 'resumed answer\n', err: '' })
@@ -789,8 +789,8 @@ describe('headless runner', () => {
const test = await bench({
before(session) {
const history = {
role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
} as UserMessage
role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: brandString<MessageId>('history'),
} satisfies UserMessage
appendTurn(session, 0, history, 'earlier answer', true)
selectPreset(session, 'minimal')
},
@@ -845,8 +845,8 @@ describe('headless runner', () => {
const test = await bench({
before(session) {
const history = {
role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
} as UserMessage
role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: brandString<MessageId>('history'),
} satisfies UserMessage
appendTurn(session, 0, history, 'earlier answer', true)
capturedLength = session.seq
Object.defineProperty(session, 'eventAt', { value: () => undefined })
@@ -59,7 +59,7 @@ function toolResult(
data: {
turn: 1,
step: 1,
message: { content: [{ type: 'tool-result', toolCallId: callId, content, isError: false }] },
message: { role: 'tool', toolCallId: callId, isError: false, content },
},
} as unknown as SessionEvent
}
@@ -169,12 +169,10 @@ describe('--json projection', () => {
turn: 1,
step: 1,
message: {
content: [{
type: 'tool-result',
toolCallId: 'c2',
content: [{ type: 'image' }, { type: 'text' }, { type: 'text', text: 'boom' }],
isError: true,
}],
role: 'tool',
toolCallId: 'c2',
isError: true,
content: [{ type: 'image' }, { type: 'text' }, { type: 'text', text: 'boom' }],
},
},
} as unknown as SessionEvent)
@@ -51,16 +51,16 @@ function rootCall(match: ConversationMatch): RunningToolCall {
function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined {
if (match.event.type !== 'tool/result') return undefined
const result = match.event.data.message.content[0]
const message = match.event.data.message
return {
kind: 'tool-result',
seq: match.event.seq,
time: match.event.time,
callId: String(match.event.data.message.source.callId),
callId: String(message.source.callId),
call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
callTime: previous?.time ?? null,
content: result.content,
isError: result.isError === true,
content: message.content,
isError: message.isError === true,
...match.event.data.error === undefined ? {} : { error: match.event.data.error },
meta: match.event.data.meta,
subCalls: [],
@@ -208,14 +208,11 @@ function assistantMessage(id: string, text: string) {
function toolResult(callId: string, text: string, isError = false) {
return {
id: `result-${callId}`,
role: 'user',
role: 'tool',
toolCallId: callId,
source: { kind: 'tool', callId },
content: [{
type: 'tool-result',
toolCallId: callId,
content: [{ type: 'text', text }],
isError,
}],
content: [{ type: 'text', text }],
isError,
}
}
@@ -7,7 +7,7 @@ function system(seq: number, text: string, replaces?: number): SessionEvent {
seq, time: seq, type: 'system/message',
data: { turn: 1, step: seq, message: { role: 'system', content: [{ type: 'text', text }] } },
surfaceOp: replaces === undefined ? 'append' : { op: 'replace', startSeq: replaces, endSeq: replaces },
} as SessionEvent
} as unknown as SessionEvent
}
function replace(seq: number, start: number, end: number): SessionEvent {
@@ -200,8 +200,7 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
return { ...context.state, calls }
}
if (match.event.type !== 'tool/result') return context.state
const result = match.event.data.message.content[0]
if (result.isError === true) return context.state
if (match.event.data.message.isError === true) return context.state
const callId = String(match.event.data.message.source.callId)
const path = context.state.calls.get(callId)
return path === null || path === undefined
@@ -185,8 +185,12 @@ function result(seq: number, callId: string, isError = false, turn = 1): Session
turn,
step: 1,
message: {
source: { type: 'tool-result', callId },
content: [{ type: 'tool-result', content: [], isError }],
id: `result-${callId}`,
role: 'tool',
toolCallId: callId,
source: { kind: 'tool', callId },
content: [],
isError,
},
})
}
@@ -106,14 +106,11 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
step: 1,
message: {
id: `result-${node.callId}`,
role: 'user',
role: 'tool',
source: { kind: 'tool', callId: node.callId },
content: [{
type: 'tool-result',
toolCallId: node.callId,
content: node.content.map(block => ({ ...block })),
isError: node.isError,
}],
toolCallId: node.callId,
content: node.content.map(block => ({ ...block })),
isError: node.isError,
},
...(node.error === undefined ? {} : { error: node.error }),
...(node.meta === undefined ? {} : { meta: node.meta }),
@@ -48,16 +48,16 @@ function rootResult(
previous?: RunningToolCall,
): ToolResultNode | undefined {
if (match.event.type !== 'tool/result') return undefined
const result = match.event.data.message.content[0]
const message = match.event.data.message
return {
kind: 'tool-result',
seq: match.event.seq,
time: match.event.time,
callId: String(match.event.data.message.source.callId),
callId: String(message.source.callId),
call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
callTime: previous?.time ?? null,
content: result.content,
isError: result.isError === true,
content: message.content,
isError: message.isError === true,
...(match.event.data.error === undefined ? {} : { error: match.event.data.error }),
meta: match.event.data.meta,
subCalls: [],
@@ -456,14 +456,11 @@ describe('Trajectory conversation Definitions', () => {
step: 1,
message: {
id: 'result-root-a',
role: 'user',
role: 'tool',
toolCallId: 'root-a',
source: { kind: 'tool', callId: 'root-a' },
content: [{
type: 'tool-result',
toolCallId: 'root-a',
content: [{ type: 'text', text: 'root failed' }],
isError: true,
}],
content: [{ type: 'text', text: 'root failed' }],
isError: true,
},
error: { name: 'ToolError', code: 'failed' },
meta: { presentation: 'raw' },
@@ -89,14 +89,11 @@ function agent(session: Session, model?: string): Agent {
} as Agent
}
/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */
/** Flatten every text fragment the summarizer received across all messages. */
function summarizedText(input: SummarizationInput): string {
const collect = (blocks: readonly ContentBlock[]): string =>
blocks.map(block =>
block.type === 'text' ? block.text
: block.type === 'tool-result' ? collect(block.content)
: '').join('\n')
return input.messages.map(message => collect(message.content)).join('\n')
return input.messages.flatMap(message => message.content)
.map(block => block.type === 'text' ? block.text : '')
.join('\n')
}
/** A minimal replayed prefix carrying one user message of the given text. */
@@ -774,8 +771,8 @@ describe('pressure measurement and retention', () => {
for (const message of messages) {
for (const block of message.content) {
if (block.type === 'tool-call') calls.add(block.id)
if (block.type === 'tool-result') expect(calls.has(block.toolCallId)).toBe(true)
}
if (message.role === 'tool') expect(calls.has(message.toolCallId)).toBe(true)
}
})
@@ -890,7 +887,7 @@ describe('optional model-free tool-result pruning', () => {
expect(await compactIfNeeded(compact, session)).not.toBeNull()
expect(compact.calls).toHaveLength(1)
const original = session.snapshotEvents().find(event => event.type === 'tool/result')
expect(original?.type === 'tool/result' && original.data.message.content[0].content[0])
expect(original?.type === 'tool/result' && original.data.message.content[0])
.toEqual({ type: 'text', text: 'X'.repeat(3_000) })
expect(session.snapshotEvents().filter(event =>
event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
@@ -1525,20 +1522,16 @@ describe('default one-shot summarizer', () => {
.rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
})
it('rejects image summary output nested in a tool result', async () => {
it('rejects image summary output', async () => {
const { compact } = await summarizerHarness([{
type: 'tool-result',
toolCallId: ToolCallId('summary-tool'),
content: [{
type: 'image',
attachment: {
attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
},
}],
type: 'image',
attachment: {
attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`),
mediaType: 'image/png',
bytes: 1,
width: 1,
height: 1,
},
}])
await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL)))
.rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' })
@@ -34,8 +34,6 @@ export function offloadOldestImages(session: Session, sourceEventSeqs: readonly
count -= 1
}
imageIndex += 1
} else if (block.type === 'tool-result') {
visit(block.content)
}
}
}
@@ -24,9 +24,6 @@ export function offloadMessageImages(message: Message, indexes: readonly number[
selected += 1
}
imageIndex += 1
} else if (block.type === 'tool-result') {
const content = visit(block.content)
if (content !== block.content) projected = { ...block, content }
}
if (projected !== block) next ??= blocks.slice(0, index)
next?.push(projected)
@@ -87,13 +87,11 @@ function image(name: string): Extract<ContentBlock, { type: 'image' }> {
function offloadedNames(options: GenerateOptions): string[] {
const names: string[] = []
const visit = (blocks: readonly ContentBlock[]): void => {
for (const block of blocks) {
for (const message of options.messages) {
for (const block of message.content) {
if (block.type === 'image' && block.offloaded === true) names.push(block.attachment.name ?? '')
if (block.type === 'tool-result') visit(block.content)
}
}
for (const message of options.messages) visit(message.content)
return names
}
@@ -235,7 +233,7 @@ describe('compaction-image-offload', () => {
})
agent.followup(createUserMessage({
content: [image('a'), { type: 'tool-result', toolCallId: ToolCallId('shot'), content: [image('b')] }, image('c')],
content: [image('a'), image('b'), image('c')],
source: { kind: 'user' },
}))
await agent.whenIdle()
@@ -297,32 +295,54 @@ describe('compaction-image-offload', () => {
]])
})
it('offloads a nested tool-result occurrence and leaves later images untouched', async () => {
it('offloads a tool-result image and leaves later images untouched', async () => {
const adapter = new ScriptedAdapter([offloadRequired(1), textResponse('sent')])
const ctx = await harness(adapter)
const agent = await ctx.agentLoop.create(SessionId('offload-tool-result'), { provider: 'mock', model: 'mock' })
const emptyCallId = ToolCallId('empty')
const callId = ToolCallId('shot')
const innerCallId = ToolCallId('inner')
agent.session.append('turn/start', { turn: 0 })
agent.session.append('assistant/message', {
turn: 0,
step: 1,
message: createAssistantMessage({
content: [{ type: 'tool-call', id: callId, name: 'read_image', arguments: '{}' }],
content: [
{ type: 'tool-call', id: emptyCallId, name: 'read_image', arguments: '{}' },
{ type: 'tool-call', id: callId, name: 'read_image', arguments: '{}' },
{ type: 'tool-call', id: innerCallId, name: 'read_image', arguments: '{}' },
],
source: { provider: 'mock', model: 'mock' },
}),
stream: [],
}, { surfaceOp: 'append' })
agent.session.append('tool/call', { turn: 0, step: 1, callId: emptyCallId, name: 'read_image', arguments: '{}' })
agent.session.append('tool/result', {
turn: 0,
step: 1,
message: createToolResultMessage({
callId: emptyCallId,
content: [{ type: 'text', text: 'no image' }],
isError: false,
}),
}, { surfaceOp: 'append' })
agent.session.append('tool/call', { turn: 0, step: 1, callId, name: 'read_image', arguments: '{}' })
const result = agent.session.append('tool/result', {
turn: 0,
step: 1,
message: createToolResultMessage({
callId,
content: [
{ type: 'tool-result', toolCallId: ToolCallId('empty'), content: [{ type: 'text', text: 'no image' }] },
image('first'),
{ type: 'tool-result', toolCallId: ToolCallId('inner'), content: [image('second')] },
],
content: [image('first')],
isError: false,
}),
}, { surfaceOp: 'append' })
agent.session.append('tool/call', { turn: 0, step: 1, callId: innerCallId, name: 'read_image', arguments: '{}' })
agent.session.append('tool/result', {
turn: 0,
step: 1,
message: createToolResultMessage({
callId: innerCallId,
content: [image('second')],
isError: false,
}),
}, { surfaceOp: 'append' })
@@ -21,13 +21,11 @@ function input(session: Session, content: ContentBlock[] = [image, image]) {
function marked(session: Session): boolean[] {
const result: boolean[] = []
const visit = (content: readonly ContentBlock[]): void => {
for (const block of content) {
for (const message of session.deriveMessages()) {
for (const block of message.content) {
if (block.type === 'image') result.push(block.offloaded === true)
if (block.type === 'tool-result') visit(block.content)
}
}
for (const message of session.deriveMessages()) visit(message.content)
return result
}
@@ -36,8 +34,8 @@ describe('durable image selections', () => {
const session = createSession(SessionId('images'))
const source = input(session, [
{ type: 'text', text: 'before' }, image,
{ type: 'tool-result', toolCallId: ToolCallId('nested'), content: [image, { type: 'text', text: 'after' }] },
{ type: 'tool-result', toolCallId: ToolCallId('text'), content: [{ type: 'text', text: 'unchanged' }] },
image, { type: 'text', text: 'after' },
{ type: 'text', text: 'unchanged' },
image,
])
const before = session.deriveMessages()
@@ -146,17 +146,13 @@ export class ToolResultPruner extends Service {
let charsRemoved = 0
for (const { seq, event } of candidates) {
const original = session.deriveEventMessage(event) as ToolResultMessage
const result = original.content[0]
const content = this.pruneContent(result.content)
const content = this.pruneContent(original.content)
if (content === null) continue
const charsBefore = this.measureContent(result.content)
const charsBefore = this.measureContent(original.content)
const charsAfter = this.measureContent(content)
const message = freezeMessage<ToolResultMessage>({
...original,
content: [{
...result,
content,
}] as [typeof result],
content,
})
// Shadow-price protocol: the metering event and its replacement are
// appended synchronously adjacent, so pure consumers subtract the
@@ -212,10 +212,7 @@ describe('ToolResultPruner session transaction', () => {
type: 'tool/result',
data: {
message: {
content: [{
type: 'tool-result',
content: [{ type: 'text', text: 'x'.repeat(100) }],
}],
content: [{ type: 'text', text: 'x'.repeat(100) }],
},
},
})
@@ -227,7 +224,9 @@ describe('ToolResultPruner session transaction', () => {
isError: true,
message: {
source: { kind: 'tool', callId: ToolCallId('one') },
content: [{ type: 'tool-result', isError: true }],
role: 'tool',
toolCallId: ToolCallId('one'),
isError: true,
},
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { diff: ['a', 'b'] },
@@ -251,7 +251,7 @@ function stubToolExecution(
}
}
function blocksText(blocks: { type: string; text?: string }[] | undefined): string {
function blocksText(blocks: readonly { type: string; text?: string }[] | undefined): string {
return blocks?.map(block => block.type === 'text' ? block.text ?? '' : '').join('\n') ?? ''
}
@@ -441,17 +441,17 @@ describe('Agent.cancel()', () => {
expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
message: {
role: 'tool',
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', isError: true }],
content: [{ type: 'text' }],
isError: true,
},
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
send(agent, 'continue safely')
await waitForIdle(ctx, agent)
const replayedResult = adapter.requests[1]!.messages
.flatMap(message => message.content)
.find(block => block.type === 'tool-result')
const replayedResult = adapter.requests[1]!.messages.find(message => message.role === 'tool')
expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
expect(reasons).toEqual([
{ kind: 'aborted', reason: { kind: 'user' } },
@@ -659,12 +659,14 @@ function expectPairedTools(messages: readonly Message[]): void {
const pending = new Set<ToolCallId>()
for (const message of messages) {
if (pending.size > 0) expect(message.source.kind).toBe('tool')
if (message.role === 'tool') {
expect(pending.delete(message.toolCallId)).toBe(true)
continue
}
for (const block of message.content) {
if (block.type === 'tool-call') {
expect(pending.has(block.id)).toBe(false)
pending.add(block.id)
} else if (block.type === 'tool-result') {
expect(pending.delete(block.toolCallId)).toBe(true)
}
}
}
@@ -716,7 +718,7 @@ describe('forked tool history reaches the next model request', () => {
expect(closers.filter(event => event.type === 'step/end')).toHaveLength(type === 'step/end' ? 0 : 1)
expect(closers.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'forked' } } })
for (const result of results) {
const text = result.data.message.content[0].content[0]
const text = result.data.message.content[0]
if (text?.type !== 'text') throw new Error('fork result has no text')
expect(text.text).toContain('The parent session may have')
}
@@ -1287,12 +1289,10 @@ describe('tool result call identity', () => {
// And deriveMessages pairs the tool-result with the assistant tool-call:
// the derived tool-result block's toolCallId equals the original call.id.
const messages = agent.session.deriveMessages()
const toolResultBlock = messages
.flatMap(m => m.content)
.find(b => b.type === 'tool-result')
expect(toolResultBlock?.type).toBe('tool-result')
if (toolResultBlock?.type === 'tool-result') {
expect(toolResultBlock.toolCallId).toBe(ToolCallId('c1'))
const toolResultMessage = messages.find(m => m.role === 'tool')
expect(toolResultMessage?.role).toBe('tool')
if (toolResultMessage?.role === 'tool') {
expect(toolResultMessage.toolCallId).toBe(ToolCallId('c1'))
}
})
})
@@ -237,7 +237,7 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
await waitForIdle(ctx, agent)
const toolResult = agent.session.snapshotEvents().find(e => e.type === 'tool/result')
expect(toolResult?.type === 'tool/result' && toolResult.data.message.content[0].isError).toBe(true)
expect(toolResult?.type === 'tool/result' && toolResult.data.message.isError).toBe(true)
expect(toolResult?.type === 'tool/result' && toolResult.data.error)
.toEqual({ name: 'HarnessError', code: 'BOOM' })
})
@@ -709,9 +709,9 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
expect(ran).toBe(false)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.message.content[0].isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.message.isError).toBe(true)
expect(result?.type === 'tool/result'
&& result.data.message.content[0].content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
&& result.data.message.content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
})
})
@@ -773,7 +773,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
// prompt allowed → user-sourced user/message recorded
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'user')).toBe(true)
// tool ran (echo allowed) and post-execute attached "audited" context
expect(log.some(e => e.type === 'tool/result' && !e.data.message.content[0].isError)).toBe(true)
expect(log.some(e => e.type === 'tool/result' && !e.data.message.isError)).toBe(true)
expect(log.some(e => e.type === 'user/message' && e.data.source.kind === 'plugin'
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
// NO hook/* events — a native plugin needs none
+5 -7
View File
@@ -496,12 +496,11 @@ describe('agent loop', () => {
// the second request's derived history contains the tool result
const secondMessages = adapter.requests[1]!.messages
const toolResultMessage = secondMessages.find(m =>
m.content.some(b => b.type === 'tool-result'))
const toolResultMessage = secondMessages.find(m => m.role === 'tool')
expect(toolResultMessage).toBeDefined()
const block = toolResultMessage!.content.find(b => b.type === 'tool-result')!
expect(block).toMatchObject({ toolCallId: 'c1', isError: false })
expect((block).content).toEqual([{ type: 'text', text: 'echo: ping' }])
const message = toolResultMessage!
expect(message).toMatchObject({ toolCallId: 'c1', isError: false })
expect(message.content).toEqual([{ type: 'text', text: 'echo: ping' }])
// session log records call + result
const types = agent.session.snapshotEvents().map(e => e.type)
@@ -1045,8 +1044,7 @@ describe('agent loop', () => {
])
const secondRequest = adapter.requests[1]!.messages
const resultIndex = secondRequest.findIndex(message =>
message.content.some(block => block.type === 'tool-result'))
const resultIndex = secondRequest.findIndex(message => message.role === 'tool')
const contextIndexes = secondRequest.flatMap((message, index) =>
message.content.some(block => block.type === 'text'
&& (block.text.includes('mid-turn notice') || block.text.includes('second notice')))
@@ -74,7 +74,7 @@ function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: ()
function userMessageTexts(agent: Agent): string[] {
return agent.session.snapshotEvents()
.filter(e => e.type === 'user/message')
.map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
.map(e => e.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join(''))
}
function turnNumbers(agent: Agent): number[] {
@@ -258,7 +258,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
await waitForIdle(ctx, agent)
const messages = agent.session.deriveMessages()
const toolResults = messages.flatMap(m => m.content.filter(b => b.type === 'tool-result'))
const toolResults = messages.filter(m => m.role === 'tool')
expect(toolResults.map(b => b.toolCallId)).toEqual([ToolCallId('c1'), ToolCallId('c2')])
})
})
@@ -463,11 +463,11 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
expect(post).toEqual(['c1', 'c2'])
const results = events(agent).filter(e => e.type === 'tool/result')
expect(results.map(e => e.data.message.source.callId)).toEqual([ToolCallId('c1'), ToolCallId('c2'), ToolCallId('c3')])
expect((results[1]!.data.message.content[0].content[0] as { text: string }).text).toContain('blocked by policy')
expect((results[1]!.data.message.content[0] as { text: string }).text).toContain('blocked by policy')
expect(results[1]!.data.error).toEqual({
name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED', reason: 'exact scope was not authorized',
})
expect((results[2]!.data.message.content[0].content[0] as { text: string }).text).toContain('pre exploded')
expect((results[2]!.data.message.content[0] as { text: string }).text).toContain('pre exploded')
})
})
@@ -495,7 +495,7 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([ToolCallId('c1'), ToolCallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({
callId: e.data.message.source.callId,
isError: e.data.message.content[0].isError,
isError: e.data.message.isError,
error: e.data.error,
}))).toEqual([
{ callId: ToolCallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
@@ -527,7 +527,7 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([ToolCallId('c1'), ToolCallId('c2')])
expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({
callId: e.data.message.source.callId,
isError: e.data.message.content[0].isError,
isError: e.data.message.isError,
error: e.data.error,
}))).toEqual([
{ callId: ToolCallId('c1'), isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH } },
@@ -565,7 +565,7 @@ describe('tool-call scheduler: abort handling', () => {
.toEqual([ToolCallId('c1'), ToolCallId('c2'), ToolCallId('c3'), ToolCallId('c4')])
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => ({
callId: e.data.message.source.callId,
isError: e.data.message.content[0].isError,
isError: e.data.message.isError,
error: e.data.error,
})))
.toEqual([
@@ -638,7 +638,8 @@ describe('tool-call scheduler: abort handling', () => {
.toMatchObject({
message: {
source: { kind: 'tool', callId: ToolCallId('c3') },
content: [{ isError: true }],
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
},
error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
})
+2 -2
View File
@@ -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/core/session/README.md
README.md: b853dfd6e0d4617cad7f1a635f3b69a19e24e162
README.zh.md: 6347aa5f346f148d0f077d7c8d8a263b9c7bc8b1
README.md: 7be5f00e55dd71477c81833ad391569fac4e132d
README.zh.md: 3f82a30123d516f8b8e6e6ac5660ccee4789edee
+1 -1
View File
@@ -51,7 +51,7 @@ Surface events (`system/message`, `user/message`, `assistant/message`, `tool/res
Plugins declare content-changing events with `@messageProjection` and register a pure definition through `ctx.sessions.registerMessageProjection()`. Session calls the definition before accepting an event and caches its immutable message updates. Missing definitions reject append and restore; unloading a used definition also blocks cached reads. Detached constructors and `foldSurface(events, projections)` require explicit definitions. Reconstructors pass the fold's `projectedMessages` to `deriveEventMessage()`; live instance methods apply the same projections automatically. [Plugin-owned message projections](../../../.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.md) defines ownership and offline assembly.
Append, seed/restore, and event adoption/snapshot reject any `header.system` and exactly empty optional request-header fields (`tools: []`, `adapterDefaults: {}`) instead of normalizing input. Tool-result `data.error` is allowed only when `message.content[0].isError === true`; failure identity remains optional. Rejected appends do not change the log, derived state, or event feed. Adoption validates event-local metadata but not referenced history or replacement membership.
Append, seed/restore, and event adoption/snapshot reject any `header.system` and exactly empty optional request-header fields (`tools: []`, `adapterDefaults: {}`) instead of normalizing input. Tool-result `data.error` is allowed only when the first-class message has `isError === true`; failure identity remains optional. Rejected appends do not change the log, derived state, or event feed. Adoption validates event-local metadata but not referenced history or replacement membership.
`system/message` holds the rendered system prompt: the first one is surface node 0, the prepared call capability governs admission, with a non-empty rendering consolidated at the first system node on an incapable route or appended after cached history inside a continuing `in-history` series; empty system nodes project to no message, so clearing the prompt requires logged empty replacements of all active system nodes, not just the latest; the surface fold rejects a replacement covering node 0 while it is a `system/message` unless the replacing event is itself a `system/message` over exactly that node, while later system nodes carry no protection and a compaction range may shadow them ([decision](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md)).
+1 -1
View File
@@ -51,7 +51,7 @@ session.deriveMessages() // the derived model history
插件用 `@messageProjection` 声明修改内容的事件,并通过 `ctx.sessions.registerMessageProjection()` 注册纯处理器。Session 在接受事件前调用处理器,并缓存其不可变消息更新。缺少处理器时拒绝追加和恢复,卸载已经使用的处理器后也会拒绝读取缓存。独立构造函数和 `foldSurface(events, projections)` 必须显式接收处理器。重建函数将折叠结果的 `projectedMessages` 传给 `deriveEventMessage()`,实时实例方法自动应用相同的投影。[插件拥有消息投影](../../../.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md)说明职责划分和离线装配。
追加、seed/restore 与事件 adoption/snapshot 会拒绝任何 `header.system` 及恰好为空的可选请求头字段(`tools: []`、`adapterDefaults: {}`),而不规范化输入。工具结果的 `data.error` 仅在 `message.content[0].isError === true` 时允许存在;失败标识仍是可选的。被拒绝的追加不会改变日志、派生状态或事件流。Adoption 校验事件局部元数据,但不校验所引用的历史或替换端点是否属于 surface。
追加、seed/restore 与事件 adoption/snapshot 会拒绝任何 `header.system` 及恰好为空的可选请求头字段(`tools: []`、`adapterDefaults: {}`),而不规范化输入。工具结果的 `data.error` 仅在一等消息的 `isError === true` 时允许存在;失败标识仍是可选的。被拒绝的追加不会改变日志、派生状态或事件流。Adoption 校验事件局部元数据,但不校验所引用的历史或替换端点是否属于 surface。
`system/message` 承载渲染后的系统提示词:第一条是 surface 第 0 号节点,准入依据已准备调用的能力,不具备能力的路由将非空渲染文本归并到首个系统节点,延续中的 `in-history` 序列则在缓存历史之后追加;空系统节点不投影为消息,因此清除提示词必须为所有生效的系统节点记录空内容替换,而非仅替换最新节点;当第 0 号节点是 `system/message` 时,surface 折叠拒绝覆盖它的替换,除非替换事件本身是恰好覆盖该节点的 `system/message`,而后续系统节点不受保护,压缩范围可以遮蔽它们([决策](../../../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md))。
+2 -9
View File
@@ -324,7 +324,7 @@ const MESSAGE_ROLE_BY_TYPE: Record<SurfaceEventType, Message['role']> = {
'system/message': 'system',
'user/message': 'user',
'assistant/message': 'assistant',
'tool/result': 'user',
'tool/result': 'tool',
}
/** Validate only the event-specific invariants needed to safely replay a message. */
@@ -375,14 +375,7 @@ function assertMessageEventShape(event: Record<string, unknown>, subject: string
|| sourceRecord['callId'] === '') {
throw new Error(`${subject} message must have tool source`)
}
const content = messageRecord['content'] as unknown[]
const block = content[0]
if (content.length !== 1 || typeof block !== 'object' || block === null
|| (block as Record<string, unknown>)['type'] !== 'tool-result'
|| !Array.isArray((block as Record<string, unknown>)['content'])) {
throw new Error(`${subject} message must contain one tool-result block`)
}
if ((block as Record<string, unknown>)['toolCallId'] !== sourceRecord['callId']) {
if (messageRecord['toolCallId'] !== sourceRecord['callId']) {
throw new Error(`${subject} message has mismatched tool call ids`)
}
}
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff. Show more