@namzu/sdk 11.0.0 → 12.0.1

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 (51) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +2 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +12 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/contracts/api.d.ts +1 -1
  9. package/dist/contracts/api.d.ts.map +1 -1
  10. package/dist/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.d.ts +2 -0
  11. package/dist/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.d.ts.map +1 -0
  12. package/dist/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.js +137 -0
  13. package/dist/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.js.map +1 -0
  14. package/dist/gateway/local.d.ts.map +1 -1
  15. package/dist/gateway/local.js +25 -1
  16. package/dist/gateway/local.js.map +1 -1
  17. package/dist/manager/plan/lifecycle.d.ts +1 -1
  18. package/dist/manager/plan/lifecycle.d.ts.map +1 -1
  19. package/dist/manager/plan/lifecycle.js +6 -1
  20. package/dist/manager/plan/lifecycle.js.map +1 -1
  21. package/dist/run/reporter.d.ts.map +1 -1
  22. package/dist/run/reporter.js +2 -0
  23. package/dist/run/reporter.js.map +1 -1
  24. package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.d.ts +2 -0
  25. package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.d.ts.map +1 -0
  26. package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.js +97 -0
  27. package/dist/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.js.map +1 -0
  28. package/dist/runtime/query/events.d.ts.map +1 -1
  29. package/dist/runtime/query/events.js +20 -2
  30. package/dist/runtime/query/events.js.map +1 -1
  31. package/dist/runtime/query/index.d.ts.map +1 -1
  32. package/dist/runtime/query/index.js +14 -1
  33. package/dist/runtime/query/index.js.map +1 -1
  34. package/dist/types/plan/index.d.ts +13 -0
  35. package/dist/types/plan/index.d.ts.map +1 -1
  36. package/dist/types/run/events.d.ts +33 -0
  37. package/dist/types/run/events.d.ts.map +1 -1
  38. package/dist/types/run/events.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/bridge/a2a/mapper.ts +2 -0
  41. package/src/bridge/sse/mapper.ts +14 -0
  42. package/src/contracts/api.ts +5 -0
  43. package/src/gateway/__tests__/a-progress-tee-cannot-name-a-task-that-does-not-exist.test.ts +175 -0
  44. package/src/gateway/local.ts +25 -1
  45. package/src/manager/plan/lifecycle.ts +6 -1
  46. package/src/run/reporter.ts +2 -0
  47. package/src/runtime/query/__tests__/a-settled-plan-reaches-the-host.test.ts +122 -0
  48. package/src/runtime/query/events.ts +20 -2
  49. package/src/runtime/query/index.ts +15 -2
  50. package/src/types/plan/index.ts +14 -0
  51. package/src/types/run/events.ts +24 -0
@@ -206,6 +206,11 @@ export type StreamEventType =
206
206
  | 'plan.approved'
207
207
  | 'plan.rejected'
208
208
  | 'plan.step_updated'
209
+ // The outcome. Without these the plan stream stopped one event short of
210
+ // saying how it went, so a client could render a plan as in-flight
211
+ // indefinitely — it learned the plan was approved and never that it closed.
212
+ | 'plan.completed'
213
+ | 'plan.failed'
209
214
  | 'agent.pending'
210
215
  | 'agent.completed'
211
216
  | 'agent.failed'
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { Agent } from '../../types/agent/core.js'
4
+ import type { AgentManagerContract } from '../../types/agent/manager.js'
5
+ import type {
6
+ AgentTask,
7
+ AgentTaskContext,
8
+ AgentTaskState,
9
+ SendMessageOptions,
10
+ } from '../../types/agent/task.js'
11
+ import type { AgentId, RunId, SessionId, TaskId, TenantId } from '../../types/ids/index.js'
12
+ import type { RunEventListener } from '../../types/run/events.js'
13
+ import type { ProjectId, ThreadId } from '../../types/session/ids.js'
14
+ import { LocalTaskGateway } from '../local.js'
15
+
16
+ /**
17
+ * A child that spoke before its own spawn resolved killed the launch.
18
+ *
19
+ * The progress tee handed to `sendMessage` read `task.taskId` — the `const`
20
+ * that the very same `await` assigns. So any run event emitted by the child
21
+ * before `sendMessage` returned reached that line inside the temporal dead
22
+ * zone and threw `Cannot access 'task' before initialization`, taking the whole
23
+ * `create_task` down with it.
24
+ *
25
+ * It survived review and a full unit suite because a single sequential launch
26
+ * usually resolves before the child says anything. A CONCURRENT fan-out does
27
+ * not — and that is the shape `create_task`'s own description tells the model
28
+ * to use. Observed live on the published package: four launches from one turn,
29
+ * three dead.
30
+ */
31
+
32
+ const RUN = 'run_tee' as RunId
33
+
34
+ /** Emits a run event DURING the spawn, before it resolves. */
35
+ class TalksDuringSpawn implements AgentManagerContract {
36
+ /** Kept so a test can make the child speak AFTER the spawn resolved too. */
37
+ private lastListener?: RunEventListener
38
+
39
+ constructor(private readonly emitsBeforeResolve: number) {}
40
+
41
+ /** The child says something once the caller holds its handle. */
42
+ speakNow(): void {
43
+ this.lastListener?.({ type: 'iteration_started', runId: RUN, iteration: 99 } as never)
44
+ }
45
+
46
+ async sendMessage(
47
+ options: SendMessageOptions,
48
+ _context: AgentTaskContext,
49
+ listener?: RunEventListener,
50
+ ): Promise<AgentTask> {
51
+ this.lastListener = listener
52
+ // The child is alive and streaming before the caller holds its handle.
53
+ for (let i = 0; i < this.emitsBeforeResolve; i += 1) {
54
+ listener?.({ type: 'iteration_started', runId: RUN, iteration: i } as never)
55
+ }
56
+ await Promise.resolve()
57
+ return {
58
+ taskId: 'task_spoke_early' as TaskId,
59
+ agentId: options.agentId,
60
+ agent: {} as Agent<never, never>,
61
+ childAbortController: new AbortController(),
62
+ context: {} as AgentTaskContext,
63
+ state: 'completed' as AgentTaskState,
64
+ pendingMessages: [],
65
+ createdAt: 1,
66
+ } as AgentTask
67
+ }
68
+
69
+ cancel(): void {}
70
+ cancelAll(): void {}
71
+ async continueTask(): Promise<void> {}
72
+ queueMessage(): void {}
73
+ drainMessages() {
74
+ return []
75
+ }
76
+ async waitForCompletion(): Promise<void> {}
77
+ getInstance(): AgentTask | undefined {
78
+ return undefined
79
+ }
80
+ listByParent(): AgentTask[] {
81
+ return []
82
+ }
83
+ listActive(): AgentTask[] {
84
+ return []
85
+ }
86
+ getState(): AgentTaskState | undefined {
87
+ return undefined
88
+ }
89
+ on(): void {}
90
+ off(): void {}
91
+ cleanup(): void {}
92
+ dispose(): void {}
93
+ }
94
+
95
+ function context(): AgentTaskContext {
96
+ return {
97
+ parentRunId: RUN,
98
+ parentAgentId: 'supervisor',
99
+ parentAbortController: new AbortController(),
100
+ depth: 0,
101
+ budgetTracker: { total: 100_000, remaining: 100_000 },
102
+ tenantId: 'tnt_t' as TenantId,
103
+ threadId: 'thd_t' as ThreadId,
104
+ sessionId: 'ses_t' as SessionId,
105
+ projectId: 'prj_t' as ProjectId,
106
+ parentActor: { kind: 'agent', agentId: 'supervisor' as AgentId, tenantId: 'tnt_t' as TenantId },
107
+ } as AgentTaskContext
108
+ }
109
+
110
+ describe('a launch survives a child that speaks before the spawn resolves', () => {
111
+ it('does not throw when an event arrives mid-spawn', async () => {
112
+ const gateway = new LocalTaskGateway(new TalksDuringSpawn(3), context())
113
+ // The listener is what makes this reproduce, and its absence is what
114
+ // made the bug invisible for so long: with no progress subscriber the
115
+ // loop body never runs, so `task.taskId` is never evaluated and the
116
+ // dead zone is never entered. `create_task` attaches one for the idle
117
+ // bound on every blocking launch, which is why it bit in production and
118
+ // not in any test.
119
+ gateway.onTaskProgress?.(() => {})
120
+
121
+ await expect(
122
+ gateway.createTask({ agentId: 'worker', prompt: 'go', workingDirectory: '/tmp' }),
123
+ ).resolves.toMatchObject({ taskId: 'task_spoke_early' })
124
+ })
125
+
126
+ it('still forwards those events to the host listener', async () => {
127
+ // The tee is what broke, not the forwarding. A host watching the child
128
+ // must not lose its early events to this fix.
129
+ const seen: string[] = []
130
+ const gateway = new LocalTaskGateway(new TalksDuringSpawn(3), context(), (e) => {
131
+ seen.push(e.type)
132
+ })
133
+
134
+ await gateway.createTask({ agentId: 'worker', prompt: 'go', workingDirectory: '/tmp' })
135
+
136
+ expect(seen).toEqual(['iteration_started', 'iteration_started', 'iteration_started'])
137
+ })
138
+
139
+ it('reports progress once the task has an id to report it against', async () => {
140
+ // Before the id exists nothing is waiting on the task — the caller does
141
+ // not hold the handle yet — so silence there is correct. What must work
142
+ // is everything after.
143
+ const manager = new TalksDuringSpawn(1)
144
+ const gateway = new LocalTaskGateway(manager, context())
145
+ const progressed: TaskId[] = []
146
+ gateway.onTaskProgress?.((id) => progressed.push(id))
147
+
148
+ await gateway.createTask({ agentId: 'worker', prompt: 'go', workingDirectory: '/tmp' })
149
+ // The one emitted mid-spawn is not attributed — there was no id yet.
150
+ expect(progressed).toEqual([])
151
+
152
+ // Now the child speaks with the handle already in the caller's hands,
153
+ // which is the case an idle bound is actually measuring.
154
+ manager.speakNow()
155
+
156
+ expect(progressed).toEqual(['task_spoke_early' as TaskId])
157
+ })
158
+
159
+ it('survives a concurrent fan-out, which is how this was found', async () => {
160
+ const gateway = new LocalTaskGateway(new TalksDuringSpawn(2), context())
161
+ // Same reason as above: the live failure came through the idle bound's
162
+ // subscriber, so a fan-out test without one would not reproduce the
163
+ // thing it is named after.
164
+ gateway.onTaskProgress?.(() => {})
165
+
166
+ const launched = await Promise.all(
167
+ [1, 2, 3, 4].map(() =>
168
+ gateway.createTask({ agentId: 'worker', prompt: 'go', workingDirectory: '/tmp' }),
169
+ ),
170
+ )
171
+
172
+ expect(launched).toHaveLength(4)
173
+ expect(launched.every((h) => h.taskId === 'task_spoke_early')).toBe(true)
174
+ })
175
+ })
@@ -60,6 +60,12 @@ export class LocalTaskGateway implements TaskGateway {
60
60
  }
61
61
 
62
62
  async createTask(options: CreateTaskOptions): Promise<TaskHandle> {
63
+ // Filled once the spawn resolves. A box rather than a bare binding
64
+ // because the assignment happens AFTER the `await` that the reader is
65
+ // passed into — see the progress tee below for why it cannot simply
66
+ // read the `task` const it is declared beside.
67
+ const launched: { id?: TaskId } = {}
68
+
63
69
  const task = await this.agentManager.sendMessage(
64
70
  {
65
71
  agentId: options.agentId,
@@ -109,12 +115,30 @@ export class LocalTaskGateway implements TaskGateway {
109
115
  // idle bound measures. The event itself is not forwarded — a
110
116
  // progress signal that carried the child's output would be a
111
117
  // second, undocumented way to read a worker's work.
118
+ //
119
+ // The id comes from `launched.id`, NOT from the `task` const
120
+ // below. This callback is handed to the very `await` that assigns
121
+ // `task`, so a child that emits anything before `sendMessage`
122
+ // resolves reached it inside the temporal dead zone and threw
123
+ // `Cannot access 'task' before initialization` — killing the launch
124
+ // outright.
125
+ //
126
+ // It survived because a single sequential launch usually resolves
127
+ // before the child says anything. A concurrent fan-out does not:
128
+ // with four `create_task` calls from one turn — the shape this
129
+ // tool's own description tells the model to use — the event loop
130
+ // interleaves and three of the four died. Found by running one.
112
131
  (event) => {
113
132
  this.listener?.(event)
114
- for (const notify of this.progressListeners) notify(task.taskId)
133
+ // No id yet means nothing is waiting on this task: the caller
134
+ // does not hold the handle, so an idle bound cannot be running
135
+ // against it. There is no progress to report to anyone.
136
+ if (launched.id === undefined) return
137
+ for (const notify of this.progressListeners) notify(launched.id)
115
138
  },
116
139
  )
117
140
 
141
+ launched.id = task.taskId
118
142
  this.trackedTaskIds.add(task.taskId)
119
143
 
120
144
  this.agentManager
@@ -276,11 +276,16 @@ export class PlanManager {
276
276
  return this.currentPlan
277
277
  }
278
278
 
279
- failPlan(_error: string): Plan | null {
279
+ failPlan(error: string): Plan | null {
280
280
  if (!this.currentPlan) return null
281
281
 
282
282
  this.currentPlan.status = 'failed'
283
283
  this.currentPlan.completedAt = Date.now()
284
+ // Recorded rather than discarded. This argument was named `_error`
285
+ // because nothing read it, so a failed plan carried no account of what
286
+ // went wrong — and the event that now reports the failure would have
287
+ // had nothing to say beyond the word.
288
+ this.currentPlan.failureReason = error
284
289
 
285
290
  for (const step of this.currentPlan.steps) {
286
291
  if (step.status === 'pending' || step.status === 'running') {
@@ -93,6 +93,8 @@ export function createRunReporter(parentLogger?: Logger): RunReporter {
93
93
  case 'plan_approved':
94
94
  case 'plan_rejected':
95
95
  case 'plan_step_updated':
96
+ case 'plan_completed':
97
+ case 'plan_failed':
96
98
  case 'tool_review_requested':
97
99
  case 'tool_review_completed':
98
100
  case 'checkpoint_created':
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { PlanManager } from '../../../manager/plan/lifecycle.js'
4
+ import { MockLLMProvider, registerMock } from '../../../provider/index.js'
5
+ import { ToolRegistry } from '../../../registry/index.js'
6
+ import type { RunEvent } from '../../../types/run/index.js'
7
+ import {
8
+ generateProjectId,
9
+ generateSessionId,
10
+ generateTenantId,
11
+ generateThreadId,
12
+ } from '../../../utils/id.js'
13
+ import { drainQuery } from '../index.js'
14
+
15
+ /**
16
+ * The plan stream stopped one event short of the outcome.
17
+ *
18
+ * `plan_ready`, `plan_approved`, `plan_rejected` and `plan_step_updated` all
19
+ * reached the wire; `plan.completed` and `plan.failed` were folded into a bare
20
+ * `break` in the translator and emitted nothing. So a host watching the stream
21
+ * saw the steps report and then silence — it could learn a plan had been
22
+ * approved and never that it closed, which leaves a plan rendered as in-flight
23
+ * indefinitely.
24
+ *
25
+ * **This was found by a live end-to-end run, not by a test, and that is the
26
+ * point worth keeping.** The settlement tests read the outcome off
27
+ * `PlanManager` through `onContextCreated`, so they proved the plan settled
28
+ * without ever asking whether a consumer of the EVENT STREAM could see it. A
29
+ * verification can be entirely sound about a thing that is no longer the thing
30
+ * you need to know.
31
+ */
32
+
33
+ registerMock()
34
+
35
+ async function runWithPlan(seed: (pm: PlanManager) => void): Promise<RunEvent[]> {
36
+ const events: RunEvent[] = []
37
+
38
+ await drainQuery(
39
+ {
40
+ provider: new MockLLMProvider({ responses: [{ content: 'done' }] } as never),
41
+ tools: new ToolRegistry(),
42
+ agentId: 'a',
43
+ agentName: 'A',
44
+ messages: [{ role: 'user', content: 'go' }],
45
+ workingDirectory: process.cwd(),
46
+ runConfig: { model: 'mock', tokenBudget: 100_000, timeoutMs: 30_000, maxIterations: 4 },
47
+ projectId: generateProjectId(),
48
+ sessionId: generateSessionId(),
49
+ threadId: generateThreadId(),
50
+ tenantId: generateTenantId(),
51
+ onContextCreated: ({ planManager }: { planManager: PlanManager }) => seed(planManager),
52
+ } as never,
53
+ (event: RunEvent) => {
54
+ events.push(event)
55
+ },
56
+ )
57
+
58
+ return events
59
+ }
60
+
61
+ function twoStepPlan(pm: PlanManager): void {
62
+ pm.startGenerating('the work')
63
+ pm.addStep({ id: 'step_1', description: 'first', dependsOn: [], order: 1 })
64
+ pm.addStep({ id: 'step_2', description: 'second', dependsOn: [], order: 2 })
65
+ pm.markReady()
66
+ pm.approve()
67
+ pm.startExecution()
68
+ }
69
+
70
+ const typesOf = (events: RunEvent[]) => events.map((e) => e.type)
71
+
72
+ describe('a settled plan says so on the run stream', () => {
73
+ it('emits plan_completed when the run settles a successful plan', async () => {
74
+ const events = await runWithPlan((p) => {
75
+ twoStepPlan(p)
76
+ p.updateStepStatus('step_1', 'completed')
77
+ p.updateStepStatus('step_2', 'skipped')
78
+ })
79
+
80
+ expect(typesOf(events)).toContain('plan_completed')
81
+ })
82
+
83
+ it('emits plan_failed, carrying the reason failPlan was given', async () => {
84
+ // `failPlan` took this argument and discarded it — the parameter was
85
+ // spelled `_error`. An event that says "failed" without saying why puts
86
+ // the reader back where the missing event did.
87
+ const events = await runWithPlan((p) => {
88
+ twoStepPlan(p)
89
+ p.failPlan('the provider refused the request')
90
+ })
91
+
92
+ const failed = events.find((e) => e.type === 'plan_failed')
93
+ expect(failed).toBeDefined()
94
+ expect((failed as { reason?: string }).reason).toBe('the provider refused the request')
95
+ })
96
+
97
+ it('emits plan_failed when a step actually failed', async () => {
98
+ const events = await runWithPlan((p) => {
99
+ twoStepPlan(p)
100
+ p.updateStepStatus('step_1', 'completed')
101
+ p.updateStepStatus('step_2', 'failed')
102
+ })
103
+
104
+ expect(typesOf(events)).toContain('plan_failed')
105
+ expect(typesOf(events)).not.toContain('plan_completed')
106
+ })
107
+
108
+ it('says nothing terminal while a step has not reported', async () => {
109
+ // The plan is genuinely unsettled, so the silence here is correct — it
110
+ // is the silence AFTER settlement that was the defect.
111
+ const events = await runWithPlan((p) => {
112
+ twoStepPlan(p)
113
+ p.updateStepStatus('step_1', 'completed')
114
+ })
115
+
116
+ expect(typesOf(events)).not.toContain('plan_completed')
117
+ expect(typesOf(events)).not.toContain('plan_failed')
118
+ // ...and the step that DID report is still announced, so this is not a
119
+ // stream that has simply gone quiet.
120
+ expect(typesOf(events)).toContain('plan_step_updated')
121
+ })
122
+ })
@@ -193,10 +193,28 @@ export class EventTranslator {
193
193
  })
194
194
  }
195
195
  break
196
- case 'plan.generating':
197
- case 'plan.executing':
198
196
  case 'plan.completed':
197
+ await this.emitEvent({
198
+ type: 'plan_completed',
199
+ runId,
200
+ planId: plan.id,
201
+ })
202
+ break
199
203
  case 'plan.failed':
204
+ await this.emitEvent({
205
+ type: 'plan_failed',
206
+ runId,
207
+ planId: plan.id,
208
+ ...(plan.failureReason ? { reason: plan.failureReason } : {}),
209
+ })
210
+ break
211
+ // Deliberately silent, and not for the same reason the terminal
212
+ // pair used to be. `plan.generating` and `plan.executing` are
213
+ // already bracketed by `plan_ready` and `plan_approved` — a
214
+ // consumer learns both facts from events it already gets, so an
215
+ // event here would carry nothing a reader did not have.
216
+ case 'plan.generating':
217
+ case 'plan.executing':
200
218
  break
201
219
  default: {
202
220
  // `PlanEvent.type` is scoped to plan-manager events; sub-session
@@ -552,8 +552,6 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
552
552
  return { approved: false, feedback: `Action: ${decision.action}` }
553
553
  })
554
554
 
555
- params.onContextCreated?.({ planManager: ctx.planManager })
556
-
557
555
  const eventTranslator = new EventTranslator(ctx.runMgr)
558
556
  eventTranslator.wireActivityStore(ctx.activityStore, ctx.runId)
559
557
  eventTranslator.wirePlanManager(ctx.planManager, ctx.runId)
@@ -977,6 +975,21 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
977
975
  try {
978
976
  await ctx.runMgr.init()
979
977
 
978
+ // Handed over here, and the position is load-bearing in BOTH
979
+ // directions. It has to follow `wirePlanManager`, or a host that
980
+ // builds its plan in this callback — which is what the callback is
981
+ // for — does it into silence: `plan_ready`, `plan_approved` and
982
+ // every `plan_step_updated` are emitted with nothing subscribed,
983
+ // and the host then watches a stream that never mentions the plan
984
+ // it just created. It also has to follow `runMgr.init()`, because
985
+ // emitting appends to the run store and an uninitialised store
986
+ // throws — moving it up to the wiring alone traded a silent drop
987
+ // for 25 unhandled rejections.
988
+ //
989
+ // Still before the iteration loop, which is the guarantee the
990
+ // callback actually makes.
991
+ params.onContextCreated?.({ planManager: ctx.planManager })
992
+
980
993
  ctx.log.info('Starting query', {
981
994
  runId: ctx.runMgr.id,
982
995
  agent: params.agentName,
@@ -68,6 +68,20 @@ export interface Plan {
68
68
  rejectedAt?: number
69
69
  completedAt?: number
70
70
  rejectionReason?: string
71
+
72
+ /**
73
+ * Why the plan failed, when it did.
74
+ *
75
+ * `failPlan` has always taken this and thrown it away — the parameter was
76
+ * spelled `_error` because nothing read it. So a plan settled as `failed`
77
+ * carried no account of what went wrong, and the `plan_failed` event that
78
+ * reports it would have said "failed" and nothing else, which puts a reader
79
+ * exactly where the silence did.
80
+ *
81
+ * Distinct from {@link rejectionReason}: that is a human declining a plan
82
+ * before it ran, this is a plan that ran and did not finish.
83
+ */
84
+ failureReason?: string
71
85
  }
72
86
 
73
87
  export interface PlanApprovalRequest {
@@ -434,6 +434,30 @@ type CoreRunEvent =
434
434
  stepId: string
435
435
  status: PlanStep['status']
436
436
  }
437
+ /**
438
+ * The plan is over, and it went the way it was supposed to.
439
+ *
440
+ * The plan events used to stop before the outcome: `plan_ready`,
441
+ * `plan_approved`, `plan_rejected` and `plan_step_updated` all reached the
442
+ * wire, and the two terminal ones were folded into a bare `break` in the
443
+ * translator. So a host watching the stream saw the steps report and then
444
+ * silence — it could tell a plan had been approved and never that it
445
+ * closed, which leaves a plan rendered as in-flight forever.
446
+ *
447
+ * Found by the first live end-to-end run rather than by a test, and the
448
+ * reason is worth keeping: the tests read the outcome off `PlanManager`
449
+ * through `onContextCreated`, so they proved the plan settled without ever
450
+ * asking whether a consumer of the EVENT STREAM could see it.
451
+ */
452
+ | { type: 'plan_completed'; runId: RunId; planId: PlanId }
453
+ /**
454
+ * The plan is over and it did not finish.
455
+ *
456
+ * `reason` is the text handed to `failPlan`, which used to be discarded —
457
+ * an event that says "failed" without saying why puts the reader back
458
+ * where the missing event did.
459
+ */
460
+ | { type: 'plan_failed'; runId: RunId; planId: PlanId; reason?: string }
437
461
  | {
438
462
  type: 'agent_pending'
439
463
  runId: RunId