Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
# Conflicts: # packages/todo/tool-todo/README.i18n.yaml
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md
|
||||
README.md: 30d4caca05ab3a0d7fa4291f55b3b03d7e7d1363
|
||||
README.zh.md: b0a471957940a16e10271887e6ba8c48390fb40a
|
||||
README.md: 5e8392686507c996424e8d463c5deda7efee10ab
|
||||
README.zh.md: 49b75869ac9a52de6832449083cb01d22e857e50
|
||||
|
||||
@@ -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` 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
|
||||
|
||||
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)).
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
|
||||
规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表:[TUI 应用](../../examples/tui-demo)将其显示为持久计划,[web 客户端](../../client/ui-conversation)则基于 `ConversationSnapshot.todos` 渲染计划横条与专属工具行([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md))。
|
||||
|
||||
## 会话投影
|
||||
|
||||
当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在一个注入式子插件下注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表(last-wins;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 1。key 在本包合并进 `SessionProjectionMap`(经接口包的 `/types` 出口);框架驱动该单元,载体在历史尾页与 `session/projection` 推送帧上供给该值。未装注册表的组合不受影响。
|
||||
|
||||
## 导出形状
|
||||
|
||||
函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会通过 Loader 的 `unwrapExports` 折叠模块并丢弃 `inject`(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。
|
||||
|
||||
@@ -15,21 +15,30 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client.d.ts",
|
||||
"default": "./lib/types/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"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 +46,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Client-namespace projection of the todo domain: a pure re-export of the package's
|
||||
* types outlet. Client code imports ONLY the client namespace (repo
|
||||
* discipline), so `./client` projects the same single-source content
|
||||
* `./types` serves to host consumers — zero duplication.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-todo/client
|
||||
*/
|
||||
|
||||
export type * from './types.ts'
|
||||
@@ -6,8 +6,17 @@
|
||||
*/
|
||||
|
||||
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'
|
||||
// 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);
|
||||
// this re-export projects the type face onto the package root AND keeps the
|
||||
// module edge in the emitted index.d.ts, so aggregate programs consuming the
|
||||
// declarations still receive the SessionProjectionMap merge.
|
||||
export type * from './types.ts'
|
||||
|
||||
export const name = 'tool-todo'
|
||||
export const inject = ['tools']
|
||||
@@ -54,8 +63,31 @@ 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(),
|
||||
])
|
||||
|
||||
/** 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 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<'todos', TodoItem[] | null>({
|
||||
key: 'todos',
|
||||
schema: todosProjectionSchema,
|
||||
init: () => null,
|
||||
apply: (state, event) => (event.type === 'todo/write' ? event.data.todos : state),
|
||||
view: state => state,
|
||||
stateVersion: 1,
|
||||
})
|
||||
})
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'todo_write',
|
||||
description: DESCRIPTION,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pure types of the todo domain: the ONE home of the `todos` projection-key
|
||||
* declaration plus its payload types, free of this package's host-side value
|
||||
* imports (dsh-tools, zod). Two namespace projections serve it — `./types`
|
||||
* for host consumers, `./client/types` (the browser half-entry's re-export)
|
||||
* for client aggregates — with zero content duplication.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-todo/types
|
||||
*/
|
||||
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
export type { TodoItem } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 (= 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.
|
||||
*/
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 - 1)
|
||||
})
|
||||
|
||||
it('serves the latest whole list after writes, asOfSeq = last event 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 - 1)
|
||||
})
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user