@vellumai/assistant 0.12.2-dev.202609182114.335fa56 → 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/docs/architecture/turn-actor.md +1 -1
- 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__/dm-backfill.test.ts +8 -0
- package/src/__tests__/emit-signal-routing-intent.test.ts +141 -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 +16 -18
- 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/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 +8 -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/runtime/approval-message-composer.ts +3 -69
- package/src/runtime/assistant-event-hub.ts +47 -3
- package/src/runtime/guardian-action-message-composer.ts +8 -161
- 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 +16 -7
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +6 -3
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +5 -1
- package/src/runtime/routes/notification-routes.ts +10 -0
- package/src/runtime/routes/roadmap-routes.ts +47 -8
|
@@ -29,7 +29,7 @@ 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.
|
|
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
33
|
|
|
34
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.
|
|
35
35
|
|
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
|
+
});
|
|
@@ -199,6 +199,7 @@ function readPersistedSlackRows(): Array<{
|
|
|
199
199
|
provenanceGuardianExternalUserId: string | undefined;
|
|
200
200
|
provenanceRequesterIdentifier: string | undefined;
|
|
201
201
|
provenanceContactId: string | undefined;
|
|
202
|
+
provenanceLookupFailed: boolean | undefined;
|
|
202
203
|
sentAt: number | undefined;
|
|
203
204
|
createdAt: number;
|
|
204
205
|
}> {
|
|
@@ -257,6 +258,10 @@ function readPersistedSlackRows(): Array<{
|
|
|
257
258
|
typeof envelope.provenanceContactId === "string"
|
|
258
259
|
? envelope.provenanceContactId
|
|
259
260
|
: undefined,
|
|
261
|
+
provenanceLookupFailed:
|
|
262
|
+
typeof envelope.provenanceLookupFailed === "boolean"
|
|
263
|
+
? envelope.provenanceLookupFailed
|
|
264
|
+
: undefined,
|
|
260
265
|
sentAt:
|
|
261
266
|
typeof envelope.sentAt === "number" ? envelope.sentAt : undefined,
|
|
262
267
|
createdAt: row.createdAt,
|
|
@@ -360,6 +365,8 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
|
|
|
360
365
|
expect(meta!.channelId).toBe(SLACK_DM_CHANNEL_ID);
|
|
361
366
|
expect(meta!.actorExternalUserId).toBe(SLACK_DM_USER_ID);
|
|
362
367
|
expect(r.provenanceTrustClass).toBe("unknown");
|
|
368
|
+
// No gateway in the test process: the lookup failed, and the row says so.
|
|
369
|
+
expect(r.provenanceLookupFailed).toBe(true);
|
|
363
370
|
expect(r.provenanceSourceChannel).toBe("slack");
|
|
364
371
|
expect(r.provenanceRequesterIdentifier).toBe(SLACK_DM_USER_ID);
|
|
365
372
|
return meta!.channelTs;
|
|
@@ -597,6 +604,7 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
|
|
|
597
604
|
for (const row of memberRows) {
|
|
598
605
|
expect(row.provenanceTrustClass).toBe("trusted_contact");
|
|
599
606
|
expect(row.provenanceContactId).toBe("contact-dm-user");
|
|
607
|
+
expect(row.provenanceLookupFailed).toBeUndefined();
|
|
600
608
|
}
|
|
601
609
|
const ownPost = rows.find((r) => r.role === "assistant");
|
|
602
610
|
expect(ownPost?.provenanceTrustClass).toBe("unknown");
|
|
@@ -95,7 +95,10 @@ mock.module("../platform/client.js", () => ({
|
|
|
95
95
|
isPlatformClientConfigured: () => isPlatformClientConfiguredMock(),
|
|
96
96
|
}));
|
|
97
97
|
|
|
98
|
-
import {
|
|
98
|
+
import {
|
|
99
|
+
classifyNotificationReceipt,
|
|
100
|
+
emitNotificationSignal,
|
|
101
|
+
} from "../notifications/emit-signal.js";
|
|
99
102
|
|
|
100
103
|
beforeEach(() => {
|
|
101
104
|
evaluateSignalMock.mockReset();
|
|
@@ -707,3 +710,140 @@ describe("high/critical urgency channel force", () => {
|
|
|
707
710
|
});
|
|
708
711
|
});
|
|
709
712
|
});
|
|
713
|
+
|
|
714
|
+
describe("exclusive channelAllowlist policy", () => {
|
|
715
|
+
function makeDecision(overrides: Record<string, unknown>) {
|
|
716
|
+
return {
|
|
717
|
+
shouldNotify: true,
|
|
718
|
+
selectedChannels: ["telegram"],
|
|
719
|
+
reasoningSummary: "LLM selected telegram only",
|
|
720
|
+
renderedCopy: {},
|
|
721
|
+
dedupeKey: "dedupe-allow-1",
|
|
722
|
+
confidence: 0.9,
|
|
723
|
+
fallbackUsed: false,
|
|
724
|
+
persistedDecisionId: "dec-allow-1",
|
|
725
|
+
...overrides,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
test("skips urgent vellum and platform force", async () => {
|
|
730
|
+
evaluateSignalMock.mockResolvedValue(
|
|
731
|
+
makeDecision({ selectedChannels: ["vellum"] }),
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
const result = await emitNotificationSignal({
|
|
735
|
+
sourceEventName: "user.send_notification",
|
|
736
|
+
sourceChannel: "assistant_tool",
|
|
737
|
+
sourceContextId: "cli-allowlist-1",
|
|
738
|
+
attentionHints: {
|
|
739
|
+
requiresAction: true,
|
|
740
|
+
urgency: "critical",
|
|
741
|
+
isAsyncBackground: false,
|
|
742
|
+
visibleInSourceNow: false,
|
|
743
|
+
},
|
|
744
|
+
contextPayload: {
|
|
745
|
+
requestedMessage: "telegram only",
|
|
746
|
+
channelAllowlist: ["vellum"],
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
expect(enforceRoutingIntentMock).not.toHaveBeenCalled();
|
|
751
|
+
const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
|
|
752
|
+
selectedChannels: string[];
|
|
753
|
+
reasoningSummary: string;
|
|
754
|
+
};
|
|
755
|
+
expect(dispatched.selectedChannels).toEqual(["vellum"]);
|
|
756
|
+
expect(dispatched.reasoningSummary).not.toContain("forced");
|
|
757
|
+
expect(result.selectedChannels).toEqual(["vellum"]);
|
|
758
|
+
expect(result.receiptClass).toBe("unknown");
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("replaces an LLM selection with the connected allowlist", async () => {
|
|
762
|
+
evaluateSignalMock.mockResolvedValue(
|
|
763
|
+
makeDecision({ selectedChannels: ["vellum", "telegram", "platform"] }),
|
|
764
|
+
);
|
|
765
|
+
|
|
766
|
+
await emitNotificationSignal({
|
|
767
|
+
sourceEventName: "user.send_notification",
|
|
768
|
+
sourceChannel: "assistant_tool",
|
|
769
|
+
sourceContextId: "cli-allowlist-2",
|
|
770
|
+
attentionHints: {
|
|
771
|
+
requiresAction: true,
|
|
772
|
+
urgency: "high",
|
|
773
|
+
isAsyncBackground: false,
|
|
774
|
+
visibleInSourceNow: false,
|
|
775
|
+
},
|
|
776
|
+
contextPayload: { channelAllowlist: ["vellum"] },
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
|
|
780
|
+
selectedChannels: string[];
|
|
781
|
+
shouldNotify: boolean;
|
|
782
|
+
reasoningSummary: string;
|
|
783
|
+
};
|
|
784
|
+
expect(dispatched.selectedChannels).toEqual(["vellum"]);
|
|
785
|
+
expect(dispatched.shouldNotify).toBe(true);
|
|
786
|
+
expect(dispatched.reasoningSummary).toContain("channelAllowlist: vellum");
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("access-request floor still adds vellum when the allowlist omitted it", async () => {
|
|
790
|
+
evaluateSignalMock.mockResolvedValue(
|
|
791
|
+
makeDecision({ shouldNotify: false, selectedChannels: [] }),
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
await emitNotificationSignal<string>({
|
|
795
|
+
sourceEventName: "ingress.access_request",
|
|
796
|
+
sourceChannel: "telegram",
|
|
797
|
+
sourceContextId: "access-req-allowlist-1",
|
|
798
|
+
requiresConversation: true,
|
|
799
|
+
attentionHints: {
|
|
800
|
+
requiresAction: true,
|
|
801
|
+
urgency: "high",
|
|
802
|
+
isAsyncBackground: false,
|
|
803
|
+
visibleInSourceNow: false,
|
|
804
|
+
},
|
|
805
|
+
contextPayload: {
|
|
806
|
+
requestId: "req-allowlist-1",
|
|
807
|
+
channelAllowlist: ["telegram"],
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
const dispatched = dispatchDecisionMock.mock.calls[0][1] as {
|
|
812
|
+
shouldNotify: boolean;
|
|
813
|
+
selectedChannels: string[];
|
|
814
|
+
reasoningSummary: string;
|
|
815
|
+
};
|
|
816
|
+
expect(dispatched.shouldNotify).toBe(true);
|
|
817
|
+
expect(dispatched.selectedChannels).toContain("vellum");
|
|
818
|
+
expect(dispatched.reasoningSummary).toContain(
|
|
819
|
+
"vellum forced: decisionable access request",
|
|
820
|
+
);
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
describe("classifyNotificationReceipt", () => {
|
|
825
|
+
test("reports provider_accepted when an external channel is sent", () => {
|
|
826
|
+
expect(
|
|
827
|
+
classifyNotificationReceipt([
|
|
828
|
+
{ channel: "telegram", destination: "chat-1", status: "sent" },
|
|
829
|
+
{ channel: "vellum", destination: "vellum", status: "sent" },
|
|
830
|
+
]),
|
|
831
|
+
).toBe("provider_accepted");
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test("reports gateway_accepted when only platform is accepted", () => {
|
|
835
|
+
expect(
|
|
836
|
+
classifyNotificationReceipt([
|
|
837
|
+
{ channel: "platform", destination: "push", status: "pending" },
|
|
838
|
+
]),
|
|
839
|
+
).toBe("gateway_accepted");
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
test("does not claim client_os_posted from a vellum sent row", () => {
|
|
843
|
+
expect(
|
|
844
|
+
classifyNotificationReceipt([
|
|
845
|
+
{ channel: "vellum", destination: "vellum", status: "sent" },
|
|
846
|
+
]),
|
|
847
|
+
).toBe("unknown");
|
|
848
|
+
});
|
|
849
|
+
});
|
|
@@ -18,7 +18,7 @@ function text(role: "user" | "assistant", body: string): Message {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function jevResponse(noul: number): ProviderResponse {
|
|
21
|
-
const answers = {
|
|
21
|
+
const answers = { answer: { type: "noul", noul } };
|
|
22
22
|
return {
|
|
23
23
|
content: [{ type: "text", text: JSON.stringify(answers) }],
|
|
24
24
|
model: "jev-latest",
|
|
@@ -102,7 +102,7 @@ describe("judgeEscalation", () => {
|
|
|
102
102
|
const request = seen as unknown as {
|
|
103
103
|
body: {
|
|
104
104
|
state: { caller_just_said: string; recent_conversation: string };
|
|
105
|
-
questions: {
|
|
105
|
+
questions: { answer: { type: string } };
|
|
106
106
|
};
|
|
107
107
|
options: SendMessageOptions;
|
|
108
108
|
};
|
|
@@ -110,7 +110,7 @@ describe("judgeEscalation", () => {
|
|
|
110
110
|
expect(request.body.state.recent_conversation).toBe(
|
|
111
111
|
"assistant: Hi, what can I do?",
|
|
112
112
|
);
|
|
113
|
-
expect(request.body.questions.
|
|
113
|
+
expect(request.body.questions.answer.type).toBe("noul");
|
|
114
114
|
expect(
|
|
115
115
|
(request.options.config as { callSite?: string } | undefined)?.callSite,
|
|
116
116
|
).toBe(VOICE_ESCALATION_JUDGE_CALL_SITE);
|
|
@@ -12,7 +12,7 @@ import { DELIVERY_STATUS } from "@vellumai/gateway-client";
|
|
|
12
12
|
import { resolveDeliverCallbackUrlForChannel } from "../approvals/guardian-channel-delivery.js";
|
|
13
13
|
import { addMessage } from "../persistence/conversation-crud.js";
|
|
14
14
|
import { deliverChannelReply } from "../runtime/gateway-client.js";
|
|
15
|
-
import {
|
|
15
|
+
import { composeGuardianActionMessage } from "../runtime/guardian-action-message-composer.js";
|
|
16
16
|
import { getLogger } from "../util/logger.js";
|
|
17
17
|
|
|
18
18
|
const log = getLogger("guardian-action-sweep");
|
|
@@ -52,9 +52,8 @@ export async function sendGuardianExpiryNotices(
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
try {
|
|
55
|
-
const expiryText =
|
|
55
|
+
const expiryText = composeGuardianActionMessage({
|
|
56
56
|
scenario: "guardian_stale_expired",
|
|
57
|
-
channel: delivery.destinationChannel,
|
|
58
57
|
});
|
|
59
58
|
|
|
60
59
|
if (
|