From b7dca9eb7eac674dd2234375f77f7e8919447f0e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:22:27 +0800 Subject: [PATCH] fix(todo): validate announced session histories --- packages/todo/tool-todo/src/invariant.ts | 53 +++++++++++++------ .../todo/tool-todo/tests/invariant.spec.ts | 30 ++++++++++- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 02eb612af3..fee7088a40 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -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() + 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 */ diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index 894afe7c7f..7d53ad052b 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -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() + } + }) })