refactor(session): separate validation from snapshots
This commit is contained in:
@@ -103,17 +103,16 @@ declare module 'cordis' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
/** Validate and freeze one detached creation header in place. */
|
||||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
|
||||||
const input: unknown = source === undefined
|
if (input === null || typeof input !== 'object' || Array.isArray(input)) {
|
||||||
? { 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)) {
|
|
||||||
throw new Error('session header is not a plain JSON record')
|
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) {
|
if (record.version !== SESSION_FORMAT_VERSION) {
|
||||||
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.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)
|
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.
|
* Detach one event while preserving deep immutability for its identified message.
|
||||||
* @param event - event imported across a query or persistence boundary.
|
* @param event - event imported across a query or persistence boundary.
|
||||||
* @returns a detached event snapshot with a validated, deeply frozen message.
|
* @returns a detached event snapshot with a validated, deeply frozen message.
|
||||||
*/
|
*/
|
||||||
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
|
export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
|
||||||
const snapshot = structuredClone(event)
|
return adoptSessionEvent(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||||
import SessionStore, {
|
import SessionStore, {
|
||||||
|
adoptSessionEvent,
|
||||||
findLastMessageTurnEnd,
|
findLastMessageTurnEnd,
|
||||||
SESSION_FORMAT_VERSION,
|
SESSION_FORMAT_VERSION,
|
||||||
Session,
|
Session,
|
||||||
@@ -389,6 +390,45 @@ describe('Session', () => {
|
|||||||
.toEqual([{ type: 'plugin-block', value: 1 }])
|
.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', () => {
|
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
|
||||||
const valid = {
|
const valid = {
|
||||||
type: 'request/header',
|
type: 'request/header',
|
||||||
|
|||||||
Reference in New Issue
Block a user