diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index a44005d002..5d748e981e 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -24,7 +24,7 @@ The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; ## Session projection -When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `todos` provider under an injected child: value = the latest `todo/write` snapshot backscanned from the in-memory log tail (whole list, last-wins), `null` before the first write. The key merges into `SessionProjectionMap` here (via the interface package's `/types` outlet); carriers serve it on the history tail page. Compositions without the registry are unaffected. +When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `todos` projection unit under an injected child: `init` = `null` (no write yet), `apply` = take the whole list from each `todo/write` (last-wins; every other event returns the same state reference), `view` = identity, `stateVersion` = 1. The key merges into `SessionProjectionMap` here (via the interface package's `/types` outlet); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. ## Export shape diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index be7bb8cf65..abdf006daf 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -9,9 +9,8 @@ import type { Context } from 'cordis' import { z } from 'zod' import type { ZodType } from 'zod' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' -// Type-only: resolves ctx.sessionProjections for the optional provider child. +import type { TodoItem } from '@deepseek-ai/dsh-session' +// Type-only: resolves ctx.sessionProjections for the optional unit child. import type {} from '@deepseek-ai/dsh-session-projection' declare module '@deepseek-ai/dsh-session-projection/types' { @@ -82,29 +81,20 @@ const todosProjectionSchema: ZodType = z.union([ z.null(), ]) -/** - * Current whole todo list: the latest `todo/write` snapshot, backscanned from - * the log tail (bounded: first hit terminates; the events live in memory). - * `null` = no write yet. - */ -function currentTodos(agent: Agent): TodoItem[] | null { - const events = agent.session.events - for (let i = events.length - 1; i >= 0; i--) { - const event = events[i] as SessionEvent - if (event.type === 'todo/write') return event.data.todos - } - return null -} - -/** Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, the `todos` provider. */ +/** Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed, the `todos` unit. */ export function apply(ctx: Context): void { - // The provider child activates only when a projection registry is composed - // (headless assemblies without the seam stay unaffected). + // The unit child activates only when a projection registry is composed + // (headless assemblies without the seam stay unaffected). Pure last-wins + // fold: state is the latest whole todo/write list, null before the first + // write; every other event returns the same reference (no downstream work). ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register({ + projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({ key: 'todos', schema: todosProjectionSchema, - get: currentTodos, + init: () => null, + apply: (state, event) => (event.type === 'todo/write' ? event.data.todos : state), + view: state => state, + stateVersion: 1, }) }) ctx.tools.register(defineTool({ diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 41e3b30bae..860613f462 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -2,7 +2,7 @@ * The `todos` projection provider (session-projection RFC knife 4 — the "a * fourth domain is just its own registrations" acceptance probe): mounting * tool-todo beside the registry serves the whole current list on the history - * tail page with a consistent asOfSeq; before any write the value is null; a + * tail page with a consistent asOfSeq (= last event seq); before any write the value is null; a * composition without tool-todo has no `todos` key; unmounting tool-todo * removes it (HMR safety). The carrier and framework are exercised unmodified. */ @@ -67,10 +67,10 @@ describe('todos projection provider', () => { seedMessage(bench.session) const projections = await bench.tailProjections() expect(projections?.values).toEqual({ todos: null }) - expect(projections?.asOfSeq).toBe(bench.session.seq) + expect(projections?.asOfSeq).toBe(bench.session.seq - 1) }) - it('serves the latest whole list after writes, asOfSeq = window tail seq', async () => { + it('serves the latest whole list after writes, asOfSeq = last event seq', async () => { const bench = await harness(true) const session = bench.session seedMessage(session) @@ -84,7 +84,7 @@ describe('todos projection provider', () => { const projections = await bench.tailProjections() // Last-wins: the latest snapshot, whole. expect(projections?.values.todos).toEqual(second) - expect(projections?.asOfSeq).toBe(session.seq) + expect(projections?.asOfSeq).toBe(session.seq - 1) }) it('has no todos key when tool-todo is not composed', async () => {