mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(session): namespace historical V3 extensions with plugin prefixes
V3 permits external event, source and content identities and additional request-tool fields. Copying those values into V4 can give an old plugin named tool a core source identity, turn old tool-addition content into a registry instruction, or activate formerly ignored deferLoading and delivery metadata. Rejecting such collisions would instead refuse valid source logs. Keep historical extensions in the existing extensible JSON representation. Prefix unknown content and ignorable event tags with plugin:, preserving all other content fields and opaque event payloads. Prefix external source identities with plugin:plugin: or plugin:source: so wrapper and direct kinds cannot collide even when original names already contain a category prefix. Known first-party mappings remain explicit and unchanged. Prefix extra request-tool field names and block-start field names with plugin:, retaining exact values and existing-prefix doubling. Core schema parameters, arguments, replay state and nested plugin data are not traversed. Keep future-generation delivery records inactive after header promotion. Pin the V3 event vocabulary independently of the current writer and provide an offline verifier against an explicitly selected historical source commit. Attachment authorization and export inspect declared event content only. Team checkpoints preserve all own keys of unknown JSON blocks while still rejecting malformed known content and retired core tool-result blocks. Exercise physical read/write/reopen, prefix injectivity, own __proto__ keys, provider opacity and retained source-identity snapshots. No generic extension block, metadata wrapper or new schema-tooling type is introduced. V4 type declarations, persistence schema, checkpoint and frozen V0-V3 codecs remain unchanged. Canonical tool-result lifting and incomplete child-catalog policy remain separate concerns. Adapted from the extension-preservation work in 7ba7e174dd1 by Tianyi Cui. Co-authored-by: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
This commit is contained in:
1 parent
cc970738d8
commit
bf29247fad
63 files changed
+1221
-182
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-09-producer-owned-message-sources.md
|
||||
2026-09-09-producer-owned-message-sources.md: 484a082607a02d469c63e17d66dc7726b3a5453a
|
||||
2026-09-09-producer-owned-message-sources.zh.md: 4fc80b66b9f1c918a52e03d636224d363edefb1b
|
||||
2026-09-09-producer-owned-message-sources.md: f2d3ce0f403bc521b853945a2461210fa33e368b
|
||||
2026-09-09-producer-owned-message-sources.zh.md: d617de9f07026f00dfc4d83e9b1adb1f36f61d4f
|
||||
@@ -10,7 +10,7 @@ Released V3 writers stamped every plugin-produced message with the shared wrappe
|
||||
|
||||
## Decision
|
||||
|
||||
Message sources are producer-owned: the producer identity is the `kind` itself, declared per plugin through `MessageSourceMap` augmentation, and the released `plugin` field no longer exists in current rows. The V3-to-V4 migration rewrites released plugin attribution onto producer kinds with a frozen rename table in `packages/session/session-format-v3-to-v4/src/sources.ts`: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; any other plugin string keeps its own name as the kind. The wrapper drops without losing identity, context-form fields survive, and tool results lift from released user-role wrapper rows into first-class tool-role messages in the same migration. Rename lookup uses only own keys, and reconstruction preserves every own JSON metadata property, including `__proto__`; legal external producer names cannot resolve to JavaScript prototype members.
|
||||
Message sources are producer-owned: `kind` identifies the producer, with declarations contributed through `MessageSourceMap`. The V3-to-V4 migration uses its fixed producer table: `@deepseek-ai/dsh-system-prompt` becomes `system-prompt` for system-role messages and `runtime-context` otherwise; `compact` becomes `compact-checkpoint`; `tools-code-mode` and `tools-ptc` become `ptc-mode`; `dsh-compaction-basic` becomes `compact-basic`; known same-name producers retain their kind. Unknown plugin names receive `plugin:plugin:` before the complete original name, and unknown direct source kinds receive `plugin:source:`. The distinct namespaces preserve identity without letting an old plugin named `user` acquire the human-user kind or collide with an old direct kind. Original context-form fields and other own JSON metadata survive; only the old wrapper's identity fields receive their specified conversion. Rename lookup uses own keys, so `__proto__` and `constructor` remain data.
|
||||
|
||||
Native V4 source admission rejects the retired `kind: 'plugin'` wrapper in every declared durable message slot, including inbox and title-request messages. This refusal applies before recoverable scanning can discard a suffix. Complete source-slot validation runs in the shared known-event pass used by both the format catalog and native JSONL scanner, before either exposes a restored artifact or handle. Unknown nonempty attribution kinds and their extra JSON fields survive without their producer installed. Native compaction and title relationship checks use the producer kinds directly; the [mandatory validation decision](2026-09-17-native-v4-read-validation.md) owns common lifecycle rules.
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
消息源是生产者归属的:生产者身份就是 `kind` 本身,由各插件通过 `MessageSourceMap` 声明合并,当前行中不再存在已发布的 `plugin` 字段。V3→V4 迁移用 `packages/session/session-format-v3-to-v4/src/sources.ts`中的冻结重命名表把已发布 plugin 归属改写到生产者 kind:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;其它 plugin 字符串保留自身名字作为 kind。包装丢弃时不丢失身份,上下文形态字段保留,工具结果在同一迁移中从已发布的 user-role 包装行提升为一等 tool-role 消息。重命名查找只读取自有键,重建时保留所有自有 JSON 元数据属性,包括 `__proto__`;合法的外部生产者名字不会被解析为 JavaScript 原型成员。
|
||||
消息源由生产者拥有:`kind` 标识生产者,各方通过 `MessageSourceMap` 提供声明。V3-to-V4 迁移使用固定生产者表:`@deepseek-ai/dsh-system-prompt` 在 system-role 消息上变成 `system-prompt`,否则变成 `runtime-context`;`compact` 变成 `compact-checkpoint`;`tools-code-mode` 与 `tools-ptc` 变成 `ptc-mode`;`dsh-compaction-basic` 变成 `compact-basic`;已知同名生产者保留 kind。未知插件名在完整原名之前添加 `plugin:plugin:`,未知的直接 source kind 则添加 `plugin:source:`。两个命名空间在保留身份的同时,防止名为 `user` 的旧插件获得人类用户 kind,或与旧的直接 kind 冲突。原上下文形态字段及其他自有 JSON 元数据保留;只有旧包装的身份字段执行指定转换。重命名查找使用自有键,因此 `__proto__` 和 `constructor` 仍是数据。
|
||||
|
||||
原生 V4 消息源准入在每个声明的持久化消息槽位拒绝退役的 `kind: 'plugin'` 包装,包括 inbox 和标题请求消息。可恢复扫描丢弃后缀之前就会执行此拒绝。完整消息源槽位校验在格式目录和原生 JSONL 扫描器共用的已知事件校验中执行,先于两者公开恢复产物或句柄。即使生产者未安装,未知的非空归属 kind 及额外 JSON 字段仍原样保留。原生压缩和标题关系校验直接使用生产者 kind;[强制校验决策](2026-09-17-native-v4-read-validation.zh.md)负责通用生命周期规则。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-persistence-attribution-policy.md
|
||||
2026-09-17-persistence-attribution-policy.md: 0fbf5ba83a65e71e7fba1c1c223931aa0962b944
|
||||
2026-09-17-persistence-attribution-policy.zh.md: 235e4dba19951a2bd34ad397e09f449ee5c4708d
|
||||
2026-09-17-persistence-attribution-policy.md: 19bebc8bc186f4b2b7e2e561fb6a53ceadcebabf
|
||||
2026-09-17-persistence-attribution-policy.zh.md: 3561c1d1afca5402cb94b1578603c4f1a2fc89a4
|
||||
@@ -16,7 +16,7 @@ The tmux-context producer qualifies its location attribution. Its own projection
|
||||
|
||||
`RequestMessage` accepts a durable `Message` or a user-only `RequestUserInput` with content and no `id` or `source`. The LLM service and provider serializers accept both; Session writes, Agent delivery, and persisted title-request inputs require durable messages. Auto Review's outer prompt and the compaction summarizer's final instruction use request-only inputs and retain their content and freezing. The caller receives no implicit durable conversion. Assistant replay metadata and tool-result correlation retain their existing requirements.
|
||||
|
||||
The finalized V4 acknowledgement records the user-source policy and removes the two request-only source registrations. Its original predecessors and header transition remain intact. This does not grant a general exception for removing accepted variants: the [producer-source migration](2026-09-09-producer-owned-message-sources.md) preserves its frozen rename and collision rules, including historical request-only kinds, independently of active source declarations. Catalog generation and comparison remain owned by the [persistence reference](../../../../docs/persistence-changes/README.md#compatibility-rules).
|
||||
The finalized V4 acknowledgement records the user-source policy and removes the two request-only source registrations. Its original predecessors and header transition remain intact. This does not grant a general exception for removing accepted variants: the [producer-source migration](2026-09-09-producer-owned-message-sources.md) preserves its producer mappings and extension namespaces, including historical request-only kinds, independently of active source declarations. Catalog generation and comparison remain owned by the [persistence reference](../../../../docs/persistence-changes/README.md#compatibility-rules).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ tmux-context 生产者将位置归属标记为符合条件。其自身投影使
|
||||
|
||||
`RequestMessage` 接受持久化 `Message`,或仅含 content、没有 `id` 和 `source` 的 user 专用 `RequestUserInput`。LLM 服务和 provider 序列化器接受两者;Session 写入、Agent 交付及持久化标题请求输入要求持久化消息。Auto Review 的外层提示和压缩摘要器的最终指令使用仅供请求使用的输入,并保留内容与冻结行为。调用方不会获得隐式持久化转换。Assistant 回放元数据和工具结果关联保持原有要求。
|
||||
|
||||
已定稿的 V4 确认记录保存 user-source 策略,并移除两个仅供请求使用的 source 注册。原有前驱和 header 迁移保持不变。这不构成删除已接受分支的通用例外:[producer-source 迁移](2026-09-09-producer-owned-message-sources.zh.md)独立于活动 source 声明保留冻结的重命名和冲突规则,包括历史的仅供请求使用的 kind。目录生成和比较仍由[持久化参考](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)负责。
|
||||
已定稿的 V4 确认记录保存 user-source 策略,并移除两个仅供请求使用的 source 注册。原有前驱和 header 迁移保持不变。这不构成删除已接受分支的通用例外:[producer-source 迁移](2026-09-09-producer-owned-message-sources.zh.md)独立于活动 source 声明保留生产者映射与扩展命名空间,包括历史的仅供请求使用的 kind。目录生成和比较仍由[持久化参考](../../../../docs/persistence-changes/README.zh.md#compatibility-rules)负责。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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/cookbook/adding-a-session-format-version.md
|
||||
adding-a-session-format-version.md: 4ea2f28d3e4164302bb23cad2a69b21f7720a299
|
||||
adding-a-session-format-version.zh.md: 4944ef2dc04028d383f355f25f048533afce60ed
|
||||
adding-a-session-format-version.md: 472bc944261474e913abecff302fcd5cef474d14
|
||||
adding-a-session-format-version.zh.md: fe4a97fa3f90027c7914aaa575866bedb4e3a483
|
||||
@@ -109,6 +109,20 @@ pnpm run lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
<a id="final-v3-vocabulary"></a>
|
||||
### Final V3 event vocabulary before V4 publication
|
||||
|
||||
While the published format remains V3 and the integration writer is V4, master can still add valid V3 event types. After each master integration and before the first V4 publication, compare the migration-owned `RELEASED_V3_EVENT_TYPES` with the latest V3-writer commit used by that refresh. Capture its full immutable id while the locally verified `origin/master` still writes V3:
|
||||
|
||||
```sh
|
||||
V3_SOURCE_REF="$(git rev-parse --verify 'origin/master^{commit}')"
|
||||
pnpm run verify-v3-event-vocabulary --source-ref "$V3_SOURCE_REF"
|
||||
```
|
||||
|
||||
The verifier reads only that local commit and requires exact event-name equality. It rejects missing V3 names, extra copied names, and a source writer other than V3. In particular, V4-only `developer/message` must not enter the frozen V3 set. Review each difference against the V3 payload, required conversions, target admission, and consumers; adding the name alone does not establish a complete migration. Add the corresponding migration regression before accepting a new source event.
|
||||
|
||||
This command does not fetch or establish remote freshness. The release operator must verify that the selected commit is the latest V3 writer included in the integration; an old remote-tracking ref or passing result for an older pin is insufficient. Record the checked id with release validation. If master already writes V4, reuse the recorded final V3 commit rather than resolving current master. After V4 ships, keep the V3 vocabulary tied to that historical source; later V4 event additions do not update it. Regular static CI has no dependency on a mutable master ref.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
## Dev Note
|
||||
|
||||
|
||||
@@ -109,6 +109,20 @@ pnpm run lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
<a id="final-v3-vocabulary"></a>
|
||||
### 发布 V4 前确认最终 V3 事件词汇
|
||||
|
||||
当已发布格式仍为 V3、集成写入方为 V4 时,master 仍可能新增有效的 V3 事件类型。每次集成 master 后及首次发布 V4 前,都要把迁移所有的 `RELEASED_V3_EVENT_TYPES` 与该次刷新使用的最新 V3 写入方提交比较。在已于本地核验的 `origin/master` 仍写入 V3 时,记录其完整、不可变的提交 id:
|
||||
|
||||
```sh
|
||||
V3_SOURCE_REF="$(git rev-parse --verify 'origin/master^{commit}')"
|
||||
pnpm run verify-v3-event-vocabulary --source-ref "$V3_SOURCE_REF"
|
||||
```
|
||||
|
||||
校验器只读取该本地提交,并要求事件名完全一致。它拒绝遗漏的 V3 名称、额外复制的名称,以及不写入 V3 的源提交。尤其不能把 V4 专有的 `developer/message` 放入冻结的 V3 集合。逐项核对 V3 payload、必要转换、目标接纳及消费方;只添加名称不能证明迁移完整。接受新源事件前要补上相应迁移回归。
|
||||
|
||||
此命令不会 fetch,也不能证明远端新鲜度。发布操作者必须确认所选提交是此次集成包含的最新 V3 写入方;陈旧的远端跟踪 ref 或旧 pin 的通过结果均不足。把核验过的 id 记入发布验证。如果 master 已写入 V4,应复用已记录的最终 V3 提交,而不再解析当前 master。V4 发布后,V3 词汇仍绑定该历史源码;后续 V4 事件新增不更新它。常规静态 CI 不依赖可变的 master ref。
|
||||
|
||||
<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/session-format-status.md
|
||||
session-format-status.md: 7ca2c4b23e6b8f186098d3114ec25d4b17c351a2
|
||||
session-format-status.zh.md: 4a6b73a8263d78735bd553b51d3ce01152fc652b
|
||||
session-format-status.md: 9df313da2fad277254f464a8bdf6a6c8b541aab1
|
||||
session-format-status.zh.md: 31e1710632b07a1f64a5bc528bf0827c5af47af9
|
||||
@@ -36,6 +36,8 @@ V4 has an accepted compatibility baseline in the [checkpoint](persistence-change
|
||||
|
||||
Finalization does not freeze every future V4 addition and does not assert publication. The release record below retains the independently verified published version. Ordinary comments, aliases, source locations, and implementation fixes preserving the accepted meaning do not change this baseline.
|
||||
|
||||
Before the first V4 publication, every integration of a newer V3-writing master must pass the [explicit V3 vocabulary check](cookbook/adding-a-session-format-version.md#final-v3-vocabulary) against the recorded local source commit. Verify the source pin’s freshness and review new event payload conversions before updating the migration-owned set. After publication, the final V3 vocabulary remains historical and independent of current V4 additions.
|
||||
|
||||
<a id="release-record"></a>
|
||||
## Release record
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ V4 的已接受兼容性基线保存在[检查点](persistence-changes/finalized
|
||||
|
||||
定稿不冻结 V4 之后的每项新增,也不表示已发布。下方发布记录保留独立验证的已发布版本。普通注释、别名、源码位置,以及保留已接受含义的实现修复,不改变该基线。
|
||||
|
||||
首次发布 V4 前,每次集成仍写入 V3 的较新 master 都必须对已记录的本地源提交执行[显式 V3 词汇校验](cookbook/adding-a-session-format-version.zh.md#final-v3-vocabulary)。更新迁移所有的集合前,要核验源 pin 的新鲜度并审查新事件 payload 的转换。发布后,最终 V3 词汇保持为历史定义,与当前 V4 新增事件无关。
|
||||
|
||||
<a id="release-record"></a>
|
||||
## 发布记录
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"verify-persistence-releases": "tsx scripts/persistence-releases.ts",
|
||||
"verify-persistence-formats": "tsx scripts/persistence-formats.ts",
|
||||
"gen-session-format-catalog": "tsx scripts/gen-session-format-catalog.ts",
|
||||
"verify-v3-event-vocabulary": "tsx scripts/verify-v3-event-vocabulary.ts",
|
||||
"verify-session-format-catalog": "tsx scripts/gen-session-format-catalog.ts --check",
|
||||
"gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts",
|
||||
"verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check",
|
||||
|
||||
@@ -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: 228ed88ed20b9f9581496468e47079afd1542e4f
|
||||
README.zh.md: edebf8bdda249cf0ea6797db7dd77fcfa973074a
|
||||
README.md: de20b2bacf0b563404b66f716c3ffdca01cb8058
|
||||
README.zh.md: cc94e573b0bc5d52323d06d010c12457749b1914
|
||||
@@ -54,6 +54,8 @@ A resume blocked by an existing write handle returns `session/writer-held` with
|
||||
|
||||
Queue edits replace pending content with non-empty text only.
|
||||
|
||||
Attachment authorization inspects only declared content fields of recognized Session events and completed assistant stream blocks. Unknown event payloads, unrelated fields, and extension data cannot authorize attachment reads.
|
||||
|
||||
<a id="client-references"></a>
|
||||
## Client references
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在
|
||||
|
||||
队列编辑仅允许用非空文本替换待处理内容。
|
||||
|
||||
附件授权只检查已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据不能授权读取附件。
|
||||
|
||||
<a id="client-references"></a>
|
||||
## Client 引用
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-format-catalog": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
|
||||
@@ -620,19 +620,16 @@ function imageBlockIn(
|
||||
if (!Array.isArray(content)) return undefined
|
||||
for (const value of content) {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||
const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown }
|
||||
const block = value as { readonly type?: unknown; readonly attachment?: unknown }
|
||||
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
|
||||
const ref = block.attachment as ImageAttachmentRef
|
||||
if (match(ref)) return ref
|
||||
}
|
||||
if (block.type === 'tool-result') {
|
||||
const nested = imageBlockIn(block.content, match)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Read only first-party declared content fields; unknown event payloads stay opaque. */
|
||||
function imageInEvent(
|
||||
event: SessionEvent,
|
||||
match: (ref: ImageAttachmentRef) => boolean,
|
||||
@@ -640,21 +637,45 @@ function imageInEvent(
|
||||
const data = event.data as {
|
||||
readonly content?: unknown
|
||||
readonly message?: { readonly content?: unknown }
|
||||
readonly inserted?: readonly { readonly content?: unknown }[]
|
||||
readonly inserted: readonly { readonly content?: unknown }[]
|
||||
readonly messages: readonly { readonly content?: unknown }[]
|
||||
readonly summary?: unknown
|
||||
readonly rawOutput?: unknown
|
||||
}
|
||||
const direct = imageBlockIn(data.content, match)
|
||||
if (direct !== undefined) return direct
|
||||
const message = imageBlockIn(data.message?.content, match)
|
||||
if (message !== undefined) return message
|
||||
for (const inserted of data.inserted ?? []) {
|
||||
const found = imageBlockIn(inserted.content, match)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
|
||||
for (const chunk of assistantStreamChunks(event.data.stream, 'block-end')) {
|
||||
const found = imageBlockIn([chunk.block], match)
|
||||
if (found !== undefined) return found
|
||||
// First-party event payloads can be present without their producer plugin mounted.
|
||||
const type: string = event.type
|
||||
switch (type) {
|
||||
case 'user/message':
|
||||
case 'tool/ptc-dispatch':
|
||||
return imageBlockIn(data.content, match)
|
||||
case 'system/message':
|
||||
case 'developer/message':
|
||||
case 'tool/result':
|
||||
case 'team/message/queued':
|
||||
return imageBlockIn(data.message?.content, match)
|
||||
case 'agent/inbox/spliced':
|
||||
case 'session/title-llm-request': {
|
||||
const messages = type === 'agent/inbox/spliced' ? data.inserted : data.messages
|
||||
for (const message of messages) {
|
||||
const found = imageBlockIn(message.content, match)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
case 'compaction/summary':
|
||||
return imageBlockIn(data.summary, match) ?? imageBlockIn(data.rawOutput, match)
|
||||
case 'assistant/message': {
|
||||
const found = imageBlockIn(data.message?.content, match)
|
||||
if (found !== undefined) return found
|
||||
break
|
||||
}
|
||||
case 'assistant/attempt': break
|
||||
default: return undefined
|
||||
}
|
||||
const assistant = event as SessionEvent<'assistant/message' | 'assistant/attempt'>
|
||||
for (const chunk of assistantStreamChunks(assistant.data.stream, 'block-end')) {
|
||||
const found = imageBlockIn([chunk.block], match)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import SessionStore, {
|
||||
SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { createSessionFormatCatalogWithChildren, historicalSessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
|
||||
import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
|
||||
import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -316,7 +317,8 @@ function imageRef(id: string): ImageAttachmentRef {
|
||||
}
|
||||
|
||||
function event(type: string, seq: SessionSeq, data: unknown): SessionEvent {
|
||||
return { type, seq, time: seq + 1, data } as SessionEvent
|
||||
const surface = ['user/message', 'system/message', 'developer/message', 'tool/result'].includes(type)
|
||||
return { type, seq, time: seq + 1, data, ...surface ? { surfaceOp: 'append' } : {} } as SessionEvent
|
||||
}
|
||||
|
||||
async function persistedController(
|
||||
@@ -348,16 +350,90 @@ async function persistedController(
|
||||
}
|
||||
|
||||
describe('Session attachment authorization', () => {
|
||||
it('finds references in direct, message, inserted, nested, and streamed content', async () => {
|
||||
it.each([
|
||||
['system/message', 'message'], ['developer/message', 'message'], ['tool/result', 'message'],
|
||||
['team/message/queued', 'message'], ['tool/ptc-dispatch', 'content'],
|
||||
['session/title-llm-request', 'messages'], ['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'],
|
||||
] as const)('reads the declared %s %s content without rewriting it', async (type, field) => {
|
||||
const ref = imageRef(`${type}-${field}`)
|
||||
const content = [{ type: 'image', attachment: ref }]
|
||||
const source = type === 'system/message' ? { kind: 'system-prompt' }
|
||||
: type === 'tool/result' ? { kind: 'tool', callId: 'declared-call' } : { kind: 'test' }
|
||||
const data = field === 'message' ? { message: {
|
||||
id: 'declared-message', role: type.split('/')[0], content, source,
|
||||
...type === 'tool/result' ? { toolCallId: 'declared-call' } : {},
|
||||
} }
|
||||
: field === 'messages' ? { messages: [{ content: [] }, { content }] } : { [field]: content }
|
||||
const events = [event(type, SessionSeq(0), data)]
|
||||
const saved = JSON.stringify(events)
|
||||
const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) }))
|
||||
const fixture = await persistedController(events, readImage)
|
||||
try {
|
||||
await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId }))
|
||||
.resolves.toEqual({ attachment: ref, data: 'AQ==' })
|
||||
expect(readImage).toHaveBeenCalledExactlyOnceWith(ref)
|
||||
expect(JSON.stringify(events)).toBe(saved)
|
||||
} finally {
|
||||
await fixture.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores unrelated fields of a known message event and its content blocks', async () => {
|
||||
const ref = imageRef('not-a-content-occurrence')
|
||||
const content = [{ type: 'image', attachment: ref }]
|
||||
const stored = event('user/message', SessionSeq(0), {
|
||||
id: 'metadata', role: 'user', source: { kind: 'user' },
|
||||
content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }],
|
||||
message: { content }, inserted: [{ content }],
|
||||
})
|
||||
const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) }))
|
||||
const fixture = await persistedController([stored], readImage)
|
||||
try {
|
||||
await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId }))
|
||||
.rejects.toMatchObject({ code: 'session/attachment-invalid' })
|
||||
expect(readImage).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await fixture.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('denies attachment access from a migrated unknown ignorable event', async () => {
|
||||
const ref = imageRef('opaque-event-image')
|
||||
const content = [{ type: 'message', message: {
|
||||
role: 'tool', toolCallId: 'opaque-call', content: [{ type: 'image', attachment: ref }],
|
||||
} }]
|
||||
const header = { type: 'session', version: 3, id: 'cold-attachment', createdAt: 1, isSeeded: false, delegationDepth: 0 }
|
||||
const original = { type: 'external/image-record', seq: 0, time: 1, ignorable: true, data: {
|
||||
content, message: { content }, inserted: [{ content }],
|
||||
stream: [{ type: 'chunk', time: 1, chunk: { type: 'block-end', index: 0, block: content[0] } }],
|
||||
} }
|
||||
const historical = historicalSessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
|
||||
historical.decodeRow(original)
|
||||
expect(historical.finish().header.version).toBe(3)
|
||||
const reader = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' })
|
||||
reader.decodeRow(original)
|
||||
const converted = reader.finish()
|
||||
expect(converted.events[0]).toMatchObject({ type: 'plugin:external/image-record', data: original.data })
|
||||
const readImage = vi.fn((image: ImageAttachmentRef) => Promise.resolve({ ref: image, data: Uint8Array.of(1) }))
|
||||
const fixture = await persistedController(converted.events as SessionEvent[], readImage)
|
||||
try {
|
||||
await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId }))
|
||||
.rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } })
|
||||
expect(readImage).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await fixture.ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('finds references in direct, message, inserted, and streamed content', async () => {
|
||||
const nested = imageRef('nested')
|
||||
const message = imageRef('message')
|
||||
const inserted = imageRef('inserted')
|
||||
const streamed = imageRef('streamed')
|
||||
const events: SessionEvent[] = [
|
||||
{ ...event('fixture/direct', SessionSeq(0), {
|
||||
content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
|
||||
type: 'tool-result', content: [{ type: 'image', attachment: nested }],
|
||||
}],
|
||||
{ ...event('user/message', SessionSeq(0), {
|
||||
id: 'direct', role: 'user', source: { kind: 'user' },
|
||||
content: [null, [], { type: 'text', text: 'none' }, { type: 'image', attachment: nested }],
|
||||
}), ignorable: true as const },
|
||||
{
|
||||
type: 'assistant/message', seq: SessionSeq(1), time: 2, surfaceOp: 'append',
|
||||
@@ -451,7 +527,7 @@ describe('Session attachment authorization', () => {
|
||||
]) {
|
||||
const ref = imageRef(`failure-${thrown.name}`)
|
||||
const fixture = await persistedController(
|
||||
[event('fixture/content', SessionSeq(0), { content: [{ type: 'image', attachment: ref }] })],
|
||||
[event('user/message', SessionSeq(0), { id: 'failure', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })],
|
||||
() => Promise.reject(thrown),
|
||||
)
|
||||
await expectFailure(fixture.controller.attachment({
|
||||
|
||||
@@ -14,7 +14,7 @@ export function apply(ctx) {
|
||||
id: randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Serial agent creation completed.' }],
|
||||
source: { kind: name },
|
||||
source: { kind: `plugin:plugin:${name}` },
|
||||
})
|
||||
ready.add(agent)
|
||||
})
|
||||
|
||||
@@ -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/experimental/agent-team/README.md
|
||||
README.md: 6e2b55018971ec96802de0e5be042d49b45c674a
|
||||
README.zh.md: b497d3790e43a9bc843e2a6b88c9755b698034b5
|
||||
README.md: 280ea4aac70ce68da48d85dac6313c05b78a8c09
|
||||
README.zh.md: 8446da495cfc476d3620a0240e4c322604b56d3c
|
||||
@@ -145,6 +145,8 @@ Tasks are complete versioned snapshots; every mutation carries `expectedRevision
|
||||
|
||||
Team events are appended to the exact live Lead Session and flushed before the operation reports success or wakes waiters. `team/member`, `team/task`, `team/message/queued`, and `team/message/delivered` are log-only: they never enter the conversation surface, so derived model history is untouched by coordination records. Session event `seq` and `time` own ordering and timing; snapshots do not duplicate them. The `./invariant` companion replays each candidate Team event against its committed prefix and rejects invalid transitions before append.
|
||||
|
||||
Mailbox projection and checkpoint admission validate known content fields and preserve unknown plugin content as decoded JSON, including own `__proto__` keys. Unknown block fields remain opaque; retired tool-result content is rejected.
|
||||
|
||||
### Disposal
|
||||
|
||||
Disposal closes admission, aborts and awaits admitted creation and mailbox-dispatch transactions, then asks the continuation owner to release the roster's exact live direct children and their descendants; non-Team continuable children of the Lead remain untouched. Cleanup failures make disposal fail visibly, bounded by `disposalTimeoutMs`.
|
||||
|
||||
@@ -145,6 +145,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息
|
||||
|
||||
Team 事件追加到精确的 live Lead 会话,并在操作报告成功或唤醒等待者之前 flush。`team/member`、`team/task`、`team/message/queued` 与 `team/message/delivered` 仅存在于日志:它们从不进入会话表面,因此派生模型历史不受协作记录影响。顺序与时间由会话事件的 `seq` 与 `time` 负责,快照不重复保存。`./invariant` 伴生插件把每条候选 Team 事件对照已提交前缀回放,并在 append 前拒绝非法转换。
|
||||
|
||||
邮箱投影和检查点准入校验已知内容字段,并按解码后的 JSON 保留未知插件内容,包括自有 `__proto__` 键。未知块字段保持不透明,退役的 tool-result 内容被拒绝。
|
||||
|
||||
### Dispose
|
||||
|
||||
dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch 事务,再让 continuation owner 释放 roster 中确切的 live direct child 及其后代;Lead 的非 Team continuable child 不受影响。cleanup 失败会让 dispose 明确失败,并以 `disposalTimeoutMs` 为上限。
|
||||
|
||||
@@ -42,7 +42,7 @@ const imageAttachmentSchema = z.object({
|
||||
|
||||
// ContentBlockMap is merge-extensible. Validate every core variant exactly,
|
||||
// while retaining JSON-decoded plugin variants under an unknown type tag.
|
||||
const contentBlockSchema: z.ZodType<ContentBlock> = z.lazy(() => z.union([
|
||||
const contentBlockSchema: z.ZodType<ContentBlock> = z.union([
|
||||
z.object({ type: z.literal('text'), text: z.string() }).strict(),
|
||||
z.object({ type: z.literal('reasoning'), text: z.string() }).strict(),
|
||||
z.object({ type: z.literal('image'), attachment: imageAttachmentSchema }).strict(),
|
||||
@@ -52,17 +52,13 @@ const contentBlockSchema: z.ZodType<ContentBlock> = z.lazy(() => z.union([
|
||||
name: z.string(),
|
||||
arguments: z.string(),
|
||||
}).strict(),
|
||||
z.object({
|
||||
type: z.literal('tool-result'),
|
||||
toolCallId: z.string().min(1),
|
||||
content: z.array(contentBlockSchema),
|
||||
isError: z.boolean().optional(),
|
||||
}).strict(),
|
||||
z.object({ type: z.string().min(1) }).loose().refine(
|
||||
block => !coreContentBlockTypes.has(block.type),
|
||||
{ message: 'known content block types must match their declared fields' },
|
||||
),
|
||||
])) as z.ZodType<ContentBlock>
|
||||
// Unknown content is decoded JSON; validation must preserve its own extension keys.
|
||||
z.custom<ContentBlock>((value) => {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||
const type = (value as { type?: unknown }).type
|
||||
return typeof type === 'string' && type.length > 0 && !coreContentBlockTypes.has(type)
|
||||
}),
|
||||
]) as z.ZodType<ContentBlock>
|
||||
|
||||
const teamMemberSnapshotSchema = z.object({
|
||||
id: sessionIdSchema,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session'
|
||||
@@ -301,6 +302,34 @@ describe('Agent Teams projection events', () => {
|
||||
expect(pending(state)[0]?.content).toEqual([extension])
|
||||
})
|
||||
|
||||
it('preserves opaque extension keys through projection and checkpoints', () => {
|
||||
const opaque = JSON.parse('{"__proto__":{"saved":true},"constructor":{"saved":false},"content":[{"type":"tool-result","toolCallId":"opaque","content":[]}]}') as Record<string, unknown>
|
||||
const content: ContentBlock[] = [{ ...opaque, type: 'plugin:vendor' } as unknown as ContentBlock]
|
||||
const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: message({ content }) }, SessionSeq(0))
|
||||
const before = JSON.stringify(queued)
|
||||
const projected = projectTeam(ROOT, [queued])
|
||||
expect(projected.messages[0]?.content).toEqual(content)
|
||||
const checkpoint = teamProjectionDefinition.stateSchema.parse(JSON.parse(JSON.stringify(projected)))
|
||||
expect(JSON.stringify(checkpoint)).toBe(JSON.stringify(projected))
|
||||
expect(JSON.stringify(queued)).toBe(before)
|
||||
const block = checkpoint.messages[0]!.content[0] as unknown as Record<string, unknown>
|
||||
expect(Object.hasOwn(block, '__proto__')).toBe(true)
|
||||
expect(Object.getPrototypeOf(block)).toBe(Object.prototype)
|
||||
})
|
||||
|
||||
it.each([
|
||||
null, [], 3,
|
||||
{ type: 'tool-result', toolCallId: 'retired', content: [] },
|
||||
{ type: '' },
|
||||
{ type: null },
|
||||
{ type: 'text', text: false },
|
||||
])('rejects malformed or retired current content in events and checkpoints: %j', (block) => {
|
||||
const saved = message({ content: [block] as unknown as ContentBlock[] })
|
||||
const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: saved }, SessionSeq(0))
|
||||
expect(() => projectTeam(ROOT, [queued])).toThrow(/team\/message\/queued payload is invalid/)
|
||||
expect(() => teamProjectionDefinition.stateSchema.parse({ ...project(ROOT, []), messages: [saved] })).toThrow()
|
||||
})
|
||||
|
||||
it('records unsupported event versions without applying them', () => {
|
||||
const invalid = event('team/task', {
|
||||
version: 1 as 2,
|
||||
|
||||
@@ -425,6 +425,24 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
expect(policies).toEqual([{ width: 1, height: 1, maxBytes: 2 * 1024 * 1024 }])
|
||||
})
|
||||
|
||||
it('keeps images inside plugin content opaque through adapter.stream', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const attachmentMocks = attachmentStoreOf(ref => Promise.resolve(requestImage(ref)))
|
||||
const adapter = adapterOf({ baseURL: server.url }, attachmentMocks.store)
|
||||
await drain(adapter.stream({ provider: 'deepseek-official', model: 'deepseek-v4-pro', messages: [
|
||||
createUserMessage({ source: { kind: 'user' }, content: [
|
||||
{ type: 'plugin:message',
|
||||
message: { role: 'tool', toolCallId: 'foreign', content: [{ type: 'image', attachment: { ...imageRef } }] },
|
||||
} as never,
|
||||
{ type: 'text', text: 'context' },
|
||||
] }),
|
||||
] }))
|
||||
expect(attachmentMocks.readImageRequest).not.toHaveBeenCalled()
|
||||
expect(server.fileRequests).toHaveLength(0)
|
||||
expect(server.requests).toHaveLength(1)
|
||||
expect(server.requests[0]).toMatchObject({ messages: [{ role: 'user', content: 'context' }] })
|
||||
})
|
||||
|
||||
it('falls back to one all-base64 request when Files API resolution fails', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const secondRef = { ...imageRef, attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`) }
|
||||
|
||||
@@ -8,7 +8,7 @@ import { serialize } from '../../../src/protocols/messages/serialize.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
'messages-input-history-snapshot': { kind: 'messages-input-history-snapshot' }
|
||||
'plugin:plugin:messages-input-history-snapshot': { kind: 'plugin:plugin:messages-input-history-snapshot' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function apply(ctx: Context): void {
|
||||
ctx.on('agent/pre-step', async (_input, next) => {
|
||||
const decision = await next()
|
||||
if (decision.kind !== 'enter') return decision
|
||||
const source = { kind: name } as const
|
||||
const source = { kind: `plugin:plugin:${name}` } as const
|
||||
return { ...decision, messages: [
|
||||
...decision.messages,
|
||||
createUserMessage({ source, content: [
|
||||
|
||||
@@ -23,6 +23,11 @@ const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate
|
||||
const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined)
|
||||
|
||||
describe('Messages request conversion', () => {
|
||||
it('rejects unknown plugin content without interpreting its payload', () => {
|
||||
expect(() => body([createUserMessage({ source: { kind: 'user' }, content: [
|
||||
{ type: 'plugin:text', text: 'opaque' } as never,
|
||||
] })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' }))
|
||||
})
|
||||
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
|
||||
|
||||
@@ -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-query/session-log-export/README.md
|
||||
README.md: 79b8d572a75f57aa13a0dfaa823a04ad1e818f87
|
||||
README.zh.md: d6229927130e7200cd21df0da64d0ce9ec80ab2f
|
||||
README.md: 7fa004df954260de16a6a151e3110e6adfbc0f09
|
||||
README.zh.md: 147ee93b3929473d4e78024ff0b4ccbd040a1220
|
||||
@@ -59,6 +59,8 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u
|
||||
|
||||
The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/<attachmentId>.<ext>`, and generic files use `files/<digest-prefix>/<digest>/<name>`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full.
|
||||
|
||||
Attachment collection reads only declared content fields of recognized Session events and completed assistant stream blocks. Unknown event payloads, unrelated fields, and extension data remain opaque; the exported log retains them unchanged.
|
||||
|
||||
### Failures
|
||||
|
||||
The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog.
|
||||
|
||||
@@ -59,6 +59,8 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
|
||||
|
||||
弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后结束时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;非活动的持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/<attachmentId>.<ext>`,通用文件使用 `files/<digest-prefix>/<digest>/<name>`。通用文件以有界分块读取并压缩,因此导出大型上传文件时不会把它完整缓冲进内存。
|
||||
|
||||
附件收集只读取已知 Session 事件声明的内容字段及已完成的 assistant 流块。未知事件载荷、无关字段和扩展数据保持不透明,导出的日志原样保留它们。
|
||||
|
||||
### 失败
|
||||
|
||||
当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
|
||||
|
||||
@@ -196,9 +196,8 @@ function fileEntryPath(ref: FileAttachmentRef): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every attachment reference inside one content array, descending into
|
||||
* nested tool results the way the live attachment route does.
|
||||
* @param content - an event content array (or nested tool-result content).
|
||||
* Collect attachment references from one flat content array; all other block fields remain opaque.
|
||||
* @param content - a declared event content array.
|
||||
* @param images - image dedupe map keyed by attachment id.
|
||||
* @param files - file dedupe map keyed by attachment id and stored name.
|
||||
*/
|
||||
@@ -213,7 +212,7 @@ function collectAttachmentRefs(
|
||||
while (pending.length > 0) {
|
||||
const value = pending.pop()
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
|
||||
const block = value as { type?: unknown; attachment?: unknown }
|
||||
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
|
||||
const ref = block.attachment as ImageAttachmentRef
|
||||
images.set(String(ref.attachmentId), ref)
|
||||
@@ -222,16 +221,12 @@ function collectAttachmentRefs(
|
||||
const ref = block.attachment as FileAttachmentRef
|
||||
files.set(`${String(ref.attachmentId)}\u0000${ref.name}`, ref)
|
||||
}
|
||||
if (Array.isArray(block.content)) {
|
||||
for (const item of block.content) pending.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every attachment reference one session event carries, across the same
|
||||
* carriers the live attachment route scans (direct content, message content,
|
||||
* inserted messages, and completed blocks in embedded Assistant streams).
|
||||
* Collect references only from declared first-party content fields and completed
|
||||
* Assistant blocks. Unknown events and unrelated payload fields remain opaque.
|
||||
* @param event - one parsed JSONL event object.
|
||||
* @param images - image dedupe map keyed by attachment id.
|
||||
* @param files - file dedupe map keyed by attachment id and stored name.
|
||||
@@ -241,18 +236,40 @@ function collectEventAttachmentRefs(
|
||||
images: Map<string, ImageAttachmentRef>,
|
||||
files: Map<string, FileAttachmentRef>,
|
||||
): void {
|
||||
const data = (event as { data?: unknown }).data
|
||||
if (typeof event !== 'object' || event === null || Array.isArray(event)) return
|
||||
const row = event as { type?: unknown; data?: unknown }
|
||||
const data = row.data
|
||||
if (typeof data !== 'object' || data === null) return
|
||||
const carrier = data as {
|
||||
content?: unknown
|
||||
message?: { content?: unknown }
|
||||
inserted?: Array<{ content?: unknown }>
|
||||
messages?: Array<{ content?: unknown }>
|
||||
summary?: unknown
|
||||
rawOutput?: unknown
|
||||
stream?: Array<{ type?: unknown; chunk?: { type?: unknown; block?: unknown } }>
|
||||
}
|
||||
collectAttachmentRefs(carrier.content, images, files)
|
||||
if (carrier.message !== undefined) collectAttachmentRefs(carrier.message.content, images, files)
|
||||
if (carrier.inserted !== undefined) {
|
||||
for (const message of carrier.inserted) collectAttachmentRefs(message.content, images, files)
|
||||
switch (row.type) {
|
||||
case 'user/message': case 'tool/ptc-dispatch':
|
||||
collectAttachmentRefs(carrier.content, images, files)
|
||||
return
|
||||
case 'system/message': case 'developer/message': case 'tool/result': case 'team/message/queued':
|
||||
collectAttachmentRefs(carrier.message?.content, images, files)
|
||||
return
|
||||
case 'agent/inbox/spliced': case 'session/title-llm-request': {
|
||||
const messages = row.type === 'agent/inbox/spliced' ? carrier.inserted : carrier.messages
|
||||
for (const message of messages ?? []) collectAttachmentRefs(message.content, images, files)
|
||||
return
|
||||
}
|
||||
case 'compaction/summary':
|
||||
collectAttachmentRefs(carrier.summary, images, files)
|
||||
collectAttachmentRefs(carrier.rawOutput, images, files)
|
||||
return
|
||||
case 'assistant/message':
|
||||
collectAttachmentRefs(carrier.message?.content, images, files)
|
||||
break
|
||||
case 'assistant/attempt': break
|
||||
default: return
|
||||
}
|
||||
if (carrier.stream !== undefined) {
|
||||
for (const record of carrier.stream) {
|
||||
|
||||
@@ -503,6 +503,15 @@ describe('session.export download endpoint', () => {
|
||||
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe('')
|
||||
})
|
||||
|
||||
it('preserves non-event JSON and absent content carriers without discovering attachment lookalikes', async () => {
|
||||
const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png' } }]
|
||||
const text = [null, [], false, { type: 'agent/inbox/spliced', data: { content } },
|
||||
{ type: 'session/title-llm-request', data: { content } }].map(value => JSON.stringify(value)).join('\n')
|
||||
const files = await directZipFiles(text)
|
||||
expect(Object.keys(files)).toEqual([exportLogName])
|
||||
expect(strFromU8(files[exportLogName] as Uint8Array)).toBe(text)
|
||||
})
|
||||
|
||||
it('waits for response pull capacity before reading the next archive entry', async () => {
|
||||
const filler = {
|
||||
type: 'user/message', seq: SessionSeq(2), time: 1000,
|
||||
@@ -797,10 +806,63 @@ describe('session.export download endpoint', () => {
|
||||
await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing')
|
||||
})
|
||||
|
||||
it('collects media referenced from nested tool results', async () => {
|
||||
it.each([
|
||||
['system/message', 'message'], ['developer/message', 'message'], ['team/message/queued', 'message'],
|
||||
['tool/ptc-dispatch', 'content'], ['session/title-llm-request', 'messages'],
|
||||
['compaction/summary', 'summary'], ['compaction/summary', 'rawOutput'],
|
||||
] as const)('exports attachments from the declared %s %s content', async (type, field) => {
|
||||
const content = [{ type: 'image', attachment: { attachmentId: 'declared-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }]
|
||||
const data = field === 'message' ? { message: { content } }
|
||||
: field === 'messages' ? { messages: [{ content: [] }, { content }] } : { [field]: content }
|
||||
const stored = log('session-root', undefined, [{ type, seq: SessionSeq(1), time: 1000, data } as unknown as SessionEvent])
|
||||
const api = await buildApi({ 'session-root': stored })
|
||||
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual(['media/declared-image.png', exportLogName].sort())
|
||||
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
|
||||
})
|
||||
|
||||
it('keeps unrelated event fields and content-block extension fields out of attachment collection', async () => {
|
||||
const content = [{ type: 'image', attachment: { attachmentId: 'not-an-occurrence', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }]
|
||||
const event = { type: 'user/message', seq: SessionSeq(1), time: 1000, data: {
|
||||
content: [{ type: 'text', text: 'no image', content }, { type: 'plugin:vendor', data: { content }, content }],
|
||||
message: { content }, inserted: [{ content }],
|
||||
stream: [{ type: 'chunk', chunk: { type: 'block-end', block: content[0] } }],
|
||||
} } as unknown as SessionEvent
|
||||
const stored = log('session-root', undefined, [event])
|
||||
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType)))
|
||||
const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage })
|
||||
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files)).toEqual([exportLogName])
|
||||
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
|
||||
expect(readImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exports an unknown ignorable event without reading attachments from its opaque payload', async () => {
|
||||
const image = { type: 'image', attachment: { attachmentId: 'opaque-image', mediaType: 'image/png', bytes: 4, width: 2, height: 2 } }
|
||||
const file = { type: 'file', attachment: { attachmentId: `sha256:${'e'.repeat(64)}`, name: 'opaque.txt', bytes: 5 } }
|
||||
const content = [{ type: 'message', message: { role: 'tool', toolCallId: 'opaque', content: [image, file] } }]
|
||||
const opaque = { type: 'plugin:external/image-record', seq: SessionSeq(1), time: 1000, ignorable: true, data: {
|
||||
content, message: { content }, inserted: [{ content }],
|
||||
stream: [{ type: 'chunk', time: 1000, chunk: { type: 'block-end', index: 0, block: content[0] } }],
|
||||
} } as unknown as SessionEvent
|
||||
const stored = log('session-root', undefined, [opaque])
|
||||
const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve(storedImage(String(ref.attachmentId), ref.mediaType)))
|
||||
const readFileStream = vi.fn(async function* (_ref: FileAttachmentRef) { yield new Uint8Array([1]) })
|
||||
const api = await buildApi({ 'session-root': stored }, [], { attachments: readImage, readFileStream })
|
||||
const response = await toFetchHandler(api).fetch(new Request('http://host/api/session.export?sessionId=session-root'))
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files)).toEqual([exportLogName])
|
||||
expect(new TextDecoder().decode(files[exportLogName])).toBe(logText(stored))
|
||||
expect(readImage).not.toHaveBeenCalled()
|
||||
expect(readFileStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collects media referenced from flat tool results', async () => {
|
||||
const nested = {
|
||||
type: 'assistant/message', seq: SessionSeq(2), time: 2000,
|
||||
data: { content: [{ type: 'tool-result', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] }] },
|
||||
type: 'tool/result', seq: SessionSeq(2), time: 2000,
|
||||
data: { message: { role: 'tool', toolCallId: 'outer', content: [{ type: 'image', attachment: { attachmentId: 'nested-1', mediaType: 'image/webp', bytes: 4, width: 2, height: 2 } }] } },
|
||||
} as unknown as SessionEvent
|
||||
const api = await buildApi({ 'session-root': log('session-root', undefined, [nested]) })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
@@ -818,7 +880,7 @@ describe('session.export download endpoint', () => {
|
||||
data: { message: { role: 'assistant', content: ['noise', block('wrapped-1', 'image/jpeg')] } },
|
||||
} as unknown as SessionEvent
|
||||
const inserted = {
|
||||
type: 'context/inserted', seq: SessionSeq(3), time: 3000,
|
||||
type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 3000,
|
||||
data: { inserted: [{ content: [block('inserted-1', 'image/gif')] }] },
|
||||
} as unknown as SessionEvent
|
||||
const attempt = {
|
||||
|
||||
@@ -46,6 +46,7 @@ describe('session-query semantic extraction', () => {
|
||||
{ type: 'reasoning', text: 'thought' },
|
||||
{ type: 'tool-call', id: callId, name: 'read', arguments: '{"path":"a"}' },
|
||||
{ type: 'text', text: 'nested' },
|
||||
{ type: 'plugin:text', text: 'opaque hidden', content: [{ type: 'text', text: 'hidden child' }] } as never,
|
||||
{ type: 'future-content', payload: 'hidden' } as never,
|
||||
]
|
||||
const events: SessionEvent[] = [
|
||||
|
||||
@@ -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-catalog/README.md
|
||||
README.md: 0666f13cef0a7f019ddd741666ee8c95f78af2b8
|
||||
README.zh.md: b0132d2543f7f5a3a1d9ca5958e5c0593f3320e1
|
||||
README.md: bb86bcaffee2c2e17afffd4ce7fbd917bf1c30ee
|
||||
README.zh.md: 156361ae330967169ae82e204ddeff26c50b05bd
|
||||
@@ -47,7 +47,7 @@ Production historical reads select `{ recovery: 'recoverable', validation: 'tran
|
||||
|
||||
The catalog contains all supported historical readers directly. A profile cannot add, remove, or reorder an edge by mounting a feature plugin. Its peer dependency on `dsh-session` supplies the installed current event vocabulary and current restoration rules, while historical edge validators remain frozen. The browser-safe `./message-projections` export assembles current plugin-owned interpreters for detached constructors and surface folds; it does not mount recovery listeners.
|
||||
|
||||
`createSessionFormatCatalogWithChildren(childFacts)` binds explicit child evidence to V3→V4 during assembly; see the [catalog-completion specification](../session-format-v3-to-v4/README.md). `historicalSessionFormatCatalog` restores V0–V3 only for collecting child prerequisites without recursively completing their catalogs. Isolated transcript replay explicitly supplies an empty array; persistence must collect the complete available direct-child set. Keep the supplied evidence unchanged for the catalog’s lifetime. Each restore owns independent stage state. The static `sessionFormatCatalog` supports header and native current-format reads; historical body reads require the child-bound catalog.
|
||||
`createSessionFormatCatalogWithChildren(childFacts)` binds explicit child evidence to V3→V4 during assembly; see the [catalog-completion specification](../session-format-v3-to-v4/README.md). `historicalSessionFormatCatalog` restores V0–V3 using the fixed released-V3 event vocabulary to collect child prerequisites without recursively completing their catalogs. An ignorable V3 extension remains opaque even when the installed writer knows its name. Isolated transcript replay explicitly supplies an empty array; persistence must collect the complete available direct-child set. Keep the supplied evidence unchanged for the catalog’s lifetime. Each restore owns independent stage state. The static `sessionFormatCatalog` supports header and native current-format reads; historical body reads require the child-bound catalog.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ Production 历史读取使用 `{ recovery: 'recoverable', validation: 'transform
|
||||
|
||||
该目录直接包含所有受支持的历史读取器。Profile 无法通过挂载功能插件来添加、移除或重新排列迁移边。它通过对 `dsh-session` 的对等依赖(peer dependency)获得已安装的当前事件词表与当前还原规则,而历史迁移边校验器保持冻结。浏览器安全的 `./message-projections` 导出为独立构造函数和 surface 折叠装配当前插件拥有的处理器,不挂载恢复监听器。
|
||||
|
||||
`createSessionFormatCatalogWithChildren(childFacts)` 在组装时将显式的子 Session 证据绑定到 V3→V4,见[目录补齐规范](../session-format-v3-to-v4/README.zh.md)。`historicalSessionFormatCatalog` 仅还原 V0–V3,用于收集子 Session 前置事实,避免递归补齐其目录。独立转录回放显式提供空数组;持久化必须收集完整的可用直属子 Session 集合。在 catalog 的生命周期内保持传入证据不变。每次恢复各自拥有独立的 stage 状态。静态 `sessionFormatCatalog` 支持 header 和原生当前格式读取;历史正文读取必须使用已绑定子 Session 的 catalog。
|
||||
`createSessionFormatCatalogWithChildren(childFacts)` 在组装时将显式的子 Session 证据绑定到 V3→V4,见[目录补齐规范](../session-format-v3-to-v4/README.zh.md)。`historicalSessionFormatCatalog` 使用固定的已发布 V3 事件词汇恢复 V0–V3,收集子日志前置事实而不递归补齐其目录。即使已安装写入方认识事件名,可忽略 V3 扩展仍保持不透明。独立转录回放显式提供空数组;持久化必须收集完整的可用直属子 Session 集合。在 catalog 的生命周期内保持传入证据不变。每次恢复各自拥有独立的 stage 状态。静态 `sessionFormatCatalog` 支持 header 和原生当前格式读取;历史正文读取必须使用已绑定子 Session 的 catalog。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Historical restoration for collecting migration prerequisites without recursively opening current Sessions. */
|
||||
|
||||
import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'
|
||||
import { RELEASED_V3_EVENT_TYPES } from '@deepseek-ai/dsh-session-format-v3-to-v4'
|
||||
import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'
|
||||
import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
|
||||
import { releasedV2SessionFormatCodec, sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
|
||||
@@ -12,8 +12,8 @@ export const historicalSessionFormatCatalog = createSessionFormatCatalog({
|
||||
codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec],
|
||||
currentEncoder: releasedV3SessionFormatCodec,
|
||||
migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2, sessionFormatV2ToV3],
|
||||
restoreCurrent: artifact => restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES),
|
||||
restoreTransformedCurrent: artifact => restoreReleasedV3Artifact(artifact, KNOWN_SESSION_EVENT_TYPES),
|
||||
restoreCurrent: artifact => restoreReleasedV3Artifact(artifact, RELEASED_V3_EVENT_TYPES),
|
||||
restoreTransformedCurrent: artifact => restoreReleasedV3Artifact(artifact, RELEASED_V3_EVENT_TYPES),
|
||||
restoreCurrentHeader(header) {
|
||||
assertReleasedV3Header(header)
|
||||
return header
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('first-party Session format catalog', () => {
|
||||
const ignorable = deepFreeze({ ...required, ignorable: true, data: { text: 'tools-code-mode', source: { kind: 'plugin', plugin: 'tools-code-mode' } } })
|
||||
const accepted = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation })
|
||||
accepted.decodeRow(ignorable)
|
||||
expect(accepted.finish().events).toEqual([ignorable])
|
||||
expect(accepted.finish().events).toEqual([{ ...ignorable, type: `plugin:${type}` }])
|
||||
}
|
||||
})
|
||||
|
||||
@@ -278,6 +278,21 @@ describe('first-party Session format catalog', () => {
|
||||
})
|
||||
|
||||
describe('historical child prerequisite catalog', () => {
|
||||
it('uses the V3 event vocabulary for ignorable events whose names become known later', () => {
|
||||
const header = { type: 'session', version: 3, id: 'old-extension', createdAt: 1, isSeeded: false, delegationDepth: 0 }
|
||||
const rows = [
|
||||
{ type: 'feedback/record', seq: 0, time: 1, data: { text: 'saved' } },
|
||||
{ type: 'developer/message', seq: 1, time: 2, ignorable: true, surfaceOp: 'append', sourceEventSeqs: [0],
|
||||
data: { content: [{ type: 'tool-result', toolCallId: 'opaque', content: [] }], vendor: true } },
|
||||
]
|
||||
const historical = historicalSessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
|
||||
for (const row of rows) historical.decodeRow(row)
|
||||
expect(historical.finish().events).toEqual(rows)
|
||||
const current = createSessionFormatCatalogWithChildren([]).createRestore(header, { recovery: 'strict', validation: 'current' })
|
||||
for (const row of rows) current.decodeRow(row)
|
||||
expect(current.finish().events).toEqual([rows[0], { ...rows[1], type: 'plugin:developer/message' }])
|
||||
})
|
||||
|
||||
it('restores V3 and preceding versions without the incoming catalog-completion edge', () => {
|
||||
const header = { type: 'session', version: 2, id: 'historical', createdAt: 1, isSeeded: false, delegationDepth: 0 }
|
||||
expect(historicalSessionFormatCatalog.readHeader(header)).toMatchObject({ status: 'migration-required' })
|
||||
|
||||
@@ -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: 51075fc5bf1032a2438b0372b7092a0bc5b4c119
|
||||
README.zh.md: 6a0d94290f177c690971e61cc3159c6de98ebd51
|
||||
README.md: 352246595a3da2d5902c51ebf946595c30b0247e
|
||||
README.zh.md: e2eebbd642122d4e0d71d379f8f342dd8d8b37f4
|
||||
@@ -17,6 +17,7 @@ Restore supported released V3 Sessions as V4 without rewriting their stored gene
|
||||
- [V3-to-V4 specification](#v3-to-v4-specification)
|
||||
- [Header and physical framing](#header-and-framing)
|
||||
- [Tool-result representation](#tool-results)
|
||||
- [Extension data](#extension-data)
|
||||
- [Message-source conversion](#message-sources)
|
||||
- [Parent catalog prerequisites](#parent-catalog)
|
||||
- [Sequence references and inheritance](#sequence-references)
|
||||
@@ -65,7 +66,7 @@ Every restore creates independent Stage state. Compact runs expand as iterables
|
||||
<a id="v3-to-v4-specification"></a>
|
||||
## V3-to-V4 specification
|
||||
|
||||
This edge changes only the named representations below and appends missing catalog facts. It retains each admitted source event's type, time, sequence, message identities, surface operation, references, and all fields outside those conversions. It creates no system prompt, developer event, tool execution, or replacement message. Earlier V0–V2 inputs first pass through their existing edges to V3; those edges retain their own transformations and refusal policies.
|
||||
This edge changes only the named representations below and appends missing catalog facts. It namespaces unknown ignorable event types and retains each admitted source event's time, sequence, message identities, surface operation, references, and all fields outside those conversions. It creates no system prompt, developer event, tool execution, or replacement message. Earlier V0–V2 inputs first pass through their existing edges to V3; those edges retain their own transformations and refusal policies.
|
||||
|
||||
<a id="header-and-framing"></a>
|
||||
### Header and physical framing
|
||||
@@ -76,7 +77,7 @@ This edge changes only the named representations below and appends missing catal
|
||||
| V3 physical rows | Released V3 source codec decodes events and compact runs | Source framing and source-event range decoding remain owned by the preceding package. |
|
||||
| V4 physical rows | `releasedV4SessionFormatCodec` reuses released V2 framing with native V4 admission | No V4 event is projected into a V3 semantic validator. Encoding and decoding do not run this incoming migration. |
|
||||
|
||||
No preset id, PTC dispatch event tag, file attachment, embedded stream, or physical filename is renamed by this edge. Their earlier migrations and storage owners remain authoritative.
|
||||
No preset id, PTC dispatch event tag, file attachment, or physical filename is renamed by this edge. Content extensions in embedded streams follow the rules below; stream order and indices remain unchanged.
|
||||
|
||||
<a id="tool-results"></a>
|
||||
### Tool-result representation
|
||||
@@ -96,10 +97,15 @@ The only admitted wrapper keys are `type`, `toolCallId`, `content`, and `isError
|
||||
|
||||
A malformed canonical wrapper raises a format error; an unmapped wrapper extension, nested result, or conflicting target field raises an unsupported-migration error. The transformation does not repair contradictory `data.error`; native target validation requires it to accompany `message.isError: true`.
|
||||
|
||||
<a id="extension-data"></a>
|
||||
### Extension data
|
||||
|
||||
Unknown V3 content tags become `plugin:<original-type>`; all other fields remain unchanged and opaque. Stream starts use the same tag in `blockType` and retain `type` and `index`; other start-owned field names receive a `plugin:` prefix. Request-tool definitions retain `name`, `description`, and `parameters`; all other own field names receive `plugin:` too, so historical `deferLoading` becomes `plugin:deferLoading` without enabling V4 capabilities. Existing prefixes double, keeping distinct old names distinct. Parameters, arguments, replay state, and plugin content fields are not traversed. These names preserve data identity; they do not load or execute plugins.
|
||||
|
||||
<a id="message-sources"></a>
|
||||
### Message-source conversion
|
||||
|
||||
The [message walker](src/sources.ts) visits only these payload positions. It rewrites a source only when `source.kind === 'plugin'`; other source values pass unchanged to target admission.
|
||||
The [message walker](src/sources.ts) visits only these payload positions. It maps known V3 identities and gives external plugin names and external direct source kinds separate reserved namespaces.
|
||||
|
||||
| Owning event | Message position |
|
||||
|---|---|
|
||||
@@ -107,9 +113,9 @@ The [message walker](src/sources.ts) visits only these payload positions. It rew
|
||||
| `system/message`, `assistant/message`, `tool/result` | `data.message` |
|
||||
| `agent/inbox/spliced` | Each `data.inserted[]` member |
|
||||
| `session/title-llm-request` | Each `data.messages[]` member |
|
||||
| `developer/message` | `data.message`; recognized by the shared native walker, never generated by the V3→V4 stage |
|
||||
| `developer/message` | Native walker only; an unknown ignorable V3 event is namespaced and its payload is not visited |
|
||||
|
||||
A plugin source requires a nonempty string `plugin`. Conversion removes that property, replaces `kind`, and preserves every other own JSON property. It does not infer missing producer metadata from message text, tool arguments, plugin configuration, or current files.
|
||||
A plugin source requires a string `plugin`, including the empty string. Conversion removes that property, replaces `kind`, and preserves every other own JSON property. Direct source kinds must be nonempty strings. Conversion does not infer missing metadata from message text, tool arguments, configuration, or current files.
|
||||
|
||||
| Exact V3 `plugin` | V4 `kind` |
|
||||
|---|---|
|
||||
@@ -119,11 +125,11 @@ A plugin source requires a nonempty string `plugin`. Conversion removes that pro
|
||||
| `@deepseek-ai/dsh-system-prompt`, on a system-role message | `system-prompt` |
|
||||
| `@deepseek-ai/dsh-system-prompt`, on another role | `runtime-context` |
|
||||
| Same-name first-party producers listed below | The exact plugin string |
|
||||
| Any other nonempty name outside the reserved collision set | The exact plugin string, including unknown external names |
|
||||
| Any other plugin name | `plugin:plugin:` followed by the complete original name |
|
||||
|
||||
The same-name producers are `agent-instructions`, `session-reference`, `team-message`, `goal`, `skill-invocation`, `skill-catalog`, `coordinator`, `subagent-report`, `subagent-settled`, `webhook`, `agent-message`, `model-selection`, `plan-mode`, `time-context`, `tmux-context`, `user-approval`, `repeat-tool-reminder`, `tool-cordis`, `cordis-host-runner`, `tool-goal`, `tool-jobs`, `hooks-codex`, `hooks-claude-code`, `schedule`, and `dsh-session-title-llm`.
|
||||
|
||||
The reserved collision names are `plugin`, `user`, `model`, `tool`, `system-prompt`, `tool-registry`, `runtime-context`, `compact-checkpoint`, `ptc-mode`, `compact-basic`, and `auto-review`. A V3 plugin source using one of them is refused instead of acquiring a different current role or producer meaning. Exact rename entries take precedence. Similar strings, `constructor`, and `__proto__` are ordinary external names.
|
||||
Namespacing prepends `plugin:plugin:` to an external plugin name or `plugin:source:` to an external direct kind. It keeps the complete original string, including an existing prefix. A plugin named `source:x` becomes `plugin:plugin:source:x`, while a direct kind named `x` becomes `plugin:source:x`; the categories cannot collide. Known V3 direct kinds remain unchanged.
|
||||
|
||||
There is no recursive source search. Captured request text, assistant replay state and streams, tool arguments/content metadata, Team payloads, and arbitrary nested objects remain unchanged unless another explicitly named rule applies.
|
||||
|
||||
@@ -161,20 +167,20 @@ For a seeded Session, the last `session/end-seed` carrying `inherited: true` ide
|
||||
| Delivery record | Admission and preservation |
|
||||
|---|---|
|
||||
| Any interpreted `session-log-deepseek/delivery-accepted` | Generation must be a nonnegative safe integer; omission identifies V0. |
|
||||
| V3 source marker claiming generation 4 | Refuse: changing the header must not activate a target-generation watermark. |
|
||||
| V3 source marker claiming any generation above 3 | Namespace its event type as `plugin:session-log-deepseek/delivery-accepted` and set `ignorable: true`; keep the inactive payload and coordinates unchanged. |
|
||||
| V3 source marker for generation 3 | Require a nonempty Session id and nonnegative safe-integer `throughSeq` before the marker; a foreign id is allowed only before the inherited cut with `parentSession`. |
|
||||
| Other source generations | Retain their ids, generation, and coordinates without activating them. |
|
||||
| Source generations below 3 | Retain their ids, generation, and coordinates without activating them. |
|
||||
| Native V4 marker for generation 4 | Apply the same earlier-coordinate and Session-ownership checks using V4 as current. |
|
||||
| Native V4 historical marker, including generation 3 | Retain recorded coordinates and identity; it is not a V4 acceptance watermark. |
|
||||
|
||||
No delivery payload is rewritten. The marker's envelope sequence also remains unchanged in this edge.
|
||||
No delivery payload is rewritten. Namespacing higher-generation records preserves their inactive V3 meaning after this and future header promotions; it does not acknowledge delivery. The marker’s envelope sequence remains unchanged.
|
||||
|
||||
<a id="source-audit"></a>
|
||||
### Source audit and refusal
|
||||
|
||||
V3 physical decoding and header validation run before the stage. The stage checks dense sequences, source cuts, canonical tool-result wrappers, named plugin sources, delivery ownership, and supplied catalog evidence as specified above. It does not run the complete released V3 semantic restorer or copy the V2→V3 event/content allowlist. Complete restoration additionally applies the V4 target rules below; physical parsing, stage conversion, and target restoration are distinct checks.
|
||||
|
||||
Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. Unknown required events are refused by vocabulary-aware target restoration; unknown ignorable events remain opaque under their physical framing and any explicitly owned admission rules. No generic source-schema validation or recursive numeric-field inference is implied.
|
||||
Only the enumerated messages and fields are converted. Unrelated events and arbitrary JSON do not acquire new meanings from matching strings or numbers. The fixed `RELEASED_V3_EVENT_TYPES` set separates source events from extensions independently of the current writer. Unknown required events are invalid V3 input; unknown ignorable names become `plugin:<original-name>` with their payload unchanged. No generic source-schema validation or recursive numeric-field inference is implied.
|
||||
|
||||
Only the canonical V3 `tool/result` wrapper has a preserving conversion. A retired `tool-result` block in another interpreted position is refused by target admission rather than retained as an invalid V4 block. Native V4 applies the same tag refusal before recoverable suffix suppression. The check covers only these positions:
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ kind: "package-library"
|
||||
- [V3 到 V4 规范](#v3-to-v4-specification)
|
||||
- [Header 与物理分帧](#header-and-framing)
|
||||
- [工具结果表示](#tool-results)
|
||||
- [扩展数据](#extension-data)
|
||||
- [消息来源转换](#message-sources)
|
||||
- [父目录前置证据](#parent-catalog)
|
||||
- [序号引用与继承](#sequence-references)
|
||||
@@ -65,7 +66,7 @@ const artifact = restore.finish()
|
||||
<a id="v3-to-v4-specification"></a>
|
||||
## V3 到 V4 规范
|
||||
|
||||
本边只改变下列明确命名的表示,并追加缺失的目录事实。每个获准源事件的 type、time、sequence、消息身份、表面操作、引用以及转换之外的字段均保留。它不创建 system prompt、developer 事件、工具执行或替换消息。更早的 V0–V2 输入先经过各自现有迁移边到达 V3;那些边保留自身的转换与拒绝策略。
|
||||
本边只改变下列明确命名的表示,并追加缺失的目录事实。未知可忽略事件的类型获得命名空间;每个获准源事件的 time、sequence、消息身份、表面操作、引用以及转换之外的字段均保留。它不创建 system prompt、developer 事件、工具执行或替换消息。更早的 V0–V2 输入先经过各自现有迁移边到达 V3;那些边保留自身的转换与拒绝策略。
|
||||
|
||||
<a id="header-and-framing"></a>
|
||||
### Header 与物理分帧
|
||||
@@ -76,7 +77,7 @@ const artifact = restore.finish()
|
||||
| V3 物理行 | 已发布 V3 源 codec 解码事件与紧凑 run | 源分帧和源事件区间解码仍归前一个包所有。 |
|
||||
| V4 物理行 | `releasedV4SessionFormatCodec` 复用已发布 V2 分帧并执行原生 V4 接纳 | 不把 V4 事件投影到 V3 语义校验器。编码和解码不运行此入边迁移。 |
|
||||
|
||||
本边不重命名 preset id、PTC dispatch 事件标签、文件附件、内嵌流或物理文件名。对应的早期迁移和存储所有者仍是权威。
|
||||
本边不重命名 preset id、PTC dispatch 事件标签、文件附件或物理文件名。内嵌流中的内容扩展遵循下列规则,流顺序和索引保持不变。
|
||||
|
||||
<a id="tool-results"></a>
|
||||
### 工具结果表示
|
||||
@@ -96,10 +97,15 @@ Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapp
|
||||
|
||||
格式错误的 canonical wrapper 产生格式错误;没有映射的 wrapper 扩展、嵌套结果或冲突目标字段产生不支持迁移错误。转换不修复矛盾的 `data.error`;原生目标校验要求它与 `message.isError: true` 同时出现。
|
||||
|
||||
<a id="extension-data"></a>
|
||||
### 扩展数据
|
||||
|
||||
V3 未知内容标签变为 `plugin:<original-type>`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,并保留 `type` 和 `index`,其他自有字段名称添加 `plugin:` 前缀。请求工具定义保留 `name`、`description` 和 `parameters`,其他自有字段名称同样添加 `plugin:`;历史 `deferLoading` 因此变为 `plugin:deferLoading`,不会启用 V4 能力。已有前缀会再次添加,使不同旧名称保持不同。不遍历参数、实参、回放状态和插件内容字段。这些名称保留数据身份,不会加载或执行插件。
|
||||
|
||||
<a id="message-sources"></a>
|
||||
### 消息来源转换
|
||||
|
||||
[消息遍历器](src/sources.ts)只访问以下载荷位置。仅在 `source.kind === 'plugin'` 时改写来源;其他来源值原样进入目标接纳。
|
||||
[消息遍历器](src/sources.ts) 只访问下列 payload 位置。它映射已知 V3 身份,并为外部插件名与外部直接来源 kind 分配独立的保留命名空间。
|
||||
|
||||
| 所属事件 | 消息位置 |
|
||||
|---|---|
|
||||
@@ -107,9 +113,9 @@ Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapp
|
||||
| `system/message`、`assistant/message`、`tool/result` | `data.message` |
|
||||
| `agent/inbox/spliced` | 每个 `data.inserted[]` 成员 |
|
||||
| `session/title-llm-request` | 每个 `data.messages[]` 成员 |
|
||||
| `developer/message` | `data.message`;由共享的原生遍历器识别,V3→V4 Stage 从不生成该事件 |
|
||||
| `developer/message` | 仅由原生遍历器处理;未知可忽略 V3 事件被命名空间化,其 payload 不被访问 |
|
||||
|
||||
Plugin 来源要求非空字符串 `plugin`。转换移除该属性、替换 `kind`,并保留其他所有自有 JSON 属性。它不从消息文本、工具参数、插件配置或当前文件推断缺失的生产者元数据。
|
||||
插件来源要求字符串 `plugin`,允许空字符串。转换移除该属性、替换 `kind`,并保留其他所有自有 JSON 属性。直接来源 kind 必须为非空字符串。转换不从消息文本、工具参数、配置或当前文件推断缺失元数据。
|
||||
|
||||
| 精确的 V3 `plugin` | V4 `kind` |
|
||||
|---|---|
|
||||
@@ -119,11 +125,11 @@ Plugin 来源要求非空字符串 `plugin`。转换移除该属性、替换 `ki
|
||||
| system 角色消息中的 `@deepseek-ai/dsh-system-prompt` | `system-prompt` |
|
||||
| 其他角色中的 `@deepseek-ai/dsh-system-prompt` | `runtime-context` |
|
||||
| 下文列出的同名第一方生产者 | 精确的 plugin 字符串 |
|
||||
| 保留冲突集合之外的其他非空名称 | 精确的 plugin 字符串,包括未知外部名称 |
|
||||
| 其他任何插件名 | `plugin:plugin:` 后接完整的原始名称 |
|
||||
|
||||
同名生产者为 `agent-instructions`、`session-reference`、`team-message`、`goal`、`skill-invocation`、`skill-catalog`、`coordinator`、`subagent-report`、`subagent-settled`、`webhook`、`agent-message`、`model-selection`、`plan-mode`、`time-context`、`tmux-context`、`user-approval`、`repeat-tool-reminder`、`tool-cordis`、`cordis-host-runner`、`tool-goal`、`tool-jobs`、`hooks-codex`、`hooks-claude-code`、`schedule` 和 `dsh-session-title-llm`。
|
||||
|
||||
保留的冲突名称为 `plugin`、`user`、`model`、`tool`、`system-prompt`、`tool-registry`、`runtime-context`、`compact-checkpoint`、`ptc-mode`、`compact-basic` 和 `auto-review`。使用其中任一名称的 V3 plugin 来源会被拒绝,避免获得不同的当前角色或生产者含义。精确重命名项优先匹配。相似字符串、`constructor` 和 `__proto__` 都是普通外部名称。
|
||||
命名空间为外部插件名称添加 `plugin:plugin:`,为外部直接 kind 添加 `plugin:source:`,完整保留包括已有前缀在内的原字符串。名为 `source:x` 的插件变为 `plugin:plugin:source:x`,直接 kind `x` 则变为 `plugin:source:x`,两类身份不会碰撞。已知 V3 直接 kind 保持不变。
|
||||
|
||||
来源查找不递归进行。捕获的请求文本、assistant 回放状态与流、工具参数/内容元数据、Team 载荷以及任意嵌套对象均保留,除非另有明确命名的规则适用。
|
||||
|
||||
@@ -161,20 +167,20 @@ Stage 只把最终继承截点之后的父目录记录作为候选。每个 inhe
|
||||
| Delivery 记录 | 接纳与保留 |
|
||||
|---|---|
|
||||
| 任何被解释的 `session-log-deepseek/delivery-accepted` | 代际必须为非负安全整数;省略表示 V0。 |
|
||||
| 声称 generation 4 的 V3 源 marker | 拒绝:改变 header 不得激活目标代际的 watermark。 |
|
||||
| V3 源标记声明高于 3 的任意代际 | 事件类型变为 `plugin:session-log-deepseek/delivery-accepted` 并设置 `ignorable: true`;保留未激活的 payload 与坐标。 |
|
||||
| generation 3 的 V3 源 marker | 要求非空 Session id 和早于 marker 的非负安全整数 `throughSeq`;只有在继承截点之前且带 `parentSession` 时才允许其他 Session id。 |
|
||||
| 其他源代际 | 保留 id、generation 和坐标,不激活它们。 |
|
||||
| 低于 3 的源代际 | 保留 id、代际与坐标,不激活它们。 |
|
||||
| generation 4 的原生 V4 marker | 以 V4 为当前代际,应用同样的较早坐标和 Session 归属检查。 |
|
||||
| 原生 V4 中的历史 marker,包括 generation 3 | 保留记录的坐标与身份;它不是 V4 接纳 watermark。 |
|
||||
|
||||
任何 delivery 载荷均不改写。该边也保留 marker 的信封序号。
|
||||
不改写投递 payload。对较高代际记录使用命名空间,保留其在本次及未来 header 升级后的 V3 未激活含义;这不确认投递。标记的信封序号保持不变。
|
||||
|
||||
<a id="source-audit"></a>
|
||||
### 源审计与拒绝
|
||||
|
||||
Stage 之前先执行 V3 物理解码与 header 校验。Stage 按上述规范检查连续序号、源截点、canonical 工具结果 wrapper、命名的 plugin 来源、delivery 归属及传入的目录证据。它不运行完整的已发布 V3 语义恢复器,也不复制 V2→V3 的事件/内容允许列表。完整恢复还会应用下述 V4 目标规则;物理解析、Stage 转换与目标恢复是不同的检查。
|
||||
|
||||
只有枚举出的消息与字段会转换。无关事件和任意 JSON 不会因字符串或数字相同而获得新含义。未知 required 事件由支持事件词汇表的目标恢复器拒绝;未知 ignorable 事件在其物理分帧与明确所属的接纳规则下保持不透明。这不意味着通用源 schema 校验或递归数字字段推断。
|
||||
只转换列明的消息及字段。无关事件及任意 JSON 不会因字符串或数字匹配而获得新含义。固定的 `RELEASED_V3_EVENT_TYPES` 集合独立于当前 writer 区分源事件和扩展。未知必需事件不是有效的 V3 输入;未知可忽略事件名称变为 `plugin:<original-name>`,载荷不变。不隐含通用源 schema 校验或递归数字字段推断。
|
||||
|
||||
只有 canonical V3 `tool/result` wrapper 具有保留信息的转换。其他被解释位置中的已退役 `tool-result` block 由目标接纳拒绝,不作为无效 V4 block 保留。原生 V4 在 recoverable 后缀抑制前应用同样的标签拒绝。检查只覆盖以下位置:
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/** V3 extension names and fields isolated from current core vocabulary. */
|
||||
|
||||
import { SessionFormatError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
|
||||
import { mapEventMessages } from './sources.ts'
|
||||
|
||||
const V3_BLOCK_TYPES = new Set(['text', 'reasoning', 'image', 'file', 'tool-call', 'tool-result'])
|
||||
const START_FIELDS = new Set(['type', 'index', 'blockType'])
|
||||
const TOOL_SCHEMA_FIELDS = new Set(['name', 'description', 'parameters'])
|
||||
|
||||
/** Prefix extension-owned field names without reinterpreting their values. */
|
||||
function namespaceFields(value: SessionFormatJsonObject, fields: ReadonlySet<string>): SessionFormatJsonObject {
|
||||
if (Object.keys(value).every(key => fields.has(key))) return value
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [fields.has(key) ? key : `plugin:${key}`, item]))
|
||||
}
|
||||
|
||||
function migrateBlock(value: SessionFormatJsonValue, subject: string): SessionFormatJsonValue {
|
||||
if (!isSessionFormatJsonObject(value) || typeof value['type'] !== 'string') {
|
||||
throw new SessionFormatError(`${subject} requires content blocks with string type tags`)
|
||||
}
|
||||
const type = value['type']
|
||||
if (V3_BLOCK_TYPES.has(type)) return value
|
||||
return { ...value, type: `plugin:${type}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert declared content tags while keeping arguments and extension data opaque.
|
||||
* @param value - released content array.
|
||||
* @param subject - owning event and content location for diagnostics.
|
||||
* @returns content in the same order, sharing unchanged blocks and image references.
|
||||
*/
|
||||
export function migrateV3Content(value: SessionFormatJsonValue | undefined, subject: string): SessionFormatJsonValue[] {
|
||||
if (!Array.isArray(value)) throw new SessionFormatError(`${subject} content must be an array`)
|
||||
const blocks = value as readonly SessionFormatJsonValue[]
|
||||
const mapped = blocks.map((block, index) => migrateBlock(block, `${subject}[${index}]`))
|
||||
return mapped.every((block, index) => block === value[index]) ? value as SessionFormatJsonValue[] : mapped
|
||||
}
|
||||
|
||||
function migrateMessage(message: SessionFormatJsonObject, subject: string): SessionFormatJsonObject {
|
||||
const content = migrateV3Content(message['content'], subject)
|
||||
return content === message['content'] ? message : { ...message, content }
|
||||
}
|
||||
|
||||
function migrateChunk(value: SessionFormatJsonValue | undefined, subject: string): SessionFormatJsonValue | undefined {
|
||||
if (!isSessionFormatJsonObject(value)) return value
|
||||
if (value['type'] === 'block-end') {
|
||||
const block = migrateBlock(value['block'] as SessionFormatJsonValue, `${subject}.block`)
|
||||
return block === value['block'] ? value : { ...value, block }
|
||||
}
|
||||
if (value['type'] !== 'block-start') return value
|
||||
const original = value['blockType']
|
||||
if (typeof original !== 'string') throw new SessionFormatError(`${subject} blockType must be a string`)
|
||||
const blockType = V3_BLOCK_TYPES.has(original) ? original : `plugin:${original}`
|
||||
const mapped = namespaceFields(value, START_FIELDS)
|
||||
return blockType === original ? mapped : { ...mapped, blockType }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert declared V3 extension content, streams, and request-tool extension fields after canonical result lifting.
|
||||
* @param event - source event after producer attribution conversion.
|
||||
* @returns the event with namespaced extension tags and fields, retaining all coordinates and values.
|
||||
*/
|
||||
export function migrateV3EventContent(event: SessionFormatEvent): SessionFormatEvent {
|
||||
const subject = `format v3 ${event.type} at seq ${event.seq}`
|
||||
let mapped = mapEventMessages(event, message => migrateMessage(message, subject))
|
||||
if (!isSessionFormatJsonObject(mapped.data)) return mapped
|
||||
let data = mapped.data
|
||||
const contentField = (key: string): void => {
|
||||
const content = migrateV3Content(data[key], `${subject}.${key}`)
|
||||
if (content !== data[key]) data = { ...data, [key]: content }
|
||||
}
|
||||
if (event.type === 'compaction/summary') {
|
||||
contentField('summary')
|
||||
if (data['rawOutput'] !== undefined) contentField('rawOutput')
|
||||
} else if (event.type === 'tool/ptc-dispatch') contentField('content')
|
||||
else if (event.type === 'team/message/queued' && isSessionFormatJsonObject(data['message'])) {
|
||||
const message = migrateMessage(data['message'], subject)
|
||||
if (message !== data['message']) data = { ...data, message }
|
||||
}
|
||||
if (event.type === 'request/header' && isSessionFormatJsonObject(data['header'])) {
|
||||
const header = data['header']
|
||||
const tools = header['tools']
|
||||
if (Array.isArray(tools)) {
|
||||
const converted = (tools as readonly SessionFormatJsonValue[]).map((tool) => {
|
||||
if (!isSessionFormatJsonObject(tool)) return tool
|
||||
return namespaceFields(tool, TOOL_SCHEMA_FIELDS)
|
||||
})
|
||||
if (converted.some((tool, index) => tool !== tools[index])) data = { ...data, header: { ...header, tools: converted } }
|
||||
}
|
||||
}
|
||||
if ((event.type === 'assistant/message' || event.type === 'assistant/attempt') && Array.isArray(data['stream'])) {
|
||||
const stream = data['stream'] as readonly SessionFormatJsonValue[]
|
||||
const converted = stream.map((entry, index) => {
|
||||
if (!isSessionFormatJsonObject(entry) || entry['type'] !== 'chunk') return entry
|
||||
const chunk = migrateChunk(entry['chunk'], `${subject}.stream[${index}]`)
|
||||
return chunk === entry['chunk'] ? entry : { ...entry, chunk: chunk as SessionFormatJsonValue }
|
||||
})
|
||||
if (converted.some((entry, index) => entry !== stream[index])) data = { ...data, stream: converted }
|
||||
}
|
||||
if (data !== mapped.data) mapped = { ...mapped, data }
|
||||
return mapped
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Fixed V3 event vocabulary and reversible namespaces for historical extension identities. */
|
||||
|
||||
import { isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
|
||||
|
||||
// This historical list must not inherit additions or removals from the current Session event list.
|
||||
/* jscpd:ignore-start */
|
||||
/** First-party event names understood by the released V3 reader, independent of the installed writer. */
|
||||
export const RELEASED_V3_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'agent-preset/selected',
|
||||
'agent/inbox/spliced',
|
||||
'approval/asked',
|
||||
'approval/decided',
|
||||
'approval/policy',
|
||||
'assistant/attempt',
|
||||
'assistant/message',
|
||||
'command/done',
|
||||
'command/run',
|
||||
'compaction/end',
|
||||
'compaction/prune',
|
||||
'compaction/start',
|
||||
'compaction/summary',
|
||||
'deliverables/presented',
|
||||
'feedback/message-delete',
|
||||
'feedback/message-put',
|
||||
'feedback/record',
|
||||
'goal/change',
|
||||
'hook/invoked',
|
||||
'hook/result',
|
||||
'image/offload',
|
||||
'llm/retry',
|
||||
'llm/retry-started',
|
||||
'model/selection',
|
||||
'permission/preset',
|
||||
'plan/mode',
|
||||
'request/context',
|
||||
'request/header',
|
||||
'sandbox/mode',
|
||||
'schedule/change',
|
||||
'session-log-deepseek/delivery-accepted',
|
||||
'session/end-seed',
|
||||
'session/title',
|
||||
'session/title-llm-request',
|
||||
'step/end',
|
||||
'step/start',
|
||||
'subagent/catalog',
|
||||
'subagent/descriptor',
|
||||
'subagent/model-selection-policy',
|
||||
'system/message',
|
||||
'team/member',
|
||||
'team/message/delivered',
|
||||
'team/message/queued',
|
||||
'team/task',
|
||||
'todo/write',
|
||||
'tool-workflow/agent-end',
|
||||
'tool-workflow/agent-start',
|
||||
'tool-workflow/run-end',
|
||||
'tool-workflow/run-start',
|
||||
'tool/call',
|
||||
'tool/ptc-dispatch',
|
||||
'tool/ptc-dispatch-start',
|
||||
'tool/result',
|
||||
'turn/end',
|
||||
'turn/start',
|
||||
'user/message',
|
||||
'web/deepseek-search-llm-request',
|
||||
'workspace/changes',
|
||||
])
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Qualify the complete original name with its historical identity namespace.
|
||||
* @param namespace - owner of the historical identity.
|
||||
* @param name - complete original source name.
|
||||
* @returns a namespaced identity retaining the complete original string as its suffix.
|
||||
*/
|
||||
export function v3ExtensionIdentity(namespace: 'plugin' | 'source', name: string): string {
|
||||
return `plugin:${namespace}:${name}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep unknown events and higher-generation delivery records opaque after header promotion.
|
||||
* @param event - source event after V3 vocabulary and delivery-generation admission.
|
||||
* @returns the same event or an ignorable namespaced event retaining its payload and coordinates.
|
||||
*/
|
||||
export function namespaceV3OpaqueEvent(event: SessionFormatEvent): SessionFormatEvent {
|
||||
const inactiveDelivery = event.type === 'session-log-deepseek/delivery-accepted'
|
||||
&& isSessionFormatJsonObject(event.data) && typeof event.data['sessionFormatVersion'] === 'number'
|
||||
&& event.data['sessionFormatVersion'] > 3
|
||||
return inactiveDelivery || event['ignorable'] === true && !RELEASED_V3_EVENT_TYPES.has(event.type)
|
||||
? { ...event, type: `plugin:${event.type}`, ignorable: true }
|
||||
: event
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './codec.ts'
|
||||
export * from './migration.ts'
|
||||
export { assertReleasedV4Header, assertReleasedV4Relationships, restoreReleasedV4Artifact } from './validation.ts'
|
||||
export { historicalChildCatalogSource } from './facts.ts'
|
||||
export { RELEASED_V3_EVENT_TYPES } from './extension-identities.ts'
|
||||
@@ -3,8 +3,10 @@
|
||||
import { defineSessionFormatMigration, SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent, SessionFormatEventRun, SessionFormatJsonObject, SessionFormatJsonValue, SessionFormatMigration, SessionFormatMigrationContext, SessionFormatMigrationStage, SessionFormatMigrationStageInput } from '@deepseek-ai/dsh-session-format'
|
||||
import { assertReleasedV3Header } from '@deepseek-ai/dsh-session-format-v2-to-v3'
|
||||
import { mapEventMessages, rewritePluginSource } from './sources.ts'
|
||||
import { mapEventMessages, rewriteV3MessageSource } from './sources.ts'
|
||||
import { liftToolResult } from './tool-role.ts'
|
||||
import { migrateV3EventContent } from './content.ts'
|
||||
import { namespaceV3OpaqueEvent } from './extension-identities.ts'
|
||||
import { assertReleasedV4Header, validateDeliveryAccepted } from './validation.ts'
|
||||
import { catalogFact, childCatalogSource, childCatalogFact, childCatalogSubject } from './facts.ts'
|
||||
|
||||
@@ -65,17 +67,17 @@ class ReleasedV3ToV4Stage implements SessionFormatMigrationStage {
|
||||
}
|
||||
const deliveryId = validateDeliveryAccepted(event, 3)
|
||||
if (event.type === 'session-log-deepseek/delivery-accepted') {
|
||||
if ((event.data as SessionFormatJsonObject)['sessionFormatVersion'] === 4) {
|
||||
throw new SessionFormatUnsupportedMigrationError('format v3 delivery marker claims target format v4')
|
||||
}
|
||||
if (deliveryId !== undefined && deliveryId !== this.input.sourceHeader.id) this.foreignDeliverySeq = event.seq
|
||||
}
|
||||
const opaque = namespaceV3OpaqueEvent(event)
|
||||
if (opaque !== event) { context.emitEvent(opaque); return }
|
||||
const rewritten = mapEventMessages(event, (message) => {
|
||||
const source = message['source']
|
||||
if (!isSessionFormatJsonObject(source) || source['kind'] !== 'plugin') return message
|
||||
return { ...message, source: rewritePluginSource(source, event.seq, message['role']) }
|
||||
if (!isSessionFormatJsonObject(source)) return message
|
||||
const converted = rewriteV3MessageSource(source, event.seq, message['role'])
|
||||
return converted === source ? message : { ...message, source: converted }
|
||||
})
|
||||
context.emitEvent(liftToolResult(rewritten))
|
||||
context.emitEvent(migrateV3EventContent(liftToolResult(rewritten)))
|
||||
}
|
||||
|
||||
transformRun(run: SessionFormatEventRun, context: SessionFormatMigrationContext): void {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** V3 source migration; tool arguments, content and extension payloads remain opaque. */
|
||||
/** V3 producer identities with reserved namespaces for external names. */
|
||||
|
||||
import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import { SessionFormatError, isSessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
|
||||
import { v3ExtensionIdentity } from './extension-identities.ts'
|
||||
|
||||
/**
|
||||
* Visit only messages carried by first-party event payloads.
|
||||
@@ -44,17 +45,11 @@ const RENAMED_PRODUCERS: Readonly<Record<string, string>> = Object.freeze({
|
||||
'@deepseek-ai/dsh-system-prompt': 'runtime-context',
|
||||
})
|
||||
|
||||
/** Current producer kinds whose names are not released V3 plugin identities. */
|
||||
const CURRENT_PRODUCER_KINDS: ReadonlySet<string> = new Set([
|
||||
'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',
|
||||
'subagent-settled', 'webhook', 'agent-message', 'model-selection',
|
||||
'plan-mode', 'time-context', 'tmux-context', 'user-approval',
|
||||
'repeat-tool-reminder', 'tool-cordis', 'cordis-host-runner', 'tool-goal',
|
||||
'tool-jobs', 'hooks-codex', 'hooks-claude-code', 'schedule',
|
||||
'dsh-session-title-llm', 'auto-review',
|
||||
/** Native V3 producer kinds whose identity already has a current owner. */
|
||||
const V3_NATIVE_PRODUCERS: ReadonlySet<string> = new Set([
|
||||
'user', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message',
|
||||
'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report',
|
||||
'subagent-settled', 'webhook', 'agent-message',
|
||||
])
|
||||
|
||||
/** First-party V3 plugin identities that intentionally keep their current kind. */
|
||||
@@ -74,12 +69,7 @@ function producerKind(plugin: string, role: SessionFormatJsonValue | undefined):
|
||||
const renamed = Object.hasOwn(RENAMED_PRODUCERS, plugin) ? RENAMED_PRODUCERS[plugin] : undefined
|
||||
if (renamed !== undefined) return renamed
|
||||
if (RELEASED_SAME_NAME_PRODUCERS.has(plugin)) return plugin
|
||||
if (CURRENT_PRODUCER_KINDS.has(plugin) || plugin === 'plugin') {
|
||||
throw new SessionFormatUnsupportedMigrationError(
|
||||
`V3 plugin source ${JSON.stringify(plugin)} collides with a current producer kind`,
|
||||
)
|
||||
}
|
||||
return plugin
|
||||
return v3ExtensionIdentity('plugin', plugin)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,9 +85,9 @@ export function rewritePluginSource(
|
||||
role: SessionFormatJsonValue | undefined,
|
||||
): SessionFormatJsonObject {
|
||||
const plugin = source['plugin']
|
||||
if (typeof plugin !== 'string' || plugin.length === 0) {
|
||||
if (typeof plugin !== 'string') {
|
||||
throw new SessionFormatError(
|
||||
`plugin source at seq ${seq} is not canonical: plugin requires a non-empty string`,
|
||||
`plugin source at seq ${seq} is not canonical: plugin requires a string`,
|
||||
)
|
||||
}
|
||||
const kind = producerKind(plugin, role)
|
||||
@@ -106,3 +96,19 @@ export function rewritePluginSource(
|
||||
.filter(([key]) => key !== 'plugin')
|
||||
.map(([key, item]) => [key, key === 'kind' ? kind : item]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain known V3 producer identities and namespace external kinds separately from plugin names.
|
||||
* @param source - decoded V3 message source.
|
||||
* @param seq - event sequence for diagnostics.
|
||||
* @param role - enclosing message role for role-sensitive producer mappings.
|
||||
* @returns the converted source with every non-identity field preserved.
|
||||
*/
|
||||
export function rewriteV3MessageSource(
|
||||
source: SessionFormatJsonObject, seq: number, role: SessionFormatJsonValue | undefined,
|
||||
): SessionFormatJsonObject {
|
||||
const kind = source['kind']
|
||||
if (typeof kind !== 'string' || kind.length === 0) throw new SessionFormatError(`message source at seq ${seq} requires a nonempty kind`)
|
||||
if (kind === 'plugin') return rewritePluginSource(source, seq, role)
|
||||
return V3_NATIVE_PRODUCERS.has(kind) ? source : { ...source, kind: v3ExtensionIdentity('source', kind) }
|
||||
}
|
||||
@@ -106,11 +106,11 @@ describe('V3 parent catalog completion', () => {
|
||||
expect(migrate([catalog], [], false, undefined, true).events).toEqual([catalog])
|
||||
})
|
||||
|
||||
it('rejects target delivery activation and invalid current delivery ownership', () => {
|
||||
it('keeps target delivery inactive and rejects invalid current delivery ownership', () => {
|
||||
const preceding = { type: 'feedback/record', seq: 0, time: 1, data: { text: 'prior' } }
|
||||
const delivery = { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 1,
|
||||
data: { sessionId: 'other', throughSeq: 0, sessionFormatVersion: 4 } }
|
||||
expect(() => migrate([preceding, delivery], [])).toThrow('claims target format v4')
|
||||
expect(migrate([preceding, delivery], []).events).toEqual([preceding, { ...delivery, type: 'plugin:session-log-deepseek/delivery-accepted', ignorable: true }])
|
||||
expect(() => migrate([preceding, { ...delivery, data: { ...delivery.data, sessionFormatVersion: 3 } }], [])).toThrow('wrong Session')
|
||||
})
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('V3 parent catalog completion', () => {
|
||||
})
|
||||
|
||||
it.each([null, { version: 99 }])('retains existing catalog extensions without interpreting unavailable descriptors: %j', (descriptor) => {
|
||||
const data = { ...child, extension: 'retained' }
|
||||
const data = { ...child, plugin: 'retained' }
|
||||
const source = [{ type: 'subagent/catalog', seq: 0, time: 1, data }]
|
||||
expect(migrate(source, [{ childId: 'child', childCreatedAt: 2, descriptorCount: descriptor === null ? 0 : 1, descriptor }]).events).toEqual(source)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
|
||||
import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
|
||||
import { migrateV3Content, migrateV3EventContent } from '../src/content.ts'
|
||||
import { createSessionFormatV3ToV4, releasedV4SessionFormatCodec } from '../src/index.ts'
|
||||
import { assertV4MessageSources } from '../src/message-sources.ts'
|
||||
import { namespaceV3OpaqueEvent } from '../src/extension-identities.ts'
|
||||
|
||||
const image = { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }
|
||||
const wrapper = { type: 'future', content: [{ type: 'tool-result', toolCallId: 'opaque', content: [image] }] }
|
||||
const convertedBlock = { ...wrapper, type: 'plugin:future' }
|
||||
const user = { id: 'user', role: 'user', source: { kind: 'user' }, content: [wrapper] }
|
||||
const event = (type: string, data: SessionFormatJsonValue): SessionFormatEvent => ({ type, seq: 0, time: 1, data })
|
||||
|
||||
function migrate(row: SessionFormatEvent): SessionFormatEvent {
|
||||
const sourceHeader = { version: 3, id: 'content', createdAt: 0, isSeeded: false, delegationDepth: 0 }
|
||||
const stage = createSessionFormatV3ToV4([]).createStage({ sourceHeader, targetHeader: { ...sourceHeader, version: 4 }, sourceInheritedEventCount: 0, sourceKind: 'decoded' })
|
||||
const collector = new SessionFormatEventCollector()
|
||||
stage.transformEvent(row, collector)
|
||||
stage.finish(collector)
|
||||
return collector.values[0]!
|
||||
}
|
||||
|
||||
describe('V3 content conversion', () => {
|
||||
it('preserves own extension keys without changing opaque values or source content', () => {
|
||||
const old = JSON.parse('{"type":"vendor","metadata":{"tag":"old"},"__proto__":{"saved":true},"constructor":{"saved":false}}') as SessionFormatJsonObject
|
||||
const actual = migrateV3Content([old, image], 'sample')
|
||||
expect(actual).toEqual([{ ...old, type: 'plugin:vendor' }, image])
|
||||
const data = actual[0] as SessionFormatJsonObject
|
||||
expect(Object.hasOwn(data, '__proto__')).toBe(true)
|
||||
expect(Object.getPrototypeOf(data)).toBe(Object.prototype)
|
||||
expect(actual[1]).toBe(image)
|
||||
expect(old['type']).toBe('vendor')
|
||||
})
|
||||
|
||||
it.each(['message', 'extension', 'tool-addition', 'tool-removal', '', 'future', 'plugin:future'])('keeps the unknown V3 tag %j opaque', (type) => {
|
||||
const block = { type, content: [wrapper], message: { role: 'tool', content: [wrapper] }, data: wrapper }
|
||||
expect(migrateV3Content([block], 'sample')).toEqual([{ ...block, type: `plugin:${type}` }])
|
||||
})
|
||||
|
||||
it('shares unchanged leaves and ignores JSON that is not a declared content slot', () => {
|
||||
const content = [{ type: 'text', text: 'saved', metadata: wrapper }, { type: 'tool-call', id: 'call', name: 'echo', arguments: JSON.stringify(wrapper) }, image]
|
||||
expect(migrateV3Content(content, 'sample')).toBe(content)
|
||||
const row = event('user/message', { ...user, content, replayState: wrapper, schema: { examples: [wrapper] } })
|
||||
expect(migrateV3EventContent(row)).toBe(row)
|
||||
const opaque = event('future', { content: [wrapper] })
|
||||
expect(migrateV3EventContent(opaque)).toBe(opaque)
|
||||
const scalar = event('future', null)
|
||||
expect(migrateV3EventContent(scalar)).toBe(scalar)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['user/message', user, { ...user, content: [convertedBlock] }],
|
||||
['system/message', { message: user }, { message: { ...user, content: [convertedBlock] } }],
|
||||
['assistant/message', { message: user }, { message: { ...user, content: [convertedBlock] } }],
|
||||
['agent/inbox/spliced', { inserted: [user] }, { inserted: [{ ...user, content: [convertedBlock] }] }],
|
||||
['session/title-llm-request', { messages: [user] }, { messages: [{ ...user, content: [convertedBlock] }] }],
|
||||
['compaction/summary', { summary: [wrapper], rawOutput: [wrapper] }, { summary: [convertedBlock], rawOutput: [convertedBlock] }],
|
||||
['compaction/summary', { summary: [] }, { summary: [] }],
|
||||
['tool/ptc-dispatch', { content: [wrapper] }, { content: [convertedBlock] }],
|
||||
['team/message/queued', { message: user }, { message: { ...user, content: [convertedBlock] } }],
|
||||
['team/message/queued', { message: { ...user, content: [] } }, { message: { ...user, content: [] } }],
|
||||
['team/message/queued', { message: null }, { message: null }],
|
||||
] as const)('converts declared %s content without changing envelope coordinates', (type, data, expected) => {
|
||||
const row = {
|
||||
...event(type, data), seq: 3, sourceEventSeqs: [0, 2], surfaceOp: { op: 'replace', startSeq: 0, endSeq: 2 }, extra: { content: [wrapper] },
|
||||
} as SessionFormatEvent
|
||||
const before = structuredClone(row)
|
||||
expect(migrateV3EventContent(row)).toEqual({ ...row, data: expected })
|
||||
expect(row).toEqual(before)
|
||||
})
|
||||
|
||||
it.each(['assistant/message', 'assistant/attempt'])('converts raw %s start/end blocks and preserves start-owned fields', (type) => {
|
||||
const chunk = (value: SessionFormatJsonValue): SessionFormatJsonObject => ({ type: 'chunk', chunk: value, elapsedMs: 7 })
|
||||
const stream = [
|
||||
chunk({ type: 'block-start', index: 0, blockType: 'future', extensionName: 'old', metadata: { old: true }, custom: wrapper }),
|
||||
chunk({ type: 'block-end', index: 0, block: wrapper }),
|
||||
chunk({ type: 'block-start', index: 1, blockType: 'message' }),
|
||||
chunk({ type: 'block-end', index: 1, block: { type: 'message', content: [wrapper] } }),
|
||||
chunk({ type: 'block-start', index: 2, blockType: 'text' }),
|
||||
chunk({ type: 'block-start', index: 3, blockType: 'text', metadata: { original: true } }),
|
||||
chunk({ type: 'block-end', index: 2, block: { type: 'text', text: 'after' } }),
|
||||
chunk({ type: 'text-delta', index: 2, text: 'after' }),
|
||||
chunk(null), null, { type: 'annotation', data: wrapper },
|
||||
]
|
||||
const actual = migrateV3EventContent(event(type, { message: { ...user, content: [] }, stream }))
|
||||
expect(actual.data).toEqual({ message: { ...user, content: [] }, stream: [
|
||||
chunk({ type: 'block-start', index: 0, blockType: 'plugin:future', 'plugin:extensionName': 'old', 'plugin:metadata': { old: true }, 'plugin:custom': wrapper }),
|
||||
chunk({ type: 'block-end', index: 0, block: convertedBlock }),
|
||||
chunk({ type: 'block-start', index: 1, blockType: 'plugin:message' }),
|
||||
chunk({ type: 'block-end', index: 1, block: { type: 'plugin:message', content: [wrapper] } }),
|
||||
stream[4], chunk({ type: 'block-start', index: 3, blockType: 'text', 'plugin:metadata': { original: true } }),
|
||||
...stream.slice(6),
|
||||
] })
|
||||
const unchanged = event(type, { message: { ...user, content: [] }, stream: stream.slice(6) })
|
||||
expect(migrateV3EventContent(unchanged)).toBe(unchanged)
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps V3 request-tool extensions opaque with deferLoading=%s', (deferLoading) => {
|
||||
const parameters = { type: 'object', properties: {}, examples: [wrapper], deferLoading: true }
|
||||
const extras = JSON.parse('{"metadata":{"saved":true},"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"}}') as SessionFormatJsonObject
|
||||
const tool = { name: 'echo', description: 'Echo', parameters, deferLoading, ...extras }
|
||||
const row = event('request/header', { reason: 'initial', header: { config: { provider: 'mock', model: 'mock' }, tools: [tool] } })
|
||||
const converted = migrate(row)
|
||||
expect(converted.data).toEqual({ reason: 'initial', header: {
|
||||
config: { provider: 'mock', model: 'mock' }, tools: [{ name: 'echo', description: 'Echo', parameters, 'plugin:deferLoading': deferLoading, 'plugin:metadata': extras['metadata'], 'plugin:__proto__': extras['__proto__'], 'plugin:constructor': extras['constructor'] }],
|
||||
} })
|
||||
expect(() => releasedV4SessionFormatCodec.encodeEvent(converted)).not.toThrow()
|
||||
const header = (converted.data as SessionFormatJsonObject)['header'] as SessionFormatJsonObject
|
||||
const actual = (header['tools'] as SessionFormatJsonObject[])[0]!
|
||||
expect(actual['parameters']).toBe(parameters)
|
||||
expect(Object.hasOwn(actual, 'plugin:__proto__')).toBe(true)
|
||||
expect(Object.getPrototypeOf(actual)).toBe(Object.prototype)
|
||||
expect(tool['deferLoading']).toBe(deferLoading)
|
||||
})
|
||||
|
||||
it('keeps original prefixed keys distinct from unprefixed tool and stream extras', () => {
|
||||
const extra = JSON.parse('{"deferLoading":true,"plugin:deferLoading":false,"metadata":{"saved":1},"plugin:metadata":{"saved":2},"__proto__":{"saved":3},"plugin:__proto__":{"saved":4}}') as SessionFormatJsonObject
|
||||
const expected = {
|
||||
'plugin:deferLoading': true, 'plugin:plugin:deferLoading': false,
|
||||
'plugin:metadata': { saved: 1 }, 'plugin:plugin:metadata': { saved: 2 },
|
||||
'plugin:__proto__': { saved: 3 }, 'plugin:plugin:__proto__': { saved: 4 },
|
||||
}
|
||||
const tool = { name: 'echo', description: 'Echo', parameters: { metadata: extra }, ...extra }
|
||||
const request = migrateV3EventContent(event('request/header', { header: { tools: [tool] } }))
|
||||
expect(request.data).toEqual({ header: { tools: [{ name: 'echo', description: 'Echo', parameters: tool.parameters, ...expected }] } })
|
||||
const start = { type: 'block-start', index: 7, blockType: 'plugin:text', ...extra }
|
||||
const stream = migrateV3EventContent(event('assistant/attempt', { stream: [{ type: 'chunk', chunk: start }] }))
|
||||
expect(stream.data).toEqual({ stream: [{ type: 'chunk', chunk: { type: 'block-start', index: 7, blockType: 'plugin:plugin:text', ...expected } }] })
|
||||
expect(extra['deferLoading']).toBe(true)
|
||||
expect(Object.hasOwn(extra, '__proto__')).toBe(true)
|
||||
})
|
||||
|
||||
it('retains native V4 capability validation independently of historical extras', () => {
|
||||
const header = { config: { provider: 'mock', model: 'mock' }, tools: [{ name: 'echo', description: 'Echo', parameters: {}, deferLoading: false }] }
|
||||
expect(() => releasedV4SessionFormatCodec.encodeEvent(event('request/header', { header }))).toThrow(/deferLoading must be true/)
|
||||
expect(() => releasedV4SessionFormatCodec.encodeEvent(migrate(event('request/header', { header })))).not.toThrow()
|
||||
})
|
||||
|
||||
it('shares V3 request tools with no extension fields and leaves malformed tools decoder-owned', () => {
|
||||
for (const header of [null, {}, { tools: null }, { tools: [{ name: 'echo', description: 'Echo', parameters: {} }, null] }]) {
|
||||
const row = event('request/header', { header })
|
||||
expect(migrateV3EventContent(row)).toBe(row)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves missing message sources for native admission to reject', () => {
|
||||
const row = event('user/message', { id: 'missing-source', role: 'user', content: [] })
|
||||
const converted = migrate(row)
|
||||
expect(converted).toBe(row)
|
||||
expect(() => { assertV4MessageSources(converted) }).toThrow(/producer-owned source kind/)
|
||||
})
|
||||
|
||||
it('rejects non-array content at the durable conversion boundary', () => {
|
||||
expect(() => migrateV3Content({}, 'sample')).toThrow(/content must be an array/)
|
||||
})
|
||||
|
||||
it.each([null, {}, { type: 4 }])('rejects type-invalid content %j', (block) => {
|
||||
expect(() => migrateV3Content([block], 'sample')).toThrow(/string type tags/)
|
||||
})
|
||||
|
||||
it('rejects a stream start without a string blockType', () => {
|
||||
expect(() => migrateV3EventContent(event('assistant/attempt', { stream: [{ type: 'chunk', chunk: { type: 'block-start', index: 0 } }] }))).toThrow(/blockType must be a string/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native V4 opaque content', () => {
|
||||
it.each(['system', 'user'])('round trips %s extension content', (role) => {
|
||||
const message = { ...user, role, source: { kind: role === 'system' ? 'system-prompt' : 'user' }, content: [convertedBlock, { type: 'plugin:future', content: [wrapper] }] }
|
||||
const row = event(`${role}/message`, role === 'user' ? message : { turn: 1, step: 1, message })
|
||||
const physical = releasedV4SessionFormatCodec.encodeEvent(row)
|
||||
const collector = new SessionFormatEventCollector()
|
||||
releasedV4SessionFormatCodec.createDecoder({ type: 'session', version: 4, id: 'native', createdAt: 0, isSeeded: false, delegationDepth: 0 }, 'strict').decodeRow(physical, collector)
|
||||
expect(collector.values).toEqual([row])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown V3 event identities', () => {
|
||||
it.each(['developer/message', 'plugin:developer/message', 'future', '\ud800'])('namespaces %j while retaining opaque data and envelope fields', (type) => {
|
||||
const row = { ...event(type, { message: user, content: [wrapper] }), ignorable: true, sourceEventSeqs: [0], surfaceOp: 'append', extra: { saved: true } } as SessionFormatEvent
|
||||
const expected = { ...row, type: `plugin:${type}` }
|
||||
expect(migrate(row)).toEqual(expected)
|
||||
expect(expected.data).toBe(row.data)
|
||||
expect(namespaceV3OpaqueEvent(expected).type).not.toBe(expected.type)
|
||||
})
|
||||
|
||||
it('leaves incomplete delivery payloads for generation validation', () => {
|
||||
for (const data of [null, {}, { sessionFormatVersion: '4' }]) {
|
||||
const row = event('session-log-deepseek/delivery-accepted', data)
|
||||
expect(namespaceV3OpaqueEvent(row)).toBe(row)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps required unknown event identity for the source reader to reject', () => {
|
||||
const unknown = event('future', {})
|
||||
expect(namespaceV3OpaqueEvent(unknown)).toBe(unknown)
|
||||
const known = event('turn/start', { turn: 1 })
|
||||
expect(namespaceV3OpaqueEvent(known)).toBe(known)
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ function restore(source: unknown, version: 3 | 4 = 4) {
|
||||
describe('V4 source admission', () => {
|
||||
it.each(['constructor', 'toString', '__proto__'])('migrates external producer %s without interpreting inherited object keys', (plugin) => {
|
||||
const before = JSON.parse(`{"kind":"plugin","plugin":"${plugin}","__proto__":{"tag":"metadata"},"nested":{"kind":"plugin","plugin":"opaque"}}`) as SessionFormatJsonObject
|
||||
const expected = JSON.parse(`{"kind":"${plugin}","__proto__":{"tag":"metadata"},"nested":{"kind":"plugin","plugin":"opaque"}}`) as SessionFormatJsonObject
|
||||
const expected = JSON.parse(`{"kind":"plugin:plugin:${plugin}","__proto__":{"tag":"metadata"},"nested":{"kind":"plugin","plugin":"opaque"}}`) as SessionFormatJsonObject
|
||||
const migrated = restore(before, 3)
|
||||
expect(migrated.events[0]?.data).toMatchObject({ source: expected })
|
||||
expect(Object.hasOwn((migrated.events[0]?.data as { source: object }).source, '__proto__')).toBe(true)
|
||||
|
||||
@@ -86,10 +86,14 @@ describe('V3 to V4 source preservation', () => {
|
||||
expect(() => migrate([fact, { ...seed, data: {} }], { ...header, isSeeded: true })).toThrow('inherited event count')
|
||||
})
|
||||
|
||||
it('refuses a target-generation delivery without changing predecessor or future delivery values', () => {
|
||||
expect(() => migrate([fact, delivery(4)])).toThrow('claims target format v4')
|
||||
expect(() => restore([fact, delivery(4)])).toThrow('claims target format v4')
|
||||
for (const version of [undefined, 0, 1, 2, 3, 5]) {
|
||||
it('keeps higher-generation delivery records inactive without changing prior generation markers', () => {
|
||||
for (const version of [4, 5, 99]) {
|
||||
const marker = delivery(version)
|
||||
const expected = { ...marker, type: 'plugin:session-log-deepseek/delivery-accepted', ignorable: true }
|
||||
expect(migrate([fact, marker]).events[1]).toEqual(expected)
|
||||
expect(restore([fact, marker]).events[1]).toEqual(expected)
|
||||
}
|
||||
for (const version of [undefined, 0, 1, 2, 3]) {
|
||||
const marker = delivery(version)
|
||||
expect(migrate([fact, marker]).events[1]).toBe(marker)
|
||||
}
|
||||
@@ -122,7 +126,7 @@ describe('V3 to V4 source preservation', () => {
|
||||
type: 'external/opaque', seq: 0, time: -5, ignorable: true,
|
||||
data: { nested: { seq: 40 }, content: ['opaque'] }, surfaceOp: { future: { ref: 9 } },
|
||||
}
|
||||
expect(restore([opaque]).events).toEqual([opaque])
|
||||
expect(restore([opaque]).events).toEqual([{ ...opaque, type: 'plugin:external/opaque' }])
|
||||
const { ignorable: _ignorable, ...required } = opaque
|
||||
expect(() => restore([required])).toThrow('unknown event type')
|
||||
expect(() => restore([{ ...fact, time: 1.5 }])).toThrow('time')
|
||||
|
||||
@@ -107,6 +107,6 @@ describe('retired tool-result content admission', () => {
|
||||
recovery: 'strict', validation: 'current',
|
||||
})
|
||||
migrated.decodeRow(opaque)
|
||||
expect(migrated.finish().events).toEqual([opaque])
|
||||
expect(migrated.finish().events).toEqual([{ ...opaque, type: 'plugin:future/opaque' }])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
|
||||
import { mapEventMessages, rewritePluginSource } from '../src/sources.ts'
|
||||
import { mapEventMessages, rewritePluginSource, rewriteV3MessageSource } from '../src/sources.ts'
|
||||
|
||||
function event(type: string, data: SessionFormatJsonObject): SessionFormatEvent {
|
||||
return { type, seq: 0, time: 1, data }
|
||||
@@ -59,19 +59,19 @@ describe('rewritePluginSource', () => {
|
||||
expect(rewritePluginSource({ kind: 'plugin', plugin: 'tools-code-mode' }, 1, 'user')).toEqual({ kind: 'ptc-mode' })
|
||||
expect(rewritePluginSource({ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, 1, 'system')).toEqual({ kind: 'system-prompt' })
|
||||
expect(rewritePluginSource({ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, 1, 'user')).toEqual({ kind: 'runtime-context' })
|
||||
expect(rewritePluginSource({ kind: 'plugin', plugin: 'external', extra: true }, 1, 'user')).toEqual({ kind: 'external', extra: true })
|
||||
expect(rewritePluginSource({ kind: 'plugin', plugin: 'external', extra: true }, 1, 'user')).toEqual({ kind: 'plugin:plugin:external', extra: true })
|
||||
})
|
||||
|
||||
it('refuses a plugin field that is not a non-empty string', () => {
|
||||
const plugins: readonly SessionFormatJsonValue[] = [null, '', 7]
|
||||
it('refuses a plugin field that is not a string', () => {
|
||||
const plugins: readonly SessionFormatJsonValue[] = [null, 7]
|
||||
for (const plugin of plugins) {
|
||||
expect(() => rewritePluginSource({ kind: 'plugin', plugin }, 3, 'user')).toThrow(/not canonical/)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses external plugin names that collide with current producer kinds', () => {
|
||||
it('namespaces external plugin names while retaining released first-party producers', () => {
|
||||
for (const plugin of ['plugin', 'user', 'model', 'tool', 'system-prompt', 'runtime-context', 'compact-checkpoint', 'ptc-mode', 'compact-basic', 'auto-review']) {
|
||||
expect(() => rewritePluginSource({ kind: 'plugin', plugin }, 3, 'user')).toThrow(/collides with a current producer kind/)
|
||||
expect(rewritePluginSource({ kind: 'plugin', plugin }, 3, 'user')).toEqual({ kind: `plugin:plugin:${plugin}` })
|
||||
}
|
||||
expect(rewritePluginSource({ kind: 'plugin', plugin: 'agent-instructions', form: 'instructions', changes: [] }, 3, 'user'))
|
||||
.toEqual({ kind: 'agent-instructions', form: 'instructions', changes: [] })
|
||||
@@ -79,3 +79,31 @@ describe('rewritePluginSource', () => {
|
||||
expect(rewritePluginSource({ kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, 3, 'system')).toEqual({ kind: 'system-prompt' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('external V3 source identities', () => {
|
||||
it.each(['', 'plugin:x', 'source:x', 'message', 'x/y', '\ud800', '__proto__'])('separates plugin and direct identity %j', (name) => {
|
||||
expect(rewritePluginSource({ kind: 'plugin', plugin: name }, 1, 'user')).toEqual({ kind: `plugin:plugin:${name}` })
|
||||
if (name.length > 0) {
|
||||
expect(rewriteV3MessageSource({ kind: name, payload: { kind: 'plugin', plugin: name } }, 1, 'user'))
|
||||
.toEqual({ kind: `plugin:source:${name}`, payload: { kind: 'plugin', plugin: name } })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps plugin and direct-source categories distinct across prefixed names', () => {
|
||||
const plugin = rewriteV3MessageSource({ kind: 'plugin', plugin: 'source:x' }, 1, 'user')
|
||||
const direct = rewriteV3MessageSource({ kind: 'x' }, 1, 'user')
|
||||
expect(plugin).toEqual({ kind: 'plugin:plugin:source:x' })
|
||||
expect(direct).toEqual({ kind: 'plugin:source:x' })
|
||||
expect(plugin).not.toEqual(direct)
|
||||
})
|
||||
|
||||
it('preserves native V3 sources and converts plugin wrappers', () => {
|
||||
const source = { kind: 'tool', callId: 'call', extra: true }
|
||||
expect(rewriteV3MessageSource(source, 1, 'user')).toBe(source)
|
||||
expect(rewriteV3MessageSource({ kind: 'plugin', plugin: 'compact' }, 1, 'user')).toEqual({ kind: 'compact-checkpoint' })
|
||||
})
|
||||
|
||||
it.each([null, 0, ''])('rejects type-invalid source kind %j', (kind) => {
|
||||
expect(() => rewriteV3MessageSource({ kind }, 1, 'user')).toThrow(/requires a nonempty kind/)
|
||||
})
|
||||
})
|
||||
@@ -361,7 +361,7 @@ describe('JSONL immutable generation publication', () => {
|
||||
{ type: 'user/message', seq: 3, time: 5,
|
||||
surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2], data: {
|
||||
id: 'summary', role: 'user', content: [{ type: 'text', text: 'summary' }],
|
||||
source: { kind: 'plugin', plugin: 'summary-fixture' },
|
||||
source: { kind: 'plugin', plugin: 'summary-fixture', form: 'notice', summary: 'summary-fixture' },
|
||||
} },
|
||||
{ type: 'request/header', seq: 4, time: 6, data: {
|
||||
header: { config, system: '', tools: [], adapterDefaults: {} }, reason: 'initial',
|
||||
@@ -377,7 +377,9 @@ describe('JSONL immutable generation publication', () => {
|
||||
expect.objectContaining({ type: 'system/message', seq: 2, surfaceOp: 'append', data: expect.objectContaining({ message: expect.objectContaining({ role: 'system', content: [] }) as unknown }) as unknown }) as unknown,
|
||||
...events.slice(2).map(event => event.seq === 3
|
||||
? { ...event, seq: 4, surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 }, sourceEventSeqs: [3],
|
||||
data: { ...event.data as Record<string, unknown>, source: { kind: 'summary-fixture' } } }
|
||||
data: { ...event.data as Record<string, unknown>, source: {
|
||||
kind: 'plugin:plugin:summary-fixture', form: 'notice', summary: 'summary-fixture',
|
||||
} } }
|
||||
: event.seq === 4 ? { ...event, seq: 5, data: { header: { config }, reason: 'initial' } } : { ...event, seq: event.seq + 1 }),
|
||||
]
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createSessionFormatCatalogWithChildren } from '@deepseek-ai/dsh-session-format-catalog'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
|
||||
import { SessionFormatUnsupportedError, SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence'
|
||||
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
@@ -248,6 +249,28 @@ async function expectOnlyGenerations(paths: readonly string[]) {
|
||||
}
|
||||
|
||||
describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compression, access }) => {
|
||||
async function expectV3Conversion(
|
||||
rows: readonly SessionFormatJsonObject[], inspect: (events: readonly SessionEvent[]) => void,
|
||||
) {
|
||||
const path = await store(3, compression, rows)
|
||||
const original = await observe(path)
|
||||
for (const mode of [access, 'read'] as const) {
|
||||
const ctx = await mount(compression)
|
||||
const handle = await ctx.sessionPersistence.open(id, mode)
|
||||
try {
|
||||
expect(handle.header.version).toBe(SESSION_FORMAT_VERSION)
|
||||
const restored = await handle.read()
|
||||
expect(restored.events.map(event => ({ seq: event.seq, time: event.time })))
|
||||
.toEqual(rows.map((_, seq) => ({ seq, time: 1001 + seq })))
|
||||
inspect(restored.events)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
expect(await observe(path)).toEqual(original)
|
||||
await expectOnlyGenerations(access === 'read' ? [path] : [path, generationLogPath(root, undefined, id, SESSION_FORMAT_VERSION, compression)])
|
||||
}
|
||||
|
||||
it.each(migrationRefusals)('refuses V2 $name without publishing or discarding a tail', async ({ tail, diagnostic }) => {
|
||||
const path = await store(2, compression, [...prefix, tail])
|
||||
const original = await observe(path)
|
||||
@@ -266,17 +289,51 @@ describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compres
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a V3 watermark claiming V4 without publishing a successor', async () => {
|
||||
it.each([4, 99])('preserves a V3 watermark claiming V%s as an opaque extension through native reopening', async (sessionFormatVersion) => {
|
||||
const marker = { type: 'session-log-deepseek/delivery-accepted', data: {
|
||||
sessionId: id, throughSeq: 0, sessionFormatVersion: 4,
|
||||
sessionId: id, throughSeq: 0, sessionFormatVersion,
|
||||
} }
|
||||
const path = await store(3, compression, [...releasedPrefix, marker])
|
||||
const original = await observe(path)
|
||||
const ctx = await mount(compression)
|
||||
await expectRefusal(ctx, access, path,
|
||||
'format v3 delivery marker claims target format v4; source v3 artifact remains unchanged (raw log: ' + path + ')')
|
||||
expect(await observe(path)).toEqual(original)
|
||||
await expectOnlyGenerations([path])
|
||||
await expectV3Conversion([...releasedPrefix, marker], (events) => {
|
||||
expect(events).toEqual([
|
||||
...nativePrefix,
|
||||
{ ...marker, type: 'plugin:session-log-deepseek/delivery-accepted', ignorable: true },
|
||||
].map((row, seq) => ({ ...row, seq, time: 1001 + seq })))
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])('preserves V3 tool-definition deferLoading=%s and extension fields under plugin-prefixed keys', async (deferLoading) => {
|
||||
const parameters = {
|
||||
type: 'object', properties: { deferLoading: { type: 'boolean' } },
|
||||
default: { type: 'tool-result', toolCallId: 'opaque-call', content: [{ type: 'text', text: 'schema data' }] },
|
||||
}
|
||||
const fields = JSON.parse('{"metadata":{"saved":"original metadata"},"__proto__":{"saved":"prototype"},"constructor":{"saved":"constructor"}}') as SessionFormatJsonObject
|
||||
const tool = { name: 'example', description: 'Saved tool definition.', parameters, deferLoading, ...fields }
|
||||
const request = { type: 'request/header', data: { header: { config, tools: [tool] }, reason: 'initial' } }
|
||||
const targetTool = {
|
||||
name: 'example', description: 'Saved tool definition.', parameters,
|
||||
'plugin:deferLoading': deferLoading, 'plugin:metadata': fields['metadata'], 'plugin:__proto__': fields['__proto__'], 'plugin:constructor': fields['constructor'],
|
||||
}
|
||||
await expectV3Conversion([...releasedPrefix.slice(0, -1), request], (events) => {
|
||||
const expected = [...nativePrefix.slice(0, -1), {
|
||||
...request, data: { ...request.data, header: { config, tools: [targetTool] } },
|
||||
}]
|
||||
expect(events).toEqual(expected.map((row, seq) => ({ ...row, seq, time: 1001 + seq })))
|
||||
const migrated = events.find(event => event.type === 'request/header')?.data.header.tools?.[0]
|
||||
expect(Object.hasOwn(migrated!, 'plugin:__proto__')).toBe(true)
|
||||
expect(Object.getPrototypeOf(migrated!)).toBe(Object.prototype)
|
||||
expect(Object.hasOwn(migrated!, 'deferLoading')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a V3 tool definition with only declared fields unchanged', async () => {
|
||||
const tool = { name: 'example', description: 'Saved tool definition.', parameters: { type: 'object' } }
|
||||
const request = { type: 'request/header', data: { header: { config, tools: [tool] }, reason: 'initial' } }
|
||||
await expectV3Conversion([...releasedPrefix.slice(0, -1), request], (events) => {
|
||||
expect(events).toEqual([...nativePrefix.slice(0, -1), request]
|
||||
.map((row, seq) => ({ ...row, seq, time: 1001 + seq })))
|
||||
const migrated = events.find(event => event.type === 'request/header')?.data.header.tools?.[0]
|
||||
expect(Object.hasOwn(migrated!, 'metadata')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['tool/code-dispatch-start', 'tool/code-dispatch'])('keeps %s unsupported beyond a damaged native suffix', async (type) => {
|
||||
|
||||
@@ -755,13 +755,13 @@ describe('normalizeSessionSnapshot', () => {
|
||||
.toThrow('session snapshot must start with a session header')
|
||||
})
|
||||
|
||||
function deliveryLog(version: number, deliveryVersion = version): string {
|
||||
function deliveryLog(version: number, deliveryVersion = version, id = 'delivery'): string {
|
||||
return [
|
||||
{ type: 'session', version, id: 'delivery', createdAt: 1, isSeeded: false, delegationDepth: 0 },
|
||||
{ type: 'session', version, id, createdAt: 1, isSeeded: false, delegationDepth: 0 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
{
|
||||
type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
|
||||
data: { sessionId: 'delivery', throughSeq: 0, sessionFormatVersion: deliveryVersion },
|
||||
data: { sessionId: id, throughSeq: 0, sessionFormatVersion: deliveryVersion },
|
||||
},
|
||||
].map(record => JSON.stringify(record)).join('\n') + '\n'
|
||||
}
|
||||
@@ -792,9 +792,23 @@ describe('normalizeSessionSnapshot', () => {
|
||||
expect(staleCurrent).toContain('"sessionFormatVersion":3')
|
||||
})
|
||||
|
||||
it('refuses a source marker claiming the migration target before creating comparison tokens', () => {
|
||||
expect(() => normalizeSessionSnapshots([deliveryLog(3, 4)], ctx, { nativeWriterOutput: true }))
|
||||
.toThrow('format v3 delivery marker claims target format v4')
|
||||
it('keeps a higher-generation V3 delivery opaque before creating comparison tokens', () => {
|
||||
const source = deliveryLog(3, 4, 'recorded-session')
|
||||
const target = prepareSessionSnapshotFixtureForComparison(source)
|
||||
const marker = JSON.parse(target.trimEnd().split('\n').at(-1)!) as unknown
|
||||
expect(marker).toEqual({
|
||||
type: 'plugin:session-log-deepseek/delivery-accepted', seq: 1, time: 2, ignorable: true,
|
||||
data: { sessionId: 'recorded-session', throughSeq: 0, sessionFormatVersion: 4 },
|
||||
})
|
||||
const [historical, migrated, native] = normalizeSessionSnapshots(
|
||||
[source, target, deliveryLog(SESSION_FORMAT_VERSION, SESSION_FORMAT_VERSION, 'recorded-session')], ctx, { nativeWriterOutput: true },
|
||||
)
|
||||
expect(historical).toBe(migrated)
|
||||
expect(historical).not.toBe(native)
|
||||
expect(historical).toContain('"type":"plugin:session-log-deepseek/delivery-accepted"')
|
||||
expect(historical).toContain('"throughSeq":0,"sessionFormatVersion":4')
|
||||
expect(historical).not.toContain('{{sourceSessionFormatVersion}}')
|
||||
expect(native).toContain('"sessionFormatVersion":"{{sourceSessionFormatVersion}}"')
|
||||
})
|
||||
|
||||
it('keeps captured generations and delivery lookalikes numeric in versioned inputs', () => {
|
||||
|
||||
Generated
+3
@@ -1142,6 +1142,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-session-format-catalog':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-format-catalog
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-persistence
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"kind": "serial-created-fixture"
|
||||
"kind": "plugin:plugin:serial-created-fixture"
|
||||
}
|
||||
},
|
||||
"surfaceOp": "append"
|
||||
@@ -1783,7 +1783,7 @@
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"kind": "serial-created-fixture"
|
||||
"kind": "plugin:plugin:serial-created-fixture"
|
||||
}
|
||||
},
|
||||
"surfaceOp": "append"
|
||||
@@ -3029,7 +3029,7 @@
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"kind": "serial-created-fixture"
|
||||
"kind": "plugin:plugin:serial-created-fixture"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -3219,7 +3219,7 @@
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"kind": "serial-created-fixture"
|
||||
"kind": "plugin:plugin:serial-created-fixture"
|
||||
}
|
||||
},
|
||||
"surfaceOp": "append"
|
||||
@@ -3822,7 +3822,7 @@
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"kind": "serial-created-fixture"
|
||||
"kind": "plugin:plugin:serial-created-fixture"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -4028,7 +4028,7 @@
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
"kind": "serial-created-fixture"
|
||||
"kind": "plugin:plugin:serial-created-fixture"
|
||||
}
|
||||
},
|
||||
"surfaceOp": "append"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
|
||||
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
@@ -10,7 +10,7 @@
|
||||
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
|
||||
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
@@ -10,7 +10,7 @@
|
||||
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:plugin:serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"python-snapshot-image-offload"}},"surfaceOp":"append"}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { RELEASED_V3_EVENT_TYPES } from '@deepseek-ai/dsh-session-format-v3-to-v4'
|
||||
import { verifyV3EventVocabulary } from './verify-v3-event-vocabulary.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const writerPath = 'packages/core/session/src/types.ts'
|
||||
const eventsPath = 'packages/core/session/src/known-event-types.ts'
|
||||
const names = [...RELEASED_V3_EVENT_TYPES]
|
||||
|
||||
function write(root: string, path: string, content: string): void {
|
||||
const target = join(root, path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, content)
|
||||
}
|
||||
|
||||
function git(root: string, args: readonly string[]): string {
|
||||
return execFileSync('git', [
|
||||
'-c', 'user.name=V3 vocabulary fixture', '-c', 'user.email=v3-fixture@example.invalid',
|
||||
'-c', 'commit.gpgsign=false', '-c', `core.hooksPath=${join(root, 'empty-hooks')}`, ...args,
|
||||
], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
||||
}
|
||||
|
||||
function fixture(options: { writer?: string; events?: string } = {}): { root: string; sourceRef: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-v3-vocabulary-'))
|
||||
roots.push(root)
|
||||
write(root, writerPath, options.writer ?? 'export const SESSION_FORMAT_VERSION = 3 as const\n')
|
||||
write(root, eventsPath, options.events ?? `export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set(${JSON.stringify(names)})\n`)
|
||||
git(root, ['init', '--quiet'])
|
||||
git(root, ['add', '.'])
|
||||
git(root, ['commit', '--quiet', '-m', 'V3 source fixture'])
|
||||
return { root, sourceRef: git(root, ['rev-parse', 'HEAD']) }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('explicit V3 source vocabulary verification', () => {
|
||||
it('checks a pinned V3 writer independently of later V4 commits', () => {
|
||||
const { root, sourceRef } = fixture()
|
||||
write(root, writerPath, 'export const SESSION_FORMAT_VERSION = 4 as const\n')
|
||||
write(root, eventsPath, `export const KNOWN_SESSION_EVENT_TYPES = new Set(${JSON.stringify([...names, 'developer/message'])})\n`)
|
||||
git(root, ['add', '.'])
|
||||
git(root, ['commit', '--quiet', '-m', 'Later V4 writer with developer events'])
|
||||
expect(verifyV3EventVocabulary(root, sourceRef)).toEqual({ sourceRef, eventCount: names.length })
|
||||
})
|
||||
|
||||
it('rejects new V3 names even when the target writer has not classified them yet', () => {
|
||||
const { root, sourceRef } = fixture({
|
||||
events: `export const KNOWN_SESSION_EVENT_TYPES = new Set(${JSON.stringify([...names, 'new-v3/required', 'new-v3/ignorable'])})\n`,
|
||||
})
|
||||
expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow('Missing V3 names: new-v3/ignorable, new-v3/required')
|
||||
expect(verifyV3EventVocabulary(root, sourceRef, new Set([...names, 'new-v3/required', 'new-v3/ignorable'])).eventCount)
|
||||
.toBe(names.length + 2)
|
||||
})
|
||||
|
||||
it('rejects a V4 source instead of treating developer/message as a V3 event', () => {
|
||||
const { root, sourceRef } = fixture({
|
||||
writer: 'export const SESSION_FORMAT_VERSION = 4 as const\n',
|
||||
events: `export const KNOWN_SESSION_EVENT_TYPES = new Set(${JSON.stringify([...names, 'developer/message'])})\n`,
|
||||
})
|
||||
expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow('must be a V3 writer')
|
||||
})
|
||||
|
||||
it('rejects a V4-only name accidentally copied into the migration-owned V3 set', () => {
|
||||
const { root, sourceRef } = fixture()
|
||||
expect(() => verifyV3EventVocabulary(root, sourceRef, new Set([...names, 'developer/message'])))
|
||||
.toThrow('Extra migration names: developer/message')
|
||||
})
|
||||
|
||||
it.each(['HEAD', 'origin/master', 'dsh-v0.1.5-alpha.1', 'a'.repeat(10), '', '--help'])('rejects a mutable or incomplete source pin %j', (ref) => {
|
||||
expect(() => verifyV3EventVocabulary(tmpdir(), ref)).toThrow('full immutable commit id')
|
||||
})
|
||||
|
||||
it('reports a source object missing from the local repository without fetching', () => {
|
||||
const { root } = fixture()
|
||||
expect(() => verifyV3EventVocabulary(root, '0'.repeat(40))).toThrow('not available locally')
|
||||
})
|
||||
|
||||
it('rejects tree objects and reports missing generated files at the source pin', () => {
|
||||
const { root, sourceRef } = fixture()
|
||||
expect(() => verifyV3EventVocabulary(root, git(root, ['rev-parse', `${sourceRef}^{tree}`])))
|
||||
.toThrow('must name a commit object')
|
||||
git(root, ['rm', eventsPath])
|
||||
git(root, ['commit', '--quiet', '-m', 'Source without generated event inventory'])
|
||||
expect(() => verifyV3EventVocabulary(root, git(root, ['rev-parse', 'HEAD']))).toThrow('not available locally')
|
||||
})
|
||||
|
||||
it('accepts harmless parentheses and reordered generated event names', () => {
|
||||
const { root, sourceRef } = fixture({
|
||||
writer: 'export const SESSION_FORMAT_VERSION = (3 as const)\n',
|
||||
events: `export const KNOWN_SESSION_EVENT_TYPES = (new Set(${JSON.stringify([...names].reverse())}))\n`,
|
||||
})
|
||||
expect(verifyV3EventVocabulary(root, sourceRef).eventCount).toBe(names.length)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'const SESSION_FORMAT_VERSION = 3\n',
|
||||
'export const SESSION_FORMAT_VERSION: number\n',
|
||||
'export const SESSION_FORMAT_VERSION = 3, SESSION_FORMAT_VERSION = 3\n',
|
||||
'export const SESSION_FORMAT_VERSION = "3"\n',
|
||||
])('rejects an ambiguous or nonliteral writer declaration %j', (writer) => {
|
||||
const { root, sourceRef } = fixture({ writer })
|
||||
expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'export const OTHER = new Set([])\n',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES = []\n',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES = new Map([])\n',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES = new Set()\n',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES = new Set(undefined)\n',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES = new Set([...otherNames])\n',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES = new Set([computeName()])\n',
|
||||
'export const KNOWN_SESSION_EVENT_TYPES = new Set(["duplicate", "duplicate"])\n',
|
||||
])('rejects unsupported generated declarations without evaluating them: %j', (events) => {
|
||||
const { root, sourceRef } = fixture({ events })
|
||||
expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
/** Compare the V3 migration vocabulary with an explicitly pinned local V3 writer. */
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import ts from 'typescript'
|
||||
import { RELEASED_V3_EVENT_TYPES } from '@deepseek-ai/dsh-session-format-v3-to-v4'
|
||||
import { isEntry } from './release/process.ts'
|
||||
|
||||
const WRITER_PATH = 'packages/core/session/src/types.ts'
|
||||
const EVENTS_PATH = 'packages/core/session/src/known-event-types.ts'
|
||||
const COMMIT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u
|
||||
|
||||
function sourceAt(root: string, sourceRef: string, path: string): string {
|
||||
try {
|
||||
return execFileSync('git', ['show', `${sourceRef}:${path}`], {
|
||||
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GIT_NO_LAZY_FETCH: '1' },
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`V3 source ${sourceRef}:${path} is not available locally; prepare the verified source pin before running this check`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
function initializer(source: string, path: string, name: string): ts.Expression {
|
||||
const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
||||
const declarations = file.statements.flatMap(statement =>
|
||||
ts.isVariableStatement(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
|
||||
? statement.declarationList.declarations.filter(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
|
||||
: [])
|
||||
let value = declarations.length === 1 ? declarations[0]?.initializer : undefined
|
||||
if (value === undefined) throw new Error(`${path}: expected one initialized export ${name}`)
|
||||
while (ts.isAsExpression(value) || ts.isParenthesizedExpression(value)) value = value.expression
|
||||
return value
|
||||
}
|
||||
|
||||
function sourceEventTypes(source: string): ReadonlySet<string> {
|
||||
const value = initializer(source, EVENTS_PATH, 'KNOWN_SESSION_EVENT_TYPES')
|
||||
const entries = ts.isNewExpression(value) && value.arguments?.length === 1 ? value.arguments[0] : undefined
|
||||
if (!ts.isNewExpression(value) || !ts.isIdentifier(value.expression) || value.expression.text !== 'Set'
|
||||
|| entries === undefined || !ts.isArrayLiteralExpression(entries)) {
|
||||
throw new Error(`${EVENTS_PATH}: KNOWN_SESSION_EVENT_TYPES must be a literal Set`)
|
||||
}
|
||||
const names = entries.elements.map((element) => {
|
||||
if (!ts.isStringLiteral(element)) throw new Error(`${EVENTS_PATH}: event names must be string literals`)
|
||||
return element.text
|
||||
})
|
||||
const result = new Set(names)
|
||||
if (result.size !== names.length) throw new Error(`${EVENTS_PATH}: duplicate event name`)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Result tied to the exact historical commit that was inspected. */
|
||||
export interface V3EventVocabularyCheck {
|
||||
readonly sourceRef: string
|
||||
readonly eventCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the complete source vocabulary without consulting remote or current-writer event lists.
|
||||
* @param root - Git checkout containing the already available source commit.
|
||||
* @param sourceRef - full immutable commit id for the V3 writer used by the release integration.
|
||||
* @param expected - migration-owned V3 event names to check.
|
||||
* @returns the checked source pin and event count; does not establish remote freshness.
|
||||
* @throws when the pin is missing, does not write V3, or its event names differ.
|
||||
*/
|
||||
export function verifyV3EventVocabulary(
|
||||
root: string, sourceRef: string, expected: ReadonlySet<string> = RELEASED_V3_EVENT_TYPES,
|
||||
): V3EventVocabularyCheck {
|
||||
if (!COMMIT_ID.test(sourceRef)) throw new Error('--source-ref requires a full immutable commit id, not a branch, tag, or abbreviated id')
|
||||
let objectType: string
|
||||
try {
|
||||
objectType = execFileSync('git', ['cat-file', '-t', sourceRef], {
|
||||
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, GIT_NO_LAZY_FETCH: '1' },
|
||||
}).trim()
|
||||
} catch (error) {
|
||||
throw new Error(`V3 source commit ${sourceRef} is not available locally; prepare the verified source pin before running this check`, { cause: error })
|
||||
}
|
||||
if (objectType !== 'commit') throw new Error('--source-ref must name a commit object')
|
||||
const writer = initializer(sourceAt(root, sourceRef, WRITER_PATH), WRITER_PATH, 'SESSION_FORMAT_VERSION')
|
||||
if (!ts.isNumericLiteral(writer) || Number(writer.text) !== 3) {
|
||||
throw new Error(`source ${sourceRef} must be a V3 writer; keep V4-only events out of RELEASED_V3_EVENT_TYPES`)
|
||||
}
|
||||
const actual = sourceEventTypes(sourceAt(root, sourceRef, EVENTS_PATH))
|
||||
const missing = [...actual].filter(name => !expected.has(name)).sort()
|
||||
const extra = [...expected].filter(name => !actual.has(name)).sort()
|
||||
if (missing.length > 0 || extra.length > 0) {
|
||||
throw new Error([
|
||||
`V3 event vocabulary differs from source ${sourceRef}; review source payload conversion before updating the migration-owned set.`,
|
||||
`Missing V3 names: ${missing.join(', ') || '(none)'}`,
|
||||
`Extra migration names: ${extra.join(', ') || '(none)'}`,
|
||||
].join('\n'))
|
||||
}
|
||||
return { sourceRef, eventCount: actual.size }
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const { values } = parseArgs({ options: { 'source-ref': { type: 'string' } }, allowPositionals: false })
|
||||
if (values['source-ref'] === undefined) throw new Error('usage: verify-v3-event-vocabulary --source-ref <full-V3-writer-commit-id>')
|
||||
const result = verifyV3EventVocabulary(resolve(process.cwd()), values['source-ref'])
|
||||
console.log(`verify-v3-event-vocabulary: ${result.eventCount} names match V3 writer ${result.sourceRef}; remote freshness was not checked.`)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
Reference in new issue
Block a user