Просмотр исходного кода

fix(todo): validate announced session histories

Tianyi Cui 1 месяц назад
Родитель
Сommit
b7dca9eb7e

+ 38 - 15
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
+}
+
+/** 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 its payload and committed session prefix. */
-function validateEvent(session: Session, event: SessionEvent, fail: InvariantFailure): void {
+/** 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 */

+ 29 - 1
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()
+    }
+  })
 })