feat(todo): allow several in_progress todos at once
Remove the single-in_progress cap from todo_write execute validation and the durable-log invariant so a task list can mirror genuinely parallel work (concurrent subagents, background commands). Update the tool description to instruct marking every actively worked task in_progress, refresh the tool catalog and keyless snapshot expected outputs, and record the decision in a new Agent Note superseding the original cap.
This commit is contained in:
@@ -146,7 +146,7 @@ export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
|
||||
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. */
|
||||
/** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */
|
||||
status: 'pending' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ The list belongs to the ONE agent session that called the tool. There is no suba
|
||||
|
||||
## 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.
|
||||
Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`. Any number of tasks may be `in_progress` at once — parallel work (concurrent subagents, background commands) legitimately runs several tasks simultaneously. Ordering and the discipline of keeping the list current are left to the model via the tool description.
|
||||
|
||||
## Rendering
|
||||
|
||||
@@ -44,7 +44,7 @@ Prefix-stable while the definition and visibility are unchanged. Plugin lifecycl
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content "<content>"`, `Error: invalid todos: at most one task may be in_progress, got <count>`, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message.
|
||||
Each assistant tool call retains the entire replacement list in its arguments. Success returns exactly `Updated todo list: <pending> pending, <inProgress> in progress, <completed> completed.` Stable failures are ``Error: invalid todo: `content` must be a non-empty string``, `Error: invalid todos: duplicate content "<content>"`, and `Error: todo_write requires an owning agent session`. The full `todo/write` session event is UI and replay state, not a second model message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -19,22 +19,24 @@ 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).'
|
||||
+ 'todo per concrete step before you start. Mark every todo being actively worked '
|
||||
+ 'on `in_progress` — several at once when work genuinely runs in parallel (e.g. '
|
||||
+ 'concurrent subagents or background commands), one for sequential work; while '
|
||||
+ 'work remains, at least one 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 ParameterSchemaSpec can't express and build the canonical {@link
|
||||
* TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry
|
||||
* has already enforced the status enum; the cast below records that guarantee.
|
||||
* TodoItem}[]: trimmed non-empty unique content. Any number of items may be in_progress —
|
||||
* parallel work (subagents, background commands) legitimately runs several tasks at once. The
|
||||
* registry has already enforced the status enum; the cast below records that guarantee.
|
||||
*/
|
||||
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) {
|
||||
@@ -44,12 +46,7 @@ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] {
|
||||
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}`)
|
||||
todos.push({ content, status: item.status as TodoItem['status'] })
|
||||
}
|
||||
return todos
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export const inject = ['invariants']
|
||||
function validateTodos(value: unknown, fail: InvariantFailure): void {
|
||||
if (!Array.isArray(value)) fail('todo/write todos must be an array')
|
||||
const seen = new Set<string>()
|
||||
let active = 0
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'object' || item === null) fail('todo/write entries must be objects')
|
||||
const { content, status } = item as Record<string, unknown>
|
||||
@@ -28,9 +27,7 @@ function validateTodos(value: unknown, fail: InvariantFailure): void {
|
||||
if (typeof status !== 'string' || !TODO_STATUSES.has(status)) {
|
||||
fail(`todo/write carries unknown status ${JSON.stringify(status)}`)
|
||||
}
|
||||
if (status === 'in_progress') active += 1
|
||||
}
|
||||
if (active > 1) fail(`todo/write contains ${active} in-progress entries; at most one is allowed`)
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
|
||||
@@ -17,11 +17,12 @@ function event(todos: unknown): SessionEvent {
|
||||
}
|
||||
|
||||
describe('todo snapshot invariants', () => {
|
||||
it('accepts a unique whole-list snapshot with one active item', async () => {
|
||||
it('accepts a unique whole-list snapshot, including several active items', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, event([
|
||||
{ content: 'Inspect state', status: 'completed' },
|
||||
{ content: 'Apply fix', status: 'in_progress' },
|
||||
{ content: 'Watch background build', status: 'in_progress' },
|
||||
{ content: 'Run checks', status: 'pending' },
|
||||
])) }).not.toThrow()
|
||||
})
|
||||
@@ -36,7 +37,6 @@ describe('todo snapshot invariants', () => {
|
||||
[[{ content: 'same', status: 'pending' }, { content: 'same', status: 'completed' }], /repeats content/],
|
||||
[[{ content: 'task', status: 42 }], /unknown status/],
|
||||
[[{ content: 'task', status: 'paused' }], /unknown status/],
|
||||
[[{ content: 'one', status: 'in_progress' }, { content: 'two', status: 'in_progress' }], /at most one/],
|
||||
])('rejects an incoherent durable todo snapshot', async (todos, message) => {
|
||||
const ctx = await setup()
|
||||
expect(() => { ctx.emit('session/event', {} as Session, event(todos)) }).toThrow(message)
|
||||
|
||||
@@ -122,10 +122,27 @@ describe('dsh-tool-todo', () => {
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts several in_progress items at once (parallel work)', async () => {
|
||||
const ctx = await setup()
|
||||
const agent = agentWithSession('parallel')
|
||||
const todos: TodoItem[] = [
|
||||
{ content: 'run subagent a', status: 'in_progress' },
|
||||
{ content: 'run subagent b', status: 'in_progress' },
|
||||
{ content: 'merge results', status: 'pending' },
|
||||
]
|
||||
const result = await callTodo(ctx, { todos }, { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected todo_write success')
|
||||
expect(result.value).toEqual({
|
||||
todos,
|
||||
counts: { pending: 1, inProgress: 2, completed: 0 },
|
||||
})
|
||||
expect(agent.session.events.findLast(e => e.type === 'todo/write')!.data.todos).toEqual(todos)
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user