refactor(session): drop the dead mutable SessionSummary

SessionSummary (updatedAt/title/firstPrompt) and SessionPersistence.update()
were dead state: zero production callers of update(), no production reader of
updatedAt/firstPrompt, and ACP's title comes from a tool-call presenter, not
storage. The live Session.header was already typed SessionHeader, so the
summary only ever existed in the persistence layer, written and read by nothing
but its own contract test.

Delete it entirely (no SessionMeta alias — SessionMeta collapses to
SessionHeader everywhere). This removes the JSONL .summary.json sidecar
machinery, the SQLite title/first_prompt/updated_at columns and per-append
updated_at bump, and the update() method from the abstract service and both
backends. SQLite SCHEMA_VERSION goes 1->2 and openDatabase now rejects any
non-current user_version (older or newer) — no migration, unreleased software.

Net -400 lines, and it erases the JSONL-sidecar-vs-SQLite-column durability
divergence that the upcoming write coordinator would otherwise have to model.

Records the decision in docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md
and migrates the 2026-06-14 session-persistence RFC's facts to current truth.
Adds a standalone AGENTS.md section "Tests document behavior, not golden truth"
(a passing test pins current behavior, not necessarily correct behavior) with
the summary-drop as its worked example, and reinforces the no-migration
pre-release stance.
This commit is contained in:
Tianyi Cui
2026-06-20 01:03:57 +08:00
parent 0561fb47b6
commit 815bac7de9
20 changed files with 163 additions and 518 deletions
@@ -4,7 +4,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, SessionHeader } 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 { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
@@ -232,14 +232,23 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
await b2.dispose()
})
it('rejects opening a database whose schema version is newer than this build', async () => {
it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => {
const path = await freshDbPath()
openDatabase(path).close() // stamp user_version = SCHEMA_VERSION
// Bump user_version past what this build supports.
const db = openDatabase(path)
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`)
db.close()
expect(() => openDatabase(path)).toThrow(/newer than this build/)
const dbNewer = openDatabase(path)
dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`)
dbNewer.close()
expect(() => openDatabase(path)).toThrow(/incompatible with this build/)
// A stale OLDER version (e.g. a pre-summary-drop v1 DB) is also rejected —
// we do not migrate (unreleased software, no backward-compat).
const olderPath = await freshDbPath()
openDatabase(olderPath).close()
const dbOlder = openDatabase(olderPath)
dbOlder.exec('PRAGMA user_version = 1')
dbOlder.close()
expect(() => openDatabase(olderPath)).toThrow(/incompatible with this build/)
})
it('append snapshots the batch: mutating an event after the call does not corrupt the persisted copy', async () => {
@@ -323,7 +332,6 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
const fiber1 = await ctx1.plugin(SessionPersistenceSqlite, { path })
await ctx1.sessionPersistence.create(m)
await ctx1.sessionPersistence.append(m.id, oneTurnLog())
await ctx1.sessionPersistence.update(m.id, { title: 'T', firstPrompt: 'hi' })
await fiber1.dispose()
const ctx2 = new Context()
@@ -331,7 +339,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
const fiber2 = await ctx2.plugin(SessionPersistenceSqlite, { path })
expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
const loaded = await ctx2.sessionPersistence.load(m.id)
expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj', title: 'T', firstPrompt: 'hi' })
expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj' })
expect(loaded.events).toEqual(oneTurnLog())
await fiber2.dispose()
})
@@ -340,8 +348,8 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
const path = await freshDbPath()
// Materialize a row with version 2 directly via the real schema.
const db = openDatabase(path)
db.prepare('INSERT INTO sessions (id, version, created_at, updated_at) VALUES (?, ?, ?, ?)')
.run('v2', 2, 1, 1)
db.prepare('INSERT INTO sessions (id, version, created_at) VALUES (?, ?, ?)')
.run('v2', 2, 1)
db.close()
const ctx = new Context()
@@ -372,7 +380,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(1)
expect(SCHEMA_VERSION).toBe(2)
})
})
@@ -468,22 +476,6 @@ describe('SessionPersistenceSqlite: write path (session/event → flush)', () =>
await expect(ctx2.parallel('session/flush', s2)).rejects.toThrow(/id collision/)
await fiber2.dispose()
})
it('update before the first append keeps the summary in memory and the session lazy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
const m = meta('lazy-update')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.update(m.id, { title: 'pending' })
// Still lazy: no materialized row yet.
expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
// The first append materializes and carries the pending title.
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const loaded = await ctx.sessionPersistence.load(m.id)
expect(loaded.meta.title).toBe('pending')
await fiber.dispose()
})
})
describe('SessionPersistenceSqlite: edge cases', () => {
@@ -529,21 +521,6 @@ describe('SessionPersistenceSqlite: edge cases', () => {
await b2.dispose()
})
it('update adopts a session that exists only in the DB (fresh instance)', async () => {
const path = await freshDbPath()
const m = meta('adopt-update')
const b1 = await backend(path)
await b1.ctx.sessionPersistence.create(m)
await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
await b1.dispose()
const b2 = await backend(path)
await b2.ctx.sessionPersistence.update(m.id, { title: 'after restart' })
const loaded = await b2.ctx.sessionPersistence.load(m.id)
expect(loaded.meta.title).toBe('after restart')
await b2.dispose()
})
it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
const path = await freshDbPath()
const m = meta('rollback-insert')
@@ -574,7 +551,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
it('round-trips a header with parentSession (fork lineage)', async () => {
const { ctx, dispose } = await backend()
const m: SessionMeta = { ...meta('child'), parentSession: SessionId('parent') }
const m: SessionHeader = { ...meta('child'), parentSession: SessionId('parent') }
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const loaded = await ctx.sessionPersistence.load(m.id)