Merge remote-tracking branch 'origin/master' into codex/project-instruction-files

This commit is contained in:
Yichen Jiang
2026-06-29 21:54:54 +08:00
42 changed files with 1166 additions and 20 deletions

View File

@@ -14,10 +14,11 @@ 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 |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, path helpers) | Support — small, stable, harness-dep-free |
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not have to treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and the hierarchy docs).
@@ -49,6 +50,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-project-instructions, 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)
@@ -89,6 +91,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) |
| `paths/` | `util` | Shared filesystem path constants and helpers for harness user data | (none) |

View File

@@ -149,6 +149,24 @@ export interface TurnEndReasonMap {
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
/**
* One entry in an agent's todo list — the unit of the `todo/write`
* {@link SessionEventMap} event's whole-list snapshot.
*
* Deliberately minimal: a human-readable `content` line and a three-state
* `status`. No id, priority, or `activeForm` — the list is replaced wholesale
* on every write (last-write-wins), 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).
*/
export interface TodoItem {
/** What this task is — a short imperative line shown in the UI. */
content: string
/** Lifecycle state. `in_progress` marks the single task being worked now. */
status: 'pending' | 'in_progress' | 'completed'
}
/**
* The session event vocabulary — the append-only source of truth for an
* agent's whole interaction history. The LLM message history is *derived*
@@ -194,6 +212,20 @@ export interface SessionEventMap {
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } }
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
/**
* The agent's whole todo list, carried as a full snapshot and replaced
* wholesale on each write — the current list is the most recent `todo/write`
* (last-write-wins on replay, no fold). Appended by an owning agent via
* `session.append('todo/write', { todos })`.
*
* NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches
* `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface —
* it is durable, replayable UI state, distinct from the conversation history.
* It is a `SessionEventMap` member riding the existing `session/event` emit,
* not a first-class Cordis `interface Events` notification, so it has no
* cordis-catalog row.
*/
'todo/write': { todos: TodoItem[] }
}
export type SessionEventType = keyof SessionEventMap

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventType } from '@deepseek-ai/dsh-session'
import type { SessionEventType, TodoItem } from '@deepseek-ai/dsh-session'
describe('Session', () => {
it('derives message history from the event log', () => {
@@ -365,3 +365,63 @@ describe('SessionStore', () => {
expect(events).toHaveLength(1)
})
})
describe('todo/write event', () => {
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
const session = new Session(SessionId('t1'))
const todos: TodoItem[] = [
{ content: 'plan the work', status: 'in_progress' },
{ content: 'write the code', status: 'pending' },
]
session.append('todo/write', { todos })
const event = session.events.findLast(e => e.type === 'todo/write')!
expect(event.type).toBe('todo/write')
expect(event.data.todos).toEqual(todos)
// The append snapshots its input: mutating the caller's array afterward must
// not change what the log holds (the durable-source-of-truth contract).
todos.push({ content: 'sneak in', status: 'pending' })
todos[0]!.status = 'completed'
expect(event.data.todos).toEqual([
{ content: 'plan the work', status: 'in_progress' },
{ content: 'write the code', status: 'pending' },
])
})
it('is last-write-wins: the current list is the most recent todo/write', () => {
const session = new Session(SessionId('t2'))
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
session.append('todo/write', { todos: [
{ content: 'first', status: 'completed' },
{ content: 'second', status: 'in_progress' },
] })
const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
expect(current).toEqual([
{ content: 'first', status: 'completed' },
{ content: 'second', status: 'in_progress' },
])
})
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
const session = new Session(SessionId('t3'))
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const before = session.deriveMessages().length
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
// The todo event must not add a message to the derived history…
expect(session.deriveMessages()).toHaveLength(before)
// …and must not appear on the surface linked list.
expect(session.surface.nodes.some(node => node.seq === session.seq - 1)).toBe(false)
})
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
const original = new Session(SessionId('t4'))
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
// Seeding a non-surface event with no surfaceOp must not throw.
const replayed = new Session(SessionId('t4-replay'), [...original.events])
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
.toEqual([{ content: 'only', status: 'completed' }])
expect(replayed.seq).toBe(original.seq)
})
})

View File

@@ -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 `)
}
})

View File

@@ -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')

9
packages/todo/README.md Normal file
View File

@@ -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.

View File

@@ -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)).

View File

@@ -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"
}
}

View File

@@ -0,0 +1,123 @@
/**
* 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 AT MOST ONE todo `in_progress` '
+ 'at a time; while work remains, exactly one active task should be '
+ '`in_progress`. Mark a todo `completed` the moment it is done (do not batch '
+ 'completions), and allow no `in_progress` item only once all work is complete. '
+ 'Skip the list for trivial single-step tasks. Statuses: `pending` '
+ '(not started), `in_progress` (being worked on now), `completed` (finished).'
/**
* Validate the value constraints the SchemaSpec can't express and build the
* canonical {@link TodoItem}[].
*
* `defineTool` already validates type/required/enum before `execute` runs (a
* bad `status` is rejected by the registry's `validateArgs`, never reaching
* here), so `status` is guaranteed to be one of the three enum literals. But
* `InferArgs` maps an `enum` string prop to plain `string`, so the compiler sees
* `args.todos` as `{ content: string; status: string }[]`; the
* `status as TodoItem['status']` narrowing records that registry guarantee
* rather than re-checking it (an unreachable re-check would be dead code — see
* AGENTS.md "don't validate scenarios that can't happen"). What remains is the
* value rules the DSL has no vocabulary for: non-empty unique content (stored
* trimmed, so the persisted value matches the dedupe/length key), 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 as TodoItem['status']
if (status === 'in_progress') inProgress++
todos.push({ 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 }),
}))
}

View File

@@ -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' },
])
})
})

View File

@@ -0,0 +1,167 @@
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('stores the trimmed content (the dedupe/length key), not the raw input', async () => {
const ctx = await setup()
const agent = agentWithSession('trim')
const result = await callTodo(ctx, { todos: [{ content: ' plan the work ', status: 'pending' }] }, { agent })
expect(result.isError).toBe(false)
const event = agent.session.events.findLast(e => e.type === 'todo/write')!
expect(event.data.todos).toEqual([{ content: 'plan the work', status: 'pending' }])
})
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')
})
})

View File

@@ -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"
}
]
}

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)', () => {