@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.
- package/ARCHITECTURE.md +8 -6
- package/docs/architecture/turn-actor.md +2 -0
- package/openapi.yaml +108 -9
- package/package.json +1 -1
- package/src/__tests__/approval-message-composer.test.ts +1 -118
- package/src/__tests__/assistant-event-hub-connection-history.test.ts +111 -0
- package/src/__tests__/assistant-event-targeting.test.ts +186 -0
- package/src/__tests__/assistant-stream-state.test.ts +7 -9
- package/src/__tests__/channel-retry-sweep.test.ts +9 -0
- package/src/__tests__/dm-backfill.test.ts +91 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +141 -1
- package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +5 -0
- package/src/__tests__/message-provenance-contact-id.test.ts +69 -0
- package/src/__tests__/process-message-display-content.test.ts +56 -0
- package/src/__tests__/reaction-persistence.test.ts +5 -0
- package/src/__tests__/runtime-events-sse-reconnect.test.ts +1 -1
- package/src/__tests__/scripted-turn-metadata-persistence.test.ts +73 -0
- package/src/__tests__/thread-backfill.test.ts +53 -0
- package/src/approvals/AGENTS.md +1 -1
- package/src/calls/__tests__/voice-escalation-judge.test.ts +3 -3
- package/src/calls/guardian-action-sweep.ts +2 -3
- package/src/calls/typesafe-noul.ts +156 -0
- package/src/calls/voice-escalation-judge.ts +30 -139
- package/src/cli/__tests__/roadmap-cli.test.ts +4 -41
- package/src/cli/commands/__tests__/notifications.test.ts +25 -0
- package/src/cli/commands/clients.help.ts +46 -0
- package/src/cli/commands/clients.ts +116 -0
- package/src/cli/commands/notifications.help.ts +16 -2
- package/src/cli/commands/notifications.ts +30 -8
- package/src/cli/commands/roadmap.help.ts +18 -21
- package/src/cli/commands/roadmap.ts +4 -21
- package/src/config/call-site-defaults.ts +4 -0
- package/src/config/schemas/call-site-catalog.ts +7 -0
- package/src/config/schemas/llm.ts +1 -0
- package/src/daemon/conversation-messaging.ts +19 -1
- package/src/daemon/message-provenance.ts +23 -0
- package/src/daemon/process-message.ts +13 -4
- package/src/live-voice/__tests__/continuation-judge.test.ts +151 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +215 -0
- package/src/live-voice/continuation-judge.ts +123 -0
- package/src/live-voice/live-voice-session.ts +193 -0
- package/src/notifications/README.md +2 -0
- package/src/notifications/__tests__/channel-allowlist.test.ts +39 -0
- package/src/notifications/__tests__/decision-engine.test.ts +75 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -0
- package/src/notifications/__tests__/notification-utils.test.ts +18 -0
- package/src/notifications/channel-allowlist.ts +44 -0
- package/src/notifications/decision-engine.ts +24 -14
- package/src/notifications/emit-signal.ts +101 -22
- package/src/notifications/home-feed-side-effect.ts +5 -1
- package/src/notifications/notification-utils.ts +19 -0
- package/src/notifications/types.ts +15 -0
- package/src/persistence/__tests__/client-connection-events-store.test.ts +322 -0
- package/src/persistence/client-connection-events-store.ts +377 -0
- package/src/persistence/conversation-crud.ts +16 -0
- package/src/persistence/migrations/382-create-client-connection-events.ts +40 -0
- package/src/persistence/migrations/__tests__/382-create-client-connection-events.test.ts +56 -0
- package/src/persistence/schema/infrastructure.ts +29 -0
- package/src/persistence/steps.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/archive-removal-framing.test.ts +64 -0
- package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +2 -2
- package/src/plugins/defaults/memory/tools.test.ts +24 -1
- package/src/plugins/defaults/memory/tools.ts +12 -1
- package/src/runtime/approval-message-composer.ts +3 -69
- package/src/runtime/assistant-event-hub.ts +63 -89
- package/src/runtime/assistant-event-targeting.ts +79 -0
- package/src/runtime/assistant-stream-state.ts +13 -112
- package/src/runtime/channel-retry-sweep.ts +1 -0
- package/src/runtime/guardian-action-message-composer.ts +8 -161
- package/src/runtime/http-types.ts +6 -0
- package/src/runtime/message-composer-types.ts +3 -61
- package/src/runtime/routes/__tests__/client-history-routes.test.ts +104 -0
- package/src/runtime/routes/__tests__/roadmap-routes.test.ts +29 -6
- package/src/runtime/routes/client-routes.ts +108 -0
- package/src/runtime/routes/events-routes.ts +18 -9
- package/src/runtime/routes/inbound-message-handler.ts +35 -38
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +168 -0
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +96 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +29 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -0
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +2 -0
- package/src/runtime/routes/notification-routes.ts +10 -0
- package/src/runtime/routes/roadmap-routes.ts +47 -8
- package/src/util/canonicalize-identity.test.ts +21 -0
- 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
|
|
1934
|
-
|
|
|
1935
|
-
| `assistant/src/runtime/assistant-event.ts`
|
|
1936
|
-
| `assistant/src/runtime/assistant-event-hub.ts`
|
|
1937
|
-
| `assistant/src/runtime/
|
|
1938
|
-
| `assistant/src/
|
|
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
|
|
29111
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
+
});
|