feat(session): enforce SurfaceEvent type

This commit is contained in:
Hypatia May
2026-06-18 13:26:50 +08:00
parent e5d8263194
commit 0279cf09d6
10 changed files with 189 additions and 63 deletions

View File

@@ -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<string>(['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<SurfaceEventType>
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}`)
}
}

View File

@@ -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/)
})
})

View File

@@ -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<SurfaceEventType>
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,
]
}

View File

@@ -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
}
/**

View File

@@ -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<SurfaceEventType>).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()
})
})

View File

@@ -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<T extends SessionEventType>(type: T, data: SessionEventMap[T], opts?: SurfaceAppendOpts): SessionEvent<T> {
append<T extends SessionEventType>(
type: T,
data: SessionEventMap[T],
...opts: T extends SurfaceEventType ? [opts?: SurfaceAppendOpts] : []
): SessionEvent<T> {
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<T>
this.log.push(event)
this.onAppend?.(event)
} as unknown as SessionEvent<T>
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

View File

@@ -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<string>([
'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<SurfaceEventType>).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

View File

@@ -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<SurfaceEventType> & { 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<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
@@ -216,6 +249,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** 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<T extends SessionEventType = SessionEventType> = {
sourceEventSeqs?: number[]
/** How this event entered the surface; absent for non-surface events. */
surfaceOp?: SurfaceOp
}
} : object)
}[T]

View File

@@ -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', () => {

View File

@@ -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<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
})
it('surfaceOp primitives are not cloned (they are immutable)', () => {