From a2b415096d732f9c5b2eeb62005e640a2e1a5522 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:01:36 +0800 Subject: [PATCH] refactor(todo): own todo event vocabulary --- packages/client/connection/package.json | 2 + .../client/connection/src/client/fixture.ts | 2 +- .../client/connection/tsconfig.client.json | 3 + packages/client/runtime/package.json | 2 + .../src/client/sessions/conversation.ts | 2 +- packages/client/runtime/tsconfig.json | 3 + packages/core/session/src/invariant.ts | 1 - packages/core/session/src/types.ts | 19 ----- packages/core/session/tests/invariant.spec.ts | 1 - .../core/session/tests/request-header.spec.ts | 8 +- packages/core/session/tests/session.spec.ts | 83 ++----------------- .../context-breakdown-projection.spec.ts | 4 +- .../tests/token-usage-projection.spec.ts | 2 +- .../session-query/session-query/package.json | 2 + .../session-query/src/extraction.ts | 2 + .../session-query/session-query/tsconfig.json | 3 + packages/todo/tool-todo/src/index.ts | 2 +- packages/todo/tool-todo/src/invariant.ts | 26 ++++-- packages/todo/tool-todo/src/types.ts | 24 +++++- .../todo/tool-todo/tests/invariant.spec.ts | 45 +++++++--- .../todo/tool-todo/tests/projection.spec.ts | 7 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 69 ++++++++++++++- pnpm-lock.yaml | 9 ++ 23 files changed, 194 insertions(+), 127 deletions(-) diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index da33c138ab..eab7707e3b 100644 --- a/packages/client/connection/package.json +++ b/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:^" } } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dc3c596864..603cd2325c 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/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' diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 4d8621e270..c40e2cbadb 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -27,6 +27,9 @@ { "path": "../../core/session" }, + { + "path": "../../todo/tool-todo" + }, { "path": "../../core/tools" }, diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index d7fde8c598..fd2e7faff2 100644 --- a/packages/client/runtime/package.json +++ b/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": [ diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 20d33b2f31..daece0addb 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/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' diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index dab941e3d8..b5fa3a55e1 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../session/session-title" }, + { + "path": "../../todo/tool-todo" + }, { "path": "../../llm/llm" }, diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index da7cd55964..ea038c5344 100644 --- a/packages/core/session/src/invariant.ts +++ b/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) { diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 31ce28a01b..b5aa518590 100644 --- a/packages/core/session/src/types.ts +++ b/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. diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index b98391cc3b..46da3d21ca 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/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', diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index 5a24618fca..764b615877 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/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) }) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 47453ea6b5..c092fd3386 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/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) - }) -}) diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 841b7d6aef..6ab566d085 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/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. diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 56ba67ee80..d076459559 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/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) diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index d39ba2c494..9d4f7bd1f2 100644 --- a/packages/session-query/session-query/package.json +++ b/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:^" } diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts index d01ccb7aa3..1efc83f251 100644 --- a/packages/session-query/session-query/src/extraction.ts +++ b/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. diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index b4764a9b48..408f51652b 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../session/session-title" }, + { + "path": "../../todo/tool-todo" + }, { "path": "../../session/session-persistence" }, diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 9c0a8e45a7..9ccbb1ab7a 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/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); diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index f1b8c63066..02eb612af3 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/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 */ diff --git a/packages/todo/tool-todo/src/types.ts b/packages/todo/tool-todo/src/types.ts index d279d1fa97..68d5649bb0 100644 --- a/packages/todo/tool-todo/src/types.ts +++ b/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 { diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index e533e11feb..894afe7c7f 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, { 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 { 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' }, diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index ff374e641e..dc2283a98c 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/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) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index dd959f2f9f..5d44a94679 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/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) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8633fc6892..8ccbc02bc2 100644 --- a/pnpm-lock.yaml +++ b/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: