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

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