feature(session): session surface

This commit is contained in:
Hypatia May
2026-06-17 19:25:29 +08:00
parent dbaef018cc
commit c5a1c494e7
17 changed files with 1047 additions and 78 deletions
@@ -1,12 +1,13 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { DatabaseSync } from 'node:sqlite'
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 SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
import { openDatabase, scanRows, type EventRow } from '../src/schema.ts'
import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
const dirs: string[] = []
@@ -42,7 +43,7 @@ describe('scanRows', () => {
// scanRows works off EventRows (data is a JSON string column); build them from
// SessionEvents so the unit tests read in terms of the event vocabulary.
const rows = (events: SessionEvent[]): EventRow[] =>
events.map(e => ({ seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data) }))
events.map(e => ({ seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data), source_event_seqs: null, surface_op: null }))
it('preserves the full log when it ends exactly on a turn/end (no torn tail)', () => {
const { preserved, tornFrom } = scanRows(rows(oneTurnLog()))
@@ -91,8 +92,8 @@ describe('scanRows', () => {
it('throws on an unparsable row inside the committed region', () => {
const withCorruptCommitted: EventRow[] = [
{ seq: 0, type: 'turn/start', time: 1, data: '{not json' }, // corrupt, sits before a turn/end
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }) },
{ seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end
{ seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null },
]
expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
})
@@ -100,7 +101,7 @@ describe('scanRows', () => {
it('tolerates an unparsable torn-tail row after the last turn/end', () => {
const withCorruptTail: EventRow[] = [
...rows(oneTurnLog()),
{ seq: 6, type: 'turn/start', time: 7, data: '{not json' }, // torn fragment, no committed turn/end after
{ seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after
]
const { preserved, tornFrom } = scanRows(withCorruptTail)
expect(preserved).toEqual(oneTurnLog())
@@ -343,7 +344,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(1)
expect(SCHEMA_VERSION).toBe(2)
})
})
@@ -749,4 +750,121 @@ describe('SessionPersistenceSqlite: edge cases', () => {
await expect(ctx.parallel('session/flush', session)).rejects.toThrow(/id collision/)
await ctx.fiber.dispose()
})
it('migrates a v1 database to v2 (adds surface columns)', async () => {
const path = await freshDbPath()
// Manually create a v1 database with the OLD schema (no surface columns).
const db = new DatabaseSync(path)
db.exec('PRAGMA user_version = 1')
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
version INTEGER NOT NULL,
created_at INTEGER NOT NULL,
cwd TEXT,
parent_session TEXT,
updated_at INTEGER NOT NULL,
title TEXT,
first_prompt TEXT
) STRICT
`)
db.exec(`
CREATE TABLE events (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
type TEXT NOT NULL,
time INTEGER NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (session_id, seq)
) STRICT
`)
db.close()
// Re-open with v2 code: migration adds the surface columns and stamps v2.
const db2 = openDatabase(path)
const version = (db2.prepare('PRAGMA user_version').get() as { user_version: number }).user_version
expect(version).toBe(2)
const info = db2.prepare("PRAGMA table_info('events')").all() as Array<{ name: string }>
const names = info.map(c => c.name)
expect(names).toContain('source_event_seqs')
expect(names).toContain('surface_op')
db2.close()
})
})
describe('surface field round-trip', () => {
it('rowToEvent parses surface fields from EventRow columns', () => {
const row: EventRow = {
seq: 0, type: 'assistant/message', time: 1,
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
source_event_seqs: JSON.stringify([3, 5]),
surface_op: JSON.stringify('append'),
}
const event = rowToEvent(row)
expect(event.sourceEventSeqs).toEqual([3, 5])
expect(event.surfaceOp).toBe('append')
})
it('rowToEvent handles replace surfaceOp object', () => {
const row: EventRow = {
seq: 0, type: 'assistant/message', time: 1,
data: JSON.stringify({ turn: 1, step: 1, content: [] }),
source_event_seqs: JSON.stringify([0, 1]),
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 })
})
it('scanRows with surface columns reconstructs events with surface fields', () => {
const rows: EventRow[] = [
{ seq: 0, type: 'user/message', time: 1,
data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' },
{ seq: 1, type: 'turn/end', time: 2,
data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
source_event_seqs: null, surface_op: null },
]
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()
})
it('append and load round-trips surface fields through SQLite', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const session = ctx.sessions.create('roundtrip-surface')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [0] })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
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()
const am = loaded.events[2]!
expect(am.surfaceOp).toBe('append')
expect(am.sourceEventSeqs).toEqual([0])
await fiber.dispose()
})
it('persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const session = ctx.sessions.create('surface-noseq')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('steering/message', { turn: 1, content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
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()
await fiber.dispose()
})
})