@namzu/sdk 5.2.0 → 6.1.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 (111) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +13 -0
  4. package/dist/agents/SupervisorAgent.js.map +1 -1
  5. package/dist/gateway/__tests__/completion-inbox.test.d.ts +2 -0
  6. package/dist/gateway/__tests__/completion-inbox.test.d.ts.map +1 -0
  7. package/dist/gateway/__tests__/completion-inbox.test.js +258 -0
  8. package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -0
  9. package/dist/gateway/completion-inbox.d.ts +113 -0
  10. package/dist/gateway/completion-inbox.d.ts.map +1 -0
  11. package/dist/gateway/completion-inbox.js +211 -0
  12. package/dist/gateway/completion-inbox.js.map +1 -0
  13. package/dist/provider/__tests__/strict-schema.test.js +50 -2
  14. package/dist/provider/__tests__/strict-schema.test.js.map +1 -1
  15. package/dist/provider/__tests__/vendor-detail.test.d.ts +2 -0
  16. package/dist/provider/__tests__/vendor-detail.test.d.ts.map +1 -0
  17. package/dist/provider/__tests__/vendor-detail.test.js +89 -0
  18. package/dist/provider/__tests__/vendor-detail.test.js.map +1 -0
  19. package/dist/provider/errors.d.ts +38 -5
  20. package/dist/provider/errors.d.ts.map +1 -1
  21. package/dist/provider/errors.js +107 -5
  22. package/dist/provider/errors.js.map +1 -1
  23. package/dist/provider/strict-schema.d.ts.map +1 -1
  24. package/dist/provider/strict-schema.js +64 -8
  25. package/dist/provider/strict-schema.js.map +1 -1
  26. package/dist/public-runtime.d.ts +4 -0
  27. package/dist/public-runtime.d.ts.map +1 -1
  28. package/dist/public-runtime.js +10 -0
  29. package/dist/public-runtime.js.map +1 -1
  30. package/dist/registry/tool/__tests__/dialect.test.d.ts +2 -0
  31. package/dist/registry/tool/__tests__/dialect.test.d.ts.map +1 -0
  32. package/dist/registry/tool/__tests__/dialect.test.js +146 -0
  33. package/dist/registry/tool/__tests__/dialect.test.js.map +1 -0
  34. package/dist/registry/tool/dialect.d.ts +50 -0
  35. package/dist/registry/tool/dialect.d.ts.map +1 -0
  36. package/dist/registry/tool/dialect.js +138 -0
  37. package/dist/registry/tool/dialect.js.map +1 -0
  38. package/dist/registry/tool/execute.d.ts.map +1 -1
  39. package/dist/registry/tool/execute.js +34 -0
  40. package/dist/registry/tool/execute.js.map +1 -1
  41. package/dist/registry/toolset/catalog.d.ts.map +1 -1
  42. package/dist/registry/toolset/catalog.js +10 -5
  43. package/dist/registry/toolset/catalog.js.map +1 -1
  44. package/dist/runtime/query/__tests__/completion-notification.test.d.ts +2 -0
  45. package/dist/runtime/query/__tests__/completion-notification.test.d.ts.map +1 -0
  46. package/dist/runtime/query/__tests__/completion-notification.test.js +168 -0
  47. package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -0
  48. package/dist/runtime/query/__tests__/step-allow-list.test.d.ts +2 -0
  49. package/dist/runtime/query/__tests__/step-allow-list.test.d.ts.map +1 -0
  50. package/dist/runtime/query/__tests__/step-allow-list.test.js +171 -0
  51. package/dist/runtime/query/__tests__/step-allow-list.test.js.map +1 -0
  52. package/dist/runtime/query/__tests__/stream-recovery.test.js +6 -0
  53. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  54. package/dist/runtime/query/executor.d.ts +14 -0
  55. package/dist/runtime/query/executor.d.ts.map +1 -1
  56. package/dist/runtime/query/executor.js +20 -1
  57. package/dist/runtime/query/executor.js.map +1 -1
  58. package/dist/runtime/query/index.d.ts +9 -0
  59. package/dist/runtime/query/index.d.ts.map +1 -1
  60. package/dist/runtime/query/index.js +1 -0
  61. package/dist/runtime/query/index.js.map +1 -1
  62. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  63. package/dist/runtime/query/iteration/index.js +74 -1
  64. package/dist/runtime/query/iteration/index.js.map +1 -1
  65. package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
  66. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  67. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  68. package/dist/runtime/query/result.d.ts.map +1 -1
  69. package/dist/runtime/query/result.js +6 -0
  70. package/dist/runtime/query/result.js.map +1 -1
  71. package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts +2 -0
  72. package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts.map +1 -0
  73. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +255 -0
  74. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -0
  75. package/dist/tools/coordinator/__tests__/task-list.test.js +13 -7
  76. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  77. package/dist/tools/coordinator/index.d.ts +35 -0
  78. package/dist/tools/coordinator/index.d.ts.map +1 -1
  79. package/dist/tools/coordinator/index.js +222 -16
  80. package/dist/tools/coordinator/index.js.map +1 -1
  81. package/dist/types/provider/error.d.ts +20 -4
  82. package/dist/types/provider/error.d.ts.map +1 -1
  83. package/dist/types/tool/index.d.ts +13 -0
  84. package/dist/types/tool/index.d.ts.map +1 -1
  85. package/dist/types/tool/index.js.map +1 -1
  86. package/package.json +1 -1
  87. package/src/agents/SupervisorAgent.ts +14 -0
  88. package/src/gateway/__tests__/completion-inbox.test.ts +320 -0
  89. package/src/gateway/completion-inbox.ts +227 -0
  90. package/src/provider/__tests__/strict-schema.test.ts +58 -2
  91. package/src/provider/__tests__/vendor-detail.test.ts +107 -0
  92. package/src/provider/errors.ts +106 -5
  93. package/src/provider/strict-schema.ts +65 -8
  94. package/src/public-runtime.ts +11 -0
  95. package/src/registry/tool/__tests__/dialect.test.ts +200 -0
  96. package/src/registry/tool/dialect.ts +143 -0
  97. package/src/registry/tool/execute.ts +35 -0
  98. package/src/registry/toolset/catalog.ts +10 -5
  99. package/src/runtime/query/__tests__/completion-notification.test.ts +196 -0
  100. package/src/runtime/query/__tests__/step-allow-list.test.ts +205 -0
  101. package/src/runtime/query/__tests__/stream-recovery.test.ts +6 -0
  102. package/src/runtime/query/executor.ts +21 -1
  103. package/src/runtime/query/index.ts +11 -0
  104. package/src/runtime/query/iteration/index.ts +77 -1
  105. package/src/runtime/query/iteration/phases/context.ts +10 -0
  106. package/src/runtime/query/result.ts +6 -0
  107. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +347 -0
  108. package/src/tools/coordinator/__tests__/task-list.test.ts +13 -7
  109. package/src/tools/coordinator/index.ts +250 -20
  110. package/src/types/provider/error.ts +20 -4
  111. package/src/types/tool/index.ts +13 -0
@@ -410,6 +410,16 @@ export interface QueryParams {
410
410
 
411
411
  taskGateway?: import('../../types/agent/gateway.js').TaskGateway
412
412
 
413
+ /**
414
+ * Where a worker completion goes when no tool call is waiting for it.
415
+ *
416
+ * Supplied by whoever built the coordinator tools, because the tools and
417
+ * this loop have to share one inbox: the tools claim what they deliver,
418
+ * and the loop delivers what is left. Omitted, the loop drains nothing and
419
+ * the behaviour is exactly what it was before the inbox existed.
420
+ */
421
+ completionInbox?: import('../../gateway/completion-inbox.js').CompletionInbox
422
+
413
423
  launchedTasks?: Map<
414
424
  import('../../types/ids/index.js').TaskId,
415
425
  import('./iteration/phases/context.js').LaunchedTaskMeta
@@ -842,6 +852,7 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
842
852
  checkpointMgr,
843
853
  planManager: ctx.planManager,
844
854
  taskGateway: params.taskGateway,
855
+ completionInbox: params.completionInbox,
845
856
  taskStore: params.taskStore,
846
857
  launchedTasks: params.launchedTasks ?? new Map(),
847
858
  // Run-scoped. An approval is a statement about this run's work;
@@ -5,6 +5,7 @@ import {
5
5
  DEFAULT_STRUCTURED_OUTPUT_RETRIES,
6
6
  STRUCTURED_OUTPUT_REPROMPT,
7
7
  } from '../../../constants/tools/index.js'
8
+ import { formatCompletionNotification } from '../../../gateway/completion-inbox.js'
8
9
  import { renderSkillsSection } from '../../../persona/assembler.js'
9
10
  import { collect } from '../../../provider/collect.js'
10
11
  import {
@@ -61,6 +62,16 @@ export type { ToolReviewOutcome } from './phases/index.js'
61
62
  */
62
63
  const DEFAULT_ANSWER_REVIEW_LIMIT = 3
63
64
 
65
+ /**
66
+ * How long a finishing run waits for a background worker it launched.
67
+ *
68
+ * Long enough to be worth having — a delegated worker doing real work takes
69
+ * minutes — and bounded because a worker that never finishes must not hold
70
+ * the run open forever. `maxIterations` bounds how many times this can
71
+ * happen, and the run's own timeout bounds the whole thing regardless.
72
+ */
73
+ const BACKGROUND_TASK_GRACE_MS = 120_000
74
+
64
75
  export class IterationOrchestrator {
65
76
  private ctx: IterationContext
66
77
  /** Rejections so far. See {@link DEFAULT_ANSWER_REVIEW_LIMIT}. */
@@ -207,7 +218,13 @@ export class IterationOrchestrator {
207
218
  // supplied no hook.
208
219
  const step = await this.prepareStep(iterationNum)
209
220
 
210
- const llmTools = this.ctx.tools.toLLMTools(step.allowedTools ?? this.ctx.allowedTools)
221
+ const stepAllowedTools = step.allowedTools ?? this.ctx.allowedTools
222
+ const llmTools = this.ctx.tools.toLLMTools(stepAllowedTools)
223
+ // The same list the request was built from now also bounds what
224
+ // may run. Narrowing only the request left the restriction
225
+ // presentational — the model was shown fewer tools and could
226
+ // still call any of them by name.
227
+ this.ctx.toolExecutor.setStepAllowedTools(stepAllowedTools)
211
228
  const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools)
212
229
  const stepModel = step.model ?? model
213
230
 
@@ -551,6 +568,36 @@ export class IterationOrchestrator {
551
568
  }
552
569
  }
553
570
 
571
+ // A background worker is still out there, and this turn was
572
+ // about to end the run.
573
+ //
574
+ // Settling here would throw away the very thing the launch
575
+ // existed to produce: the supervisor said "launched", the
576
+ // worker had not finished, and the run closed over it. So
577
+ // the run is held open — bounded by the deadline below and
578
+ // by `maxIterations` above, so a worker that never finishes
579
+ // cannot keep it open forever — and the completion arrives
580
+ // as a notification the next turn reads.
581
+ if (!forceFinalize && this.ctx.completionInbox?.hasPendingWork) {
582
+ this.ctx.log.info('Holding the run open for a background task', {
583
+ runId: runMgr.id,
584
+ iteration: iterationNum,
585
+ })
586
+ await this.ctx.completionInbox.waitForArrival(BACKGROUND_TASK_GRACE_MS)
587
+ const arrived = this.ctx.completionInbox.drain()
588
+ if (arrived.length > 0) {
589
+ runMgr.pushMessage(createUserMessage(formatCompletionNotification(arrived)))
590
+ await this.ctx.emitEvent({
591
+ type: 'iteration_completed',
592
+ runId: runMgr.id,
593
+ iteration: iterationNum,
594
+ hasToolCalls: false,
595
+ })
596
+ yield* this.ctx.drainPending()
597
+ continue
598
+ }
599
+ }
600
+
554
601
  if (!hasContent && !forceFinalize) {
555
602
  this.ctx.log.warn('Empty completion detected — requesting final summary', {
556
603
  iteration: iterationNum,
@@ -674,6 +721,35 @@ export class IterationOrchestrator {
674
721
  return
675
722
  }
676
723
 
724
+ // Workers that finished with nobody listening.
725
+ //
726
+ // A completion normally reaches the supervisor as the
727
+ // `tool_result` of the `create_task` that launched it. Two
728
+ // cases have no such call: a launch made in the background on
729
+ // purpose, and a blocking launch whose deadline passed — the
730
+ // model was told "timed out, it may still be running" and the
731
+ // worker then finished, holding a result nothing would read.
732
+ //
733
+ // This is the channel that was removed in `dc16d58` because it
734
+ // double-delivered: it fired for completions the blocking tool
735
+ // had already handed over, so the supervisor saw each result
736
+ // twice. The inbox restores it with the distinction that was
737
+ // missing — a tool that delivers a completion claims it, and
738
+ // only unclaimed ones arrive here.
739
+ //
740
+ // Placed beside the advisory phase deliberately: that is the
741
+ // established seam for putting a user message in after tool
742
+ // results and before the next turn.
743
+ const unheard = this.ctx.completionInbox?.drain() ?? []
744
+ if (unheard.length > 0) {
745
+ this.ctx.log.info('Delivering unawaited task completions', {
746
+ runId: runMgr.id,
747
+ iteration: iterationNum,
748
+ tasks: unheard.map((h) => h.taskId),
749
+ })
750
+ runMgr.pushMessage(createUserMessage(formatCompletionNotification(unheard)))
751
+ }
752
+
677
753
  await runAdvisoryPhase(this.ctx, iterationNum, response)
678
754
 
679
755
  if (this.ctx.pluginManager) {
@@ -3,6 +3,7 @@ import type { AgentBus } from '../../../../bus/index.js'
3
3
  import type { WorkingStateManager } from '../../../../compaction/manager.js'
4
4
  import type { ContextReducer } from '../../../../compaction/reducer.js'
5
5
  import type { CompactionConfig } from '../../../../config/runtime.js'
6
+ import type { CompletionInbox } from '../../../../gateway/completion-inbox.js'
6
7
  import type { PlanManager } from '../../../../manager/plan/lifecycle.js'
7
8
  import type { RunPersistence } from '../../../../manager/run/persistence.js'
8
9
  import type { ActivityStore } from '../../../../store/activity/memory.js'
@@ -103,6 +104,15 @@ export interface IterationContext {
103
104
 
104
105
  readonly taskGateway?: TaskGateway
105
106
 
107
+ /**
108
+ * Completions no call is waiting for, on their way to the transcript.
109
+ *
110
+ * Absent means the loop behaves exactly as it did before this existed:
111
+ * a blocking `create_task` still delivers its own result, and a
112
+ * completion nobody awaited is simply never mentioned.
113
+ */
114
+ readonly completionInbox?: CompletionInbox
115
+
106
116
  readonly taskStore?: TaskStore
107
117
 
108
118
  readonly launchedTasks: Map<TaskId, LaunchedTaskMeta>
@@ -129,6 +129,12 @@ export class ResultAssembler {
129
129
  providerId: err.providerId,
130
130
  ...(err.status !== undefined ? { status: err.status } : {}),
131
131
  ...(err.retryAfterMs !== undefined ? { retryAfterMs: err.retryAfterMs } : {}),
132
+ // The provider's own sentence, already truncated and scrubbed
133
+ // by the driver. Without it a host rendering this metadata
134
+ // knows a request was rejected but not which field, and has to
135
+ // go re-parse `error` to find out — which is exactly the
136
+ // re-parsing the line above says this exists to avoid.
137
+ ...(err.detail !== undefined ? { detail: err.detail } : {}),
132
138
  }
133
139
  : undefined
134
140
  runMgr.markFailed(errorMessage, providerError)
@@ -0,0 +1,347 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { CompletionInbox } from '../../../gateway/completion-inbox.js'
4
+ import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
5
+ import type { TaskId } from '../../../types/ids/index.js'
6
+ import type { ToolDefinition } from '../../../types/tool/index.js'
7
+ import { DELEGATION_TIMEOUT_MS, buildCoordinatorTools } from '../index.js'
8
+
9
+ /**
10
+ * Who claims a completion, and who is left to announce it.
11
+ *
12
+ * A worker's output normally rides back as the `tool_result` of the
13
+ * `create_task` that launched it. Two situations have no such call:
14
+ *
15
+ * - the launch was made in the background on purpose;
16
+ * - the launch blocked, the executor's deadline passed, and the model was
17
+ * told *"timed out… it may still be running"*. The worker then finished
18
+ * normally and its result had no reader at all.
19
+ *
20
+ * The second one was the live defect: a supervisor whose `create_task` timed
21
+ * out lost both the task id and the output, and `agent_task_list` — the only
22
+ * tool it had left — reported id, state and duration while withholding the
23
+ * one field it was looking for. `agent_task_list` in a sleep loop was not the
24
+ * model misbehaving; it was the only move on the board.
25
+ *
26
+ * These tests drive the claim/announce split from the tool side, because the
27
+ * inbox can only tell a delivered completion from an abandoned one if the
28
+ * tools tell it the truth.
29
+ */
30
+
31
+ interface Harness {
32
+ tools: ToolDefinition[]
33
+ inbox: CompletionInbox
34
+ settle: (handle: TaskHandle) => void
35
+ /** Resolve the pending `waitForTask` for this task. */
36
+ finish: (taskId: string, result: string) => void
37
+ }
38
+
39
+ function harness(opts: { autoFinish?: boolean } = {}): Harness {
40
+ const listeners = new Set<(h: TaskHandle) => void>()
41
+ const waiters = new Map<string, (h: TaskHandle) => void>()
42
+ const handles = new Map<string, TaskHandle>()
43
+ let seq = 0
44
+
45
+ const makeHandle = (taskId: string, result?: string): TaskHandle => ({
46
+ taskId: taskId as TaskId,
47
+ agentId: 'reviewer',
48
+ state: 'completed',
49
+ createdAt: 1_000,
50
+ completedAt: 2_000,
51
+ ...(result !== undefined
52
+ ? { result: { status: 'completed', result } as TaskHandle['result'] }
53
+ : {}),
54
+ })
55
+
56
+ const gateway = {
57
+ createTask: async () => {
58
+ seq += 1
59
+ const taskId = `tsk_${seq}`
60
+ const handle: TaskHandle = { ...makeHandle(taskId), state: 'running' }
61
+ handles.set(taskId, handle)
62
+ return handle
63
+ },
64
+ waitForTask: (taskId: TaskId) =>
65
+ new Promise<TaskHandle>((resolve) => {
66
+ if (opts.autoFinish) {
67
+ resolve(makeHandle(taskId, 'the worker output'))
68
+ return
69
+ }
70
+ waiters.set(taskId, resolve)
71
+ }),
72
+ getTask: (taskId: TaskId) => handles.get(taskId),
73
+ listTasks: () => [...handles.values()],
74
+ cancelTask: () => undefined,
75
+ continueTask: async () => undefined,
76
+ onTaskCompleted: (cb: (h: TaskHandle) => void) => {
77
+ listeners.add(cb)
78
+ return () => listeners.delete(cb)
79
+ },
80
+ } as unknown as TaskGateway
81
+
82
+ const inbox = new CompletionInbox()
83
+ inbox.attach(gateway)
84
+
85
+ return {
86
+ inbox,
87
+ tools: buildCoordinatorTools({
88
+ gateway,
89
+ completionInbox: inbox,
90
+ workingDirectory: '/tmp/test',
91
+ allowedAgentIds: ['reviewer'],
92
+ }),
93
+ settle: (handle) => {
94
+ handles.set(handle.taskId, handle)
95
+ for (const cb of listeners) cb(handle)
96
+ },
97
+ finish: (taskId, result) => {
98
+ const handle = makeHandle(taskId, result)
99
+ handles.set(taskId, handle)
100
+ waiters.get(taskId)?.(handle)
101
+ },
102
+ }
103
+ }
104
+
105
+ function toolNamed(tools: ToolDefinition[], name: string): ToolDefinition {
106
+ const tool = tools.find((t) => t.name === name)
107
+ if (!tool) throw new Error(`${name} is not registered`)
108
+ return tool
109
+ }
110
+
111
+ describe('the supervisor has a tool for every move it needs', () => {
112
+ it('registers a way to wait, so polling is no longer the only option', () => {
113
+ // Before this, waiting meant `continue_task` — which blocks only as a
114
+ // side effect of sending a message, so a supervisor that just wanted
115
+ // to wait had to invent something to say.
116
+ const names = harness().tools.map((t) => t.name)
117
+
118
+ expect(names).toContain('wait_for_task')
119
+ })
120
+
121
+ it('registers a way to cancel, which background launching makes necessary', () => {
122
+ // `cancel_task` was defined in this file and never returned from it.
123
+ // It matters now: a background launch can leave a worker running with
124
+ // nothing waiting on it, and without this the supervisor could start
125
+ // one it had no way to stop.
126
+ expect(harness().tools.map((t) => t.name)).toContain('cancel_task')
127
+ })
128
+
129
+ it('gives the launching tool a deadline a real worker can meet', () => {
130
+ // The run default is two minutes; a delegated worker doing real work
131
+ // takes longer, and every expiry past that point produced the state
132
+ // this whole change exists to repair. Not firing at all beats
133
+ // recovering well.
134
+ expect(toolNamed(harness().tools, 'create_task').timeoutMs).toBe(DELEGATION_TIMEOUT_MS)
135
+ })
136
+
137
+ it('gives the waiting tool a deadline longer than the executor default', () => {
138
+ // A tool whose entire job is to wait must not be killed for waiting.
139
+ // `ToolExecutor` reads `timeoutMs` before falling back to the run
140
+ // default, so declaring one here is the supported way to say so.
141
+ expect(toolNamed(harness().tools, 'wait_for_task').timeoutMs).toBe(DELEGATION_TIMEOUT_MS)
142
+ })
143
+
144
+ it('mounts none of them when there is no roster to delegate to', () => {
145
+ const tools = buildCoordinatorTools({
146
+ gateway: { onTaskCompleted: () => () => {} } as unknown as TaskGateway,
147
+ workingDirectory: '/tmp/test',
148
+ allowedAgentIds: [],
149
+ })
150
+
151
+ expect(tools.map((t) => t.name)).toEqual(['agent_task_list'])
152
+ })
153
+ })
154
+
155
+ describe('a blocking launch claims its own completion', () => {
156
+ it('delivers the output inline and leaves nothing to announce', () => {
157
+ // The `dc16d58` case: this result reached the model as a tool_result,
158
+ // so an envelope carrying it again would be the duplicate delivery
159
+ // that removal fixed.
160
+ const h = harness({ autoFinish: true })
161
+
162
+ return toolNamed(h.tools, 'create_task')
163
+ .execute({ agent_id: 'reviewer', prompt: 'go', description: 'review' }, {} as never)
164
+ .then((result) => {
165
+ expect(result.success).toBe(true)
166
+ expect(result.output).toContain('the worker output')
167
+ expect(h.inbox.drain()).toEqual([])
168
+ })
169
+ })
170
+ })
171
+
172
+ describe('an abandoned launch leaves its completion to be announced', () => {
173
+ it('does not claim when the executor already gave up on the call', async () => {
174
+ // This is the whole fix. The executor's deadline passed, the model was
175
+ // told the tool timed out, and whatever this call returns now is
176
+ // discarded — so claiming here would delete the worker's output for
177
+ // good.
178
+ const h = harness({ autoFinish: true })
179
+ const aborted = AbortSignal.abort()
180
+
181
+ const result = await toolNamed(h.tools, 'create_task').execute(
182
+ { agent_id: 'reviewer', prompt: 'go', description: 'review' },
183
+ { abortSignal: aborted } as never,
184
+ )
185
+
186
+ expect(result.success).toBe(false)
187
+ // It also says where the result WILL turn up, rather than leaving the
188
+ // model to conclude the work was lost.
189
+ expect(result.output).toContain('task notification')
190
+
191
+ h.settle({
192
+ taskId: 'tsk_1' as TaskId,
193
+ agentId: 'reviewer',
194
+ state: 'completed',
195
+ createdAt: 1_000,
196
+ completedAt: 2_000,
197
+ result: {
198
+ status: 'completed',
199
+ result: 'the worker output',
200
+ } as TaskHandle['result'],
201
+ })
202
+
203
+ expect(h.inbox.drain().map((x) => x.taskId)).toEqual(['tsk_1'])
204
+ })
205
+ })
206
+
207
+ describe('a background launch returns a handle instead of a result', () => {
208
+ it('hands back the task id straight away and does not wait', async () => {
209
+ // `autoFinish` is off, so `waitForTask` never resolves here. If the
210
+ // background path awaited it, this test would hang — which is the
211
+ // point: returning is what lets the supervisor keep working.
212
+ const h = harness()
213
+
214
+ const result = await toolNamed(h.tools, 'create_task').execute(
215
+ {
216
+ agent_id: 'reviewer',
217
+ prompt: 'go',
218
+ description: 'review',
219
+ background: true,
220
+ },
221
+ {} as never,
222
+ )
223
+
224
+ expect(result.success).toBe(true)
225
+ expect((result.data as { task_id?: string } | undefined)?.task_id).toBe('tsk_1')
226
+ // In `output` too, not only in `data` — the executor builds the
227
+ // tool_result from `output` alone, so an id that lives only in `data`
228
+ // is an id the model never receives.
229
+ expect(result.output).toContain('tsk_1')
230
+ // And it tells the model what happens next, so "launched" does not
231
+ // read as "finished".
232
+ expect(result.output).toContain('task notification')
233
+ })
234
+
235
+ it('leaves the completion for the transcript', async () => {
236
+ const h = harness()
237
+ await toolNamed(h.tools, 'create_task').execute(
238
+ {
239
+ agent_id: 'reviewer',
240
+ prompt: 'go',
241
+ description: 'review',
242
+ background: true,
243
+ },
244
+ {} as never,
245
+ )
246
+
247
+ h.settle({
248
+ taskId: 'tsk_1' as TaskId,
249
+ agentId: 'reviewer',
250
+ state: 'completed',
251
+ createdAt: 1_000,
252
+ completedAt: 2_000,
253
+ result: { status: 'completed', result: 'done' } as TaskHandle['result'],
254
+ })
255
+
256
+ expect(h.inbox.drain()).toHaveLength(1)
257
+ })
258
+ })
259
+
260
+ describe('waiting explicitly beats listing in a loop', () => {
261
+ it('returns the output and claims it', async () => {
262
+ const h = harness({ autoFinish: true })
263
+ await toolNamed(h.tools, 'create_task').execute(
264
+ {
265
+ agent_id: 'reviewer',
266
+ prompt: 'go',
267
+ description: 'review',
268
+ background: true,
269
+ },
270
+ {} as never,
271
+ )
272
+
273
+ const result = await toolNamed(h.tools, 'wait_for_task').execute(
274
+ { task_id: 'tsk_1' },
275
+ {} as never,
276
+ )
277
+
278
+ expect(result.success).toBe(true)
279
+ expect(result.output).toContain('the worker output')
280
+ // Claimed, so it is not also announced.
281
+ expect(h.inbox.drain()).toEqual([])
282
+ })
283
+
284
+ it('says so plainly rather than hanging on an id that does not exist', async () => {
285
+ const h = harness()
286
+
287
+ const result = await toolNamed(h.tools, 'wait_for_task').execute(
288
+ { task_id: 'tsk_nope' },
289
+ {} as never,
290
+ )
291
+
292
+ expect(result.success).toBe(false)
293
+ expect(result.output).toContain('No task')
294
+ })
295
+ })
296
+
297
+ describe('the task listing carries the output it always had', () => {
298
+ async function listWith(result: string): Promise<string> {
299
+ const h = harness()
300
+ h.settle({
301
+ taskId: 'tsk_1' as TaskId,
302
+ agentId: 'reviewer',
303
+ state: 'completed',
304
+ createdAt: 1_000,
305
+ completedAt: 2_000,
306
+ result: { status: 'completed', result } as TaskHandle['result'],
307
+ })
308
+ const listed = await toolNamed(h.tools, 'agent_task_list').execute({}, {} as never)
309
+ return listed.output
310
+ }
311
+
312
+ it('puts the worker result where the model can actually read it', async () => {
313
+ // Two failures stacked here. The projection read `h.result` for the
314
+ // status and the error and stopped one property short of the thing the
315
+ // task was launched to produce — and the obvious repair, adding it to
316
+ // `data`, would have been invisible: the executor builds the
317
+ // tool_result from `output` alone and never reads `data`. So the fix
318
+ // has to land in the RENDERED text, and this asserts on `output` for
319
+ // that reason.
320
+ expect(await listWith('the findings')).toContain('the findings')
321
+ })
322
+
323
+ it('names the tool that fetches the rest when it truncates', async () => {
324
+ const output = await listWith('x'.repeat(6_000))
325
+
326
+ expect(output).toContain('truncated')
327
+ expect(output).toContain('wait_for_task with "tsk_1"')
328
+ })
329
+
330
+ it('still lists a task that has produced nothing yet', async () => {
331
+ const h = harness()
332
+ await toolNamed(h.tools, 'create_task').execute(
333
+ {
334
+ agent_id: 'reviewer',
335
+ prompt: 'go',
336
+ description: 'review',
337
+ background: true,
338
+ },
339
+ {} as never,
340
+ )
341
+
342
+ const listed = await toolNamed(h.tools, 'agent_task_list').execute({}, {} as never)
343
+
344
+ expect(listed.output).toContain('tsk_1')
345
+ expect(listed.output).toContain('running')
346
+ })
347
+ })
@@ -180,7 +180,7 @@ describe('coordinator agent_task_list tool', () => {
180
180
  expect(names).not.toContain('task_list')
181
181
  })
182
182
 
183
- it('does not advertise per-task cancellation on the blocking coordinator surface', () => {
183
+ it('advertises per-task cancellation now that a task can outlive its launch', () => {
184
184
  const coordinatorTools = buildCoordinatorTools({
185
185
  gateway: gatewayWith([]),
186
186
  workingDirectory: '/tmp/test',
@@ -188,11 +188,17 @@ describe('coordinator agent_task_list tool', () => {
188
188
  })
189
189
  const names = coordinatorTools.map((tool) => tool.name)
190
190
 
191
- // create_task returns only after the worker is terminal, so the
192
- // supervisor cannot know a live task id in a later model turn.
193
- // Keeping cancel_task here only manufactured success for missing
194
- // or terminal ids because every gateway cancellation is a void
195
- // no-op in those states.
196
- expect(names).not.toContain('cancel_task')
191
+ // This assertion used to be its own inverse, and the reasoning was
192
+ // correct at the time: create_task returned only after the worker was
193
+ // terminal, so the supervisor could never hold a LIVE task id in a
194
+ // later turn, and cancel_task could only manufacture success for
195
+ // something already finished.
196
+ //
197
+ // `background: true` reinstates the precondition that argument rested
198
+ // on. A background launch hands back an id while the worker is still
199
+ // running, and a supervisor able to start one it cannot stop is a
200
+ // hole — so the tool comes back with the capability that makes it
201
+ // mean something.
202
+ expect(names).toContain('cancel_task')
197
203
  })
198
204
  })