@vellumai/assistant 0.12.2-dev.202609181913.1108ac3 → 0.12.2-dev.202609182114.335fa56
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 +9 -7
- package/docs/architecture/turn-actor.md +2 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +4 -1
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +38 -7
- package/package.json +1 -1
- 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 +83 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +1 -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__/notification-telegram-adapter.test.ts +2 -2
- 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/cli/commands/__tests__/notifications.test.ts +10 -10
- package/src/cli/commands/roadmap.help.ts +2 -3
- 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/ipc/assistant-server.ts +2 -0
- package/src/ipc/routes/__tests__/channel-reply-ipc-routes.test.ts +111 -0
- package/src/ipc/routes/channel-reply-ipc-routes.ts +73 -0
- package/src/messaging/providers/discord/send.ts +3 -2
- package/src/messaging/providers/telegram-bot/send.test.ts +30 -1
- package/src/messaging/providers/telegram-bot/send.ts +10 -7
- package/src/notifications/README.md +3 -1
- package/src/notifications/__tests__/broadcaster.test.ts +1 -1
- package/src/notifications/__tests__/copy-composer.test.ts +1 -1
- package/src/notifications/__tests__/decision-engine.test.ts +4 -2
- package/src/notifications/__tests__/deterministic-checks.test.ts +10 -10
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +6 -4
- package/src/notifications/copy-composer.ts +0 -23
- package/src/notifications/deterministic-checks.ts +1 -1
- package/src/notifications/home-feed-side-effect.ts +4 -6
- package/src/notifications/signal.ts +3 -18
- package/src/persistence/conversation-crud.ts +8 -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/AGENTS.md +2 -0
- package/src/runtime/assistant-event-hub.ts +16 -86
- 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/http-types.ts +6 -0
- package/src/runtime/routes/events-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +35 -38
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +165 -0
- package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +92 -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/util/canonicalize-identity.test.ts +21 -0
- package/src/util/canonicalize-identity.ts +16 -0
package/ARCHITECTURE.md
CHANGED
|
@@ -300,7 +300,7 @@ The WhatsApp channel enables inbound and outbound messaging via the Meta WhatsAp
|
|
|
300
300
|
|
|
301
301
|
**Egress** (daemon WhatsApp transport, `src/messaging/providers/whatsapp/`):
|
|
302
302
|
|
|
303
|
-
1. Replies, approval prompts, and proactive sends go out through the daemon's `whatsappTransport` (`transport.ts`), which calls the Meta Cloud API itself. `isDirectDelivery()` in `src/messaging/providers/index.ts` resolves the `/deliver/whatsapp` callback URL to this transport through `channelForCallback()` (`src/messaging/providers/callback-routing.ts`), so the daemon never posts back to the gateway or dials the URL's host and port. The
|
|
303
|
+
1. Replies, approval prompts, and proactive sends go out through the daemon's `whatsappTransport` (`transport.ts`), which calls the Meta Cloud API itself. `isDirectDelivery()` in `src/messaging/providers/index.ts` resolves the `/deliver/whatsapp` callback URL to this transport through `channelForCallback()` (`src/messaging/providers/callback-routing.ts`), so the daemon never posts back to the gateway or dials the URL's host and port. The gateway's own replies to invite and verification codes it intercepts at ingress reach this transport the same way: `deliverVerificationReply` (`gateway/src/verification/reply-delivery.ts`) hands the reply and the inbound message's callback URL to the daemon's IPC-only `deliver_gateway_reply` method (`src/ipc/routes/channel-reply-ipc-routes.ts`), which resolves the transport from that URL.
|
|
304
304
|
2. `api.ts` reads `phone_number_id` and `access_token` from the secure store and sends through the Cloud API `/{phoneNumberId}/messages` endpoint.
|
|
305
305
|
3. `send.ts` splits text into 4096-character chunks, preferring newline and then whitespace boundaries.
|
|
306
306
|
4. An approval prompt renders as an interactive message with up to three reply buttons whose ids follow the shared `apr:<requestId>:<action>` convention. When the cap forces a cut, the reject and block actions are kept. The gateway normalizes the guardian's `button_reply` back into callback data on ingress.
|
|
@@ -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.
|
|
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
|
|
@@ -24,13 +24,15 @@ export {
|
|
|
24
24
|
PersistentIpcClient,
|
|
25
25
|
} from "./ipc-client.js";
|
|
26
26
|
|
|
27
|
-
// Outbound delivery contract
|
|
27
|
+
// Outbound delivery contract: Zod schemas + derived types
|
|
28
28
|
export {
|
|
29
29
|
ApprovalActionOptionSchema,
|
|
30
30
|
ApprovalUIMetadataSchema,
|
|
31
31
|
AttachmentMetadataSchema,
|
|
32
32
|
ChannelDeliveryResultSchema,
|
|
33
33
|
ChannelReplyPayloadSchema,
|
|
34
|
+
DELIVER_GATEWAY_REPLY_IPC_METHOD,
|
|
35
|
+
GatewayReplyRequestSchema,
|
|
34
36
|
MessageAudienceSchema,
|
|
35
37
|
PermissionRequestDetailsSchema,
|
|
36
38
|
StreamOpSchema,
|
|
@@ -45,6 +47,7 @@ export type {
|
|
|
45
47
|
AttachmentMetadata,
|
|
46
48
|
ChannelDeliveryResult,
|
|
47
49
|
ChannelReplyPayload,
|
|
50
|
+
GatewayReplyRequest,
|
|
48
51
|
MessageAudience,
|
|
49
52
|
PermissionRequestDetails,
|
|
50
53
|
StreamOp,
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Outbound channel delivery contract.
|
|
3
3
|
*
|
|
4
|
-
* Zod schemas
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Zod schemas for a reply to a channel chat. The daemon constructs these
|
|
5
|
+
* payloads in `deliverChannelReply()` and `deliverApprovalPrompt()` and hands
|
|
6
|
+
* them to the channel transport its callback URL names
|
|
7
|
+
* (`messaging/providers`). `/deliver/{channel}` is only that callback URL's
|
|
8
|
+
* addressing form: no service serves it. A callback URL no
|
|
9
|
+
* transport owns (a managed callback carrying a `callback_token`) is POSTed
|
|
10
|
+
* over HTTP by `http-delivery.ts` instead.
|
|
7
11
|
*
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
10
|
-
*
|
|
12
|
+
* The gateway sends through the same transports, never to a provider itself:
|
|
13
|
+
* a reply it composes for a message it answered at ingress goes to the daemon
|
|
14
|
+
* as a {@link GatewayReplyRequest}.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import type { KnownBlock } from "@slack/types";
|
|
@@ -279,6 +283,33 @@ export const ChannelReplyPayloadSchema = z.object({
|
|
|
279
283
|
|
|
280
284
|
export type ChannelReplyPayload = z.infer<typeof ChannelReplyPayloadSchema>;
|
|
281
285
|
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Gateway reply: gateway to daemon
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Daemon IPC method that delivers a {@link GatewayReplyRequest}. IPC-only:
|
|
292
|
+
* it has no HTTP route.
|
|
293
|
+
*/
|
|
294
|
+
export const DELIVER_GATEWAY_REPLY_IPC_METHOD = "deliver_gateway_reply";
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A text reply the gateway composed for an inbound message it answered
|
|
298
|
+
* itself (a verification code, an invite redemption), which the daemon
|
|
299
|
+
* delivers through the channel transport `callbackUrl` names. The callback
|
|
300
|
+
* URL is the one the inbound message carried, so the reply lands in the chat
|
|
301
|
+
* and thread the person wrote from.
|
|
302
|
+
*/
|
|
303
|
+
export const GatewayReplyRequestSchema = ChannelReplyPayloadSchema.pick({
|
|
304
|
+
chatId: true,
|
|
305
|
+
assistantId: true,
|
|
306
|
+
}).extend({
|
|
307
|
+
callbackUrl: z.string().min(1),
|
|
308
|
+
text: z.string().min(1),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
export type GatewayReplyRequest = z.infer<typeof GatewayReplyRequestSchema>;
|
|
312
|
+
|
|
282
313
|
// ---------------------------------------------------------------------------
|
|
283
314
|
// Channel delivery result — gateway response
|
|
284
315
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|
|
@@ -9,10 +9,8 @@ import { dirname, join } from "node:path";
|
|
|
9
9
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
10
10
|
|
|
11
11
|
import type { AssistantEventEnvelope } from "../api/index.js";
|
|
12
|
-
import type {
|
|
13
|
-
|
|
14
|
-
ReplaySubscriber,
|
|
15
|
-
} from "../runtime/assistant-stream-state.js";
|
|
12
|
+
import type { AssistantEventPublishOptions } from "../runtime/assistant-event-publish-options.js";
|
|
13
|
+
import type { SubscriberIdentity } from "../runtime/assistant-event-targeting.js";
|
|
16
14
|
import {
|
|
17
15
|
_peekStreamForTesting,
|
|
18
16
|
_resetStreamStateForTesting,
|
|
@@ -103,7 +101,7 @@ describe("assistant-stream-state", () => {
|
|
|
103
101
|
/** Targeted events stay in the ring so replay can filter them. */
|
|
104
102
|
|
|
105
103
|
// GIVEN a targeting modifier
|
|
106
|
-
const targeting:
|
|
104
|
+
const targeting: AssistantEventPublishOptions = {
|
|
107
105
|
targetCapability: "host_bash",
|
|
108
106
|
};
|
|
109
107
|
|
|
@@ -334,28 +332,28 @@ describe("assistant-stream-state", () => {
|
|
|
334
332
|
});
|
|
335
333
|
|
|
336
334
|
describe("getReplayWindow — targeting filter", () => {
|
|
337
|
-
const MACOS_CLIENT:
|
|
335
|
+
const MACOS_CLIENT: SubscriberIdentity = {
|
|
338
336
|
type: "client",
|
|
339
337
|
clientId: "mac-1",
|
|
340
338
|
interfaceId: "macos",
|
|
341
339
|
capabilities: ["host_bash", "host_file", "host_cu", "host_browser"],
|
|
342
340
|
};
|
|
343
341
|
|
|
344
|
-
const WEB_CLIENT:
|
|
342
|
+
const WEB_CLIENT: SubscriberIdentity = {
|
|
345
343
|
type: "client",
|
|
346
344
|
clientId: "web-1",
|
|
347
345
|
interfaceId: "web",
|
|
348
346
|
capabilities: [],
|
|
349
347
|
};
|
|
350
348
|
|
|
351
|
-
const CHROME_EXT_CLIENT:
|
|
349
|
+
const CHROME_EXT_CLIENT: SubscriberIdentity = {
|
|
352
350
|
type: "client",
|
|
353
351
|
clientId: "ext-1",
|
|
354
352
|
interfaceId: "chrome-extension",
|
|
355
353
|
capabilities: ["host_browser"],
|
|
356
354
|
};
|
|
357
355
|
|
|
358
|
-
const PROCESS_SUB:
|
|
356
|
+
const PROCESS_SUB: SubscriberIdentity = { type: "process" };
|
|
359
357
|
|
|
360
358
|
test("untargeted events are replayed to all subscriber types", () => {
|
|
361
359
|
/** Events without targeting metadata go to everyone. */
|
|
@@ -98,6 +98,7 @@ import {
|
|
|
98
98
|
clearConversations,
|
|
99
99
|
setConversation,
|
|
100
100
|
} from "../daemon/conversation-registry.js";
|
|
101
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
101
102
|
import { getDb } from "../persistence/db-connection.js";
|
|
102
103
|
import { initializeDb } from "../persistence/db-init.js";
|
|
103
104
|
import * as deliveryCrud from "../persistence/delivery-crud.js";
|
|
@@ -318,7 +319,11 @@ describe("channel-retry-sweep", () => {
|
|
|
318
319
|
}
|
|
319
320
|
| undefined;
|
|
320
321
|
|
|
322
|
+
let capturedAuthor: TrustContext | undefined;
|
|
323
|
+
let capturedTrust: TrustContext | undefined;
|
|
321
324
|
await sweepFailedEvents(async (conversationId, _content, options) => {
|
|
325
|
+
capturedAuthor = options?.author;
|
|
326
|
+
capturedTrust = options?.trustContext;
|
|
322
327
|
capturedOptions = options as {
|
|
323
328
|
trustContext?: {
|
|
324
329
|
trustClass?: string;
|
|
@@ -345,6 +350,10 @@ describe("channel-retry-sweep", () => {
|
|
|
345
350
|
"principal-1",
|
|
346
351
|
);
|
|
347
352
|
expect(capturedOptions?.isInteractive).toBe(c.expectedInteractive);
|
|
353
|
+
// The replay persists the sender's own message, so it names them as
|
|
354
|
+
// the row's author, exactly as live ingress does.
|
|
355
|
+
expect(capturedAuthor?.trustClass).toBe(c.trustClass);
|
|
356
|
+
expect(capturedAuthor).toEqual(capturedTrust);
|
|
348
357
|
|
|
349
358
|
const db = getDb();
|
|
350
359
|
const row = db
|
|
@@ -11,7 +11,15 @@
|
|
|
11
11
|
* - When `backfillDm` throws, the turn proceeds without a crash and
|
|
12
12
|
* nothing extra is persisted.
|
|
13
13
|
*/
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
afterAll,
|
|
16
|
+
beforeEach,
|
|
17
|
+
describe,
|
|
18
|
+
expect,
|
|
19
|
+
mock,
|
|
20
|
+
spyOn,
|
|
21
|
+
test,
|
|
22
|
+
} from "bun:test";
|
|
15
23
|
|
|
16
24
|
// ---------------------------------------------------------------------------
|
|
17
25
|
// Mocks (must precede module imports under test)
|
|
@@ -68,6 +76,7 @@ mock.module("../messaging/providers/slack/backfill.js", () => ({
|
|
|
68
76
|
backfillThread: () => backfillThreadMock(),
|
|
69
77
|
}));
|
|
70
78
|
|
|
79
|
+
import * as inboundTrustReader from "../calls/inbound-trust-reader.js";
|
|
71
80
|
import {
|
|
72
81
|
loadRawConfig,
|
|
73
82
|
saveRawConfig,
|
|
@@ -87,6 +96,15 @@ import {
|
|
|
87
96
|
|
|
88
97
|
await initializeDb();
|
|
89
98
|
|
|
99
|
+
// Backfilled senders are classified by the gateway verdict; with no gateway
|
|
100
|
+
// in the test process the read reports unreachable unless a test says so.
|
|
101
|
+
// A spy rather than mock.module, so the stub does not leak into other files.
|
|
102
|
+
const readInboundTrustMock = spyOn(inboundTrustReader, "readInboundTrust");
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
readInboundTrustMock.mockRestore();
|
|
106
|
+
});
|
|
107
|
+
|
|
90
108
|
// ---------------------------------------------------------------------------
|
|
91
109
|
// Helpers
|
|
92
110
|
// ---------------------------------------------------------------------------
|
|
@@ -180,6 +198,7 @@ function readPersistedSlackRows(): Array<{
|
|
|
180
198
|
provenanceSourceChannel: string | undefined;
|
|
181
199
|
provenanceGuardianExternalUserId: string | undefined;
|
|
182
200
|
provenanceRequesterIdentifier: string | undefined;
|
|
201
|
+
provenanceContactId: string | undefined;
|
|
183
202
|
sentAt: number | undefined;
|
|
184
203
|
createdAt: number;
|
|
185
204
|
}> {
|
|
@@ -234,6 +253,10 @@ function readPersistedSlackRows(): Array<{
|
|
|
234
253
|
typeof envelope.provenanceRequesterIdentifier === "string"
|
|
235
254
|
? envelope.provenanceRequesterIdentifier
|
|
236
255
|
: undefined,
|
|
256
|
+
provenanceContactId:
|
|
257
|
+
typeof envelope.provenanceContactId === "string"
|
|
258
|
+
? envelope.provenanceContactId
|
|
259
|
+
: undefined,
|
|
237
260
|
sentAt:
|
|
238
261
|
typeof envelope.sentAt === "number" ? envelope.sentAt : undefined,
|
|
239
262
|
createdAt: row.createdAt,
|
|
@@ -279,6 +302,8 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
|
|
|
279
302
|
backfillDmMock.mockReset();
|
|
280
303
|
backfillDmMock.mockImplementation(async () => []);
|
|
281
304
|
backfillThreadMock.mockReset();
|
|
305
|
+
readInboundTrustMock.mockReset();
|
|
306
|
+
readInboundTrustMock.mockImplementation(async () => ({ ok: false }));
|
|
282
307
|
});
|
|
283
308
|
|
|
284
309
|
test("first DM in cold conversation triggers backfill exactly once and persists history", async () => {
|
|
@@ -523,6 +548,63 @@ describe("PR 23 — Slack DM cold-start backfill", () => {
|
|
|
523
548
|
expect(botRow?.provenanceRequesterIdentifier).toBe("B_BOT");
|
|
524
549
|
});
|
|
525
550
|
|
|
551
|
+
test("backfilled rows record the sender's gateway trust class and contact id", async () => {
|
|
552
|
+
readInboundTrustMock.mockImplementation(async (input) =>
|
|
553
|
+
input.actorExternalId === SLACK_DM_USER_ID
|
|
554
|
+
? {
|
|
555
|
+
ok: true,
|
|
556
|
+
verdict: {
|
|
557
|
+
trustClass: "trusted_contact",
|
|
558
|
+
canonicalSenderId: SLACK_DM_USER_ID,
|
|
559
|
+
contactId: "contact-dm-user",
|
|
560
|
+
channelId: "channel-dm-user",
|
|
561
|
+
status: "active",
|
|
562
|
+
policy: "allow",
|
|
563
|
+
},
|
|
564
|
+
admissionPolicy: null,
|
|
565
|
+
}
|
|
566
|
+
: { ok: false },
|
|
567
|
+
);
|
|
568
|
+
backfillDmMock.mockImplementation(async () => [
|
|
569
|
+
makeBackfilledMessage({
|
|
570
|
+
id: "1700000000.000001",
|
|
571
|
+
text: "first from member",
|
|
572
|
+
}),
|
|
573
|
+
makeBackfilledMessage({
|
|
574
|
+
id: "1700000000.000002",
|
|
575
|
+
text: "assistant's own post",
|
|
576
|
+
sender: { id: "U_BOT", name: "assistant-bot" },
|
|
577
|
+
metadata: { isBot: true },
|
|
578
|
+
}),
|
|
579
|
+
makeBackfilledMessage({
|
|
580
|
+
id: "1700000000.000003",
|
|
581
|
+
text: "second from member",
|
|
582
|
+
}),
|
|
583
|
+
]);
|
|
584
|
+
|
|
585
|
+
await handleChannelInbound(
|
|
586
|
+
buildDmRequest("live new DM"),
|
|
587
|
+
noopProcessMessage,
|
|
588
|
+
TEST_BEARER_TOKEN,
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const rows = readPersistedSlackRows();
|
|
592
|
+
const memberRows = rows.filter((r) => r.role === "user");
|
|
593
|
+
expect(memberRows.map((r) => r.content).sort()).toEqual([
|
|
594
|
+
"first from member",
|
|
595
|
+
"second from member",
|
|
596
|
+
]);
|
|
597
|
+
for (const row of memberRows) {
|
|
598
|
+
expect(row.provenanceTrustClass).toBe("trusted_contact");
|
|
599
|
+
expect(row.provenanceContactId).toBe("contact-dm-user");
|
|
600
|
+
}
|
|
601
|
+
const ownPost = rows.find((r) => r.role === "assistant");
|
|
602
|
+
expect(ownPost?.provenanceTrustClass).toBe("unknown");
|
|
603
|
+
expect(ownPost?.provenanceContactId).toBeUndefined();
|
|
604
|
+
// One read per distinct sender, the member's rows sharing theirs.
|
|
605
|
+
expect(readInboundTrustMock).toHaveBeenCalledTimes(2);
|
|
606
|
+
});
|
|
607
|
+
|
|
526
608
|
test("skips Slack assistant new-thread placeholder during DM backfill", async () => {
|
|
527
609
|
backfillDmMock.mockImplementation(async () => [
|
|
528
610
|
makeBackfilledMessage({
|
|
@@ -662,7 +662,7 @@ describe("high/critical urgency channel force", () => {
|
|
|
662
662
|
sourceChannel: "watcher" | "assistant_tool",
|
|
663
663
|
) {
|
|
664
664
|
return emitNotificationSignal({
|
|
665
|
-
sourceEventName: "watcher.
|
|
665
|
+
sourceEventName: "watcher.notification",
|
|
666
666
|
sourceChannel,
|
|
667
667
|
sourceContextId: "watch-1",
|
|
668
668
|
routingIntent: "single_channel",
|
|
@@ -276,6 +276,11 @@ export function createOutboundSessionGuarded(
|
|
|
276
276
|
}
|
|
277
277
|
source.status = "revoked";
|
|
278
278
|
source.updatedAt = Date.now();
|
|
279
|
+
// Like the gateway, the replacement keeps the claimed session's purpose.
|
|
280
|
+
return createOutboundSession({
|
|
281
|
+
...createParams,
|
|
282
|
+
verificationPurpose: source.verificationPurpose,
|
|
283
|
+
});
|
|
279
284
|
}
|
|
280
285
|
|
|
281
286
|
return createOutboundSession(createParams);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { actorAuthorProvenance } from "../daemon/message-provenance.js";
|
|
4
|
+
import type { TrustContext } from "../daemon/trust-context-types.js";
|
|
5
|
+
import {
|
|
6
|
+
addMessage,
|
|
7
|
+
createConversation,
|
|
8
|
+
getMessageById,
|
|
9
|
+
parseMessageMetadata,
|
|
10
|
+
provenanceFromTrustContext,
|
|
11
|
+
} from "../persistence/conversation-crud.js";
|
|
12
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
13
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
14
|
+
|
|
15
|
+
await initializeDb();
|
|
16
|
+
|
|
17
|
+
const ALICE: TrustContext = {
|
|
18
|
+
sourceChannel: "slack",
|
|
19
|
+
trustClass: "trusted_contact",
|
|
20
|
+
requesterIdentifier: "@alice",
|
|
21
|
+
requesterContactId: "contact-alice",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function persistWith(
|
|
25
|
+
role: "user" | "assistant",
|
|
26
|
+
metadata: Record<string, unknown>,
|
|
27
|
+
): Promise<ReturnType<typeof parseMessageMetadata>> {
|
|
28
|
+
const conversation = createConversation("provenance-contact-id");
|
|
29
|
+
const row = await addMessage(conversation.id, role, "hello", { metadata });
|
|
30
|
+
const stored = getMessageById(row.id, conversation.id);
|
|
31
|
+
return parseMessageMetadata(stored?.metadata ?? null);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("message provenance contact id", () => {
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
const db = getDb();
|
|
37
|
+
db.run(`DELETE FROM messages`);
|
|
38
|
+
db.run(`DELETE FROM conversations`);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a person's own row names them as its author", async () => {
|
|
42
|
+
const metadata = await persistWith("user", {
|
|
43
|
+
...provenanceFromTrustContext(ALICE),
|
|
44
|
+
...actorAuthorProvenance(ALICE),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(metadata?.provenanceTrustClass).toBe("trusted_contact");
|
|
48
|
+
expect(metadata?.provenanceContactId).toBe("contact-alice");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a row written during their turn carries the turn's trust but no author", async () => {
|
|
52
|
+
// The assistant's replies, tool results, and notices are stamped with the
|
|
53
|
+
// turn's provenance alone.
|
|
54
|
+
const metadata = await persistWith(
|
|
55
|
+
"assistant",
|
|
56
|
+
provenanceFromTrustContext(ALICE),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect(metadata?.provenanceTrustClass).toBe("trusted_contact");
|
|
60
|
+
expect(metadata?.provenanceContactId).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("an actor with no resolved contact names no author", () => {
|
|
64
|
+
expect(
|
|
65
|
+
actorAuthorProvenance({ sourceChannel: "slack", trustClass: "unknown" }),
|
|
66
|
+
).toEqual({});
|
|
67
|
+
expect(actorAuthorProvenance(undefined)).toEqual({});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -183,7 +183,7 @@ describe("TelegramAdapter", () => {
|
|
|
183
183
|
|
|
184
184
|
await adapter.send(
|
|
185
185
|
makePayload({
|
|
186
|
-
sourceEventName: "watcher.
|
|
186
|
+
sourceEventName: "watcher.notification",
|
|
187
187
|
copy: {
|
|
188
188
|
title: " ",
|
|
189
189
|
body: "",
|
|
@@ -191,7 +191,7 @@ describe("TelegramAdapter", () => {
|
|
|
191
191
|
}),
|
|
192
192
|
makeDestination(),
|
|
193
193
|
);
|
|
194
|
-
expect(sendCalls[2]?.text).toBe("watcher
|
|
194
|
+
expect(sendCalls[2]?.text).toBe("watcher notification");
|
|
195
195
|
});
|
|
196
196
|
|
|
197
197
|
// ── Access request inline keyboard tests ──────────────────────────────
|