@namzu/sdk 10.0.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/manager/plan/lifecycle.d.ts +10 -0
  3. package/dist/manager/plan/lifecycle.d.ts.map +1 -1
  4. package/dist/manager/plan/lifecycle.js +14 -0
  5. package/dist/manager/plan/lifecycle.js.map +1 -1
  6. package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.d.ts +2 -0
  7. package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.d.ts.map +1 -0
  8. package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.js +90 -0
  9. package/dist/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.js.map +1 -0
  10. package/dist/runtime/query/result.d.ts.map +1 -1
  11. package/dist/runtime/query/result.js +17 -1
  12. package/dist/runtime/query/result.js.map +1 -1
  13. package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.d.ts +2 -0
  14. package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.d.ts.map +1 -0
  15. package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.js +139 -0
  16. package/dist/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.js.map +1 -0
  17. package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.d.ts +2 -0
  18. package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.d.ts.map +1 -0
  19. package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.js +160 -0
  20. package/dist/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.js.map +1 -0
  21. package/dist/tools/coordinator/__tests__/approve-plan.test.js +21 -6
  22. package/dist/tools/coordinator/__tests__/approve-plan.test.js.map +1 -1
  23. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +4 -0
  24. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
  25. package/dist/tools/coordinator/__tests__/task-list.test.js +44 -15
  26. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  27. package/dist/tools/coordinator/index.d.ts.map +1 -1
  28. package/dist/tools/coordinator/index.js +159 -8
  29. package/dist/tools/coordinator/index.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/manager/plan/lifecycle.ts +14 -0
  32. package/src/runtime/query/__tests__/a-plan-that-succeeded-says-so.test.ts +109 -0
  33. package/src/runtime/query/result.ts +18 -1
  34. package/src/tools/coordinator/__tests__/a-listing-is-not-a-back-door.test.ts +171 -0
  35. package/src/tools/coordinator/__tests__/a-plan-step-reports-its-own-outcome.test.ts +215 -0
  36. package/src/tools/coordinator/__tests__/approve-plan.test.ts +32 -11
  37. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +7 -0
  38. package/src/tools/coordinator/__tests__/task-list.test.ts +47 -20
  39. package/src/tools/coordinator/index.ts +177 -8
@@ -0,0 +1,171 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
4
+ import type { TaskId } from '../../../types/ids/index.js'
5
+ import type { ToolContext } from '../../../types/tool/index.js'
6
+ import { buildCoordinatorTools } from '../index.js'
7
+
8
+ /**
9
+ * A supervisor could read a sibling run's worker output by listing.
10
+ *
11
+ * `SupervisorAgentConfig.gateway` exists so a host can hand the SAME gateway to
12
+ * several runs, which makes `listTasks()` gateway-wide by design.
13
+ * `agent_task_list` handed that straight to the model — including each task's
14
+ * `result`, the worker's actual output — and `wait_for_task` had the same reach
15
+ * through `getTask`.
16
+ *
17
+ * `CompletionInbox` closed exactly this on the push side, because
18
+ * `onTaskCompleted` is a broadcast and a shared gateway would otherwise hand
19
+ * each supervisor the other's completions. The pull side kept no such record
20
+ * and asked the gateway directly, so the same leak stayed open through a
21
+ * different door.
22
+ */
23
+
24
+ const AGENTS = ['reviewer', 'researcher']
25
+
26
+ function makeContext(): ToolContext {
27
+ return {
28
+ runId: 'run_scope' as never,
29
+ workingDirectory: '/tmp/test',
30
+ abortSignal: new AbortController().signal,
31
+ env: {},
32
+ log: () => {},
33
+ }
34
+ }
35
+
36
+ /** One gateway, as a host sharing it between two supervisors would have. */
37
+ function sharedGateway() {
38
+ const handles = new Map<string, TaskHandle>()
39
+ let seq = 0
40
+
41
+ const gateway = {
42
+ createTask: async (opts: { agentId: string }) => {
43
+ seq += 1
44
+ const taskId = `tsk_${seq}` as TaskId
45
+ const handle: TaskHandle = {
46
+ taskId,
47
+ agentId: opts.agentId,
48
+ state: 'completed',
49
+ createdAt: 1_000,
50
+ completedAt: 2_000,
51
+ result: {
52
+ status: 'completed',
53
+ result: `output of ${opts.agentId} on ${taskId}`,
54
+ } as TaskHandle['result'],
55
+ }
56
+ handles.set(taskId, handle)
57
+ return handle
58
+ },
59
+ waitForTask: async (taskId: TaskId) => handles.get(taskId) as TaskHandle,
60
+ getTask: (taskId: TaskId) => handles.get(taskId),
61
+ listTasks: () => [...handles.values()],
62
+ cancelTask: () => undefined,
63
+ continueTask: async () => undefined,
64
+ onTaskCompleted: () => () => {},
65
+ } as unknown as TaskGateway
66
+
67
+ return gateway
68
+ }
69
+
70
+ /** A run's own coordinator surface over a gateway it may be sharing. */
71
+ function runOver(gateway: TaskGateway) {
72
+ const tools = buildCoordinatorTools({
73
+ gateway,
74
+ workingDirectory: '/tmp/test',
75
+ allowedAgentIds: AGENTS,
76
+ })
77
+ const named = (name: string) => {
78
+ const t = tools.find((tool) => tool.name === name)
79
+ if (!t) throw new Error(`${name} missing from coordinator builder`)
80
+ return t
81
+ }
82
+ return {
83
+ launch: (agentId: string) =>
84
+ named('create_task').execute(
85
+ { agent_id: agentId, prompt: 'work', description: `${agentId} work` },
86
+ makeContext(),
87
+ ),
88
+ list: () => named('agent_task_list').execute({}, makeContext()),
89
+ waitFor: (taskId: string) => named('wait_for_task').execute({ task_id: taskId }, makeContext()),
90
+ }
91
+ }
92
+
93
+ describe('one run cannot read another run through the listing', () => {
94
+ it('lists only the tasks this run launched', async () => {
95
+ const gateway = sharedGateway()
96
+ const first = runOver(gateway)
97
+ const second = runOver(gateway)
98
+
99
+ await first.launch('reviewer')
100
+ await second.launch('researcher')
101
+
102
+ const listed = await second.list()
103
+
104
+ // Its own, yes.
105
+ expect(listed.output).toContain('tsk_2')
106
+ // The sibling's task, and — the part that matters — the sibling's
107
+ // worker output, which the listing renders inline.
108
+ expect(listed.output).not.toContain('tsk_1')
109
+ expect(listed.output).not.toContain('output of reviewer')
110
+ })
111
+
112
+ it('counts only its own in the summary', async () => {
113
+ // The summary is what a supervisor reads to decide "done vs not done".
114
+ // A total that includes a sibling's tasks is a wrong answer to that
115
+ // question even when no output leaks with it.
116
+ const gateway = sharedGateway()
117
+ const first = runOver(gateway)
118
+ const second = runOver(gateway)
119
+
120
+ await first.launch('reviewer')
121
+ await first.launch('reviewer')
122
+ await second.launch('researcher')
123
+
124
+ const listed = await second.list()
125
+ const data = listed.data as { summary: { total: number }; items: unknown[] }
126
+
127
+ expect(data.summary.total).toBe(1)
128
+ expect(data.items).toHaveLength(1)
129
+ })
130
+
131
+ it('refuses to wait on a task another run launched', async () => {
132
+ const gateway = sharedGateway()
133
+ const first = runOver(gateway)
134
+ const second = runOver(gateway)
135
+
136
+ await first.launch('reviewer')
137
+
138
+ const waited = await second.waitFor('tsk_1')
139
+
140
+ expect(waited.success).toBe(false)
141
+ expect(waited.output).not.toContain('output of reviewer')
142
+ })
143
+
144
+ it('says the same thing about a task that never existed', async () => {
145
+ // The refusal must not distinguish "belongs to someone else" from
146
+ // "never existed". Confirming a real id to a run that should not know
147
+ // it is the leak in miniature.
148
+ const gateway = sharedGateway()
149
+ const first = runOver(gateway)
150
+ const second = runOver(gateway)
151
+
152
+ await first.launch('reviewer')
153
+
154
+ const sibling = await second.waitFor('tsk_1')
155
+ const fictional = await second.waitFor('tsk_9999')
156
+
157
+ expect(sibling.output).toBe(fictional.output.replace('tsk_9999', 'tsk_1'))
158
+ })
159
+
160
+ it('still lets a run wait on its own task', async () => {
161
+ // The scope has to be a filter, not a wall — a run that launched a task
162
+ // must still be able to read it back, or the fix breaks delegation.
163
+ const gateway = sharedGateway()
164
+ const only = runOver(gateway)
165
+
166
+ await only.launch('reviewer')
167
+ const waited = await only.waitFor('tsk_1')
168
+
169
+ expect(waited.success).toBe(true)
170
+ })
171
+ })
@@ -0,0 +1,215 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { PlanManager } from '../../../manager/plan/lifecycle.js'
4
+ import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
5
+ import type { RunId, TaskId } from '../../../types/ids/index.js'
6
+ import type { ToolContext, ToolDefinition } from '../../../types/tool/index.js'
7
+ import { buildCoordinatorTools } from '../index.js'
8
+
9
+ /**
10
+ * A plan's steps had no relationship to the work that carried them out.
11
+ *
12
+ * `approve_plan` built steps, `create_task` launched workers, and nothing
13
+ * connected the two — so no step could ever be observed, `updateStepStatus` had
14
+ * no production caller, and a plan could reach `failed` (the error path calls
15
+ * `failPlan`) or sit at `executing` forever, but never `completed`.
16
+ *
17
+ * Two bindings close it, because there are two kinds of step. A DELEGATED step
18
+ * reports through the `create_task` that carries it out. An ORCHESTRATOR-OWNED
19
+ * step has no tool call to bind to at all, and reports through
20
+ * `update_plan_step` — without which a plan containing one could never settle
21
+ * however well it went.
22
+ */
23
+
24
+ const RUN = 'run_step_binding' as RunId
25
+
26
+ function gatewayReturning(outcome: 'ok' | 'failed'): TaskGateway {
27
+ const handle = (taskId: TaskId): TaskHandle => ({
28
+ taskId,
29
+ agentId: 'worker',
30
+ state: 'completed',
31
+ createdAt: 1_000,
32
+ completedAt: 2_000,
33
+ result: (outcome === 'ok'
34
+ ? { status: 'completed', result: 'the work' }
35
+ : { status: 'failed', lastError: 'the worker died' }) as TaskHandle['result'],
36
+ })
37
+ return {
38
+ async createTask() {
39
+ return handle('tsk_1' as TaskId)
40
+ },
41
+ async waitForTask(id) {
42
+ return handle(id)
43
+ },
44
+ async continueTask() {},
45
+ cancelTask() {},
46
+ getTask(id) {
47
+ return handle(id)
48
+ },
49
+ listTasks() {
50
+ return []
51
+ },
52
+ onTaskCompleted() {
53
+ return () => {}
54
+ },
55
+ }
56
+ }
57
+
58
+ function ctx(): ToolContext {
59
+ return {
60
+ runId: RUN,
61
+ workingDirectory: '/tmp/test',
62
+ abortSignal: new AbortController().signal,
63
+ env: {},
64
+ log: () => {},
65
+ }
66
+ }
67
+
68
+ /** An approved two-step plan: one delegated, one the orchestrator's own. */
69
+ function approvedPlan(): PlanManager {
70
+ const pm = new PlanManager(RUN, async () => ({ approved: true }))
71
+ pm.startGenerating('do the work')
72
+ pm.addStep({
73
+ id: 'step_1',
74
+ description: 'delegated work',
75
+ agentId: 'worker',
76
+ dependsOn: [],
77
+ order: 1,
78
+ })
79
+ pm.addStep({ id: 'step_2', description: 'my own work', dependsOn: [], order: 2 })
80
+ pm.markReady()
81
+ pm.approve()
82
+ pm.startExecution()
83
+ return pm
84
+ }
85
+
86
+ function toolsOver(pm: PlanManager, gateway: TaskGateway): (name: string) => ToolDefinition {
87
+ const tools = buildCoordinatorTools({
88
+ gateway,
89
+ workingDirectory: '/tmp/test',
90
+ allowedAgentIds: ['worker'],
91
+ getPlanManager: () => pm,
92
+ })
93
+ return (name: string) => {
94
+ const t = tools.find((tool) => tool.name === name)
95
+ if (!t) throw new Error(`${name} missing from coordinator builder`)
96
+ return t
97
+ }
98
+ }
99
+
100
+ const stepStatus = (pm: PlanManager, id: string) =>
101
+ pm.active?.steps.find((s) => s.id === id)?.status
102
+
103
+ describe('a delegated step reports through the launch that carries it out', () => {
104
+ it('completes the step when the worker succeeded', async () => {
105
+ const pm = approvedPlan()
106
+ const named = toolsOver(pm, gatewayReturning('ok'))
107
+
108
+ await named('create_task').execute(
109
+ { agent_id: 'worker', prompt: 'go', description: 'do it', plan_step_id: 'step_1' },
110
+ ctx(),
111
+ )
112
+
113
+ expect(stepStatus(pm, 'step_1')).toBe('completed')
114
+ })
115
+
116
+ it('fails the step when the worker failed, from both authorities', async () => {
117
+ // The handle is `state: 'completed'` with `result.status: 'failed'` —
118
+ // the split that made a failed worker read as an answer. The step must
119
+ // follow the run status, not the gateway state.
120
+ const pm = approvedPlan()
121
+ const named = toolsOver(pm, gatewayReturning('failed'))
122
+
123
+ await named('create_task').execute(
124
+ { agent_id: 'worker', prompt: 'go', description: 'do it', plan_step_id: 'step_1' },
125
+ ctx(),
126
+ )
127
+
128
+ expect(stepStatus(pm, 'step_1')).toBe('failed')
129
+ })
130
+
131
+ it('leaves the plan alone when the launch names no step', async () => {
132
+ // A launch outside the approved plan must not silently settle one.
133
+ const pm = approvedPlan()
134
+ const named = toolsOver(pm, gatewayReturning('ok'))
135
+
136
+ await named('create_task').execute(
137
+ { agent_id: 'worker', prompt: 'go', description: 'unrelated work' },
138
+ ctx(),
139
+ )
140
+
141
+ expect(stepStatus(pm, 'step_1')).toBe('pending')
142
+ })
143
+ })
144
+
145
+ describe('an orchestrator-owned step reports through update_plan_step', () => {
146
+ it('records the outcome and says what is still outstanding', async () => {
147
+ const pm = approvedPlan()
148
+ const named = toolsOver(pm, gatewayReturning('ok'))
149
+
150
+ const result = await named('update_plan_step').execute(
151
+ { step_id: 'step_2', status: 'completed' },
152
+ ctx(),
153
+ )
154
+
155
+ expect(result.success).toBe(true)
156
+ expect(stepStatus(pm, 'step_2')).toBe('completed')
157
+ // step_1 has not reported, and saying so is the point — this is what
158
+ // tells the model the plan cannot settle yet.
159
+ expect(result.output).toContain('step_1')
160
+ })
161
+
162
+ it('treats skipped as a real outcome, not a failure', async () => {
163
+ const pm = approvedPlan()
164
+ const named = toolsOver(pm, gatewayReturning('ok'))
165
+
166
+ await named('update_plan_step').execute({ step_id: 'step_2', status: 'skipped' }, ctx())
167
+
168
+ expect(stepStatus(pm, 'step_2')).toBe('skipped')
169
+ })
170
+
171
+ it('refuses an id the plan does not have, and names the ones it does', async () => {
172
+ const pm = approvedPlan()
173
+ const named = toolsOver(pm, gatewayReturning('ok'))
174
+
175
+ const result = await named('update_plan_step').execute(
176
+ { step_id: 'step_9', status: 'completed' },
177
+ ctx(),
178
+ )
179
+
180
+ expect(result.success).toBe(false)
181
+ expect(result.error).toContain('step_1')
182
+ expect(result.error).toContain('step_2')
183
+ })
184
+ })
185
+
186
+ describe('the two bindings together let a plan settle', () => {
187
+ it('reaches completed once every step has reported', async () => {
188
+ const pm = approvedPlan()
189
+ const named = toolsOver(pm, gatewayReturning('ok'))
190
+
191
+ await named('create_task').execute(
192
+ { agent_id: 'worker', prompt: 'go', description: 'do it', plan_step_id: 'step_1' },
193
+ ctx(),
194
+ )
195
+ await named('update_plan_step').execute({ step_id: 'step_2', status: 'completed' }, ctx())
196
+
197
+ expect(pm.unreportedSteps).toHaveLength(0)
198
+ expect(pm.completePlan()?.status).toBe('completed')
199
+ })
200
+
201
+ it('leaves the plan unsettled while a step is still silent', async () => {
202
+ // The state the kernel reads before deciding whether to settle. An
203
+ // unreported step means the caller and the plan disagree about whether
204
+ // the work is over, and the run must not resolve that by guessing.
205
+ const pm = approvedPlan()
206
+ const named = toolsOver(pm, gatewayReturning('ok'))
207
+
208
+ await named('create_task').execute(
209
+ { agent_id: 'worker', prompt: 'go', description: 'do it', plan_step_id: 'step_1' },
210
+ ctx(),
211
+ )
212
+
213
+ expect(pm.unreportedSteps.map((s) => s.id)).toEqual(['step_2'])
214
+ })
215
+ })
@@ -88,14 +88,30 @@ describe('coordinator approve_plan tool', () => {
88
88
  ])
89
89
  })
90
90
 
91
- it('keeps the bare-approve tool_result output byte-identical', async () => {
91
+ it('opens with the historical approval sentence, then names the steps', async () => {
92
+ // This assertion used to be `toBe` on the whole string, guarding the
93
+ // approve-with-edits change against disturbing the bare-approve path.
94
+ // It was never a promise that the output would carry nothing more, and
95
+ // it now carries the step roster — without which `plan_step_id` and
96
+ // `update_plan_step` name ids the model has never been told.
92
97
  const result = await executeApprovePlan({ approved: true })
93
98
 
94
99
  expect(result.success).toBe(true)
95
- expect(result.output).toBe(
96
- 'Plan approved by user. Proceed with execution — launch workers via create_task.',
97
- )
98
- expect(result.data).toEqual({ approved: true, feedback: undefined })
100
+ expect(
101
+ result.output.startsWith(
102
+ 'Plan approved by user. Proceed with execution — launch workers via create_task.',
103
+ ),
104
+ ).toBe(true)
105
+ expect(result.output).toContain('step_1 — Extract uploaded DOCX files')
106
+ expect(result.data).toMatchObject({ approved: true, feedback: undefined })
107
+ })
108
+
109
+ it('carries the step ids in data, so a host does not have to parse prose', async () => {
110
+ const result = await executeApprovePlan({ approved: true })
111
+
112
+ expect((result.data as { steps: unknown }).steps).toEqual([
113
+ { step_id: 'step_1', description: 'Extract uploaded DOCX files', agent_id: undefined },
114
+ ])
99
115
  })
100
116
 
101
117
  it('embeds approve-with-edits feedback in the output and data', async () => {
@@ -105,12 +121,17 @@ describe('coordinator approve_plan tool', () => {
105
121
  })
106
122
 
107
123
  expect(result.success).toBe(true)
108
- expect(result.output).toBe(
109
- 'Plan approved by user with required edits — apply them during execution:\n' +
110
- 'Skip step 2 and use the staging database instead.\n' +
111
- 'Proceed with execution launch workers via create_task.',
112
- )
113
- expect(result.data).toEqual({
124
+ expect(
125
+ result.output.startsWith(
126
+ 'Plan approved by user with required edits apply them during execution:\n' +
127
+ 'Skip step 2 and use the staging database instead.\n' +
128
+ 'Proceed with execution — launch workers via create_task.',
129
+ ),
130
+ ).toBe(true)
131
+ // The edits stay ahead of the roster: what the user demanded is the
132
+ // first thing read, and the step list is reference material after it.
133
+ expect(result.output.indexOf('staging database')).toBeLessThan(result.output.indexOf('step_1'))
134
+ expect(result.data).toMatchObject({
114
135
  approved: true,
115
136
  feedback: 'Skip step 2 and use the staging database instead.',
116
137
  })
@@ -298,6 +298,13 @@ describe('waiting explicitly beats listing in a loop', () => {
298
298
  describe('the task listing carries the output it always had', () => {
299
299
  async function listWith(result: string): Promise<string> {
300
300
  const h = harness()
301
+ // Launch it first. The listing is scoped to what this run launched, so
302
+ // settling a task the tools never created describes a sibling run's
303
+ // work — which the listing now declines to show, correctly.
304
+ await toolNamed(h.tools, 'create_task').execute(
305
+ { agent_id: 'reviewer', prompt: 'go', description: 'review', background: true },
306
+ {} as never,
307
+ )
301
308
  h.settle({
302
309
  taskId: 'tsk_1' as TaskId,
303
310
  agentId: 'reviewer',
@@ -27,13 +27,27 @@ function makeContext(): ToolContext {
27
27
  }
28
28
  }
29
29
 
30
+ /**
31
+ * Hands back the seeded handles in order, one per `createTask`.
32
+ *
33
+ * The listing is scoped to what this tool set launched, so a fixture that
34
+ * only stuffed `listTasks()` would now list nothing — and a gateway holding
35
+ * tasks these tools never launched is precisely the sibling-run case the
36
+ * scope exists to refuse. So the tests launch through the front door and the
37
+ * fixture plays along.
38
+ */
30
39
  function gatewayWith(handles: TaskHandle[]): TaskGateway {
40
+ let nextLaunch = 0
31
41
  return {
32
42
  async createTask() {
33
- throw new Error('not used')
43
+ const h = handles[nextLaunch++]
44
+ if (!h) throw new Error('fixture ran out of seeded handles')
45
+ return h
34
46
  },
35
- async waitForTask() {
36
- throw new Error('not used')
47
+ async waitForTask(id) {
48
+ const h = handles.find((x) => x.taskId === id)
49
+ if (!h) throw new Error(`fixture has no handle ${id}`)
50
+ return h
37
51
  },
38
52
  async continueTask() {},
39
53
  cancelTask() {},
@@ -79,12 +93,29 @@ function handle(input: {
79
93
  }
80
94
  }
81
95
 
82
- function findAgentTaskList(gateway: TaskGateway) {
96
+ /**
97
+ * Build the coordinator surface, launch each seeded handle through
98
+ * `create_task`, and return `agent_task_list`.
99
+ *
100
+ * Launching is what puts the tasks in this run's scope. Reaching past it to
101
+ * seed the gateway directly would test a listing nobody can produce.
102
+ */
103
+ async function agentTaskListOver(seeded: TaskHandle[]) {
83
104
  const tools = buildCoordinatorTools({
84
- gateway,
105
+ gateway: gatewayWith(seeded),
85
106
  workingDirectory: '/tmp/test',
86
107
  allowedAgentIds: ['solution-architecture', 'enterprise-architecture'],
87
108
  })
109
+
110
+ const createTask = tools.find((tool) => tool.name === 'create_task')
111
+ if (!createTask) throw new Error('create_task tool missing from coordinator builder')
112
+ for (const h of seeded) {
113
+ await createTask.execute(
114
+ { agent_id: h.agentId, prompt: 'work', description: `launch ${h.taskId}` },
115
+ makeContext(),
116
+ )
117
+ }
118
+
88
119
  const t = tools.find((tool) => tool.name === 'agent_task_list')
89
120
  if (!t) throw new Error('agent_task_list tool missing from coordinator builder')
90
121
  return t
@@ -92,7 +123,7 @@ function findAgentTaskList(gateway: TaskGateway) {
92
123
 
93
124
  describe('coordinator agent_task_list tool', () => {
94
125
  it('lists every task with state, agent, and timing', async () => {
95
- const gateway = gatewayWith([
126
+ const seeded = [
96
127
  handle({
97
128
  id: 'task_a',
98
129
  agentId: 'solution-architecture',
@@ -114,9 +145,8 @@ describe('coordinator agent_task_list tool', () => {
114
145
  completedAt: 4000,
115
146
  lastError: 'bash exit 1',
116
147
  }),
117
- ])
118
-
119
- const tool = findAgentTaskList(gateway)
148
+ ]
149
+ const tool = await agentTaskListOver(seeded)
120
150
  const result = await tool.execute({}, makeContext())
121
151
  expect(result.success).toBe(true)
122
152
  expect(result.output).toMatch(/Tasks: 3 total/)
@@ -131,7 +161,7 @@ describe('coordinator agent_task_list tool', () => {
131
161
  })
132
162
 
133
163
  it('filters by state', async () => {
134
- const gateway = gatewayWith([
164
+ const seeded = [
135
165
  handle({
136
166
  id: 'task_a',
137
167
  agentId: 'solution-architecture',
@@ -145,9 +175,8 @@ describe('coordinator agent_task_list tool', () => {
145
175
  state: 'running',
146
176
  createdAt: 1000,
147
177
  }),
148
- ])
149
-
150
- const tool = findAgentTaskList(gateway)
178
+ ]
179
+ const tool = await agentTaskListOver(seeded)
151
180
  const result = await tool.execute({ state: 'running' }, makeContext())
152
181
  expect(result.success).toBe(true)
153
182
  const data = result.data as { items: Array<{ task_id: string }> }
@@ -157,7 +186,7 @@ describe('coordinator agent_task_list tool', () => {
157
186
  })
158
187
 
159
188
  it('handles an empty gateway', async () => {
160
- const tool = findAgentTaskList(gatewayWith([]))
189
+ const tool = await agentTaskListOver([])
161
190
  const result = await tool.execute({}, makeContext())
162
191
  expect(result.success).toBe(true)
163
192
  expect(result.output).toMatch(/Tasks: 0 total/)
@@ -224,7 +253,7 @@ describe('agent_task_list frames what a worker said', () => {
224
253
  }
225
254
 
226
255
  async function render(text: string): Promise<string> {
227
- const tool = findAgentTaskList(gatewayWith([withResult(text)]))
256
+ const tool = await agentTaskListOver([withResult(text)])
228
257
  const out = await tool.execute({}, makeContext())
229
258
  return out.output
230
259
  }
@@ -263,11 +292,9 @@ describe('agent_task_list frames what a worker said', () => {
263
292
  })
264
293
 
265
294
  it('says nothing extra for a task that produced no output', async () => {
266
- const tool = findAgentTaskList(
267
- gatewayWith([
268
- handle({ id: 'task_none', agentId: 'reviewer', state: 'running', createdAt: 0 }),
269
- ]),
270
- )
295
+ const tool = await agentTaskListOver([
296
+ handle({ id: 'task_none', agentId: 'reviewer', state: 'running', createdAt: 0 }),
297
+ ])
271
298
 
272
299
  const out = await tool.execute({}, makeContext())
273
300