@vellumai/assistant 0.11.4-staging.2 → 0.11.4-staging.3

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 (189) hide show
  1. package/AGENTS.md +8 -2
  2. package/ARCHITECTURE.md +2 -0
  3. package/docs/architecture/memory.md +15 -0
  4. package/docs/browser-use-architecture-phase2.md +128 -56
  5. package/docs/flux-turn-detection-spike.md +11 -6
  6. package/knip.json +3 -0
  7. package/openapi.yaml +136 -76
  8. package/package.json +1 -1
  9. package/scripts/write-plugin-api-shim.ts +10 -0
  10. package/src/__tests__/app-control-flow.test.ts +1 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -2
  12. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  13. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  14. package/src/__tests__/compaction-events.test.ts +8 -10
  15. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  16. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  17. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  18. package/src/__tests__/conversation-queue.test.ts +39 -62
  19. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  20. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  21. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  22. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  23. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  25. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  28. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  29. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  30. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  31. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  37. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  38. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  39. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  40. package/src/__tests__/document-sync-tags.test.ts +0 -75
  41. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  42. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  43. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  44. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  45. package/src/__tests__/media-generate-image.test.ts +131 -21
  46. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  47. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  48. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  49. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  50. package/src/__tests__/qdrant-manager.test.ts +14 -1
  51. package/src/__tests__/run-due-schedules.test.ts +21 -0
  52. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  53. package/src/__tests__/schedule-routes.test.ts +23 -0
  54. package/src/__tests__/schedule-store.test.ts +17 -0
  55. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  56. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  57. package/src/__tests__/starter-task-flow.test.ts +5 -4
  58. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  59. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  60. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  61. package/src/__tests__/subagent-tools.test.ts +81 -101
  62. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  63. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  64. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  65. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  66. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  67. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  68. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  69. package/src/acp/session-manager.ts +8 -1
  70. package/src/api/surfaces.ts +5 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  72. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  73. package/src/calls/voice-session-bridge.ts +44 -25
  74. package/src/calls/voice-triage-escalate.ts +1 -0
  75. package/src/cli/bundled-modules.ts +29 -0
  76. package/src/cli/commands/db/repair.ts +4 -8
  77. package/src/cli/commands/domain.ts +6 -3
  78. package/src/cli/commands/email.ts +6 -3
  79. package/src/cli/commands/keys.ts +8 -3
  80. package/src/cli/commands/plugins.ts +85 -36
  81. package/src/cli/commands/schedules.ts +35 -1
  82. package/src/cli/lib/bundled-marketplace.json +1 -1
  83. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  84. package/src/config/assistant-feature-flags.ts +36 -15
  85. package/src/config/balanced-model-experiment.ts +35 -0
  86. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  87. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  89. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  90. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  91. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  92. package/src/config/call-site-defaults.ts +7 -0
  93. package/src/config/default-profile-catalog.ts +96 -4
  94. package/src/config/feature-flag-registry.json +11 -11
  95. package/src/config/skills.ts +9 -2
  96. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  97. package/src/daemon/conversation-agent-loop.ts +14 -13
  98. package/src/daemon/conversation-notifiers.ts +11 -9
  99. package/src/daemon/conversation-process.ts +0 -27
  100. package/src/daemon/conversation-store.ts +4 -4
  101. package/src/daemon/conversation-surfaces.ts +27 -13
  102. package/src/daemon/conversation-tool-setup.ts +2 -4
  103. package/src/daemon/conversation.ts +81 -44
  104. package/src/daemon/doordash-steps.ts +2 -2
  105. package/src/daemon/lifecycle.ts +14 -1
  106. package/src/daemon/process-message.ts +0 -13
  107. package/src/daemon/windows-compiled-entry.ts +4 -0
  108. package/src/documents/document-store.ts +5 -235
  109. package/src/hooks/types.ts +5 -0
  110. package/src/ipc/gateway-flag-listener.ts +17 -3
  111. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  112. package/src/live-voice/live-voice-manager.ts +16 -3
  113. package/src/live-voice/live-voice-session.ts +63 -9
  114. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  115. package/src/monitoring/control.ts +1 -0
  116. package/src/monitoring/db-integrity-sample.ts +4 -5
  117. package/src/permissions/prompter.ts +1 -5
  118. package/src/persistence/conversation-queries.ts +66 -16
  119. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  120. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  121. package/src/persistence/schema/documents.ts +4 -4
  122. package/src/plugin-api/constants.ts +8 -0
  123. package/src/plugin-api/index.ts +5 -1
  124. package/src/plugin-api/webhook-url.ts +13 -11
  125. package/src/plugins/defaults/main.ts +6 -7
  126. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  127. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  128. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  129. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  130. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  131. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  132. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  133. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  134. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  135. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  136. package/src/plugins/mtime-cache.ts +17 -0
  137. package/src/prompts/templates/system-sections.ts +0 -7
  138. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  139. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  140. package/src/providers/openai/chat-completions-provider.ts +11 -1
  141. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  142. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  143. package/src/routes/control.ts +1 -0
  144. package/src/routes/route-host-client.ts +1 -0
  145. package/src/runtime/AGENTS.md +16 -17
  146. package/src/runtime/agent-wake.ts +15 -12
  147. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  148. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  149. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  150. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  151. package/src/runtime/routes/conversation-routes.ts +11 -13
  152. package/src/runtime/routes/documents-routes.ts +3 -222
  153. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  154. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  155. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  156. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  157. package/src/runtime/routes/schedule-routes.ts +94 -7
  158. package/src/runtime/routes/workspace-routes.ts +0 -9
  159. package/src/runtime/routes/workspace-utils.ts +3 -13
  160. package/src/runtime/services/conversation-serializer.ts +7 -2
  161. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  162. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  163. package/src/schedule/plugin-schedule-availability.ts +58 -0
  164. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  165. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  166. package/src/schedule/schedule-store.ts +5 -1
  167. package/src/schedule/scheduler.ts +9 -4
  168. package/src/schedule/worker-control.ts +1 -0
  169. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  170. package/src/subagent/consult-context.ts +11 -11
  171. package/src/subagent/consult-prompt.ts +26 -35
  172. package/src/subagent/manager.ts +20 -37
  173. package/src/subagent/notify.ts +7 -1
  174. package/src/subagent/types.ts +8 -6
  175. package/src/tools/acp/spawn.ts +6 -4
  176. package/src/tools/skills/scaffold-managed.ts +25 -7
  177. package/src/tools/subagent/spawn.ts +28 -88
  178. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  179. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  180. package/src/util/logger.ts +16 -0
  181. package/src/util/worker-process.ts +37 -4
  182. package/src/windows-compiled-cli.ts +32 -0
  183. package/src/windows-compiled-entry.ts +4 -0
  184. package/src/windows-compiled-logger.ts +6 -0
  185. package/src/windows-compiled-worker-entry.ts +29 -0
  186. package/src/__tests__/document-workspace-file.test.ts +0 -467
  187. package/src/daemon/interactive-turn-sender.ts +0 -59
  188. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  189. package/src/subagent/consult-transcript.ts +0 -90
@@ -40,6 +40,18 @@ mock.module("../daemon/conversation-store.js", () => ({
40
40
  },
41
41
  }));
42
42
 
43
+ /**
44
+ * Whether the daemon activated the fixture plugin. Run-now answers only for
45
+ * plugins this process brought up, and no route test runs the plugin loader,
46
+ * so the double says "activated" unless a case turns it off.
47
+ */
48
+ let pluginActivated = true;
49
+ const realMtimeCache = await import("../plugins/mtime-cache.js");
50
+ mock.module("../plugins/mtime-cache.js", () => ({
51
+ ...realMtimeCache,
52
+ isPluginDirActivated: () => pluginActivated,
53
+ }));
54
+
43
55
  import type { AssistantEventEnvelope } from "../api/index.js";
44
56
  import { SYNC_TAGS } from "../daemon/message-types/sync.js";
45
57
  import {
@@ -1574,6 +1586,7 @@ describe("plugin-sourced schedules over routes", () => {
1574
1586
 
1575
1587
  beforeEach(() => {
1576
1588
  rmSync(RUN_MARKER, { force: true });
1589
+ pluginActivated = true;
1577
1590
  });
1578
1591
 
1579
1592
  function seedSourcedScript() {
@@ -1626,6 +1639,16 @@ describe("plugin-sourced schedules over routes", () => {
1626
1639
  expect(existsSync(RUN_MARKER)).toBe(false);
1627
1640
  });
1628
1641
 
1642
+ test("refuses a sourced row whose plugin the daemon never activated", async () => {
1643
+ const sourced = await seedSourcedScript();
1644
+ // The declaration is intact on disk, but nothing ran the plugin's
1645
+ // `init`, so run-now must not execute its script.
1646
+ pluginActivated = false;
1647
+
1648
+ await expect(runNow(sourced.id)).rejects.toThrow(BadRequestError);
1649
+ expect(existsSync(RUN_MARKER)).toBe(false);
1650
+ });
1651
+
1629
1652
  test("runs a sourced row whose plugin is healthy", async () => {
1630
1653
  const sourced = await seedSourcedScript();
1631
1654
 
@@ -23,6 +23,7 @@ import {
23
23
  } from "../persistence/conversation-crud.js";
24
24
  import { getDb } from "../persistence/db-connection.js";
25
25
  import { initializeDb } from "../persistence/db-init.js";
26
+ import { isPluginDirActivated } from "../plugins/mtime-cache.js";
26
27
  import { assistantEventHub } from "../runtime/assistant-event-hub.js";
27
28
  import {
28
29
  hasOwnerDeferProvenance,
@@ -1856,6 +1857,22 @@ describe("declared schedules", () => {
1856
1857
  expect(result!.enabled).toBe(false);
1857
1858
  });
1858
1859
 
1860
+ test("enabling a schedule re-arms it on the on-disk declaration alone", async () => {
1861
+ const created = await upsertDeclaredSchedule(SOURCE_KEY, makeDefinition());
1862
+ await setUserEnabled(created.id, false);
1863
+ // A schedule tool reaches this from a conversation turn, which can run in
1864
+ // a sidecar worker process where no plugin is activated. The probe must
1865
+ // therefore read the disk alone: asking about activation here would refuse
1866
+ // every re-enable. Whether the plugin stays armed is the reconciler's
1867
+ // call, and its next sweep disarms the row if it should not be.
1868
+ expect(isPluginDirActivated(examplePluginDir())).toBe(false);
1869
+
1870
+ const result = await setUserEnabled(created.id, true);
1871
+
1872
+ expect(result!.userEnabled).toBe(true);
1873
+ expect(result!.enabled).toBe(true);
1874
+ });
1875
+
1859
1876
  test("enabling a schedule whose plugin manifest is broken records the override without re-arming", async () => {
1860
1877
  const created = await upsertDeclaredSchedule(SOURCE_KEY, makeDefinition());
1861
1878
  await setUserEnabled(created.id, false);
@@ -155,7 +155,7 @@ const runAgentLoopMock = mock(async () => undefined);
155
155
  function makeSendMessageDeps() {
156
156
  const session = {
157
157
  setTrustContext: () => {},
158
- updateClient: () => {},
158
+ replayActivityState: () => {},
159
159
  emitConfirmationStateChanged: () => {},
160
160
  emitActivityState: () => {},
161
161
  setTurnChannelContext: () => {},
@@ -130,7 +130,7 @@ function makeCompletingConversation(): Conversation {
130
130
  setTurnInterfaceContext: () => {},
131
131
  ensureActorScopedHistory: async () => {},
132
132
  usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
133
- updateClient: () => {},
133
+ replayActivityState: () => {},
134
134
  setHostBrowserProxy: () => {},
135
135
  setHostCuProxy: () => {},
136
136
  setHostAppControlProxy: () => {},
@@ -180,7 +180,7 @@ function makeHangingConversation(): Conversation {
180
180
  setTurnInterfaceContext: () => {},
181
181
  ensureActorScopedHistory: async () => {},
182
182
  usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
183
- updateClient: () => {},
183
+ replayActivityState: () => {},
184
184
  setHostBrowserProxy: () => {},
185
185
  setHostCuProxy: () => {},
186
186
  setHostAppControlProxy: () => {},
@@ -261,7 +261,7 @@ function makePendingApprovalConversation(
261
261
  setTurnInterfaceContext: () => {},
262
262
  ensureActorScopedHistory: async () => {},
263
263
  usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
264
- updateClient: () => {},
264
+ replayActivityState: () => {},
265
265
  setHostBrowserProxy: () => {},
266
266
  setHostCuProxy: () => {},
267
267
  setHostAppControlProxy: () => {},
@@ -39,10 +39,12 @@ import {
39
39
  } from "../daemon/conversation-surfaces.js";
40
40
  import { asConversation } from "./helpers/mock-conversation.js";
41
41
 
42
- function makeContext(): Conversation {
42
+ function makeContext(
43
+ emit: (msg: AssistantEvent) => void = () => {},
44
+ ): Conversation {
43
45
  return asConversation({
44
46
  conversationId: "session-1",
45
- sendToClient: () => {},
47
+ emit,
46
48
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
47
49
  lastSurfaceAction: new Map<
48
50
  string,
@@ -131,8 +133,7 @@ describe("starter task surface actions", () => {
131
133
 
132
134
  test("app_open registers dynamic_page surface as action-capable", async () => {
133
135
  const sent: AssistantEvent[] = [];
134
- const ctx = makeContext();
135
- ctx.sendToClient = (msg) => sent.push(msg);
136
+ const ctx = makeContext((msg) => sent.push(msg));
136
137
 
137
138
  const result = await surfaceProxyResolver(ctx, "app_open", {
138
139
  app_id: "test-app",
@@ -182,7 +182,7 @@ describe("SubagentManager fork — prompt source and role decoupling", () => {
182
182
  }
183
183
 
184
184
  test("fork with systemPromptOverride uses that prompt and does not set hasSystemPromptOverride", async () => {
185
- const overridePrompt = "You are an advisor. Frame the context as advice.";
185
+ const overridePrompt = "You are a reviewer. Frame the context as review.";
186
186
  const created = await spawnFork({ systemPromptOverride: overridePrompt });
187
187
 
188
188
  expect(created.systemPrompt).toBe(overridePrompt);
@@ -589,20 +589,17 @@ describe("SubagentManager — first user message framing", () => {
589
589
  await manager.spawnAndAwait(
590
590
  makeConfig({
591
591
  objective: "Please advise.",
592
- fork: true,
593
592
  role: "advisor",
593
+ spawnMode: "advisor_consult",
594
594
  // The advisor always supplies its own framing; setUpSubagent uses it
595
- // verbatim and never falls back to parentSystemPrompt.
595
+ // in place of a built subagent preamble.
596
596
  systemPromptOverride: "You are a senior advisor.",
597
- parentMessages: [
598
- { role: "user", content: [{ type: "text", text: "prior turn" }] },
599
- ],
600
597
  }),
601
598
  () => {},
602
599
  );
603
600
 
604
- // The advisor's user turn is the bare advice request — the generic fork
605
- // directive would fight the advisor system prompt.
601
+ // The consult is a regular blocking spawn, so its user turn is the brief
602
+ // itself: the fork directive would fight the advisor system prompt.
606
603
  expect(lastPersistedUserMessage).toBe("Please advise.");
607
604
  expect(lastPersistedUserMessage).not.toContain("FORK TASK");
608
605
  });
@@ -118,7 +118,7 @@ function makeSetupCtx(overrides: Partial<Conversation> = {}): Conversation {
118
118
  workingDir: "/tmp/test",
119
119
  getTurnActorPrincipalId: () => undefined,
120
120
  abortController: null,
121
- sendToClient: mock(() => {}),
121
+ emit: mock(() => {}),
122
122
  pendingSurfaceActions: new Map(),
123
123
  lastSurfaceAction: new Map(),
124
124
  surfaceState: new Map(),
@@ -1073,16 +1073,24 @@ describe("Subagent spawn repeat-loop guard", () => {
1073
1073
  const objective = "Audit the advisor pipeline for drift";
1074
1074
  seedSpawns("guard-advisor", objective, 6);
1075
1075
 
1076
- const { result } = await spawnWithGuard({
1077
- label: "Consult",
1078
- objective,
1079
- role: "advisor",
1080
- });
1076
+ const manager = getSubagentManager();
1077
+ const originalAwait = manager.spawnAndAwait.bind(manager);
1078
+ manager.spawnAndAwait = (async () =>
1079
+ "guidance") as unknown as typeof manager.spawnAndAwait;
1080
+ try {
1081
+ const { result } = await spawnWithGuard({
1082
+ label: "Consult",
1083
+ objective,
1084
+ role: "advisor",
1085
+ });
1081
1086
 
1082
- // The advisor branch runs and reports its own missing-parent notice, so the
1083
- // guard never saw the repetition.
1084
- expect(result.content).toContain("advisor unavailable");
1085
- expect(result.content).not.toContain("confirm_repeat");
1087
+ // The advisor branch runs and returns its guidance, so the guard never
1088
+ // saw the repetition.
1089
+ expect(result.content).toBe("guidance");
1090
+ expect(result.content).not.toContain("confirm_repeat");
1091
+ } finally {
1092
+ manager.spawnAndAwait = originalAwait;
1093
+ }
1086
1094
  });
1087
1095
 
1088
1096
  test("copies still running trip the guard before any of them completes", async () => {
@@ -2831,19 +2839,27 @@ describe("Subagent output contract", () => {
2831
2839
  });
2832
2840
 
2833
2841
  test("a null contract reads as omitted and reaches the advisor branch", async () => {
2834
- const result = await executeSubagentSpawn(
2835
- {
2836
- label: "Consult",
2837
- objective: "Check the plan",
2838
- role: "advisor",
2839
- output_contract: null,
2840
- },
2841
- makeContext("sess-contract-advisor-none", { sendToClient: () => {} }),
2842
- );
2843
- // The advisor branch runs and reports its own missing-parent notice, so
2844
- // the contract check waved this spawn through.
2845
- expect(result.content).toContain("advisor unavailable");
2846
- expect(result.content).not.toContain("does not apply to the advisor");
2842
+ const manager = getSubagentManager();
2843
+ const originalAwait = manager.spawnAndAwait.bind(manager);
2844
+ manager.spawnAndAwait = (async () =>
2845
+ "guidance") as unknown as typeof manager.spawnAndAwait;
2846
+ try {
2847
+ const result = await executeSubagentSpawn(
2848
+ {
2849
+ label: "Consult",
2850
+ objective: "Check the plan",
2851
+ role: "advisor",
2852
+ output_contract: null,
2853
+ },
2854
+ makeContext("sess-contract-advisor-none", { sendToClient: () => {} }),
2855
+ );
2856
+ // The advisor branch runs and returns its guidance, so the contract check
2857
+ // waved this spawn through.
2858
+ expect(result.content).toBe("guidance");
2859
+ expect(result.content).not.toContain("does not apply to the advisor");
2860
+ } finally {
2861
+ manager.spawnAndAwait = originalAwait;
2862
+ }
2847
2863
  });
2848
2864
 
2849
2865
  test("verdict defaults the child to the cost-optimized tier", async () => {
@@ -2998,7 +3014,6 @@ describe("Subagent output contract", () => {
2998
3014
  // ── Advisor-role consult ────────────────────────────────────────────
2999
3015
 
3000
3016
  describe("Subagent advisor-role consult", () => {
3001
- type Block = { type: string; [k: string]: unknown };
3002
3017
  type CapturedAwait = {
3003
3018
  config: Record<string, unknown>;
3004
3019
  opts?: {
@@ -3062,16 +3077,11 @@ describe("Subagent advisor-role consult", () => {
3062
3077
  expect(result.content).toBe("Here is my advice.");
3063
3078
  // Ran synchronously through spawnAndAwait, not fire-and-forget spawn.
3064
3079
  expect(captured.current).toBeDefined();
3065
- expect(captured.current!.config.fork).toBe(true);
3066
3080
  expect(captured.current!.config.role).toBe("advisor");
3067
3081
  // The advisor is a ROLE, not an `LLMCallSiteEnum` value, so its usage
3068
3082
  // lands under `subagentSpawn` like any other subagent. The declared
3069
- // spawn mode is what separates it from a plain fork in cost telemetry.
3083
+ // spawn mode is what separates it from a plain spawn in cost telemetry.
3070
3084
  expect(captured.current!.config.spawnMode).toBe("advisor_consult");
3071
- // Framing embeds the executor prompt as advisor system prompt context.
3072
- expect(captured.current!.config.systemPromptOverride).toContain(
3073
- "PARENT SYSTEM PROMPT",
3074
- );
3075
3085
  // The situational context pack must never ride display surfaces: the
3076
3086
  // system prompt stays minimal and `objective` (rendered verbatim by the
3077
3087
  // subagent detail panel) stays the concise advice request. Only the
@@ -3089,26 +3099,18 @@ describe("Subagent advisor-role consult", () => {
3089
3099
  }
3090
3100
  });
3091
3101
 
3092
- test("advisor inherits and sanitizes the parent transcript", async () => {
3093
- // Parent in-memory history carries a thinking block (must be stripped) and
3094
- // a completed tool_use/tool_result pair (must be preserved).
3102
+ test("advisor carries no parent transcript or system prompt", async () => {
3103
+ // The consult is a regular blocking spawn: the brief is its whole input, so
3104
+ // nothing of the parent conversation is snapshotted into it.
3095
3105
  mockFindConversation = () => ({
3096
3106
  messages: [
3097
3107
  { role: "user", content: [{ type: "text", text: "Do the task" }] },
3098
3108
  {
3099
3109
  role: "assistant",
3100
- content: [
3101
- { type: "thinking", thinking: "secret reasoning" },
3102
- { type: "text", text: "Working on it" },
3103
- { type: "tool_use", id: "t1", name: "bash", input: {} },
3104
- ],
3105
- },
3106
- {
3107
- role: "user",
3108
- content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }],
3110
+ content: [{ type: "text", text: "Working on it" }],
3109
3111
  },
3110
3112
  ],
3111
- getCurrentSystemPrompt: () => "SYS",
3113
+ getCurrentSystemPrompt: () => "PARENT SYSTEM PROMPT",
3112
3114
  });
3113
3115
  const { captured, restore } = stubAwait("advice");
3114
3116
  try {
@@ -3116,77 +3118,55 @@ describe("Subagent advisor-role consult", () => {
3116
3118
  { label: "Consult", objective: "x", role: "advisor" },
3117
3119
  makeContext("advisor-sess-2", { sendToClient: () => {} }),
3118
3120
  );
3119
- const msgs = captured.current!.config.parentMessages as Array<{
3120
- role: string;
3121
- content: Block[];
3122
- }>;
3123
- const allBlocks = msgs.flatMap((m) => m.content);
3124
- // Thinking is stripped; the completed tool_use/tool_result pair survives.
3125
- expect(allBlocks.some((b) => b.type === "thinking")).toBe(false);
3126
- expect(allBlocks.some((b) => b.type === "tool_use")).toBe(true);
3127
- expect(allBlocks.some((b) => b.type === "tool_result")).toBe(true);
3121
+ expect(captured.current!.config.fork).toBeUndefined();
3122
+ expect(captured.current!.config.parentMessages).toBeUndefined();
3123
+ expect(captured.current!.config.parentSystemPrompt).toBeUndefined();
3124
+ expect(captured.current!.config.systemPromptOverride).not.toContain(
3125
+ "PARENT SYSTEM PROMPT",
3126
+ );
3128
3127
  } finally {
3129
3128
  restore();
3130
3129
  mockFindConversation = () => undefined;
3131
3130
  }
3132
3131
  });
3133
3132
 
3134
- test("in-flight plan is visible to the advisor with no dangling tool_use", async () => {
3135
- // The in-memory snapshot ends on the user turn — the in-flight assistant
3136
- // turn (this turn's plan + the pending advisor tool_use) lives only in the
3137
- // DB at consult time. It must be appended, and its dangling tool_use stripped.
3138
- mockFindConversation = () => ({
3139
- messages: [
3140
- { role: "user", content: [{ type: "text", text: "Plan the work" }] },
3141
- ],
3142
- getCurrentSystemPrompt: () => "SYS",
3143
- });
3144
- mockGetMessages = (convId: string) => {
3145
- if (convId !== "advisor-sess-3") {
3146
- return null;
3147
- }
3148
- return [
3149
- {
3150
- role: "user",
3151
- content: [{ type: "text", text: "Plan the work" }],
3152
- },
3153
- {
3154
- role: "assistant",
3155
- content: [
3156
- { type: "text", text: "My plan: step 1, step 2." },
3157
- {
3158
- type: "tool_use",
3159
- id: "adv-1",
3160
- name: "subagent_spawn",
3161
- input: {},
3162
- },
3163
- ],
3164
- },
3165
- ];
3166
- };
3133
+ test("the agent's objective reaches the advisor as its brief", async () => {
3134
+ // The brief is the advisor's only account of the work, so it must travel
3135
+ // verbatim inside the request fence.
3136
+ const brief = [
3137
+ "Task: migrate the queue to the new store.",
3138
+ "Plan: dual-write, then cut over.",
3139
+ "Evidence: src/queue/store.ts already exports both writers.",
3140
+ "Question: is dual-write the right shape here?",
3141
+ ].join("\n");
3167
3142
  const { captured, restore } = stubAwait("advice");
3168
3143
  try {
3169
3144
  await executeSubagentSpawn(
3170
- { label: "Consult", objective: "x", role: "advisor" },
3171
- makeContext("advisor-sess-3", { sendToClient: () => {} }),
3145
+ { label: "Consult", objective: brief, role: "advisor" },
3146
+ makeContext("advisor-sess-brief", { sendToClient: () => {} }),
3147
+ );
3148
+ expect(captured.current!.config.requestText).toContain(
3149
+ `<agent_request>\n${brief}\n</agent_request>`,
3172
3150
  );
3173
- const msgs = captured.current!.config.parentMessages as Array<{
3174
- role: string;
3175
- content: Block[];
3176
- }>;
3177
- const allBlocks = msgs.flatMap((m) => m.content);
3178
- // The plan text the model wrote this turn is present in the consult.
3179
- expect(
3180
- allBlocks.some(
3181
- (b) => b.type === "text" && b.text === "My plan: step 1, step 2.",
3182
- ),
3183
- ).toBe(true);
3184
- // No dangling tool_use is sent.
3185
- expect(allBlocks.some((b) => b.type === "tool_use")).toBe(false);
3186
3151
  } finally {
3187
3152
  restore();
3188
- mockFindConversation = () => undefined;
3189
- mockGetMessages = () => null;
3153
+ }
3154
+ });
3155
+
3156
+ test("advisor consults still run when the parent conversation is gone", async () => {
3157
+ // The parent is looked up only for its warm skill catalog, so an evicted
3158
+ // conversation costs a section of the context pack, not the consult.
3159
+ mockFindConversation = () => undefined;
3160
+ const { captured, restore } = stubAwait("advice");
3161
+ try {
3162
+ const result = await executeSubagentSpawn(
3163
+ { label: "Consult", objective: "advise me", role: "advisor" },
3164
+ makeContext("advisor-sess-no-parent", { sendToClient: () => {} }),
3165
+ );
3166
+ expect(result.content).toBe("advice");
3167
+ expect(captured.current!.config.role).toBe("advisor");
3168
+ } finally {
3169
+ restore();
3190
3170
  }
3191
3171
  });
3192
3172
 
@@ -104,6 +104,7 @@ function makeContext(): Conversation {
104
104
  conversationId: CONVERSATION_ID,
105
105
  trustContext: { trustClass: "guardian", sourceChannel: "vellum" },
106
106
  sendToClient: () => {},
107
+ emit: () => {},
107
108
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
108
109
  lastSurfaceAction: new Map(),
109
110
  surfaceState: new Map(),
@@ -22,7 +22,7 @@ import { asConversation } from "./helpers/mock-conversation.js";
22
22
  function makeContext(sent: AssistantEvent[] = []): Conversation {
23
23
  return asConversation({
24
24
  conversationId: "session-1",
25
- sendToClient: (msg) => sent.push(msg),
25
+ emit: (msg) => sent.push(msg),
26
26
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
27
27
  lastSurfaceAction: new Map<
28
28
  string,
@@ -32,7 +32,7 @@ const HTML = '<div style="color:var(--content-default)">Hello</div>';
32
32
  function makeContext(sent: AssistantEvent[] = []): Conversation {
33
33
  return asConversation({
34
34
  conversationId: "session-1",
35
- sendToClient: (msg) => sent.push(msg),
35
+ emit: (msg) => sent.push(msg),
36
36
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
37
37
  lastSurfaceAction: new Map<
38
38
  string,
@@ -26,7 +26,7 @@ import { asConversation } from "./helpers/mock-conversation.js";
26
26
  function makeContext(sent: AssistantEvent[] = []): Conversation {
27
27
  return asConversation({
28
28
  conversationId: "session-1",
29
- sendToClient: (msg) => sent.push(msg),
29
+ emit: (msg) => sent.push(msg),
30
30
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
31
31
  lastSurfaceAction: new Map<
32
32
  string,
@@ -19,7 +19,7 @@ import { asConversation } from "./helpers/mock-conversation.js";
19
19
  function makeContext(sent: AssistantEvent[] = []): Conversation {
20
20
  return asConversation({
21
21
  conversationId: "session-1",
22
- sendToClient: (msg) => sent.push(msg),
22
+ emit: (msg) => sent.push(msg),
23
23
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
24
24
  lastSurfaceAction: new Map<
25
25
  string,
@@ -89,7 +89,8 @@ const CALL_SESSION_ID = "call-session-voice-grant-test";
89
89
 
90
90
  /**
91
91
  * Create a mock session that, when runAgentLoop is called, emits a
92
- * confirmation_request through the updateClient callback before completing.
92
+ * confirmation_request through the registered event observer before
93
+ * completing.
93
94
  */
94
95
  function createMockSession(opts?: {
95
96
  confirmationRequestId?: string;
@@ -118,8 +119,9 @@ function createMockSession(opts?: {
118
119
  currentRequestId: requestId,
119
120
  abort: () => {},
120
121
  persistUserMessage: async () => ({ id: "msg-1", deduplicated: false }),
121
- updateClient: (cb: (msg: AssistantEvent) => void, _reset?: boolean) => {
122
+ addEventObserver: (cb: (msg: AssistantEvent) => void) => {
122
123
  clientCallback = cb;
124
+ return () => {};
123
125
  },
124
126
  handleConfirmationResponse: (
125
127
  reqId: string,
@@ -138,7 +140,7 @@ function createMockSession(opts?: {
138
140
  _messageId: string,
139
141
  options?: { onEvent?: (msg: AssistantEvent) => void },
140
142
  ) => {
141
- // Emit a confirmation_request through the client callback
143
+ // Emit a confirmation_request through the event observer
142
144
  if (clientCallback) {
143
145
  clientCallback({
144
146
  type: "confirmation_request",