@namzu/sdk 7.0.0 → 9.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 (206) hide show
  1. package/CHANGELOG.md +652 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +186 -163
  4. package/dist/agents/SupervisorAgent.js.map +1 -1
  5. package/dist/agents/__tests__/a-policy-nobody-can-select.test.d.ts +2 -0
  6. package/dist/agents/__tests__/a-policy-nobody-can-select.test.d.ts.map +1 -0
  7. package/dist/agents/__tests__/a-policy-nobody-can-select.test.js +177 -0
  8. package/dist/agents/__tests__/a-policy-nobody-can-select.test.js.map +1 -0
  9. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts +2 -0
  10. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts.map +1 -0
  11. package/dist/agents/__tests__/supervisor-inbox-scope.test.js +125 -0
  12. package/dist/agents/__tests__/supervisor-inbox-scope.test.js.map +1 -0
  13. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  14. package/dist/bridge/a2a/mapper.js +4 -0
  15. package/dist/bridge/a2a/mapper.js.map +1 -1
  16. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  17. package/dist/bridge/sse/mapper.js +24 -0
  18. package/dist/bridge/sse/mapper.js.map +1 -1
  19. package/dist/contracts/api.d.ts +1 -1
  20. package/dist/contracts/api.d.ts.map +1 -1
  21. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts +2 -0
  22. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.d.ts.map +1 -0
  23. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js +114 -0
  24. package/dist/gateway/__tests__/a-knob-nobody-can-turn.test.js.map +1 -0
  25. package/dist/gateway/__tests__/completion-inbox.test.js +292 -2
  26. package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
  27. package/dist/gateway/completion-inbox.d.ts +94 -6
  28. package/dist/gateway/completion-inbox.d.ts.map +1 -1
  29. package/dist/gateway/completion-inbox.js +235 -15
  30. package/dist/gateway/completion-inbox.js.map +1 -1
  31. package/dist/gateway/local.d.ts.map +1 -1
  32. package/dist/gateway/local.js +19 -6
  33. package/dist/gateway/local.js.map +1 -1
  34. package/dist/manager/agent/__tests__/lifecycle.test.js +56 -0
  35. package/dist/manager/agent/__tests__/lifecycle.test.js.map +1 -1
  36. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  37. package/dist/manager/agent/lifecycle.js +62 -33
  38. package/dist/manager/agent/lifecycle.js.map +1 -1
  39. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts +2 -0
  40. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.d.ts.map +1 -0
  41. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js +73 -0
  42. package/dist/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.js.map +1 -0
  43. package/dist/manager/plan/lifecycle.d.ts +48 -0
  44. package/dist/manager/plan/lifecycle.d.ts.map +1 -1
  45. package/dist/manager/plan/lifecycle.js +56 -0
  46. package/dist/manager/plan/lifecycle.js.map +1 -1
  47. package/dist/manager/run/persistence.d.ts +8 -0
  48. package/dist/manager/run/persistence.d.ts.map +1 -1
  49. package/dist/manager/run/persistence.js +12 -0
  50. package/dist/manager/run/persistence.js.map +1 -1
  51. package/dist/run/reporter.d.ts.map +1 -1
  52. package/dist/run/reporter.js +11 -0
  53. package/dist/run/reporter.js.map +1 -1
  54. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts +2 -0
  55. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts.map +1 -0
  56. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js +142 -0
  57. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js.map +1 -0
  58. package/dist/runtime/query/__tests__/completion-notification.test.js +414 -32
  59. package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -1
  60. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts +2 -0
  61. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts.map +1 -0
  62. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js +100 -0
  63. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js.map +1 -0
  64. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts +2 -0
  65. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.d.ts.map +1 -0
  66. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js +62 -0
  67. package/dist/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.js.map +1 -0
  68. package/dist/runtime/query/events.d.ts.map +1 -1
  69. package/dist/runtime/query/events.js +5 -0
  70. package/dist/runtime/query/events.js.map +1 -1
  71. package/dist/runtime/query/guard.d.ts +28 -0
  72. package/dist/runtime/query/guard.d.ts.map +1 -1
  73. package/dist/runtime/query/guard.js +31 -0
  74. package/dist/runtime/query/guard.js.map +1 -1
  75. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts +2 -0
  76. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts.map +1 -0
  77. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js +226 -0
  78. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js.map +1 -0
  79. package/dist/runtime/query/iteration/index.d.ts +92 -0
  80. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  81. package/dist/runtime/query/iteration/index.js +814 -566
  82. package/dist/runtime/query/iteration/index.js.map +1 -1
  83. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts +2 -0
  84. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts.map +1 -0
  85. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js +95 -0
  86. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js.map +1 -0
  87. package/dist/runtime/query/iteration/phases/compaction.d.ts +34 -0
  88. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  89. package/dist/runtime/query/iteration/phases/compaction.js +61 -4
  90. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  91. package/dist/store/task/disk.d.ts.map +1 -1
  92. package/dist/store/task/disk.js +14 -0
  93. package/dist/store/task/disk.js.map +1 -1
  94. package/dist/store/task/memory.d.ts.map +1 -1
  95. package/dist/store/task/memory.js +14 -0
  96. package/dist/store/task/memory.js.map +1 -1
  97. package/dist/tools/__tests__/untrusted-envelope.test.js +16 -0
  98. package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -1
  99. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts +2 -0
  100. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.d.ts.map +1 -0
  101. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js +101 -0
  102. package/dist/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.js.map +1 -0
  103. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +117 -0
  104. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
  105. package/dist/tools/coordinator/__tests__/task-list.test.js +57 -0
  106. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  107. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts +2 -0
  108. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.d.ts.map +1 -0
  109. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js +99 -0
  110. package/dist/tools/coordinator/__tests__/the-plan-a-human-approves.test.js.map +1 -0
  111. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  112. package/dist/tools/coordinator/agent.js +17 -23
  113. package/dist/tools/coordinator/agent.js.map +1 -1
  114. package/dist/tools/coordinator/index.d.ts.map +1 -1
  115. package/dist/tools/coordinator/index.js +142 -14
  116. package/dist/tools/coordinator/index.js.map +1 -1
  117. package/dist/tools/coordinator/outcome.d.ts +60 -0
  118. package/dist/tools/coordinator/outcome.d.ts.map +1 -0
  119. package/dist/tools/coordinator/outcome.js +68 -0
  120. package/dist/tools/coordinator/outcome.js.map +1 -0
  121. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  122. package/dist/tools/untrusted-envelope.js +9 -1
  123. package/dist/tools/untrusted-envelope.js.map +1 -1
  124. package/dist/types/agent/gateway.d.ts +37 -2
  125. package/dist/types/agent/gateway.d.ts.map +1 -1
  126. package/dist/types/agent/supervisor.d.ts +38 -1
  127. package/dist/types/agent/supervisor.d.ts.map +1 -1
  128. package/dist/types/plan/index.d.ts +26 -0
  129. package/dist/types/plan/index.d.ts.map +1 -1
  130. package/dist/types/provider/interface.d.ts +35 -0
  131. package/dist/types/provider/interface.d.ts.map +1 -1
  132. package/dist/types/run/entity.d.ts +16 -0
  133. package/dist/types/run/entity.d.ts.map +1 -1
  134. package/dist/types/run/events.d.ts +92 -0
  135. package/dist/types/run/events.d.ts.map +1 -1
  136. package/dist/types/run/events.js.map +1 -1
  137. package/dist/types/run/prepare-step.d.ts +17 -2
  138. package/dist/types/run/prepare-step.d.ts.map +1 -1
  139. package/dist/types/task/index.d.ts +19 -1
  140. package/dist/types/task/index.d.ts.map +1 -1
  141. package/dist/types/task/index.js +10 -1
  142. package/dist/types/task/index.js.map +1 -1
  143. package/dist/types/verification/index.d.ts +98 -0
  144. package/dist/types/verification/index.d.ts.map +1 -1
  145. package/dist/types/verification/index.js +10 -0
  146. package/dist/types/verification/index.js.map +1 -1
  147. package/dist/verification/__tests__/argument-pattern.test.d.ts +2 -0
  148. package/dist/verification/__tests__/argument-pattern.test.d.ts.map +1 -0
  149. package/dist/verification/__tests__/argument-pattern.test.js +122 -0
  150. package/dist/verification/__tests__/argument-pattern.test.js.map +1 -0
  151. package/dist/verification/gate.d.ts.map +1 -1
  152. package/dist/verification/gate.js +44 -0
  153. package/dist/verification/gate.js.map +1 -1
  154. package/dist/verification/rules.d.ts.map +1 -1
  155. package/dist/verification/rules.js +27 -0
  156. package/dist/verification/rules.js.map +1 -1
  157. package/package.json +1 -1
  158. package/src/agents/SupervisorAgent.ts +189 -167
  159. package/src/agents/__tests__/a-policy-nobody-can-select.test.ts +202 -0
  160. package/src/agents/__tests__/supervisor-inbox-scope.test.ts +149 -0
  161. package/src/bridge/a2a/mapper.ts +4 -0
  162. package/src/bridge/sse/mapper.ts +25 -0
  163. package/src/contracts/api.ts +1 -0
  164. package/src/gateway/__tests__/a-knob-nobody-can-turn.test.ts +139 -0
  165. package/src/gateway/__tests__/completion-inbox.test.ts +348 -2
  166. package/src/gateway/completion-inbox.ts +248 -16
  167. package/src/gateway/local.ts +19 -7
  168. package/src/manager/agent/__tests__/lifecycle.test.ts +74 -0
  169. package/src/manager/agent/lifecycle.ts +72 -42
  170. package/src/manager/plan/__tests__/a-plan-that-succeeded-is-not-failed.test.ts +88 -0
  171. package/src/manager/plan/lifecycle.ts +61 -0
  172. package/src/manager/run/persistence.ts +12 -0
  173. package/src/run/reporter.ts +12 -0
  174. package/src/runtime/query/__tests__/completion-does-not-erase-the-answer.test.ts +163 -0
  175. package/src/runtime/query/__tests__/completion-notification.test.ts +486 -34
  176. package/src/runtime/query/__tests__/context-size-on-the-wire.test.ts +122 -0
  177. package/src/runtime/query/__tests__/the-plan-graph-reaches-a-host.test.ts +79 -0
  178. package/src/runtime/query/events.ts +5 -0
  179. package/src/runtime/query/guard.ts +32 -0
  180. package/src/runtime/query/iteration/__tests__/settle-grace.test.ts +265 -0
  181. package/src/runtime/query/iteration/index.ts +902 -636
  182. package/src/runtime/query/iteration/phases/__tests__/compaction-declined.test.ts +124 -0
  183. package/src/runtime/query/iteration/phases/compaction.ts +83 -10
  184. package/src/store/task/disk.ts +15 -0
  185. package/src/store/task/memory.ts +15 -0
  186. package/src/tools/__tests__/untrusted-envelope.test.ts +23 -0
  187. package/src/tools/coordinator/__tests__/a-failed-worker-is-not-an-answer.test.ts +133 -0
  188. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +147 -0
  189. package/src/tools/coordinator/__tests__/task-list.test.ts +72 -0
  190. package/src/tools/coordinator/__tests__/the-plan-a-human-approves.test.ts +121 -0
  191. package/src/tools/coordinator/agent.ts +17 -25
  192. package/src/tools/coordinator/index.ts +155 -18
  193. package/src/tools/coordinator/outcome.ts +70 -0
  194. package/src/tools/untrusted-envelope.ts +9 -1
  195. package/src/types/agent/gateway.ts +37 -2
  196. package/src/types/agent/supervisor.ts +40 -1
  197. package/src/types/plan/index.ts +29 -0
  198. package/src/types/provider/interface.ts +39 -0
  199. package/src/types/run/entity.ts +17 -0
  200. package/src/types/run/events.ts +92 -0
  201. package/src/types/run/prepare-step.ts +17 -2
  202. package/src/types/task/index.ts +22 -3
  203. package/src/types/verification/index.ts +61 -0
  204. package/src/verification/__tests__/argument-pattern.test.ts +158 -0
  205. package/src/verification/gate.ts +45 -0
  206. package/src/verification/rules.ts +28 -0
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { RunId } from '../../../types/ids/index.js'
4
+ import { PlanManager } from '../lifecycle.js'
5
+
6
+ /**
7
+ * `completePlan` scored an unreported step as a failure.
8
+ *
9
+ * The test was "is every step completed or skipped", and everything else fell
10
+ * to the same branch — so a step still `pending` produced `failed`. Since
11
+ * `addStep` defaults every step to `pending`, a caller that added steps, did
12
+ * the work, and settled the plan without reporting each one got `failed` for a
13
+ * plan that had fully succeeded. That is the path of least effort, not an
14
+ * unusual one.
15
+ *
16
+ * A step that FAILED is an outcome. A step nobody reported on is not — it says
17
+ * the caller and the plan disagree about whether the work is over, and
18
+ * answering "failed" settles that by inventing a result.
19
+ */
20
+
21
+ const RUN = 'run_plan_outcome' as RunId
22
+
23
+ function planWithSteps(count: number): PlanManager {
24
+ const manager = new PlanManager(RUN)
25
+ manager.startGenerating('a plan')
26
+ for (let i = 0; i < count; i += 1) {
27
+ manager.addStep({
28
+ id: `step-${i + 1}`,
29
+ description: `step ${i + 1}`,
30
+ dependsOn: [],
31
+ order: i,
32
+ })
33
+ }
34
+ manager.markReady()
35
+ return manager
36
+ }
37
+
38
+ describe('a plan settles on what its steps actually reported', () => {
39
+ it('completes when every step reported success', () => {
40
+ const manager = planWithSteps(2)
41
+ for (const step of manager.active?.steps ?? []) {
42
+ manager.updateStepStatus(step.id, 'completed')
43
+ }
44
+
45
+ expect(manager.completePlan()?.status).toBe('completed')
46
+ })
47
+
48
+ it('counts a skipped step as settled, not as a failure', () => {
49
+ const manager = planWithSteps(2)
50
+ const steps = manager.active?.steps ?? []
51
+ manager.updateStepStatus(steps[0]?.id as string, 'completed')
52
+ manager.updateStepStatus(steps[1]?.id as string, 'skipped')
53
+
54
+ expect(manager.completePlan()?.status).toBe('completed')
55
+ })
56
+
57
+ it('fails when a step actually failed', () => {
58
+ const manager = planWithSteps(2)
59
+ const steps = manager.active?.steps ?? []
60
+ manager.updateStepStatus(steps[0]?.id as string, 'completed')
61
+ manager.updateStepStatus(steps[1]?.id as string, 'failed')
62
+
63
+ expect(manager.completePlan()?.status).toBe('failed')
64
+ })
65
+
66
+ it('refuses rather than scoring a step nobody reported on', () => {
67
+ // The defect, in the shape a caller reaches it: steps added, work done,
68
+ // nothing reported. Answering `failed` here is the invented result.
69
+ const manager = planWithSteps(2)
70
+ manager.updateStepStatus(manager.active?.steps[0]?.id as string, 'completed')
71
+
72
+ expect(() => manager.completePlan()).toThrow(/have not reported an outcome/)
73
+ })
74
+
75
+ it('names the way out rather than only the refusal', () => {
76
+ // A caller in this position either forgot to report progress or called
77
+ // too early, and only they know which — so the message has to carry
78
+ // both moves, not just the complaint.
79
+ const manager = planWithSteps(1)
80
+
81
+ expect(() => manager.completePlan()).toThrow(/updateStepStatus/)
82
+ expect(() => manager.completePlan()).toThrow(/failPlan/)
83
+ })
84
+
85
+ it('still returns null when there is no plan at all', () => {
86
+ expect(new PlanManager(RUN).completePlan()).toBeNull()
87
+ })
88
+ })
@@ -26,6 +26,33 @@ export type PlanEventListener = (event: PlanEvent) => void
26
26
 
27
27
  export type PlanApprovalHandler = (request: PlanApprovalRequest) => Promise<PlanApprovalResponse>
28
28
 
29
+ /**
30
+ * The plan a run declares, and the gate a host approves it through.
31
+ *
32
+ * **The kernel deliberately drives only part of this class.** It builds a plan
33
+ * (`approve_plan` calls `startGenerating` / `addStep` / `markReady`), gates it
34
+ * (`iteration/phases/context.ts` calls `approve` and `startExecution`),
35
+ * translates its events onto the run stream (`EventTranslator.wirePlanManager`),
36
+ * and settles it on failure (`runtime/query/result.ts` calls `failPlan`). It
37
+ * never reports a step outcome and never settles a plan that succeeded.
38
+ *
39
+ * That is a split, not an omission — `drainQuery` hands the manager to the host
40
+ * through `onContextCreated({ planManager })` BEFORE the iteration loop starts,
41
+ * precisely so a host can drive the half the kernel does not. So a grep for
42
+ * callers of `updateStepStatus` or `completePlan` inside this package finds
43
+ * none, and that is not evidence the methods are dead: the callers are hosts,
44
+ * and they are outside the repository. `PlanManager` is exported from
45
+ * `public-runtime.ts` for this reason.
46
+ *
47
+ * Recorded here because the absence has already been read once as a dead layer
48
+ * and proposed for deletion. What it would have deleted is a working
49
+ * human-in-the-loop approval gate.
50
+ *
51
+ * The one genuine gap in the split is tracked separately: nothing settles a
52
+ * plan that SUCCEEDED, so its status can reach `failed` or stay `executing`
53
+ * but never `completed`. Fixing that needs a decision about what a
54
+ * kernel-built plan's steps mean, not a guessed status — see `completePlan`.
55
+ */
29
56
  export class PlanManager {
30
57
  private currentPlan: Plan | null = null
31
58
  private runId: RunId
@@ -185,9 +212,43 @@ export class PlanManager {
185
212
  return step
186
213
  }
187
214
 
215
+ /**
216
+ * Settle the plan, computing its outcome from its steps.
217
+ *
218
+ * A step that is still `pending` or `running` used to land here as
219
+ * **`failed`**, because the test was "is every step completed or skipped"
220
+ * and anything else fell to the same branch. So a caller that added steps,
221
+ * did the work, and settled the plan without reporting each step got
222
+ * `failed` for a plan that fully succeeded — and `addStep` defaults every
223
+ * step to `pending`, so that is the path of least effort, not an unusual
224
+ * one.
225
+ *
226
+ * The two cases are different facts and want different responses. A step
227
+ * that FAILED is an outcome: the plan failed, report it. A step nobody
228
+ * reported on is not an outcome at all — it says the caller and this plan
229
+ * disagree about whether the work is over, and answering "failed" resolves
230
+ * that disagreement by inventing a result.
231
+ *
232
+ * So an unfinished step is refused rather than scored. The message names
233
+ * the steps and the two ways out, because a caller in this position either
234
+ * forgot to report progress or called too early, and only they know which.
235
+ */
188
236
  completePlan(): Plan | null {
189
237
  if (!this.currentPlan) return null
190
238
 
239
+ const unfinished = this.currentPlan.steps.filter(
240
+ (s) => s.status === 'pending' || s.status === 'running',
241
+ )
242
+ if (unfinished.length > 0) {
243
+ const named = unfinished.slice(0, 3).map((s) => s.description)
244
+ const rest = unfinished.length - named.length
245
+ const listed = rest > 0 ? `${named.join('; ')}, and ${rest} more` : named.join('; ')
246
+ const counted = `${unfinished.length} of ${this.currentPlan.steps.length} steps`
247
+ throw new Error(
248
+ `Cannot complete plan "${this.currentPlan.title}": ${counted} have not reported an outcome (${listed}). Report each step with updateStepStatus — 'skipped' is a valid outcome — or call failPlan if the plan is being abandoned. Scoring an unreported step as a failure would report a plan that succeeded as one that did not.`,
249
+ )
250
+ }
251
+
191
252
  const allDone = this.currentPlan.steps.every(
192
253
  (s) => s.status === 'completed' || s.status === 'skipped',
193
254
  )
@@ -270,6 +270,18 @@ export class RunPersistence {
270
270
  this.run.structuredOutput = value
271
271
  }
272
272
 
273
+ /**
274
+ * Name the delegated work this run ended without waiting for.
275
+ *
276
+ * See {@link Run.abandonedTaskIds}. Recording rather than cancelling is
277
+ * the point: the kernel owes the caller the truth about what it walked
278
+ * away from, and nothing more.
279
+ */
280
+ setAbandonedTaskIds(taskIds: readonly string[]): void {
281
+ if (taskIds.length === 0) return
282
+ this.run.abandonedTaskIds = [...taskIds]
283
+ }
284
+
273
285
  setSteps(steps: readonly StepResult[]): void {
274
286
  this.run.steps = steps
275
287
  }
@@ -270,6 +270,18 @@ export function createRunReporter(parentLogger?: Logger): RunReporter {
270
270
  })
271
271
  break
272
272
 
273
+ case 'compaction_failed':
274
+ // warn rather than info: the run is now continuing at a context
275
+ // size it had already decided was too large.
276
+ log.warn('Context compaction shed nothing', {
277
+ runId: event.runId,
278
+ iteration: event.iteration,
279
+ cause: event.cause,
280
+ messages: event.messages,
281
+ ...(event.error !== undefined ? { error: event.error } : {}),
282
+ })
283
+ break
284
+
273
285
  case 'capability_warning':
274
286
  log.warn('Provider capability mismatch', {
275
287
  runId: event.runId,
@@ -0,0 +1,163 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { z } from 'zod'
6
+
7
+ import { CompletionInbox } from '../../../gateway/completion-inbox.js'
8
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
9
+ import { defineTool } from '../../../tools/defineTool.js'
10
+ import type { TaskHandle } from '../../../types/agent/gateway.js'
11
+ import type { SessionId, TaskId, TenantId } from '../../../types/ids/index.js'
12
+ import { createUserMessage } from '../../../types/message/index.js'
13
+ import type { LLMProvider, StreamChunk } from '../../../types/provider/index.js'
14
+ import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
15
+ import { drainQuery } from '../index.js'
16
+
17
+ /**
18
+ * A notification appended after the answer must not become the answer's grave.
19
+ *
20
+ * `RunPersistence.resolveResult` assembles `Run.result` by walking the message
21
+ * tail BACKWARDS and stopping at the first non-assistant message, and it runs
22
+ * at `markCompleted` — after the loop has finished. So a task notification
23
+ * pushed after the final assistant turn hides that turn from the assembler
24
+ * entirely.
25
+ *
26
+ * This is not hypothetical. It was introduced by the change that made every
27
+ * exit hand over a finished worker's output, and measured here: a run whose
28
+ * model had just said "THIS IS THE RUN ANSWER." returned `run.result ===
29
+ * undefined`. Trading a lost worker result for a lost RUN result is strictly
30
+ * worse than the defect the delivery exists to fix, and every one of the
31
+ * suite's 2,600 tests passed while it was true, because none of them asserted
32
+ * `run.result` on a path where a completion could land last.
33
+ */
34
+
35
+ const ZERO_USAGE = {
36
+ promptTokens: 0,
37
+ completionTokens: 0,
38
+ totalTokens: 0,
39
+ cachedTokens: 0,
40
+ cacheWriteTokens: 0,
41
+ }
42
+
43
+ const ANSWER = 'THIS IS THE RUN ANSWER.'
44
+
45
+ function completed(): TaskHandle {
46
+ return {
47
+ taskId: 'tsk_bg' as TaskId,
48
+ agentId: 'reviewer',
49
+ state: 'completed',
50
+ createdAt: 0,
51
+ completedAt: 1,
52
+ result: { status: 'completed', result: 'THE WORKER RESULT' },
53
+ } as TaskHandle
54
+ }
55
+
56
+ const noop = defineTool({
57
+ name: 'noop',
58
+ description: 'does nothing',
59
+ inputSchema: z.object({}),
60
+ category: 'analysis',
61
+ permissions: [],
62
+ readOnly: true,
63
+ destructive: false,
64
+ concurrencySafe: true,
65
+ async execute() {
66
+ return { success: true, output: 'ok' }
67
+ },
68
+ })
69
+
70
+ const workdirs: string[] = []
71
+ afterEach(async () => {
72
+ await Promise.all(workdirs.map((dir) => rm(dir, { recursive: true, force: true })))
73
+ workdirs.length = 0
74
+ })
75
+
76
+ describe('a completion delivered on the way out leaves the answer readable', () => {
77
+ it('keeps run.result when the worker lands during the closing turn', async () => {
78
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-answer-'))
79
+ workdirs.push(workingDirectory)
80
+
81
+ const inbox = new CompletionInbox()
82
+ let announce: ((h: TaskHandle) => void) | undefined
83
+ inbox.launched('tsk_bg' as TaskId)
84
+ inbox.attach({
85
+ onTaskCompleted: (cb: (h: TaskHandle) => void) => {
86
+ announce = cb
87
+ return () => {
88
+ announce = undefined
89
+ }
90
+ },
91
+ getTask: () => undefined,
92
+ } as never)
93
+
94
+ /**
95
+ * One tool call, then the iteration ceiling forces a closing turn. The
96
+ * worker settles DURING that closing turn — after the last in-loop
97
+ * drain, so the only thing left to deliver it is the exit path.
98
+ */
99
+ class ClosingTurnProvider implements LLMProvider {
100
+ readonly id = 'closing-turn'
101
+ readonly name = 'Closing Turn Provider'
102
+ calls = 0
103
+ async *chatStream(): AsyncIterable<StreamChunk> {
104
+ this.calls += 1
105
+ if (this.calls === 1) {
106
+ yield {
107
+ id: 'm1',
108
+ delta: {
109
+ toolCalls: [
110
+ {
111
+ index: 0,
112
+ id: 'toolu_1',
113
+ type: 'function',
114
+ function: { name: 'noop', arguments: '{}' },
115
+ },
116
+ ],
117
+ },
118
+ }
119
+ yield { id: 'm1', delta: {}, finishReason: 'tool_calls', usage: ZERO_USAGE }
120
+ return
121
+ }
122
+ announce?.(completed())
123
+ yield { id: 'm2', delta: { content: ANSWER } }
124
+ yield { id: 'm2', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
125
+ }
126
+ }
127
+
128
+ const tools = new ToolRegistry()
129
+ tools.register(noop)
130
+
131
+ const run = await drainQuery({
132
+ provider: new ClosingTurnProvider(),
133
+ tools,
134
+ completionInbox: inbox,
135
+ agentId: 'agent_test',
136
+ agentName: 'Test Agent',
137
+ messages: [createUserMessage('go')],
138
+ workingDirectory,
139
+ runConfig: {
140
+ model: 'mock-model',
141
+ timeoutMs: 20_000,
142
+ tokenBudget: 100_000,
143
+ maxIterations: 1,
144
+ maxResponseTokens: 256,
145
+ },
146
+ sessionId: 'ses_answer' as SessionId,
147
+ threadId: 'thd_answer' as ThreadId,
148
+ projectId: 'prj_answer' as ProjectId,
149
+ tenantId: 'tnt_answer' as TenantId,
150
+ } as never)
151
+
152
+ // Both halves, because either one alone is satisfied by a broken fix:
153
+ // dropping the delivery keeps the answer, and dropping the answer fix
154
+ // keeps the notification.
155
+ expect(run.result, 'the notification buried the run answer').toBe(ANSWER)
156
+ expect(
157
+ (run.messages as { content: unknown }[]).some(
158
+ (m) => typeof m.content === 'string' && m.content.includes('THE WORKER RESULT'),
159
+ ),
160
+ 'the worker result was not delivered',
161
+ ).toBe(true)
162
+ }, 60_000)
163
+ })