refactor(session): separate validation from snapshots

This commit is contained in:
imccyu
2026-08-04 23:29:59 +08:00
parent 69b1ea8d8c
commit c02871b910
2 changed files with 88 additions and 28 deletions

View File

@@ -103,17 +103,16 @@ declare module 'cordis' {
}
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
/** Validate and freeze one detached creation header in place. */
function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('session header is not a plain JSON record')
}
const record = snapshot as Record<string, unknown>
const prototype = Reflect.getPrototypeOf(input)
if (prototype !== Object.prototype && prototype !== null) {
throw new Error('session header is not a plain JSON record')
}
const record = input as Record<string, unknown>
if (record.version !== SESSION_FORMAT_VERSION) {
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
}
@@ -148,31 +147,52 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
return deepFreeze(record as unknown as SessionHeader)
}
/** Detach, validate, and freeze the creation metadata published by a session. */
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
const input: unknown = source === undefined
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
: source
const snapshot = snapshotJsonValue(input)
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
return validateSessionHeader(id, snapshot)
}
/**
* Validate an exclusively owned event and deeply freeze its identified message
* without copying the event. The caller transfers an object graph that no
* producer retains and that shares no mutable children with another event.
* Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
* @param event - exclusively owned event imported across a trusted boundary.
* @returns the same event object with a validated, deeply frozen message.
*/
export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
assertMessageEventShape(
event,
`session event at seq ${event.seq}`,
)
switch (event.type) {
case 'user/message':
deepFreeze(event.data)
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(event.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return event
}
/**
* Detach one event while preserving deep immutability for its identified message.
* @param event - event imported across a query or persistence boundary.
* @returns a detached event snapshot with a validated, deeply frozen message.
*/
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
const snapshot = structuredClone(event)
assertMessageEventShape(
snapshot,
`session event at seq ${snapshot.seq}`,
)
switch (snapshot.type) {
case 'user/message':
deepFreeze(snapshot.data)
break
case 'assistant/message':
case 'tool/result':
case 'steering/message':
deepFreeze(snapshot.data.message)
break
default:
// SessionEventMap is merge-extensible; plugin-owned events carry no core message.
break
}
return snapshot
return adoptSessionEvent(structuredClone(event))
}
/** Validate the fixed event envelope after one-pass JSON materialization. */

View File

@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
adoptSessionEvent,
findLastMessageTurnEnd,
SESSION_FORMAT_VERSION,
Session,
@@ -389,6 +390,45 @@ describe('Session', () => {
.toEqual([{ type: 'plugin-block', value: 1 }])
})
it('adopts exclusively owned messages in place and keeps snapshots detached', () => {
const owned = {
type: 'user/message',
seq: 0,
time: 1,
surfaceOp: 'append',
data: {
id: 'owned-message',
role: 'user',
content: [{ type: 'text', text: 'owned' }],
source: { kind: 'user' },
},
} as SessionEvent<'user/message'>
expect(adoptSessionEvent(owned)).toBe(owned)
expect(Object.isFrozen(owned.data)).toBe(true)
expect(Object.isFrozen(owned.data.content)).toBe(true)
const source = structuredClone(owned)
const snapshot = snapshotSessionEvent(source)
expect(snapshot).not.toBe(source)
expect(snapshot.data).not.toBe(source.data)
expect(snapshot.data.content).not.toBe(source.data.content)
})
it('validates message shape before adopting ownership', () => {
const malformed = {
type: 'user/message',
seq: 0,
time: 1,
data: {
id: 'wrong-role',
role: 'assistant',
content: [],
source: { kind: 'user' },
},
} as unknown as SessionEvent
expect(() => adoptSessionEvent(malformed)).toThrow('message must have role "user"')
})
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
const valid = {
type: 'request/header',