diff --git a/packages/session/session-format-v3-to-v4/README.i18n.yaml b/packages/session/session-format-v3-to-v4/README.i18n.yaml
index 5f7a913ffa..7259494569 100644
--- a/packages/session/session-format-v3-to-v4/README.i18n.yaml
+++ b/packages/session/session-format-v3-to-v4/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md
-README.md: 352246595a3da2d5902c51ebf946595c30b0247e
-README.zh.md: e2eebbd642122d4e0d71d379f8f342dd8d8b37f4
+README.md: 0cbdc50675085733fc5a8028e5456e29ec65f695
+README.zh.md: 29764dd973a61b6ee43e5c98a3908df29b3b803c
diff --git a/packages/session/session-format-v3-to-v4/README.md b/packages/session/session-format-v3-to-v4/README.md
index 352246595a..0cbdc50675 100644
--- a/packages/session/session-format-v3-to-v4/README.md
+++ b/packages/session/session-format-v3-to-v4/README.md
@@ -100,7 +100,7 @@ A malformed canonical wrapper raises a format error; an unmapped wrapper extensi
### Extension data
-Unknown V3 content tags become `plugin:`; 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.
+Unknown V3 content tags become `plugin:`; all other fields remain unchanged and opaque. Stream starts use the same tag in `blockType`; every other field retains its original key and value. 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.
### Message-source conversion
diff --git a/packages/session/session-format-v3-to-v4/README.zh.md b/packages/session/session-format-v3-to-v4/README.zh.md
index e2eebbd642..29764dd973 100644
--- a/packages/session/session-format-v3-to-v4/README.zh.md
+++ b/packages/session/session-format-v3-to-v4/README.zh.md
@@ -100,7 +100,7 @@ Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapp
### 扩展数据
-V3 未知内容标签变为 `plugin:`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,并保留 `type` 和 `index`,其他自有字段名称添加 `plugin:` 前缀。请求工具定义保留 `name`、`description` 和 `parameters`,其他自有字段名称同样添加 `plugin:`;历史 `deferLoading` 因此变为 `plugin:deferLoading`,不会启用 V4 能力。已有前缀会再次添加,使不同旧名称保持不同。不遍历参数、实参、回放状态和插件内容字段。这些名称保留数据身份,不会加载或执行插件。
+V3 未知内容标签变为 `plugin:`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,其他字段的原始键和值均保留。请求工具定义保留 `name`、`description` 和 `parameters`,其他自有字段名称同样添加 `plugin:`;历史 `deferLoading` 因此变为 `plugin:deferLoading`,不会启用 V4 能力。已有前缀会再次添加,使不同旧名称保持不同。不遍历参数、实参、回放状态和插件内容字段。这些名称保留数据身份,不会加载或执行插件。
### 消息来源转换
diff --git a/packages/session/session-format-v3-to-v4/src/content.ts b/packages/session/session-format-v3-to-v4/src/content.ts
index 67062e9c17..0dd45b228f 100644
--- a/packages/session/session-format-v3-to-v4/src/content.ts
+++ b/packages/session/session-format-v3-to-v4/src/content.ts
@@ -5,7 +5,6 @@ import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValu
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. */
@@ -51,8 +50,7 @@ function migrateChunk(value: SessionFormatJsonValue | undefined, subject: string
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 }
+ return blockType === original ? value : { ...value, blockType }
}
/**
diff --git a/packages/session/session-format-v3-to-v4/tests/content.spec.ts b/packages/session/session-format-v3-to-v4/tests/content.spec.ts
index 962de79b40..12c2f30804 100644
--- a/packages/session/session-format-v3-to-v4/tests/content.spec.ts
+++ b/packages/session/session-format-v3-to-v4/tests/content.spec.ts
@@ -71,7 +71,7 @@ describe('V3 content conversion', () => {
})
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 chunk = (value: SessionFormatJsonValue): SessionFormatJsonObject => ({ type: 'chunk', chunk: value, time: 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 }),
@@ -85,11 +85,11 @@ describe('V3 content conversion', () => {
]
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-start', index: 0, blockType: 'plugin:future', extensionName: 'old', metadata: { old: true }, 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[4], stream[5],
...stream.slice(6),
] })
const unchanged = event(type, { message: { ...user, content: [] }, stream: stream.slice(6) })
@@ -114,7 +114,7 @@ describe('V3 content conversion', () => {
expect(tool['deferLoading']).toBe(deferLoading)
})
- it('keeps original prefixed keys distinct from unprefixed tool and stream extras', () => {
+ it('prefixes tool extension keys while preserving stream-start extra keys', () => {
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,
@@ -126,7 +126,7 @@ describe('V3 content conversion', () => {
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(stream.data).toEqual({ stream: [{ type: 'chunk', chunk: { ...start, blockType: 'plugin:plugin:text' } }] })
expect(extra['deferLoading']).toBe(true)
expect(Object.hasOwn(extra, '__proto__')).toBe(true)
})