@namzu/sdk 6.2.0 → 8.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 (251) hide show
  1. package/CHANGELOG.md +677 -0
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +5 -0
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  6. package/dist/agents/SupervisorAgent.js +172 -158
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts +2 -0
  9. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts.map +1 -0
  10. package/dist/agents/__tests__/supervisor-inbox-scope.test.js +125 -0
  11. package/dist/agents/__tests__/supervisor-inbox-scope.test.js.map +1 -0
  12. package/dist/agents/runAgent.d.ts +19 -1
  13. package/dist/agents/runAgent.d.ts.map +1 -1
  14. package/dist/agents/runAgent.js +2 -0
  15. package/dist/agents/runAgent.js.map +1 -1
  16. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  17. package/dist/bridge/a2a/mapper.js +4 -0
  18. package/dist/bridge/a2a/mapper.js.map +1 -1
  19. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  20. package/dist/bridge/sse/mapper.js +24 -0
  21. package/dist/bridge/sse/mapper.js.map +1 -1
  22. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
  23. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
  24. package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
  25. package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
  26. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  27. package/dist/connector/mcp/adapter.js +123 -8
  28. package/dist/connector/mcp/adapter.js.map +1 -1
  29. package/dist/constants/agent/index.d.ts +5 -0
  30. package/dist/constants/agent/index.d.ts.map +1 -1
  31. package/dist/constants/agent/index.js +5 -0
  32. package/dist/constants/agent/index.js.map +1 -1
  33. package/dist/constants/plugin/index.d.ts +15 -0
  34. package/dist/constants/plugin/index.d.ts.map +1 -1
  35. package/dist/constants/plugin/index.js +15 -0
  36. package/dist/constants/plugin/index.js.map +1 -1
  37. package/dist/contracts/api.d.ts +1 -1
  38. package/dist/contracts/api.d.ts.map +1 -1
  39. package/dist/gateway/__tests__/completion-inbox.test.js +292 -2
  40. package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
  41. package/dist/gateway/completion-inbox.d.ts +94 -6
  42. package/dist/gateway/completion-inbox.d.ts.map +1 -1
  43. package/dist/gateway/completion-inbox.js +235 -15
  44. package/dist/gateway/completion-inbox.js.map +1 -1
  45. package/dist/gateway/local.d.ts +11 -0
  46. package/dist/gateway/local.d.ts.map +1 -1
  47. package/dist/gateway/local.js +27 -1
  48. package/dist/gateway/local.js.map +1 -1
  49. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  50. package/dist/manager/agent/lifecycle.js +6 -0
  51. package/dist/manager/agent/lifecycle.js.map +1 -1
  52. package/dist/manager/run/persistence.d.ts +8 -0
  53. package/dist/manager/run/persistence.d.ts.map +1 -1
  54. package/dist/manager/run/persistence.js +12 -0
  55. package/dist/manager/run/persistence.js.map +1 -1
  56. package/dist/provider/thinking-support.d.ts +2 -1
  57. package/dist/provider/thinking-support.d.ts.map +1 -1
  58. package/dist/provider/thinking-support.js +14 -0
  59. package/dist/provider/thinking-support.js.map +1 -1
  60. package/dist/public-runtime.d.ts +1 -1
  61. package/dist/public-runtime.d.ts.map +1 -1
  62. package/dist/public-runtime.js +9 -1
  63. package/dist/public-runtime.js.map +1 -1
  64. package/dist/run/reporter.d.ts.map +1 -1
  65. package/dist/run/reporter.js +11 -0
  66. package/dist/run/reporter.js.map +1 -1
  67. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts +2 -0
  68. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts.map +1 -0
  69. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js +142 -0
  70. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js.map +1 -0
  71. package/dist/runtime/query/__tests__/completion-notification.test.js +414 -32
  72. package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -1
  73. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts +2 -0
  74. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts.map +1 -0
  75. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js +100 -0
  76. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js.map +1 -0
  77. package/dist/runtime/query/__tests__/context.test.js +18 -0
  78. package/dist/runtime/query/__tests__/context.test.js.map +1 -1
  79. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
  80. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
  81. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
  82. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
  83. package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
  84. package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
  85. package/dist/runtime/query/context.d.ts.map +1 -1
  86. package/dist/runtime/query/context.js +16 -1
  87. package/dist/runtime/query/context.js.map +1 -1
  88. package/dist/runtime/query/executor.d.ts.map +1 -1
  89. package/dist/runtime/query/executor.js +11 -1
  90. package/dist/runtime/query/executor.js.map +1 -1
  91. package/dist/runtime/query/guard.d.ts +28 -0
  92. package/dist/runtime/query/guard.d.ts.map +1 -1
  93. package/dist/runtime/query/guard.js +31 -0
  94. package/dist/runtime/query/guard.js.map +1 -1
  95. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts +2 -0
  96. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts.map +1 -0
  97. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js +226 -0
  98. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js.map +1 -0
  99. package/dist/runtime/query/iteration/index.d.ts +92 -0
  100. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  101. package/dist/runtime/query/iteration/index.js +818 -565
  102. package/dist/runtime/query/iteration/index.js.map +1 -1
  103. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts +2 -0
  104. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts.map +1 -0
  105. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js +95 -0
  106. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js.map +1 -0
  107. package/dist/runtime/query/iteration/phases/compaction.d.ts +34 -0
  108. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  109. package/dist/runtime/query/iteration/phases/compaction.js +61 -4
  110. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  111. package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
  112. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
  113. package/dist/telemetry/__tests__/span-closure.test.js +12 -5
  114. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
  115. package/dist/tools/__tests__/untrusted-envelope.test.js +16 -0
  116. package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -1
  117. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +117 -0
  118. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
  119. package/dist/tools/coordinator/__tests__/task-list.test.js +57 -0
  120. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  121. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
  122. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
  123. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
  124. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
  125. package/dist/tools/coordinator/index.d.ts +19 -0
  126. package/dist/tools/coordinator/index.d.ts.map +1 -1
  127. package/dist/tools/coordinator/index.js +191 -71
  128. package/dist/tools/coordinator/index.js.map +1 -1
  129. package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
  130. package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
  131. package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
  132. package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
  133. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  134. package/dist/tools/untrusted-envelope.js +9 -1
  135. package/dist/tools/untrusted-envelope.js.map +1 -1
  136. package/dist/types/agent/base.d.ts +16 -0
  137. package/dist/types/agent/base.d.ts.map +1 -1
  138. package/dist/types/agent/gateway.d.ts +41 -0
  139. package/dist/types/agent/gateway.d.ts.map +1 -1
  140. package/dist/types/agent/lifecycle-event.d.ts +9 -1
  141. package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
  142. package/dist/types/agent/task.d.ts +5 -0
  143. package/dist/types/agent/task.d.ts.map +1 -1
  144. package/dist/types/hitl/index.d.ts +10 -0
  145. package/dist/types/hitl/index.d.ts.map +1 -1
  146. package/dist/types/hitl/index.js.map +1 -1
  147. package/dist/types/probe/registry.d.ts +6 -0
  148. package/dist/types/probe/registry.d.ts.map +1 -1
  149. package/dist/types/provider/interface.d.ts +35 -0
  150. package/dist/types/provider/interface.d.ts.map +1 -1
  151. package/dist/types/run/config.d.ts +25 -0
  152. package/dist/types/run/config.d.ts.map +1 -1
  153. package/dist/types/run/entity.d.ts +16 -0
  154. package/dist/types/run/entity.d.ts.map +1 -1
  155. package/dist/types/run/events.d.ts +75 -0
  156. package/dist/types/run/events.d.ts.map +1 -1
  157. package/dist/types/run/events.js.map +1 -1
  158. package/dist/types/run/prepare-step.d.ts +17 -2
  159. package/dist/types/run/prepare-step.d.ts.map +1 -1
  160. package/dist/types/verification/index.d.ts +98 -0
  161. package/dist/types/verification/index.d.ts.map +1 -1
  162. package/dist/types/verification/index.js +10 -0
  163. package/dist/types/verification/index.js.map +1 -1
  164. package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
  165. package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
  166. package/dist/utils/__tests__/abort-reason.test.js +48 -0
  167. package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
  168. package/dist/utils/abort.d.ts +26 -0
  169. package/dist/utils/abort.d.ts.map +1 -1
  170. package/dist/utils/abort.js +34 -0
  171. package/dist/utils/abort.js.map +1 -1
  172. package/dist/verification/__tests__/argument-pattern.test.d.ts +2 -0
  173. package/dist/verification/__tests__/argument-pattern.test.d.ts.map +1 -0
  174. package/dist/verification/__tests__/argument-pattern.test.js +122 -0
  175. package/dist/verification/__tests__/argument-pattern.test.js.map +1 -0
  176. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
  177. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
  178. package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
  179. package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
  180. package/dist/verification/gate.d.ts +17 -1
  181. package/dist/verification/gate.d.ts.map +1 -1
  182. package/dist/verification/gate.js +102 -2
  183. package/dist/verification/gate.js.map +1 -1
  184. package/dist/verification/index.d.ts +1 -1
  185. package/dist/verification/index.d.ts.map +1 -1
  186. package/dist/verification/index.js +1 -1
  187. package/dist/verification/index.js.map +1 -1
  188. package/dist/verification/rules.d.ts.map +1 -1
  189. package/dist/verification/rules.js +27 -0
  190. package/dist/verification/rules.js.map +1 -1
  191. package/package.json +1 -1
  192. package/src/agents/ReactiveAgent.ts +5 -0
  193. package/src/agents/SupervisorAgent.ts +175 -162
  194. package/src/agents/__tests__/supervisor-inbox-scope.test.ts +149 -0
  195. package/src/agents/runAgent.ts +22 -1
  196. package/src/bridge/a2a/mapper.ts +4 -0
  197. package/src/bridge/sse/mapper.ts +25 -0
  198. package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
  199. package/src/connector/mcp/adapter.ts +131 -7
  200. package/src/constants/agent/index.ts +5 -0
  201. package/src/constants/plugin/index.ts +15 -0
  202. package/src/contracts/api.ts +1 -0
  203. package/src/gateway/__tests__/completion-inbox.test.ts +348 -2
  204. package/src/gateway/completion-inbox.ts +248 -16
  205. package/src/gateway/local.ts +26 -1
  206. package/src/manager/agent/lifecycle.ts +6 -0
  207. package/src/manager/run/persistence.ts +12 -0
  208. package/src/provider/thinking-support.ts +19 -2
  209. package/src/public-runtime.ts +9 -0
  210. package/src/run/reporter.ts +12 -0
  211. package/src/runtime/query/__tests__/completion-does-not-erase-the-answer.test.ts +163 -0
  212. package/src/runtime/query/__tests__/completion-notification.test.ts +486 -34
  213. package/src/runtime/query/__tests__/context-size-on-the-wire.test.ts +122 -0
  214. package/src/runtime/query/__tests__/context.test.ts +24 -0
  215. package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
  216. package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
  217. package/src/runtime/query/context.ts +16 -1
  218. package/src/runtime/query/executor.ts +11 -1
  219. package/src/runtime/query/guard.ts +32 -0
  220. package/src/runtime/query/iteration/__tests__/settle-grace.test.ts +265 -0
  221. package/src/runtime/query/iteration/index.ts +906 -635
  222. package/src/runtime/query/iteration/phases/__tests__/compaction-declined.test.ts +124 -0
  223. package/src/runtime/query/iteration/phases/compaction.ts +83 -10
  224. package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
  225. package/src/telemetry/__tests__/span-closure.test.ts +12 -5
  226. package/src/tools/__tests__/untrusted-envelope.test.ts +23 -0
  227. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +147 -0
  228. package/src/tools/coordinator/__tests__/task-list.test.ts +72 -0
  229. package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
  230. package/src/tools/coordinator/index.ts +205 -78
  231. package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
  232. package/src/tools/untrusted-envelope.ts +9 -1
  233. package/src/types/agent/base.ts +17 -0
  234. package/src/types/agent/gateway.ts +42 -0
  235. package/src/types/agent/lifecycle-event.ts +7 -0
  236. package/src/types/agent/task.ts +5 -0
  237. package/src/types/hitl/index.ts +10 -0
  238. package/src/types/probe/registry.ts +6 -0
  239. package/src/types/provider/interface.ts +39 -0
  240. package/src/types/run/config.ts +26 -0
  241. package/src/types/run/entity.ts +17 -0
  242. package/src/types/run/events.ts +75 -0
  243. package/src/types/run/prepare-step.ts +17 -2
  244. package/src/types/verification/index.ts +61 -0
  245. package/src/utils/__tests__/abort-reason.test.ts +56 -0
  246. package/src/utils/abort.ts +34 -0
  247. package/src/verification/__tests__/argument-pattern.test.ts +158 -0
  248. package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
  249. package/src/verification/gate.ts +106 -3
  250. package/src/verification/index.ts +1 -1
  251. package/src/verification/rules.ts +28 -0
@@ -10,7 +10,12 @@ import { defineTool } from '../../../tools/defineTool.js'
10
10
  import type { TaskHandle } from '../../../types/agent/gateway.js'
11
11
  import type { SessionId, TaskId, TenantId } from '../../../types/ids/index.js'
12
12
  import { createUserMessage } from '../../../types/message/index.js'
13
- import type { LLMProvider, StreamChunk } from '../../../types/provider/index.js'
13
+ import type { Message } from '../../../types/message/index.js'
14
+ import type {
15
+ ChatCompletionParams,
16
+ LLMProvider,
17
+ StreamChunk,
18
+ } from '../../../types/provider/index.js'
14
19
  import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
15
20
  import { drainQuery } from '../index.js'
16
21
 
@@ -39,9 +44,19 @@ class ToolThenAnswerProvider implements LLMProvider {
39
44
  readonly id = 'tool-then-answer'
40
45
  readonly name = 'Tool Then Answer Provider'
41
46
  calls = 0
47
+ /**
48
+ * What the model was actually SHOWN, turn by turn.
49
+ *
50
+ * `run.messages` proves a notification exists somewhere in the transcript.
51
+ * It does not prove the model ever saw it — a delivery that only happens on
52
+ * the way out satisfies the transcript and leaves the model with nothing to
53
+ * act on, which is the whole point of injecting mid-run.
54
+ */
55
+ readonly requests: Message[][] = []
42
56
 
43
- async *chatStream(): AsyncIterable<StreamChunk> {
57
+ async *chatStream(params: ChatCompletionParams): AsyncIterable<StreamChunk> {
44
58
  this.calls += 1
59
+ this.requests.push([...params.messages])
45
60
 
46
61
  if (this.calls === 1) {
47
62
  yield {
@@ -71,6 +86,43 @@ class ToolThenAnswerProvider implements LLMProvider {
71
86
  }
72
87
  }
73
88
 
89
+ /** The same two turns, aimed at the tool the exit tests register. */
90
+ class CallsFinisherProvider implements LLMProvider {
91
+ readonly id = 'calls-finisher'
92
+ readonly name = 'Calls Finisher Provider'
93
+ calls = 0
94
+
95
+ async *chatStream(): AsyncIterable<StreamChunk> {
96
+ this.calls += 1
97
+
98
+ if (this.calls === 1) {
99
+ yield {
100
+ id: 'msg_1',
101
+ delta: {
102
+ toolCalls: [
103
+ {
104
+ index: 0,
105
+ id: 'toolu_finisher_1',
106
+ type: 'function',
107
+ function: { name: 'finisher', arguments: '{}' },
108
+ },
109
+ ],
110
+ },
111
+ }
112
+ yield {
113
+ id: 'msg_1',
114
+ delta: {},
115
+ finishReason: 'tool_calls',
116
+ usage: ZERO_USAGE,
117
+ }
118
+ return
119
+ }
120
+
121
+ yield { id: 'msg_2', delta: { content: 'Done.' } }
122
+ yield { id: 'msg_2', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
123
+ }
124
+ }
125
+
74
126
  const noop = defineTool({
75
127
  name: 'noop',
76
128
  description: 'does nothing',
@@ -102,15 +154,18 @@ afterEach(async () => {
102
154
  workdirs.length = 0
103
155
  })
104
156
 
105
- async function runWith(inbox: CompletionInbox | undefined): Promise<string[]> {
157
+ async function runWith(
158
+ inbox: CompletionInbox | undefined,
159
+ ): Promise<{ userMessages: string[]; provider: ToolThenAnswerProvider }> {
106
160
  const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-completion-'))
107
161
  workdirs.push(workingDirectory)
108
162
 
109
163
  const tools = new ToolRegistry()
110
164
  tools.register(noop)
111
165
 
166
+ const provider = new ToolThenAnswerProvider()
112
167
  const run = await drainQuery({
113
- provider: new ToolThenAnswerProvider(),
168
+ provider,
114
169
  tools,
115
170
  ...(inbox ? { completionInbox: inbox } : {}),
116
171
  agentId: 'agent_test',
@@ -130,24 +185,36 @@ async function runWith(inbox: CompletionInbox | undefined): Promise<string[]> {
130
185
  tenantId: 'tnt_completion' as TenantId,
131
186
  })
132
187
 
133
- return run.messages
134
- .filter((m) => m.role === 'user')
135
- .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
188
+ return {
189
+ userMessages: run.messages
190
+ .filter((m) => m.role === 'user')
191
+ .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))),
192
+ provider,
193
+ }
194
+ }
195
+
196
+ /** Settled before the turn ended, with nothing waiting on it. */
197
+ function inboxHolding(taskId: string, result: string): CompletionInbox {
198
+ const inbox = new CompletionInbox()
199
+ // Said before the announcement because `create_task` says it on every
200
+ // launch: an inbox only hears about tasks its own run started, so a
201
+ // gateway shared between two supervisors cannot cross-deliver.
202
+ inbox.launched(taskId as TaskId)
203
+ inbox.attach({
204
+ onTaskCompleted: (cb: (h: TaskHandle) => void) => {
205
+ cb(completed(taskId, result))
206
+ return () => {}
207
+ },
208
+ getTask: () => undefined,
209
+ } as never)
210
+ return inbox
136
211
  }
137
212
 
138
213
  describe('an unclaimed completion reaches the transcript', () => {
139
214
  it('injects the notification as a user message the next turn can read', async () => {
140
- const inbox = new CompletionInbox()
141
- // Settled by a gateway before the turn ended, with nothing waiting on
142
- // it — the abandoned-launch case.
143
- inbox.attach({
144
- onTaskCompleted: (cb: (h: TaskHandle) => void) => {
145
- cb(completed('tsk_late', 'the worker finished after the wait was abandoned'))
146
- return () => {}
147
- },
148
- } as never)
215
+ const inbox = inboxHolding('tsk_late', 'the worker finished after the wait was abandoned')
149
216
 
150
- const userMessages = await runWith(inbox)
217
+ const { userMessages } = await runWith(inbox)
151
218
  const notification = userMessages.find((m) => m.includes('task-notification'))
152
219
 
153
220
  expect(notification).toBeDefined()
@@ -155,16 +222,36 @@ describe('an unclaimed completion reaches the transcript', () => {
155
222
  expect(notification).toContain('the worker finished after the wait was abandoned')
156
223
  })
157
224
 
225
+ it('shows it to the model on the very next turn, not eventually', async () => {
226
+ // Three sites now drain this inbox — the post-tool-batch injection, the
227
+ // bounded hold on the final-answer turn, and the exit-time delivery —
228
+ // and all three put the same text in `run.messages`. So an assertion on
229
+ // the transcript, or on the LAST model request, passes whichever of the
230
+ // three did the work, and none of them is pinned.
231
+ //
232
+ // The one that matters is the injection right after the tool batch,
233
+ // because it is the only one that reaches the model while there is
234
+ // still work to redirect. Hence the SECOND request specifically: with
235
+ // that injection gone the notification still arrives, one turn later,
236
+ // via the hold — later is a different behaviour, and this is the
237
+ // assertion that can tell them apart.
238
+ const inbox = inboxHolding('tsk_late', 'THE WORKER SAID THIS')
239
+
240
+ const { provider } = await runWith(inbox)
241
+
242
+ const turnAfterTheToolBatch = provider.requests[1] ?? []
243
+ expect(
244
+ turnAfterTheToolBatch.some(
245
+ (m) => typeof m.content === 'string' && m.content.includes('THE WORKER SAID THIS'),
246
+ ),
247
+ 'the model was not shown the completion on the turn after the tool batch',
248
+ ).toBe(true)
249
+ })
250
+
158
251
  it('drains it exactly once, however many turns follow', async () => {
159
- const inbox = new CompletionInbox()
160
- inbox.attach({
161
- onTaskCompleted: (cb: (h: TaskHandle) => void) => {
162
- cb(completed('tsk_late', 'only once'))
163
- return () => {}
164
- },
165
- } as never)
252
+ const inbox = inboxHolding('tsk_late', 'only once')
166
253
 
167
- const userMessages = await runWith(inbox)
254
+ const { userMessages } = await runWith(inbox)
168
255
 
169
256
  expect(userMessages.filter((m) => m.includes('task-notification'))).toHaveLength(1)
170
257
  })
@@ -172,16 +259,10 @@ describe('an unclaimed completion reaches the transcript', () => {
172
259
  it('says nothing when every completion was already delivered', async () => {
173
260
  // The `dc16d58` regression at the loop level: a blocking `create_task`
174
261
  // claims its own completion, so the transcript must stay clean.
175
- const inbox = new CompletionInbox()
176
- inbox.attach({
177
- onTaskCompleted: (cb: (h: TaskHandle) => void) => {
178
- cb(completed('tsk_awaited', 'delivered as a tool_result'))
179
- return () => {}
180
- },
181
- } as never)
262
+ const inbox = inboxHolding('tsk_awaited', 'delivered as a tool_result')
182
263
  inbox.claim('tsk_awaited' as TaskId)
183
264
 
184
- const userMessages = await runWith(inbox)
265
+ const { userMessages } = await runWith(inbox)
185
266
 
186
267
  expect(userMessages.some((m) => m.includes('task-notification'))).toBe(false)
187
268
  })
@@ -189,8 +270,379 @@ describe('an unclaimed completion reaches the transcript', () => {
189
270
  it('runs unchanged with no inbox at all', async () => {
190
271
  // The kernel must not require one: a host on the old wiring keeps
191
272
  // working, it just never hears about abandoned completions.
192
- const userMessages = await runWith(undefined)
273
+ const { userMessages } = await runWith(undefined)
193
274
 
194
275
  expect(userMessages.some((m) => m.includes('task-notification'))).toBe(false)
195
276
  })
196
277
  })
278
+
279
+ /**
280
+ * The exits that are not the ordinary final answer.
281
+ *
282
+ * The inbox was consulted at exactly one site, inside the no-tool-calls branch.
283
+ * The loop leaves by eight other routes, and three of them are ways a run
284
+ * legitimately ENDS: a tool the author declared terminal, a captured structured
285
+ * output, and the host's `stopWhen`. A worker that finished while any of those
286
+ * was deciding had its output dropped on the floor — the gateway held the
287
+ * result, the run closed, and nothing ever read it.
288
+ *
289
+ * These drive `drainQuery` rather than the helper directly, and that is the
290
+ * point: the delivery happens in the loop, so a unit test on the inbox proves
291
+ * nothing about whether the loop reaches it.
292
+ */
293
+ describe('a run that ends some other way still hands over what finished', () => {
294
+ async function runEndingWith(options: {
295
+ terminal?: boolean
296
+ stopWhen?: boolean
297
+ }): Promise<{ userMessages: string[]; run: Awaited<ReturnType<typeof drainQuery>> }> {
298
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-completion-exit-'))
299
+ workdirs.push(workingDirectory)
300
+
301
+ const inbox = new CompletionInbox()
302
+ inbox.launched('tsk_bg' as TaskId)
303
+ let announce: ((h: TaskHandle) => void) | undefined
304
+ inbox.attach({
305
+ onTaskCompleted: (cb: (h: TaskHandle) => void) => {
306
+ announce = cb
307
+ return () => {
308
+ announce = undefined
309
+ }
310
+ },
311
+ getTask: () => undefined,
312
+ } as never)
313
+
314
+ // Settles from inside the tool call, so the completion is in hand
315
+ // BEFORE the exit is decided. The question under test is which exits
316
+ // hand it over, not whether the wait works.
317
+ const finisher = defineTool({
318
+ name: 'finisher',
319
+ description: 'the worker finishes while this runs',
320
+ inputSchema: z.object({}),
321
+ category: 'analysis',
322
+ permissions: [],
323
+ readOnly: true,
324
+ destructive: false,
325
+ concurrencySafe: true,
326
+ ...(options.terminal ? { terminal: true } : {}),
327
+ async execute() {
328
+ announce?.(completed('tsk_bg', 'THE BACKGROUND WORKER RESULT'))
329
+ return { success: true, output: 'this call is the answer' }
330
+ },
331
+ })
332
+
333
+ const tools = new ToolRegistry()
334
+ tools.register(finisher)
335
+
336
+ const run = await drainQuery({
337
+ provider: new CallsFinisherProvider(),
338
+ tools,
339
+ completionInbox: inbox,
340
+ agentId: 'agent_test',
341
+ agentName: 'Test Agent',
342
+ messages: [createUserMessage('delegate and report')],
343
+ workingDirectory,
344
+ ...(options.stopWhen ? { stopWhen: () => true } : {}),
345
+ runConfig: {
346
+ model: 'mock-model',
347
+ timeoutMs: 10_000,
348
+ tokenBudget: 100_000,
349
+ maxIterations: 4,
350
+ maxResponseTokens: 256,
351
+ },
352
+ sessionId: 'ses_completion' as SessionId,
353
+ threadId: 'thd_completion' as ThreadId,
354
+ projectId: 'prj_completion' as ProjectId,
355
+ tenantId: 'tnt_completion' as TenantId,
356
+ })
357
+
358
+ return {
359
+ userMessages: run.messages
360
+ .filter((m) => m.role === 'user')
361
+ .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content))),
362
+ run,
363
+ }
364
+ }
365
+
366
+ it('a terminal tool settles the run without discarding the worker', async () => {
367
+ const { userMessages } = await runEndingWith({ terminal: true })
368
+
369
+ expect(
370
+ userMessages.some((m) => m.includes('THE BACKGROUND WORKER RESULT')),
371
+ 'the terminal-tool exit dropped a finished completion',
372
+ ).toBe(true)
373
+ })
374
+
375
+ it("the host's stopWhen ends the run without discarding it either", async () => {
376
+ const { userMessages } = await runEndingWith({ stopWhen: true })
377
+
378
+ expect(
379
+ userMessages.some((m) => m.includes('THE BACKGROUND WORKER RESULT')),
380
+ 'the stopWhen exit dropped a finished completion',
381
+ ).toBe(true)
382
+ })
383
+
384
+ it('lets a worker that has not arrived yet outrank the host stop predicate, once', async () => {
385
+ // A chosen precedence, not something `stopWhen` implies. A stop
386
+ // predicate is a programmable halt that says nothing about whether the
387
+ // answer is complete — unlike a terminal tool or a captured structured
388
+ // output, which decide the result and leave no turn to use anything in.
389
+ // So this one waits, delivers, and gives the model the turn; the
390
+ // predicate fires again next turn with nothing pending and stops. One
391
+ // extra turn, bounded.
392
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-stopwhen-hold-'))
393
+ workdirs.push(workingDirectory)
394
+
395
+ const inbox = new CompletionInbox()
396
+ let announce: ((h: TaskHandle) => void) | undefined
397
+ inbox.attach({
398
+ onTaskCompleted: (cb: (h: TaskHandle) => void) => {
399
+ announce = cb
400
+ return () => {
401
+ announce = undefined
402
+ }
403
+ },
404
+ getTask: () => undefined,
405
+ } as never)
406
+ // OUTSTANDING, not arrived: the exit-time delivery has nothing to hand
407
+ // over, so only a hold can produce this result.
408
+ inbox.expect('tsk_slow' as TaskId)
409
+
410
+ const tools = new ToolRegistry()
411
+ tools.register(
412
+ defineTool({
413
+ name: 'finisher',
414
+ description: 'the worker is still going when this returns',
415
+ inputSchema: z.object({}),
416
+ category: 'analysis',
417
+ permissions: [],
418
+ readOnly: true,
419
+ destructive: false,
420
+ concurrencySafe: true,
421
+ async execute() {
422
+ setTimeout(() => announce?.(completed('tsk_slow', 'LATE WORKER RESULT')), 20).unref?.()
423
+ return { success: true, output: 'launched' }
424
+ },
425
+ }),
426
+ )
427
+
428
+ const provider = new CallsFinisherProvider()
429
+ const run = await drainQuery({
430
+ provider,
431
+ tools,
432
+ completionInbox: inbox,
433
+ agentId: 'agent_test',
434
+ agentName: 'Test Agent',
435
+ messages: [createUserMessage('go')],
436
+ workingDirectory,
437
+ stopWhen: () => true,
438
+ runConfig: {
439
+ model: 'mock-model',
440
+ timeoutMs: 20_000,
441
+ tokenBudget: 100_000,
442
+ maxIterations: 4,
443
+ maxResponseTokens: 256,
444
+ },
445
+ sessionId: 'ses_completion' as SessionId,
446
+ threadId: 'thd_completion' as ThreadId,
447
+ projectId: 'prj_completion' as ProjectId,
448
+ tenantId: 'tnt_completion' as TenantId,
449
+ } as never)
450
+
451
+ const userText = (run.messages as { role: string; content: unknown }[])
452
+ .filter((m) => m.role === 'user')
453
+ .map((m) => (typeof m.content === 'string' ? m.content : ''))
454
+
455
+ expect(
456
+ userText.some((m) => m.includes('LATE WORKER RESULT')),
457
+ 'the stop predicate discarded a worker that had not landed yet',
458
+ ).toBe(true)
459
+ // The extra turn is the point: the model was asked again, once.
460
+ expect(provider.calls, 'the model never got a turn to use the result').toBe(2)
461
+
462
+ // And the run says WHY it is over. The extra turn is prose, and
463
+ // `stopWhen` is consulted only after a tool batch — deliberately, so a
464
+ // predicate can see what the tools returned — so the predicate is never
465
+ // asked again and the run leaves by the ordinary route. Reporting
466
+ // `end_turn` there would name the shape of the last message rather than
467
+ // the host's decision, and this repo carries thirteen `StopReason`
468
+ // values precisely so a run that ends for a nameable reason names it.
469
+ expect(run.stopReason).toBe('stop_condition')
470
+ }, 60_000)
471
+
472
+ it('forgets the deferral if the extra turn did not end the run', async () => {
473
+ // The lifetime is the whole safety of the flag. It is set at the end of
474
+ // one iteration and read by the next, and the next clears it whether or
475
+ // not anything read it — so a deferral cannot colour a stop reason two
476
+ // turns later, when the predicate has since been asked again and said
477
+ // no. A flag that outlived its continuation would put `stop_condition`
478
+ // on a run the host had stopped asking to stop.
479
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-defer-life-'))
480
+ workdirs.push(workingDirectory)
481
+
482
+ const inbox = new CompletionInbox()
483
+ let announce: ((h: TaskHandle) => void) | undefined
484
+ inbox.attach({
485
+ onTaskCompleted: (cb: (h: TaskHandle) => void) => {
486
+ announce = cb
487
+ return () => {
488
+ announce = undefined
489
+ }
490
+ },
491
+ getTask: () => undefined,
492
+ } as never)
493
+ inbox.expect('tsk_slow' as TaskId)
494
+
495
+ const tools = new ToolRegistry()
496
+ tools.register(
497
+ defineTool({
498
+ name: 'finisher',
499
+ description: 'launches, then does nothing',
500
+ inputSchema: z.object({}),
501
+ category: 'analysis',
502
+ permissions: [],
503
+ readOnly: true,
504
+ destructive: false,
505
+ concurrencySafe: true,
506
+ async execute() {
507
+ setTimeout(() => announce?.(completed('tsk_slow', 'LATE RESULT')), 20).unref?.()
508
+ return { success: true, output: 'ok' }
509
+ },
510
+ }),
511
+ )
512
+
513
+ /** Tool, tool, prose — so the predicate is asked twice. */
514
+ class ToolToolProse implements LLMProvider {
515
+ readonly id = 'tool-tool-prose'
516
+ readonly name = 'Tool Tool Prose'
517
+ calls = 0
518
+ async *chatStream(): AsyncIterable<StreamChunk> {
519
+ this.calls += 1
520
+ if (this.calls <= 2) {
521
+ yield {
522
+ id: `m${this.calls}`,
523
+ delta: {
524
+ toolCalls: [
525
+ {
526
+ index: 0,
527
+ id: `toolu_${this.calls}`,
528
+ type: 'function',
529
+ function: { name: 'finisher', arguments: '{}' },
530
+ },
531
+ ],
532
+ },
533
+ }
534
+ yield { id: `m${this.calls}`, delta: {}, finishReason: 'tool_calls', usage: ZERO_USAGE }
535
+ return
536
+ }
537
+ yield { id: 'm3', delta: { content: 'Done.' } }
538
+ yield { id: 'm3', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
539
+ }
540
+ }
541
+
542
+ // True once — the deferral — then never again.
543
+ let asked = 0
544
+ const run = await drainQuery({
545
+ provider: new ToolToolProse(),
546
+ tools,
547
+ completionInbox: inbox,
548
+ agentId: 'agent_test',
549
+ agentName: 'Test Agent',
550
+ messages: [createUserMessage('go')],
551
+ workingDirectory,
552
+ stopWhen: () => ++asked === 1,
553
+ runConfig: {
554
+ model: 'mock-model',
555
+ timeoutMs: 20_000,
556
+ tokenBudget: 100_000,
557
+ maxIterations: 6,
558
+ maxResponseTokens: 256,
559
+ },
560
+ sessionId: 'ses_completion' as SessionId,
561
+ threadId: 'thd_completion' as ThreadId,
562
+ projectId: 'prj_completion' as ProjectId,
563
+ tenantId: 'tnt_completion' as TenantId,
564
+ } as never)
565
+
566
+ expect(asked, 'the predicate was never asked a second time').toBeGreaterThan(1)
567
+ expect(run.stopReason, 'a spent deferral coloured a later stop reason').toBe('end_turn')
568
+ }, 60_000)
569
+
570
+ it('names the work it walked away from, without cancelling it', async () => {
571
+ // The other half of the principle. Delivering what arrived is honest;
572
+ // staying silent about what did not is the false impression the whole
573
+ // mechanism exists to prevent. Naming rather than cancelling, because
574
+ // "the parent answered early" is a weaker warrant for killing a child
575
+ // than "the clock ran out" — and this subsystem already ruled that the
576
+ // clock does not license it.
577
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-abandoned-'))
578
+ workdirs.push(workingDirectory)
579
+
580
+ const inbox = new CompletionInbox()
581
+ inbox.attach({ onTaskCompleted: () => () => {}, getTask: () => undefined } as never)
582
+ // Launched, never settles, and the terminal tool ends the run over it.
583
+ inbox.expect('tsk_still_running' as TaskId)
584
+
585
+ const tools = new ToolRegistry()
586
+ tools.register(
587
+ defineTool({
588
+ name: 'finisher',
589
+ description: 'ends the run',
590
+ inputSchema: z.object({}),
591
+ category: 'analysis',
592
+ permissions: [],
593
+ readOnly: true,
594
+ destructive: false,
595
+ concurrencySafe: true,
596
+ terminal: true,
597
+ async execute() {
598
+ return { success: true, output: 'this call is the answer' }
599
+ },
600
+ } as never),
601
+ )
602
+
603
+ const run = await drainQuery({
604
+ provider: new CallsFinisherProvider(),
605
+ tools,
606
+ completionInbox: inbox,
607
+ agentId: 'agent_test',
608
+ agentName: 'Test Agent',
609
+ messages: [createUserMessage('go')],
610
+ workingDirectory,
611
+ runConfig: {
612
+ model: 'mock-model',
613
+ timeoutMs: 10_000,
614
+ tokenBudget: 100_000,
615
+ maxIterations: 4,
616
+ maxResponseTokens: 256,
617
+ },
618
+ sessionId: 'ses_completion' as SessionId,
619
+ threadId: 'thd_completion' as ThreadId,
620
+ projectId: 'prj_completion' as ProjectId,
621
+ tenantId: 'tnt_completion' as TenantId,
622
+ } as never)
623
+
624
+ expect(run.abandonedTaskIds, 'the run said nothing about the worker it left running').toEqual([
625
+ 'tsk_still_running',
626
+ ])
627
+ // Named, not stopped: the inbox still counts it as outstanding, and
628
+ // nothing asked the gateway to cancel anything.
629
+ expect(inbox.outstandingTaskIds).toEqual(['tsk_still_running'])
630
+ })
631
+
632
+ it('leaves the field absent when the run walked away from nothing', async () => {
633
+ // A field that is always present says nothing; one that appears only
634
+ // when there is something to report is a signal a host can branch on.
635
+ const { run } = await runEndingWith({ terminal: true })
636
+
637
+ expect(run.abandonedTaskIds).toBeUndefined()
638
+ })
639
+
640
+ it('hands it over exactly once when the ordinary exit already did', async () => {
641
+ // The `dc16d58` failure in a new place: an exit-time delivery that
642
+ // re-sends what the in-loop drain already sent is the duplicate bug
643
+ // again. `drain()` empties, so the second call finds nothing.
644
+ const { userMessages } = await runEndingWith({})
645
+
646
+ expect(userMessages.filter((m) => m.includes('THE BACKGROUND WORKER RESULT'))).toHaveLength(1)
647
+ })
648
+ })