@vellumai/assistant 0.11.2-dev.202608051637.aae860e → 0.11.2-dev.202608051734.355dff7

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 (33) hide show
  1. package/openapi.yaml +37 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/assistant-event-hub.test.ts +48 -0
  4. package/src/__tests__/client-os-metadata-persistence.test.ts +99 -0
  5. package/src/__tests__/conversation-queue.test.ts +56 -0
  6. package/src/__tests__/events-dev-bypass-actor.test.ts +183 -1
  7. package/src/__tests__/helpers/hub-clients.ts +42 -0
  8. package/src/__tests__/host-bash-routes.test.ts +67 -0
  9. package/src/__tests__/http-user-message-parity.test.ts +44 -0
  10. package/src/config/feature-flag-registry.json +8 -0
  11. package/src/contacts/guardian-delivery-reader.ts +2 -2
  12. package/src/daemon/conversation-messaging.ts +37 -0
  13. package/src/daemon/conversation-process.ts +6 -0
  14. package/src/notifications/__tests__/assistant-reply-producer.test.ts +169 -0
  15. package/src/notifications/assistant-reply-producer.ts +35 -4
  16. package/src/persistence/conversation-crud.ts +14 -0
  17. package/src/persistence/conversation-types.ts +29 -1
  18. package/src/runtime/__tests__/desktop-presence.test.ts +162 -0
  19. package/src/runtime/assistant-event-hub.ts +87 -12
  20. package/src/runtime/auth/same-actor.ts +18 -1
  21. package/src/runtime/desktop-presence.ts +69 -0
  22. package/src/runtime/local-actor-identity.ts +11 -9
  23. package/src/runtime/routes/__tests__/client-routes.test.ts +186 -14
  24. package/src/runtime/routes/client-routes.ts +114 -2
  25. package/src/runtime/routes/conversation-routes.ts +5 -0
  26. package/src/runtime/routes/events-routes.ts +28 -1
  27. package/src/runtime/routes/host-app-control-routes.ts +2 -0
  28. package/src/runtime/routes/host-bash-routes.ts +2 -0
  29. package/src/runtime/routes/host-browser-routes.ts +2 -0
  30. package/src/runtime/routes/host-cu-routes.ts +2 -0
  31. package/src/runtime/routes/host-file-routes.ts +2 -0
  32. package/src/runtime/routes/host-transfer-routes.ts +4 -0
  33. package/src/runtime/routes/ui-snapshot-routes.ts +1 -0
package/openapi.yaml CHANGED
@@ -4615,6 +4615,43 @@ paths:
4615
4615
  required:
4616
4616
  - disconnected
4617
4617
  additionalProperties: false
4618
+ /v1/clients/presence:
4619
+ post:
4620
+ operationId: clients_presence_post
4621
+ summary: Report desktop presence
4622
+ description: Record the desktop presence state reported by the client identified by the X-Vellum-Client-Id header.
4623
+ tags:
4624
+ - clients
4625
+ requestBody:
4626
+ required: true
4627
+ content:
4628
+ application/json:
4629
+ schema:
4630
+ type: object
4631
+ properties:
4632
+ state:
4633
+ type: string
4634
+ enum:
4635
+ - active
4636
+ - idle
4637
+ - away
4638
+ description: Reported desktop presence state.
4639
+ required:
4640
+ - state
4641
+ responses:
4642
+ "200":
4643
+ description: Successful response
4644
+ content:
4645
+ application/json:
4646
+ schema:
4647
+ type: object
4648
+ properties:
4649
+ recorded:
4650
+ type: boolean
4651
+ description: Whether a connected client matched the reporting clientId.
4652
+ required:
4653
+ - recorded
4654
+ additionalProperties: false
4618
4655
  /v1/config:
4619
4656
  get:
4620
4657
  operationId: config_get
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.2-dev.202608051637.aae860e",
3
+ "version": "0.11.2-dev.202608051734.355dff7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,7 @@ import {
11
11
  getReplayWindow,
12
12
  } from "../runtime/assistant-stream-state.js";
13
13
  import * as pendingInteractions from "../runtime/pending-interactions.js";
14
+ import { registerHubClient } from "./helpers/hub-clients.js";
14
15
 
15
16
  function makeEvent(
16
17
  overrides: Partial<AssistantEventEnvelope> = {},
@@ -435,6 +436,53 @@ describe("AssistantEventHub — actorPrincipalId on ClientEntry", () => {
435
436
  });
436
437
  });
437
438
 
439
+ // ── ClientEntry desktop presence ─────────────────────────────────────────────
440
+
441
+ describe("AssistantEventHub client presence", () => {
442
+ test("records presence on a matching client", () => {
443
+ const hub = new AssistantEventHub();
444
+ registerHubClient({ hub, clientId: "mac-1" });
445
+
446
+ expect(hub.setClientPresence("mac-1", "active")).toBe(true);
447
+
448
+ const presence = hub.getClientById("mac-1")!.presence!;
449
+ expect(presence.state).toBe("active");
450
+ });
451
+
452
+ test("reportedAt advances on every report", async () => {
453
+ const hub = new AssistantEventHub();
454
+ registerHubClient({ hub, clientId: "mac-1" });
455
+
456
+ hub.setClientPresence("mac-1", "idle");
457
+ const first = hub.getClientById("mac-1")!.presence!;
458
+
459
+ await Bun.sleep(5);
460
+ hub.setClientPresence("mac-1", "idle");
461
+ const second = hub.getClientById("mac-1")!.presence!;
462
+
463
+ expect(second.reportedAt.getTime()).toBeGreaterThan(
464
+ first.reportedAt.getTime(),
465
+ );
466
+ });
467
+
468
+ test("returns false for an unknown clientId", () => {
469
+ const hub = new AssistantEventHub();
470
+ registerHubClient({ hub, clientId: "mac-1" });
471
+
472
+ expect(hub.setClientPresence("does-not-exist", "active")).toBe(false);
473
+ expect(hub.getClientById("mac-1")?.presence).toBeUndefined();
474
+ });
475
+
476
+ test("a disposed client no longer accepts presence", () => {
477
+ const hub = new AssistantEventHub();
478
+ registerHubClient({ hub, clientId: "mac-1" });
479
+
480
+ expect(hub.disposeClient("mac-1")).toBe(1);
481
+ expect(hub.setClientPresence("mac-1", "active")).toBe(false);
482
+ expect(hub.getClientById("mac-1")).toBeUndefined();
483
+ });
484
+ });
485
+
438
486
  // ── capabilityForMessageType — host-prefix routing ───────────────────────────
439
487
 
440
488
  describe("capabilityForMessageType — host-prefix routing", () => {
@@ -65,6 +65,7 @@ import type {
65
65
  import type { MessagingConversationContext } from "../daemon/conversation-messaging.js";
66
66
  import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
67
67
  import type { MessageQueue } from "../daemon/conversation-queue-manager.js";
68
+ import { isMacOriginatedUserMessage } from "../persistence/conversation-types.js";
68
69
 
69
70
  function createWebTurnContext(
70
71
  clientOs: string | undefined,
@@ -173,3 +174,101 @@ describe("client OS surface metadata persistence", () => {
173
174
  });
174
175
  });
175
176
  });
177
+
178
+ /**
179
+ * `Conversation.clientOs` is a live field that only a transport-carrying
180
+ * message refreshes, so a transport-less turn stamps whatever an earlier send
181
+ * left there. `clientOsFromRequest` separates the two, and
182
+ * {@link isMacOriginatedUserMessage} (the row-origin read behind the
183
+ * `chat.assistant_reply` presence gate) requires it before letting an attended
184
+ * Mac suppress a push.
185
+ */
186
+ describe("client OS per-row evidence marker", () => {
187
+ beforeEach(() => {
188
+ addMessageCalls.length = 0;
189
+ });
190
+
191
+ test("marks an OS this row's own transport reported", async () => {
192
+ const ctx = createWebTurnContext("macos");
193
+ await persistQueuedMessageBody(ctx, {
194
+ content: "hello",
195
+ requestId: "req-transport-os",
196
+ requestClientOs: "macos",
197
+ });
198
+
199
+ expect(lastUserMetadata().client).toEqual({ os: "macos" });
200
+ expect(lastUserMetadata().clientOsFromRequest).toBe(true);
201
+ expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(true);
202
+ });
203
+
204
+ test("marks an OS this row's own request headers reported", async () => {
205
+ const ctx = createWebTurnContext(undefined);
206
+ await persistQueuedMessageBody(ctx, {
207
+ content: "hello",
208
+ requestId: "req-header-os",
209
+ metadata: { client: { os: "macos", interface_version: "0.8.2" } },
210
+ });
211
+
212
+ expect(lastUserMetadata().clientOsFromRequest).toBe(true);
213
+ expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(true);
214
+ });
215
+
216
+ // The bug shape: a button tapped on the phone reaches a conversation whose
217
+ // last desktop send left `clientOs: "macos"` behind. The row still carries
218
+ // the inherited OS for telemetry, but nothing says this turn came from the
219
+ // Mac, so the reply push must survive.
220
+ test("leaves an inherited OS unmarked and not Mac-originated", async () => {
221
+ const ctx = createWebTurnContext("macos");
222
+ await persistQueuedMessageBody(ctx, {
223
+ content: "[User action on card surface: submit]",
224
+ requestId: "req-inherited-os",
225
+ });
226
+
227
+ expect(lastUserMetadata().client).toEqual({ os: "macos" });
228
+ expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
229
+ expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
230
+ });
231
+
232
+ // The marker vouches for the `client.os` that actually landed, not for
233
+ // whatever the row reported: a reported OS the stamp disagrees with is no
234
+ // evidence at all.
235
+ test("leaves the marker off when the reported OS is not the stamped one", async () => {
236
+ const ctx = createWebTurnContext("macos");
237
+ await persistQueuedMessageBody(ctx, {
238
+ content: "hello",
239
+ requestId: "req-mismatched-os",
240
+ requestClientOs: "ios",
241
+ });
242
+
243
+ expect(lastUserMetadata().client).toEqual({ os: "macos" });
244
+ expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
245
+ expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
246
+ });
247
+
248
+ // The marker is derived from what this persist can see, so a bag value
249
+ // riding in from a caller must not survive the metadata spread and assert an
250
+ // origin the row never reported.
251
+ test("drops a caller-supplied marker from the metadata bag", async () => {
252
+ const ctx = createWebTurnContext("macos");
253
+ await persistQueuedMessageBody(ctx, {
254
+ content: "[User action on card surface: submit]",
255
+ requestId: "req-spoofed-marker",
256
+ metadata: { clientOsFromRequest: true },
257
+ });
258
+
259
+ expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
260
+ expect(isMacOriginatedUserMessage(lastUserMetadata())).toBe(false);
261
+ });
262
+
263
+ test("leaves the marker off for a reported OS outside the vocabulary", async () => {
264
+ const ctx = createWebTurnContext(undefined);
265
+ await persistQueuedMessageBody(ctx, {
266
+ content: "hello",
267
+ requestId: "req-unknown-os",
268
+ requestClientOs: "windows",
269
+ });
270
+
271
+ expect(lastUserMetadata().client).toBeUndefined();
272
+ expect(lastUserMetadata().clientOsFromRequest).toBeUndefined();
273
+ });
274
+ });
@@ -676,6 +676,62 @@ describe("Conversation message queue", () => {
676
676
  expect(pendingRuns.length).toBe(3);
677
677
  });
678
678
 
679
+ // `Conversation.clientOs` is a live field that only a transport-carrying
680
+ // message refreshes, so a transport-less drain (a surface action, a signal)
681
+ // persists the OS of an earlier send. Both rows keep that OS for telemetry;
682
+ // only the row that reported it claims the surface, which is what stops an
683
+ // attended Mac from suppressing the push for a button tapped on the phone.
684
+ test("[experimental] only a queued send that reported its own OS claims that surface", async () => {
685
+ capturedAddMessages.length = 0;
686
+ const conversation = makeConversation();
687
+ await conversation.loadFromDb();
688
+
689
+ const p1 = conversation.processMessage({
690
+ content: "msg-1",
691
+ attachments: [],
692
+ onEvent: () => {},
693
+ requestId: "req-1",
694
+ });
695
+ await waitForPendingRun(1);
696
+
697
+ conversation.enqueueMessage({
698
+ content: "from-the-mac",
699
+ onEvent: () => {},
700
+ requestId: "req-mac",
701
+ transport: { channelId: "vellum", interfaceId: "web", clientOs: "macos" },
702
+ });
703
+ // A surface action: no transport, so the drain leaves the conversation's
704
+ // `clientOs` on the macOS value the send above applied.
705
+ conversation.enqueueMessage({
706
+ content: "[User action on card surface: submit]",
707
+ onEvent: () => {},
708
+ requestId: "req-surface-action",
709
+ });
710
+
711
+ await resolveRun(0);
712
+ await p1;
713
+ await waitForPendingRun(2);
714
+ await resolveRun(1);
715
+ await waitForPendingRun(3);
716
+
717
+ const macRow = capturedAddMessages.find(
718
+ (m) => m.role === "user" && m.content.includes("from-the-mac"),
719
+ );
720
+ expect(macRow).toBeDefined();
721
+ expect(macRow!.metadata?.client).toEqual({ os: "macos" });
722
+ expect(macRow!.metadata?.clientOsFromRequest).toBe(true);
723
+
724
+ const surfaceActionRow = capturedAddMessages.find(
725
+ (m) => m.role === "user" && m.content.includes("User action on card"),
726
+ );
727
+ expect(surfaceActionRow).toBeDefined();
728
+ expect(surfaceActionRow!.metadata?.client).toEqual({ os: "macos" });
729
+ expect(surfaceActionRow!.metadata?.clientOsFromRequest).toBeUndefined();
730
+
731
+ await resolveRun(2);
732
+ await new Promise((r) => setTimeout(r, 10));
733
+ });
734
+
679
735
  test("[experimental] queued passthrough siblings viewing different apps do NOT batch", async () => {
680
736
  // A batched turn applies only the head's `visibleAppId`, which drives the
681
737
  // `visible_app:` context line. Coalescing messages sent while different
@@ -16,13 +16,17 @@
16
16
  * principalId at registration time so both sides agree. This keeps targeted
17
17
  * host proxy execution working on Docker / platform-managed deployments.
18
18
  */
19
- import { afterAll, describe, expect, mock, test } from "bun:test";
19
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
20
20
 
21
21
  // ── Module mocks (must be set up before importing the route) ──────────────
22
22
 
23
23
  let fakeHttpAuthDisabled = false;
24
24
  let fakeGuardianPrincipalId: string | undefined = undefined;
25
25
 
26
+ // Manually-resolvable async resolutions, in call order, so tests control
27
+ // exactly when the SSE self-heal lookup completes.
28
+ const pendingAsyncResolutions: Array<(value: string | undefined) => void> = [];
29
+
26
30
  mock.module("../config/env.js", () => ({
27
31
  isHttpAuthDisabled: () => fakeHttpAuthDisabled,
28
32
  hasUngatedHttpAuthDisabled: () => false,
@@ -38,8 +42,21 @@ mock.module("../runtime/local-actor-identity.js", () => ({
38
42
  }
39
43
  return fakeGuardianPrincipalId;
40
44
  },
45
+ resolveActorPrincipalIdForLocalGuardian: (rawHeader: string | undefined) => {
46
+ if (rawHeader !== "dev-bypass" || !fakeHttpAuthDisabled) {
47
+ return Promise.resolve(rawHeader);
48
+ }
49
+ return new Promise<string | undefined>((resolve) => {
50
+ pendingAsyncResolutions.push(resolve);
51
+ });
52
+ },
41
53
  }));
42
54
 
55
+ /** Let the fire-and-forget heal promise chain settle. */
56
+ async function flushMicrotasks(): Promise<void> {
57
+ await new Promise((resolve) => setTimeout(resolve, 0));
58
+ }
59
+
43
60
  // ── Real imports (after mocks) ────────────────────────────────────────────
44
61
 
45
62
  import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
@@ -50,6 +67,10 @@ afterAll(() => {
50
67
  });
51
68
 
52
69
  describe("events SSE registration — dev-bypass actor translation", () => {
70
+ beforeEach(() => {
71
+ pendingAsyncResolutions.length = 0;
72
+ });
73
+
53
74
  test("translates 'dev-bypass' to the real guardian principalId when auth is disabled", () => {
54
75
  fakeHttpAuthDisabled = true;
55
76
  fakeGuardianPrincipalId = "guardian-real-id";
@@ -104,6 +125,167 @@ describe("events SSE registration — dev-bypass actor translation", () => {
104
125
  ac.abort();
105
126
  });
106
127
 
128
+ test("self-heals a cold-cache registration once the async guardian lookup resolves", async () => {
129
+ fakeHttpAuthDisabled = true;
130
+ fakeGuardianPrincipalId = undefined; // cold cache: sync resolution misses
131
+
132
+ const ac = new AbortController();
133
+ const hub = new AssistantEventHub();
134
+
135
+ handleSubscribeAssistantEvents(
136
+ {
137
+ headers: {
138
+ "x-vellum-client-id": "heal-client-001",
139
+ "x-vellum-interface-id": "chrome-extension",
140
+ "x-vellum-actor-principal-id": "dev-bypass",
141
+ },
142
+ abortSignal: ac.signal,
143
+ },
144
+ { hub },
145
+ );
146
+
147
+ // Registered without a principal, heal lookup in flight.
148
+ expect(hub.getActorPrincipalIdForClient("heal-client-001")).toBeUndefined();
149
+ expect(pendingAsyncResolutions).toHaveLength(1);
150
+
151
+ pendingAsyncResolutions.shift()!("guardian-real-id");
152
+ await flushMicrotasks();
153
+
154
+ // Hub record patched: a host-proxy request registered now snapshots the
155
+ // real principal, so the result-route same-actor check passes.
156
+ expect(hub.getActorPrincipalIdForClient("heal-client-001")).toBe(
157
+ "guardian-real-id",
158
+ );
159
+
160
+ ac.abort();
161
+ });
162
+
163
+ test("does not start the heal lookup when the sync resolution already succeeded", () => {
164
+ fakeHttpAuthDisabled = true;
165
+ fakeGuardianPrincipalId = "guardian-real-id"; // warm cache
166
+
167
+ const ac = new AbortController();
168
+ const hub = new AssistantEventHub();
169
+
170
+ handleSubscribeAssistantEvents(
171
+ {
172
+ headers: {
173
+ "x-vellum-client-id": "warm-client-001",
174
+ "x-vellum-interface-id": "macos",
175
+ "x-vellum-actor-principal-id": "dev-bypass",
176
+ },
177
+ abortSignal: ac.signal,
178
+ },
179
+ { hub },
180
+ );
181
+
182
+ expect(pendingAsyncResolutions).toHaveLength(0);
183
+
184
+ ac.abort();
185
+ });
186
+
187
+ test("does not start the heal lookup for connections without a dev-bypass principal", () => {
188
+ fakeHttpAuthDisabled = true;
189
+ fakeGuardianPrincipalId = undefined;
190
+
191
+ const ac = new AbortController();
192
+ const hub = new AssistantEventHub();
193
+
194
+ // Legacy connection with no actor-principal header at all: it registers
195
+ // without a principal and must stay that way (no dev-bypass translation).
196
+ handleSubscribeAssistantEvents(
197
+ {
198
+ headers: {
199
+ "x-vellum-client-id": "legacy-client-001",
200
+ "x-vellum-interface-id": "macos",
201
+ },
202
+ abortSignal: ac.signal,
203
+ },
204
+ { hub },
205
+ );
206
+
207
+ expect(pendingAsyncResolutions).toHaveLength(0);
208
+ expect(
209
+ hub.getActorPrincipalIdForClient("legacy-client-001"),
210
+ ).toBeUndefined();
211
+
212
+ ac.abort();
213
+ });
214
+
215
+ test("a stale heal cannot patch the subscription that replaced it (reconnect race)", async () => {
216
+ fakeHttpAuthDisabled = true;
217
+ fakeGuardianPrincipalId = undefined; // cold cache for the first connect
218
+
219
+ const ac1 = new AbortController();
220
+ const hub = new AssistantEventHub();
221
+
222
+ handleSubscribeAssistantEvents(
223
+ {
224
+ headers: {
225
+ "x-vellum-client-id": "race-client-001",
226
+ "x-vellum-interface-id": "macos",
227
+ "x-vellum-actor-principal-id": "dev-bypass",
228
+ },
229
+ abortSignal: ac1.signal,
230
+ },
231
+ { hub },
232
+ );
233
+ expect(pendingAsyncResolutions).toHaveLength(1);
234
+ const staleHeal = pendingAsyncResolutions.shift()!;
235
+
236
+ // Client reconnects before the first heal resolves; cache is warm now.
237
+ fakeGuardianPrincipalId = "guardian-current";
238
+ const ac2 = new AbortController();
239
+ handleSubscribeAssistantEvents(
240
+ {
241
+ headers: {
242
+ "x-vellum-client-id": "race-client-001",
243
+ "x-vellum-interface-id": "macos",
244
+ "x-vellum-actor-principal-id": "dev-bypass",
245
+ },
246
+ abortSignal: ac2.signal,
247
+ },
248
+ { hub },
249
+ );
250
+
251
+ // The stale heal resolves late with a different value; it is keyed to
252
+ // the disposed connection, so the live record must be untouched.
253
+ staleHeal("guardian-stale");
254
+ await flushMicrotasks();
255
+
256
+ expect(hub.getActorPrincipalIdForClient("race-client-001")).toBe(
257
+ "guardian-current",
258
+ );
259
+
260
+ ac1.abort();
261
+ ac2.abort();
262
+ });
263
+
264
+ test("fillClientActorPrincipalId never overwrites a present principal", () => {
265
+ const hub = new AssistantEventHub();
266
+ const sub = hub.subscribe({
267
+ type: "client",
268
+ clientId: "direct-client-001",
269
+ interfaceId: "macos",
270
+ capabilities: [],
271
+ actorPrincipalId: "existing-principal",
272
+ callback: () => {},
273
+ });
274
+
275
+ hub.fillClientActorPrincipalId(sub.connectionId, "other-principal");
276
+ expect(hub.getActorPrincipalIdForClient("direct-client-001")).toBe(
277
+ "existing-principal",
278
+ );
279
+
280
+ // Unknown connection id is a no-op.
281
+ hub.fillClientActorPrincipalId("conn-does-not-exist", "other-principal");
282
+ expect(hub.getActorPrincipalIdForClient("direct-client-001")).toBe(
283
+ "existing-principal",
284
+ );
285
+
286
+ sub.dispose();
287
+ });
288
+
107
289
  test("does NOT translate when auth is enabled (production mode)", () => {
108
290
  // Defense in depth: make sure we never silently rewrite a real
109
291
  // principalId that legitimately happens to be the literal "dev-bypass"
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Register and tear down client subscribers on an assistant event hub.
3
+ *
4
+ * The caller supplies the hub, so this helper holds no runtime import into
5
+ * `src/` and stays safe for the test preload's import graph.
6
+ */
7
+ import type { HostProxyCapability, InterfaceId } from "../../channels/types.js";
8
+ import type {
9
+ AssistantEventHub,
10
+ DesktopPresenceState,
11
+ } from "../../runtime/assistant-event-hub.js";
12
+
13
+ export interface RegisterHubClientArgs {
14
+ hub: AssistantEventHub;
15
+ clientId: string;
16
+ interfaceId?: InterfaceId;
17
+ capabilities?: HostProxyCapability[];
18
+ actorPrincipalId?: string;
19
+ /** Reported immediately after subscribing, when supplied. */
20
+ presence?: DesktopPresenceState;
21
+ }
22
+
23
+ export function registerHubClient(args: RegisterHubClientArgs): void {
24
+ const { hub } = args;
25
+ hub.subscribe({
26
+ type: "client",
27
+ clientId: args.clientId,
28
+ interfaceId: args.interfaceId ?? "macos",
29
+ capabilities: args.capabilities ?? [],
30
+ actorPrincipalId: args.actorPrincipalId,
31
+ callback: () => {},
32
+ });
33
+ if (args.presence) {
34
+ hub.setClientPresence(args.clientId, args.presence);
35
+ }
36
+ }
37
+
38
+ export function clearHubClients(hub: AssistantEventHub): void {
39
+ for (const client of hub.listClients()) {
40
+ hub.disposeClient(client.clientId);
41
+ }
42
+ }
@@ -312,6 +312,73 @@ describe("handleHostBashResult", () => {
312
312
  });
313
313
  });
314
314
 
315
+ // ── Fill-if-missing: persisted target healed after registration ────
316
+ //
317
+ // A request registered while the target's SSE record had no principal
318
+ // persists `targetActorPrincipalId: undefined`. Once the SSE self-heal
319
+ // fills the hub record, the result route re-reads the live record,
320
+ // but ONLY when the persisted value is missing.
321
+
322
+ describe("targeted request - missing persisted target principal fills from the live hub record", () => {
323
+ test("accepts a matching submitter when the hub record was healed after registration", async () => {
324
+ const requestId = "req-healed-target";
325
+ // Registered pre-heal: hub had no principal, so nothing was persisted.
326
+ registerPending(requestId, { targetClientId: "client-abc" });
327
+ // SSE self-heal fills the hub record afterwards.
328
+ clientActorPrincipals.set("client-abc", "principal-shared");
329
+
330
+ const result = await handleHostBashResult({
331
+ body: bashBody(requestId),
332
+ headers: {
333
+ "x-vellum-client-id": "client-abc",
334
+ "x-vellum-actor-principal-id": "principal-shared",
335
+ },
336
+ });
337
+
338
+ expect(result).toEqual({ accepted: true });
339
+ expect(resolvedIds).toContain(requestId);
340
+ });
341
+
342
+ test("still throws ForbiddenError (403) for a submitter with a different principal than the healed target", () => {
343
+ const requestId = "req-healed-target-mismatch";
344
+ registerPending(requestId, { targetClientId: "client-abc" });
345
+ clientActorPrincipals.set("client-abc", "principal-victim");
346
+
347
+ expect(() =>
348
+ handleHostBashResult({
349
+ body: bashBody(requestId),
350
+ headers: {
351
+ "x-vellum-client-id": "client-abc",
352
+ "x-vellum-actor-principal-id": "principal-attacker",
353
+ },
354
+ }),
355
+ ).toThrow(ForbiddenError);
356
+ expect(pendingStore.has(requestId)).toBe(true);
357
+ });
358
+
359
+ test("a present persisted principal always wins over the live hub record", () => {
360
+ // The fill is missing-only: a persisted value captured at registration
361
+ // is never overridden, so a submitter matching only the (different)
362
+ // live record is still rejected.
363
+ const requestId = "req-persisted-wins";
364
+ registerPending(requestId, {
365
+ targetClientId: "client-abc",
366
+ targetActorPrincipalId: "principal-registration",
367
+ });
368
+ clientActorPrincipals.set("client-abc", "principal-live");
369
+
370
+ expect(() =>
371
+ handleHostBashResult({
372
+ body: bashBody(requestId),
373
+ headers: {
374
+ "x-vellum-client-id": "client-abc",
375
+ "x-vellum-actor-principal-id": "principal-live",
376
+ },
377
+ }),
378
+ ).toThrow(ForbiddenError);
379
+ });
380
+ });
381
+
315
382
  // ── Untargeted-request behavior (regression for new check) ─────────
316
383
 
317
384
  describe("untargeted request — actor principal check is skipped", () => {
@@ -650,6 +650,50 @@ describe("HTTP POST /v1/messages client metadata headers", () => {
650
650
  ];
651
651
  expect(persistOptions.metadata).toBeUndefined();
652
652
  });
653
+
654
+ // Persistence merges the row's `client.os` from two sources and only one of
655
+ // them is this row's own: the request. Threading the body's `clientOs` is
656
+ // what lets it tell a reported OS apart from the one a transport-less turn
657
+ // inherits off the live conversation.
658
+ test("threads the body clientOs as this row's own OS evidence", async () => {
659
+ const persistUserMessage = mock(
660
+ async (_options: { requestClientOs?: string }) => ({
661
+ id: "persisted-msg-id",
662
+ deduplicated: false,
663
+ }),
664
+ );
665
+ const runAgentLoop = mock(async () => undefined);
666
+ const conversation = makeConversation({ persistUserMessage, runAgentLoop });
667
+
668
+ const res = await sendMessage("hello", conversation, {
669
+ clientOs: "macos",
670
+ });
671
+
672
+ expect(res.status).toBe(202);
673
+ const [persistOptions] = persistUserMessage.mock.calls[0] as unknown as [
674
+ { requestClientOs?: string },
675
+ ];
676
+ expect(persistOptions.requestClientOs).toBe("macos");
677
+ });
678
+
679
+ test("omits the OS evidence when the body reports no clientOs", async () => {
680
+ const persistUserMessage = mock(
681
+ async (_options: { requestClientOs?: string }) => ({
682
+ id: "persisted-msg-id",
683
+ deduplicated: false,
684
+ }),
685
+ );
686
+ const runAgentLoop = mock(async () => undefined);
687
+ const conversation = makeConversation({ persistUserMessage, runAgentLoop });
688
+
689
+ const res = await sendMessage("hello", conversation);
690
+
691
+ expect(res.status).toBe(202);
692
+ const [persistOptions] = persistUserMessage.mock.calls[0] as unknown as [
693
+ { requestClientOs?: string },
694
+ ];
695
+ expect(persistOptions.requestClientOs).toBeUndefined();
696
+ });
653
697
  });
654
698
 
655
699
  // ============================================================================