@vellumai/assistant 0.12.2-dev.202609182015.188de8a → 0.12.2-dev.202609182214.bd8f92f

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 (85) hide show
  1. package/ARCHITECTURE.md +8 -6
  2. package/docs/architecture/turn-actor.md +2 -0
  3. package/openapi.yaml +108 -9
  4. package/package.json +1 -1
  5. package/src/__tests__/approval-message-composer.test.ts +1 -118
  6. package/src/__tests__/assistant-event-hub-connection-history.test.ts +111 -0
  7. package/src/__tests__/assistant-event-targeting.test.ts +186 -0
  8. package/src/__tests__/assistant-stream-state.test.ts +7 -9
  9. package/src/__tests__/channel-retry-sweep.test.ts +9 -0
  10. package/src/__tests__/dm-backfill.test.ts +91 -1
  11. package/src/__tests__/emit-signal-routing-intent.test.ts +141 -1
  12. package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +5 -0
  13. package/src/__tests__/message-provenance-contact-id.test.ts +69 -0
  14. package/src/__tests__/process-message-display-content.test.ts +56 -0
  15. package/src/__tests__/reaction-persistence.test.ts +5 -0
  16. package/src/__tests__/runtime-events-sse-reconnect.test.ts +1 -1
  17. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +73 -0
  18. package/src/__tests__/thread-backfill.test.ts +53 -0
  19. package/src/approvals/AGENTS.md +1 -1
  20. package/src/calls/__tests__/voice-escalation-judge.test.ts +3 -3
  21. package/src/calls/guardian-action-sweep.ts +2 -3
  22. package/src/calls/typesafe-noul.ts +156 -0
  23. package/src/calls/voice-escalation-judge.ts +30 -139
  24. package/src/cli/__tests__/roadmap-cli.test.ts +4 -41
  25. package/src/cli/commands/__tests__/notifications.test.ts +25 -0
  26. package/src/cli/commands/clients.help.ts +46 -0
  27. package/src/cli/commands/clients.ts +116 -0
  28. package/src/cli/commands/notifications.help.ts +16 -2
  29. package/src/cli/commands/notifications.ts +30 -8
  30. package/src/cli/commands/roadmap.help.ts +18 -21
  31. package/src/cli/commands/roadmap.ts +4 -21
  32. package/src/config/call-site-defaults.ts +4 -0
  33. package/src/config/schemas/call-site-catalog.ts +7 -0
  34. package/src/config/schemas/llm.ts +1 -0
  35. package/src/daemon/conversation-messaging.ts +19 -1
  36. package/src/daemon/message-provenance.ts +23 -0
  37. package/src/daemon/process-message.ts +13 -4
  38. package/src/live-voice/__tests__/continuation-judge.test.ts +151 -0
  39. package/src/live-voice/__tests__/live-voice-vad.test.ts +215 -0
  40. package/src/live-voice/continuation-judge.ts +123 -0
  41. package/src/live-voice/live-voice-session.ts +193 -0
  42. package/src/notifications/README.md +2 -0
  43. package/src/notifications/__tests__/channel-allowlist.test.ts +39 -0
  44. package/src/notifications/__tests__/decision-engine.test.ts +75 -0
  45. package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -0
  46. package/src/notifications/__tests__/notification-utils.test.ts +18 -0
  47. package/src/notifications/channel-allowlist.ts +44 -0
  48. package/src/notifications/decision-engine.ts +24 -14
  49. package/src/notifications/emit-signal.ts +101 -22
  50. package/src/notifications/home-feed-side-effect.ts +5 -1
  51. package/src/notifications/notification-utils.ts +19 -0
  52. package/src/notifications/types.ts +15 -0
  53. package/src/persistence/__tests__/client-connection-events-store.test.ts +322 -0
  54. package/src/persistence/client-connection-events-store.ts +377 -0
  55. package/src/persistence/conversation-crud.ts +16 -0
  56. package/src/persistence/migrations/382-create-client-connection-events.ts +40 -0
  57. package/src/persistence/migrations/__tests__/382-create-client-connection-events.test.ts +56 -0
  58. package/src/persistence/schema/infrastructure.ts +29 -0
  59. package/src/persistence/steps.ts +2 -0
  60. package/src/plugins/defaults/memory/__tests__/archive-removal-framing.test.ts +64 -0
  61. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +2 -2
  62. package/src/plugins/defaults/memory/tools.test.ts +24 -1
  63. package/src/plugins/defaults/memory/tools.ts +12 -1
  64. package/src/runtime/approval-message-composer.ts +3 -69
  65. package/src/runtime/assistant-event-hub.ts +63 -89
  66. package/src/runtime/assistant-event-targeting.ts +79 -0
  67. package/src/runtime/assistant-stream-state.ts +13 -112
  68. package/src/runtime/channel-retry-sweep.ts +1 -0
  69. package/src/runtime/guardian-action-message-composer.ts +8 -161
  70. package/src/runtime/http-types.ts +6 -0
  71. package/src/runtime/message-composer-types.ts +3 -61
  72. package/src/runtime/routes/__tests__/client-history-routes.test.ts +104 -0
  73. package/src/runtime/routes/__tests__/roadmap-routes.test.ts +29 -6
  74. package/src/runtime/routes/client-routes.ts +108 -0
  75. package/src/runtime/routes/events-routes.ts +18 -9
  76. package/src/runtime/routes/inbound-message-handler.ts +35 -38
  77. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +168 -0
  78. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +96 -0
  79. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +29 -0
  80. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -0
  81. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +2 -0
  82. package/src/runtime/routes/notification-routes.ts +10 -0
  83. package/src/runtime/routes/roadmap-routes.ts +47 -8
  84. package/src/util/canonicalize-identity.test.ts +21 -0
  85. package/src/util/canonicalize-identity.ts +16 -0
package/ARCHITECTURE.md CHANGED
@@ -1930,12 +1930,14 @@ Keep-alive heartbeats (every 30 s by default):
1930
1930
 
1931
1931
  ### Key Source Files
1932
1932
 
1933
- | File | Role |
1934
- | ----------------------------------------------- | ------------------------------------------------------------------------------ |
1935
- | `assistant/src/runtime/assistant-event.ts` | `AssistantEvent` type, `buildAssistantEvent()` factory, SSE framing helpers |
1936
- | `assistant/src/runtime/assistant-event-hub.ts` | `AssistantEventHub` class and process-level singleton |
1937
- | `assistant/src/runtime/routes/events-routes.ts` | `handleSubscribeAssistantEvents()` SSE route handler |
1938
- | `assistant/src/daemon/server.ts` | Session event paths that publish to the hub (`send` `publishAssistantEvent`) |
1933
+ | File | Role |
1934
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
1935
+ | `assistant/src/runtime/assistant-event.ts` | `AssistantEvent` type, `buildAssistantEvent()` factory, SSE framing helpers |
1936
+ | `assistant/src/runtime/assistant-event-hub.ts` | `AssistantEventHub` class and process-level singleton |
1937
+ | `assistant/src/runtime/assistant-event-targeting.ts` | `matchesTargeting()`, the targeting check live fanout and replay share |
1938
+ | `assistant/src/runtime/assistant-stream-state.ts` | Per-assistant `seq` counter and ring buffer behind reconnect and `events/tail` replay |
1939
+ | `assistant/src/runtime/routes/events-routes.ts` | `handleSubscribeAssistantEvents()`, the SSE route handler |
1940
+ | `assistant/src/daemon/server.ts` | Session event paths that publish to the hub (`send` → `publishAssistantEvent`) |
1939
1941
 
1940
1942
  ---
1941
1943
 
@@ -29,6 +29,8 @@ Do not read `trustContext` or `currentTurnTrustContext` directly. Call sites han
29
29
 
30
30
  **Use `getTurnOrRestingTrust()`** for provenance stamped onto persisted rows (see `provenanceFromTrustContext`). Provenance is read back by the memory indexer, which runs extraction only for guardian rows, and by the transcript assembly, which wraps non-guardian user content before it reaches the model. The turn's actor is correct when a turn is running; rows persisted with no turn in flight (wake notices) must keep the owner's class, because stamping `"unknown"` there silently stops memory extraction for the owner's own flows.
31
31
 
32
+ That provenance describes the turn, so it is stamped on every row the turn writes, the assistant's replies and tool results included. Who wrote a row is a separate fact: `provenanceContactId` names the author's contact and needs positive evidence. It is added only where a person's own inbound words or actions are persisted (`actorAuthorProvenance`): a message channel ingress or its retry replay relays with an explicit `author`, unless that row is scripted or hidden, and the actor's reaction row. The shared user-message writer never infers an author from the conversation's trust, because machine-authored rows (ACP and subagent notifications, pointer turns) persist through it too. Rows the assistant writes during someone's turn never carry it. Slack backfill imports other people's messages, so the turn's actor says nothing about them; each backfilled sender is classified by the same gateway verdict live ingress uses (`resolve_inbound_trust`, see `inbound-stages/backfill-sender-provenance.ts`), which supplies both its trust class and its contact id. When that lookup returns no usable verdict, the row falls back to the guardian-address comparison and is marked `provenanceLookupFailed`, so a failed lookup is never stored as the same fact as a sender the gateway resolved as a stranger. Live ingress needs no such marker: it denies a turn whose verdict failed.
33
+
32
34
  **Use `getTrustContext()`** when there is no turn: HTTP routes reporting conversation state, hydration, and persistence of conversation-level options. Also when a caller deliberately wants the conversation's owner rather than whoever is currently acting; that intent should be obvious from the call site, and if it is not, it is probably the wrong accessor.
33
35
 
34
36
  ## When the acting actor is unknown
package/openapi.yaml CHANGED
@@ -5647,6 +5647,64 @@ paths:
5647
5647
  required:
5648
5648
  - disconnected
5649
5649
  additionalProperties: false
5650
+ /v1/clients/history:
5651
+ get:
5652
+ operationId: clients_history_get
5653
+ summary: List client connection history
5654
+ description:
5655
+ Return persisted client subscribe/dispose events and coalesced sessions. Reconnects shorter than 60 seconds
5656
+ are flaps, not outages.
5657
+ tags:
5658
+ - clients
5659
+ parameters:
5660
+ - name: clientId
5661
+ in: query
5662
+ required: false
5663
+ schema:
5664
+ type: string
5665
+ description: Restrict history to one client UUID.
5666
+ - name: interfaceId
5667
+ in: query
5668
+ required: false
5669
+ schema:
5670
+ type: string
5671
+ description: Restrict history to one interface (e.g. chrome-extension).
5672
+ - name: since
5673
+ in: query
5674
+ required: false
5675
+ schema:
5676
+ type: string
5677
+ description: ISO-8601 or epoch-ms lower bound. Sessions already open at this time are included.
5678
+ - name: limit
5679
+ in: query
5680
+ required: false
5681
+ schema:
5682
+ type: string
5683
+ description: Max coalesced sessions to return (1-500, default 50).
5684
+ responses:
5685
+ "200":
5686
+ description: Successful response
5687
+ content:
5688
+ application/json:
5689
+ schema:
5690
+ type: object
5691
+ properties:
5692
+ sessions:
5693
+ type: array
5694
+ items:
5695
+ type: object
5696
+ properties: {}
5697
+ additionalProperties: {}
5698
+ events:
5699
+ type: array
5700
+ items:
5701
+ type: object
5702
+ properties: {}
5703
+ additionalProperties: {}
5704
+ required:
5705
+ - sessions
5706
+ - events
5707
+ additionalProperties: false
5650
5708
  /v1/clients/presence:
5651
5709
  post:
5652
5710
  operationId: clients_presence_post
@@ -24646,11 +24704,58 @@ paths:
24646
24704
  type: boolean
24647
24705
  reason:
24648
24706
  type: string
24707
+ selectedChannels:
24708
+ type: array
24709
+ items:
24710
+ type: string
24711
+ deliveryResults:
24712
+ type: array
24713
+ items:
24714
+ type: object
24715
+ properties:
24716
+ channel:
24717
+ type: string
24718
+ destination:
24719
+ type: string
24720
+ status:
24721
+ type: string
24722
+ enum:
24723
+ - pending
24724
+ - sent
24725
+ - failed
24726
+ - skipped
24727
+ errorCode:
24728
+ type: string
24729
+ errorMessage:
24730
+ type: string
24731
+ sentAt:
24732
+ type: number
24733
+ conversationId:
24734
+ type: string
24735
+ messageId:
24736
+ type: string
24737
+ conversationStrategy:
24738
+ type: string
24739
+ required:
24740
+ - channel
24741
+ - destination
24742
+ - status
24743
+ additionalProperties: false
24744
+ receiptClass:
24745
+ type: string
24746
+ enum:
24747
+ - provider_accepted
24748
+ - gateway_accepted
24749
+ - client_os_posted
24750
+ - unknown
24649
24751
  required:
24650
24752
  - signalId
24651
24753
  - dispatched
24652
24754
  - deduplicated
24653
24755
  - reason
24756
+ - selectedChannels
24757
+ - deliveryResults
24758
+ - receiptClass
24654
24759
  additionalProperties: false
24655
24760
  /v1/notifications/events:
24656
24761
  post:
@@ -29107,8 +29212,8 @@ paths:
29107
29212
  operationId: roadmap_by_slug_patch
29108
29213
  summary: Update a roadmap item
29109
29214
  description:
29110
- Updates a roadmap item. At least one field is required; the roadmap service decides which items this
29111
- assistant may edit.
29215
+ Updates the title or description of a roadmap item this assistant filed, while the item is still open.
29216
+ Status and tags are set by Vellum staff.
29112
29217
  tags:
29113
29218
  - roadmap
29114
29219
  parameters:
@@ -29128,13 +29233,7 @@ paths:
29128
29233
  type: string
29129
29234
  description:
29130
29235
  type: string
29131
- status:
29132
- type: string
29133
- tags:
29134
- description: Tag slugs
29135
- type: array
29136
- items:
29137
- type: string
29236
+ additionalProperties: false
29138
29237
  responses:
29139
29238
  "200":
29140
29239
  description: Successful response
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609182015.188de8a",
3
+ "version": "0.12.2-dev.202609182214.bd8f92f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,21 +17,10 @@ const ALL_SCENARIOS: ApprovalMessageScenario[] = [
17
17
  "standard_prompt",
18
18
  "guardian_prompt",
19
19
  "reminder_prompt",
20
- "guardian_delivery_failed",
21
- "guardian_request_forwarded",
22
- "guardian_disambiguation",
23
20
  "guardian_identity_mismatch",
24
21
  "request_pending_guardian",
25
- "guardian_decision_outcome",
26
- "guardian_expired_requester",
27
- "guardian_expired_guardian",
28
- "guardian_verify_success",
29
22
  "guardian_verify_failed",
30
23
  "guardian_verify_challenge_setup",
31
- "guardian_verify_status_bound",
32
- "guardian_verify_status_unbound",
33
- "guardian_deny_no_identity",
34
- "guardian_deny_no_binding",
35
24
  ];
36
25
 
37
26
  describe("approval-message-composer", () => {
@@ -66,59 +55,6 @@ describe("approval-message-composer", () => {
66
55
  expect(msg).toContain("write_file");
67
56
  });
68
57
 
69
- test("guardian_delivery_failed includes toolName when provided", () => {
70
- const msg = getFallbackMessage({
71
- scenario: "guardian_delivery_failed",
72
- toolName: "execute_shell",
73
- });
74
- expect(msg).toContain("execute_shell");
75
- });
76
-
77
- test("guardian_request_forwarded includes toolName", () => {
78
- const msg = getFallbackMessage({
79
- scenario: "guardian_request_forwarded",
80
- toolName: "execute_shell",
81
- });
82
- expect(msg).toContain("execute_shell");
83
- });
84
-
85
- test("guardian_disambiguation includes pendingCount", () => {
86
- const msg = getFallbackMessage({
87
- scenario: "guardian_disambiguation",
88
- pendingCount: 3,
89
- });
90
- expect(msg).toContain("3");
91
- });
92
-
93
- test("guardian_decision_outcome includes decision and toolName", () => {
94
- const msg = getFallbackMessage({
95
- scenario: "guardian_decision_outcome",
96
- decision: "approved",
97
- toolName: "read_file",
98
- });
99
- expect(msg).toContain("approved");
100
- expect(msg).toContain("read_file");
101
- });
102
-
103
- test("guardian_expired_requester includes toolName", () => {
104
- const msg = getFallbackMessage({
105
- scenario: "guardian_expired_requester",
106
- toolName: "deploy",
107
- });
108
- expect(msg).toContain("deploy");
109
- expect(msg).toContain("expired");
110
- });
111
-
112
- test("guardian_expired_guardian includes requester and toolName", () => {
113
- const msg = getFallbackMessage({
114
- scenario: "guardian_expired_guardian",
115
- requesterIdentifier: "bob",
116
- toolName: "delete_file",
117
- });
118
- expect(msg).toContain("bob");
119
- expect(msg).toContain("delete_file");
120
- });
121
-
122
58
  test("guardian_verify_failed includes failureReason", () => {
123
59
  const msg = getFallbackMessage({
124
60
  scenario: "guardian_verify_failed",
@@ -141,36 +77,7 @@ describe("approval-message-composer", () => {
141
77
  // -----------------------------------------------------------------------
142
78
 
143
79
  describe("composeApprovalMessage", () => {
144
- test("returns assistantPreface when provided (primary source)", () => {
145
- const preface = "The assistant already said something helpful.";
146
- const msg = composeApprovalMessage({
147
- scenario: "standard_prompt",
148
- toolName: "execute_shell",
149
- assistantPreface: preface,
150
- });
151
- expect(msg).toBe(preface);
152
- });
153
-
154
- test("ignores empty assistantPreface and falls back to template", () => {
155
- const msg = composeApprovalMessage({
156
- scenario: "standard_prompt",
157
- toolName: "execute_shell",
158
- assistantPreface: "",
159
- });
160
- expect(msg).toContain("execute_shell");
161
- expect(msg).not.toBe("");
162
- });
163
-
164
- test("ignores whitespace-only assistantPreface", () => {
165
- const msg = composeApprovalMessage({
166
- scenario: "standard_prompt",
167
- toolName: "execute_shell",
168
- assistantPreface: " ",
169
- });
170
- expect(msg).toContain("execute_shell");
171
- });
172
-
173
- test("falls back to deterministic template when no assistantPreface", () => {
80
+ test("returns the deterministic template", () => {
174
81
  const msg = composeApprovalMessage({
175
82
  scenario: "guardian_prompt",
176
83
  toolName: "write_file",
@@ -222,29 +129,5 @@ describe("approval-message-composer", () => {
222
129
  expect(msg.length).toBeGreaterThan(0);
223
130
  expect(msg).toContain("invalid or has expired");
224
131
  });
225
-
226
- test("guardian_verify_success produces a non-empty success message", () => {
227
- const msg = composeApprovalMessage({
228
- scenario: "guardian_verify_success",
229
- });
230
- expect(typeof msg).toBe("string");
231
- expect(msg.length).toBeGreaterThan(0);
232
- });
233
-
234
- test("guardian_verify_status_bound produces a non-empty message", () => {
235
- const msg = composeApprovalMessage({
236
- scenario: "guardian_verify_status_bound",
237
- });
238
- expect(typeof msg).toBe("string");
239
- expect(msg.length).toBeGreaterThan(0);
240
- });
241
-
242
- test("guardian_verify_status_unbound produces a non-empty message", () => {
243
- const msg = composeApprovalMessage({
244
- scenario: "guardian_verify_status_unbound",
245
- });
246
- expect(typeof msg).toBe("string");
247
- expect(msg.length).toBeGreaterThan(0);
248
- });
249
132
  });
250
133
  });
@@ -0,0 +1,111 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import type { ClientConnectionEventInput } from "../persistence/client-connection-events-store.js";
4
+ import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
5
+
6
+ describe("AssistantEventHub connection history", () => {
7
+ const recorded: Array<Pick<ClientConnectionEventInput, "clientId" | "reason">> =
8
+ [];
9
+
10
+ beforeEach(() => {
11
+ recorded.length = 0;
12
+ });
13
+
14
+ function hub(options?: { maxSubscribers?: number }): AssistantEventHub {
15
+ return new AssistantEventHub({
16
+ ...options,
17
+ recordConnection: (input) => {
18
+ recorded.push({ clientId: input.clientId, reason: input.reason });
19
+ return null;
20
+ },
21
+ });
22
+ }
23
+
24
+ test("records sse_open on client subscribe and sse_close on dispose", () => {
25
+ const sub = hub().subscribe({
26
+ type: "client",
27
+ clientId: "client-123",
28
+ interfaceId: "chrome-extension",
29
+ capabilities: ["host_browser"],
30
+ callback: () => {},
31
+ });
32
+
33
+ expect(recorded.map((row) => row.reason)).toEqual(["sse_open"]);
34
+ sub.dispose();
35
+ expect(recorded.map((row) => row.reason)).toEqual(["sse_open", "sse_close"]);
36
+ });
37
+
38
+ test("records stale_replaced then sse_open on reconnect", () => {
39
+ const instance = hub();
40
+ instance.subscribe({
41
+ type: "client",
42
+ clientId: "client-123",
43
+ interfaceId: "chrome-extension",
44
+ capabilities: ["host_browser"],
45
+ callback: () => {},
46
+ });
47
+ instance.subscribe({
48
+ type: "client",
49
+ clientId: "client-123",
50
+ interfaceId: "chrome-extension",
51
+ capabilities: ["host_browser"],
52
+ callback: () => {},
53
+ });
54
+
55
+ expect(recorded.map((row) => row.reason)).toEqual([
56
+ "sse_open",
57
+ "stale_replaced",
58
+ "sse_open",
59
+ ]);
60
+ });
61
+
62
+ test("records force_disconnect from disposeClient", () => {
63
+ const instance = hub();
64
+ instance.subscribe({
65
+ type: "client",
66
+ clientId: "client-123",
67
+ interfaceId: "chrome-extension",
68
+ capabilities: ["host_browser"],
69
+ callback: () => {},
70
+ });
71
+ recorded.length = 0;
72
+
73
+ expect(instance.disposeClient("client-123")).toBe(1);
74
+ expect(recorded.map((row) => row.reason)).toEqual(["force_disconnect"]);
75
+ });
76
+
77
+ test("records cap_evicted when the oldest subscriber is shed", () => {
78
+ const instance = hub({ maxSubscribers: 1 });
79
+ instance.subscribe({
80
+ type: "client",
81
+ clientId: "client-old",
82
+ interfaceId: "web",
83
+ capabilities: [],
84
+ callback: () => {},
85
+ });
86
+ recorded.length = 0;
87
+
88
+ instance.subscribe({
89
+ type: "client",
90
+ clientId: "client-new",
91
+ interfaceId: "web",
92
+ capabilities: [],
93
+ callback: () => {},
94
+ });
95
+
96
+ expect(recorded.map((row) => row.reason)).toEqual([
97
+ "cap_evicted",
98
+ "sse_open",
99
+ ]);
100
+ expect(recorded[0].clientId).toBe("client-old");
101
+ });
102
+
103
+ test("does not record process subscribers", () => {
104
+ const sub = hub().subscribe({
105
+ type: "process",
106
+ callback: () => {},
107
+ });
108
+ sub.dispose();
109
+ expect(recorded).toEqual([]);
110
+ });
111
+ });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Tests for `matchesTargeting`, the one targeting check behind live fanout
3
+ * (`AssistantEventHub.publish`) and replay (`getReplayWindow`).
4
+ *
5
+ * The parity block runs every case through both paths and requires them to
6
+ * agree, so a rule added to one path alone fails here.
7
+ */
8
+ import { beforeEach, describe, expect, test } from "bun:test";
9
+
10
+ import type { AssistantEventEnvelope } from "../api/index.js";
11
+ import type { HostProxyCapability, InterfaceId } from "../channels/types.js";
12
+ import { AssistantEventHub } from "../runtime/assistant-event-hub.js";
13
+ import type { AssistantEventPublishOptions } from "../runtime/assistant-event-publish-options.js";
14
+ import { matchesTargeting } from "../runtime/assistant-event-targeting.js";
15
+ import {
16
+ _resetStreamStateForTesting,
17
+ getReplayWindow,
18
+ stampAndBuffer,
19
+ } from "../runtime/assistant-stream-state.js";
20
+
21
+ const CONV = "conv-targeting";
22
+
23
+ interface ClientIdentity {
24
+ type: "client";
25
+ clientId: string;
26
+ interfaceId: InterfaceId;
27
+ capabilities: HostProxyCapability[];
28
+ actorPrincipalId?: string;
29
+ }
30
+
31
+ const MAC: ClientIdentity = {
32
+ type: "client",
33
+ clientId: "mac-1",
34
+ interfaceId: "macos",
35
+ capabilities: ["host_bash", "host_browser"],
36
+ actorPrincipalId: "principal-g",
37
+ };
38
+ const WEB: ClientIdentity = {
39
+ type: "client",
40
+ clientId: "web-1",
41
+ interfaceId: "web",
42
+ capabilities: [],
43
+ actorPrincipalId: "principal-g",
44
+ };
45
+ const OTHER_USER: ClientIdentity = {
46
+ type: "client",
47
+ clientId: "web-2",
48
+ interfaceId: "web",
49
+ capabilities: [],
50
+ actorPrincipalId: "principal-x",
51
+ };
52
+ const NO_PRINCIPAL: ClientIdentity = {
53
+ type: "client",
54
+ clientId: "legacy-1",
55
+ interfaceId: "web",
56
+ capabilities: [],
57
+ };
58
+ interface ProcessIdentity {
59
+ type: "process";
60
+ }
61
+
62
+ const PROCESS: ProcessIdentity = { type: "process" };
63
+
64
+ const SUBSCRIBERS: Array<[string, ClientIdentity | ProcessIdentity]> = [
65
+ ["mac", MAC],
66
+ ["web", WEB],
67
+ ["other user", OTHER_USER],
68
+ ["no principal", NO_PRINCIPAL],
69
+ ["process", PROCESS],
70
+ ];
71
+
72
+ /** Each targeting case with the subscribers it must reach, by label. */
73
+ const CASES: Array<{
74
+ name: string;
75
+ targeting: AssistantEventPublishOptions | undefined;
76
+ reaches: string[];
77
+ }> = [
78
+ {
79
+ name: "untargeted",
80
+ targeting: undefined,
81
+ reaches: ["mac", "web", "other user", "no principal", "process"],
82
+ },
83
+ {
84
+ name: "self-echo exclusion",
85
+ targeting: { excludeClientId: "web-1" },
86
+ reaches: ["mac", "other user", "no principal", "process"],
87
+ },
88
+ {
89
+ name: "interface",
90
+ targeting: { targetInterfaceId: "web" },
91
+ reaches: ["web", "other user", "no principal"],
92
+ },
93
+ {
94
+ name: "principal",
95
+ targeting: { targetActorPrincipalId: "principal-g" },
96
+ reaches: ["mac", "web"],
97
+ },
98
+ {
99
+ name: "client",
100
+ targeting: { targetClientId: "mac-1" },
101
+ reaches: ["mac"],
102
+ },
103
+ {
104
+ name: "capability",
105
+ targeting: { targetCapability: "host_browser" },
106
+ reaches: ["mac"],
107
+ },
108
+ {
109
+ name: "client without the capability",
110
+ targeting: { targetClientId: "web-1", targetCapability: "host_browser" },
111
+ reaches: [],
112
+ },
113
+ {
114
+ name: "principal and interface together",
115
+ targeting: {
116
+ targetActorPrincipalId: "principal-g",
117
+ targetInterfaceId: "web",
118
+ },
119
+ reaches: ["web"],
120
+ },
121
+ ];
122
+
123
+ function makeEvent(): AssistantEventEnvelope {
124
+ return {
125
+ id: `evt-${Math.random().toString(36).slice(2, 10)}`,
126
+ conversationId: CONV,
127
+ emittedAt: new Date().toISOString(),
128
+ message: { type: "assistant_text_delta", conversationId: CONV, text: "x" },
129
+ };
130
+ }
131
+
132
+ async function reachedLive(
133
+ targeting: AssistantEventPublishOptions | undefined,
134
+ ): Promise<string[]> {
135
+ const hub = new AssistantEventHub();
136
+ const reached: string[] = [];
137
+ for (const [label, identity] of SUBSCRIBERS) {
138
+ const callback = () => {
139
+ reached.push(label);
140
+ };
141
+ if (identity.type === "process") {
142
+ hub.subscribe({ type: "process", callback });
143
+ } else {
144
+ hub.subscribe({ ...identity, callback });
145
+ }
146
+ }
147
+ await hub.publish(makeEvent(), targeting);
148
+ return reached.sort();
149
+ }
150
+
151
+ function reachedOnReplay(
152
+ targeting: AssistantEventPublishOptions | undefined,
153
+ ): string[] {
154
+ _resetStreamStateForTesting();
155
+ stampAndBuffer(makeEvent(), targeting ? { targeting } : undefined);
156
+ return SUBSCRIBERS.filter(
157
+ ([, identity]) => (getReplayWindow(0, identity) ?? []).length > 0,
158
+ )
159
+ .map(([label]) => label)
160
+ .sort();
161
+ }
162
+
163
+ describe("matchesTargeting", () => {
164
+ for (const { name, targeting, reaches } of CASES) {
165
+ test(`${name}: reaches exactly its subscribers`, () => {
166
+ const reached = SUBSCRIBERS.filter(([, identity]) =>
167
+ matchesTargeting(targeting, identity),
168
+ ).map(([label]) => label);
169
+ expect(reached.sort()).toEqual([...reaches].sort());
170
+ });
171
+ }
172
+ });
173
+
174
+ describe("live fanout and replay agree", () => {
175
+ beforeEach(() => {
176
+ _resetStreamStateForTesting();
177
+ });
178
+
179
+ for (const { name, targeting, reaches } of CASES) {
180
+ test(`${name}: both paths deliver to the same subscribers`, async () => {
181
+ const expected = [...reaches].sort();
182
+ expect(await reachedLive(targeting)).toEqual(expected);
183
+ expect(reachedOnReplay(targeting)).toEqual(expected);
184
+ });
185
+ }
186
+ });