feat(session): add developer changes using historical tool schemas

Session V4 reserves developer-role messages for incremental model-visible
session changes. The role, tool addition/removal blocks, and deferred-loading
marker need a durable representation before independent provider and UI
integrations can emit or consume them. Known developer events must match an
open turn and step; unsupported providers and UI consumers fail explicitly.

Request headers already capture full tool definitions. Persisting another
ToolSchema in each addition would duplicate Session information and create
two definitions that could drift. Additions therefore store toolName, and
the containing developer event uses headerSeq to select one earlier known
request/header for all its additions. A same-name replacement keeps its
historical definition identity through restart, fork and surface changes.
Removals require no definition copy, and ordinary developer messages omit
headerSeq. Generic sourceEventSeqs remains independent derivation metadata.

The rejected inline tool field is optional never with @persistenceReserved.
The schema extractor retains that uninhabited slot, so permitting a future
value requires a format bump instead of being mistaken for compatible
optional metadata. Unrelated optional fields remain additive and readable.

Native and Session admission reject missing, forward, non-header, unknown,
ambiguous and incomplete referenced definitions, and reject the retired
inline tool field. Validate complete schema fields only for referenced
definitions, preserving unrelated header behavior and extra JSON metadata.
Unknown ignorable records remain opaque. Empty developer nodes retain their
surface position without producing a message or shadowing the system head.
No automatic emitter, provider activation, UI implementation, or speculative
public resolver is added.

Keep the core-owned developer source binding beside the introduced role,
including the request-only type rejection and attribution round trips. Update
consumer guards, SDK/snapshot handling, bilingual owner documentation, and
the same unreleased V4 acknowledgement. Keep earlier tool/source migration
work and its documentation in the parent. Preserve the existing recorded
Cordis inspection generation from the original developer change.

Integration keeps the final source layer as the sole parent and preserves
master's Messages input filtering, empty-user omission, empty tool results,
and system-update placement. Developer/deferred guards still run before
conversion. Native V4 admission, header references, reservation declarations,
and generic tooling match the validated marker candidate. Parent-owned
fixture and generator fixes remain in the parent.

Earlier feature validation passed full build, full lint, 41 doc-sync gates,
and 16 hygiene gates. The unchanged native implementation retains that evidence.
The focused Session/V4/request-input run passes 884 tests across 38 files with
100% statement, branch, function and line coverage for the V3-to-V4 package
and Session surface. Consumer tests pass 482 with one existing skip; tool,
message and optional-field tests pass 146. Cordis inspection and compaction
replays pass in both source and built modes (two cases per mode). Ten invalid
referenced-definition/unknown-header controls failed before their scoped
guards and pass after them.

Reservation validation: the actual generated developer root records tool as
optional primitive never. Widening it requires a bump, while traceId remains
additive and preserved; all 85 focused reader/classifier tests pass.

Final-parent validation: 205 tests across eight provider, type, catalog,
Session and native-format files pass. The preserved parent fixture verifies
Messages input-history replay; runtime/declaration and parent-owned-file
comparisons show no loss during the final rebase.

Co-authored-by: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
This commit is contained in:
Yixiang TuandTianyi Cui authored and Tianyi Cui committed 2026-09-18 19:08:11 +08:00
1 parent b88afa8a72
commit e0bd7e1960
101 files changed
+65173 -45818

No files matched your search

@@ -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-developer-session-changes.md
2026-09-17-developer-session-changes.md: da4bb4173977cb2ac12a308381b160c0b31d011e
2026-09-17-developer-session-changes.zh.md: 98af3d82ad5631ff13d2c0b0595510057cba8cae
@@ -0,0 +1,35 @@
# Agent Note: Developer messages for incremental session changes
Status: implemented
English | [中文](2026-09-17-developer-session-changes.zh.md)
## Problem
Dynamic tool changes need durable, ordered records before providers can preserve the cached conversation prefix while changing available tools. Encoding these changes as free-form reminders would lose their structured tool identity and couple session state to prompt wording.
## Decision
`DeveloperMessage` records admitted model-visible session changes in conversation order. Additions store only `toolName`; the containing event's `headerSeq` selects an earlier full `request/header` with exactly one schema for each added name. One event binds all its additions to one header revision. Additions replace the active same-name definition; removals identify the name without duplicating its schema. The header reference is absent when the message contains no additions. Same-name replacement, restart, fork, and surface replacement retain historical definition identity without consulting the current registry or latest request header. Intermediate registry changes that no admitted request exposes do not require developer records.
`developer/message` carries the message and its admitting turn/step coordinates. Native readers and Session admission reject missing, forward, unknown, non-header, and ambiguous schema references, incomplete referenced definitions, and reject retired inline definitions while preserving unrelated JSON fields. `sourceEventSeqs` remains independent derivation metadata. A replacement retains its selected header and cites the shadowed surface nodes; combining additions from different header revisions requires separate events. Forks retain the referenced prefix, and compaction does not delete header events. Future cardinality-changing migrations must remap `headerSeq` as a same-artifact event reference. Empty developer content retains its surface position without adding a model message or shadowing the protected system head. The tools package owns the `tool-registry` source kind under the [producer-owned source rules](2026-09-09-producer-owned-message-sources.md).
`ToolAdditionBlock.tool` is declared as optional `never` with `@persistenceReserved`. The schema history therefore records the rejected field: permitting any value later changes an existing value type and requires a format bump, while unrelated optional metadata remains additive.
`ToolSchema.deferLoading` requests deferred definition loading independently of tool-addition history. Tool construction, registry projection, and prompt assembly preserve the marker. Dynamic addition and definition loading are separate facts; a predeclared deferred definition need not acquire a fabricated addition record.
The V4 codec confines developer roles to the `developer/message` slot, including rejection from inbox and title-request arrays. Native validation checks required developer fields, historical header bindings, and developer-only tool-change blocks while preserving additional JSON properties. Optional metadata additions therefore remain readable under the persistence checker's optional-property rule; additional fields cannot weaken required-field, role, or open-step checks. An ignorable marker does not establish reader support: physical decoding preserves developer payloads until vocabulary-aware admission, while writers and native readers reject malformed known data. Developer admission leaves malformed ordinary message slots to decoder recovery. Installed Session adoption validates surface relationships without converting developer records into released-V3 user records. The [released migration rules](2026-08-31-released-session-format-migrations.md) continue to protect committed predecessor generations.
The producer requirement has one scoped exception: these types reserve the persisted representation needed by issue #4146 before Session V4 merges. Waiting for provider and UI implementations would couple the format release to those independent integrations. The reservation permits storage and validation only; no shipped profile emits developer records, and unsupported consumers throw. Production emission requires provider, UI, and compaction support together.
## Alternatives considered
**Use text reminders as developer content.** The format represents structured session changes; model instructions belong in system messages. Future developer content is unspecified rather than constrained to reminders.
**Duplicate definitions in each addition.** Full request headers already preserve the exact schemas. A historical reference avoids a second persisted copy while retaining same-name replacement identity. Binding by the latest header or registry instead would reinterpret older additions.
**Treat deferred loading as proof of a tool-addition record.** That would prevent predeclared deferred tools. The schema flag describes loading, while the block records a dynamic addition.
## Consequences
Session V4 persists the tool-change representation for issue #4146. Automatic emission, provider serialization, deferred loading, and UI presentation are intentionally deferred so this type-and-persistence change does not block the V4 merge. Both DeepSeek protocols and pi-ai throw for developer history and deferred-loading requests; Chat and Trajectory throw for developer events. Shipped model-output and SDK snapshot scenarios contain no developer records.
@@ -0,0 +1,35 @@
# Agent Note:使用 developer 消息记录增量 Session 变更
Status: implemented
[English](2026-09-17-developer-session-changes.md) | 中文
## Problem
动态工具变更需要持久且有序的记录,提供方才能在改变可用工具时保留对话的缓存前缀。将这些变更编码为自由文本提醒会丢失结构化的工具身份,并将 Session 状态与提示词措辞耦合。
## Decision
`DeveloperMessage` 按对话顺序记录已接纳且模型可见的 Session 变更。添加块仅存储 `toolName`;所在事件的 `headerSeq` 选择更早的完整 `request/header`,其中每个添加名称必须恰好对应一个模式。同一事件的所有添加绑定到同一个请求头版本。添加替换当前同名定义;移除仅记录名称,不重复模式。没有添加块的消息不携带请求头引用。同名替换、重启、fork 和 surface 替换保留历史定义身份,不查询当前注册表或最新请求头。没有被任何已接纳请求暴露的中间注册表变更无需 developer 记录。
`developer/message` 携带消息及其接纳 turn/step 坐标。原生读取方和 Session 接纳逻辑拒绝缺失、前向、未知、非请求头及歧义模式引用、不完整的被引用定义,并拒绝已退役的内嵌定义,同时保留无关 JSON 字段。`sourceEventSeqs` 保持为独立的派生元数据。替换保留选定的请求头,并引用被覆盖的 surface 节点;不同请求头版本的添加必须分属不同事件。fork 保留所引用的前缀,压缩不删除请求头事件。未来改变事件数量的迁移必须将 `headerSeq` 作为同一文件内的事件引用重映射。空 developer 内容保留其 surface 位置,不添加模型消息,也不能覆盖受保护的 system 头节点。tools 包按照[生产者自有来源规则](2026-09-09-producer-owned-message-sources.zh.md)拥有 `tool-registry` 来源种类。
`ToolAdditionBlock.tool` 声明为可选的 `never`,并标注 `@persistenceReserved`。模式历史因此记录这个被拒绝的字段:后续允许任何值都会改变现有值类型并要求格式递增,无关的可选元数据仍属于可兼容添加。
`ToolSchema.deferLoading` 请求延迟加载工具定义,与工具添加历史相互独立。工具构造、注册表投影与提示词组装保留该标记。动态添加与定义加载是不同事实;预先声明的延迟加载定义无需虚构添加记录。
V4 编解码器将 developer 角色限定在 `developer/message` 字段内,并拒绝 inbox 与标题请求数组中的该角色。原生验证检查 developer 必需字段、历史请求头绑定及工具变更块仅限 developer 的约束,同时保留附加 JSON 属性。因此,可选元数据添加符合持久化检查器的可选属性规则,仍可读取;附加字段不能削弱必需字段、角色或打开 step 的检查。可忽略标记不代表读取方支持该事件:物理解码保留 developer 载荷,直到按词表执行接纳;写入方和原生读取方仍拒绝格式错误的已知数据。developer 接纳逻辑将格式错误的普通消息字段留给解码器恢复。已安装的 Session 接纳逻辑验证 surface 关系,不把 developer 记录转换为已发布 V3 的 user 记录。[已发布格式迁移规则](2026-08-31-released-session-format-migrations.zh.md)继续保护已提交的前代文件。
产生方要求有一个限定例外:这些类型在 Session V4 合并前为 issue #4146 保留所需的持久化表示。等待提供方与 UI 实现会将格式发布与这些独立集成耦合。该预留仅允许存储和验证;已提供的 profile 不发出 developer 记录,不支持的消费者会抛出错误。生产环境发出这些记录要求提供方、UI 与压缩支持一起落地。
## Alternatives considered
**将文本提醒作为 developer 内容。** 此格式表示结构化 Session 变更;模型指令属于 system 消息。未来的 developer 内容尚未指定,不将其限制为提醒。
**在每次添加中重复定义。** 完整请求头已经保留精确模式。历史引用避免第二份持久化副本,同时保留同名替换的身份。若绑定到最新请求头或注册表,则会重新解释较早的添加。
**将延迟加载视为存在工具添加记录的证据。** 这会阻止预先声明的延迟工具。模式标记描述加载方式,内容块记录动态添加。
## Consequences
Session V4 为 issue #4146 持久化工具变更表示。自动发出事件、提供方序列化、延迟加载与 UI 展示有意留待后续实现,使此类型与持久化变更不阻塞 V4 合并。DeepSeek 两种协议与 pi-ai 对 developer 历史及延迟加载请求抛出错误;Chat 与 Trajectory 对 developer 事件抛出错误。已提供的模型输出与 SDK 快照场景不包含 developer 记录。
+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/config-catalog.md
config-catalog.md: d617ed628bf1ca0235bc68660418c860ff952dc8
config-catalog.zh.md: 067d5a23b699087ddaeedc96db674f807f509fbe
config-catalog.md: 0a07065790a76079970618e3d9cbbf2579bb2339
config-catalog.zh.md: 138f747973725859151145c3544b69dce444ddad
+1 -1
View File
@@ -3496,7 +3496,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'ptc' | 'both'
```
Source: [`packages/core/tools/src/index.ts:656`](../packages/core/tools/src/index.ts)
Source: [`packages/core/tools/src/index.ts:663`](../packages/core/tools/src/index.ts)
<a id="deepseek-aidsh-typert-loader"></a>
+1 -1
View File
@@ -3498,7 +3498,7 @@ export interface Config {
export type ToolPresentationMode = 'native' | 'ptc' | 'both'
```
来源:[`packages/core/tools/src/index.ts:656`](../packages/core/tools/src/index.ts)
来源:[`packages/core/tools/src/index.ts:663`](../packages/core/tools/src/index.ts)
<a id="deepseek-aidsh-typert-loader"></a>
+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/event-producer-consumer.md
event-producer-consumer.md: de9a96089397614e32487c29f34302d77f1f07a9
event-producer-consumer.zh.md: bf668ddc86a610f05cf9f2c9a04b892a37dbcd4e
event-producer-consumer.md: c1b373d4d13d6471303488d87105b546e55d720b
event-producer-consumer.zh.md: ec8932de71a2867737ebb86ccc60dc92a00bc521
+6 -6
View File
@@ -68,12 +68,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:164`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:153`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
+6 -6
View File
@@ -70,12 +70,12 @@
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:164`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:169`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:164`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:153`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
+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: 94bac3e3478ce021d43a0a000e8c5b59697d95a3
persistence-catalog.zh.md: 608980a2eff2a446e95c94011b3615a247f6e66e
persistence-catalog.md: 43b4f86125847625026bd5619216ccdc7be926c1
persistence-catalog.zh.md: c655d97086fa60630aa5dae1bf3f93be595f179b
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: eea41088f14d7955d461e27b08be2522d2eadae9
2026-09-16-session-format-v4.zh.md: 222c062e4ca13fc000449a4440bb378ce43fa5c6
2026-09-16-session-format-v4.md: 467a2230041f3113c118ee5b7ac9191e207d8372
2026-09-16-session-format-v4.zh.md: 983cf7fdd3abab51707de441fd20851dea34c567
@@ -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, records first-class tool-role results and producer-owned sources, 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 producer-owned sources, and adds the forked variant to turn/end.reason. Adds developer-role Session changes with name-only tool additions bound to historical request headers, tool removals, and deferred-loading schema markers.
## Table of Contents
@@ -32,43 +32,47 @@ changes:
decision: version-bump
- root: "event:agent/inbox/spliced"
previous: "2026-09-14-image-offload"
after: "3aa732b3400e924132366555c88618f62232b41c1e56b86beee05fd7edc24367"
after: "1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c"
decision: version-bump
- root: "event:assistant/attempt"
previous: "2026-09-14-image-offload"
after: "2f1565d92801f4334f8dc34fbb980890eef621e81a7c90ed0fd8d0a6db2ec3b2"
after: "15d5dfdd822aa35e115afd74a8982825a493880457774e6850bc1520b50875e4"
decision: version-bump
- root: "event:assistant/message"
previous: "2026-09-14-image-offload"
after: "f219a9f66fcc92ffec27bbd96fa21ffc2cffbaf2ad1de05757ec37205b1a0b58"
after: "1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625"
decision: version-bump
- root: "event:compaction/summary"
previous: "2026-09-14-image-offload"
after: "86c1ee9c8ab6c6e160d4ccc00b3d9ad5a6c687938b552d55eec528279baa1559"
after: "e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602"
decision: version-bump
- root: "event:developer/message"
previous: null
after: "eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80"
decision: version-bump
- root: "event:request/header"
previous: "2026-09-11-initial"
after: "91fb2b19bd1bfda3a1d30b71f39445595e1d4c120e8fe3553f96e25a3c3d1c7d"
after: "4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41"
decision: version-bump
- root: "event:session/title-llm-request"
previous: "2026-09-14-image-offload"
after: "4eeb9bb8f9c35f59b3020a07113f256c363a504983aecbe33e57fd1ab4f03b19"
after: "fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d"
decision: version-bump
- root: "event:system/message"
previous: "2026-09-14-image-offload"
after: "54a8682615a7ffc3e1e3c41ab003ae61082ae2792c57407f5bd8bb38fded3f0e"
after: "69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1"
decision: version-bump
- root: "event:team/message/queued"
previous: "2026-09-14-image-offload"
after: "9a25c2e889855516a531dc906905ec1d88157f7c2651aed27ac4f754bb128240"
after: "21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d"
decision: version-bump
- root: "event:tool/ptc-dispatch"
previous: "2026-09-14-image-offload"
after: "75f1d59512dd2e6468bed27c6203a721b116ef7869e291e3fee34c977d013185"
after: "100f6dca1468538239522cde3533e5bd721d0f1a7b50bea8b0eb533ea6c96163"
decision: version-bump
- root: "event:tool/result"
previous: "2026-09-14-image-offload"
after: "f4c6f3eae6607f412a9023406c8c3bd351a02451848bb78c2436f409447dd60c"
after: "7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397"
decision: version-bump
- root: "event:turn/end"
previous: "2026-09-14-image-offload"
@@ -76,7 +80,7 @@ changes:
decision: version-bump
- root: "event:user/message"
previous: "2026-09-14-image-offload"
after: "c3a5c8d0f1bc05ab074fdb086da866eece51dd573ae0c30b17a52bb76dec8836"
after: "3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761"
decision: version-bump
```
@@ -95,6 +99,8 @@ Producer-owned sources replace released plugin wrappers through the [V3-to-V4 mi
The core-owned user source property records the attribution-preservation policy; tmux-context qualifies its location attribution while retaining producer-local duplicate suppression. Auto Review and the compaction summarizer use request-only user inputs, removing their active source registrations without removing historical migration support. These inputs cannot be written as durable Session messages. Catalog formatVersion 2 stores the policy metadata; it does not change the Session version or rewrite frozen schema records. System, model, and tool sources retain their strict semantic rules.
Developer events retain their original role and require an open step. Each addition stores toolName; developer/message.headerSeq identifies an earlier known request/header containing exactly one complete matching ToolSchema. All additions in one event share that historical header revision, while removal-only and other developer messages omit headerSeq. Native and Session admission reject absent, forward, non-header, unknown-header, ambiguous, incomplete-definition and retired inline-definition forms without interpreting unknown ignorable records or dropping unrelated JSON metadata. Same-name replacement, restart, fork, surface replacement and compaction retain historical schema identity without consulting the latest header or registry. Generic sourceEventSeqs remains independent. Developer sources use the same recorded attribution-preservation policy as user sources. The optional deferLoading marker is independent of addition history. No shipped profile emits developer records; provider serialization, automatic emission and UI support remain deferred.
The PTC producer writes `source.kind: 'ptc-mode'`. The V3-to-V4 edge retains `tools-code-mode` and `tools-ptc` as historical plugin lookup keys and maps both to that current kind. The source policy reserves `ptc-mode` against attribution-only qualification.
<a id="verification"></a>
@@ -106,6 +112,8 @@ The generated request-header reservation regression and existing retired-syntax
The producer-source and request-input checks pass 447 tests across 12 files, including provider equality, request-only type rejection, user-attribution retention, source migration, and native source admission. Comparing the generated inventories with the tool-role parent reports four changed roots and 447 unchanged type fingerprints.
The focused Session, V4 and request-input suite passes 884 tests across 38 files, with 100% statements, branches, functions and lines for the complete V3-to-V4 package and Session surface. Coverage includes historical same-name schema binding, compound additions/removals, malformed references and definitions, unknown-header refusal, opaque unknown records, metadata preservation, forks, replacements, compaction references, and native plain/zstd read/write admission without rewriting the generation.
<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,记录一等 tool 角色结果与生产者拥有的 source,并向 turn/end.reason 添加 forked 变体。
将 V4 集成写入方声明的 SessionHeader.version 从 3 推进到 4,记录一等 tool 角色结果与生产者拥有的 source,并向 turn/end.reason 添加 forked 变体。 添加 developer 角色的 Session 变更,工具添加仅记录名称并绑定历史请求头,另含工具移除和延迟加载模式标记。
## 目录
@@ -32,43 +32,47 @@ changes:
decision: version-bump
- root: "event:agent/inbox/spliced"
previous: "2026-09-14-image-offload"
after: "3aa732b3400e924132366555c88618f62232b41c1e56b86beee05fd7edc24367"
after: "1506a9b8224986c83015ae99d2cb5ede705538c58c063d6a48ef6d761a31ba6c"
decision: version-bump
- root: "event:assistant/attempt"
previous: "2026-09-14-image-offload"
after: "2f1565d92801f4334f8dc34fbb980890eef621e81a7c90ed0fd8d0a6db2ec3b2"
after: "15d5dfdd822aa35e115afd74a8982825a493880457774e6850bc1520b50875e4"
decision: version-bump
- root: "event:assistant/message"
previous: "2026-09-14-image-offload"
after: "f219a9f66fcc92ffec27bbd96fa21ffc2cffbaf2ad1de05757ec37205b1a0b58"
after: "1033093edd0db80ff410e00830b523405e00bb0c7684948e531ff65095799625"
decision: version-bump
- root: "event:compaction/summary"
previous: "2026-09-14-image-offload"
after: "86c1ee9c8ab6c6e160d4ccc00b3d9ad5a6c687938b552d55eec528279baa1559"
after: "e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602"
decision: version-bump
- root: "event:developer/message"
previous: null
after: "eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80"
decision: version-bump
- root: "event:request/header"
previous: "2026-09-11-initial"
after: "91fb2b19bd1bfda3a1d30b71f39445595e1d4c120e8fe3553f96e25a3c3d1c7d"
after: "4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41"
decision: version-bump
- root: "event:session/title-llm-request"
previous: "2026-09-14-image-offload"
after: "4eeb9bb8f9c35f59b3020a07113f256c363a504983aecbe33e57fd1ab4f03b19"
after: "fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d"
decision: version-bump
- root: "event:system/message"
previous: "2026-09-14-image-offload"
after: "54a8682615a7ffc3e1e3c41ab003ae61082ae2792c57407f5bd8bb38fded3f0e"
after: "69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1"
decision: version-bump
- root: "event:team/message/queued"
previous: "2026-09-14-image-offload"
after: "9a25c2e889855516a531dc906905ec1d88157f7c2651aed27ac4f754bb128240"
after: "21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d"
decision: version-bump
- root: "event:tool/ptc-dispatch"
previous: "2026-09-14-image-offload"
after: "75f1d59512dd2e6468bed27c6203a721b116ef7869e291e3fee34c977d013185"
after: "100f6dca1468538239522cde3533e5bd721d0f1a7b50bea8b0eb533ea6c96163"
decision: version-bump
- root: "event:tool/result"
previous: "2026-09-14-image-offload"
after: "f4c6f3eae6607f412a9023406c8c3bd351a02451848bb78c2436f409447dd60c"
after: "7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397"
decision: version-bump
- root: "event:turn/end"
previous: "2026-09-14-image-offload"
@@ -76,7 +80,7 @@ changes:
decision: version-bump
- root: "event:user/message"
previous: "2026-09-14-image-offload"
after: "c3a5c8d0f1bc05ab074fdb086da866eece51dd573ae0c30b17a52bb76dec8836"
after: "3f72db3d87a0c5c43e68be467b4cca728eaf5adc1d5d2b6975ff42bfbd961761"
decision: version-bump
```
@@ -95,6 +99,8 @@ V3-to-V4 迁移将已发布的 user 角色工具结果提升为 tool 角色消
核心拥有的 user source 属性记录归属保留策略;tmux-context 将位置归属标记为符合条件,同时保留生产者内部的去重。Auto Review 和压缩摘要器使用仅供请求使用的 user 输入,移除其活动 source 注册,同时保留历史迁移支持。这些输入不能写为持久化 Session 消息。目录 formatVersion 2 保存策略元数据,不改变 Session 版本,也不重写冻结的 schema 记录。System、model 和 tool source 保持严格的语义规则。
Developer 事件保留原始角色,并要求处于打开的 step。每个添加块存储 toolName;developer/message.headerSeq 指向更早且已知的 request/header,其中必须恰好包含一个完整同名 ToolSchema。同一事件的所有添加共用该历史请求头版本,仅移除或其他 developer 消息省略 headerSeq。原生与 Session 接纳拒绝缺失、前向、非请求头、未知请求头、歧义、不完整定义及已退役内嵌定义形式,不解释未知且可忽略的记录,也不丢弃无关 JSON 元数据。同名替换、重启、fork、surface 替换和压缩保留历史模式身份,不查询最新请求头或注册表。通用 sourceEventSeqs 保持独立。Developer source 使用与 user source 相同的已记录归属保留策略。可选的 deferLoading 标记独立于添加历史。已提供的 profile 不发出 developer 记录;提供方序列化、自动发出及 UI 支持仍未启用。
PTC 生产者写入 `source.kind: 'ptc-mode'`。V3→V4 迁入边保留 `tools-code-mode` 和 `tools-ptc` 作为历史 plugin 查找键,并将二者映射到该当前 kind。source 策略保留 `ptc-mode`,不允许将其声明为仅用于归属。
<a id="verification"></a>
@@ -106,6 +112,8 @@ PTC 生产者写入 `source.kind: 'ptc-mode'`。V3→V4 迁入边保留 `tools-c
Producer-source 与 request-input 检查共通过 12 个文件中的 447 项测试,覆盖 provider 等价性、仅供请求使用的输入的类型拒绝、user 归属保留、source 迁移及原生 source 准入。与 tool-role 父层的生成目录相比,有四个 root 发生变化,447 个类型指纹保持不变。
聚焦的 Session、V4 和 request-input 测试共 38 个文件、884 项通过,完整 V3-to-V4 包与 Session surface 的语句、分支、函数及行覆盖率均为 100%。覆盖历史同名模式绑定、复合添加/移除、畸形引用与定义、未知请求头拒绝、未知记录不透明性、元数据保留、fork、替换、压缩引用,以及不重写 generation 的原生 plain/zstd 读写接纳。
<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: a4ae7ea4e92cb05b16a989de72605621307fa5bb
README.zh.md: e76ed913b79baa1d876e8dd33edeeb72223799b8
README.md: 9bcfaa1246a03446d4d4fc95dd19f9ca60196301
README.zh.md: 1f17367beb484feaeb289a42b3e5de50d3139be5
@@ -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 / 572 |
| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 583 |
<!-- 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 / 572 |
| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 583 |
<!-- persistence-format-index:end -->
+55431 -41404
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: 55976c9724413eea2230929747e27152011f2c1e
llm-streaming.zh.md: 43583597ff356d067869356b64d71d373f610682
llm-streaming.md: 07fc6599bc5d4eb5a4af9b566032004932052585
llm-streaming.zh.md: 079a33599d1e5169af6402db2b2abdd7dc978369
+14 -2
View File
@@ -17,7 +17,9 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts)
```ts type-equiv
/**
* Merge-extensible content blocks keyed by `type`. New core blocks must land
* with adapter, UI, and compaction support.
* with adapter, UI, and compaction support. Developer tool-change blocks are
* reserved for Session V4 persistence; providers and UI reject them until
* their producers and consumers are implemented together.
*/
interface ContentBlockMap {
'text': TextBlock
@@ -25,10 +27,12 @@ interface ContentBlockMap {
'image': ImageBlock
'file': FileBlock
'tool-call': ToolCallBlock
'tool-addition': ToolAdditionBlock
'tool-removal': ToolRemovalBlock
}
```
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.
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. Developer tool-change blocks are a persistence-only exception described in the [decision](../../.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.md).
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`.
@@ -67,6 +71,8 @@ interface AssistantProviderMetadata {
type Message = MessageRoleMap[keyof MessageRoleMap]
```
`DeveloperMessage` records incremental agent session changes in conversation order with the `developer` role. `ToolAdditionBlock.toolName` activates the definition selected by the containing Session event's historical header reference; `ToolRemovalBlock.toolName` removes the active definition. Both blocks are rejected in other message roles. `deferLoading` independently requests deferred definition loading without requiring an addition record. See [Session](../../packages/core/session/README.md) for header binding and the [LLM package](../../packages/llm/llm/README.md#known-limitations-and-deferred-work) for provider support limits.
Where a message came from is itself a merge-extensible sum type:
```ts type-equiv
@@ -661,6 +667,12 @@ interface FinishReasonMap {
* it from this package.
*/
interface ToolSchema {
/**
* Requests deferred loading of the tool definition into model context,
* independently of whether a tool-addition block records the tool.
* Uses Anthropic's defer_loading terminology.
*/
deferLoading?: true
name: string
description: string
/** JSON Schema object for the arguments. */
+14 -2
View File
@@ -17,7 +17,9 @@
```ts type-equiv
/**
* Merge-extensible content blocks keyed by `type`. New core blocks must land
* with adapter, UI, and compaction support.
* with adapter, UI, and compaction support. Developer tool-change blocks are
* reserved for Session V4 persistence; providers and UI reject them until
* their producers and consumers are implemented together.
*/
interface ContentBlockMap {
'text': TextBlock
@@ -25,10 +27,12 @@ interface ContentBlockMap {
'image': ImageBlock
'file': FileBlock
'tool-call': ToolCallBlock
'tool-addition': ToolAdditionBlock
'tool-removal': ToolRemovalBlock
}
```
各块接口(完整字段见源码):`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。
各块接口(完整字段见源码):`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。 Developer 工具变更块属于仅持久化的例外,见[决策](../../.agents/notes/implemented/architecture/2026-09-17-developer-session-changes.zh.md)。
图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。
@@ -67,6 +71,8 @@ interface AssistantProviderMetadata {
type Message = MessageRoleMap[keyof MessageRoleMap]
```
`DeveloperMessage` 以 `developer` 角色按对话顺序记录增量智能体 Session 变更。`ToolAdditionBlock.toolName` 激活由所在 Session 事件的历史请求头引用所选定的定义;`ToolRemovalBlock.toolName` 移除当前生效的定义。其他消息角色拒绝这两种内容块。`deferLoading` 独立控制工具定义的加载请求,不要求存在添加记录。请求头绑定见 [Session](../../packages/core/session/README.zh.md),提供方支持限制见 [LLM 包](../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。
消息来源本身也是一个可合并扩展的和类型:
```ts type-equiv
@@ -667,6 +673,12 @@ interface FinishReasonMap {
* it from this package.
*/
interface ToolSchema {
/**
* Requests deferred loading of the tool definition into model context,
* independently of whether a tool-addition block records the tool.
* Uses Anthropic's defer_loading terminology.
*/
deferLoading?: true
name: string
description: string
/** JSON Schema object for the arguments. */
+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: fcf4a657de2a16950fd98fc53f8cdae6ab294425
session.zh.md: d7b2fb1e699938af0a67e6525c7cbc4f0aec4838
session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149
session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a
+10 -1
View File
@@ -53,6 +53,14 @@ interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
/** An incremental agent session change admitted at the named turn and step. */
'developer/message': {
turn: number
step: number
message: DeveloperMessage
/** Earlier request/header defining every tool addition; required exactly when additions are present. */
headerSeq?: SessionSeq
}
/**
* The rendered system prompt on the model-visible surface. The loop appends
* the first one as surface node 0 before the step's first `user/message`.
@@ -287,7 +295,7 @@ Every surface event requires `surfaceOp`; known log-only events forbid both surf
## Surface types
The four message-producing types (`SurfaceEventType` — `system/message`, `user/message`, `assistant/message`, `tool/result`) carry surface metadata declaring how they join the ordered derived surface. `system/message` holds the rendered system prompt: the loop appends the first one as surface node 0 and, when the prompt changes, replaces exactly the latest system node or appends a new one on an in-history route; the surface fold rejects any other replacement covering a `system/message` at node 0, while a later system node is ordinary history that a compaction replacement may shadow. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md).
The message-producing types (`SurfaceEventType` — `system/message`, `developer/message`, `user/message`, `assistant/message`, `tool/result`) carry surface metadata declaring how they join the ordered derived surface. `system/message` holds the rendered system prompt: the loop appends the first one as surface node 0 and, when the prompt changes, replaces exactly the latest system node or appends a new one on an in-history route; the surface fold rejects any other replacement covering a `system/message` at node 0, while a later system node is ordinary history that a compaction replacement may shadow. See the [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md).
### `SurfaceEventType` — the message-producing subset of event types
@@ -300,6 +308,7 @@ The four message-producing types (`SurfaceEventType` — `system/message`, `user
*/
type SurfaceEventType =
| 'system/message'
| 'developer/message'
| 'user/message'
| 'assistant/message'
| 'tool/result'
+10 -1
View File
@@ -53,6 +53,14 @@ interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
/** An incremental agent session change admitted at the named turn and step. */
'developer/message': {
turn: number
step: number
message: DeveloperMessage
/** Earlier request/header defining every tool addition; required exactly when additions are present. */
headerSeq?: SessionSeq
}
/**
* The rendered system prompt on the model-visible surface. The loop appends
* the first one as surface node 0 before the step's first `user/message`.
@@ -289,7 +297,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
## Surface 类型
四种产生消息的类型(`SurfaceEventType`:`system/message`、`user/message`、`assistant/message`、`tool/result`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。`system/message` 承载渲染后的系统提示词:循环把第一条追加为 surface 第 0 号节点,并在提示词变化时恰好替换最新的系统节点,或在历史内路由上追加一条新的;surface 折叠拒绝任何其他覆盖第 0 号节点 `system/message` 的替换,而后续系统节点是普通历史,压缩替换可以遮蔽它。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md)。
产生消息的类型(`SurfaceEventType`:`system/message`、`developer/message`、`user/message`、`assistant/message`、`tool/result`)携带 surface 元数据,用来声明它们如何加入有序的派生 surface。`system/message` 承载渲染后的系统提示词:循环把第一条追加为 surface 第 0 号节点,并在提示词变化时恰好替换最新的系统节点,或在历史内路由上追加一条新的;surface 折叠拒绝任何其他覆盖第 0 号节点 `system/message` 的替换,而后续系统节点是普通历史,压缩替换可以遮蔽它。见 [session surface Agent Note](../../.agents/notes/implemented/architecture/2026-06-18-session-surface.zh.md)。
### `SurfaceEventType`:事件类型中产生消息的子集
@@ -302,6 +310,7 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
*/
type SurfaceEventType =
| 'system/message'
| 'developer/message'
| 'user/message'
| 'assistant/message'
| 'tool/result'
@@ -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/api/session-controller/README.md
README.md: 02dfab026daff2bc59d0e6fbf3fe03128a26f001
README.zh.md: c504fee72932ef7cc8b2b07d9fb9408afbac6a30
README.md: 228ed88ed20b9f9581496468e47079afd1542e4f
README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a
@@ -52,6 +52,8 @@ A resume blocked by an existing write handle returns `session/writer-held` with
`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` still publishes its single page directly.
Queue edits replace pending content with non-empty text only.
<a id="client-references"></a>
## Client references
@@ -52,6 +52,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 仍直接发布其单页结果。
队列编辑仅允许用非空文本替换待处理内容。
<a id="client-references"></a>
## Client 引用
@@ -432,6 +432,7 @@ export class SessionCommandController {
*/
async updateQueue(request: SessionUpdateQueueRequest): Promise<SessionUpdateQueueValue> {
if (request.action.kind === 'edit') {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- Remote callers can submit untyped JSON.
if (request.action.content.some(block => block.type !== 'text')) {
throw new RemoteError(
'session/attachment-invalid',
+2 -2
View File
@@ -5,7 +5,7 @@ import type {
} from '@deepseek-ai/dsh-attachment'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TextBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
@@ -154,7 +154,7 @@ export type QueueAction =
| {
readonly kind: 'edit'
/** Non-empty text-only replacement content. */
readonly content: readonly ContentBlock[]
readonly content: readonly TextBlock[]
}
| { readonly kind: 'remove' }
| { readonly kind: 'steer' }
@@ -153,6 +153,7 @@ describe('Session queue commands', () => {
action: {
kind: 'edit',
content: [{
// @ts-expect-error -- remote edit payloads can carry unsupported image blocks.
type: 'image',
attachment: {
attachmentId: AttachmentId('att-edit'), mediaType: 'image/png', bytes: 1, width: 1, height: 1,
+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/client/ui-chat/README.md
README.md: 7f3d905b026edeb538de87dc5e54e0f05cd3c3bc
README.zh.md: 118fc29fdb4960dfc1606d7e9cd0a1ccaea9843d
README.md: ca16822299c06c23e4bf206d626719e609908d42
README.zh.md: 9d6aada08a3a32551ea129d61068e71811415ac3
+2
View File
@@ -102,6 +102,8 @@ None; Chat presentation does not assemble or mutate provider requests.
<a id="known-limitations-and-deferred-work"></a>
- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row.
- **The transcript reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation is wider than the window: the rail merges the loaded Turns with the host `turnOutline` projection, so every started Turn gets a fixed-pitch mark (10px apart; a ladder taller than the frame scrolls inside it with gradient fades), and activating an unloaded mark pages history through the Turn's `turn/start` seq before landing on its row. Without the projection (assemblies not mounting `dsh-session-turn-outline`) the rail falls back to loaded Turns only.
- **Rail previews are card-sized** — one prompt line (50 characters) and up to three response lines (120), on loaded and unloaded Turns alike; an unloaded Turn's response arrives from the outline only once the Turn settled, so an open Turn previews its prompt (or just the Turn number) until then.
+2
View File
@@ -102,6 +102,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有
<a id="known-limitations-and-deferred-work"></a>
- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。
- **transcript 只反映已加载的 Session 窗口**——只有会话控制器加载前一页事件后,更早的 transcript node 才会出现。轮次导航比窗口更宽:轨道把已加载的轮次与宿主 `turnOutline` 投影合并,每个已开始的轮次都有固定间距刻度(相隔 10px;阶梯高于外框时在框内滚动并以渐变淡出标示可滚方向),激活未加载刻度会先把历史分页拉到该轮次的 `turn/start` seq 再落到它的行上。没有该投影时(未挂载 `dsh-session-turn-outline` 的装配),轨道回退到仅显示已加载轮次。
- **导航预览按卡片尺寸截断**——提示词一行(50 字符)、回复至多三行(120 字符),已加载与未加载 Turn 一致;未加载 Turn 的回复要等该轮落定后才随大纲到达,进行中的轮次在此之前只预览提示词(或仅轮次号)。
@@ -44,11 +44,15 @@ function isCompactionCheckpoint(event: Parameters<ConversationNodeDefinition['ma
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
kind: 'input-message',
target: 'chat',
match: event => event.type === 'user/message'
&& isAppendSurfaceEvent(event)
&& !isCompactionCheckpoint(event)
? { id: String(event.data.id), role: 'start' }
: null,
match: (event) => {
// Developer history is persisted for V4; presentation is intentionally deferred.
if (event.type === 'developer/message') throw new Error('Chat developer messages are not supported yet')
return event.type === 'user/message'
&& isAppendSurfaceEvent(event)
&& !isCompactionCheckpoint(event)
? { id: String(event.data.id), role: 'start' }
: null
},
start: (_context, match, reader) => {
if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message')
const event = match.event
@@ -217,6 +217,13 @@ function toolResult(callId: string, text: string, isError = false) {
}
describe('built-in conversation node Definitions', () => {
it('rejects developer history until presentation is implemented', () => {
expect(() => assembler([at(0, 'developer/message', { turn: 1, step: 1, message: {
id: 'developer', role: 'developer', source: { kind: 'tool-registry' },
content: [{ type: 'tool-addition', toolName: 'search' }],
} }, { surfaceOp: 'append' })])).toThrow('developer messages are not supported yet')
})
it('rejects an unrelated event passed directly to the request-prompt start', () => {
const input = at(1, 'turn/start', { turn: 1 })
const invalidStart = {
@@ -197,13 +197,9 @@ export interface CompactionSummaryNode {
}
/**
* Fallback for surface events this UI version does not know: the documented
* default arm of `SessionEventMap`, which is merge-extensible, so the
* projection's switch cannot end in `assertNever`. No event produces this node
* because `isAppendSurfaceEvent` admits only the four types in core's
* `SurfaceEventType`, and each has its own arm (`system/message` is claimed by
* a Chat Definition that renders no transcript row) — and it exists so widening
* that set core-side degrades to a raw row instead of dropping the event silently.
* Fallback for unclaimed append-surface events. The merge-extensible
* SessionEventMap permits unfamiliar events to retain a raw presentation.
* Known unsupported developer events throw before fallback selection.
*/
export interface UnknownSurfaceNode {
kind: 'unknown'
@@ -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/client/ui-trajectory/README.md
README.md: 35135c726d59669ee6368d33ba35882897df7b15
README.zh.md: ddde852f56924599b46a7f813619dcfc7d099714
README.md: d15b1c7c6d605ba1b73180566a84fe29ab08530c
README.zh.md: 87cbae17c53a112cc62054a6150df9565a7bd8c0
+2
View File
@@ -94,6 +94,8 @@ None; this package neither assembles nor sends a provider request.
<a id="known-limitations-and-deferred-work"></a>
- **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row.
These limits define what the view can show while work is in flight; they are current package constraints.
@@ -94,6 +94,8 @@ Trajectory 要求会话壳把 composer 作为浮层置于全高记录表上方
<a id="known-limitations-and-deferred-work"></a>
- **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。
这些限制定义工作仍在进行时视图能显示什么;它们是当前包约束。
@@ -136,9 +136,13 @@ const trajectoryInboxDefinition: ConversationNodeDefinition<InboxState> = {
const trajectoryMessageDefinition: ConversationNodeDefinition<MessageNode> = {
kind: 'trajectory-input-message',
target: 'trajectory',
match: event => event.type === 'user/message'
? { id: String(event.seq), role: 'start' }
: null,
match: (event) => {
// Developer history is persisted for V4; presentation is intentionally deferred.
if (event.type === 'developer/message') throw new Error('Trajectory developer messages are not supported yet')
return event.type === 'user/message'
? { id: String(event.seq), role: 'start' }
: null
},
start: (_context, match, reader) => {
if (match.event.type !== 'user/message') {
throw new Error('trajectory-input-message start requires user/message')
@@ -176,6 +176,13 @@ describe('Trajectory conversation Definitions', () => {
expect(trajectoryViewDefinition.toolCallFocus?.('call-1')).toBe('call-1')
})
it('rejects developer history until presentation is implemented', () => {
expect(() => assembler([at(0, 'developer/message', { turn: 1, step: 1, message: {
id: 'developer', role: 'developer', source: { kind: 'tool-registry' },
content: [{ type: 'tool-addition', toolName: 'search' }],
} }, { surfaceOp: 'append' })])).toThrow('developer messages are not supported yet')
})
it('assembles streaming usage, preserves retry facts, and materializes interruption', () => {
const value = assembler([
at(1, 'turn/start', { turn: 1 }),
@@ -51,6 +51,7 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
if (text !== '') conversation.push({ role: 'assistant', text, checkpoint: false, originalText: text, omittedBytes: 0 })
break
}
case 'developer/message':
case 'system/message':
case 'tool/result':
break
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { agentEvents, installModelSelection, type Agent, type ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction'
import LlmRuntime, { createMessage, createSystemMessage, createToolResultMessage, createUserMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm'
import LlmRuntime, { createDeveloperMessage, createMessage, createSystemMessage, createToolResultMessage, createUserMessage, LlmError, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -1017,6 +1017,12 @@ describe('session reference discovery and preparation', () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
source.append('developer/message', {
turn: 1, step: 1,
message: createDeveloperMessage({
content: [{ type: 'tool-removal', toolName: 'private_tool' }], source: { kind: 'test' },
}),
}, { surfaceOp: 'append' })
source.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'nested referenced snapshot must not propagate' }],
source: {
+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: 7be5f00e55dd71477c81833ad391569fac4e132d
README.zh.md: 3f82a30123d516f8b8e6e6ac5660ccee4789edee
README.md: 06c4f1806995c2fdff78515f88a60c6e449e3a26
README.zh.md: c52eee96af19bcd6f5a76f950026d2c191002732
+5 -3
View File
@@ -47,7 +47,9 @@ session.append('user/message', { role: 'user', content: [{ type: 'text', text: '
session.deriveMessages() // the derived model history
```
Surface events (`system/message`, `user/message`, `assistant/message`, `tool/result`) require `surfaceOp` in both typed events and append input. A replacement uses exactly `{ op: 'replace', startSeq, endSeq }`, with inclusive `SessionSeq` endpoints in current surface order. An Assistant message embeds its exact compact provider stream and forbids `sourceEventSeqs`. Known log-only events forbid both metadata fields and never produce a message.
Surface events (`system/message`, `developer/message`, `user/message`, `assistant/message`, `tool/result`) require `surfaceOp` in both typed events and append input. A replacement uses exactly `{ op: 'replace', startSeq, endSeq }`, with inclusive `SessionSeq` endpoints in current surface order. An Assistant message embeds its exact compact provider stream and forbids `sourceEventSeqs`. Known log-only events forbid both metadata fields and never produce a message.
`developer/message` stores tool-addition names and a `headerSeq` reference to an earlier `request/header`. The reference is required exactly when additions are present, and each name must identify exactly one complete schema in that header. Removals name the tool without a header reference. Historical headers remain available across restart, fork, and surface replacement; the current registry and latest header do not select an earlier addition's definition. `sourceEventSeqs` continues to describe derivation and replaced nodes.
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.
@@ -107,7 +109,7 @@ Every append uses the shared iterative `snapshotJsonValue()` pass, which reads,
### Derived history
`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The four surface event types (`system/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; an empty-content system node projects to no message. Plugin-owned projections change derived content without mutating recorded messages. Replacements and projection decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only.
`deriveMessages()` caches deep-frozen projections and returns a fresh array per call. The surface event types (`system/message`, `developer/message`, `user/message`, `assistant/message`, `tool/result`) supply their recorded message identities and content; empty-content system and developer nodes project to no message. Plugin-owned projections change derived content without mutating recorded messages. Replacements and projection decisions invalidate the cache. Embedded Assistant streams and `assistant/attempt` events remain replay and diagnostic data only.
### The request header
@@ -137,7 +139,7 @@ The package-level contract is enough for most consumers; read these when you nee
#### What the model sees
The model receives the complete messages from `system/message`, `user/message`, `assistant/message`, and `tool/result` surface entries with logged projections applied, the system prompt first. Identities, roles, sources, and unmodified blocks retain their original values; projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their attribution. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message.
The model receives the complete messages from `system/message`, `developer/message`, `user/message`, `assistant/message`, and `tool/result` surface entries with logged projections applied, the system prompt first. Identities, roles, sources, and unmodified blocks retain their original values; projections never mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their attribution. Embedded streams, `assistant/attempt`, boundaries, and other log-only facts add no message.
#### Token effect
+5 -3
View File
@@ -47,7 +47,9 @@ session.append('user/message', { role: 'user', content: [{ type: 'text', text: '
session.deriveMessages() // the derived model history
```
表层事件(`system/message`、`user/message`、`assistant/message`、`tool/result`)在类型化事件与追加输入中都必须带有 `surfaceOp`。替换操作仅接受 `{ op: 'replace', startSeq, endSeq }`,端点为包含边界的 `SessionSeq`,按当前 surface 顺序解释。assistant 消息会嵌入精确、紧凑的提供方流,并禁止 `sourceEventSeqs`。已知仅日志事件禁止这两个元数据字段,且从不产生消息。
表层事件(`system/message`、`developer/message`、`user/message`、`assistant/message`、`tool/result`)在类型化事件与追加输入中都必须带有 `surfaceOp`。替换操作仅接受 `{ op: 'replace', startSeq, endSeq }`,端点为包含边界的 `SessionSeq`,按当前 surface 顺序解释。assistant 消息会嵌入精确、紧凑的提供方流,并禁止 `sourceEventSeqs`。已知仅日志事件禁止这两个元数据字段,且从不产生消息。
`developer/message` 存储工具添加名称,以及指向更早 `request/header` 的 `headerSeq` 引用。仅在存在添加块时必须携带该引用,每个名称必须在所引用的请求头中恰好对应一个完整模式。移除块仅记录工具名称,不携带请求头引用。重启、fork 和 surface 替换保留历史请求头;较早添加的定义不由当前注册表或最新请求头决定。`sourceEventSeqs` 继续描述派生来源及被替换节点。
插件用 `@messageProjection` 声明修改内容的事件,并通过 `ctx.sessions.registerMessageProjection()` 注册纯处理器。Session 在接受事件前调用处理器,并缓存其不可变消息更新。缺少处理器时拒绝追加和恢复,卸载已经使用的处理器后也会拒绝读取缓存。独立构造函数和 `foldSurface(events, projections)` 必须显式接收处理器。重建函数将折叠结果的 `projectedMessages` 传给 `deriveEventMessage()`,实时实例方法自动应用相同的投影。[插件拥有消息投影](../../../.agents/notes/implemented/architecture/2026-09-11-plugin-owned-message-projections.zh.md)说明职责划分和离线装配。
@@ -107,7 +109,7 @@ session.deriveMessages() // the derived model history
### 派生历史
`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。四种 surface 事件类型(`system/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的系统节点不派生消息。插件拥有的投影修改派生内容,不修改记录的消息。替换和投影决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。
`deriveMessages()` 缓存深度冻结的派生消息,每次调用返回新数组。surface 事件类型(`system/message`、`developer/message`、`user/message`、`assistant/message`、`tool/result`)提供记录的消息身份和内容,空内容的 system 和 developer 节点不派生消息。插件拥有的投影修改派生内容,不修改记录的消息。替换和投影决策使缓存失效。嵌入式 Assistant stream 与 `assistant/attempt` 事件只保留回放和诊断数据。
### 请求头
@@ -137,7 +139,7 @@ session.deriveMessages() // the derived model history
#### 模型看到什么
模型会接收 `system/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的消息,并应用日志中的投影,系统提示词在先。消息标识、角色、来源及未修改的内容块保持不变,投影不生成标识。直接提示词与注入上下文仍是独立的 `user/message` 事件,各事件的来源保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不添加消息。
模型会接收 `system/message`、`developer/message`、`user/message`、`assistant/message` 与 `tool/result` surface 条目中的消息,并应用日志中的投影,系统提示词在先。消息标识、角色、来源及未修改的内容块保持不变,投影不生成标识。直接提示词与注入上下文仍是独立的 `user/message` 事件,各事件的来源保留其出处。嵌入式 stream、`assistant/attempt`、边界与其他仅日志事实不添加消息。
#### Token 影响
+6 -3
View File
@@ -26,7 +26,7 @@ export { buildForkSeed } from './fork.ts'
export * from './types.ts'
export { SessionPreparation } from './preparation.ts'
export type { SessionPreparationOptions } from './preparation.ts'
export type { AssistantMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export type { AssistantMessage, DeveloperMessage, SystemMessage, ToolResultMessage, UserMessage } from '@deepseek-ai/dsh-llm'
export { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from './repair.ts'
export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult, SessionMessageProjection, SessionMessageProjectionContext } from './surface.ts'
export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
@@ -177,6 +177,7 @@ export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
case 'user/message':
deepFreeze(event.data)
break
case 'developer/message':
case 'system/message':
case 'assistant/message':
case 'tool/result':
@@ -231,6 +232,7 @@ function assertSessionEventEnvelope(value: unknown, index: number): asserts valu
validateSessionEventData(event as SessionEvent, `seed ${type} at index ${index}`)
switch (type) {
case 'request/header':
case 'developer/message':
case 'system/message':
case 'user/message':
case 'assistant/attempt':
@@ -314,14 +316,15 @@ function assertAdapterDefaults(
}
}
/** The four surface event types whose payload carries an identified message. */
/** The surface event types whose payload carries an identified message. */
function isMessageEventType(type: unknown): type is SurfaceEventType {
return type === 'system/message' || type === 'user/message'
return type === 'developer/message' || type === 'system/message' || type === 'user/message'
|| type === 'assistant/message' || type === 'tool/result'
}
const MESSAGE_ROLE_BY_TYPE: Record<SurfaceEventType, Message['role']> = {
'system/message': 'system',
'developer/message': 'developer',
'user/message': 'user',
'assistant/message': 'assistant',
'tool/result': 'tool',
+4
View File
@@ -104,6 +104,10 @@ function validateEvent(
openStep = event.data.step
break
}
case 'developer/message': {
requireOpenStep(trace, 'developer/message', event.data.turn, event.data.step, fail)
break
}
case 'step/end': {
requireOpenStep(trace, 'step/end', event.data.turn, event.data.step, fail)
pendingCalls = { kind: 'clear' }
@@ -34,6 +34,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'compaction/start',
'compaction/summary',
'deliverables/presented',
'developer/message',
'feedback/message-delete',
'feedback/message-put',
'feedback/record',
+70 -10
View File
@@ -8,7 +8,7 @@
* @module @deepseek-ai/dsh-session/surface
*/
import type { Message } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import { SessionLogOffset, SessionSeq } from './types.ts'
import { KNOWN_SESSION_EVENT_TYPES, MESSAGE_PROJECTION_EVENT_TYPES } from './known-event-types.ts'
import type {
@@ -49,6 +49,7 @@ export interface SessionMessageProjection<T extends SessionEventType = SessionEv
/** Runtime counterpart of the message-producing event union. */
const SURFACE_EVENT_TYPES = new Set<string>([
'system/message',
'developer/message',
'user/message',
'assistant/message',
'tool/result',
@@ -57,7 +58,7 @@ const SURFACE_EVENT_TYPES = new Set<string>([
/**
* Whether an event type can join the model-visible surface.
* @param type - event type to test.
* @returns true for one of the four message-producing event types.
* @returns true for one of the message-producing event types.
*/
export function isSurfaceEligibleType(type: string): boolean {
return SURFACE_EVENT_TYPES.has(type)
@@ -107,7 +108,7 @@ export function isReplacementSurfaceEvent(
/**
* Project a single event into the LLM message it derives to, or null when it
* produces none — a non-surface event (attempt, boundary, log-only record) or an
* empty-content assistant/message (which exists only to host usage). A caller
* empty-content system, developer, or assistant message. A caller
* reconstructing model input supplies the same prefix's `projectedMessages`
* from {@link foldSurface}; without that map this function reads original
* event content. Session instance methods apply the live projection. Messages
@@ -137,12 +138,11 @@ export function deriveEventMessage(
case 'user/message': {
return event.data
}
// An empty-content message projects to no wire message. For
// system/message the node records "no system prompt" while keeping its
// surface position; for assistant/message the event exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
// turn into the provider transcript.
// Empty system and developer nodes retain their surface positions without
// adding wire messages. An empty assistant event hosts a max-tokens step's
// usage and must not inject a content-less turn into the provider transcript.
case 'system/message':
case 'developer/message':
case 'assistant/message': {
if (event.data.message.content.length === 0) return null
return event.data.message
@@ -163,17 +163,46 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
/**
* Reject noncanonical request-header fields and contradictory tool failure metadata.
* Reject noncanonical request-header fields, developer roles/content, and contradictory tool failure metadata.
* This does not validate complete event payloads or embedded provider streams.
* @param event - event whose locally related payload fields are inspected.
* @param subject - event location to include in validation errors.
* @throws when request data/header is not an object, optional header fields are empty, or tool failure metadata contradicts its message.
* @throws when request-header fields, developer roles/content, or tool failure metadata are invalid.
*/
export function validateSessionEventData(
event: Pick<SessionEvent, 'type' | 'data'>,
subject: string,
): void {
const data: unknown = event.data
if (SURFACE_EVENT_TYPES.has(event.type) && isRecord(data)) {
const message = event.type === 'user/message' ? data : data['message']
if (isRecord(message)) {
if ((event.type === 'developer/message') !== (message['role'] === 'developer')) {
throw new Error(`${subject} developer/message and developer role must occur together`)
}
if (message['role'] !== 'developer' && Array.isArray(message['content'])
&& message['content'].some((block: unknown) => isRecord(block)
&& (block['type'] === 'tool-addition' || block['type'] === 'tool-removal'))) {
throw new Error(`${subject} tool-change blocks require developer role`)
}
if (event.type === 'developer/message' && Array.isArray(message['content'])) {
let hasAdditions = false
for (const block of message['content']) {
if (!isRecord(block) || (block['type'] !== 'tool-addition' && block['type'] !== 'tool-removal')) continue
if (typeof block['toolName'] !== 'string' || block['toolName'].length === 0) {
throw new Error(`${subject} ${block['type']} requires a nonempty toolName`)
}
if (block['type'] === 'tool-addition') {
hasAdditions = true
if (Object.hasOwn(block, 'tool')) throw new Error(`${subject} tool-addition must omit inline tool definitions`)
}
}
if (hasAdditions ? !isEventSeq(data['headerSeq']) : Object.hasOwn(data, 'headerSeq')) {
throw new Error(`${subject} requires headerSeq exactly when tool additions are present`)
}
}
}
}
if (event.type === 'request/header') {
if (!isRecord(data)) throw new Error(`${subject} data must be an object`)
const header = data['header']
@@ -341,6 +370,36 @@ function assertSourceEventReferences(
}
}
/** Resolve tool additions against their immutable historical request header. */
function assertDeveloperHeader(
event: SessionEvent,
events: readonly SessionEvent[],
baseSeq: SessionLogOffset,
): void {
if (event.type !== 'developer/message') return
validateSessionEventData(event, `developer/message at seq ${event.seq}`)
if (event.data.headerSeq === undefined) return
const headerSeq = event.data.headerSeq
const headerEvent = events[headerSeq - baseSeq]
if (headerSeq >= event.seq || headerEvent?.type !== 'request/header') {
throw new Error('developer/message headerSeq must reference an earlier request/header')
}
for (const block of event.data.message.content) {
if (block.type !== 'tool-addition') continue
const definitions = headerEvent.data.header.tools?.filter(tool => tool.name === block.toolName) ?? []
if (definitions.length !== 1) {
throw new Error(`developer/message tool-addition "${block.toolName}" must name exactly one tool in headerSeq ${headerSeq}`)
}
const definition = definitions[0] as ToolSchema
if (typeof definition.description !== 'string' || !isRecord(definition.parameters)) {
throw new Error(`developer/message tool-addition "${block.toolName}" requires a complete tool definition in headerSeq ${headerSeq}`)
}
if (Object.hasOwn(definition, 'deferLoading') && definition.deferLoading !== true) {
throw new Error('developer/message referenced tool deferLoading must be true when present')
}
}
}
/**
* Validate one event's surface metadata without checking membership in a log or surface.
* @param event - event whose marker and source sequence values are inspected.
@@ -466,6 +525,7 @@ function planSurfaceEvent(
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
}
const surfaceOp = validateSurfaceMetadata(event)
assertDeveloperHeader(event, events, baseSeq)
const projection = projections.find(item => item.type === event.type)
if (projection !== undefined) {
return { kind: 'project', projection, messages: projection.project(event, {
+10
View File
@@ -1,6 +1,7 @@
import { brandNumber, brandString, type Branded, type BrandedNumber } from '@deepseek-ai/dsh-brand'
import type {
AssistantMessage,
DeveloperMessage,
AssistantStreamRecord,
ToolCallId,
LlmCallConfig,
@@ -306,6 +307,14 @@ export interface SessionEventMap {
* project their `content` verbatim; `source` tells them apart.
*/
'user/message': UserMessage
/** An incremental agent session change admitted at the named turn and step. */
'developer/message': {
turn: number
step: number
message: DeveloperMessage
/** Earlier request/header defining every tool addition; required exactly when additions are present. */
headerSeq?: SessionSeq
}
/**
* The rendered system prompt on the model-visible surface. The loop appends
* the first one as surface node 0 before the step's first `user/message`.
@@ -429,6 +438,7 @@ export type SessionEventType = keyof SessionEventMap
*/
export type SurfaceEventType =
| 'system/message'
| 'developer/message'
| 'user/message'
| 'assistant/message'
| 'tool/result'
@@ -0,0 +1,109 @@
/** Tool additions bind to one historical request header across Session lifecycle paths. */
import { describe, expect, it } from 'vitest'
import { createDeveloperMessage } from '@deepseek-ai/dsh-llm'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { buildForkSeed } from '@deepseek-ai/dsh-session/fork'
import { foldSurface } from '../src/surface.ts'
const first: ToolSchema = { name: 'search', description: 'First', parameters: { type: 'object' } }
const second: ToolSchema = { name: 'search', description: 'Second', parameters: { properties: { query: { type: 'string' } } } }
const addition = () => createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-addition', toolName: 'search' }] })
function opened(tools: ToolSchema[] = [first]) {
const session = Session.create(SessionId('developer-header'))
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
const headerSeq = session.append('request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools } }).seq
return { session, headerSeq }
}
describe('developer request-header references', () => {
it('keeps an older header binding through compound changes, replacement, restore, and fork', () => {
const { session, headerSeq } = opened([first, { name: 'other', description: 'Other', parameters: {} }])
session.append('request/header', { reason: 'change', header: { config: { provider: 'test', model: 'test' }, tools: [second] } })
const message = createDeveloperMessage({ source: { kind: 'test' }, content: [
{ type: 'tool-removal', toolName: 'search' },
{ type: 'tool-addition', toolName: 'search' },
{ type: 'tool-addition', toolName: 'other' },
] })
const initial = session.append('developer/message', { turn: 1, step: 1, headerSeq, message }, { surfaceOp: 'append' })
const replacement = session.append('developer/message', { turn: 1, step: 1, headerSeq, message: addition() }, {
surfaceOp: { op: 'replace', startSeq: initial.seq, endSeq: initial.seq }, sourceEventSeqs: [initial.seq],
})
const events = session.snapshotEvents()
expect(events[headerSeq]).toMatchObject({ data: { header: { tools: [first, { name: 'other', description: 'Other', parameters: {} }] } } })
expect(replacement.data.message.content).toEqual([{ type: 'tool-addition', toolName: 'search' }])
expect(foldSurface(events).nodes).toEqual([replacement.seq])
const restored = Session.fromRestore(session.id, events, session.header, SessionLogOffset(0), 'shared-frozen')
expect(restored.deriveMessages()).toEqual([replacement.data.message])
const childId = SessionId('developer-header-child')
const child = Session.create(childId, buildForkSeed(events, replacement.seq), {
...session.header, id: childId, parentSession: session.id, isSeeded: true,
}, SessionLogOffset(replacement.seq + 1))
expect(child.snapshotEvents()[replacement.seq]).toEqual(replacement)
expect(child.snapshotEvents()[headerSeq]).toEqual(events[headerSeq])
})
it.each([0, 3, 100])('refuses a non-header or non-earlier reference %s before append', (reference) => {
const { session } = opened()
const before = session.snapshotEvents()
expect(() => session.append('developer/message', {
turn: 1, step: 1, headerSeq: SessionSeq(reference), message: addition(),
}, { surfaceOp: 'append' })).toThrow('earlier request/header')
expect(session.snapshotEvents()).toBe(before)
})
it.each([{ tools: [{ ...first, name: 'different' }] }, { tools: [first, second] }])('refuses missing or ambiguous historical tool names %#', ({ tools }) => {
const { session, headerSeq } = opened(tools)
expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: addition() }, { surfaceOp: 'append' }))
.toThrow('exactly one tool')
})
it('refuses an addition bound to a tool-less header', () => {
const session = Session.create(SessionId('without-tools'))
const headerSeq = session.append('request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' } } }).seq
expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: addition() }, { surfaceOp: 'append' }))
.toThrow('exactly one tool')
})
it('requires a header exactly for additions and rejects inline definitions', () => {
const { session, headerSeq } = opened()
expect(() => session.append('developer/message', { turn: 1, step: 1, message: addition() }, { surfaceOp: 'append' })).toThrow('requires headerSeq')
const removal = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-removal', toolName: 'search' }] })
expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: removal }, { surfaceOp: 'append' })).toThrow('requires headerSeq')
// @ts-expect-error -- legacy inline definitions are rejected at Session admission.
const inline = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-addition', toolName: 'search', tool: first }] })
expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: inline }, { surfaceOp: 'append' })).toThrow('omit inline')
const unnamed = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-addition', toolName: '' }] })
expect(() => session.append('developer/message', { turn: 1, step: 1, headerSeq, message: unnamed }, { surfaceOp: 'append' })).toThrow('nonempty toolName')
})
it.each([
{ name: 'search' },
{ name: 'search', description: null, parameters: {} },
{ name: 'search', description: '', parameters: [] },
{ name: 'search', description: '', parameters: {}, deferLoading: false },
])('refuses an incomplete referenced definition in imported history %#', (tool) => {
const { session, headerSeq } = opened()
const source = session.snapshotEvents()[headerSeq]!
const events = JSON.parse(JSON.stringify([
...session.snapshotEvents().slice(0, headerSeq),
{ ...source, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [tool] } } },
{ type: 'developer/message', seq: 3, time: 4, surfaceOp: 'append', data: { turn: 1, step: 1, headerSeq, message: addition() } },
])) as SessionEvent[]
expect(() => Session.create(SessionId('incomplete-tool'), events)).toThrow(/complete tool definition|deferLoading/)
})
it('applies the same binding check to imported events and full-log surface folds', () => {
const { session } = opened()
const invalid: SessionEvent<'developer/message'> = {
type: 'developer/message', seq: SessionSeq(3), time: 4, surfaceOp: 'append',
data: { turn: 1, step: 1, headerSeq: SessionSeq(0), message: addition() },
}
const events = [...session.snapshotEvents(), invalid]
expect(() => Session.create(SessionId('bad-import'), events)).toThrow('earlier request/header')
expect(() => foldSurface(events)).toThrow('earlier request/header')
})
})
+22 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { createSystemMessage, createUserMessage, ToolCallId, createMessage, createToolResultMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import { createDeveloperMessage, createSystemMessage, createUserMessage, ToolCallId, createMessage, createToolResultMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, SessionSeq, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
@@ -38,6 +38,27 @@ describe('session-log invariants', () => {
}).not.toThrow()
})
it('requires developer messages to belong to the open turn and step', async () => {
const { ctx } = await setup()
try {
const session = ctx.sessions.create()
const message = createDeveloperMessage({ content: [{ type: 'tool-removal', toolName: 'search' }], source: { kind: 'test' } })
const append = (turn: number, step: number) => session.append('developer/message', { turn, step, message }, { surfaceOp: 'append' })
expect(() => append(1, 1)).toThrow('developer/message names turn 1/step 1')
session.append('turn/start', { turn: 1 })
expect(() => append(1, 1)).toThrow('developer/message names turn 1/step 1')
session.append('step/start', { turn: 1, step: 1 })
expect(() => append(2, 1)).toThrow('developer/message names turn 2/step 1')
expect(() => append(1, 2)).toThrow('developer/message names turn 1/step 2')
expect(() => append(1, 1)).not.toThrow()
expect(session.deriveMessages()).toEqual([message])
session.append('step/end', { turn: 1, step: 1 })
expect(() => append(1, 1)).toThrow('developer/message names turn 1/step 1')
} finally {
await ctx.fiber.dispose()
}
})
it('accepts a well-formed turn, step, and tool sequence', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
+14 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { createSystemMessage, createUserMessage, ToolCallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { createDeveloperMessage, createSystemMessage, createUserMessage, ToolCallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import SessionStore, {
@@ -477,6 +477,19 @@ describe('Session', () => {
expect(snapshot.data.content).not.toBe(source.data.content)
})
it('adopts developer messages in place and freezes their nested tool changes', () => {
const event: SessionEvent<'developer/message'> = {
type: 'developer/message', seq: SessionSeq(1), time: 1, surfaceOp: 'append',
data: { turn: 1, step: 1, headerSeq: SessionSeq(0), message: structuredClone(createDeveloperMessage({
content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'test' },
})) },
}
expect(Object.isFrozen(event.data.message)).toBe(false)
expect(adoptSessionEvent(event)).toBe(event)
expect(Object.isFrozen(event.data.message)).toBe(true)
expect(Object.isFrozen(event.data.message.content[0])).toBe(true)
})
it('adopts a system/message by freezing its message', () => {
const event = {
type: 'system/message',
@@ -6,6 +6,7 @@ import {
SessionLogOffset,
SessionSeq,
foldSurface,
snapshotSessionEvent,
isAppendSurfaceEvent,
isReplacementSurfaceEvent,
isSurfaceEligibleType,
@@ -16,6 +17,7 @@ import {
MessageId,
ToolCallId,
createMessage,
createDeveloperMessage,
createSystemMessage,
createToolResultMessage,
createUserMessage,
@@ -976,3 +978,47 @@ describe('system/message surface node', () => {
expect(() => Session.create(SessionId('bad-source'), [badSource as SessionEvent])).toThrow(/system-prompt source/)
})
})
describe('developer message history', () => {
it.each(['user/message', 'developer/message'] as const)('rejects a mismatched developer role in %s at event admission', (type) => {
const message = createDeveloperMessage({ source: { kind: 'test' }, content: [] })
const event = {
type, seq: SessionSeq(0), time: 0, surfaceOp: 'append',
data: type === 'user/message' ? message : { turn: 1, step: 1, message: { ...message, role: 'user' } },
} as unknown as SessionEvent
expect(() => snapshotSessionEvent(event)).toThrow('must occur together')
})
it('retains an empty developer node while projecting no message', () => {
const session = Session.create(SessionId('empty-developer'))
const message = createDeveloperMessage({ source: { kind: 'test' }, content: [] })
const event = session.append('developer/message', { turn: 1, step: 1, message }, { surfaceOp: 'append' })
expect(session.snapshotEvents()).toContain(event)
expect(session.deriveMessages()).toEqual([])
const restored = Session.fromRestore(session.id, session.snapshotEvents(), session.header, SessionLogOffset(0), 'shared-frozen')
expect(restored.deriveMessages()).toEqual([])
})
it.each(['tool-addition', 'tool-removal'] as const)('rejects %s in user messages at Session admission', (type) => {
const session = Session.create(SessionId('invalid-tool-change'))
const message = createUserMessage({ source: { kind: 'test' }, content: [{ type, toolName: 'search' }] })
expect(() => session.append('user/message', message, { surfaceOp: 'append' })).toThrow('developer role')
})
it('retains developer blocks through append, replacement, and restoration', () => {
const session = Session.create(SessionId('developer'))
const message = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-addition', toolName: 'search' }] })
const headerSeq = session.append('request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [{ name: 'search', description: 'Search', parameters: {} }] } }).seq
const first = session.append('developer/message', { turn: 1, step: 1, headerSeq, message }, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual([message])
const replacement = createDeveloperMessage({ source: { kind: 'test' }, content: [{ type: 'tool-removal', toolName: 'search' }] })
session.append('developer/message', { turn: 1, step: 1, message: replacement }, {
surfaceOp: { op: 'replace', startSeq: first.seq, endSeq: first.seq }, sourceEventSeqs: [first.seq],
})
expect(session.deriveMessages()).toEqual([replacement])
expect(isSurfaceEligibleType('developer/message')).toBe(true)
const restored = Session.fromRestore(session.id, session.snapshotEvents(), session.header, SessionLogOffset(0), 'shared-frozen')
expect(restored.deriveMessages()).toEqual([replacement])
})
})
+2 -1
View File
@@ -584,10 +584,11 @@ export class SystemPrompt extends Service {
const knownNames = new Set<string>()
for (const provider of providers) {
const result = provider(context)
const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({
const schemas = result.schemas.map(({ name, description, parameters, deferLoading }): ToolSchema => ({
name,
description,
parameters: structuredClone(parameters),
...deferLoading === true ? { deferLoading } : {},
}))
const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name)
collected.push(...schemas)
+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/tools/README.md
README.md: 0bcc7ae5a90a5fad495385453ee2747ef91e7bb5
README.zh.md: 3e65248bf34f84040bfebf5ab34ba54cb82ecef0
README.md: b5400d6b8743d904f5ff07a5fef50e041633ffbb
README.zh.md: 1bfd05db5c333215544b515a52364edbd6c126d8
+2
View File
@@ -232,6 +232,8 @@ These limits define when the registry needs special care. They are current packa
- **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap.
- **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP, because cross-call state would be invisible to the log.
`defineTool()`, registry schema projection, and system-prompt assembly preserve `deferLoading: true`. The marker requests deferred definition loading and does not imply a `tool-addition` record; the [LLM package](../../../packages/llm/llm/README.md#known-limitations-and-deferred-work) documents provider enforcement limits.
<a id="dev-note"></a>
### Dev Note
+2
View File
@@ -232,6 +232,8 @@ Program-only SDK bindings:
- **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。
- **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核,因为跨调用状态不会出现在日志中。
`defineTool()`、注册表模式投影和系统提示组装会保留 `deferLoading: true`。该标记请求延迟加载工具定义,并不意味着存在 `tool-addition` 记录;提供方执行语义的限制见 [LLM 包](../../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。
<a id="dev-note"></a>
### 开发备注
+9 -1
View File
@@ -28,6 +28,13 @@ import { renderToolsSdk } from './ts-types.ts'
import type { ToolSdkSchema } from './ts-types.ts'
import { renderToolsSdkPy } from './py-types.ts'
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
/** Tool availability changes supplied by the tool registry. */
'tool-registry': { kind: 'tool-registry' }
}
}
/**
* Language → SDK-section renderer. The registry looks up the loaded
* `ctx.ptcRuntime.language` in this table when assembling the `tools:sdk`
@@ -1260,7 +1267,7 @@ export class ToolRuntime extends Service {
/** Project one definition onto the model-facing schema fields. */
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
const { name, description, parameters } = definition
const { name, description, parameters, deferLoading } = definition
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
if (detached === undefined) {
throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
@@ -1269,6 +1276,7 @@ export class ToolRuntime extends Service {
name,
description,
parameters: detached,
...deferLoading === true ? { deferLoading } : {},
}
}
+3
View File
@@ -496,6 +496,8 @@ export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends Valu
/** Pure replayable presentation metadata for direct top-level calls. */
presentationMeta?(args: InferArgs<S>, value: InferValue<NoInfer<O>>): JsonValue
}
/** Requests deferred loading of the tool definition; see {@link @deepseek-ai/dsh-llm#ToolSchema.deferLoading}. */
readonly deferLoading?: true
/** Optional positive cooperative timeout budget in milliseconds. */
readonly timeoutMs?: number
/**
@@ -581,6 +583,7 @@ export function defineTool<const S extends ParameterSchemaSpec, const O extends
},
} : {},
},
...(options.deferLoading === true ? { deferLoading: options.deferLoading } : {}),
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
async execute(args: unknown, exec: ToolRunContext): Promise<JsonValue> {
const violations = validate(args)
+11
View File
@@ -52,6 +52,17 @@ const echoTool = defineTool({
})
describe('ToolRuntime', () => {
it('preserves deferred loading through registry and prompt schema projection', async () => {
const ctx = await setup()
try {
ctx.tools.register(defineContentToolFixture({ name: 'deferred', description: '', parameters: {}, deferLoading: true, async execute() { return [] } }))
expect(ctx.tools.schemas()[0]?.deferLoading).toBe(true)
expect((await ctx.systemPrompt.assemble()).tools[0]?.deferLoading).toBe(true)
} finally {
await ctx.fiber.dispose()
}
})
it('registers tools, exposes schemas, and feeds the system-prompt assembly', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -787,7 +787,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'QueueAction',
declaration: 'export type QueueAction = {\n readonly kind: \'edit\';\n readonly content: readonly ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};',
declaration: 'export type QueueAction = {\n readonly kind: \'edit\';\n readonly content: readonly TextBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};',
},
{
name: 'RegisterFactory',
@@ -4377,7 +4377,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ContentBlockMap',
declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'file\': FileBlock;\n \'tool-call\': ToolCallBlock;\n}',
declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'file\': FileBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-addition\': ToolAdditionBlock;\n \'tool-removal\': ToolRemovalBlock;\n}',
},
{
name: 'ContentBlockType',
@@ -4531,6 +4531,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DeepSeekLlmApiJson',
declaration: 'export type DeepSeekLlmApiJson = null | boolean | number | string | DeepSeekLlmApiJson[] | {\n [key: string]: DeepSeekLlmApiJson;\n};',
},
{
name: 'DeveloperMessage',
declaration: 'export interface DeveloperMessage extends MessageBase {\n readonly role: \'developer\';\n}',
},
{
name: 'DiffCallView',
declaration: 'export interface DiffCallView {\n card: \'diff\';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n}',
@@ -5173,7 +5177,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'MessageRoleMap',
declaration: 'export interface MessageRoleMap {\n system: SystemMessage;\n user: UserMessage;\n assistant: AssistantMessage;\n tool: ToolResultMessage;\n}',
declaration: 'export interface MessageRoleMap {\n system: SystemMessage;\n developer: DeveloperMessage;\n user: UserMessage;\n assistant: AssistantMessage;\n tool: ToolResultMessage;\n}',
},
{
name: 'MessageSourceMap',
@@ -5461,7 +5465,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'QueueAction',
declaration: 'export type QueueAction = {\n readonly kind: \'edit\';\n readonly content: readonly ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};',
declaration: 'export type QueueAction = {\n readonly kind: \'edit\';\n readonly content: readonly TextBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};',
},
{
name: 'ReadFileLine',
@@ -5737,7 +5741,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'system/message\': {\n turn: number;\n step: number;\n message: SystemMessage;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n reason?: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': {\n inherited?: true;\n };\n}',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': UserMessage;\n \'developer/message\': {\n turn: number;\n step: number;\n message: DeveloperMessage;\n headerSeq?: SessionSeq;\n };\n \'system/message\': {\n turn: number;\n step: number;\n message: SystemMessage;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n stream: AssistantStreamRecord[];\n usage?: TokenUsage;\n interrupted?: true;\n };\n \'assistant/attempt\': {\n turn: number;\n step: number;\n stream: AssistantStreamRecord[];\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: ToolCallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n reason?: string;\n };\n meta?: JsonValue;\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n startsSeries?: true;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': {\n /* …truncated — full shape in source */',
},
{
name: 'SessionEventMetadataFilter',
@@ -6513,7 +6517,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SurfaceEventType',
declaration: 'export type SurfaceEventType = \'system/message\' | \'user/message\' | \'assistant/message\' | \'tool/result\';',
declaration: 'export type SurfaceEventType = \'system/message\' | \'developer/message\' | \'user/message\' | \'assistant/message\' | \'tool/result\';',
},
{
name: 'SurfaceIntent',
@@ -6711,6 +6715,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TokenUsage',
declaration: 'export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n totalTokens?: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n}',
},
{
name: 'ToolAdditionBlock',
declaration: 'export interface ToolAdditionBlock {\n type: \'tool-addition\';\n toolName: string;\n tool?: never;\n}',
},
{
name: 'ToolCallKind',
declaration: 'export type ToolCallKind = \'read\' | \'edit\' | \'delete\' | \'move\' | \'search\' | \'execute\' | \'fetch\' | \'other\';',
@@ -6783,6 +6791,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ToolProviderResult',
declaration: 'export interface ToolProviderResult {\n readonly schemas: readonly ToolSchema[];\n readonly knownNames?: readonly string[];\n}',
},
{
name: 'ToolRemovalBlock',
declaration: 'export interface ToolRemovalBlock {\n type: \'tool-removal\';\n toolName: string;\n}',
},
{
name: 'ToolRestriction',
declaration: 'export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n}',
@@ -6813,7 +6825,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolSchema',
declaration: 'export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}',
declaration: 'export interface ToolSchema {\n deferLoading?: true;\n name: string;\n description: string;\n parameters: Record<string, unknown>;\n}',
},
{
name: 'TurnEndCancelCause',
@@ -107,9 +107,19 @@ function assertTextOnly(blocks: readonly ContentBlock[]): void {
}
}
/** Reject roles whose DeepSeek history format cannot carry image input. */
function assertSupportedImageRoles(messages: readonly RequestMessage[]): void {
/** Reject developer history before text serialization or image offloading. */
function assertSupportedRole(message: RequestMessage): void {
// Developer history is persisted for V4; provider serialization is intentionally deferred.
if (message.role === 'developer') throw new LlmError('Developer messages are not supported yet', 'UNSUPPORTED_CONTENT')
if (message.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) {
throw new LlmError('Tool-change blocks require developer role', 'UNSUPPORTED_CONTENT')
}
}
/** Reject unsupported roles, tool-change blocks, and image roles before history conversion. */
function assertSupportedHistory(messages: readonly RequestMessage[]): void {
for (const message of messages) {
assertSupportedRole(message)
if (message.role !== 'user' && message.role !== 'tool' && contentHasImage(message.content)) {
throw new LlmError(
`The DeepSeek chat-completions adapter cannot represent image content in a ${message.role} message.`,
@@ -236,6 +246,7 @@ function serializeAssistant(message: Message): WireMessage {
export function serializeMessages(messages: RequestMessage[]): WireMessage[] {
const wire: WireMessage[] = []
for (const message of messages) {
assertSupportedRole(message)
assertTextOnly(message.content)
if (message.role === 'system') {
wire.push({ role: 'system', content: flattenText(message.content) })
@@ -272,7 +283,7 @@ export async function serializeMessagesWithImages(
messages: readonly RequestMessage[],
images: ImageSerializationOptions,
): Promise<WireMessage[]> {
assertSupportedImageRoles(messages)
assertSupportedHistory(messages)
const wire: WireMessage[] = []
let pendingToolImages: WireImageContentPart[] = []
const flushToolImages = (): void => {
@@ -324,6 +335,10 @@ function requestWithMessages(
messages: WireMessage[],
defaults: RequestDefaults,
): WireRequest {
// Deferred definitions are persisted for V4; provider loading is intentionally deferred.
if (options.tools?.some(tool => tool.deferLoading === true)) {
throw new LlmError('Deferred tool loading is not supported yet', 'UNSUPPORTED_CONTENT')
}
const tools: WireTool[] | undefined = options.tools?.map(tool => ({
type: 'function',
function: {
@@ -414,7 +429,7 @@ export async function serializeRequestWithImages(
images: ImageSerializationOptions,
defaults: RequestDefaults = {},
): Promise<WireRequest> {
assertSupportedImageRoles(options.messages)
assertSupportedHistory(options.messages)
assertRetainedImagesFit(options.messages, images)
const requestMessages = projectOffloadedImages(
options.messages,
@@ -83,7 +83,14 @@ export function serialize(
if (messages.at(-1)?.role !== 'user') return unsupported('system update without a preceding user or tool-result turn')
messages.push(...systemUpdates.splice(0))
}
// Deferred definitions are persisted for V4; provider loading is intentionally deferred.
if (options.tools?.some(tool => tool.deferLoading === true)) return unsupported('deferred tool loading')
for (const message of history) {
// Developer history is persisted for V4; provider serialization is intentionally deferred.
if (message.role === 'developer') return unsupported('developer message')
if (message.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) {
return unsupported('tool-change blocks outside developer messages')
}
if (message.role === 'system') {
const texts = message.content.filter(block => block.type === 'text')
if (texts.length !== message.content.length) return unsupported('non-text system message')
@@ -1,6 +1,6 @@
/** Request conversion and durable replay validation. */
import { describe, expect, it, vi } from 'vitest'
import { createUserMessage, createAssistantMessage, createMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm'
import { createDeveloperMessage, createUserMessage, createAssistantMessage, createMessage, createSystemMessage, createToolResultMessage, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, ImageBlock, Message, RequestMessage, RequestUserInput } from '@deepseek-ai/dsh-llm'
import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
import type { AttachmentStore, ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
@@ -23,6 +23,18 @@ const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate
const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined)
describe('Messages request conversion', () => {
it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => {
for (const type of ['tool-addition', 'tool-removal'] as const) {
const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message
expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
}
})
it('rejects deferred tool definitions until provider loading is implemented', () => {
expect(() => body([], { tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] }))
.toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
})
it('preserves the exact request with request-only text after durable tool results', () => {
const prefix = [user(), assistant([call()]), result()]
const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'review or summarize this input' }] }
@@ -53,6 +65,11 @@ describe('Messages request conversion', () => {
expect(input).not.toHaveProperty('source')
})
it('rejects developer history while provider serialization is unsupported', () => {
const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'tool-registry' } })
expect(() => body([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
})
it('keeps the original top-level prompt and cached prefix while appending native system updates', () => {
const head = createSystemMessage('original')
const first = [head, user('first')]
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment'
import type { ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment'
import { createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm'
import { createDeveloperMessage, createToolResultMessage, createUserMessage, ToolCallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message, RequestUserInput } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import {
@@ -85,6 +85,18 @@ function inlineImageOptions(
}
}
it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => {
for (const type of ['tool-addition', 'tool-removal'] as const) {
const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message
expect(() => serializeMessages([message])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
}
})
it('rejects deferred tool definitions until provider loading is implemented', () => {
expect(() => serializeRequest(request({ tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] })))
.toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
})
describe('request-only user input', () => {
it('preserves the exact text request and durable tool-result prefix', () => {
const prefix = [
@@ -672,6 +684,19 @@ describe('image serialization', () => {
})
})
it('uses the configured image-count quantum before resolving files', async () => {
const ref = imageRef()
const resolveFileId = fileResolver()
const messages = [createUserMessage({
content: Array.from({ length: 3 }, () => ({ type: 'image' as const, attachment: ref })),
source: { kind: 'test' },
})]
await expect(serializeRequestWithImages(request({ messages }), {
...imageOptions([ref], resolveFileId), maxImagesPerRequest: 2, countQuantum: 2,
})).rejects.toMatchObject({ code: 'IMAGE_OFFLOAD_REQUIRED', failure: { offloadImages: 2 } })
expect(resolveFileId).not.toHaveBeenCalled()
})
it('counts only retained occurrences against the bound', async () => {
const ref = imageRef('image/png', 3)
const wire = await serializeRequestWithImages(request({
@@ -710,6 +735,21 @@ describe('image serialization', () => {
expect(resolveFileId).not.toHaveBeenCalled()
})
it('rejects developer history on text and image paths before reading attachments', async () => {
const resolveFileId = fileResolver()
const message = createDeveloperMessage({
content: [{ type: 'tool-addition', toolName: 'search' }],
source: { kind: 'test' },
})
const failure = { code: 'UNSUPPORTED_CONTENT', message: 'Developer messages are not supported yet' }
expect(() => serializeMessages([message])).toThrow(expect.objectContaining(failure))
await expect(serializeMessagesWithImages([message], imageOptions([], resolveFileId)))
.rejects.toMatchObject(failure)
await expect(serializeRequestWithImages(request({ messages: [message] }), imageOptions([], resolveFileId)))
.rejects.toMatchObject(failure)
expect(resolveFileId).not.toHaveBeenCalled()
})
it('rejects unsupported image history before request offloading can replace it', async () => {
const resolveFileId = vi.fn()
await expect(serializeRequestWithImages(request({
+13 -4
View File
@@ -46,9 +46,14 @@ function toolResultOf(
}
}
/** Reject image roles that pi-ai cannot replay before request-size offloading can replace them. */
function assertSupportedImageRoles(messages: readonly RequestMessage[]): void {
/** Reject unsupported roles, tool-change blocks, and image roles before replay or image offloading. */
function assertSupportedHistory(messages: readonly RequestMessage[]): void {
for (const message of messages) {
// Developer history is persisted for V4; provider serialization is intentionally deferred.
if (message.role === 'developer') throw new LlmError('Developer messages are not supported yet', 'UNSUPPORTED_CONTENT')
if (message.content.some(block => block.type === 'tool-addition' || block.type === 'tool-removal')) {
throw new LlmError('Tool-change blocks require developer role', 'UNSUPPORTED_CONTENT')
}
if (message.role !== 'user' && message.role !== 'tool' && contentHasImage(message.content)) {
throw new LlmError(
`pi-ai cannot represent an image in an in-history ${message.role} message`,
@@ -122,6 +127,10 @@ async function prepareRequestImages(
}
function toolsOf(options: GenerateOptions): PiTool[] | undefined {
// Deferred definitions are persisted for V4; provider loading is intentionally deferred.
if (options.tools?.some(tool => tool.deferLoading === true)) {
throw new LlmError('Deferred tool loading is not supported yet', 'UNSUPPORTED_CONTENT')
}
return options.tools?.map(tool => ({
name: tool.name,
description: tool.description,
@@ -198,7 +207,7 @@ function appendSystemOrAssistant(
}
function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: string) => void): PiContext {
assertSupportedImageRoles(options.messages)
assertSupportedHistory(options.messages)
const split = splitSystemPrompt(options)
const toolNames = new Map<ToolCallId, string>()
const messages: PiMessage[] = []
@@ -292,7 +301,7 @@ async function toPiContextWithImages(
maxPixels: DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
maxBytes: DEFAULT_REQUEST_IMAGE_MAX_BYTES,
}
assertSupportedImageRoles(options.messages)
assertSupportedHistory(options.messages)
const split = splitSystemPrompt(options)
const requestImages = await prepareRequestImages(split.messages, attachments, requestImagePolicy, options.signal)
if (maxRequestImageBytes !== undefined) {
+22 -1
View File
@@ -6,7 +6,7 @@ import type {
ImageRequestTarget,
RequestImageAttachment,
} from '@deepseek-ai/dsh-attachment'
import { ToolCallId, createAssistantMessage, createMessage, createToolResultMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm'
import { createDeveloperMessage, ToolCallId, createAssistantMessage, createMessage, createToolResultMessage, createUserMessage, offloadedImageText } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message, RequestUserInput } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
import { toPiContext } from '../src/context.ts'
@@ -84,6 +84,18 @@ function history(role: 'system' | 'assistant', content: ContentBlock[]): Message
}
describe('pi-ai request context conversion', () => {
it.each(['user', 'system', 'assistant', 'tool'] as const)('rejects tool-change blocks in %s history', (role) => {
for (const type of ['tool-addition', 'tool-removal'] as const) {
const message = { id: 'invalid', role, source: { kind: 'test' }, content: [{ type, toolName: 'search' }] } as unknown as Message
expect(() => toPiContext(request([message]))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
}
})
it('rejects deferred tool definitions until provider loading is implemented', () => {
expect(() => toPiContext({ ...request([]), tools: [{ name: 'search', description: '', parameters: {}, deferLoading: true }] }))
.toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
})
it('preserves the exact context for request-only input after durable tool history', async () => {
const prefix = [
history('assistant', [{ type: 'tool-call', id: ToolCallId('lookup'), name: 'lookup', arguments: '{}' }]),
@@ -102,6 +114,15 @@ describe('pi-ai request context conversion', () => {
expect(withImage).not.toHaveProperty('source')
})
it('rejects developer history before reading image attachments', async () => {
const read = vi.fn((value: ImageAttachmentRef) => Promise.resolve(requestImage(value, Uint8Array.of(1))))
const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'test' } })
const failure = { code: 'UNSUPPORTED_CONTENT', message: 'Developer messages are not supported yet' }
expect(() => toPiContext(request([message]))).toThrow(expect.objectContaining(failure))
await expect(toPiContext(request([message]), imageContext(projectionStore(read)))).rejects.toMatchObject(failure)
expect(read).not.toHaveBeenCalled()
})
it('omits absent and empty request-level optional fields', () => {
const base = { provider: 'openai', model: 'gpt-4.1', messages: [] }
expect(toPiContext(base)).toEqual({ messages: [] })
+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/llm/llm/README.md
README.md: c2b79c08484cc9e321f8775ae5eb016859dac452
README.zh.md: 798977588f3add0db37b90ff221bd843b3b15f3d
README.md: cdda682cebc720d51692a1b0c4c8691fa84359a3
README.zh.md: 8c679ecc6910376f7270450ae5be867d0da353fc
+2 -1
View File
@@ -154,9 +154,10 @@ These limits define where this service stops and other packages or future work b
- **No retry execution, caching, or rate limiting ships in this service** — provider registration stores the retry policy, but a stream remains a single provider attempt; `@deepseek-ai/dsh-llm-retry` executes the policy at durable agent-step boundaries.
- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md)).
- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant have no producer ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
- **Variants normally require a producer** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant have no producer ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
- **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw.
- **`GenerateOptions.sessionId` is a locally-declared brand** — importing dsh-session's `SessionId` would create a dependency cycle.
- **Session-change types are a V4 persistence exception** — `DeveloperMessage` carries incremental session changes. Additions and removals name tools; the containing Session event binds additions to a historical request header that owns their definitions. See [Session references](../../core/session/README.md) for admission and restoration. Provider serialization, deferred loading, and UI presentation remain deferred. Both DeepSeek protocols and pi-ai reject developer history and `deferLoading` requests; Chat and Trajectory reject developer events. Ordinary requests retain their existing behavior.
<a id="dev-note"></a>
### Dev Note
+2 -1
View File
@@ -154,9 +154,10 @@ for await (const chunk of ctx.llm.stream({
- **本服务不提供重试执行、缓存或速率限制**——提供方注册会存储重试策略,但一次流仍是一次提供方尝试;`@deepseek-ai/dsh-llm-retry` 在持久 agent 步骤边界上执行该策略。
- **`GenerateOptions` 采样只包含 `temperature`/`maxTokens`/`stop`**——没有 `tool_choice`、`top_p` 或 penalty 字段;有产生方落地时词汇才会增长(见[已删除惰性旋钮](../../../.agents/notes/archived/simplification/2026-07-04-drop-inert-request-knobs.md))。
- **只有出现实际产生方后,相应变体才会加入**——`prefill`、逐工具 `strict`、内容块 `cache` 提示和 `agent` 消息来源变体都没有产生方(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。
- **变体通常要求实际产生方**——`prefill`、逐工具 `strict`、内容块 `cache` 提示和 `agent` 消息来源变体都没有产生方(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md))。
- **`BlockAssembler` 只处理核心块类型**——插件添加块类型的流若从未由 `block-end` 关闭,`blocks()` 会抛出异常。
- **`GenerateOptions.sessionId` 是本地声明的品牌类型**——导入 dsh-session 的 `SessionId` 会产生依赖循环。
- **Session 变更类型是 V4 持久化的例外** — `DeveloperMessage` 承载增量 Session 变更。添加与移除块记录工具名称;所在的 Session 事件将添加绑定到拥有其定义的历史请求头。接纳和恢复规则见 [Session 引用](../../core/session/README.zh.md)。提供方序列化、延迟加载与 UI 展示仍留待后续实现。DeepSeek 两种协议与 pi-ai 均拒绝 developer 历史及 `deferLoading` 请求;Chat 与 Trajectory 拒绝 developer 事件。普通请求的行为不变。
<a id="dev-note"></a>
### 开发备注
+19 -1
View File
@@ -142,7 +142,7 @@ interface MessageBase {
/** Exact model-facing blocks. */
readonly content: readonly ContentBlock[]
/** Required source fields supplied by the producer.
* @persistenceSource user
* @persistenceSource user developer
*/
readonly source: MessageSource
}
@@ -153,6 +153,11 @@ export interface SystemMessage extends MessageBase {
readonly source: MessageSourceMap['system-prompt']
}
/** Incremental agent session changes in conversation order, currently tool additions and removals. */
export interface DeveloperMessage extends MessageBase {
readonly role: 'developer'
}
/** A user-role specialization of the shared message representation. */
export interface UserMessage extends MessageBase {
readonly role: 'user'
@@ -181,6 +186,7 @@ export interface ToolResultMessage extends MessageBase {
*/
export interface MessageRoleMap {
system: SystemMessage
developer: DeveloperMessage
user: UserMessage
assistant: AssistantMessage
tool: ToolResultMessage
@@ -192,6 +198,7 @@ export type Message = MessageRoleMap[keyof MessageRoleMap]
type NewMessage = {
[Role in keyof MessageRoleMap]: Omit<MessageRoleMap[Role], 'id'>
}[keyof MessageRoleMap]
type NewDeveloperMessage = Omit<DeveloperMessage, 'id' | 'role'>
type NewUserMessage = Omit<UserMessage, 'id' | 'role'>
type NewAssistantMessage = Omit<AssistantMessage, 'id' | 'role' | 'source'> & {
readonly source: Omit<ModelMessageSource, 'kind'> & { readonly kind?: never }
@@ -220,6 +227,17 @@ export function createMessage<T extends NewMessage>(
}))
}
/**
* Create an identified, immutable developer message.
* @param input - content and producer source for the new message.
* @returns a detached developer message with a fresh identity.
*/
export function createDeveloperMessage<T extends NewDeveloperMessage>(
input: T & { readonly id?: never; readonly role?: never },
): T & Pick<DeveloperMessage, 'id' | 'role'> {
return createMessage({ ...input, role: 'developer' })
}
/**
* Create one identified user-role message and freeze it before publication.
* @param input - complete content and source for a new user message.
+30 -1
View File
@@ -26,6 +26,7 @@ declare module '@deepseek-ai/cordis' {
export type {
AssistantMessage,
DeveloperMessage,
AssistantProviderMetadata,
Message,
MessageSource,
@@ -110,9 +111,29 @@ export interface ToolCallBlock {
arguments: string
}
/** Activates a tool definition from the developer event's referenced request header. */
export interface ToolAdditionBlock {
type: 'tool-addition'
/** Name of exactly one tool in the referenced historical header. */
toolName: string
/**
* Reserved against inline definitions; the historical request header owns the schema.
* @persistenceReserved
*/
tool?: never
}
/** Records the dynamic removal of a tool identified by its session-local name. */
export interface ToolRemovalBlock {
type: 'tool-removal'
toolName: string
}
/**
* Merge-extensible content blocks keyed by `type`. New core blocks must land
* with adapter, UI, and compaction support.
* with adapter, UI, and compaction support. Developer tool-change blocks are
* reserved for Session V4 persistence; providers and UI reject them until
* their producers and consumers are implemented together.
*/
export interface ContentBlockMap {
'text': TextBlock
@@ -120,6 +141,8 @@ export interface ContentBlockMap {
'image': ImageBlock
'file': FileBlock
'tool-call': ToolCallBlock
'tool-addition': ToolAdditionBlock
'tool-removal': ToolRemovalBlock
}
/** The block `type` tag vocabulary; widens as plugins add entries to {@link ContentBlockMap}. */
@@ -436,6 +459,12 @@ export type StreamChunk =
* it from this package.
*/
export interface ToolSchema {
/**
* Requests deferred loading of the tool definition into model context,
* independently of whether a tool-addition block records the tool.
* Uses Anthropic's defer_loading terminology.
*/
deferLoading?: true
name: string
description: string
/** JSON Schema object for the arguments. */
+11
View File
@@ -4,6 +4,7 @@ import {
boundContextSummary,
CONTEXT_SUMMARY_MAX_CHARS,
createAssistantMessage,
createDeveloperMessage,
createSystemMessage,
createToolResultMessage,
createUserMessage,
@@ -12,6 +13,16 @@ import {
} from '@deepseek-ai/dsh-llm'
describe('message construction', () => {
it('detaches and freezes developer tool names without copying their definitions', () => {
const content = [{ type: 'tool-addition' as const, toolName: 'search' }]
const message = createDeveloperMessage({ content, source: { kind: 'test' } })
content[0]!.toolName = 'changed'
expect(message.role).toBe('developer')
expect(message.content).toEqual([{ type: 'tool-addition', toolName: 'search' }])
expect(Object.isFrozen(message.content[0])).toBe(true)
expect(freezeMessage(message).id).toBe(message.id)
})
it('bounds producer summaries while preserving summaries at the exact limit', () => {
const exact = 'x'.repeat(CONTEXT_SUMMARY_MAX_CHARS)
expect(boundContextSummary(exact)).toBe(exact)
@@ -0,0 +1,121 @@
/** Initial and incremental tool definitions survive current-format restoration and forks. */
import { describe, expect, expectTypeOf, it } from 'vitest'
import { createDeveloperMessage } from '@deepseek-ai/dsh-llm'
import type { ToolAdditionBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { buildForkSeed } from '@deepseek-ai/dsh-session/fork'
import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
import { restoreReleasedV4Artifact } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import { sessionFormatCatalogOptions } from '../src/generated.ts'
import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
import { sessionFormatCatalog } from '../src/index.ts'
function restore(session: Session): Session {
const header = { ...session.header, delegationDepth: 0 }
const reader = sessionFormatCatalog.createRestore(
sessionFormatCatalog.encodeCurrentHeader(header, session.inheritedEventCount),
{ recovery: 'strict', validation: 'current' },
)
for (const event of session.snapshotEvents()) {
reader.decodeRow(sessionFormatCatalog.encodeCurrentEvent(event as unknown as SessionFormatEvent))
}
const artifact = reader.finish()
return Session.fromRestore(SessionId(artifact.header.id), artifact.events as SessionEvent[],
artifact.header as unknown as SessionHeader, SessionLogOffset(artifact.inheritedEventCount), 'detached')
}
function toolStates(session: Session): (ToolSchema | undefined)[] {
const initial = session.snapshotEvents().find(event => event.type === 'request/header')
if (initial?.type !== 'request/header') throw new Error('missing initial request header')
const active = new Map(initial.data.header.tools?.map(tool => [tool.name, tool]))
const states = [active.get('search')]
const events = session.snapshotEvents()
for (const seq of session.surface.nodes) {
const event = events[seq]
if (event?.type !== 'developer/message') continue
for (const block of event.data.message.content) {
if (block.type === 'tool-addition') {
const source = events[event.data.headerSeq!]
if (source?.type !== 'request/header') throw new Error('missing referenced header')
const tool = source.data.header.tools?.find(tool => tool.name === block.toolName)
if (tool === undefined) throw new Error('missing referenced definition')
active.set(block.toolName, tool)
}
else if (block.type === 'tool-removal') active.delete(block.toolName)
else continue
states.push(active.get('search'))
}
}
return states
}
describe('developer tool history', () => {
it.each([false, true])('defers ignorable developer interpretation until catalog completion (known: %s)', (knowsDeveloper) => {
const header = { version: 4, id: 'opaque-developer', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const events: SessionFormatEvent[] = [
{ type: 'developer/message', seq: 0, time: 1, ignorable: true, surfaceOp: { future: true }, data: {
message: { role: 'developer', source: { kind: 'plugin' } }, future: true,
} },
{ type: 'turn/start', seq: 1, time: 2, data: { turn: 1 } },
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
]
const known = new Set(events.map(event => event.type))
if (!knowsDeveloper) known.delete('developer/message')
const catalog = createSessionFormatCatalog({
...sessionFormatCatalogOptions,
restoreCurrent: candidate => restoreReleasedV4Artifact(candidate, known),
})
const reader = catalog.createRestore(sessionFormatCatalog.encodeCurrentHeader(header, 0), { recovery: 'strict', validation: 'current' })
for (const event of events) reader.decodeRow(event)
if (knowsDeveloper) expect(() => reader.finish()).toThrow()
else expect(reader.finish()).toEqual({ header, events, inheritedEventCount: 0 })
})
it('stores only the activated name in each addition', () => {
expectTypeOf<ToolAdditionBlock>().toEqualTypeOf<{ type: 'tool-addition'; toolName: string; tool?: never }>()
})
it('reconstructs remove/re-add and replacement from historical headers after restart and fork', () => {
const parent = Session.create(SessionId('tool-history'))
const initial: ToolSchema = { name: 'search', description: 'Initial', parameters: {} }
const first: ToolSchema = { name: 'search', description: 'First', parameters: { properties: { query: { type: 'string' } } }, deferLoading: true }
const second: ToolSchema = { name: 'search', description: 'Second', parameters: { properties: { limit: { type: 'number' } } } }
const third: ToolSchema = { name: 'search', description: 'Third', parameters: { properties: { exact: { type: 'boolean' } } } }
const expected = structuredClone([initial, first, undefined, second, third])
const append = (turn: number, tool: ToolSchema) => {
const headerSeq = parent.append('request/header', { reason: 'change', header: { config: { provider: 'test', model: 'test' }, tools: [tool] } }).seq
return parent.append('developer/message', {
turn, step: 1, headerSeq, message: createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [{ type: 'tool-addition', toolName: tool.name }] }),
}, { surfaceOp: 'append' })
}
parent.append('turn/start', { turn: 1 })
parent.append('step/start', { turn: 1, step: 1 })
parent.append('request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [initial] } })
append(1, first)
parent.append('developer/message', { turn: 1, step: 1, message: createDeveloperMessage({
source: { kind: 'tool-registry' }, content: [{ type: 'tool-removal', toolName: 'search' }],
}) }, { surfaceOp: 'append' })
append(1, second)
parent.append('step/end', { turn: 1, step: 1 })
const boundary = parent.append('turn/end', { turn: 1, reason: { kind: 'completed' } }).seq
parent.append('turn/start', { turn: 2 })
parent.append('step/start', { turn: 2, step: 1 })
append(2, third)
parent.append('step/end', { turn: 2, step: 1 })
parent.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
initial.description = 'mutated initial definition'
first.description = 'mutated producer definition'
first.parameters['properties'] = {}
second.description = 'mutated replacement'
expect(toolStates(parent)).toEqual(expected)
expect(toolStates(restore(parent))).toEqual(expected)
const childId = SessionId('tool-history-fork')
const child = Session.create(childId, buildForkSeed(parent.snapshotEvents(), boundary),
{ ...parent.header, id: childId, parentSession: parent.id, isSeeded: true }, SessionLogOffset(boundary + 1))
expect(toolStates(restore(child))).toEqual(expected.slice(0, 4))
})
})
@@ -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/session/session-format-v3-to-v4/README.md
README.md: 2d9e7065bcc4a0eaaa6716ccfce9cf11d6c0157d
README.zh.md: 4dc20acf5d43b6afb0d1cc1ca315322bf033601c
README.md: 35ff9e02c4477f6e1bd651b7bb47e3d3bb1ed45c
README.zh.md: ec8d9fd41ff1f92354fbc236dda9e26f273934aa
@@ -51,7 +51,7 @@ A `session-log-deepseek/delivery-accepted` event must identify a nonnegative saf
V4 source admission rejects retired plugin wrappers in surface, inbox, and title-request messages before recoverable scanning can discard rows. The catalog and native JSONL scanner validate every known message-source slot before exposing restored data. Unknown nonempty attribution kinds and every own JSON metadata field remain intact; external names such as `constructor` and `__proto__` are ordinary producer strings.
V4 restoration validates V4 delivery coordinates and ownership under the same rules. V3 deliveries are historical in V4 and do not become V4 watermarks. The V4 codec retains the released V2 physical framing and performs native tool-result and system-message field admission before recovery; complete vocabulary, delivery, and relationship checks require the target restorer or catalog `validation: 'current'`. Retired `header.system` fields and required predecessor PTC tags are refused even in a recoverable suffix; ignorable predecessor PTC records remain opaque. Recoverable codec reads alone do not establish strict restoration.
V4 restoration validates V4 delivery coordinates and ownership under the same rules. V3 deliveries are historical in V4 and do not become V4 watermarks. The V4 codec retains the released V2 physical framing and performs native tool-result and system-message field admission before recovery. Physical decoding defers ignorable developer payloads until reader vocabulary is available; encoders still validate developer payloads. Native JSONL scanners validate known developer payloads before suppressing a recoverable suffix. Complete vocabulary, delivery, and relationship checks require the target restorer or catalog `validation: 'current'`. Retired `header.system` fields and required predecessor PTC tags are refused even in a recoverable suffix; ignorable predecessor PTC records remain opaque. Recoverable codec reads alone do not establish strict restoration.
-----
@@ -74,6 +74,8 @@ Migration retains every source event, order, sequence, timestamp, and payload, a
V4 also admits fork-generated `TOOL_NOT_STARTED` results with deterministic `forked-tool-result-<callId>-<seq>` IDs and branch-specific text. Native validation checks the advertised call, error result, identity suffix, and surface operation directly; persisted and restored data retain the original fork ID and wording. Later surface replacements, including tool-result pruning, retain that identity and validate their source references through the installed Session. Released V0–V3 validators remain unchanged.
Native V4 preserves `developer/message` events at positive turn/step coordinates, tool-addition/removal blocks, and `deferLoading: true` tool-schema markers. Known developer events must match an open step; unknown ignorable events remain opaque. Additions store `toolName` and require the event's `headerSeq` to identify an earlier known `request/header` containing exactly one complete matching schema. Removals and messages without additions omit `headerSeq`. Native validation rejects inline definitions, missing or ambiguous bindings, and invalid roles while preserving unrelated JSON fields, replacement coordinates, and source references. Empty developer nodes retain their surface positions and cannot shadow the protected system head. This format support does not activate provider tool loading.
<a id="understand-the-implementation"></a>
## Understand the implementation
@@ -51,7 +51,7 @@ const targetHeader = sessionFormatV3ToV4.migrateHeader(sourceHeader)
V4 消息源准入在可恢复扫描丢弃行之前拒绝 surface、inbox 和标题请求消息中的退役 plugin 包装。目录和原生 JSONL 扫描器在公开恢复数据之前校验所有已知消息源槽位。未知的非空归属 kind 和所有自有 JSON 元数据字段保持不变;`constructor`、`__proto__` 等外部名字只是普通的生产者字符串。
V4 恢复按相同规则校验 V4 投递坐标和归属。V3 投递在 V4 中属于历史数据,不会成为 V4 水位。V4 编解码器只复用已发布 V2 的物理分帧,并在恢复前直接执行原生工具结果和 system 消息字段准入;完整的词表、投递和关系校验需要目标恢复器或目录的 `validation: 'current'`。即使位于可恢复的后缀,退役的 `header.system` 字段和必需的前代 PTC 标签也会被拒绝;可忽略的前代 PTC 记录保持不透明。仅执行可恢复编解码读取不能证明严格恢复成功。
V4 恢复按相同规则校验 V4 投递坐标和归属。V3 投递在 V4 中属于历史数据,不会成为 V4 水位。V4 编解码器只复用已发布 V2 的物理分帧,并在恢复前直接执行原生工具结果与 system 消息字段准入。物理解码将可忽略的 developer 载荷检查留到读取方词表可用之后;编码器仍校验 developer 载荷。原生 JSONL 扫描器在抑制可恢复后缀前校验已知 developer 载荷。完整的词表、投递和关系校验需要目标恢复器或目录的 `validation: 'current'`。即使位于可恢复的后缀,退役的 `header.system` 字段和必需的前代 PTC 标签也会被拒绝;可忽略的前代 PTC 记录保持不透明。仅执行可恢复编解码读取不能证明严格恢复成功。
-----
@@ -74,6 +74,8 @@ const artifact = restore.finish()
V4 还接受 fork 生成的 `TOOL_NOT_STARTED` 结果,使用确定性的 `forked-tool-result-<callId>-<seq>` ID 和分支专用文案。原生校验直接检查声明的调用、错误结果、身份后缀和 surface 操作;持久化与恢复的数据保留原始 fork ID 和文案。后续 surface 替换(包括工具结果裁剪)保留该身份,并由已安装的 Session 校验其源引用。已发布的 V0–V3 校验器保持不变。
原生 V4 保留携带正整数 turn/step 坐标的 `developer/message` 事件、工具添加/移除块及 `deferLoading: true` 工具模式标记。已知 developer 事件必须匹配打开的 step;未知且可忽略的事件保持不透明。添加块存储 `toolName`,事件必须携带 `headerSeq`,指向包含恰好一个完整同名模式的更早且已知的 `request/header`。移除及不含添加的消息不携带 `headerSeq`。原生验证拒绝内嵌定义、缺失或歧义绑定及无效角色,同时保留无关 JSON 字段、替换坐标和来源引用。空 developer 节点保留其 surface 位置,且不能覆盖受保护的 system 头节点。此格式支持不启用提供方工具加载。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -6,6 +6,7 @@ import { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v2
import { assertV4SourceRowAdmission } from './message-sources.ts'
import { assertV4RetiredSyntax } from './retired-syntax.ts'
import { assertV4SystemMessageFields } from './system-message.ts'
import { assertV4DeveloperData } from './developer.ts'
import { assertV4ForkResult } from './fork-result.ts'
import { assertV4ToolResultMessage } from './tool-role.ts'
import { assertReleasedV4Header } from './validation.ts'
@@ -43,6 +44,7 @@ export const releasedV4SessionFormatCodec = Object.freeze({
return { ...releasedV2SessionFormatCodec.encodeHeader({ ...header, version: 2 }, inheritedEventCount), version: 4 }
},
encodeEvent(event: SessionFormatEvent) {
if (event.type === 'developer/message' && event['ignorable'] === true) assertV4DeveloperData(event)
assertV4RowAdmission(event)
return releasedV2SessionFormatCodec.encodeEvent(event)
},
@@ -50,9 +52,16 @@ export const releasedV4SessionFormatCodec = Object.freeze({
/**
* Apply native V4 admission before a scanner discards a recoverable suffix.
* Ignorable developer payloads require reader vocabulary; physical decoding defers them.
* @param row - parsed physical row before framing and source-event range decoding.
* @param knownEventTypes - installed event types, supplied by native readers before tail recovery.
*/
export function assertV4RowAdmission(row: unknown): void {
export function assertV4RowAdmission(row: unknown, knownEventTypes?: ReadonlySet<string>): void {
if (isSessionFormatJsonObject(row)) {
if (row['type'] === 'developer/message' && row['ignorable'] === true
&& knownEventTypes?.has('developer/message') !== true) return
assertV4DeveloperData(row as unknown as SessionFormatEvent)
}
assertV4SourceRowAdmission(row)
assertV4RetiredSyntax(row)
assertV4SystemMessageFields(row)
@@ -0,0 +1,113 @@
/** Native V4 developer-message and deferred-tool-schema validation. */
import { SessionFormatError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
function assertToolChange(block: unknown, developer: boolean): void {
if (!isSessionFormatJsonObject(block) || (block['type'] !== 'tool-addition' && block['type'] !== 'tool-removal')) return
if (!developer) throw new SessionFormatError(`format v4 ${block['type']} requires developer role`)
if (typeof block['toolName'] !== 'string' || block['toolName'].length === 0) {
throw new SessionFormatError(`format v4 ${block['type']} requires a nonempty toolName`)
}
if (block['type'] === 'tool-addition' && Object.hasOwn(block, 'tool')) {
throw new SessionFormatError('format v4 tool-addition must omit inline tool definitions')
}
}
function assertContent(value: SessionFormatJsonValue | undefined, developer = false): void {
if (Array.isArray(value)) value.forEach((block) => { assertToolChange(block, developer) })
}
function assertDeveloperMessage(message: SessionFormatJsonObject): void {
assertContent(message['content'], true)
const source = message['source']
if (typeof message['id'] !== 'string' || message['id'].length === 0
|| !Array.isArray(message['content']) || !isSessionFormatJsonObject(source)
|| typeof source['kind'] !== 'string' || source['kind'].length === 0 || source['kind'] === 'plugin') {
throw new SessionFormatError('format v4 developer message requires id, role, content, and a producer-owned source')
}
}
/** Ordinary-message corruption belongs to the decoder; recognized V4-only values are refused here. */
function assertOrdinaryMessage(value: SessionFormatJsonValue | undefined): void {
if (!isSessionFormatJsonObject(value)) return
if (value['role'] === 'developer') {
throw new SessionFormatError('format v4 developer/message and developer role must occur together')
}
assertContent(value['content'])
}
/**
* Validate native V4 developer messages, tool-change blocks, and deferred tool schemas.
* Additional developer JSON fields are preserved. Malformed legacy message
* slots remain available to recoverable decoding.
* @param event - logical event or physical row before source-event range decoding.
*/
export function assertV4DeveloperData(event: SessionFormatEvent): void {
const data = event.data
if (!isSessionFormatJsonObject(data)) {
if (event.type === 'developer/message') throw new SessionFormatError('format v4 developer/message data must be an object')
return
}
if (event.type === 'developer/message') {
const message = data['message']
if (!isSessionFormatJsonObject(message) || message['role'] !== 'developer') {
throw new SessionFormatError('format v4 developer/message requires turn, step, and a developer message')
}
for (const field of ['turn', 'step']) {
if (sessionFormatCount(data[field], `developer/message ${field}`) === 0) {
throw new SessionFormatError(`developer/message ${field} must be positive`)
}
}
assertDeveloperMessage(message)
const content = message['content'] as SessionFormatJsonValue[]
const hasAdditions = content.some(block => isSessionFormatJsonObject(block) && block['type'] === 'tool-addition')
if (hasAdditions) {
sessionFormatCount(data['headerSeq'], 'developer/message headerSeq')
} else if (Object.hasOwn(data, 'headerSeq')) {
throw new SessionFormatError('format v4 developer/message must omit headerSeq without tool additions')
}
} else {
switch (event.type) {
case 'user/message':
assertOrdinaryMessage(data)
break
case 'system/message':
case 'assistant/message':
case 'tool/result':
assertOrdinaryMessage(data['message'])
break
case 'agent/inbox/spliced':
case 'session/title-llm-request': {
const messages = data[event.type === 'agent/inbox/spliced' ? 'inserted' : 'messages']
if (Array.isArray(messages)) messages.forEach(assertOrdinaryMessage)
break
}
}
}
if (event.type === 'compaction/summary') {
assertContent(data['summary'])
assertContent(data['rawOutput'])
}
if ((event.type === 'assistant/message' || event.type === 'assistant/attempt') && Array.isArray(data['stream'])) {
for (const entry of data['stream']) {
if (!isSessionFormatJsonObject(entry) || entry['type'] !== 'chunk') continue
const chunk = entry['chunk']
if (!isSessionFormatJsonObject(chunk)) continue
if (chunk['type'] === 'block-end') assertToolChange(chunk['block'], false)
if (chunk['type'] === 'block-start' && (chunk['blockType'] === 'tool-addition' || chunk['blockType'] === 'tool-removal')) {
throw new SessionFormatError('format v4 tool-change blocks require developer role')
}
}
}
if (event.type === 'request/header' && isSessionFormatJsonObject(data['header'])) {
const header = data['header']
const tools = header['tools']
if (Array.isArray(tools)) {
for (const tool of tools) {
if (!isSessionFormatJsonObject(tool) || !Object.hasOwn(tool, 'deferLoading')) continue
if (tool['deferLoading'] !== true) throw new SessionFormatError('format v4 tool deferLoading must be true when present')
}
}
}
}
@@ -4,8 +4,8 @@ import { isDeepStrictEqual } from 'node:util'
import { SessionFormatError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
const SURFACE_TYPES = new Set(['system/message', 'user/message', 'assistant/message', 'tool/result'])
const STEP_EVENT_TYPES = new Set(['system/message', 'assistant/attempt'])
const SURFACE_TYPES = new Set(['system/message', 'user/message', 'developer/message', 'assistant/message', 'tool/result'])
const STEP_EVENT_TYPES = new Set(['system/message', 'developer/message', 'assistant/attempt'])
const RELATIONSHIP_TYPES = new Set([
...SURFACE_TYPES, ...STEP_EVENT_TYPES, 'turn/start', 'turn/end', 'step/start', 'step/end',
'tool/call', 'request/header', 'request/context', 'tool/ptc-dispatch-start', 'tool/ptc-dispatch',
@@ -93,6 +93,34 @@ class Relationships {
this.tools.clear()
}
developer(event: SessionFormatEvent, data: SessionFormatJsonObject): void {
if (data['headerSeq'] === undefined) return
const headerSeq = earlier(data['headerSeq'], event.seq, 'developer/message headerSeq')
const headerEvent = this.artifact.events[headerSeq]
if (headerEvent?.type !== 'request/header') {
throw new SessionFormatError('developer/message headerSeq must reference an earlier request/header')
}
if (!this.knownEventTypes.has(headerEvent.type)) {
throw new SessionFormatError('developer/message headerSeq must reference a known request/header')
}
const tools = record(record(headerEvent.data, 'request/header data')['header'], 'request header')['tools']
const content = array(record(data['message'], 'developer message')['content'], 'developer content')
for (const value of content) {
if (!isSessionFormatJsonObject(value) || value['type'] !== 'tool-addition') continue
const toolName = value['toolName'] as string
const definitions = Array.isArray(tools)
? tools.filter(tool => isSessionFormatJsonObject(tool) && tool['name'] === toolName)
: []
if (definitions.length !== 1) {
throw new SessionFormatError(`developer/message tool-addition "${toolName}" must name exactly one tool in headerSeq ${headerSeq}`)
}
const definition = definitions[0] as SessionFormatJsonObject
if (typeof definition['description'] !== 'string' || !isSessionFormatJsonObject(definition['parameters'])) {
throw new SessionFormatError(`developer/message tool-addition "${toolName}" requires a complete tool definition in headerSeq ${headerSeq}`)
}
}
}
foldSurface(event: SessionFormatEvent): void {
if (!SURFACE_TYPES.has(event.type)) return
if (event.type === 'system/message' && this.surface.length > 0 && this.protectedHead === undefined) {
@@ -267,6 +295,7 @@ class Relationships {
this.nextStep += 1
break
case 'assistant/message': case 'tool/call': case 'tool/result': this.tool(event, data); break
case 'developer/message': this.developer(event, data); break
case 'request/header':
this.requireTurn(event.type)
this.provider = record(record(data['header'], 'request header')['config'], 'request config')['provider']
@@ -19,7 +19,7 @@ export function mapEventMessages(
const message = transform(data)
return message === data ? event : { ...event, data: message }
}
if (event.type === 'system/message' || event.type === 'assistant/message' || event.type === 'tool/result') {
if (event.type === 'developer/message' || event.type === 'system/message' || event.type === 'assistant/message' || event.type === 'tool/result') {
if (!isSessionFormatJsonObject(data['message'])) throw new SessionFormatError(event.type + ' requires a message')
const message = transform(data['message'])
return message === data['message'] ? event : { ...event, data: { ...data, message } }
@@ -46,7 +46,7 @@ const RENAMED_PRODUCERS: Readonly<Record<string, string>> = Object.freeze({
/** Current producer kinds whose names are not released V3 plugin identities. */
const CURRENT_PRODUCER_KINDS: ReadonlySet<string> = new Set([
'user', 'model', 'tool', 'system-prompt',
'user', 'model', 'tool', 'system-prompt', 'tool-registry',
'runtime-context', 'compact-checkpoint', 'ptc-mode', 'compact-basic',
'agent-instructions', 'session-reference', 'team-message', 'goal',
'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report',
@@ -3,6 +3,7 @@
import { isAbsolute } from 'node:path'
import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatArtifact, SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
import { assertV4DeveloperData } from './developer.ts'
import { assertV4LifecycleRelationships } from './relationships.ts'
import { assertV4MessageSources } from './message-sources.ts'
import { catalogFact } from './facts.ts'
@@ -99,7 +100,8 @@ export function validateDeliveryAccepted(event: SessionFormatEvent, currentVersi
}
/**
* Validate native message sources, lifecycle, catalog, and delivery relationships without changing event vocabulary or tail recovery.
* Validate native developer fields, message sources, lifecycle, catalog, and delivery
* relationships without changing event vocabulary or tail recovery.
* The owning admission stage rejects unknown required events;
* unknown ignorable records retain their uninterpreted payloads.
* @param artifact - decoded artifact with its final inherited cut.
@@ -109,6 +111,7 @@ export function assertReleasedV4Relationships(artifact: SessionFormatArtifact, k
const ids = new Set<string>()
for (const event of artifact.events) {
if (!knownEventTypes.has(event.type)) continue
assertV4DeveloperData(event)
assertV4MessageSources(event)
const deliveryId = validateDeliveryAccepted(event, 4)
if (deliveryId !== undefined
@@ -9,16 +9,16 @@ import { releasedV4SessionFormatCodec as codec, restoreReleasedV4Artifact } from
const header = { version: 4, id: 'unknown-attribution', createdAt: 1, isSeeded: false, delegationDepth: 0 }
describe('uninstalled producer attribution', () => {
it('preserves user source metadata through decoding, adoption and derivation', () => {
it.each(['user', 'developer'] as const)('preserves %s source metadata through decoding, adoption and derivation', (role) => {
const message = {
id: 'attributed-message', role: 'user',
id: 'attributed-message', role,
source: { kind: 'external-attribution', location: { file: 'notes.txt', lines: [2, 5] }, enabled: false },
content: [{ type: 'text', text: 'Retain this context.' }],
}
const input: SessionFormatEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'user/message', seq: 2, time: 3, surfaceOp: 'append', data: message },
{ type: `${role}/message`, seq: 2, time: 3, surfaceOp: 'append', data: role === 'user' ? message : { turn: 1, step: 1, message } },
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
]
@@ -0,0 +1,112 @@
/** Developer surface positions and admission remain distinct from unknown ignorable records. */
import { describe, expect, it } from 'vitest'
import { Session, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
import { assertV4RowAdmission, releasedV4SessionFormatCodec as codec, restoreReleasedV4Artifact } from '../src/index.ts'
type Row = {
type: string
data: SessionFormatJsonValue
surfaceOp?: SessionFormatJsonValue
sourceEventSeqs?: SessionFormatJsonValue
ignorable?: true
}
const coordinates = { turn: 1, step: 1 }
const begin: Row[] = [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'step/start', data: coordinates },
]
const system: Row = { type: 'system/message', surfaceOp: 'append', data: {
...coordinates, message: { id: 'system', role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'System' }] },
} }
const emptyDeveloper: Row = { type: 'developer/message', surfaceOp: 'append', data: {
...coordinates, message: { id: 'developer', role: 'developer', source: { kind: 'tool-registry' }, content: [] },
} }
function artifact(rows: readonly Row[]): SessionFormatArtifact {
return {
header: { version: 4, id: 'developer-relations', createdAt: 1, isSeeded: false, delegationDepth: 0 },
events: rows.map((row, seq): SessionFormatEvent => ({ ...row, seq, time: seq + 1 })),
inheritedEventCount: 0,
}
}
function restore(rows: readonly Row[]): SessionFormatArtifact {
const input = artifact(rows)
return restoreReleasedV4Artifact(input, new Set(input.events.map(event => event.type)))
}
describe('V4 developer relationship admission', () => {
it('retains an empty developer position while replacing the protected system head', () => {
const replacement: Row = { ...system, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 2 }, sourceEventSeqs: [2], data: {
...coordinates, message: { id: 'replacement', role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'Updated' }] },
} }
const input = restore([...begin, system, emptyDeveloper, replacement])
const session = Session.fromRestore(SessionId(input.header.id), input.events as SessionEvent[],
input.header as unknown as SessionHeader, SessionLogOffset(0), 'detached')
expect(session.surface.nodes).toEqual([4, 3])
expect(session.deriveMessages().map(message => message.id)).toEqual(['replacement'])
expect(input.events[3]?.data).toEqual(emptyDeveloper.data)
})
it('rejects an empty developer replacement of the protected system head', () => {
const replacement = { ...emptyDeveloper, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 2 }, sourceEventSeqs: [2] }
expect(() => restore([...begin, system, replacement])).toThrow(/protected system head/)
})
it('includes empty developer nodes in compaction spans and protects the system head', () => {
const prune: Row = { type: 'compaction/prune', data: {
shadowedRange: { start: 3, end: 3 }, shadowedSeqs: [3], shadowedTokenCount: 0,
} }
expect(() => restore([...begin, system, emptyDeveloper, prune])).not.toThrow()
expect(() => restore([...begin, system, emptyDeveloper, { ...prune, data: {
shadowedRange: { start: 2, end: 3 }, shadowedSeqs: [2, 3], shadowedTokenCount: 0,
} }])).toThrow(/protected system head/)
})
it.each([
{ rows: [begin[0]!, emptyDeveloper] },
{ rows: [...begin, { ...emptyDeveloper, data: { ...coordinates, step: 2, message: {
id: 'developer', role: 'developer', source: { kind: 'tool-registry' }, content: [],
} } }] },
])('requires known developer messages to match the current open step %#', ({ rows }) => {
expect(() => restore(rows)).toThrow(/open.*step/)
})
it('defers ignorable developer payloads during physical decoding while keeping encoder validation', () => {
const opaque: Row = { type: 'developer/message', ignorable: true, data: {
message: { role: 'developer', source: { kind: 'plugin' } }, future: true,
}, surfaceOp: { future: true } }
const input = artifact([opaque, ...begin, system])
const physicalHeader = codec.encodeHeader(input.header, 0)
const decoded = new SessionFormatEventCollector()
const decoder = codec.createDecoder(physicalHeader, 'strict')
for (const event of input.events) decoder.decodeRow(event, decoded)
decoder.finish(decoded)
expect(decoded.values).toEqual(input.events)
expect(() => codec.encodeEvent(input.events[0]!)).toThrow()
expect(() => { assertV4RowAdmission(input.events[0], new Set()) }).not.toThrow()
expect(() => { assertV4RowAdmission(input.events[0], new Set(['developer/message'])) }).toThrow()
const known = { ...input.events[0]!, data: emptyDeveloper.data }
expect(codec.encodeEvent(known)).toMatchObject({ type: 'developer/message', ignorable: true })
})
it('keeps unknown ignorable developer fields and coordinates opaque before an ordinary step', () => {
const opaque: Row = { type: 'developer/message', ignorable: true, data: {
message: { role: 'developer', source: { kind: 'plugin' } }, future: true,
}, surfaceOp: { future: true }, sourceEventSeqs: { future: true } }
const input = artifact([opaque, ...begin, system])
const before = JSON.stringify(input)
const known = new Set(input.events.map(event => event.type))
known.delete('developer/message')
expect(restoreReleasedV4Artifact(input, known)).toBe(input)
expect(JSON.stringify(input)).toBe(before)
expect(input.events.map(event => event.seq)).toEqual([0, 1, 2, 3])
known.add('developer/message')
expect(() => restoreReleasedV4Artifact(input, known)).toThrow()
})
})
@@ -0,0 +1,302 @@
/** Developer messages and deferred schemas survive native V4 persistence. */
import { describe, expect, it } from 'vitest'
import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
import type { SessionFormatEvent, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import { assertV4RowAdmission, releasedV4SessionFormatCodec as codec, restoreReleasedV4Artifact } from '../src/index.ts'
const header = { version: 4, id: 'developer', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const message = { id: 'developer-1', role: 'developer', source: { kind: 'tool-registry' }, content: [{ type: 'tool-addition', toolName: 'search' }, { type: 'tool-removal', toolName: 'old' }] }
const developer: SessionFormatEvent = { type: 'developer/message', seq: 3, time: 4, surfaceOp: 'append', data: { turn: 1, step: 1, headerSeq: 2, message } }
const request: SessionFormatEvent = { type: 'request/header', seq: 2, time: 3, data: {
reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [{ name: 'search', description: 'Search', parameters: {}, deferLoading: true }] },
} }
function events(): SessionFormatEvent[] {
return [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
request, developer,
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
]
}
function roundTrip(input: SessionFormatEvent[]): SessionFormatEvent[] {
const decoder = codec.createDecoder(codec.encodeHeader(header, 0), 'strict')
const output = new SessionFormatEventCollector()
for (const event of input) decoder.decodeRow(codec.encodeEvent(event), output)
decoder.finish(output)
return output.values
}
describe('V4 developer messages', () => {
it('round trips native messages and deferred schemas without rewriting native payloads', () => {
const input = events()
expect(roundTrip(input)).toEqual(input)
const artifact = { header, events: input, inheritedEventCount: 0 }
expect(restoreReleasedV4Artifact(artifact, new Set(input.map(event => event.type)))).toBe(artifact)
})
it('preserves additional JSON fields in developer data, messages, sources, and blocks', () => {
const input = events()
input[3] = { ...developer, data: { turn: 1, step: 1, headerSeq: 2, metadata: { future: true }, message: {
...message, metadata: ['retained'], source: { kind: 'tool-registry', future: 1 }, content: [
{ type: 'tool-addition', toolName: 'search', future: true },
{ type: 'tool-removal', toolName: 'old', future: true },
],
} } }
input[2] = { ...request, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [{ name: 'search', description: 'Search', parameters: {}, deferLoading: true, future: { retained: true } }] } } }
expect(roundTrip(input)).toEqual(input)
const artifact = { header, events: input, inheritedEventCount: 0 }
expect(restoreReleasedV4Artifact(artifact, new Set(input.map(event => event.type)))).toBe(artifact)
})
it.each([undefined, null, '', 1])('rejects a malformed addition name %#', (toolName) => {
const row = { ...developer, data: { turn: 1, step: 1, headerSeq: 2, message: {
...message, content: [{ type: 'tool-addition', ...(toolName === undefined ? {} : { toolName }) }],
} } }
expect(() => codec.encodeEvent(row)).toThrow('nonempty toolName')
expect(() => { assertV4RowAdmission(row) }).toThrow('nonempty toolName')
})
it('refuses an inline definition even when a header and name are present', () => {
const row = { ...developer, data: { turn: 1, step: 1, headerSeq: 2, message: {
...message, content: [{ type: 'tool-addition', toolName: 'search', tool: { name: 'search', description: 'Duplicate', parameters: {} } }],
} } }
expect(() => codec.encodeEvent(row)).toThrow('omit inline tool definitions')
expect(() => { assertV4RowAdmission(row) }).toThrow('omit inline tool definitions')
})
it.each([undefined, null, -1, -0, 1.5, '2'])('rejects an absent or invalid addition header sequence %#', (headerSeq) => {
const row = { ...developer, data: { turn: 1, step: 1, message, ...(headerSeq === undefined ? {} : { headerSeq }) } }
expect(() => codec.encodeEvent(row)).toThrow('headerSeq')
expect(() => { assertV4RowAdmission(row) }).toThrow('headerSeq')
})
it('requires removal-only messages to omit the header sequence', () => {
const row = { ...developer, data: { turn: 1, step: 1, headerSeq: 2, message: {
...message, content: [{ type: 'tool-removal', toolName: 'search' }],
} } }
expect(() => codec.encodeEvent(row)).toThrow('omit headerSeq')
})
it.each([0, 3, 50])('refuses a non-header or non-earlier schema reference %s', (headerSeq) => {
const input = events()
input[3] = { ...developer, data: { turn: 1, step: 1, headerSeq, message } }
expect(() => restoreReleasedV4Artifact({ header, events: input, inheritedEventCount: 0 }, new Set(input.map(event => event.type))))
.toThrow(/headerSeq.*earlier/)
})
it.each([
{ tools: undefined },
{ tools: [null] },
{ tools: [{ name: 'different', description: '', parameters: {} }] },
{ tools: [{ name: 'search', description: 'First', parameters: {} }, { name: 'search', description: 'Second', parameters: {} }] },
])('refuses missing or ambiguous names in the referenced header %#', ({ tools }) => {
const input = events()
input[2] = { ...request, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, ...(tools === undefined ? {} : { tools }) } } }
expect(() => restoreReleasedV4Artifact({ header, events: input, inheritedEventCount: 0 }, new Set(input.map(event => event.type))))
.toThrow('exactly one tool')
})
it.each([
{ name: 'search' },
{ name: 'search', description: null, parameters: {} },
{ name: 'search', description: '', parameters: [] },
{ name: 'search', description: '', parameters: null },
])('refuses an incomplete referenced definition %#', (tool) => {
const input = events()
input[2] = { ...request, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [tool] } } }
expect(() => restoreReleasedV4Artifact({ header, events: input, inheritedEventCount: 0 }, new Set(input.map(event => event.type))))
.toThrow('complete tool definition')
})
it.each([null, request.data])('does not interpret an unknown ignorable header referenced by a known developer event %#', (data) => {
const input = events()
input[2] = { ...request, ignorable: true, data }
const known = new Set(input.map(event => event.type))
known.delete('request/header')
expect(() => restoreReleasedV4Artifact({ header, events: input, inheritedEventCount: 0 }, known)).toThrow('known request/header')
input[3] = { ...developer, ignorable: true, data: { opaque: true, headerSeq: { future: true } }, surfaceOp: { future: true } }
known.delete('developer/message')
const artifact = { header, events: input, inheritedEventCount: 0 }
expect(restoreReleasedV4Artifact(artifact, known)).toBe(artifact)
})
it('keeps historical schema references when compaction shadows their developer nodes', () => {
const input = [
...events().slice(0, 4),
{ type: 'compaction/prune', seq: 4, time: 5, data: { shadowedRange: { start: 3, end: 3 }, shadowedSeqs: [3], shadowedTokenCount: 0 } },
...events().slice(4).map(event => ({ ...event, seq: event.seq + 1 })),
]
const artifact = { header, events: input, inheritedEventCount: 0 }
expect(restoreReleasedV4Artifact(artifact, new Set(input.map(event => event.type)))).toBe(artifact)
expect(input[2]).toEqual(request)
})
it.each([null, [], 'invalid'])('refuses non-object developer data %j before recovery', (data) => {
const row = { ...developer, data }
expect(() => codec.encodeEvent(row)).toThrow('developer/message data must be an object')
expect(() => { assertV4RowAdmission(row) }).toThrow('developer/message data must be an object')
})
it.each([{ turn: 1, step: 1 }, { turn: 1, step: 1, message: null }])(
'rejects malformed developer event fields %#', (data) => {
expect(() => codec.encodeEvent({ ...developer, data })).toThrow('requires turn, step, and a developer message')
},
)
it('leaves malformed legacy stream chunks to canonical decoding', () => {
expect(() => { assertV4RowAdmission({ type: 'assistant/attempt', seq: 2, time: 3,
data: { stream: [{ type: 'chunk', chunk: null }] },
}) }).not.toThrow()
})
it.each(['agent/inbox/spliced', 'session/title-llm-request'])('leaves malformed ordinary arrays in %s to canonical decoding', (type) => {
const key = type === 'agent/inbox/spliced' ? 'inserted' : 'messages'
expect(() => { assertV4RowAdmission({ type, seq: 2, time: 3, data: { [key]: null } }) }).not.toThrow()
})
it.each([false, true])('preserves ordinary tools alongside deferred tools: %s', (mixed) => {
const ordinary = { name: 'ordinary', description: 'Ordinary', parameters: {} }
const tools = mixed ? [ordinary, { ...ordinary, name: 'deferred', deferLoading: true }] : [ordinary]
const input = events()
input[2] = { ...request, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools } } }
expect(roundTrip(input)).toEqual(input)
})
it('does not interpret tool entries without deferred markers', () => {
const row = { ...request, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [null] } } }
expect(() => { assertV4RowAdmission(row) }).not.toThrow()
})
it('preserves the historical header while replacing a developer surface node', () => {
const replacement = { ...developer, seq: 4, time: 5, surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 }, sourceEventSeqs: [3],
data: { turn: 1, step: 1, headerSeq: 2, message: { ...message, id: 'developer-2' } } }
const input = [...events().slice(0, 4), replacement, ...events().slice(4).map(event => ({ ...event, seq: event.seq + 1 }))]
expect(roundTrip(input)).toEqual(input)
const restored = restoreReleasedV4Artifact({ header, events: input, inheritedEventCount: 0 }, new Set(input.map(event => event.type)))
expect(restored.events).toEqual(input)
})
it.each([false, null, 'true', 1])('rejects invalid deferred marker %j', (deferLoading) => {
const row = { ...request, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [{ name: 'search', description: '', parameters: {}, deferLoading }] } } }
expect(() => codec.encodeEvent(row)).toThrow('deferLoading')
expect(() => { assertV4RowAdmission(row) }).toThrow('deferLoading')
})
it.each([
{ ...message, role: 'user' },
{ ...message, id: '' },
{ ...message, source: { kind: 'plugin' } },
{ ...message, content: [{ type: 'tool-addition', toolName: '' }] },
{ ...message, content: [{ type: 'tool-removal', toolName: '' }] },
])('rejects malformed developer message %#', (invalid) => {
const row = { ...developer, data: { turn: 1, step: 1, message: invalid } }
expect(() => codec.encodeEvent(row)).toThrow()
expect(() => { assertV4RowAdmission(row) }).toThrow()
})
it('preserves developer messages inside an inherited prefix', () => {
const inheritedHeader = { ...header, isSeeded: true, parentSession: 'parent' }
const input = events()
const inherited = [
...input.slice(0, 4),
{ type: 'session/end-seed', seq: 4, time: 5, data: { inherited: true } },
...input.slice(4).map(event => ({ ...event, seq: event.seq + 1 })),
]
const decoder = codec.createDecoder(codec.encodeHeader(inheritedHeader, 4), 'strict')
const output = new SessionFormatEventCollector()
for (const event of inherited) decoder.decodeRow(codec.encodeEvent(event), output)
expect(decoder.finish(output)).toBe(4)
expect(output.values).toEqual(inherited)
const artifact = { header: inheritedHeader, events: output.values, inheritedEventCount: 4 }
expect(restoreReleasedV4Artifact(artifact, new Set(inherited.map(event => event.type)))).toBe(artifact)
})
it('refuses developer history when the reader does not know the event type', () => {
const input = events()
const known = new Set(input.map(event => event.type))
known.delete('developer/message')
expect(() => restoreReleasedV4Artifact({ header, events: input, inheritedEventCount: 0 }, known)).toThrow('unknown event type')
})
it('checks request coordinates and the open step', () => {
const input = events()
input[3] = { ...developer, data: { ...(developer.data as SessionFormatJsonObject), step: 2 } }
const known = new Set(input.map(event => event.type))
expect(() => restoreReleasedV4Artifact({ header, events: input, inheritedEventCount: 0 }, known)).toThrow(/open.*step/)
expect(() => codec.encodeEvent({ ...developer, data: { turn: 0, step: 1, message } })).toThrow('positive')
})
it.each(['user', 'system', 'assistant', 'tool'])('rejects tool-change blocks in %s messages', (role) => {
for (const type of ['tool-addition', 'tool-removal']) {
const source = role === 'system' ? { kind: 'system-prompt' }
: role === 'assistant' ? { kind: 'model', provider: 'test', model: 'test' }
: role === 'tool' ? { kind: 'tool', callId: 'call' } : { kind: 'user' }
const invalid = { ...message, role, source, content: [{ type, toolName: 'search' }],
...role === 'tool' ? { toolCallId: 'call', isError: false } : {},
}
const eventType = role === 'tool' ? 'tool/result' : `${role}/message`
const row = { ...developer, type: eventType, data: role === 'user' ? invalid : { turn: 1, step: 1, message: invalid } }
expect(() => codec.encodeEvent(row)).toThrow('developer role')
expect(() => { assertV4RowAdmission(row) }).toThrow('developer role')
const decoder = codec.createDecoder(codec.encodeHeader(header, 0), 'strict')
expect(() => { decoder.decodeRow(row, new SessionFormatEventCollector()) }).toThrow('developer role')
const input = events()
input[3] = row
const artifact = { header, events: input, inheritedEventCount: 0 }
expect(() => restoreReleasedV4Artifact(artifact, new Set(input.map(event => event.type)))).toThrow('developer role')
}
})
it.each(['user/message', 'system/message', 'assistant/message', 'tool/result'])('rejects developer role inside %s before payload recovery', (type) => {
const row = { ...developer, type, data: type === 'user/message' ? message : { turn: 1, step: 1, message } }
expect(() => codec.encodeEvent(row)).toThrow(/must occur together|requires a tool-role message/)
expect(() => { assertV4RowAdmission(row) }).toThrow('must occur together')
const decoder = codec.createDecoder(codec.encodeHeader(header, 0), 'strict')
expect(() => { decoder.decodeRow(row, new SessionFormatEventCollector()) }).toThrow('must occur together')
})
it.each(['agent/inbox/spliced', 'session/title-llm-request'])('rejects developer role in %s message arrays', (type) => {
const key = type === 'agent/inbox/spliced' ? 'inserted' : 'messages'
const row = { type, seq: 3, time: 4, data: { [key]: [null, message] } }
expect(() => codec.encodeEvent(row)).toThrow('must occur together')
expect(() => { assertV4RowAdmission(row) }).toThrow('must occur together')
const decoder = codec.createDecoder(codec.encodeHeader(header, 0), 'strict')
expect(() => { decoder.decodeRow(row, new SessionFormatEventCollector()) }).toThrow('must occur together')
const input = events()
input[3] = row
const artifact = { header, events: input, inheritedEventCount: 0 }
expect(() => restoreReleasedV4Artifact(artifact, new Set(input.map(event => event.type)))).toThrow('must occur together')
})
it('retains unknown ignorable developer records without interpreting their payload or surface', () => {
const input = events()
input[3] = { ...developer, ignorable: true, data: { future: true }, surfaceOp: { future: true } }
const known = new Set(input.map(event => event.type))
known.delete('developer/message')
const artifact = { header, events: input, inheritedEventCount: 0 }
expect(restoreReleasedV4Artifact(artifact, known)).toBe(artifact)
})
it('still validates known ignorable developer records', () => {
const input = events()
input[3] = { ...developer, ignorable: true, data: { turn: 1, step: 2, headerSeq: 2, message } }
const known = new Set(input.map(event => event.type))
expect(() => restoreReleasedV4Artifact({ header, events: input, inheritedEventCount: 0 }, known)).toThrow(/open.*step/)
})
it.each(['tool-addition', 'tool-removal'])('rejects %s outside developer content, including streamed blocks', (type) => {
const block = { type, toolName: 'search' }
const payloads = [
{ type: 'compaction/summary', data: { summary: [block] } },
{ type: 'compaction/summary', data: { rawOutput: [block] } },
{ type: 'assistant/attempt', data: { stream: [{ type: 'chunk', chunk: { type: 'block-end', block } }] } },
{ type: 'assistant/attempt', data: { stream: [{ type: 'chunk', chunk: { type: 'block-start', blockType: type } }] } },
{ type: 'agent/inbox/spliced', data: { inserted: [{ ...message, role: 'user', content: [block] }] } },
{ type: 'session/title-llm-request', data: { messages: [{ ...message, role: 'user', content: [block] }] } },
]
for (const payload of payloads) {
expect(() => { assertV4RowAdmission({ seq: 2, time: 3, ...payload }) }).toThrow('developer role')
}
})
})
@@ -77,6 +77,7 @@ function wireEvent(event: SessionEvent): DeepSeekSessionLogWireEvent {
...event.ignorable === undefined ? {} : { ignorable: event.ignorable },
}
switch (event.type) {
case 'developer/message':
case 'system/message':
case 'user/message':
case 'tool/result':
@@ -12,7 +12,7 @@ import SessionStore, {
} from '@deepseek-ai/dsh-session'
import { createSessionFormatCatalogWithChildren } from '@deepseek-ai/dsh-session-format-catalog'
import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import { createAssistantMessage, createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { createDeveloperMessage, createAssistantMessage, createSystemMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import * as SessionLogDeepSeek from '../src/index.ts'
import type { DeepSeekSessionLogExtension, DeepSeekSessionLogWireEvent, DeepSeekSessionLogWireSurfaceOp } from '../src/types.ts'
@@ -85,6 +85,19 @@ describe('incremental DeepSeek session-log upload', () => {
}>()
})
it('uploads developer changes with their placement and source-event references', async () => {
const { ctx, session } = await harness('wire-developer')
const message = createDeveloperMessage({ content: [{ type: 'tool-addition', toolName: 'search' }], source: { kind: 'tool-registry' } })
const headerSeq = session.append('request/header', { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [{ name: 'search', description: 'Search', parameters: {} }] } }).seq
const first = session.append('developer/message', { turn: 1, step: 1, headerSeq, message }, { surfaceOp: 'append' })
session.append('developer/message', {
turn: 1, step: 1,
message: createDeveloperMessage({ content: [{ type: 'tool-removal', toolName: 'search' }], source: { kind: 'tool-registry' } }),
}, { surfaceOp: { op: 'replace', startSeq: first.seq, endSeq: first.seq }, sourceEventSeqs: [first.seq] })
const prepared = await ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: session.id })
expect(prepared.fields.dsh_session_log?.events).toEqual(session.snapshotEvents())
})
it('uploads Assistant provider metadata only through its embedded stream', async () => {
const { ctx, session } = await harness('wire-assistant')
const assistant = session.append('assistant/message', {
@@ -490,7 +490,7 @@ export class SessionLogScanner {
// This scanner accepts only current-generation files. Owned structural refusal must
// precede its recoverable-tail suppression, independently of the strict decoder state.
try {
assertV4RowAdmission(decoded)
assertV4RowAdmission(decoded, KNOWN_SESSION_EVENT_TYPES)
} catch (error: unknown) {
if (error instanceof SessionFormatUnsupportedMigrationError) throw new SessionFormatUnsupportedError(error.message)
throw error
@@ -2203,6 +2203,32 @@ describe('JsonlSessionPersistence: scanLog unit', () => {
expect(() => scanLog(Buffer.from(log))).toThrow(/seq gap/)
})
it.each([
['assistant/message', {}, /assistant\/message requires a message/],
['agent/inbox/spliced', { inserted: null }, /agent\/inbox\/spliced requires message array/],
['session/title-llm-request', { messages: [null] }, /session\/title-llm-request requires message objects/],
])('rejects malformed %s message slots before exposing a native scan', (type, data, error) => {
const prefix = [
{ type: 'session', version: SESSION_FORMAT_VERSION, id: 'malformed-tail', createdAt: 1, isSeeded: false, delegationDepth: 0 },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
].map(row => JSON.stringify(row)).join('\n') + '\n'
const malformed = JSON.stringify({ type, seq: 1, time: 2, data }) + '\n'
expect(() => scanLog(Buffer.from(prefix + malformed))).toThrow(error)
const committed = JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }) + '\n'
expect(() => scanLog(Buffer.from(prefix + malformed + committed))).toThrow(error)
})
it.each([false, true])('rejects malformed native tool results before recovery (sealed=%s)', (sealed) => {
const rows = [
{ type: 'session', version: SESSION_FORMAT_VERSION, id: 'malformed-tool', createdAt: 1, isSeeded: false, delegationDepth: 0 },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'tool/result', seq: 1, time: 2, data: { message: [] } },
...(sealed ? [{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }] : []),
]
const log = rows.map(row => JSON.stringify(row)).join('\n') + '\n'
expect(() => scanLog(Buffer.from(log))).toThrow(/tool\/result.*message must be an object/)
})
it('rejects malformed records before a later committed turn/end', () => {
const corruptRecords = [
['{not json', /unparsable committed event/],
@@ -0,0 +1,114 @@
/** Native JSONL reads enforce developer relationships before exposing stored data. */
import { Context } from '@deepseek-ai/cordis'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import { SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, onTestFinished } from 'vitest'
import { generationLogPath } from '../src/format.ts'
import { compressZstdFrame } from '../src/zstd.ts'
const id = SessionId('native-developer-admission')
const modes = (['none', 'zstd'] as const).flatMap(compression => (['read', 'write'] as const).map(access => ({ compression, access })))
const coordinates = { turn: 1, step: 1 }
const developer = { type: 'developer/message', surfaceOp: 'append', data: {
...coordinates, message: { id: 'developer', role: 'developer', source: { kind: 'tool-registry' }, content: [], extra: { retained: true } },
} }
describe.each(modes)('native developer admission ($compression, $access)', ({ compression, access }) => {
async function stored(event: object, corruptPrefix = false) {
const root = await mkdtemp(join(tmpdir(), 'dsh-native-developer-'))
const ctx = new Context()
onTestFinished(async () => {
try {
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
}
})
const events = [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'step/start', data: coordinates },
{ type: 'system/message', surfaceOp: 'append', data: {
...coordinates, message: { id: 'system', role: 'system', source: { kind: 'system-prompt' }, content: [{ type: 'text', text: 'System' }] },
} },
{ type: 'request/header', data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [{ name: 'search', description: 'Historical', parameters: {} }] } } },
event,
].map((row, seq) => ({ ...row, seq, time: seq + 1 }))
const header = { type: 'session', version: SESSION_FORMAT_VERSION, id, createdAt: 1, delegationDepth: 0, isSeeded: false }
const lines = [header, ...events].map(value => JSON.stringify(value) + '\n')
if (corruptPrefix) lines.splice(-1, 0, '{invalid json}\n')
const bytes = compression === 'none' ? Buffer.from(lines.join('')) : Buffer.concat(await Promise.all(lines.map(compressZstdFrame)))
const path = generationLogPath(root, undefined, id, SESSION_FORMAT_VERSION, compression)
await mkdir(dirname(path), { recursive: true })
await writeFile(path, bytes)
await ctx.plugin(JsonlSessionPersistence, { root, compression })
return { ctx, path, bytes, events }
}
it.each([false, true])('preserves an empty developer node and additional metadata on a valid open step (ignorable: %s)', async (ignorable) => {
const { ctx, path, bytes, events } = await stored({ ...developer, ...(ignorable ? { ignorable: true } : {}) })
const handle = await ctx.sessionPersistence.open(id, access)
try {
expect((await handle.read()).events).toEqual(events)
} finally {
await handle.close()
}
expect(await readFile(path)).toEqual(bytes)
})
it('reads name-only additions bound to their historical header without copying definitions', async () => {
const event = { ...developer, data: { ...developer.data, headerSeq: 3, message: {
...developer.data.message, content: [{ type: 'tool-removal', toolName: 'old' }, { type: 'tool-addition', toolName: 'search' }],
} } }
const { ctx, path, bytes, events } = await stored(event)
const handle = await ctx.sessionPersistence.open(id, access)
try {
expect((await handle.read()).events).toEqual(events)
} finally {
await handle.close()
}
expect(await readFile(path)).toEqual(bytes)
})
it.each([
{ headerSeq: 0, toolName: 'search', error: /earlier request\/header/ },
{ headerSeq: 4, toolName: 'search', error: /must name an earlier event/ },
{ headerSeq: 3, toolName: 'absent', error: /exactly one tool/ },
])('refuses invalid historical schema binding %# without rewriting the generation', async ({ headerSeq, toolName, error }) => {
const event = { ...developer, data: { ...developer.data, headerSeq, message: {
...developer.data.message, content: [{ type: 'tool-addition', toolName }],
} } }
const { ctx, path, bytes } = await stored(event)
const opened = ctx.sessionPersistence.open(id, access).then(async (handle) => { await handle.close() })
await expect(opened).rejects.toThrow(error)
expect(await readFile(path)).toEqual(bytes)
})
it('refuses malformed known ignorable developer data even after a corrupt row', async () => {
const invalid = { ...developer, ignorable: true, data: { ...developer.data, message: {
...developer.data.message, content: [{ type: 'tool-addition', toolName: 'search', tool: null }],
} } }
const { ctx, path, bytes } = await stored(invalid, true)
const opened = ctx.sessionPersistence.open(id, access).then(async (handle) => { await handle.close() })
await expect(opened).rejects.toThrow('omit inline tool definitions')
expect(await readFile(path)).toEqual(bytes)
})
it.each([
{ name: 'mismatched step', event: { ...developer, data: { ...developer.data, step: 2 } }, error: /open.*step/ },
{ name: 'protected system head', event: {
...developer, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 2 }, sourceEventSeqs: [2],
}, error: /protected system head/ },
])('refuses a developer event with $name without rewriting the generation', async ({ event, error }) => {
const { ctx, path, bytes } = await stored(event)
const opened = ctx.sessionPersistence.open(id, access).then(async (handle) => { await handle.close() })
await expect(opened).rejects.toBeInstanceOf(SessionPersistenceCorruptionError)
await expect(opened).rejects.toThrow(error)
expect(await readFile(path)).toEqual(bytes)
expect((await readdir(dirname(path))).filter(name => name !== 'session.lock')).toHaveLength(1)
})
})
@@ -646,6 +646,7 @@ function surfaceEventMessage(record: Record<string, unknown>): Record<string, un
message = data
break
case 'system/message':
case 'developer/message':
case 'assistant/message':
case 'tool/result':
message = data.message
@@ -946,6 +946,15 @@ describe('stabilizeFixtureMessageIds', () => {
}
})
it('stabilizes developer message identities through their explicit surface case', () => {
const freshId = '11111111-1111-4111-8111-111111111111'
const oldId = '22222222-2222-4222-8222-222222222222'
const log = (id: string) => JSON.stringify({ type: 'developer/message', data: { turn: 1, step: 1, message: {
id, role: 'developer', source: { kind: 'tool-registry' }, content: [{ type: 'tool-addition', toolName: 'search' }],
} } }) + '\n'
expect(stabilizeFixtureMessageIds([log(freshId)], [log(oldId)])).toEqual([log(oldId)])
})
it('rewrites only complete messages carried by surface events or durable inbox splices', () => {
const ids = {
freshUser: '11111111-1111-4111-8111-111111111111',
+68
View File
@@ -0,0 +1,68 @@
/** Developer field classification matches native admission and metadata preservation. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { Session, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import { classifyPersistenceChange, parsePersistenceSnapshot } from './persistence-changes.ts'
function toolAdditionSchema() {
const inventory = parsePersistenceSnapshot(JSON.parse(readFileSync(new URL('../docs/persistence-schema.json', import.meta.url), 'utf8')) as unknown)
const before = inventory.roots.find(root => root.key === 'event:developer/message')!
const nodes = [...structuredClone(before.schema.nodes)]
const blockIndex = nodes.findIndex(node => node.kind === 'object' && node.properties.some((property) => {
const type = nodes[property.type]
return property.name === 'type' && type?.kind === 'literal' && type.value === 'tool-addition'
}))
const block = nodes[blockIndex]!
if (block.kind !== 'object') throw new Error('missing tool-addition schema')
const stringIndex = nodes.findIndex(node => node.kind === 'primitive' && node.type === 'string')
return { before, nodes, blockIndex, block, stringIndex }
}
describe('developer field compatibility', () => {
it('requires a version bump before a future declaration can use the rejected inline tool field', () => {
const { before, nodes, blockIndex, block, stringIndex } = toolAdditionSchema()
nodes[blockIndex] = { ...block, properties: [
...block.properties.filter(property => property.name !== 'tool'),
{ name: 'tool', type: stringIndex, optional: true },
] }
const schema = canonicalizeSchema(nodes, 0)
expect(classifyPersistenceChange(before, { ...before, schema, digest: schemaDigest(schema) }))
.toContainEqual(expect.objectContaining({ requiresVersionBump: true }))
const reserved = block.properties.find(property => property.name === 'tool')
expect(reserved).toMatchObject({ optional: true })
expect(before.schema.nodes[reserved!.type]).toEqual({ kind: 'primitive', type: 'never' })
const event: SessionFormatEvent = { type: 'developer/message', seq: 3, time: 4, surfaceOp: 'append', data: {
turn: 1, step: 1, headerSeq: 2, message: { id: 'legacy-inline', role: 'developer', source: { kind: 'tool-registry' },
content: [{ type: 'tool-addition', toolName: 'search', tool: 'optional metadata' }],
},
} }
expect(() => sessionFormatCatalog.encodeCurrentEvent(event)).toThrow('omit inline tool definitions')
})
it('accepts and preserves a tool-addition field classified as optional', () => {
const { before, nodes, blockIndex, block, stringIndex } = toolAdditionSchema()
nodes[blockIndex] = { ...block, properties: [...block.properties, { name: 'traceId', type: stringIndex, optional: true }] }
const schema = canonicalizeSchema(nodes, 0)
expect(classifyPersistenceChange(before, { ...before, schema, digest: schemaDigest(schema) })).toMatchObject([{ kind: 'optional-property-added', requiresVersionBump: false }])
const header = { version: 4, id: 'optional-developer-field', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const message = { id: 'developer', role: 'developer', source: { kind: 'tool-registry', extra: true },
content: [{ type: 'tool-addition', toolName: 'search', traceId: 'retained' }], extra: true }
const events: SessionFormatEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 2, time: 3, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [{ name: 'search', description: 'Search', parameters: {} }] } } },
{ type: 'developer/message', seq: 3, time: 4, surfaceOp: 'append', data: { turn: 1, step: 1, headerSeq: 2, message, extra: true } },
]
const reader = sessionFormatCatalog.createRestore(sessionFormatCatalog.encodeCurrentHeader(header, 0), { recovery: 'strict', validation: 'current' })
for (const event of events) reader.decodeRow(sessionFormatCatalog.encodeCurrentEvent(event))
const artifact = reader.finish()
expect(artifact.events).toEqual(events)
const restored = Session.fromRestore(SessionId(header.id), artifact.events as SessionEvent[],
artifact.header as unknown as SessionHeader, SessionLogOffset(0), 'detached')
expect(restored.deriveMessages()).toEqual([message])
})
})
+2
View File
@@ -42,6 +42,8 @@ const undefinedSource: RequestUserInput = { ...input, source: undefined }
const assistant: RequestMessage = { role: 'assistant', content: [] }
// @ts-expect-error -- system prompts retain their durable identity and source.
const system: RequestMessage = { role: 'system', content: [] }
// @ts-expect-error -- developer instructions retain their durable identity and source.
const developer: RequestMessage = { role: 'developer', content: [] }
// @ts-expect-error -- tool results retain their durable identity and correlation.
const tool: RequestMessage = { role: 'tool', content: [] }
// @ts-expect-error -- constructing a durable user message requires its source.
+1 -1
View File
@@ -2229,7 +2229,7 @@ def normalize_snapshot_value(
dt = member.get("dt")
if isinstance(dt, list):
member["dt"] = [0] * len(dt)
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "system", "user", "tool"):
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "system", "user", "tool", "developer"):
if not normalized["id"].startswith("{{message:"):
normalized["id"] = "{{messageId}}"
if normalized.get("type") in ("feedback/message-put", "feedback/message-delete"):
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more