@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
@@ -11,7 +11,6 @@ import type {
11
11
  } from "../agent/loop.js";
12
12
  import type { AssistantEvent } from "../api/index.js";
13
13
  import type { Message, ProviderResponse } from "../providers/types.js";
14
- import { broadcastMessage } from "../runtime/assistant-event-hub.js";
15
14
  import { stampAndBuffer } from "../runtime/assistant-stream-state.js";
16
15
  import { createAbortReason } from "../util/abort-reasons.js";
17
16
  import { setConfig } from "./helpers/set-config.js";
@@ -2989,6 +2988,9 @@ describe("Conversation host attachment directives", () => {
2989
2988
  attachments: [],
2990
2989
  onEvent: (e) => events.push(e),
2991
2990
  requestId: "req-1",
2991
+ // A host attachment read prompts only when a human is present to
2992
+ // answer; presence is declared per turn.
2993
+ isInteractive: true,
2992
2994
  });
2993
2995
  await waitForPendingRun(1);
2994
2996
 
@@ -3059,6 +3061,9 @@ describe("Conversation host attachment directives", () => {
3059
3061
  attachments: [],
3060
3062
  onEvent: (e) => events.push(e),
3061
3063
  requestId: "req-1",
3064
+ // A host attachment read prompts only when a human is present to
3065
+ // answer; presence is declared per turn.
3066
+ isInteractive: true,
3062
3067
  });
3063
3068
  await waitForPendingRun(1);
3064
3069
 
@@ -3925,13 +3930,20 @@ describe("subagent notification user_message_echo suppression", () => {
3925
3930
  });
3926
3931
  });
3927
3932
 
3928
- describe("interactive drain sender rebind", () => {
3933
+ describe("drained turns deliver through the conversation sink", () => {
3929
3934
  beforeEach(() => {
3930
3935
  pendingRuns = [];
3931
3936
  });
3932
3937
 
3933
- test("a drained interactive message rebinds the hub sender for its turn, then restores the pre-drain state", async () => {
3934
- const conversation = makeConversation();
3938
+ test("a confirmation prompt raised in a drained interactive turn reaches the sink", async () => {
3939
+ // The sink is fixed for the conversation's life, so an interactive turn
3940
+ // drained from the queue delivers exactly like a live one: no per-turn
3941
+ // rebinding, nothing for the previous turn's cleanup to clobber. This is
3942
+ // the invariant behind the acp_spawn permission-prompt timeouts (a queued
3943
+ // interactive turn's confirmation_request used to be emitted into a
3944
+ // reset no-op sender and hang until the permission timeout).
3945
+ const sink: AssistantEvent[] = [];
3946
+ const conversation = makeConversation((msg) => sink.push(msg));
3935
3947
  await conversation.loadFromDb();
3936
3948
 
3937
3949
  const p1 = conversation.processMessage({
@@ -3942,78 +3954,46 @@ describe("interactive drain sender rebind", () => {
3942
3954
  });
3943
3955
  await waitForPendingRun(1);
3944
3956
 
3945
- // Queue an interactive item the way the /v1/messages route does when the
3946
- // conversation is busy.
3947
- const result = conversation.enqueueMessage({
3957
+ conversation.enqueueMessage({
3948
3958
  content: "msg-2",
3949
3959
  requestId: "req-2",
3950
3960
  onEvent: () => {},
3951
3961
  isInteractive: true,
3952
3962
  });
3953
- expect(result.queued).toBe(true);
3954
-
3955
- // The interactive turn that finishes ahead of the drain resets the
3956
- // conversation-level sender to a no-op (process-message.ts contract).
3957
- // The drained turn must not inherit that dead sender: the
3958
- // PermissionPrompter reads the conversation-level sender, so a
3959
- // confirmation_request raised during the drained turn would otherwise
3960
- // reach no client and hang until the permission timeout.
3961
- conversation.updateClient(() => {}, true);
3962
3963
 
3963
3964
  await resolveRun(0);
3964
3965
  await p1;
3965
3966
  await waitForPendingRun(2);
3966
3967
 
3967
- expect(conversation.getCurrentSender()).toBe(broadcastMessage);
3968
+ // The drained turn is in flight and interactive: presence reads a human.
3968
3969
  expect(conversation.hasNoClient).toBe(false);
3969
3970
 
3970
- await resolveRun(1);
3971
- await waitForCondition(
3972
- () => conversation.getCurrentSender() !== broadcastMessage,
3971
+ // A prompt raised now, mid-drained-turn, must land on the sink.
3972
+ void conversation.prompter.prompt(
3973
+ "acp_spawn",
3974
+ { agent: "claude", task: "t" },
3975
+ "high",
3976
+ [],
3977
+ [],
3978
+ undefined,
3979
+ conversation.conversationId,
3980
+ "host",
3981
+ false,
3982
+ undefined,
3983
+ "tool-use-1",
3973
3984
  );
3974
- expect(conversation.hasNoClient).toBe(true);
3975
- });
3976
-
3977
- test("a drained interactive message restores a live pre-drain binding instead of clearing it", async () => {
3978
- const conversation = makeConversation();
3979
- await conversation.loadFromDb();
3980
-
3981
- const p1 = conversation.processMessage({
3982
- content: "msg-1",
3983
- attachments: [],
3984
- onEvent: () => {},
3985
- requestId: "req-1",
3986
- });
3987
- await waitForPendingRun(1);
3988
-
3989
- conversation.enqueueMessage({
3990
- content: "msg-2",
3991
- requestId: "req-2",
3992
- onEvent: () => {},
3993
- isInteractive: true,
3994
- });
3995
-
3996
- // A live binding predates the drained turn (the send route installs one
3997
- // when a message is queued behind a non-interactive turn, which performs
3998
- // no restore). The drain's restore must preserve it for out-of-turn
3999
- // producers (call transcript and completion notifiers), not clear it.
4000
- conversation.updateClient(broadcastMessage, false);
4001
-
4002
- await resolveRun(0);
4003
- await p1;
4004
- await waitForPendingRun(2);
4005
-
4006
- expect(conversation.getCurrentSender()).toBe(broadcastMessage);
4007
- expect(conversation.hasNoClient).toBe(false);
3985
+ const prompt = sink.find((e) => e.type === "confirmation_request");
3986
+ expect(prompt).toBeDefined();
3987
+ expect((prompt as { toolName: string }).toolName).toBe("acp_spawn");
3988
+ // Settle it so the prompt's timer does not outlive the test.
3989
+ conversation.prompter.denyAllPending();
4008
3990
 
4009
3991
  await resolveRun(1);
4010
- // The drained turn has ended; the pre-drain live binding must survive.
4011
- await waitForCondition(() => !conversation.isProcessing());
4012
- expect(conversation.getCurrentSender()).toBe(broadcastMessage);
4013
- expect(conversation.hasNoClient).toBe(false);
3992
+ // Turn over: nothing is in flight, so no human is asserted present.
3993
+ await waitForCondition(() => conversation.hasNoClient);
4014
3994
  });
4015
3995
 
4016
- test("a drained non-interactive message leaves the no-op sender in place", async () => {
3996
+ test("a drained non-interactive turn asserts no human present", async () => {
4017
3997
  const conversation = makeConversation();
4018
3998
  await conversation.loadFromDb();
4019
3999
 
@@ -4032,13 +4012,10 @@ describe("interactive drain sender rebind", () => {
4032
4012
  isInteractive: false,
4033
4013
  });
4034
4014
 
4035
- conversation.updateClient(() => {}, true);
4036
-
4037
4015
  await resolveRun(0);
4038
4016
  await p1;
4039
4017
  await waitForPendingRun(2);
4040
4018
 
4041
- expect(conversation.getCurrentSender()).not.toBe(broadcastMessage);
4042
4019
  expect(conversation.hasNoClient).toBe(true);
4043
4020
 
4044
4021
  await resolveRun(1);
@@ -149,7 +149,7 @@ function createFakeConversation(conversationId: string): Conversation {
149
149
  return this.turnInterfaceContext;
150
150
  },
151
151
  ensureActorScopedHistory: async () => {},
152
- updateClient: () => {},
152
+ replayActivityState: () => {},
153
153
 
154
154
  setHostCuProxy(this: { hostCuProxy: unknown }, proxy: unknown) {
155
155
  this.hostCuProxy = proxy;
@@ -158,7 +158,7 @@ function makeConversation() {
158
158
  currentRequestId: undefined,
159
159
  queue: { length: 0 },
160
160
  setTrustContext: () => {},
161
- updateClient: () => {},
161
+ replayActivityState: () => {},
162
162
  emitConfirmationStateChanged: () => {},
163
163
  emitActivityState: () => {},
164
164
  setTurnChannelContext: () => {},
@@ -163,7 +163,7 @@ describe("handleSendMessage guardian reply interception", () => {
163
163
  const runAgentLoop = mock(async () => undefined);
164
164
  const session = {
165
165
  setTrustContext: () => {},
166
- updateClient: () => {},
166
+ replayActivityState: () => {},
167
167
  emitConfirmationStateChanged: () => {},
168
168
  emitActivityState: () => {},
169
169
  setTurnChannelContext: () => {},
@@ -253,7 +253,7 @@ describe("handleSendMessage guardian reply interception", () => {
253
253
  const runAgentLoop = mock(async () => undefined);
254
254
  const session = {
255
255
  setTrustContext: () => {},
256
- updateClient: () => {},
256
+ replayActivityState: () => {},
257
257
  emitConfirmationStateChanged: () => {},
258
258
  emitActivityState: () => {},
259
259
  setTurnChannelContext: () => {},
@@ -331,7 +331,7 @@ describe("handleSendMessage guardian reply interception", () => {
331
331
  const runAgentLoop = mock(async () => undefined);
332
332
  const session = {
333
333
  setTrustContext: () => {},
334
- updateClient: () => {},
334
+ replayActivityState: () => {},
335
335
  emitConfirmationStateChanged: () => {},
336
336
  emitActivityState: () => {},
337
337
  setTurnChannelContext: () => {},
@@ -419,7 +419,7 @@ describe("handleSendMessage guardian reply interception", () => {
419
419
  const runAgentLoop = mock(async () => undefined);
420
420
  const session = {
421
421
  setTrustContext: () => {},
422
- updateClient: () => {},
422
+ replayActivityState: () => {},
423
423
  emitConfirmationStateChanged: () => {},
424
424
  emitActivityState: () => {},
425
425
  setTurnChannelContext: () => {},
@@ -507,7 +507,7 @@ describe("handleSendMessage guardian reply interception", () => {
507
507
  const runAgentLoop = mock(async () => undefined);
508
508
  const session = {
509
509
  setTrustContext: () => {},
510
- updateClient: () => {},
510
+ replayActivityState: () => {},
511
511
  emitConfirmationStateChanged: () => {},
512
512
  emitActivityState: () => {},
513
513
  setTurnChannelContext: () => {},
@@ -590,7 +590,7 @@ describe("handleSendMessage guardian reply interception", () => {
590
590
  const runAgentLoop = mock(async () => undefined);
591
591
  const session = {
592
592
  setTrustContext: () => {},
593
- updateClient: () => {},
593
+ replayActivityState: () => {},
594
594
  emitConfirmationStateChanged: () => {},
595
595
  emitActivityState: () => {},
596
596
  setTurnChannelContext: () => {},
@@ -667,7 +667,7 @@ describe("handleSendMessage guardian reply interception", () => {
667
667
  const runAgentLoop = mock(async () => undefined);
668
668
  const session = {
669
669
  setTrustContext: () => {},
670
- updateClient: () => {},
670
+ replayActivityState: () => {},
671
671
  emitConfirmationStateChanged: () => {},
672
672
  emitActivityState: () => {},
673
673
  setTurnChannelContext: () => {},
@@ -746,7 +746,7 @@ describe("handleSendMessage guardian reply interception", () => {
746
746
  const runAgentLoop = mock(async () => undefined);
747
747
  const session = {
748
748
  setTrustContext: () => {},
749
- updateClient: () => {},
749
+ replayActivityState: () => {},
750
750
  emitConfirmationStateChanged: () => {},
751
751
  emitActivityState: () => {},
752
752
  setTurnChannelContext: () => {},
@@ -826,7 +826,7 @@ describe("handleSendMessage guardian reply interception", () => {
826
826
  const runAgentLoop = mock(async () => undefined);
827
827
  const session = {
828
828
  setTrustContext: () => {},
829
- updateClient: () => {},
829
+ replayActivityState: () => {},
830
830
  emitConfirmationStateChanged: () => {},
831
831
  emitActivityState: () => {},
832
832
  setTurnChannelContext: () => {},
@@ -169,7 +169,7 @@ function makeConversationWithPendingConfirmation(
169
169
  setTrustContext(this: { trustContext: unknown }, ctx: unknown) {
170
170
  this.trustContext = ctx;
171
171
  },
172
- updateClient: () => {},
172
+ replayActivityState: () => {},
173
173
  emitConfirmationStateChanged: () => {},
174
174
  emitActivityState: () => {},
175
175
  setTurnChannelContext: () => {},
@@ -233,7 +233,7 @@ function makeConversation() {
233
233
  currentRequestId: undefined,
234
234
  queue: { length: 0 },
235
235
  setTrustContext: () => {},
236
- updateClient: (_fn: unknown, _b: boolean) => {},
236
+ replayActivityState: () => {},
237
237
  emitConfirmationStateChanged: () => {},
238
238
  emitActivityState: () => {},
239
239
  setTurnChannelContext: () => {},
@@ -62,6 +62,9 @@ mock.module("../persistence/conversation-crud.js", () => ({
62
62
  getLastUserTimestampBefore: () => 0,
63
63
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
64
64
  updateMessageContent: mock(() => {}),
65
+ // The drain records the snapshot-stream anchor after each echo. Mocked so a
66
+ // sink that stamps seqs (the hub) cannot send the real recorder to SQLite.
67
+ recordConversationPersistedSeq: () => {},
65
68
  }));
66
69
 
67
70
  mock.module("../persistence/conversation-queries.js", () => ({
@@ -43,6 +43,7 @@ function makeContext(sent: AssistantEvent[] = []): Conversation & {
43
43
  return asConversation({
44
44
  conversationId: "conv-1",
45
45
  sendToClient: (msg: AssistantEvent) => sent.push(msg),
46
+ emit: (msg: AssistantEvent) => sent.push(msg),
46
47
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
47
48
  lastSurfaceAction: new Map<
48
49
  string,
@@ -69,6 +69,7 @@ function makeContext(
69
69
  return {
70
70
  conversationId,
71
71
  sendToClient: (msg: AssistantEvent) => sent.push(msg),
72
+ emit: (msg: AssistantEvent) => sent.push(msg),
72
73
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
73
74
  lastSurfaceAction: new Map<
74
75
  string,
@@ -97,6 +97,7 @@ function buildMockContext(
97
97
  } as Conversation["currentTurnAuthContext"])
98
98
  : undefined,
99
99
  sendToClient: () => {},
100
+ emit: () => {},
100
101
  pendingSurfaceActions: new Map(),
101
102
  lastSurfaceAction: new Map(),
102
103
  surfaceState: new Map(),
@@ -29,7 +29,7 @@ function makeContext(
29
29
  channelCapabilities:
30
30
  options.channelCapabilities &&
31
31
  mockChannelCapabilities(options.channelCapabilities),
32
- sendToClient: (msg) => sent.push(msg),
32
+ emit: (msg) => sent.push(msg),
33
33
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
34
34
  lastSurfaceAction: new Map<
35
35
  string,
@@ -57,7 +57,7 @@ import { asConversation } from "./helpers/mock-conversation.js";
57
57
  function makeContext(sent: AssistantEvent[] = []): Conversation {
58
58
  return asConversation({
59
59
  conversationId: "conv-persist-1",
60
- sendToClient: (msg) => sent.push(msg),
60
+ emit: (msg) => sent.push(msg),
61
61
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
62
62
  lastSurfaceAction: new Map<
63
63
  string,
@@ -124,13 +124,17 @@ type EnqueueResult = { queued: boolean; requestId: string; rejected?: boolean };
124
124
 
125
125
  function makeContext(
126
126
  enqueueResult: EnqueueResult = { queued: false, requestId: "req-1" },
127
- overrides: { trustContext?: TrustContext; queueDepth?: number } = {},
127
+ overrides: {
128
+ trustContext?: TrustContext;
129
+ queueDepth?: number;
130
+ emit?: (msg: AssistantEvent) => void;
131
+ } = {},
128
132
  ): Conversation & { enqueuedContents: string[] } {
129
133
  const enqueuedContents: string[] = [];
130
134
  return asConversation({
131
135
  conversationId: CONVERSATION_ID,
132
136
  trustContext: overrides.trustContext ?? GUARDIAN_TRUST,
133
- sendToClient: () => {},
137
+ emit: overrides.emit ?? (() => {}),
134
138
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
135
139
  lastSurfaceAction: new Map<
136
140
  string,
@@ -885,11 +889,12 @@ describe("surface completion when the persisted write does not land", () => {
885
889
  test("a ui_dismiss completion whose write throws stays retryable", async () => {
886
890
  const surfaceId = "surface-dismiss-persist-throws-1";
887
891
  seedSurfaceRow(surfaceId, "choice");
888
- const ctx = makeContext();
889
892
  const sentToClient: AssistantEvent[] = [];
890
- ctx.sendToClient = (msg) => {
891
- sentToClient.push(msg);
892
- };
893
+ const ctx = makeContext(undefined, {
894
+ emit: (msg) => {
895
+ sentToClient.push(msg);
896
+ },
897
+ });
893
898
  seedAnsweredSurface(ctx, surfaceId);
894
899
  persistError = new Error("database is locked");
895
900
 
@@ -919,11 +924,12 @@ describe("surface completion when the persisted write does not land", () => {
919
924
  test("a ui_dismiss completion that lands clears the live state", async () => {
920
925
  const surfaceId = "surface-dismiss-persist-lands-1";
921
926
  seedSurfaceRow(surfaceId, "choice");
922
- const ctx = makeContext();
923
927
  const sentToClient: AssistantEvent[] = [];
924
- ctx.sendToClient = (msg) => {
925
- sentToClient.push(msg);
926
- };
928
+ const ctx = makeContext(undefined, {
929
+ emit: (msg) => {
930
+ sentToClient.push(msg);
931
+ },
932
+ });
927
933
  seedAnsweredSurface(ctx, surfaceId);
928
934
 
929
935
  const result = await surfaceProxyResolver(ctx, "ui_dismiss", {
@@ -942,11 +948,12 @@ describe("surface completion when the persisted write does not land", () => {
942
948
  test("a ui_dismiss with no recorded action clears the live state even when the write throws", async () => {
943
949
  const surfaceId = "surface-dismiss-no-action-1";
944
950
  seedSurfaceRow(surfaceId, "card");
945
- const ctx = makeContext();
946
951
  const sentToClient: AssistantEvent[] = [];
947
- ctx.sendToClient = (msg) => {
948
- sentToClient.push(msg);
949
- };
952
+ const ctx = makeContext(undefined, {
953
+ emit: (msg) => {
954
+ sentToClient.push(msg);
955
+ },
956
+ });
950
957
  ctx.pendingSurfaceActions.set(surfaceId, { surfaceType: "card" });
951
958
  ctx.surfaceState.set(surfaceId, { surfaceType: "card", data: {} });
952
959
  persistError = new Error("database is locked");
@@ -22,6 +22,7 @@ function makeQueuedContext(): Conversation {
22
22
  return asConversation({
23
23
  conversationId: CONV_ID,
24
24
  sendToClient: () => {},
25
+ emit: () => {},
25
26
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
26
27
  lastSurfaceAction: new Map<
27
28
  string,
@@ -65,6 +65,7 @@ function createMockContext(
65
65
  })
66
66
  : undefined,
67
67
  sendToClient: (msg: AssistantEvent) => sentMessages.push(msg),
68
+ emit: (msg: AssistantEvent) => sentMessages.push(msg),
68
69
  pendingSurfaceActions: new Map(),
69
70
  lastSurfaceAction: new Map(),
70
71
  surfaceState: new Map(),
@@ -53,6 +53,7 @@ function createMockContext(
53
53
  })
54
54
  : undefined,
55
55
  sendToClient: (msg: AssistantEvent) => sentMessages.push(msg),
56
+ emit: (msg: AssistantEvent) => sentMessages.push(msg),
56
57
  pendingSurfaceActions: new Map(),
57
58
  lastSurfaceAction: new Map(),
58
59
  surfaceState: new Map(),
@@ -26,7 +26,7 @@ function makeContext(opts?: { sent?: AssistantEvent[] }): Conversation & {
26
26
 
27
27
  return asConversation({
28
28
  conversationId: "test-session",
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,
@@ -49,7 +49,7 @@ function makeContext(): Conversation & {
49
49
 
50
50
  return asConversation({
51
51
  conversationId: "test-convo",
52
- sendToClient: (msg) => sentMessages.push(msg),
52
+ emit: (msg) => sentMessages.push(msg),
53
53
  pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
54
54
  lastSurfaceAction: new Map<
55
55
  string,
@@ -26,7 +26,7 @@ function makeContext(
26
26
  conversationId: "session-1",
27
27
  channelCapabilities:
28
28
  channelCapabilities && mockChannelCapabilities(channelCapabilities),
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,
@@ -94,7 +94,7 @@ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
94
94
  workingDir: "/tmp/test",
95
95
  getTurnActorPrincipalId: () => undefined,
96
96
  abortController: null,
97
- sendToClient: mock(() => {}),
97
+ emit: mock(() => {}),
98
98
  pendingSurfaceActions: new Map(),
99
99
  lastSurfaceAction: new Map(),
100
100
  surfaceState: new Map(),
@@ -101,7 +101,7 @@ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
101
101
  workingDir: "/tmp/test",
102
102
  getTurnActorPrincipalId: () => undefined,
103
103
  abortController: null,
104
- sendToClient: mock(() => {}),
104
+ emit: mock(() => {}),
105
105
  pendingSurfaceActions: new Map(),
106
106
  lastSurfaceAction: new Map(),
107
107
  surfaceState: new Map(),
@@ -97,6 +97,7 @@ function buildMockContext(
97
97
  } as Conversation["currentTurnAuthContext"])
98
98
  : undefined,
99
99
  sendToClient: () => {},
100
+ emit: () => {},
100
101
  pendingSurfaceActions: new Map(),
101
102
  lastSurfaceAction: new Map(),
102
103
  surfaceState: new Map(),
@@ -3,8 +3,6 @@
3
3
  * the Library and the per-conversation assets list converge on every surface.
4
4
  * Read routes and rejected writes must stay silent.
5
5
  */
6
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
8
6
  import { afterAll, beforeEach, describe, expect, test } from "bun:test";
9
7
 
10
8
  import type { AssistantEventEnvelope } from "../api/index.js";
@@ -24,9 +22,6 @@ import { resetDbForTesting } from "./db-test-helpers.js";
24
22
 
25
23
  await initializeDb();
26
24
 
27
- const workspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
28
- const notesDir = join(workspaceDir, "sync-notes");
29
-
30
25
  const CONVERSATION_ID = "conv-doc-sync";
31
26
  const OTHER_CONVERSATION_ID = "conv-doc-sync-other";
32
27
  const MISSING_CONVERSATION_ID = "conv-doc-sync-missing";
@@ -110,15 +105,6 @@ async function saveViaRoute(
110
105
  });
111
106
  }
112
107
 
113
- async function openWorkspaceFile(
114
- path: string,
115
- conversationId = CONVERSATION_ID,
116
- ): Promise<unknown> {
117
- return await invoke("documentForWorkspaceFile", {
118
- body: { path, conversationId },
119
- });
120
- }
121
-
122
108
  async function linkViaRoute(
123
109
  surfaceId: string,
124
110
  conversationId: string,
@@ -136,12 +122,9 @@ beforeEach(() => {
136
122
  db.run("DELETE FROM conversations");
137
123
  createConversation({ id: CONVERSATION_ID });
138
124
  createConversation({ id: OTHER_CONVERSATION_ID });
139
- rmSync(notesDir, { recursive: true, force: true });
140
- mkdirSync(notesDir, { recursive: true });
141
125
  });
142
126
 
143
127
  afterAll(() => {
144
- rmSync(notesDir, { recursive: true, force: true });
145
128
  resetDbForTesting();
146
129
  });
147
130
 
@@ -220,67 +203,9 @@ describe("document write routes publish documents:list", () => {
220
203
  expect(events).toHaveLength(1);
221
204
  expect("originClientId" in events[0].message).toBe(false);
222
205
  });
223
-
224
- test("binding a workspace file publishes without an origin so the caller is not suppressed", async () => {
225
- // `viewer-store.loadWorkspaceFileDocument` creates the row and invalidates
226
- // nothing, so the client that opened the file is the one that most needs
227
- // the invalidation.
228
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
229
-
230
- const events = await captureSyncEvents(() =>
231
- invoke("documentForWorkspaceFile", {
232
- body: { path: "sync-notes/plan.md", conversationId: CONVERSATION_ID },
233
- headers: { "x-vellum-client-id": "client-abc" },
234
- }),
235
- );
236
-
237
- expect(events).toHaveLength(1);
238
- expect("originClientId" in events[0].message).toBe(false);
239
- });
240
-
241
- test("binding a workspace file to a new document publishes", async () => {
242
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
243
-
244
- const events = await captureSyncEvents(() =>
245
- openWorkspaceFile("sync-notes/plan.md"),
246
- );
247
- expectDocumentsChanged(events);
248
- });
249
-
250
- test("reopening the file from another conversation publishes the new link", async () => {
251
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
252
- await openWorkspaceFile("sync-notes/plan.md");
253
-
254
- const events = await captureSyncEvents(() =>
255
- openWorkspaceFile("sync-notes/plan.md", OTHER_CONVERSATION_ID),
256
- );
257
- expectDocumentsChanged(events);
258
- });
259
-
260
- test("refreshing a document whose file changed on disk publishes", async () => {
261
- const filePath = join(notesDir, "plan.md");
262
- writeFileSync(filePath, "# Plan");
263
- await openWorkspaceFile("sync-notes/plan.md");
264
- writeFileSync(filePath, "# Plan\n\nedited outside the editor");
265
-
266
- const events = await captureSyncEvents(() =>
267
- openWorkspaceFile("sync-notes/plan.md"),
268
- );
269
- expectDocumentsChanged(events);
270
- });
271
206
  });
272
207
 
273
208
  describe("document routes that change nothing the list shows stay silent", () => {
274
- test("reopening an unchanged, already-linked file publishes nothing", async () => {
275
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
276
- await openWorkspaceFile("sync-notes/plan.md");
277
-
278
- const events = await captureSyncEvents(() =>
279
- openWorkspaceFile("sync-notes/plan.md"),
280
- );
281
- expect(events).toEqual([]);
282
- });
283
-
284
209
  test("a save rejected by validation publishes nothing", async () => {
285
210
  const events = await captureSyncEvents(async () => {
286
211
  await expect(saveViaRoute({ title: undefined })).rejects.toThrow(
@@ -83,11 +83,8 @@ function isGatewayInternal(filePath: string): boolean {
83
83
  /** Additional files allowed for the interpolated-port check only (use gateway port, not runtime). */
84
84
  const INTERPOLATED_PORT_ALLOWLIST = new Set([
85
85
  "clients/macos/src/main/bundle-flow.ts",
86
- // Electron main bridges the host proxy to a local assistant's gateway
87
- // (gatewayPort) over loopback with a Guardian-minted gateway token — same
88
- // class as bundle-flow.ts. Added with #34049 (host proxy for cloud/managed
89
- // assistants); the allowlist entry was missed there.
90
- "clients/macos/src/main/host-proxy-router.ts",
86
+ // Desktop clients connect to local assistants through their loopback gateway.
87
+ "packages/electron-desktop/src/host-proxy/router.ts",
91
88
  ]);
92
89
 
93
90
  /** Shared violation filter: exempt test files, gateway internals, and allowlisted paths. */
@@ -189,7 +189,7 @@ const _testAuthContext: AuthContext = {
189
189
  function makeConversation(overrides: Record<string, unknown> = {}) {
190
190
  return {
191
191
  setTrustContext: () => {},
192
- updateClient: () => {},
192
+ replayActivityState: () => {},
193
193
  setHostBrowserProxy: () => {},
194
194
  setHostCuProxy: () => {},
195
195
  setHostAppControlProxy: () => {},