@vellumai/assistant 0.9.0-dev.202606181513.fd39213 → 0.9.0-dev.202606181735.ae6e1d8

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 (41) hide show
  1. package/openapi.yaml +82 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/plugin-tool-contribution.test.ts +6 -3
  4. package/src/api/events/workflow-completed.ts +53 -0
  5. package/src/api/events/workflow-leaf-finished.ts +38 -0
  6. package/src/api/events/workflow-leaf-started.ts +35 -0
  7. package/src/api/events/workflow-progress.ts +32 -0
  8. package/src/api/events/workflow-started.ts +31 -0
  9. package/src/api/index.ts +38 -0
  10. package/src/api/responses/workflow-journal.ts +53 -0
  11. package/src/daemon/message-types/workflows.ts +83 -1
  12. package/src/memory/db-init.ts +2 -0
  13. package/src/memory/migrations/293-workflow-journal-leaf-tokens.ts +32 -0
  14. package/src/memory/migrations/index.ts +1 -0
  15. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +43 -0
  16. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +134 -0
  17. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +147 -0
  18. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +138 -0
  19. package/src/plugins/defaults/advisor/__tests__/transcript.test.ts +147 -0
  20. package/src/plugins/defaults/advisor/advisor-state-store.ts +94 -0
  21. package/src/plugins/defaults/advisor/config.ts +26 -0
  22. package/src/plugins/defaults/advisor/consult.ts +76 -0
  23. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +34 -0
  24. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +24 -0
  25. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +19 -0
  26. package/src/plugins/defaults/advisor/package.json +14 -0
  27. package/src/plugins/defaults/advisor/steering.ts +51 -0
  28. package/src/plugins/defaults/advisor/tools/advisor.ts +52 -0
  29. package/src/plugins/defaults/advisor/transcript.ts +76 -0
  30. package/src/plugins/defaults/index.ts +35 -0
  31. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +3 -3
  32. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
  33. package/src/runtime/routes/workflow-routes.test.ts +225 -1
  34. package/src/runtime/routes/workflow-routes.ts +131 -1
  35. package/src/tools/workflows/run-workflow.ts +1 -0
  36. package/src/workflows/engine.test.ts +175 -1
  37. package/src/workflows/engine.ts +82 -0
  38. package/src/workflows/journal-store.test.ts +70 -0
  39. package/src/workflows/journal-store.ts +18 -3
  40. package/src/workflows/run-manager.test.ts +171 -3
  41. package/src/workflows/run-manager.ts +63 -0
@@ -73,6 +73,30 @@ export type WorkflowProgressEvent =
73
73
  | { type: "phase"; title: string }
74
74
  | { type: "log"; message: string };
75
75
 
76
+ /**
77
+ * An observe-only lifecycle event for a single leaf — the source of a live leaf
78
+ * tree. Emitted once when a leaf STARTS (after the cap check + spawn increment)
79
+ * and once when it FINISHES (completed or failed). A journal-replayed leaf and
80
+ * an aborted in-flight leaf emit neither.
81
+ */
82
+ export type WorkflowLeafEvent =
83
+ | {
84
+ type: "leaf_started";
85
+ seq: number;
86
+ label?: string;
87
+ phase?: string;
88
+ promptSummary: string;
89
+ }
90
+ | {
91
+ type: "leaf_finished";
92
+ seq: number;
93
+ status: "completed" | "failed";
94
+ label?: string;
95
+ inputTokens: number;
96
+ outputTokens: number;
97
+ resultSummary: string;
98
+ };
99
+
76
100
  /** The journal-store surface the engine depends on (injectable for tests). */
77
101
  export interface WorkflowJournal {
78
102
  appendJournalEntry: typeof JournalStore.appendJournalEntry;
@@ -108,6 +132,13 @@ export interface ExecuteWorkflowOptions {
108
132
  trustContext: TrustContext;
109
133
  /** Receives `phase`/`log` progress events from the script. */
110
134
  onProgress?: (event: WorkflowProgressEvent) => void;
135
+ /**
136
+ * Observe-only per-leaf lifecycle hook. Emits once on START (after the cap
137
+ * check + spawn increment; NEVER on a journal-replay short-circuit) and once
138
+ * on FINISH (completed or failed). An aborted in-flight leaf emits NEITHER a
139
+ * finish nor a spurious failed.
140
+ */
141
+ onLeaf?: (event: WorkflowLeafEvent) => void;
111
142
  /** Cooperative cancellation for the whole run. */
112
143
  signal?: AbortSignal;
113
144
  }
@@ -451,6 +482,26 @@ function skipRegexLiteral(source: string, start: number): number {
451
482
  return source.length - 1;
452
483
  }
453
484
 
485
+ /**
486
+ * A bounded string summary of a leaf prompt or result, for an observe-only
487
+ * {@link WorkflowLeafEvent}. Strings pass through; everything else is
488
+ * JSON-serialized (falling back to `String(value)` if it cannot be). Truncated
489
+ * to `max` chars with a trailing ellipsis.
490
+ */
491
+ function summarizeForLeafEvent(value: unknown, max = 200): string {
492
+ let text: string;
493
+ if (typeof value === "string") {
494
+ text = value;
495
+ } else {
496
+ try {
497
+ text = JSON.stringify(value);
498
+ } catch {
499
+ text = String(value);
500
+ }
501
+ }
502
+ return text.length > max ? `${text.slice(0, max)}…` : text;
503
+ }
504
+
454
505
  function callHashOf(prompt: string, opts: LeafCallOptions): string {
455
506
  return createHash("sha256")
456
507
  .update(deterministicStringify({ prompt, opts }))
@@ -542,6 +593,7 @@ export async function executeWorkflow(
542
593
  leafRunner,
543
594
  trustContext,
544
595
  onProgress,
596
+ onLeaf,
545
597
  signal,
546
598
  } = opts;
547
599
 
@@ -630,6 +682,14 @@ export async function executeWorkflow(
630
682
  }
631
683
  agentsSpawned += 1;
632
684
 
685
+ onLeaf?.({
686
+ type: "leaf_started",
687
+ seq,
688
+ ...(leafOpts.label !== undefined ? { label: leafOpts.label } : {}),
689
+ ...(leafOpts.phase !== undefined ? { phase: leafOpts.phase } : {}),
690
+ promptSummary: summarizeForLeafEvent(prompt),
691
+ });
692
+
633
693
  try {
634
694
  // A leaf is EITHER a schema leaf (structured output via forced
635
695
  // tool-choice — `schema` set, no tools) OR a tool leaf (free-form with
@@ -666,6 +726,17 @@ export async function executeWorkflow(
666
726
  request: { prompt, opts: leafOpts },
667
727
  result: result.output,
668
728
  status: "completed",
729
+ inputTokens: result.inputTokens,
730
+ outputTokens: result.outputTokens,
731
+ });
732
+ onLeaf?.({
733
+ type: "leaf_finished",
734
+ seq,
735
+ status: "completed",
736
+ ...(leafOpts.label !== undefined ? { label: leafOpts.label } : {}),
737
+ inputTokens: result.inputTokens,
738
+ outputTokens: result.outputTokens,
739
+ resultSummary: summarizeForLeafEvent(result.output),
669
740
  });
670
741
  return { output: result.output, failed: false };
671
742
  } catch (err) {
@@ -693,6 +764,17 @@ export async function executeWorkflow(
693
764
  { err, runId, seq, label: leafOpts.label },
694
765
  "Workflow leaf failed",
695
766
  );
767
+ onLeaf?.({
768
+ type: "leaf_finished",
769
+ seq,
770
+ status: "failed",
771
+ ...(leafOpts.label !== undefined ? { label: leafOpts.label } : {}),
772
+ inputTokens: 0,
773
+ outputTokens: 0,
774
+ resultSummary: summarizeForLeafEvent(
775
+ err instanceof Error ? err.message : String(err),
776
+ ),
777
+ });
696
778
  return { output: null, failed: true };
697
779
  }
698
780
  };
@@ -197,6 +197,76 @@ describe("workflow journal store", () => {
197
197
  });
198
198
  });
199
199
 
200
+ test("appendJournalEntry round-trips per-leaf token usage", () => {
201
+ createRun({ id: "wf-tok", scriptSource: "s", scriptHash: "h" });
202
+
203
+ appendJournalEntry({
204
+ runId: "wf-tok",
205
+ seq: 0,
206
+ callHash: "c0",
207
+ kind: "agent",
208
+ request: { prompt: "with tokens" },
209
+ result: { text: "r" },
210
+ status: "completed",
211
+ inputTokens: 120,
212
+ outputTokens: 45,
213
+ });
214
+ // A leaf with no recorded usage stores NULL → reads back as undefined.
215
+ appendJournalEntry({
216
+ runId: "wf-tok",
217
+ seq: 1,
218
+ callHash: "c1",
219
+ kind: "agent",
220
+ request: { prompt: "no tokens" },
221
+ result: { error: "boom" },
222
+ status: "failed",
223
+ });
224
+
225
+ const journal = getJournal("wf-tok");
226
+ expect(journal[0]).toMatchObject({
227
+ seq: 0,
228
+ inputTokens: 120,
229
+ outputTokens: 45,
230
+ });
231
+ expect(journal[1].inputTokens).toBeUndefined();
232
+ expect(journal[1].outputTokens).toBeUndefined();
233
+ });
234
+
235
+ test("appendJournalEntry upserts token usage on a changed-hash re-run", () => {
236
+ createRun({ id: "wf-tok-resume", scriptSource: "s", scriptHash: "h" });
237
+
238
+ appendJournalEntry({
239
+ runId: "wf-tok-resume",
240
+ seq: 0,
241
+ callHash: "hash-old",
242
+ kind: "agent",
243
+ result: { text: "old" },
244
+ status: "completed",
245
+ inputTokens: 10,
246
+ outputTokens: 5,
247
+ });
248
+ // Resume re-runs the leaf with new usage; the upsert overwrites the tokens.
249
+ appendJournalEntry({
250
+ runId: "wf-tok-resume",
251
+ seq: 0,
252
+ callHash: "hash-new",
253
+ kind: "agent",
254
+ result: { text: "new" },
255
+ status: "completed",
256
+ inputTokens: 30,
257
+ outputTokens: 12,
258
+ });
259
+
260
+ const journal = getJournal("wf-tok-resume");
261
+ expect(journal).toHaveLength(1);
262
+ expect(journal[0]).toMatchObject({
263
+ seq: 0,
264
+ callHash: "hash-new",
265
+ inputTokens: 30,
266
+ outputTokens: 12,
267
+ });
268
+ });
269
+
200
270
  test("appendJournalEntry is idempotent on (run_id, seq)", () => {
201
271
  createRun({ id: "wf-dup", scriptSource: "s", scriptHash: "h" });
202
272
 
@@ -68,6 +68,9 @@ export interface WorkflowJournalEntry {
68
68
  request: unknown;
69
69
  result: unknown;
70
70
  status: string;
71
+ /** Per-leaf token usage for a completed agent leaf; undefined otherwise. */
72
+ inputTokens?: number;
73
+ outputTokens?: number;
71
74
  createdAt: number | null;
72
75
  }
73
76
 
@@ -111,6 +114,9 @@ export interface AppendJournalEntryInput {
111
114
  request?: unknown;
112
115
  result?: unknown;
113
116
  status: string;
117
+ /** Per-leaf token usage; set for a completed agent leaf, omitted otherwise. */
118
+ inputTokens?: number;
119
+ outputTokens?: number;
114
120
  }
115
121
 
116
122
  // ---------------------------------------------------------------------------
@@ -145,6 +151,8 @@ interface WorkflowJournalRow {
145
151
  request_json: string | null;
146
152
  result_json: string | null;
147
153
  status: string;
154
+ input_tokens: number | null;
155
+ output_tokens: number | null;
148
156
  created_at: number | null;
149
157
  }
150
158
 
@@ -194,6 +202,8 @@ function rowToJournalEntry(row: WorkflowJournalRow): WorkflowJournalEntry {
194
202
  request: parseJsonColumn(row.request_json),
195
203
  result: parseJsonColumn(row.result_json),
196
204
  status: row.status,
205
+ inputTokens: row.input_tokens ?? undefined,
206
+ outputTokens: row.output_tokens ?? undefined,
197
207
  createdAt: row.created_at,
198
208
  };
199
209
  }
@@ -318,14 +328,17 @@ export function appendJournalEntry(
318
328
  rawRun(
319
329
  /*sql*/ `
320
330
  INSERT INTO workflow_journal (
321
- run_id, seq, call_hash, kind, request_json, result_json, status, created_at
322
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
331
+ run_id, seq, call_hash, kind, request_json, result_json, status,
332
+ input_tokens, output_tokens, created_at
333
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
323
334
  ON CONFLICT(run_id, seq) DO UPDATE SET
324
335
  call_hash = excluded.call_hash,
325
336
  kind = excluded.kind,
326
337
  request_json = excluded.request_json,
327
338
  result_json = excluded.result_json,
328
- status = excluded.status
339
+ status = excluded.status,
340
+ input_tokens = excluded.input_tokens,
341
+ output_tokens = excluded.output_tokens
329
342
  `,
330
343
  input.runId,
331
344
  input.seq,
@@ -334,6 +347,8 @@ export function appendJournalEntry(
334
347
  serializeJsonColumn(input.request),
335
348
  serializeJsonColumn(input.result),
336
349
  input.status,
350
+ input.inputTokens ?? null,
351
+ input.outputTokens ?? null,
337
352
  Date.now(),
338
353
  );
339
354
  return getJournalEntry(input.runId, input.seq)!;
@@ -292,6 +292,7 @@ describe("WorkflowRunManager.abort", () => {
292
292
  scriptSource: "export const meta = { name: 'x', description: 'y' }",
293
293
  args: {},
294
294
  manifest: { tools: [], hostFunctions: [], persona: false },
295
+ conversationId: "conv-1",
295
296
  trustContext: TRUST,
296
297
  });
297
298
 
@@ -318,6 +319,7 @@ describe("WorkflowRunManager — progress events", () => {
318
319
  scriptSource: "export const meta = { name: 'x', description: 'y' }",
319
320
  args: {},
320
321
  manifest: { tools: [], hostFunctions: [], persona: false },
322
+ conversationId: "conv-1",
321
323
  label: "My Flow",
322
324
  trustContext: TRUST,
323
325
  });
@@ -330,14 +332,164 @@ describe("WorkflowRunManager — progress events", () => {
330
332
  expect(progress).toHaveLength(2);
331
333
  expect(progress[0]).toMatchObject({
332
334
  type: "workflow_progress",
335
+ conversationId: "conv-1",
333
336
  label: "My Flow",
334
337
  phase: "Gathering",
335
338
  });
336
339
  expect(progress[1]).toMatchObject({
337
340
  type: "workflow_progress",
341
+ conversationId: "conv-1",
338
342
  message: "step done",
339
343
  });
340
344
  });
345
+
346
+ test("a run with no conversation broadcasts progress unscoped (no conversationId)", () => {
347
+ const h = makeHarness();
348
+ h.manager.start({
349
+ scriptSource: "export const meta = { name: 'x', description: 'y' }",
350
+ args: {},
351
+ manifest: { tools: [], hostFunctions: [], persona: false },
352
+ label: "My Flow",
353
+ trustContext: TRUST,
354
+ });
355
+
356
+ const onProgress = h.executeCalls[0]!.onProgress!;
357
+ onProgress({ type: "phase", title: "Gathering" });
358
+ onProgress({ type: "log", message: "step done" });
359
+
360
+ // `workflow_progress` is a pre-existing event; it still broadcasts for a
361
+ // conversationless run, just without a `conversationId` (unscoped).
362
+ const progress = h.broadcasts.filter((b) => b.type === "workflow_progress");
363
+ expect(progress).toHaveLength(2);
364
+ expect(progress[0]).toMatchObject({
365
+ type: "workflow_progress",
366
+ label: "My Flow",
367
+ phase: "Gathering",
368
+ });
369
+ expect(progress[0]).not.toHaveProperty("conversationId");
370
+ });
371
+
372
+ test("onLeaf start/finish are republished as scoped leaf events", () => {
373
+ const fakeEngine: WorkflowRunManagerDeps["executeWorkflow"] = (options) => {
374
+ options.onLeaf?.({
375
+ type: "leaf_started",
376
+ seq: 1,
377
+ label: "Research",
378
+ phase: "Gather",
379
+ promptSummary: "look it up",
380
+ });
381
+ options.onLeaf?.({
382
+ type: "leaf_finished",
383
+ seq: 1,
384
+ status: "completed",
385
+ label: "Research",
386
+ inputTokens: 30,
387
+ outputTokens: 12,
388
+ resultSummary: "found it",
389
+ });
390
+ return new Promise(() => {}) as ReturnType<
391
+ WorkflowRunManagerDeps["executeWorkflow"]
392
+ >;
393
+ };
394
+ const h = makeHarness({ engine: fakeEngine });
395
+
396
+ const { runId } = h.manager.start({
397
+ scriptSource: "export const meta = { name: 'x', description: 'y' }",
398
+ args: {},
399
+ manifest: { tools: [], hostFunctions: [], persona: false },
400
+ conversationId: "conv-1",
401
+ trustContext: TRUST,
402
+ });
403
+
404
+ const started = h.broadcasts.find(
405
+ (b) => b.type === "workflow_leaf_started",
406
+ );
407
+ expect(started).toMatchObject({
408
+ type: "workflow_leaf_started",
409
+ runId,
410
+ conversationId: "conv-1",
411
+ seq: 1,
412
+ label: "Research",
413
+ phase: "Gather",
414
+ promptSummary: "look it up",
415
+ });
416
+
417
+ const finished = h.broadcasts.find(
418
+ (b) => b.type === "workflow_leaf_finished",
419
+ );
420
+ expect(finished).toMatchObject({
421
+ type: "workflow_leaf_finished",
422
+ runId,
423
+ conversationId: "conv-1",
424
+ seq: 1,
425
+ status: "completed",
426
+ label: "Research",
427
+ inputTokens: 30,
428
+ outputTokens: 12,
429
+ resultSummary: "found it",
430
+ });
431
+ });
432
+
433
+ test("a run with no conversation gets no onLeaf callback", () => {
434
+ const h = makeHarness();
435
+ h.manager.start({
436
+ scriptSource: "export const meta = { name: 'x', description: 'y' }",
437
+ args: {},
438
+ manifest: { tools: [], hostFunctions: [], persona: false },
439
+ trustContext: TRUST,
440
+ });
441
+
442
+ expect(h.executeCalls[0]!.onLeaf).toBeUndefined();
443
+ });
444
+ });
445
+
446
+ describe("WorkflowRunManager.start — workflow_started", () => {
447
+ test("start with a conversation broadcasts workflow_started carrying toolUseId", () => {
448
+ const h = makeHarness();
449
+ const { runId } = h.manager.start({
450
+ scriptSource: "export const meta = { name: 'x', description: 'y' }",
451
+ args: {},
452
+ manifest: { tools: [], hostFunctions: [], persona: false },
453
+ conversationId: "conv-1",
454
+ toolUseId: "toolu-abc",
455
+ label: "My Flow",
456
+ trustContext: TRUST,
457
+ });
458
+
459
+ const started = h.broadcasts.find((b) => b.type === "workflow_started");
460
+ expect(started).toMatchObject({
461
+ type: "workflow_started",
462
+ runId,
463
+ conversationId: "conv-1",
464
+ toolUseId: "toolu-abc",
465
+ label: "My Flow",
466
+ });
467
+ });
468
+
469
+ test("start without a conversation broadcasts no workflow_started", () => {
470
+ const h = makeHarness();
471
+ h.manager.start({
472
+ scriptSource: "export const meta = { name: 'x', description: 'y' }",
473
+ args: {},
474
+ manifest: { tools: [], hostFunctions: [], persona: false },
475
+ trustContext: TRUST,
476
+ });
477
+
478
+ expect(h.broadcasts.some((b) => b.type === "workflow_started")).toBe(false);
479
+ });
480
+
481
+ test("resume never broadcasts workflow_started", () => {
482
+ const h = makeHarness({});
483
+ seedRun(h, {
484
+ id: "run-x",
485
+ status: "interrupted",
486
+ conversationId: "conv-1",
487
+ });
488
+
489
+ h.manager.resume("run-x");
490
+
491
+ expect(h.broadcasts.some((b) => b.type === "workflow_started")).toBe(false);
492
+ });
341
493
  });
342
494
 
343
495
  describe("WorkflowRunManager — completion", () => {
@@ -364,6 +516,7 @@ describe("WorkflowRunManager — completion", () => {
364
516
  expect(completed).toMatchObject({
365
517
  type: "workflow_completed",
366
518
  runId,
519
+ conversationId: "conv-1",
367
520
  status: "completed",
368
521
  agentsSpawned: 4,
369
522
  inputTokens: 100,
@@ -418,7 +571,7 @@ describe("WorkflowRunManager — completion", () => {
418
571
  expect(h.manager.status(runId)?.result).toBe(big);
419
572
  });
420
573
 
421
- test("no conversationId → completion events fire but no wake", async () => {
574
+ test("no conversationId → pre-existing events broadcast unscoped; UI events and wake are gated", async () => {
422
575
  const h = makeHarness();
423
576
  h.manager.start({
424
577
  scriptSource: "export const meta = { name: 'x', description: 'y' }",
@@ -435,8 +588,23 @@ describe("WorkflowRunManager — completion", () => {
435
588
  outputTokens: 1,
436
589
  });
437
590
 
438
- expect(h.broadcasts.some((b) => b.type === "workflow_completed")).toBe(
439
- true,
591
+ // The pre-existing terminal event still broadcasts (unscoped) so raw SSE
592
+ // listeners and conversationless scheduled runs are still surfaced.
593
+ const completed = h.broadcasts.find((b) => b.type === "workflow_completed");
594
+ expect(completed).toMatchObject({
595
+ type: "workflow_completed",
596
+ status: "completed",
597
+ });
598
+ expect(completed).not.toHaveProperty("conversationId");
599
+
600
+ // The conversation-only signals stay gated: no `workflow_started`, no leaf
601
+ // events, and no completion wake without an originating conversation.
602
+ expect(h.broadcasts.some((b) => b.type === "workflow_started")).toBe(false);
603
+ expect(h.broadcasts.some((b) => b.type === "workflow_leaf_started")).toBe(
604
+ false,
605
+ );
606
+ expect(h.broadcasts.some((b) => b.type === "workflow_leaf_finished")).toBe(
607
+ false,
440
608
  );
441
609
  expect(h.wakes).toHaveLength(0);
442
610
  });
@@ -114,6 +114,11 @@ interface StartWorkflowCommon {
114
114
  * scheduled run with no chat surface) — the summary is then events-only.
115
115
  */
116
116
  conversationId?: string;
117
+ /**
118
+ * The `skill_execute` tool-use block id that launched this run; forwarded on
119
+ * `workflow_started` so the client anchors the inline card to the spawn call.
120
+ */
121
+ toolUseId?: string;
117
122
  /** Human-readable label for display; defaults to the run id. */
118
123
  label?: string;
119
124
  /** Trust/auth context forwarded to every leaf. */
@@ -215,6 +220,19 @@ export class WorkflowRunManager {
215
220
  const controller = new AbortController();
216
221
  this.inflight.set(runId, controller);
217
222
 
223
+ // Announce the launch only when there's a conversation to scope it to (the
224
+ // same gate the in-flight/terminal broadcasts use). `resume` never emits
225
+ // this — the run already announced itself on its original `start`.
226
+ if (opts.conversationId) {
227
+ this.deps.broadcast({
228
+ type: "workflow_started",
229
+ runId,
230
+ conversationId: opts.conversationId,
231
+ ...(opts.toolUseId ? { toolUseId: opts.toolUseId } : {}),
232
+ label,
233
+ });
234
+ }
235
+
218
236
  // Fire-and-forget: the engine owns its own try/catch and always finishes
219
237
  // the run row. We never await it — `start` must return synchronously.
220
238
  void this.runToCompletion(
@@ -376,6 +394,14 @@ export class WorkflowRunManager {
376
394
  ctx: RunContext,
377
395
  ): Promise<void> {
378
396
  const config = this.deps.getConfig();
397
+ // `workflow_progress` / `workflow_completed` always broadcast: a run with no
398
+ // originating conversation emits them unscoped (no `conversationId`) for raw
399
+ // SSE listeners and the DB record, preserving the pre-scoping contract that
400
+ // surfaces conversationless scheduled runs. The conversation-only signals —
401
+ // `workflow_started`, the `onLeaf` leaf events, and the completion wake —
402
+ // stay gated on `conversationId` since they exist solely to drive the inline
403
+ // workflow card in that conversation.
404
+ const conversationId = ctx.conversationId;
379
405
  try {
380
406
  const result = await this.deps.executeWorkflow({
381
407
  runId,
@@ -393,6 +419,7 @@ export class WorkflowRunManager {
393
419
  this.deps.broadcast({
394
420
  type: "workflow_progress",
395
421
  runId,
422
+ ...(conversationId ? { conversationId } : {}),
396
423
  label,
397
424
  agentsSpawned,
398
425
  ...(event.type === "phase"
@@ -400,6 +427,41 @@ export class WorkflowRunManager {
400
427
  : { message: event.message }),
401
428
  });
402
429
  },
430
+ ...(conversationId
431
+ ? {
432
+ onLeaf: (event) => {
433
+ if (event.type === "leaf_started") {
434
+ this.deps.broadcast({
435
+ type: "workflow_leaf_started",
436
+ runId,
437
+ conversationId,
438
+ seq: event.seq,
439
+ ...(event.label !== undefined
440
+ ? { label: event.label }
441
+ : {}),
442
+ ...(event.phase !== undefined
443
+ ? { phase: event.phase }
444
+ : {}),
445
+ promptSummary: event.promptSummary,
446
+ });
447
+ } else {
448
+ this.deps.broadcast({
449
+ type: "workflow_leaf_finished",
450
+ runId,
451
+ conversationId,
452
+ seq: event.seq,
453
+ status: event.status,
454
+ ...(event.label !== undefined
455
+ ? { label: event.label }
456
+ : {}),
457
+ inputTokens: event.inputTokens,
458
+ outputTokens: event.outputTokens,
459
+ resultSummary: event.resultSummary,
460
+ });
461
+ }
462
+ },
463
+ }
464
+ : {}),
403
465
  });
404
466
 
405
467
  const summary = buildCompletionSummary(
@@ -412,6 +474,7 @@ export class WorkflowRunManager {
412
474
  this.deps.broadcast({
413
475
  type: "workflow_completed",
414
476
  runId,
477
+ ...(conversationId ? { conversationId } : {}),
415
478
  status: result.status,
416
479
  agentsSpawned: result.agentsSpawned,
417
480
  inputTokens: result.inputTokens,