fix(session): preserve historical stream-start extension field names

V3 and V4 declare the same block-start fields: type, index and blockType.
The migration's identifier policy can qualify an extension blockType, but
prefixing every additional field has no current V4 field collision to solve.
That rewrite changed captured keys to anticipate hypothetical future fields.

Retain every non-blockType field and value exactly, including already
prefixed names and own JSON keys. Keep the approved blockType identifier
prefix and the separate ToolSchema field policy unchanged. Update the local
conversion tests to verify both known and extension starts retain metadata
and to use the actual raw-record time field. Later field-specific conversion
belongs to a concrete source/target change instead of this edge.
This commit is contained in:
Tianyi Cui committed 2026-09-19 15:43:09 +08:00
1 parent 2833ff566e
commit 28b209867f
5 files changed
+10 -12

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-format-v3-to-v4/README.md
README.md: 352246595a3da2d5902c51ebf946595c30b0247e
README.zh.md: e2eebbd642122d4e0d71d379f8f342dd8d8b37f4
README.md: 0cbdc50675085733fc5a8028e5456e29ec65f695
README.zh.md: 29764dd973a61b6ee43e5c98a3908df29b3b803c
@@ -100,7 +100,7 @@ A malformed canonical wrapper raises a format error; an unmapped wrapper extensi
<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.
Unknown V3 content tags become `plugin:<original-type>`; 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.
<a id="message-sources"></a>
### Message-source conversion
@@ -100,7 +100,7 @@ Wrapper 只允许 `type`、`toolCallId`、`content` 和 `isError`。未知 wrapp
<a id="extension-data"></a>
### 扩展数据
V3 未知内容标签变为 `plugin:<original-type>`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,并保留 `type` 和 `index`,其他自有字段名称添加 `plugin:` 前缀。请求工具定义保留 `name`、`description` 和 `parameters`,其他自有字段名称同样添加 `plugin:`;历史 `deferLoading` 因此变为 `plugin:deferLoading`,不会启用 V4 能力。已有前缀会再次添加,使不同旧名称保持不同。不遍历参数、实参、回放状态和插件内容字段。这些名称保留数据身份,不会加载或执行插件。
V3 未知内容标签变为 `plugin:<original-type>`,其他字段原样保留且不作解释。流起始块的 `blockType` 使用相同标签,其他字段的原始键和值均保留。请求工具定义保留 `name`、`description` 和 `parameters`,其他自有字段名称同样添加 `plugin:`;历史 `deferLoading` 因此变为 `plugin:deferLoading`,不会启用 V4 能力。已有前缀会再次添加,使不同旧名称保持不同。不遍历参数、实参、回放状态和插件内容字段。这些名称保留数据身份,不会加载或执行插件。
<a id="message-sources"></a>
### 消息来源转换
@@ -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 }
}
/**
@@ -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)
})