feat: todos session-projection provider in tool-todo (knife-4 domain probe)
This commit is contained in:
92
packages/client/runtime/tests/projection-todo.spec.ts
Normal file
92
packages/client/runtime/tests/projection-todo.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
106
packages/todo/tool-todo/tests/projection.spec.ts
Normal file
106
packages/todo/tool-todo/tests/projection.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user