feat(session): enforce SurfaceEvent type
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user