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:
Chinesezjc
2026-07-28 18:07:41 +08:00
136 changed files with 3687 additions and 574 deletions
+2 -2
View File
@@ -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
+4
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` 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)).
+4
View File
@@ -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))。
+12
View File
@@ -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"
}
}
+10
View File
@@ -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'
+33 -1
View File
@@ -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,
+24
View File
@@ -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)
})
})
+3
View File
@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../support/invariants"
}