fix(todo): validate announced session histories

This commit is contained in:
Tianyi Cui
2026-08-22 22:22:27 +08:00
parent c41c5f3959
commit b7dca9eb7e
2 changed files with 67 additions and 16 deletions
+38 -15
View File
@@ -39,34 +39,57 @@ function validateTodos(value: unknown, fail: InvariantFailure): void {
}
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
/** Whether the committed log prefix ends inside an open turn. */
function hasOpenTurn(events: readonly SessionEvent[]): boolean {
let open = false
for (const event of events) {
if (event.type === 'turn/start') open = true
if (event.type === 'turn/end') open = false
}
return open
/** Incremental turn state for one committed session log. */
interface TurnTrace {
open: boolean
}
/** Validate one package-owned event against its payload and committed session prefix. */
function validateEvent(session: Session, event: SessionEvent, fail: InvariantFailure): void {
/** Advance the trace after one event has committed. */
function advanceTrace(trace: TurnTrace, event: SessionEvent): void {
if (event.type === 'turn/start') trace.open = true
if (event.type === 'turn/end') trace.open = false
}
/** Validate one package-owned event against the preceding committed trace. */
function validateEvent(event: SessionEvent, trace: TurnTrace, fail: InvariantFailure): void {
if (event.type !== 'todo/write') return
validateTodos(event.data.todos, fail)
if (!hasOpenTurn(session.events.slice(0, event.seq))) {
fail('todo/write appended outside any open turn')
if (!trace.open) fail('todo/write appended outside any open turn')
}
/** Validate one existing log in a single pass and return its tail trace. */
function seedTrace(session: Session, fail: InvariantFailure): TurnTrace {
const trace: TurnTrace = { open: false }
for (const event of session.events) {
validateEvent(event, trace, fail)
advanceTrace(trace, event)
}
return trace
}
/** Install validation for loaded and newly appended whole-list todo snapshots. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
for (const session of ctx.sessions.list()) {
for (const event of session.events) validateEvent(session, event, fail)
const traces = new WeakMap<Session, TurnTrace>()
const seed = (session: Session): void => {
traces.set(session, seedTrace(session, fail))
}
const traceFor = (session: Session): TurnTrace => {
let trace = traces.get(session)
if (trace === undefined) {
trace = seedTrace(session, fail)
traces.set(session, trace)
}
return trace
}
for (const session of ctx.sessions.list()) seed(session)
ctx.on('session/created', (session) => { seed(session) }, { global: true })
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
validateEvent(session, event, fail)
validateEvent(event, traceFor(session), fail)
}, { global: true })
ctx.on('session/event', (session, event) => {
advanceTrace(traceFor(session), event)
}, { global: true })
}, { inject: ['sessions'] })
/* jscpd:ignore-end */
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as TodoInvariant from '@deepseek-ai/dsh-tool-todo/invariant'
@@ -96,4 +96,32 @@ describe('todo snapshot invariants', () => {
await expect(ctx.plugin(TodoInvariant).then(() => undefined)).rejects.toThrow(/repeats content "duplicate"/)
})
it('validates seeded sessions announced after companion installation', async () => {
const ctx = await setup()
const valid = ctx.sessions.create(SessionId('todo-seeded-valid'), { seed: [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'todo/write', seq: 1, time: 2, data: { todos: [] } },
] })
expect(() => valid.append('todo/write', { todos: [] })).not.toThrow()
expect(() => ctx.sessions.create(SessionId('todo-seeded-invalid'), { seed: [
{ type: 'todo/write', seq: 0, time: 1, data: { todos: [] } },
] })).toThrow(/outside any open turn/)
})
it('tracks events committed before a prepared session is announced', async () => {
const ctx = await setup()
const session = ctx.sessions.prepare(SessionId('todo-prepared'))
const detach = ctx.sessions.enter(session)
try {
session.append('turn/start', { turn: 1 })
expect(() => session.append('todo/write', { todos: [] })).not.toThrow()
ctx.sessions.announce(session)
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/)
} finally {
detach()
}
})
})