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:
@@ -73,6 +73,10 @@ packages/ Harness packages, grouped by role at packages/<group>/<pkg>/.
|
||||
bash/ abstract bash executor seam (ctx.bash) — interface only
|
||||
bash-local/ local-subprocess BashExecutor implementation
|
||||
tool-bash/ model-facing bash/bash_output/bash_kill tool schemas
|
||||
todo/ todo/planning capability family
|
||||
tool-todo/ model-facing todo_write tool: writes the whole task list to
|
||||
the session log (todo/write), rendered as a stdio checklist /
|
||||
ACP plan
|
||||
session-persistence/ persistence capability family
|
||||
session-persistence/ durable persistence seam + write coordinator
|
||||
session-persistence-jsonl/ JSONL-sidecar backend
|
||||
|
||||
@@ -196,7 +196,7 @@ Every MVP feature (including the TODO-marked ones), with the mechanism that impl
|
||||
| System prompt configurability | `ctx.systemPrompt.section()` with ordering |
|
||||
| AGENTS.md (root) | a section provider reading the file |
|
||||
| AGENTS.md (subdir, on-touch) + file-change notices | `agent.inject()` from a watcher / tool-result listener |
|
||||
| Built-in tools (Read/Write/Edit/Bash/…) | `ctx.tools.register()`; schemas flow into the assembly automatically. **Bash: implemented** — `dsh-bash` (seam) + `dsh-bash-local` (subprocesses) + `dsh-tool-bash` (`bash`/`bash_output`/`bash_kill`, incl. background tasks) |
|
||||
| Built-in tools (Read/Write/Edit/Bash/…) | `ctx.tools.register()`; schemas flow into the assembly automatically. **Bash: implemented** — `dsh-bash` (seam) + `dsh-bash-local` (subprocesses) + `dsh-tool-bash` (`bash`/`bash_output`/`bash_kill`, incl. background tasks). **`todo_write`: implemented** — `dsh-tool-todo` writes the whole task list to the session log (`todo/write`), rendered as a stdio checklist / ACP `plan` |
|
||||
| ToolSearch / progressive disclosure | wrap `agent/request`, filter `req.tools` |
|
||||
| Tool sandbox (landlock / sandbox-exec) | wrap `tools/execute`, or implement a sandboxing `BashExecutor` (the dsh-bash seam) |
|
||||
| Permission system / AskUserQuestion | wrap `tools/execute` (veto or ask); register an ask tool |
|
||||
|
||||
@@ -16,7 +16,7 @@ packages/<group>/<pkg>/
|
||||
README.md # service API, events, extension points, design notes
|
||||
```
|
||||
|
||||
Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it.
|
||||
Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `compact`, `subagent`, `todo`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it.
|
||||
|
||||
package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ interface SessionEventMap {
|
||||
|
||||
### `TodoItem` — one todo-list entry
|
||||
|
||||
The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal — a `content` line and a three-state `status` (no id, priority, or `activeForm`): the list is replaced wholesale on every write, so entries need no stable identity, and the status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally requires).
|
||||
The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal — a `content` line and a three-state `status` (no id, priority, or `activeForm`): the list is replaced wholesale on every write, so entries need no stable identity, and the status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally requires). See the [todo_write RFC](../rfc/implemented/feature/2026-06-29-todo-write-tool.md).
|
||||
|
||||
```ts type-equiv
|
||||
export interface TodoItem {
|
||||
|
||||
@@ -54,6 +54,9 @@ graph TD
|
||||
tool-bash --> bash
|
||||
tool-bash --> llm
|
||||
tool-bash --> tools
|
||||
tool-todo --> agent
|
||||
tool-todo --> session
|
||||
tool-todo --> tools
|
||||
agent-core --> agent
|
||||
agent-core --> agent-loop
|
||||
agent-core --> invariants
|
||||
@@ -115,6 +118,7 @@ graph TD
|
||||
| `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` |
|
||||
| `subagent` | `agent`, `llm`, `tools` |
|
||||
| `tool-bash` | `agent`, `bash`, `llm`, `tools` |
|
||||
| `tool-todo` | `agent`, `session`, `tools` |
|
||||
| `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` |
|
||||
| `subagent-acp` | `agent`, `llm`, `subagent` |
|
||||
| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` |
|
||||
|
||||
@@ -85,6 +85,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 |
|
||||
| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
|
||||
| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
|
||||
| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
|
||||
|
||||
### Simplification
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# RFC: The `todo_write` tool — model task list as event-sourced session state
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The harness gives the model bash and subagent tools but no way to record a structured task list. A todo list serves two co-equal purposes: it steers the model to plan multi-step work and keep exactly one task active (anti-drift on long tasks), and it gives the human a live progress checklist. The ACP protocol has a native `plan` sessionUpdate that editors (Zed) already render, but the bridge never emitted one. Every reference coding agent surveyed (claude-code, opencode, codex, oh-my-pi, pi) ships some form of this; the harness had nothing.
|
||||
|
||||
## Decision
|
||||
|
||||
Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Both the stdio UI and the ACP bridge render off the existing `session/event` — the ACP bridge maps the list to a `plan` sessionUpdate.
|
||||
|
||||
### Whole-list replace, three-state status
|
||||
|
||||
The model sends the ENTIRE list every call; the new list replaces the old (last-write-wins on replay). This is the shape claude-code V1, opencode, and codex `update_plan` all use, and the shape the model is most trained on — no per-item ids, no delta protocol. `status` is exactly `pending | in_progress | completed`: the same triple as codex `update_plan` and, crucially, **identical to the ACP `PlanEntryStatus`**, so the bridge maps it 1:1 with no lossy translation.
|
||||
|
||||
### State on the session log, not a service
|
||||
|
||||
The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and `session/load` reconstruction for free: a reopened session re-derives the current list (the last `todo/write`) and the ACP bridge re-emits the `plan` on load, with no separate persistence backend, no in-memory service to rehydrate, and no extra wiring. An in-memory `ctx.todos` service would have had to reinvent all of that.
|
||||
|
||||
### NOT a surface event
|
||||
|
||||
`todo/write` is deliberately excluded from `SurfaceEventType`. The surface is the projection that produces the LLM message history (`deriveMessages()`); a todo write produces no conversation message. So it carries no `surfaceOp`, never joins the surface linked list, and never reaches `deriveMessages()` — it is durable, replayable *UI* state that travels alongside the conversation without being part of it. (The dev-mode invariants still require it to sit inside an open turn, which it always does: it is appended mid-step during a tool call.)
|
||||
|
||||
### Priority synthesized only at the ACP boundary
|
||||
|
||||
ACP's `PlanEntry` requires `content` + `priority` + `status`, but a `TodoItem` has no priority — the model never reasons about it. Rather than burden the schema with a field the model must always supply, the bridge synthesizes a constant `priority: 'medium'` on every entry when it builds the `plan`. Priority is an ACP wire requirement, not a harness concept, so it lives at exactly the boundary that needs it.
|
||||
|
||||
### Dropped vs claude-code V1: `activeForm`, id, priority
|
||||
|
||||
claude-code V1's item is `{ content, status, activeForm }`; later (V2) it grew ids, dependencies, and ownership — but only to support agent *swarms* (disk-backed, lock-guarded, per-item mutation). This tool keeps the item at the minimum: `{ content, status }`. No `activeForm` (the present-continuous label) — the UI shows `content`; no id — whole-list replace needs no stable identity; no priority — see above. Each dropped field is one less thing the model must produce on every call.
|
||||
|
||||
### Single owner — no swarm machinery (YAGNI)
|
||||
|
||||
The list belongs to the ONE agent session that called the tool (`exec.agent.session`); a non-agent caller is rejected. There is deliberately no shared/multi-owner scope, no capability seam (interface/impl/consumer), no scope resolver, and no delta protocol. The harness does have subagents, and a shared cross-agent list is conceivable — but building that now means designing for a form the product does not yet have. The whole-list-replace + single-owner shape is what claude-code V1, opencode, and codex all ship; if a shared list is ever needed, the on-log representation would change to per-item deltas (so concurrent writers can't clobber each other) and a scope resolver would choose the target log. That is a future RFC, not speculative scaffolding today.
|
||||
|
||||
### Validation: the cheap middle
|
||||
|
||||
The schema enforces type/required/enum. Beyond that, `execute` rejects empty or duplicate `content` and more than one `in_progress` task. claude-code leaves single-in-progress to the prompt; oh-my-pi enforces it in code. We take the middle: enforce the cheap invariants that make a plan *coherent* (no blank tasks, no dupes, at most one active), but leave ordering and the discipline of keeping the list current to the model via the tool description. A rejected write returns an `isError` result so the model self-corrects.
|
||||
|
||||
## Why no cordis-catalog entry / no `@mode`
|
||||
|
||||
`todo/write` is a member of `SessionEventMap`, not a first-class cordis `interface Events` event. The catalog generator (`scripts/gen-cordis-catalog.ts`) scans `interface Events` declarations; a `SessionEventMap` variant rides the existing `session/event` emit and produces no new catalog row. So it carries no `@mode` tag (which the generator requires only on `interface Events` members) — adding one would be meaningless.
|
||||
|
||||
## Testing
|
||||
|
||||
Four tiers, designed up front:
|
||||
- **Unit** — the session event (append/snapshot-clone/last-write-wins/not-on-surface); the tool (schema shape, arg validation via the real `ctx.tools.execute`, value validation, the event append + replacement, no-agent rejection, `presentCall`, HMR-safety); the ACP `todosToPlan` mapping; the stdio render arm.
|
||||
- **Real-Loader path** — the plugin run through `Loader.unwrapExports`, asserting the namespace export shape survives (it HAS `inject`, so a stray default would crash at load — postmortem/0001).
|
||||
- **Full-loop integration** — a scripted mock model calls `todo_write` through the real agent loop; the `todo/write` event lands and a second call replaces it.
|
||||
- **`session/load` replay** — a persisted `todo/write` re-emits the `plan` update when a fresh ACP bridge loads the session.
|
||||
- **With-key e2e + snapshot** — a real prompt induces a `todo_write`; the snapshot golden gains the `plan` notification and the log event.
|
||||
|
||||
## Alternatives rejected
|
||||
|
||||
- **In-memory `ctx.todos` service** — would reinvent durability, replay, and `session/load` reconstruction the log gives for free.
|
||||
- **Per-item delta protocol** — only needed for a shared multi-owner list, which is out of scope; whole-list replace is simpler and matches the references.
|
||||
- **Tool in `core/`** — `todo_write` is an extension tool registering on `ctx.tools`, not part of the spine; it lives in its own `packages/todo/` group like other tool families.
|
||||
+1
-1
@@ -15,7 +15,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo <something>" to trigge
|
||||
|
||||
## coding-agent
|
||||
|
||||
The real thing: DeepSeek V4 + the bash tool suite on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant.
|
||||
The real thing: DeepSeek V4 + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-agent` app. Where echo-agent proves the skeleton with mocks, this is a usable coding assistant.
|
||||
|
||||
Run with: `pnpm run demo:coding` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details.
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
a fresh child agent (it works in its own context and returns only its
|
||||
final result) — give it a complete, standalone instruction.
|
||||
|
||||
For multi-step work, use the todo_write tool to track a task list:
|
||||
send the WHOLE list each call (it replaces the previous one), keep
|
||||
exactly one task in_progress, and mark a task completed as soon as it
|
||||
is done. Skip it for trivial single-step tasks.
|
||||
|
||||
# The subagent seam + both in-process backends + two model-facing tools —
|
||||
# identical to cordis.yml's wiring (only the LLM backend differs above): spawn
|
||||
# and fork are each reachable via a dsh-tool-subagent bound to it with a distinct
|
||||
@@ -70,3 +75,8 @@
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
|
||||
# The model-facing todo_write tool — identical to cordis.yml's wiring, so a
|
||||
# replayed todo_write tool call resolves to a real tool during snapshot replay.
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
@@ -51,6 +51,11 @@
|
||||
a fresh child agent (it works in its own context and returns only its
|
||||
final result) — give it a complete, standalone instruction.
|
||||
|
||||
For multi-step work, use the todo_write tool to track a task list:
|
||||
send the WHOLE list each call (it replaces the previous one), keep
|
||||
exactly one task in_progress, and mark a task completed as soon as it
|
||||
is done. Skip it for trivial single-step tasks.
|
||||
|
||||
# The subagent seam + both in-process backends + two model-facing tools, as leaf
|
||||
# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh
|
||||
# child) and fork (a child seeded with the parent's completed-turn prefix) are
|
||||
@@ -81,3 +86,8 @@
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
|
||||
# The model-facing todo_write tool: whole-list task tracking written to the
|
||||
# session log (todo/write), surfaced to the ACP client as a `plan` update.
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
@@ -12,7 +12,7 @@ The first REAL agent wiring: DeepSeek V4 + the bash tool suite + stdio chat
|
||||
pnpm run demo:coding
|
||||
```
|
||||
|
||||
Type a coding task. The agent's only tools are `bash` (+ `bash_output` / `bash_kill` for background tasks): file reads, writes, searches, and test runs all happen through shell commands, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Reasoning streams dimmed; tool calls/results render inline.
|
||||
Type a coding task. The agent works through `bash` (+ `bash_output` / `bash_kill` for background tasks): file reads, writes, searches, and test runs all happen through shell commands, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline.
|
||||
|
||||
```
|
||||
> fix the failing test in /path/to/project
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
# under ./.sessions); unset starts a fresh session each run.
|
||||
resumeSessionId: !!js process.env.RESUME_SESSION_ID
|
||||
persistenceRoot: './.sessions'
|
||||
welcome: 'coding-agent ready. Give it a coding task (its tools are bash and subagent).'
|
||||
welcome: 'coding-agent ready. Give it a coding task (its tools are bash, subagent, and todo_write).'
|
||||
systemPrompt: |
|
||||
You are coding-agent, a CLI coding assistant.
|
||||
|
||||
@@ -65,6 +65,11 @@
|
||||
failures before moving on. Verify your work by running the code or
|
||||
tests. Keep answers brief and factual.
|
||||
|
||||
For multi-step work, use the todo_write tool to track a task list:
|
||||
send the WHOLE list each call (it replaces the previous one), keep
|
||||
exactly one task in_progress, and mark a task completed as soon as it
|
||||
is done. Skip it for trivial single-step tasks.
|
||||
|
||||
# The subagent seam + BOTH in-process backends + two model-facing tools, as leaf
|
||||
# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh
|
||||
# child) and fork (a child seeded with the parent's completed-turn prefix) are
|
||||
@@ -96,3 +101,8 @@
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
|
||||
# The model-facing todo_write tool: whole-list task tracking written to the
|
||||
# session log (todo/write), rendered as a stdio checklist / ACP plan.
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
@@ -13,6 +13,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam (backend + tool deferred) | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
|
||||
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
|
||||
@@ -46,6 +47,7 @@ dsh-subagent-spawn ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (in-proces
|
||||
dsh-subagent-fork ← dsh-subagent-spawn, dsh-agent, dsh-session (in-process child seeded from parent log)
|
||||
dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk (out-of-process child over ACP)
|
||||
dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool)
|
||||
dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log)
|
||||
dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin)
|
||||
dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin)
|
||||
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin)
|
||||
@@ -85,6 +87,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
|
||||
| `subagent-acp/` | `subagent` | Out-of-process backend: a child agent in a spawned subprocess, driven over the Agent Client Protocol | (registers on `ctx.subagents`) |
|
||||
| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) |
|
||||
| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
|
||||
| `tool-todo/` | `todo` | Model-facing `todo_write` tool; writes the whole task list to the session log (`todo/write`) | (registers on `ctx.tools`) |
|
||||
| `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
|
||||
|
||||
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
|
||||
|
||||
@@ -110,6 +110,13 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
const { content } = event.data
|
||||
const text = content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
output.write(`\n [tool result] ${text}\n `)
|
||||
} else if (event.type === 'todo/write') {
|
||||
if (inReasoning) output.write('\x1B[0m')
|
||||
inReasoning = false
|
||||
const glyph = (status: string): string =>
|
||||
status === 'completed' ? '[x]' : status === 'in_progress' ? '[~]' : '[ ]'
|
||||
const lines = event.data.todos.map(todo => ` ${glyph(todo.status)} ${todo.content}`).join('\n')
|
||||
output.write(`\n [todos]\n${lines}\n `)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -151,6 +151,35 @@ describe('createStdioChat rendering', () => {
|
||||
expect(out.text()).toContain('[tool result] file.txt')
|
||||
})
|
||||
|
||||
it('renders a todo/write session event as a glyphed checklist', async () => {
|
||||
const { ctx, out } = await setup()
|
||||
const session = {} as Session
|
||||
ctx.emit('session/event', session, {
|
||||
type: 'todo/write', seq: 1, time: 0,
|
||||
data: { todos: [
|
||||
{ content: 'read the code', status: 'completed' },
|
||||
{ content: 'write the fix', status: 'in_progress' },
|
||||
{ content: 'run the tests', status: 'pending' },
|
||||
] },
|
||||
} as SessionEvent)
|
||||
const text = out.text()
|
||||
expect(text).toContain('[todos]')
|
||||
expect(text).toContain('[x] read the code')
|
||||
expect(text).toContain('[~] write the fix')
|
||||
expect(text).toContain('[ ] run the tests')
|
||||
})
|
||||
|
||||
it('resets dim styling when a todo/write interrupts reasoning', async () => {
|
||||
const { ctx, out } = await setup()
|
||||
const agent = makeAgent('main')
|
||||
ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'r' })
|
||||
ctx.emit('session/event', {} as Session, {
|
||||
type: 'todo/write', seq: 1, time: 0,
|
||||
data: { todos: [{ content: 'a task', status: 'pending' }] },
|
||||
} as SessionEvent)
|
||||
expect(out.text()).toContain('\x1B[2mr\x1B[0m')
|
||||
})
|
||||
|
||||
it('resets dim styling when a tool/call interrupts reasoning', async () => {
|
||||
const { ctx, out } = await setup()
|
||||
const agent = makeAgent('main')
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# todo/ — todo / planning capability family
|
||||
|
||||
The model-facing todo tool. A single **product** package — there is no interface/implementation seam here, because the list is single-owner session state (one agent session owns its own list), not a swappable capability.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
|
||||
|
||||
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio UI](../support/ui-stdio) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
|
||||
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-tool-todo
|
||||
|
||||
The model-facing `todo_write` tool: the agent's whole task list, replaced wholesale on each call.
|
||||
|
||||
## What it does
|
||||
|
||||
Registers one tool, `todo_write(todos: [{ content, status }])`, on `ctx.tools`. The model sends the ENTIRE list every call — there are no partial updates or per-item edits. Each call appends a `todo/write` event (the full list snapshot) to the calling agent's session log via `agent.session.append('todo/write', { todos })`; the current list is the most recent such event (last-write-wins on replay).
|
||||
|
||||
`status` is one of `pending`, `in_progress`, `completed` — exactly the ACP `PlanEntryStatus` triple.
|
||||
|
||||
## Single owner
|
||||
|
||||
The list belongs to the ONE agent session that called the tool. There is no subagent/shared/swarm scope: a non-agent caller (no `exec.agent`) has nowhere to write the list and is rejected. This is a deliberate scope limit — see the RFC.
|
||||
|
||||
## Validation
|
||||
|
||||
Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content` and more than one `in_progress` task (a coherent plan has at most one task active). Ordering and the discipline of keeping the list current are left to the model via the tool description.
|
||||
|
||||
## Rendering
|
||||
|
||||
The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio UI](../../support/ui-stdio) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
|
||||
|
||||
## 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)).
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-todo",
|
||||
"description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* The model-facing `todo_write` tool: the agent's whole task list, replaced
|
||||
* wholesale on each call. Every call appends a `todo/write` event (the full
|
||||
* list snapshot) to the calling agent's session log via
|
||||
* `exec.agent.session.append('todo/write', { todos })`; the current list is the
|
||||
* most recent such event (last-write-wins on replay). UIs render off
|
||||
* `session/event`: the stdio UI prints the checklist, the ACP bridge maps it to
|
||||
* a `plan` sessionUpdate.
|
||||
*
|
||||
* Single owner: the list belongs to the ONE agent session that called the tool.
|
||||
* There is no subagent/shared/swarm scope — a non-agent caller (no
|
||||
* `exec.agent`) has nowhere to write the list and is rejected.
|
||||
*
|
||||
* Plugin export shape: named exports, NO default. The cordis Loader's
|
||||
* `unwrapExports` does `exports.default ?? exports`, so a stray default would
|
||||
* collapse the module to the bare `apply` and drop `inject`, crashing at load
|
||||
* (see docs/postmortem/0001).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-todo
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
||||
|
||||
export const name = 'tool-todo'
|
||||
export const inject = ['tools']
|
||||
|
||||
/** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */
|
||||
const STATUSES = ['pending', 'in_progress', 'completed'] as const
|
||||
|
||||
const DESCRIPTION =
|
||||
'Record and update a structured task list for the current work. Send the ENTIRE '
|
||||
+ 'list every call — it REPLACES the previous list (there are no partial updates, '
|
||||
+ 'no per-item edits). Use it to plan multi-step work and show progress: add one '
|
||||
+ 'todo per concrete step before you start. Keep EXACTLY ONE todo `in_progress` at '
|
||||
+ 'a time, and mark a todo `completed` the moment it is done (do not batch '
|
||||
+ 'completions). Skip the list for trivial single-step tasks. Statuses: `pending` '
|
||||
+ '(not started), `in_progress` (being worked on now), `completed` (finished).'
|
||||
|
||||
/**
|
||||
* Validate the constraints the SchemaSpec can't express AND narrow the loosely
|
||||
* typed args into a real {@link TodoItem}[].
|
||||
*
|
||||
* `defineTool` already validates type/required/enum before `execute` runs, but
|
||||
* `InferArgs` maps an `enum` string prop to plain `string` (not the literal
|
||||
* union), so `args.todos` arrives as `{ content: string; status: string }[]` —
|
||||
* not assignable to `TodoItem[]`. This pass is therefore the type boundary: it
|
||||
* re-checks each `status` against the literal set (belt-and-suspenders for the
|
||||
* compiler, which can't see the registry's prior validation) and builds a fresh
|
||||
* `TodoItem[]`. It also enforces the value rules the DSL has no vocabulary for:
|
||||
* non-empty unique content, and at most one `in_progress` task.
|
||||
*/
|
||||
function toTodoList(raw: { content: string; status: string }[]): TodoItem[] {
|
||||
const todos: TodoItem[] = []
|
||||
const seen = new Set<string>()
|
||||
let inProgress = 0
|
||||
for (const item of raw) {
|
||||
const content = item.content.trim()
|
||||
if (content.length === 0) {
|
||||
throw new Error('invalid todo: `content` must be a non-empty string')
|
||||
}
|
||||
if (seen.has(content)) {
|
||||
throw new Error(`invalid todos: duplicate content ${JSON.stringify(content)}`)
|
||||
}
|
||||
seen.add(content)
|
||||
const status = item.status
|
||||
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') {
|
||||
throw new Error(`invalid todo status ${JSON.stringify(status)}: expected one of ${STATUSES.join(', ')}`)
|
||||
}
|
||||
if (status === 'in_progress') inProgress++
|
||||
todos.push({ content: item.content, status })
|
||||
}
|
||||
if (inProgress > 1) {
|
||||
throw new Error(`invalid todos: at most one task may be in_progress, got ${inProgress}`)
|
||||
}
|
||||
return todos
|
||||
}
|
||||
|
||||
/** Register the `todo_write` tool on `ctx.tools`. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'todo_write',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
todos: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
description: 'The COMPLETE task list, replacing any previous list.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' },
|
||||
status: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: [...STATUSES],
|
||||
description: 'pending (not started) | in_progress (now) | completed (done).',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
execute(args, exec): Promise<ContentBlock[]> {
|
||||
const todos = toTodoList(args.todos)
|
||||
if (!exec.agent) {
|
||||
// The list is per-agent-session state; a non-agent caller (no owning
|
||||
// session) has nowhere to write it. Reject rather than silently no-op.
|
||||
throw new Error('todo_write requires an owning agent session')
|
||||
}
|
||||
exec.agent.session.append('todo/write', { todos })
|
||||
const count = (status: TodoItem['status']): number => todos.filter(t => t.status === status).length
|
||||
return Promise.resolve([{
|
||||
type: 'text',
|
||||
text: `Updated todo list: ${count('pending')} pending, ${count('in_progress')} in progress, ${count('completed')} completed.`,
|
||||
}])
|
||||
},
|
||||
presentCall: args => ({ title: 'Update todo list', kind: 'other', rawInput: args.todos }),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Full-loop integration: a scripted mock model drives the REAL todo_write tool
|
||||
* through the agent loop, exercising the same seams a live model would — the
|
||||
* tool/call + tool/result session events AND the todo/write event the tool
|
||||
* appends. Only the model is mocked; the tool and the session log are real.
|
||||
*/
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(ToolTodo)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function findEvent<T extends SessionEvent['type']>(
|
||||
log: readonly SessionEvent[],
|
||||
type: T,
|
||||
position: 'first' | 'last' = 'first',
|
||||
): Extract<SessionEvent, { type: T }> {
|
||||
const found = position === 'first'
|
||||
? log.find(event => event.type === type)
|
||||
: log.findLast(event => event.type === type)
|
||||
if (!found) throw new Error(`no ${type} event in the session log`)
|
||||
return found as Extract<SessionEvent, { type: T }>
|
||||
}
|
||||
|
||||
describe('todo_write tool through the agent loop', () => {
|
||||
it('model calls todo_write: a tool/call, a non-error tool/result, and a todo/write snapshot land', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'todo_write', {
|
||||
todos: [
|
||||
{ content: 'read the code', status: 'in_progress' },
|
||||
{ content: 'write the fix', status: 'pending' },
|
||||
],
|
||||
}, 'Planning the work.'),
|
||||
textResponse('Plan recorded.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-todo'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'plan a two-step task' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = agent.session.events
|
||||
expect(findEvent(log, 'tool/call').data.name).toBe('todo_write')
|
||||
expect(findEvent(log, 'tool/result').data.isError).toBe(false)
|
||||
|
||||
const todoEvent = findEvent(log, 'todo/write')
|
||||
expect(todoEvent.data.todos).toEqual([
|
||||
{ content: 'read the code', status: 'in_progress' },
|
||||
{ content: 'write the fix', status: 'pending' },
|
||||
])
|
||||
})
|
||||
|
||||
it('a second todo_write replaces the list (last-write-wins on the log)', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('call-1', 'todo_write', { todos: [{ content: 'step one', status: 'in_progress' }] }),
|
||||
toolCallResponse('call-2', 'todo_write', {
|
||||
todos: [
|
||||
{ content: 'step one', status: 'completed' },
|
||||
{ content: 'step two', status: 'in_progress' },
|
||||
],
|
||||
}),
|
||||
textResponse('Done planning.'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('it-todo-2'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'plan then update' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const todoEvents = agent.session.events.filter(e => e.type === 'todo/write')
|
||||
expect(todoEvents).toHaveLength(2)
|
||||
expect(findEvent(agent.session.events, 'todo/write', 'last').data.todos).toEqual([
|
||||
{ content: 'step one', status: 'completed' },
|
||||
{ content: 'step two', status: 'in_progress' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import * as tool from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Drives the REAL plugin body: mounts `dsh-tool-todo` on a real `ToolRegistry`
|
||||
* and invokes the registered `todo_write` tool through `ctx.tools.execute`,
|
||||
* with a fake parent Agent carrying a real `Session` — so the append the tool
|
||||
* makes is observable on a genuine session log (only the agent wrapper is a
|
||||
* stand-in; the session and the tool are the shipping code).
|
||||
*/
|
||||
|
||||
/** A parent Agent backed by a real Session — the tool reads `agent.session`. */
|
||||
function agentWithSession(id = 'parent-1'): Agent & { session: Session } {
|
||||
const session = new Session(SessionId(id))
|
||||
return { id: AgentId(id), session } as unknown as Agent & { session: Session }
|
||||
}
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(tool)
|
||||
return ctx
|
||||
}
|
||||
|
||||
let callCounter = 0
|
||||
function callTodo(ctx: Context, args: unknown, over: { agent?: Agent | undefined } = {}) {
|
||||
const agent = 'agent' in over ? over.agent : agentWithSession()
|
||||
return ctx.tools.execute({
|
||||
callId: CallId(`call-${++callCounter}`),
|
||||
name: 'todo_write',
|
||||
arguments: args,
|
||||
...agent ? { agent } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('dsh-tool-todo', () => {
|
||||
it('registers a `todo_write` tool whose schema is an array of {content,status}', async () => {
|
||||
const ctx = await setup()
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'todo_write')
|
||||
expect(schema).toBeDefined()
|
||||
const props = (schema!.parameters as { properties?: Record<string, unknown> }).properties ?? {}
|
||||
expect(Object.keys(props)).toEqual(['todos'])
|
||||
const todos = props.todos as { type: string; items?: { properties?: Record<string, { type: string; enum?: string[] }> } }
|
||||
expect(todos.type).toBe('array')
|
||||
const itemProps = todos.items?.properties ?? {}
|
||||
expect(Object.keys(itemProps).sort()).toEqual(['content', 'status'])
|
||||
expect(itemProps.status?.enum).toEqual(['pending', 'in_progress', 'completed'])
|
||||
})
|
||||
|
||||
it('appends a todo/write event carrying the whole list to the calling session', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = agentWithSession('writer')
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'plan', status: 'in_progress' },
|
||||
{ content: 'build', status: 'pending' },
|
||||
]
|
||||
const result = await callTodo(ctx, { todos }, { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('1 pending, 1 in progress, 0 completed')
|
||||
|
||||
const event = agent.session.events.findLast(e => e.type === 'todo/write')!
|
||||
expect(event.data.todos).toEqual(todos)
|
||||
})
|
||||
|
||||
it('replaces the list on a second call (last-write-wins on the log)', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = agentWithSession('writer-2')
|
||||
await callTodo(ctx, { todos: [{ content: 'a', status: 'pending' }] }, { agent })
|
||||
await callTodo(ctx, { todos: [
|
||||
{ content: 'a', status: 'completed' },
|
||||
{ content: 'b', status: 'in_progress' },
|
||||
] }, { agent })
|
||||
|
||||
const current = agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos
|
||||
expect(current).toEqual([
|
||||
{ content: 'a', status: 'completed' },
|
||||
{ content: 'b', status: 'in_progress' },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a malformed status before execute runs (registry arg-validation)', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callTodo(ctx, { todos: [{ content: 'x', status: 'doing' }] })
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a non-array todos argument', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callTodo(ctx, { todos: 'nope' })
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ label: 'empty content', todos: [{ content: ' ', status: 'pending' }], fragment: 'non-empty' },
|
||||
{ label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' },
|
||||
{ label: 'two in_progress', todos: [{ content: 'a', status: 'in_progress' }, { content: 'b', status: 'in_progress' }], fragment: 'in_progress' },
|
||||
])('rejects $label as an isError result', async ({ todos, fragment }) => {
|
||||
const ctx = await setup()
|
||||
const result = await callTodo(ctx, { todos })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain(fragment)
|
||||
})
|
||||
|
||||
it('rejects a non-agent caller (the list has no owning session)', async () => {
|
||||
const ctx = await setup()
|
||||
const result = await callTodo(ctx, { todos: [{ content: 'a', status: 'pending' }] }, { agent: undefined })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('owning agent session')
|
||||
})
|
||||
|
||||
it('presents the call with a stable title and the list as raw input', async () => {
|
||||
const ctx = await setup()
|
||||
const def = ctx.tools.get('todo_write')!
|
||||
const todos = [{ content: 'a', status: 'pending' }]
|
||||
expect(def.presentCall?.({ todos })).toEqual({ title: 'Update todo list', kind: 'other', rawInput: todos })
|
||||
})
|
||||
|
||||
it('unregisters the tool when its contributing fiber is disposed (HMR-safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const fiber = await ctx.plugin(tool)
|
||||
expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(true)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(false)
|
||||
})
|
||||
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
|
||||
// Postmortem 0001 guard: this plugin HAS `inject = ['tools']`, so a stray
|
||||
// `export default apply` would collapse the module via `unwrapExports`
|
||||
// (`exports.default ?? exports`), DROP `inject`, and crash at load with
|
||||
// "cannot get property … without inject". Guard the shape directly.
|
||||
expect('default' in tool).toBe(false)
|
||||
expect(tool.name).toBe('tool-todo')
|
||||
expect(tool.inject).toEqual(['tools'])
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(tool) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tool)
|
||||
expect(unwrapped.name).toBe('tool-todo')
|
||||
expect(unwrapped.inject).toEqual(['tools'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Generated
+27
@@ -594,6 +594,30 @@ importers:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/todo/tool-todo:
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent
|
||||
'@deepseek-ai/dsh-agent-loop':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/agent-loop
|
||||
'@deepseek-ai/dsh-llm':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/llm
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
cordis:
|
||||
specifier: ^4.0.0-rc.6
|
||||
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
|
||||
|
||||
packages/ui/acp:
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk':
|
||||
@@ -633,6 +657,9 @@ importers:
|
||||
'@deepseek-ai/dsh-tool-bash':
|
||||
specifier: workspace:^
|
||||
version: link:../../bash/tool-bash
|
||||
'@deepseek-ai/dsh-tool-todo':
|
||||
specifier: workspace:^
|
||||
version: link:../../todo/tool-todo
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"./packages/bash/*/src",
|
||||
"./packages/compact/*/src",
|
||||
"./packages/subagent/*/src",
|
||||
"./packages/todo/*/src",
|
||||
"./packages/session-persistence/*/src",
|
||||
"./packages/ui/*/src",
|
||||
"./packages/util/*/src",
|
||||
|
||||
+2
-1
@@ -39,6 +39,7 @@
|
||||
{ "path": "./packages/subagent/subagent-inprocess" },
|
||||
{ "path": "./packages/subagent/subagent-spawn" },
|
||||
{ "path": "./packages/subagent/subagent-fork" },
|
||||
{ "path": "./packages/subagent/subagent-acp" }
|
||||
{ "path": "./packages/subagent/subagent-acp" },
|
||||
{ "path": "./packages/todo/tool-todo" }
|
||||
]
|
||||
}
|
||||
+2
-1
@@ -50,6 +50,7 @@
|
||||
{ "path": "./packages/subagent/subagent-inprocess" },
|
||||
{ "path": "./packages/subagent/subagent-spawn" },
|
||||
{ "path": "./packages/subagent/subagent-fork" },
|
||||
{ "path": "./packages/subagent/subagent-acp" }
|
||||
{ "path": "./packages/subagent/subagent-acp" },
|
||||
{ "path": "./packages/todo/tool-todo" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user