From e900ebd4c67246453854f300636112b7d0aab495 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:33:33 +0800 Subject: [PATCH] feat: todos session-projection provider in tool-todo (knife-4 domain probe) --- .../runtime/tests/projection-todo.spec.ts | 92 +++++++++++++++ packages/todo/tool-todo/README.md | 4 + packages/todo/tool-todo/package.json | 7 ++ packages/todo/tool-todo/src/index.ts | 52 ++++++++- .../todo/tool-todo/tests/projection.spec.ts | 106 ++++++++++++++++++ packages/todo/tool-todo/tsconfig.json | 3 + pnpm-lock.yaml | 16 +++ 7 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 packages/client/runtime/tests/projection-todo.spec.ts create mode 100644 packages/todo/tool-todo/tests/projection.spec.ts diff --git a/packages/client/runtime/tests/projection-todo.spec.ts b/packages/client/runtime/tests/projection-todo.spec.ts new file mode 100644 index 0000000000..8b98a82edf --- /dev/null +++ b/packages/client/runtime/tests/projection-todo.spec.ts @@ -0,0 +1,92 @@ +/** + * Knife-4 acceptance probe (session-projection RFC): the todo domain's client + * cell — `fromEvent: todo/write ⇒ whole list` — runs end to end on the + * UNMODIFIED cell framework: baseline seeding from a history response's + * projections block, live last-wins folding, and the seq guard, with the + * `todos` key merged test-locally the same way the domain client plugin will + * (through the interface package's pure-type outlet). Zero framework edits. + */ +import { describe, expect, it } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { TodoItem } from '@deepseek-ai/dsh-session/types' +import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { ProjectionCellSpec } from '../src/client/sessions/projection-cell.ts' +import { Session } from '../src/client/sessions/session.ts' +import { FakeApiClient, ok } from './fake-api.ts' +import { entries, plainTurn } from './event-script.ts' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + todos: TodoItem[] | null + } +} + +const SID = 'fk-todo' as SessionId + +const todoEvent = (seq: number, todos: TodoItem[]): SessionEvent => + ({ seq, time: 1_700_000_000_000 + seq, type: 'todo/write', data: { todos } }) as unknown as SessionEvent + +/** The exact cell the todo domain client plugin will register: whole-list fromEvent, array-or-null schema. */ +const todosSpec = (): ProjectionCellSpec<'todos'> => ({ + key: 'todos', + schema: { + parse: (value) => { + if (value === null || Array.isArray(value)) return value as TodoItem[] | null + throw new Error('not a todos payload') + }, + }, + fromEvent: event => (event.type === 'todo/write' + ? (event as unknown as { data: { todos: TodoItem[] } }).data.todos + : undefined), +}) + +function makeSession() { + const api = new FakeApiClient() + const session = new Session(SID, api) + session.projections.register(todosSpec()) + const cell = session.projections.cellOf('todos') + if (cell === undefined) throw new Error('cell missing after register') + return { api, session, cell } +} + +describe('todo projection cell over the unmodified framework', () => { + it('seeds null from a pre-first-write baseline, then a live todo/write replaces it whole', async () => { + const { api, session, cell } = makeSession() + api.onHistory = () => Promise.resolve(ok({ + events: entries(plainTurn(0, 0, 'q', 'a')) as never[], hasMore: false, + projections: { asOfSeq: 5, values: { todos: null } }, + } as never)) + await session.open() + expect(cell.getSnapshot()).toBeNull() + const list: TodoItem[] = [{ content: 'ship knife 4', status: 'in_progress' }] + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: todoEvent(6, list) }) + expect(cell.getSnapshot()).toEqual(list) + }) + + it('seeds the whole list from the baseline and drops a replayed older snapshot (last-wins)', async () => { + const { api, session, cell } = makeSession() + const current: TodoItem[] = [ + { content: 'a', status: 'completed' }, + { content: 'b', status: 'pending' }, + ] + api.onHistory = () => Promise.resolve(ok({ + events: entries(plainTurn(0, 0, 'q', 'a')) as never[], hasMore: false, + projections: { asOfSeq: 9, values: { todos: current } }, + } as never)) + await session.open() + expect(cell.getSnapshot()).toEqual(current) + // A replayed pre-cut write (window path) must not roll the list back. + session.projections.offerWindow([todoEvent(4, [{ content: 'stale', status: 'pending' }])]) + expect(cell.getSnapshot()).toEqual(current) + }) + + it('reads capability-absent (undefined) when the block omits the todos key', async () => { + const { api, session, cell } = makeSession() + api.onHistory = () => Promise.resolve(ok({ + events: entries(plainTurn(0, 0, 'q', 'a')) as never[], hasMore: false, + projections: { asOfSeq: 5, values: {} }, + } as never)) + await session.open() + expect(cell.getSnapshot()).toBeUndefined() + }) +}) diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 3615f68953..a44005d002 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -22,6 +22,10 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../examples/tui-demo) shows it as a persistent plan, and the [web client](../../client/ui-conversation) renders a plan strip plus a dedicated tool row off `ConversationSnapshot.todos` ([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)). +## 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. + ## Export shape A function/namespace plugin: it exports `name` / `inject` / `apply` and NO default. A stray `export default` would collapse the module via the Loader's `unwrapExports` and drop `inject` (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 88d5e9b9c9..66d3e66add 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -26,10 +26,14 @@ "src" ], "license": "BSD-3-Clause", + "dependencies": { + "zod": "^4.4.3" + }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-projection": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -37,11 +41,14 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 66b0a8ab12..be7bb8cf65 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -6,8 +6,24 @@ */ import type { Context } from 'cordis' +import { z } from 'zod' +import type { ZodType } from 'zod' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { TodoItem } from '@deepseek-ai/dsh-session' +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 {} from '@deepseek-ai/dsh-session-projection' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** + * The agent's current whole todo list (the latest `todo/write` snapshot), + * or `null` before the first write. Whole-value rule: every `todo/write` + * carries the complete replacement list, so the fold is last-wins. + */ + todos: TodoItem[] | null + } +} export const name = 'tool-todo' export const inject = ['tools'] @@ -57,8 +73,40 @@ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { return todos } -/** Register the `todo_write` tool on `ctx.tools`. */ +/** Wire payload schema of the `todos` projection (whole list or pre-first-write null). */ +const todosProjectionSchema: ZodType = z.union([ + z.array(z.object({ + content: z.string(), + status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]), + })), + 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. */ export function apply(ctx: Context): void { + // The provider child activates only when a projection registry is composed + // (headless assemblies without the seam stay unaffected). + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register({ + key: 'todos', + schema: todosProjectionSchema, + get: currentTodos, + }) + }) ctx.tools.register(defineTool({ name: 'todo_write', description: DESCRIPTION, diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts new file mode 100644 index 0000000000..41e3b30bae --- /dev/null +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -0,0 +1,106 @@ +/** + * 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 + * composition without tool-todo has no `todos` key; unmounting tool-todo + * removes it (HMR safety). The carrier and framework are exercised unmodified. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, TodoItem } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' + +let nextRpc = 1 +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`todo-proj-${String(nextRpc++)}`), payload } +} + +interface Bench { + ctx: Context + session: Session + tailProjections(): Promise<{ asOfSeq: number; values: Record } | undefined> +} + +async function harness(withTodoTool: boolean): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + if (withTodoTool) await ctx.plugin(ToolTodo) + const session = ctx.sessions.create() + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }) + return { + ctx, + session, + async tailProjections() { + const response = await api.sessions.history(request({ sessionId: session.id })) + if (!response.result.ok) throw new Error('history failed') + return response.result.value.projections as { asOfSeq: number; values: Record } | undefined + }, + } +} + +/** One paginable message so the tail page is non-degenerate. */ +function seedMessage(session: Session): void { + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) +} + +describe('todos projection provider', () => { + it('serves null before the first todo/write', async () => { + const bench = await harness(true) + seedMessage(bench.session) + const projections = await bench.tailProjections() + expect(projections?.values).toEqual({ todos: null }) + expect(projections?.asOfSeq).toBe(bench.session.seq) + }) + + it('serves the latest whole list after writes, asOfSeq = window tail seq', async () => { + const bench = await harness(true) + const session = bench.session + seedMessage(session) + const first: TodoItem[] = [{ content: 'a', status: 'pending' }] + const second: TodoItem[] = [ + { content: 'a', status: 'completed' }, + { content: 'b', status: 'in_progress' }, + ] + session.append('todo/write', { todos: first }) + session.append('todo/write', { todos: second }) + const projections = await bench.tailProjections() + // Last-wins: the latest snapshot, whole. + expect(projections?.values.todos).toEqual(second) + expect(projections?.asOfSeq).toBe(session.seq) + }) + + it('has no todos key when tool-todo is not composed', async () => { + const bench = await harness(false) + seedMessage(bench.session) + const projections = await bench.tailProjections() + expect(projections).toBeDefined() + expect('todos' in (projections?.values ?? {})).toBe(false) + }) + + it('drops the key when the tool-todo fiber unloads (HMR safety)', async () => { + const bench = await harness(false) + seedMessage(bench.session) + const fiber = await bench.ctx.plugin(ToolTodo) + expect((await bench.tailProjections())?.values).toEqual({ todos: null }) + await fiber.dispose() + expect('todos' in ((await bench.tailProjections())?.values ?? {})).toBe(false) + }) +}) diff --git a/packages/todo/tool-todo/tsconfig.json b/packages/todo/tool-todo/tsconfig.json index f980e5ead1..b35157e58d 100644 --- a/packages/todo/tool-todo/tsconfig.json +++ b/packages/todo/tool-todo/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/session" }, + { + "path": "../../session-projection/session-projection" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5322fa9a7f..5d037b6a5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -874,6 +874,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection immer: specifier: ^10.1.1 version: 10.2.0 @@ -4269,6 +4272,10 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/todo/tool-todo: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4279,6 +4286,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4288,12 +4298,18 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../../ui/user-interaction cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)