Selaa lähdekoodia

refactor(todo): own todo event vocabulary

Tianyi Cui 1 kuukausi sitten
vanhempi
sitoutus
a2b415096d

+ 2 - 0
packages/client/connection/package.json

@@ -56,6 +56,7 @@
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^"
   },
   "devDependencies": {
@@ -68,6 +69,7 @@
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^"
   }
 }

+ 1 - 1
packages/client/connection/src/client/fixture.ts

@@ -20,8 +20,8 @@ import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-atta
 import type {
   SessionEvent,
   SessionId,
-  TodoItem,
 } from '@deepseek-ai/dsh-session/types'
+import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
 // Type-only: the brand constructor is host-side; the fixture casts at its
 // wire-fabrication boundary (the schema layer's one-cast-point posture).
 import type { CommandId } from '@deepseek-ai/dsh-commands/brand'

+ 3 - 0
packages/client/connection/tsconfig.client.json

@@ -27,6 +27,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../todo/tool-todo"
+    },
     {
       "path": "../../core/tools"
     },

+ 2 - 0
packages/client/runtime/package.json

@@ -61,6 +61,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
+    "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^"
   },
   "devDependencies": {
@@ -83,6 +84,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
+    "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^"
   },
   "files": [

+ 1 - 1
packages/client/runtime/src/client/sessions/conversation.ts

@@ -7,7 +7,7 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
-import type { TodoItem } from '@deepseek-ai/dsh-session/types'
+import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
 import type {
   RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
 } from '@deepseek-ai/dsh-api-remotes/client'

+ 3 - 0
packages/client/runtime/tsconfig.json

@@ -38,6 +38,9 @@
     {
       "path": "../../session/session-title"
     },
+    {
+      "path": "../../todo/tool-todo"
+    },
     {
       "path": "../../llm/llm"
     },

+ 0 - 1
packages/core/session/src/invariant.ts

@@ -147,7 +147,6 @@ function validateEvent(
     case 'session/end-seed':
       // Unconstrained: an unbalanced seed legally puts it inside an open turn.
       break
-    case 'todo/write':
     case 'request/header':
     case 'request/context': {
       if (trace.openTurn === null) {

+ 0 - 19
packages/core/session/src/types.ts

@@ -176,23 +176,6 @@ export interface TurnEndReasonMap {
 /** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
 export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
 
-/**
- * One entry in an agent's todo list — the unit of the `todo/write`
- * {@link SessionEventMap} event's whole-list snapshot.
- *
- * Deliberately minimal: a human-readable `content` line and a three-state
- * `status`. No id, priority, or `activeForm` — the list is replaced wholesale
- * on every write (last-write-wins), so entries need no stable identity. The
- * three statuses describe the complete portable lifecycle needed by model and
- * UI consumers.
- */
-export interface TodoItem {
-  /** What this task is — a short imperative line shown in the UI. */
-  content: string
-  /** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */
-  status: 'pending' | 'in_progress' | 'completed'
-}
-
 /**
  * Logged request state outside derived history: call config, system prompt, and
  * tools. The latest full `request/header` snapshot reconstructs it; canonical
@@ -299,8 +282,6 @@ export interface SessionEventMap {
     error?: { name: string; code: string }
     meta?: JsonValue
   }
-  /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
-  'todo/write': { todos: TodoItem[] }
   /**
    * Full header for the next request, appended inside its step before dispatch.
    * It is log-only; the latest snapshot reconstructs the request header.

+ 0 - 1
packages/core/session/tests/invariant.spec.ts

@@ -141,7 +141,6 @@ describe('session-log invariants', () => {
     const enclosed = (await setup()).ctx.sessions.create()
     enclosed.append('turn/start', { turn: 1 })
     enclosed.append('step/start', { turn: 1, step: 1 })
-    expect(() => enclosed.append('todo/write', { todos: [] })).not.toThrow()
     expect(() => enclosed.append('request/header', {
       header: { config: { provider: 'mock', model: 'mock' } },
       reason: 'initial',

+ 6 - 2
packages/core/session/tests/request-header.spec.ts

@@ -146,7 +146,9 @@ describe('Session.requestContext', () => {
   it('advances incrementally across appends and skips unrelated events', () => {
     const session = Session.create(SessionId('incremental-capacity'), seedWith(CAPACITY))
     expect(session.requestContext()).toEqual(CAPACITY)
-    session.append('todo/write', { todos: [] })
+    session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
     expect(session.requestContext()).toEqual(CAPACITY)
     session.append('request/context', { ...CAPACITY, model: 'next', contextWindow: 64_000 })
     expect(session.requestContext()).toEqual({ provider: 'mock', model: 'next', contextWindow: 64_000 })
@@ -158,7 +160,9 @@ describe('Session.requestContext', () => {
     const session = Session.create(SessionId('batched-capacity'), seedWith(CAPACITY))
     expect(session.requestContext()).toEqual(CAPACITY)
     session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
-    session.append('todo/write', { todos: [] })
+    session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
     session.append('request/context', { ...CAPACITY, contextWindow: 300_000 })
     expect(session.requestContext()?.contextWindow).toBe(300_000)
   })

+ 9 - 74
packages/core/session/tests/session.spec.ts

@@ -9,7 +9,7 @@ import SessionStore, {
   SessionId,
   snapshotSessionEvent,
 } from '@deepseek-ai/dsh-session'
-import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
+import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface } from '@deepseek-ai/dsh-session'
 
 describe('Session', () => {
   it('exposes one stable readonly surface view', () => {
@@ -787,7 +787,7 @@ describe('Session', () => {
       },
     })
 
-    const event = session.append('todo/write', data as never)
+    const event = session.append('request/context', data as never)
 
     expect(reads).toBe(1)
     expect(event.data).toEqual({ value: 'accepted' })
@@ -914,14 +914,14 @@ describe('Session', () => {
     expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
 
     const appended = Session.create(SessionId('append-frozen'))
-    const appendedEvent = appended.append('todo/write', {
-      todos: [{ content: 'first', status: 'pending' }],
-    })
+    const appendedEvent = appended.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
     expect(Object.isFrozen(appendedEvent)).toBe(true)
     expect(Object.isFrozen(appendedEvent.data)).toBe(true)
-    expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true)
-    expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true)
-    expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
+    expect(Object.isFrozen(appendedEvent.data.content)).toBe(true)
+    expect(Object.isFrozen(appendedEvent.data.content[0])).toBe(true)
+    expect(() => { (appendedEvent.data.content[0] as { text: string }).text = 'mutated' }).toThrow(TypeError)
   })
 
   it('iteratively freezes deeply nested restored event data', () => {
@@ -1531,7 +1531,7 @@ describe('SessionStore', () => {
     const session = ctx.sessions.create(SessionId('reentrant-observer'))
     const heard: SessionEvent[] = []
     ctx.on('session/event', (observedSession) => {
-      observedSession.append('todo/write', { todos: [] })
+      observedSession.append('request/context', { provider: 'mock', model: 'mock' })
     })
     ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
 
@@ -1663,68 +1663,3 @@ describe('SessionStore', () => {
     expect(heard).toEqual([session])
   })
 })
-
-describe('todo/write event', () => {
-  it('appends the whole-list snapshot and isolates the log from later mutation', () => {
-    const session = Session.create(SessionId('t1'))
-    const todos: TodoItem[] = [
-      { content: 'plan the work', status: 'in_progress' },
-      { content: 'write the code', status: 'pending' },
-    ]
-    session.append('todo/write', { todos })
-
-    const event = session.events.findLast(e => e.type === 'todo/write')!
-    expect(event.type).toBe('todo/write')
-    expect(event.data.todos).toEqual(todos)
-
-    // The append snapshots its input: mutating the caller's array afterward must
-    // not change what the log holds (the durable-source-of-truth contract).
-    todos.push({ content: 'sneak in', status: 'pending' })
-    todos[0]!.status = 'completed'
-    expect(event.data.todos).toEqual([
-      { content: 'plan the work', status: 'in_progress' },
-      { content: 'write the code', status: 'pending' },
-    ])
-  })
-
-  it('is last-write-wins: the current list is the most recent todo/write', () => {
-    const session = Session.create(SessionId('t2'))
-    session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
-    session.append('todo/write', { todos: [
-      { content: 'first', status: 'completed' },
-      { content: 'second', status: 'in_progress' },
-    ] })
-
-    const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
-    expect(current).toEqual([
-      { content: 'first', status: 'completed' },
-      { content: 'second', status: 'in_progress' },
-    ])
-  })
-
-  it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
-    const session = Session.create(SessionId('t3'))
-    session.append('user/message', createUserMessage({
-      content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
-    }), { surfaceOp: 'append' })
-    const before = session.deriveMessages().length
-    session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
-    // The todo event must not add a message to the derived history…
-    expect(session.deriveMessages()).toHaveLength(before)
-    // …and must not appear on the ordered surface.
-    expect(session.surface.nodes).not.toContain(session.seq - 1)
-  })
-
-  it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
-    const original = Session.create(SessionId('t4'))
-    original.append('turn/start', { turn: 1 })
-    original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
-    original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-    // Seeding a non-surface event with no surfaceOp must not throw.
-    const replayed = Session.create(SessionId('t4-replay'), [...original.events])
-    expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
-      .toEqual([{ content: 'only', status: 'completed' }])
-    expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
-    expect(replayed.firstLiveSeq).toBe(original.seq)
-  })
-})

+ 2 - 2
packages/llm/token-meter/tests/context-breakdown-projection.spec.ts

@@ -94,7 +94,7 @@ describe('contextBreakdown session projection', () => {
       header: { config: CONFIG, system: 'You are terse.', tools: TOOLS },
       reason: 'change',
     })
-    session.append('todo/write', { todos: [] })
+    session.append('session/end-seed', {})
     expect(changed).not.toContain('contextBreakdown')
 
     // A system-less, tool-less envelope prices back to zero.
@@ -216,7 +216,7 @@ describe('contextBreakdown session projection', () => {
     expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
     // A claim expires after one intervening event, so replacement delta is zero.
     let expired = definition.apply(state, meter(1, 3, 8))
-    expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
+    expired = definition.apply(expired, { type: 'session/end-seed', seq: 9, time: 0, data: {} })
     expect(definition.wire.view(definition.apply(expired, replace(1, 3))).messageTokens)
       .toBe(definition.wire.view(state).messageTokens)
     // The armed claim prices exactly the next event's matching replacement.

+ 1 - 1
packages/llm/token-meter/tests/token-usage-projection.spec.ts

@@ -357,7 +357,7 @@ describe('contextPressure session projection', () => {
     const changed: string[] = []
     ctx.sessionProjections.onChanged((_session, key) => { changed.push(key) })
 
-    session.append('todo/write', { todos: [] })
+    session.append('session/end-seed', {})
     expect(changed).not.toContain('contextPressure')
     // A repeated capacity record for the same window is also a no-op.
     recordContext(session, 'small', 64_000)

+ 2 - 0
packages/session-query/session-query/package.json

@@ -37,6 +37,7 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
+    "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -51,6 +52,7 @@
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
+    "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   }

+ 2 - 0
packages/session-query/session-query/src/extraction.ts

@@ -1,6 +1,8 @@
 /** First-party semantic text extraction for session-query consumers. */
 
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
+// Type-only: includes the first-party todo event consumed below.
+import type {} from '@deepseek-ai/dsh-tool-todo'
 
 /**
  * Extract searchable semantic text from one first-party session event.

+ 3 - 0
packages/session-query/session-query/tsconfig.json

@@ -26,6 +26,9 @@
     {
       "path": "../../session/session-title"
     },
+    {
+      "path": "../../todo/tool-todo"
+    },
     {
       "path": "../../session/session-persistence"
     },

+ 1 - 1
packages/todo/tool-todo/src/index.ts

@@ -10,7 +10,7 @@ import z from '@deepseek-ai/schemastery'
 import { z as zod } from 'zod'
 import type { ZodType } from 'zod'
 import { defineTool } from '@deepseek-ai/dsh-tools'
-import type { TodoItem } from '@deepseek-ai/dsh-session'
+import type { TodoItem } from './types.ts'
 // Type-only: resolves ctx.sessionProjections for the optional unit child.
 import type {} from '@deepseek-ai/dsh-session-projection'
 // The `todos` projection-key declaration lives in src/types.ts (its one home);

+ 20 - 6
packages/todo/tool-todo/src/invariant.ts

@@ -39,20 +39,34 @@ function validateTodos(value: unknown, fail: InvariantFailure): void {
 }
 
 /* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
-/** Validate the package-owned event fields and ignore unrelated events. */
-function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
-  if (event.type === 'todo/write') validateTodos(event.data.todos, fail)
+/** 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
+}
+
+/** Validate one package-owned event against its payload and committed session prefix. */
+function validateEvent(session: Session, event: SessionEvent, 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')
+  }
 }
 
 /** 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(event, fail)
+    for (const event of session.events) validateEvent(session, event, fail)
   }
   ctx.on('internal/dispatch', (_mode, eventName, args) => {
     if (eventName !== 'session/event') return
-    const event = (args as [Session, SessionEvent])[1]
-    validateEvent(event, fail)
+    const [session, event] = args as [Session, SessionEvent]
+    validateEvent(session, event, fail)
   }, { global: true })
 }, { inject: ['sessions'] })
 /* jscpd:ignore-end */

+ 22 - 2
packages/todo/tool-todo/src/types.ts

@@ -8,9 +8,29 @@
  * @module @deepseek-ai/dsh-tool-todo/types
  */
 
-import type { TodoItem } from '@deepseek-ai/dsh-session/types'
+/**
+ * One entry in an agent's todo list — the unit of the `todo/write`
+ * whole-list snapshot declared by this package.
+ *
+ * Deliberately minimal: a human-readable `content` line and a three-state
+ * `status`. No id, priority, or `activeForm` — the list is replaced wholesale
+ * on every write (last-write-wins), so entries need no stable identity. The
+ * three statuses describe the complete portable lifecycle needed by model and
+ * UI consumers.
+ */
+export interface TodoItem {
+  /** What this task is — a short imperative line shown in the UI. */
+  content: string
+  /** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */
+  status: 'pending' | 'in_progress' | 'completed'
+}
 
-export type { TodoItem } from '@deepseek-ai/dsh-session/types'
+declare module '@deepseek-ai/dsh-session/types' {
+  interface SessionEventMap {
+    /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
+    'todo/write': { todos: TodoItem[] }
+  }
+}
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {

+ 33 - 12
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, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionStore 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'
@@ -14,10 +14,6 @@ async function setup(): Promise<Context> {
   return ctx
 }
 
-function event(todos: unknown): SessionEvent {
-  return { type: 'todo/write', seq: 0, time: 0, data: { todos } } as SessionEvent
-}
-
 describe('todo snapshot invariants', () => {
   it('accepts historical and live parallel snapshots under the single-active tool policy', async () => {
     const todos = [
@@ -30,11 +26,13 @@ describe('todo snapshot invariants', () => {
     await ctx.plugin(SessionStore)
     await ctx.plugin(ToolRuntime)
     await ctx.plugin(ToolTodo, { allowParallelInProgress: false })
-    ctx.sessions.create().append('todo/write', { todos: [...todos] })
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1 })
+    session.append('todo/write', { todos: [...todos] })
     await ctx.plugin(InvariantRegistry, { enabled: true })
 
     await expect(ctx.plugin(TodoInvariant).then(() => undefined)).resolves.toBeUndefined()
-    expect(() => { ctx.emit('session/event', {} as Session, event(todos)) }).not.toThrow()
+    expect(() => { session.append('todo/write', { todos: [...todos] }) }).not.toThrow()
   })
 
   it.each([
@@ -49,23 +47,46 @@ describe('todo snapshot invariants', () => {
     [[{ content: 'task', status: 'paused' }], /unknown status/],
   ])('rejects an incoherent durable todo snapshot', async (todos, message) => {
     const ctx = await setup()
-    expect(() => { ctx.emit('session/event', {} as Session, event(todos)) }).toThrow(message)
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1 })
+    expect(() => { session.append('todo/write', { todos } as never) }).toThrow(message)
   })
 
   it('ignores unrelated dispatches and session events', async () => {
     const ctx = await setup()
+    const session = ctx.sessions.create()
     expect(() => {
       ctx.emit('tools/change')
-      ctx.emit('session/event', {} as Session, {
-        type: 'turn/start', seq: 0, time: 0, data: { turn: 1 },
-      })
+      session.append('turn/start', { turn: 1 })
     }).not.toThrow()
   })
 
+  it('rejects a live snapshot outside an open turn before it enters the log', async () => {
+    const ctx = await setup()
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1 })
+    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    const before = [...session.events]
+
+    expect(() => session.append('todo/write', { todos: [] })).toThrow(/outside any open turn/)
+    expect(session.events).toEqual(before)
+  })
+
+  it('rejects an existing snapshot outside an open turn on late registration', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    ctx.sessions.create().append('todo/write', { todos: [] })
+    await ctx.plugin(InvariantRegistry, { enabled: true })
+
+    await expect(ctx.plugin(TodoInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
+  })
+
   it('rejects an invalid existing snapshot on late registration', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
-    ctx.sessions.create().append('todo/write', {
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1 })
+    session.append('todo/write', {
       todos: [
         { content: 'duplicate', status: 'pending' },
         { content: 'duplicate', status: 'completed' },

+ 5 - 2
packages/todo/tool-todo/tests/projection.spec.ts

@@ -13,7 +13,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
-import type { Session, TodoItem } from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
+import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime from '@deepseek-ai/dsh-tools'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -83,6 +84,7 @@ describe('todos projection provider', () => {
       { content: 'a', status: 'completed' },
       { content: 'b', status: 'in_progress' },
     ]
+    session.append('turn/start', { turn: 1 })
     session.append('todo/write', { todos: first })
     session.append('todo/write', { todos: second })
     const projections = await bench.tailProjections()
@@ -96,10 +98,11 @@ describe('todos projection provider', () => {
     const session = bench.session
     seedMessage(session)
     const list: TodoItem[] = [{ content: 'done', status: 'completed' }]
+    session.append('turn/start', { turn: 1 })
     session.append('todo/write', { todos: list })
     session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
     expect((await bench.tailProjections())?.values.todos).toEqual(list)
-    session.append('turn/start', { turn: 1 })
+    session.append('turn/start', { turn: 2 })
     const cleared = await bench.tailProjections()
     expect(cleared?.values.todos).toBeNull()
     expect(cleared?.asOfSeq).toBe(session.seq - 1)

+ 67 - 2
packages/todo/tool-todo/tests/tool-todo.spec.ts

@@ -1,11 +1,11 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
-import { CallId } from '@deepseek-ai/dsh-llm'
+import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime from '@deepseek-ai/dsh-tools'
 import { Session, SessionId } from '@deepseek-ai/dsh-session'
-import type { TodoItem } from '@deepseek-ai/dsh-session'
+import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
 import { type Agent } from '@deepseek-ai/dsh-agent'
 
 import * as tool from '../src/index.ts'
@@ -233,3 +233,68 @@ describe('dsh-tool-todo', () => {
     expect(typeof unwrapped.apply).toBe('function')
   })
 })
+
+describe('todo/write event', () => {
+  it('appends the whole-list snapshot and isolates the log from later mutation', () => {
+    const session = Session.create(SessionId('t1'))
+    session.append('turn/start', { turn: 1 })
+    const todos: TodoItem[] = [
+      { content: 'plan the work', status: 'in_progress' },
+      { content: 'write the code', status: 'pending' },
+    ]
+    session.append('todo/write', { todos })
+
+    const event = session.events.findLast(e => e.type === 'todo/write')!
+    expect(event.type).toBe('todo/write')
+    expect(event.data.todos).toEqual(todos)
+
+    todos.push({ content: 'sneak in', status: 'pending' })
+    todos[0]!.status = 'completed'
+    expect(event.data.todos).toEqual([
+      { content: 'plan the work', status: 'in_progress' },
+      { content: 'write the code', status: 'pending' },
+    ])
+  })
+
+  it('is last-write-wins: the current list is the most recent todo/write', () => {
+    const session = Session.create(SessionId('t2'))
+    session.append('turn/start', { turn: 1 })
+    session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
+    session.append('todo/write', { todos: [
+      { content: 'first', status: 'completed' },
+      { content: 'second', status: 'in_progress' },
+    ] })
+
+    const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
+    expect(current).toEqual([
+      { content: 'first', status: 'completed' },
+      { content: 'second', status: 'in_progress' },
+    ])
+  })
+
+  it('does not add a derived message or surface node', () => {
+    const session = Session.create(SessionId('t3'))
+    session.append('turn/start', { turn: 1 })
+    session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    const before = session.deriveMessages().length
+    session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
+
+    expect(session.deriveMessages()).toHaveLength(before)
+    expect(session.surface.nodes).not.toContain(session.seq - 1)
+  })
+
+  it('round-trips through a seeded replay identically without surface metadata', () => {
+    const original = Session.create(SessionId('t4'))
+    original.append('turn/start', { turn: 1 })
+    original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
+    original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    const replayed = Session.create(SessionId('t4-replay'), [...original.events])
+
+    expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
+      .toEqual([{ content: 'only', status: 'completed' }])
+    expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
+    expect(replayed.firstLiveSeq).toBe(original.seq)
+  })
+})

+ 9 - 0
pnpm-lock.yaml

@@ -1565,6 +1565,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-tool-todo':
+        specifier: workspace:^
+        version: link:../../todo/tool-todo
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
@@ -1706,6 +1709,9 @@ importers:
       '@deepseek-ai/dsh-timeout':
         specifier: workspace:^
         version: link:../../util/timeout
+      '@deepseek-ai/dsh-tool-todo':
+        specifier: workspace:^
+        version: link:../../todo/tool-todo
       '@deepseek-ai/dsh-tools':
         specifier: workspace:^
         version: link:../../core/tools
@@ -6403,6 +6409,9 @@ importers:
       '@deepseek-ai/dsh-session-title':
         specifier: workspace:^
         version: link:../../session/session-title
+      '@deepseek-ai/dsh-tool-todo':
+        specifier: workspace:^
+        version: link:../../todo/tool-todo
 
   packages/session-query/session-query-sqlite:
     dependencies: