From 0279cf09d678ec0298715f61329a2e9b968f8e3f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 18 Jun 2026 13:26:50 +0800 Subject: [PATCH] feat(session): enforce SurfaceEvent type --- packages/invariants/src/index.ts | 35 ++++++++++++---- packages/invariants/tests/invariants.spec.ts | 26 +++++++++++- .../session-persistence-sqlite/src/index.ts | 7 ++-- .../session-persistence-sqlite/src/schema.ts | 16 +++---- .../tests/sqlite.spec.ts | 28 ++++++------- packages/session/src/index.ts | 36 ++++++++++++---- packages/session/src/surface.ts | 42 ++++++++++++++++--- packages/session/src/types.ts | 40 ++++++++++++++++-- packages/session/tests/repair.spec.ts | 6 +-- packages/session/tests/surface.spec.ts | 16 +++---- 10 files changed, 189 insertions(+), 63 deletions(-) diff --git a/packages/invariants/src/index.ts b/packages/invariants/src/index.ts index 7655be46d6..97cb0af680 100644 --- a/packages/invariants/src/index.ts +++ b/packages/invariants/src/index.ts @@ -22,7 +22,7 @@ import type { Context } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' export const name = 'invariants' export const inject = ['sessions'] @@ -108,15 +108,32 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { trace.lastSeq = event.seq // --- Surface invariants --- - if (event.sourceEventSeqs !== undefined) { - if (event.sourceEventSeqs.length === 0) { + // Surface metadata (sourceEventSeqs, surfaceOp) is only valid on + // surface-eligible event types. The compiler enforces this at append() + // call sites; this runtime check catches casts and persisted data. + const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message']) + // Cast to surface-eligible event type so we can access surfaceOp and + // sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent). + // SurfaceEvent's mandatory surfaceOp is too strict here — we need to + // CHECK whether surface metadata is present, not assume it. + const se = event as SessionEvent + if (!SURFACE_TYPES.has(event.type)) { + if (se.sourceEventSeqs !== undefined) { + throw new InvariantError(`${event.type} cannot carry sourceEventSeqs (non-surface event)`) + } + if (se.surfaceOp !== undefined) { + throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`) + } + } + if (se.sourceEventSeqs !== undefined) { + if (se.sourceEventSeqs.length === 0) { throw new InvariantError('sourceEventSeqs must not be empty when present') } - const unique = new Set(event.sourceEventSeqs) - if (unique.size !== event.sourceEventSeqs.length) { + const unique = new Set(se.sourceEventSeqs) + if (unique.size !== se.sourceEventSeqs.length) { throw new InvariantError('sourceEventSeqs must not contain duplicates') } - for (const ref of event.sourceEventSeqs) { + for (const ref of se.sourceEventSeqs) { if (ref >= event.seq) { throw new InvariantError(`sourceEventSeqs must reference earlier events: ${ref} >= current seq ${event.seq}`) } @@ -125,9 +142,9 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { } } } - if (event.surfaceOp !== undefined && typeof event.surfaceOp !== 'string') { - if (event.surfaceOp.start > event.surfaceOp.end) { - throw new InvariantError(`surface replace: start ${event.surfaceOp.start} must be <= end ${event.surfaceOp.end}`) + if (se.surfaceOp !== undefined && typeof se.surfaceOp !== 'string') { + if (se.surfaceOp.start > se.surfaceOp.end) { + throw new InvariantError(`surface replace: start ${se.surfaceOp.start} must be <= end ${se.surfaceOp.end}`) } } diff --git a/packages/invariants/tests/invariants.spec.ts b/packages/invariants/tests/invariants.spec.ts index 8e4c5edebe..28fd27163f 100644 --- a/packages/invariants/tests/invariants.spec.ts +++ b/packages/invariants/tests/invariants.spec.ts @@ -105,7 +105,11 @@ describe('session-log invariants', () => { expect(() => session.append('error', { turn: 1, step: 1, message: 'boom' })) .toThrow(/outside any open turn/) // A PLUGIN-added (merge-extensible) event type is caught by the default too. - expect(() => session.append('compaction/marker' as never, { foo: 'bar' } as never)) + // Cast through `any`: 'compaction/marker' is not in SessionEventType (it's + // merge-extensible), so the typed append() won't accept it. The test verifies + // the runtime default-branch turn-enclosure check. + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return + expect(() => (session.append as any)('compaction/marker', { foo: 'bar' })) .toThrow(/outside any open turn/) }) @@ -498,4 +502,24 @@ describe('surface invariants', () => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2] }) }).toThrow(/must be <= end/) }) + + it('rejects sourceEventSeqs on a non-surface event', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + // Type system prevents surface metadata on non-surface events; this test + // exercises the runtime guard against casts or persisted-data bypass. + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return + expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { sourceEventSeqs: [0] })) + .toThrow(/cannot carry sourceEventSeqs/) + }) + + it('rejects surfaceOp on a non-surface event', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create() + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return + expect(() => (session.append as any)('turn/end', { turn: 1, reason: { kind: 'completed' } }, { surfaceOp: 'append' })) + .toThrow(/cannot carry surfaceOp/) + }) }) diff --git a/packages/session-persistence-sqlite/src/index.ts b/packages/session-persistence-sqlite/src/index.ts index 3475f8b4dd..de3e96fda5 100644 --- a/packages/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence-sqlite/src/index.ts @@ -26,7 +26,7 @@ import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { isJsonValue, interruptedTurnClosers } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' import { openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, } from './schema.ts' @@ -39,9 +39,10 @@ export { SCHEMA_VERSION } from './schema.ts' * events, events written before surface support). */ function surfaceBindings(event: SessionEvent): [string | null, string | null] { + const se = event as SessionEvent return [ - event.sourceEventSeqs ? JSON.stringify(event.sourceEventSeqs) : null, - event.surfaceOp !== undefined ? JSON.stringify(event.surfaceOp) : null, + se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null, + se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null, ] } diff --git a/packages/session-persistence-sqlite/src/schema.ts b/packages/session-persistence-sqlite/src/schema.ts index 68ff4905ae..974fd77778 100644 --- a/packages/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence-sqlite/src/schema.ts @@ -126,19 +126,19 @@ export function rowToMeta(row: SessionRow): SessionMeta { /** Reconstruct a {@link SessionEvent} from an `events` row (parses `data`). */ export function rowToEvent(row: EventRow): SessionEvent { - const event = { + // Surface-metadata fields are conditional on the event type in the type + // system; spread them so each variant gets only the fields it declares. + const surfaceFields = { + ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {}, + ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {}, + } + return { type: row.type as SessionEvent['type'], seq: row.seq, time: row.time, data: JSON.parse(row.data) as SessionEvent['data'], + ...surfaceFields, } as SessionEvent - if (row.source_event_seqs !== null) { - event.sourceEventSeqs = JSON.parse(row.source_event_seqs) as number[] - } - if (row.surface_op !== null) { - event.surfaceOp = JSON.parse(row.surface_op) as SurfaceOp - } - return event } /** diff --git a/packages/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence-sqlite/tests/sqlite.spec.ts index 61b166f257..252e9c4660 100644 --- a/packages/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence-sqlite/tests/sqlite.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionMeta } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType, SessionMeta } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts' import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts' @@ -800,8 +800,8 @@ describe('surface field round-trip', () => { surface_op: JSON.stringify('append'), } const event = rowToEvent(row) - expect(event.sourceEventSeqs).toEqual([3, 5]) - expect(event.surfaceOp).toBe('append') + expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5]) + expect((event as SurfaceEvent).surfaceOp).toBe('append') }) it('rowToEvent handles replace surfaceOp object', () => { @@ -812,8 +812,8 @@ describe('surface field round-trip', () => { surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }), } const event = rowToEvent(row) - expect(event.sourceEventSeqs).toEqual([0, 1]) - expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 1 }) + expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) + expect((event as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 1 }) }) it('scanRows with surface columns reconstructs events with surface fields', () => { @@ -827,9 +827,9 @@ describe('surface field round-trip', () => { ] const { preserved } = scanRows(rows) expect(preserved).toHaveLength(2) - expect(preserved[0]!.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) - expect(preserved[0]!.sourceEventSeqs).toBeUndefined() - expect(preserved[1]!.surfaceOp).toBeUndefined() + expect((preserved[0]! as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) + expect((preserved[0]! as SurfaceEvent).sourceEventSeqs).toBeUndefined() + expect((preserved[1] as SessionEvent).surfaceOp).toBeUndefined() }) it('append and load round-trips surface fields through SQLite', async () => { @@ -845,11 +845,11 @@ describe('surface field round-trip', () => { const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface')) expect(loaded.events).toHaveLength(4) const um = loaded.events[1]! - expect(um.surfaceOp).toBe('append') - expect(um.sourceEventSeqs).toBeUndefined() + expect((um as SurfaceEvent).surfaceOp).toBe('append') + expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined() const am = loaded.events[2]! - expect(am.surfaceOp).toBe('append') - expect(am.sourceEventSeqs).toEqual([0]) + expect((am as SurfaceEvent).surfaceOp).toBe('append') + expect((am as SurfaceEvent).sourceEventSeqs).toEqual([0]) await fiber.dispose() }) @@ -863,8 +863,8 @@ describe('surface field round-trip', () => { session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) await ctx.parallel('session/flush', session) const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq')) - expect(loaded.events[1]!.surfaceOp).toBe('append') - expect(loaded.events[1]!.sourceEventSeqs).toBeUndefined() + expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append') + expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined() await fiber.dispose() }) }) diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index d0d4d44aed..e04bda5e7d 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -10,7 +10,7 @@ import { Context, Service } from 'cordis' import { isAbsolute } from 'node:path' import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionId } from './types.ts' -import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceAppendOpts } from './types.ts' +import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceAppendOpts, SurfaceEventType } from './types.ts' import { isJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' @@ -18,6 +18,7 @@ export * from './types.ts' export { isJsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceNode } from './surface.ts' +export { isSurfaceEvent } from './surface.ts' declare module 'cordis' { interface Context { @@ -138,7 +139,9 @@ export class Session { * @param data - The event payload; must be JSON-serializable. * @param opts - Optional surface metadata: `surfaceOp` controls how the * event enters the surface linked list; `sourceEventSeqs` records - * provenance (the seq numbers of events this one derives from). + * provenance (the seq numbers of events this one derives from). Only + * accepted for {@link SurfaceEventType} events — the compiler rejects + * surface opts for non-surface types like `turn/start` or `assistant/chunk`. * @throws if `data` is not losslessly JSON-serializable (BigInt, function, * symbol, undefined, non-finite number, circular ref, or an exotic object * like Map/Set/Date). The event log is the durable source of truth, so this @@ -147,7 +150,11 @@ export class Session { * throw surfaces at the buggy caller's append site, not asynchronously in a * backend flush. */ - append(type: T, data: SessionEventMap[T], opts?: SurfaceAppendOpts): SessionEvent { + append( + type: T, + data: SessionEventMap[T], + ...opts: T extends SurfaceEventType ? [opts?: SurfaceAppendOpts] : [] + ): SessionEvent { if (!isJsonValue(data)) { throw new Error(`session event "${type}" carries non-JSON-serializable data`) } @@ -164,18 +171,25 @@ export class Session { // Surface metadata is snapshot separately: sourceEventSeqs (number[] — // primitives, so array spread is a complete copy) and surfaceOp (a string // primitive, or cloned if it's a replace object). + const surfaceOpts: SurfaceAppendOpts | undefined = opts[0] + // Build the event shape with conditional surface fields via spreading. + // The result is cast through `unknown` because the conditional spreads + // produce an intersection type that the assignability checker can't + // narrow to a specific discriminated-union member when T is generic. + // This is a safe internal boundary: data was validated above, and + // surface metadata was snapshot from primitive/clone-safe values. const event = { type, seq: this.log.length, time: Date.now(), data: structuredClone(data), - ...opts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...opts.sourceEventSeqs] } : {}, - ...opts?.surfaceOp !== undefined ? { - surfaceOp: typeof opts.surfaceOp === 'string' ? opts.surfaceOp : structuredClone(opts.surfaceOp), + ...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {}, + ...surfaceOpts?.surfaceOp !== undefined ? { + surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp), } : {}, - } as SessionEvent - this.log.push(event) - this.onAppend?.(event) + } as unknown as SessionEvent + this.log.push(event as unknown as SessionEvent) + this.onAppend?.(event as unknown as SessionEvent) return event } @@ -207,6 +221,10 @@ export class Session { // index by construction. The non-null assertion expresses that invariant. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const msg = this._deriveOneMessage(this.log[node.seq]!) + // isSurfaceEvent guarantees only the five surface-eligible types + // enter the surface, and all five produce messages → msg is never + // null. Defensive guard retained for interface contract clarity. + /* v8 ignore next */ if (msg) messages.push(msg) } return messages diff --git a/packages/session/src/surface.ts b/packages/session/src/surface.ts index a09dbd001f..eaa8baeb4a 100644 --- a/packages/session/src/surface.ts +++ b/packages/session/src/surface.ts @@ -7,7 +7,33 @@ * @module @deepseek-ai/dsh-session/surface */ -import type { SessionEvent, SurfaceOp } from './types.ts' +import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts' + +/** + * The set of event type strings that are eligible for the surface linked list. + * Mirrors the {@link SurfaceEventType} union; kept as a runtime set so the + * type guard can check membership without a chain of string comparisons. + */ +const SURFACE_EVENT_TYPES = new Set([ + 'user/message', + 'assistant/message', + 'tool/result', + 'context/message', + 'steering/message', +]) + +/** + * Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the + * event's `type` is surface-eligible AND that `surfaceOp` is present. + * The narrowed type has mandatory {@link SurfaceOp}. + */ +export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent { + if (!SURFACE_EVENT_TYPES.has(event.type)) return false + // surfaceOp is optional on SessionEvent (even for surface-eligible types) + // but mandatory on SurfaceEvent — this check is the narrowing gate. + if ((event as SessionEvent).surfaceOp === undefined) return false + return true +} /** One node in the surface linked list. */ export interface SurfaceNode { @@ -57,11 +83,12 @@ export class SurfaceManager { get hasSurface(): boolean { if (this._nodes.length > 0) return true // Never processed anything — scan the whole log. - if (this._lastProcessedSeq === -1) return this.log.some(e => e.surfaceOp !== undefined) + if (this._lastProcessedSeq === -1) return this.log.some(e => isSurfaceEvent(e)) // Processed up to _lastProcessedSeq without finding surface nodes; check // only new events. for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) { - if (this.log[i]?.surfaceOp !== undefined) return true + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (isSurfaceEvent(this.log[i]!)) return true } return false } @@ -72,8 +99,13 @@ export class SurfaceManager { */ private _processDelta(): void { for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) { - const event = this.log[i] - if (event === undefined || event.surfaceOp === undefined) continue + // Index is bounded by i < this.log.length — never undefined. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const event = this.log[i]! + // isSurfaceEvent checks event.type first (is it a surface-eligible type?) + // then checks that surfaceOp is present. Only after both pass do we treat + // it as a SurfaceEvent with mandatory surfaceOp. + if (!isSurfaceEvent(event)) continue if (event.surfaceOp === 'append') { const tail = this._nodes.length > 0 ? this._nodes[this._nodes.length - 1] : undefined diff --git a/packages/session/src/types.ts b/packages/session/src/types.ts index d7e9b19259..be3ed61af2 100644 --- a/packages/session/src/types.ts +++ b/packages/session/src/types.ts @@ -175,8 +175,31 @@ export interface SessionEventMap { export type SessionEventType = keyof SessionEventMap /** - * How a session event entered the surface linked list. Absent for non-surface - * events (boundaries, chunks, usage, errors). + * The subset of {@link SessionEventType} values whose events produce LLM + * messages and are eligible to appear on the surface linked list. Only these + * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}. + */ +export type SurfaceEventType = + | 'user/message' + | 'assistant/message' + | 'tool/result' + | 'context/message' + | 'steering/message' + +/** + * A {@link SessionEvent} that is **on** the surface linked list — its + * `surfaceOp` is guaranteed present (mandatory), narrowed from a + * surface-eligible {@link SessionEvent} by checking both `type` and + * `surfaceOp` at runtime. + * + * Use the `isSurfaceEvent` type guard (in `surface.ts`) to narrow a + * `SessionEvent` to this type. + */ +export type SurfaceEvent = SessionEvent & { surfaceOp: SurfaceOp } + +/** + * How a session event entered the surface linked list. Only valid on + * {@link SurfaceEventType} events. * * - `'append'`: added to the tail — normal path for user/assistant/tool/context * messages. @@ -196,6 +219,9 @@ export type SurfaceOp = * `sourceEventSeqs` records the seq numbers of events that are provenance * sources of this one (e.g. the `assistant/chunk` seqs behind an * `assistant/message`, or the shadowed nodes behind a compaction replacement). + * + * Only accepted for {@link SurfaceEventType} events — non-surface event types + * (`turn/start`, `assistant/chunk`, `error`, …) cannot carry surface metadata. */ export interface SurfaceAppendOpts { surfaceOp?: SurfaceOp @@ -207,6 +233,13 @@ export interface SurfaceAppendOpts { * * A proper discriminated union over `type` (not independent `type`/`data` * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. */ export type SessionEvent = { [K in SessionEventType]: { @@ -216,6 +249,7 @@ export type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + } & (K extends SurfaceEventType ? { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, @@ -224,5 +258,5 @@ export type SessionEvent = { sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp - } + } : object) }[T] diff --git a/packages/session/tests/repair.spec.ts b/packages/session/tests/repair.spec.ts index cf6fb2b51c..0b7dee9f2b 100644 --- a/packages/session/tests/repair.spec.ts +++ b/packages/session/tests/repair.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' import { interruptedTurnClosers } from '../src/index.ts' -import type { SessionEvent } from '../src/index.ts' +import type { SessionEvent, SurfaceEvent } from '../src/index.ts' /** * Unit coverage for the crash-recovery closer synthesis. The persistence @@ -135,8 +135,8 @@ describe('interruptedTurnClosers', () => { const closers = interruptedTurnClosers(events) expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end']) const result = closers[0]! - expect(result.surfaceOp).toBe('append') - expect(result.sourceEventSeqs).toEqual([3]) + expect((result as SurfaceEvent).surfaceOp).toBe('append') + expect((result as SurfaceEvent).sourceEventSeqs).toEqual([3]) }) it('handles tool/call without a matching assistant/message entry gracefully', () => { diff --git a/packages/session/tests/surface.spec.ts b/packages/session/tests/surface.spec.ts index d8f503c5e5..51ddcbcd44 100644 --- a/packages/session/tests/surface.spec.ts +++ b/packages/session/tests/surface.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import { Session, SessionId } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' @@ -188,7 +188,7 @@ describe('SurfaceManager', () => { // Mutate caller's array after append. sources.push(30) sources[0] = 99 - const logged = s.events[0]! + const logged = s.events[0]! as SurfaceEvent expect(logged.sourceEventSeqs).toEqual([10, 20]) }) @@ -219,7 +219,7 @@ describe('SurfaceManager', () => { s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: op, sourceEventSeqs: [0] }) // Mutate caller's object after append. op.start = 99 - const logged = s.events[1]! + const logged = s.events[1]! as SurfaceEvent expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) }) }) @@ -288,8 +288,8 @@ describe('Session.append surface opts', () => { expect(event.sourceEventSeqs).toEqual([3, 5, 7]) expect(event.surfaceOp).toBe('append') // The logged event matches the returned event. - expect(s.events[0]!.sourceEventSeqs).toEqual([3, 5, 7]) - expect(s.events[0]!.surfaceOp).toBe('append') + expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7]) + expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append') }) it('deriveMessages skips surface nodes whose event type is not message-producing', () => { @@ -299,7 +299,7 @@ describe('Session.append surface opts', () => { const seed: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, - { type: 'usage', seq: 2, time: 3, data: { turn: 1, step: 1, usage: { inputTokens: 0, outputTokens: 0 } }, surfaceOp: 'append' as const }, + { type: 'usage', seq: 2, time: 3, data: { turn: 1, step: 1, usage: { inputTokens: 0, outputTokens: 0 } }, surfaceOp: 'append' as const } as SessionEvent, { type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } }, { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, ] @@ -311,8 +311,8 @@ describe('Session.append surface opts', () => { it('append without surface opts produces an event without surface fields', () => { const s = new Session(SessionId('noopts')) s.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }) - expect(s.events[0]!.sourceEventSeqs).toBeUndefined() - expect(s.events[0]!.surfaceOp).toBeUndefined() + expect((s.events[0] as SessionEvent).sourceEventSeqs).toBeUndefined() + expect((s.events[0] as SessionEvent).surfaceOp).toBeUndefined() }) it('surfaceOp primitives are not cloned (they are immutable)', () => {