fix(session-format): admit audited V2 corpus payloads

V2 assistant attempts have no V0 semantic dispatch case: validate their exact V2 members and positive step coordinates locally while preserving the embedded stream. Explicitly classify agent-message relay attribution and file attachment metadata; both preserve foreign identities and non-Session byte counts, and reject unknown members.

Native V3 request headers retain current extension fields while rejecting retired header.system, independently of the closed V2 migration inventory. Add direct and multihop attempt regressions, exact source/file negative cases, and native header roundtrip coverage. All 41 focused tests and host tsc pass; scoped lint and bilingual pairing pass.
This commit is contained in:
Tianyi Cui committed 2026-09-06 20:45:00 +08:00
1 parent 9fa3c8e7f6
commit 6f5fc05ea3
5 files changed
+74 -9

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-v2-to-v3/README.md
README.md: 231987d44f79253464f809eb68a8673011e01acd
README.zh.md: 81459f560013031529e2df48d711415239961048
README.md: 63a422a9bab02edae78a8c993b62862ca8fb22e7
README.zh.md: cf497f9efb17ada37bae8abaa2c9dd350b537cdb
@@ -84,7 +84,7 @@ The model-message prefix remains unchanged.
- **No file publication** — persistence owns immutable successor publication; this package never overwrites released generations.
- **Chronology-preserving inputs** — a surface event before the first step, or a changed prompt outside an open step, is refused with `SessionFormatUnsupportedMigrationError`; moving events or inventing out-of-step system messages would violate reconstruction.
- **Audited migration vocabulary** — V2 events and the installed message-feedback additions are classified explicitly. Unknown events, even ignorable ones, and unknown message-source or content kinds are refused during migration because sequence dependencies cannot be inferred. Native equal-version reads retain ordinary ignorable-event admission.
- **Audited migration vocabulary** — V2 events, including log-only Assistant attempts, and the installed message-feedback additions are classified explicitly. Agent relay attribution and file attachment metadata are preserved without interpreting their identifiers or byte counts as sequence references. Unknown events, even ignorable ones, and unknown message-source or content kinds are refused during migration because sequence dependencies cannot be inferred. Native equal-version reads retain ordinary ignorable-event admission and request-header extensions; only the retired `header.system` field is prohibited.
<a id="dev-note"></a>
### Dev Note
@@ -84,7 +84,7 @@ Session 元数据仅将版本改为 3。阶段在首个 `step/start` 后立即
- **不发布文件** — 持久化负责不可变后继代的发布;本包绝不覆盖已发布代。
- **保持时序的输入** — 首个步骤前出现表面事件,或在开放步骤之外更改提示时,以 `SessionFormatUnsupportedMigrationError` 拒绝;移动事件或虚构步骤外的系统消息会破坏重建。
- **经过审计的迁移词汇** — 显式分类 V2 事件与已安装的消息反馈扩展。迁移拒绝未知事件(即使标为可忽略)及未知消息来源或内容种类,因为无法推断其序列依赖。原生同版本读取保留普通可忽略事件的准入规则。
- **经过审计的迁移词汇** — 显式分类 V2 事件(包括仅日志 Assistant 尝试)与已安装的消息反馈扩展。Agent 中继归属与文件附件元数据保持不变,其标识符与字节计数不被解释为序列引用。迁移拒绝未知事件(即使标为可忽略)及未知消息来源或内容种类,因为无法推断其序列依赖。原生同版本读取保留普通可忽略事件的准入规则及请求头扩展,仅禁止已退役的 `header.system` 字段。
<a id="dev-note"></a>
### 开发备注
@@ -7,7 +7,7 @@ import { RELEASED_V2_EVENT_DISPOSITIONS } from '@deepseek-ai/dsh-session-format-
/** Audited surface event names; all other admitted events are log-only. */
export const SURFACE_TYPES: ReadonlySet<string> = new Set(['system/message', 'user/message', 'assistant/message', 'tool/result'])
const SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook'])
const SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook', 'agent-message'])
/**
* Require a JSON object at the durable input boundary.
@@ -64,12 +64,14 @@ export function assertEvent(event: SessionFormatEvent, version: 2 | 3): void {
assertFeedback(event.type, data)
return
}
if (version === 3 && event.type === 'request/header') {
assertV3StructuralRow(event)
return
}
if (disposition === undefined) throw new SessionFormatError('missing event disposition')
keys(data, disposition.required, disposition.optional, event.type + ' data')
if (version === 3 && event.type === 'request/header' && Object.hasOwn(record(data['header'], 'request header'), 'system')) {
throw new SessionFormatError('format v3 request/header rejects retired header.system')
}
assertReleasedPayloadSemantics(event, version)
// Assistant attempts are introduced by V2; the V0 helper has no case for them.
if (event.type !== 'assistant/attempt') assertReleasedPayloadSemantics(event, version)
if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
if (!Array.isArray(data['stream'])) throw new SessionFormatError('assistant stream must be an array')
for (const coordinate of ['turn', 'step']) {
@@ -115,6 +117,12 @@ function assertSource(message: SessionFormatJsonObject): void {
if (typeof source['kind'] !== 'string' || !SOURCE_KINDS.has(source['kind'])) {
throw new SessionFormatUnsupportedMigrationError('cannot safely transform unclassified message source')
}
if (source['kind'] === 'agent-message') {
keys(source, ['kind', 'form', 'senderSessionId'], [], 'agent-message source')
if (source['form'] !== 'relay' || typeof source['senderSessionId'] !== 'string' || source['senderSessionId'].length === 0) {
throw new SessionFormatError('agent-message source requires relay form and senderSessionId')
}
}
assertContentKinds(message['content'])
}
@@ -128,6 +136,15 @@ function assertContentKinds(content: SessionFormatJsonValue | undefined): void {
case 'image':
case 'tool-call':
break
case 'file': {
keys(block, ['type', 'attachment'], [], 'file content')
const attachment = record(block['attachment'], 'file attachment')
keys(attachment, ['attachmentId', 'name', 'bytes'], [], 'file attachment')
if (typeof attachment['attachmentId'] !== 'string' || attachment['attachmentId'].length === 0
|| typeof attachment['name'] !== 'string') throw new SessionFormatError('file attachment requires attachmentId and name')
sessionFormatCount(attachment['bytes'], 'file attachment bytes')
break
}
case 'tool-result':
assertContentKinds(block['content'])
break
@@ -211,6 +211,42 @@ describe('streaming V2 system prompt migration', () => {
expect(() => migrate([...opening(), event('user/message', { ...user(), content: [{ type: 'future-block', seq: 1 }] }, 'append')])).toThrow(/unclassified message content/)
})
it('migrates audited agent relay sources and file blocks verbatim, refusing unclassified members', () => {
const source = { kind: 'agent-message', form: 'relay', senderSessionId: 'other-session' }
const attachment = { attachmentId: 'sha256:content', name: 'poem.txt', bytes: 16 }
const message = { ...user('relay'), source, content: [{ type: 'file', attachment }] }
const inbox = event('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message] })
const output = migrate([inbox, ...opening(), event('user/message', message, 'append')])
expect(output.events[0]?.data).toEqual(inbox.data)
expect(output.events.at(-1)?.data).toEqual(message)
const badMessages = [
{ ...message, source: { ...source, sourceEventSeq: 0 } },
{ ...message, source: { ...source, form: 'notice' } },
{ ...message, content: [{ type: 'file', attachment: { ...attachment, seq: 0 } }] },
{ ...message, content: [{ type: 'file', attachment: { ...attachment, bytes: -1 } }] },
]
for (const bad of badMessages) expect(() => migrate([...opening(), event('user/message', bad, 'append')])).toThrow()
})
it.each([0, 1, 2])('restores log-only failed attempts from V%s without invoking the V0 event inventory', (version) => {
const stream = [{ type: 'finish', reason: { kind: 'error', error: { name: 'ProviderError', message: 'failed' } } }]
const attempts = version === 2
? [event('assistant/attempt', { turn: 1, step: 1, stream })]
: [event('assistant/chunk', { turn: 1, step: 1, chunk: stream[0]! })]
const source = dense([...opening(), ...attempts])
const physical = version === 2 ? { type: 'session', ...header } : { type: 'session', version, id: header.id, createdAt: 1, delegationDepth: 0 }
const restore = catalog.createRestore(physical, { recovery: 'strict', validation: 'current' })
for (const row of source) restore.decodeRow(row)
expect(restore.finish().events.at(-1)?.type).toBe('assistant/attempt')
})
it('rejects malformed V2 attempts and unaudited attempt fields', () => {
const invalid = [{ turn: 0, step: 1, stream: [] }, { turn: 1, step: 1, stream: null }, { turn: 1, step: 1, stream: [], seqRef: 0 }]
for (const data of invalid) {
expect(() => migrate([...opening(), event('assistant/attempt', data)])).toThrow()
}
})
it('preserves message feedback identities and rejects unaudited feedback fields', () => {
const feedback = event('feedback/message-put', { sessionId: 'other', item: { messageId: 'unchanged', rating: 'positive', version: 'opaque', createdAt: 1, updatedAt: 2 } })
expect(migrate([...opening(), feedback]).events.at(-1)?.data).toEqual(feedback.data)
@@ -294,6 +330,18 @@ describe('native V3 codec and restorer', () => {
expect(() => migrate([...opening(), event('user/message', { ...user(), installedExtension: true }, 'append')])).toThrow(/unexpected/)
})
it('round-trips native header extensions while refusing them as unclassified V2 input', () => {
const data = { ...request(), header: { ...request().header, messagePrefix: ['current extension'] } }
const target = migrate(opening())
const extended = { ...event('request/header', data), seq: target.events.length }
const artifact = { ...target, events: [...target.events, extended] }
const restore = catalog.createRestore(releasedV3SessionFormatCodec.encodeHeader(target.header, 0), { recovery: 'strict', validation: 'current' })
for (const row of artifact.events) restore.decodeRow(releasedV3SessionFormatCodec.encodeEvent(row))
expect(restore.finish()).toEqual(artifact)
expect(() => migrate([...opening(), event('request/header', data)])).toThrow(/unexpected/)
expect(() => releasedV3SessionFormatCodec.encodeEvent({ ...extended, data: { ...data, header: { ...data.header, system: 'retired' } } })).toThrow(/header.system/)
})
it('retains equal-generation ignorable events but rejects unknown required events', () => {
const artifact = { header: { ...header, version: 3 }, inheritedEventCount: 0, events: [{ ...event('external/event', null), ignorable: true }] }
expect(restoreReleasedV3Artifact(artifact, new Set())).toBe(artifact)