diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 47a4006f87..4293d83c58 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -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 + 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 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(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(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. */ diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 3c1800fdef..cd0b17eb45 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -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',