feat: todos session-projection provider in tool-todo (knife-4 domain probe)

This commit is contained in:
imccyu
2026-07-27 16:33:33 +08:00
parent 555a6aa7cc
commit e900ebd4c6
7 changed files with 278 additions and 2 deletions

View File

@@ -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()
})
})

View File

@@ -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)).

View File

@@ -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"
}
}

View File

@@ -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<TodoItem[] | null> = 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,

View File

@@ -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<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`todo-proj-${String(nextRpc++)}`), payload }
}
interface Bench {
ctx: Context
session: Session
tailProjections(): Promise<{ asOfSeq: number; values: Record<string, unknown> } | undefined>
}
async function harness(withTodoTool: boolean): Promise<Bench> {
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<string, unknown> } | 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)
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../support/invariants"
}

16
pnpm-lock.yaml generated
View File

@@ -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)