feat(tool-todo): add the model-facing todo_write tool

Add @deepseek-ai/dsh-tool-todo (a new packages/todo/ group): a model-facing
todo_write(todos: [{content, status}]) tool with whole-list-replace semantics.
Each call appends the full list as a todo/write event to the calling agent's
session log; the current list is the most recent such event (last-write-wins).
Single-owner — a non-agent caller is rejected. Beyond the schema's
type/required/enum checks, execute rejects empty/duplicate content and more than
one in_progress task, narrowing the loosely-typed args into a real TodoItem[].

Both UIs render off the existing session/event: the stdio UI prints a glyphed
checklist; the ACP bridge maps the list to a `plan` sessionUpdate (todosToPlan
synthesizes the priority ACP requires; status maps 1:1). Wired into the
coding-agent, acp-agent, and snapshot example configs with a system-prompt nudge.

Tests: unit (schema, validation, append/replace, no-agent rejection, presentCall,
HMR-safety, Loader export-shape guard), full-loop integration through the agent
loop, the ACP todosToPlan mapping + stream-update arm, the stdio render arm, and
a session/load replay that re-emits the plan. New-group TS wiring added to
tsconfig.base/json/build. RFC + a doc-inventory sweep (architecture, packages
README, AGENTS layout, cookbook group list, example READMEs) ship with it.

The todo-plan ACP snapshot scenario is recorded separately (needs an API key).
This commit is contained in:
Tianyi Cui
2026-06-29 10:30:52 +08:00
parent 4f09157612
commit 46e31d8481
31 changed files with 765 additions and 10 deletions

View File

@@ -44,6 +44,7 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -53,6 +53,8 @@ import {
type LoadSessionResponse,
type NewSessionRequest,
type NewSessionResponse,
type Plan,
type PlanEntry,
type PromptRequest,
type PromptResponse,
type SessionNotification,
@@ -64,7 +66,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools'
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
@@ -873,6 +875,10 @@ export function streamSessionEventUpdate(
})
return
}
case 'todo/write': {
notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } })
return
}
// turn/step boundaries, context/message, steering,
// assistant/message — no direct ACP client update.
default:
@@ -880,6 +886,18 @@ export function streamSessionEventUpdate(
}
}
/**
* Map a harness todo list to an ACP `plan` body. ACP's `PlanEntry` requires
* `content` + `priority` + `status`, but a {@link TodoItem} carries no priority,
* so synthesize a constant `'medium'` on every entry; `status` maps 1:1 (the
* harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole
* plan on each `plan` update, matching the harness's whole-list-replace
* semantics, so no per-entry diffing is needed.
*/
export function todosToPlan(todos: TodoItem[]): Plan {
return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
}
/**
* Per-connection terminal-rendering context threaded into
* {@link streamSessionEventUpdate}: whether the client advertised the

View File

@@ -20,6 +20,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import {
ClientSideConnection,
ndJsonStream,
@@ -158,6 +159,12 @@ export async function makeBridgeHarness(options: {
* implementation over a mock in tests").
*/
withBash?: boolean
/**
* Plug the REAL `dsh-tool-todo` tool so a test can drive `todo_write` through
* the bridge and assert the resulting `plan` sessionUpdate — the shipping
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
withTodo?: boolean
} = { storageDir: '' }): Promise<BridgeHarness> {
const adapter = new MockAdapter(options.script ?? [])
@@ -173,6 +180,9 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash)
}
if (options.withTodo) {
await ctx.plugin(ToolTodo)
}
ctx.llm.registerAdapter(['mock'], adapter)
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the

View File

@@ -94,6 +94,44 @@ describe('acp bridge — session/load replay', () => {
expect(content[0]?.content.text).toBe('```console\nhello\n```')
})
it('replays a persisted todo/write as a plan sessionUpdate on load', async () => {
// A turn whose model called todo_write persists a todo/write event. A fresh
// bridge loading the session must re-emit the ACP `plan` update from the log
// (the load replay runs every event through streamSessionEventUpdate), so an
// editor reopening the session sees the current plan.
live = await makeBridgeHarness({
storageDir,
withTodo: true,
script: [
toolCallResponse('c1', 'todo_write', {
todos: [
{ content: 'first step', status: 'in_progress' },
{ content: 'second step', status: 'pending' },
],
}),
textResponse('planned'),
],
})
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'plan it' }] })
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, withTodo: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const plan = loader.updates.find(u => u.sessionUpdate === 'plan')
expect(plan).toEqual({
sessionUpdate: 'plan',
entries: [
{ content: 'first step', priority: 'medium', status: 'in_progress' },
{ content: 'second step', priority: 'medium', status: 'pending' },
],
})
})
it('replays a persisted bash call as a TERMINAL card when the loader advertises the capability', async () => {
// The presentation is resolved at replay time, so a loader that advertised
// _meta.terminal_output must reconstruct the terminal card (content + _meta)

View File

@@ -3,7 +3,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionNotification } from '@agentclientprotocol/sdk'
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts'
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
/** Collect the updates a single event produces (no presenter → generic fallback). */
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
@@ -118,6 +118,43 @@ describe('streamSessionEventUpdate', () => {
expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'completed' } }))).toEqual([])
expect(updatesFor(evt('step/start', { turn: 1, step: 1 }))).toEqual([])
})
it('maps todo/write to a plan sessionUpdate with priority synthesized as medium', () => {
expect(updatesFor(evt('todo/write', {
todos: [
{ content: 'plan the work', status: 'in_progress' },
{ content: 'write the code', status: 'pending' },
{ content: 'run the tests', status: 'completed' },
],
}))).toEqual([{
sessionUpdate: 'plan',
entries: [
{ content: 'plan the work', priority: 'medium', status: 'in_progress' },
{ content: 'write the code', priority: 'medium', status: 'pending' },
{ content: 'run the tests', priority: 'medium', status: 'completed' },
],
}])
})
it('maps an empty todo list to a plan with no entries', () => {
expect(updatesFor(evt('todo/write', { todos: [] }))).toEqual([{ sessionUpdate: 'plan', entries: [] }])
})
})
describe('todosToPlan', () => {
it('maps status 1:1 and stamps every entry priority medium', () => {
expect(todosToPlan([
{ content: 'a', status: 'pending' },
{ content: 'b', status: 'in_progress' },
{ content: 'c', status: 'completed' },
])).toEqual({
entries: [
{ content: 'a', priority: 'medium', status: 'pending' },
{ content: 'b', priority: 'medium', status: 'in_progress' },
{ content: 'c', priority: 'medium', status: 'completed' },
],
})
})
})
describe('ToolPresenter (tool-owned presentation via the tool registry)', () => {