@vellumai/assistant 0.12.2-dev.202609182015.188de8a → 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.
Files changed (38) hide show
  1. package/ARCHITECTURE.md +8 -6
  2. package/docs/architecture/turn-actor.md +2 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/assistant-event-targeting.test.ts +186 -0
  5. package/src/__tests__/assistant-stream-state.test.ts +7 -9
  6. package/src/__tests__/channel-retry-sweep.test.ts +9 -0
  7. package/src/__tests__/dm-backfill.test.ts +83 -1
  8. package/src/__tests__/helpers/verification-sessions-ipc-sim.ts +5 -0
  9. package/src/__tests__/message-provenance-contact-id.test.ts +69 -0
  10. package/src/__tests__/process-message-display-content.test.ts +56 -0
  11. package/src/__tests__/reaction-persistence.test.ts +5 -0
  12. package/src/__tests__/runtime-events-sse-reconnect.test.ts +1 -1
  13. package/src/__tests__/scripted-turn-metadata-persistence.test.ts +73 -0
  14. package/src/__tests__/thread-backfill.test.ts +53 -0
  15. package/src/approvals/AGENTS.md +1 -1
  16. package/src/cli/commands/roadmap.help.ts +2 -3
  17. package/src/daemon/conversation-messaging.ts +19 -1
  18. package/src/daemon/message-provenance.ts +23 -0
  19. package/src/daemon/process-message.ts +13 -4
  20. package/src/persistence/conversation-crud.ts +8 -0
  21. package/src/plugins/defaults/memory/__tests__/archive-removal-framing.test.ts +64 -0
  22. package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +2 -2
  23. package/src/plugins/defaults/memory/tools.test.ts +24 -1
  24. package/src/plugins/defaults/memory/tools.ts +12 -1
  25. package/src/runtime/assistant-event-hub.ts +16 -86
  26. package/src/runtime/assistant-event-targeting.ts +79 -0
  27. package/src/runtime/assistant-stream-state.ts +13 -112
  28. package/src/runtime/channel-retry-sweep.ts +1 -0
  29. package/src/runtime/http-types.ts +6 -0
  30. package/src/runtime/routes/events-routes.ts +2 -2
  31. package/src/runtime/routes/inbound-message-handler.ts +35 -38
  32. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.test.ts +165 -0
  33. package/src/runtime/routes/inbound-stages/backfill-sender-provenance.ts +92 -0
  34. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +29 -0
  35. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -0
  36. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +2 -0
  37. package/src/util/canonicalize-identity.test.ts +21 -0
  38. package/src/util/canonicalize-identity.ts +16 -0
package/ARCHITECTURE.md CHANGED
@@ -1930,12 +1930,14 @@ Keep-alive heartbeats (every 30 s by default):
1930
1930
 
1931
1931
  ### Key Source Files
1932
1932
 
1933
- | File | Role |
1934
- | ----------------------------------------------- | ------------------------------------------------------------------------------ |
1935
- | `assistant/src/runtime/assistant-event.ts` | `AssistantEvent` type, `buildAssistantEvent()` factory, SSE framing helpers |
1936
- | `assistant/src/runtime/assistant-event-hub.ts` | `AssistantEventHub` class and process-level singleton |
1937
- | `assistant/src/runtime/routes/events-routes.ts` | `handleSubscribeAssistantEvents()` SSE route handler |
1938
- | `assistant/src/daemon/server.ts` | Session event paths that publish to the hub (`send` `publishAssistantEvent`) |
1933
+ | File | Role |
1934
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
1935
+ | `assistant/src/runtime/assistant-event.ts` | `AssistantEvent` type, `buildAssistantEvent()` factory, SSE framing helpers |
1936
+ | `assistant/src/runtime/assistant-event-hub.ts` | `AssistantEventHub` class and process-level singleton |
1937
+ | `assistant/src/runtime/assistant-event-targeting.ts` | `matchesTargeting()`, the targeting check live fanout and replay share |
1938
+ | `assistant/src/runtime/assistant-stream-state.ts` | Per-assistant `seq` counter and ring buffer behind reconnect and `events/tail` replay |
1939
+ | `assistant/src/runtime/routes/events-routes.ts` | `handleSubscribeAssistantEvents()`, the SSE route handler |
1940
+ | `assistant/src/daemon/server.ts` | Session event paths that publish to the hub (`send` → `publishAssistantEvent`) |
1939
1941
 
1940
1942
  ---
1941
1943
 
@@ -29,6 +29,8 @@ Do not read `trustContext` or `currentTurnTrustContext` directly. Call sites han
29
29
 
30
30
  **Use `getTurnOrRestingTrust()`** for provenance stamped onto persisted rows (see `provenanceFromTrustContext`). Provenance is read back by the memory indexer, which runs extraction only for guardian rows, and by the transcript assembly, which wraps non-guardian user content before it reaches the model. The turn's actor is correct when a turn is running; rows persisted with no turn in flight (wake notices) must keep the owner's class, because stamping `"unknown"` there silently stops memory extraction for the owner's own flows.
31
31
 
32
+ That provenance describes the turn, so it is stamped on every row the turn writes, the assistant's replies and tool results included. Who wrote a row is a separate fact: `provenanceContactId` names the author's contact and needs positive evidence. It is added only where a person's own inbound words or actions are persisted (`actorAuthorProvenance`): a message channel ingress or its retry replay relays with an explicit `author`, unless that row is scripted or hidden, and the actor's reaction row. The shared user-message writer never infers an author from the conversation's trust, because machine-authored rows (ACP and subagent notifications, pointer turns) persist through it too. Rows the assistant writes during someone's turn never carry it. Slack backfill imports other people's messages, so the turn's actor says nothing about them; each backfilled sender is classified by the same gateway verdict live ingress uses (`resolve_inbound_trust`, see `inbound-stages/backfill-sender-provenance.ts`), which supplies both its trust class and its contact id.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609182015.188de8a",
3
+ "version": "0.12.2-dev.202609182114.335fa56",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- EventTargeting,
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: EventTargeting = {
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: ReplaySubscriber = {
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: ReplaySubscriber = {
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: ReplaySubscriber = {
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: ReplaySubscriber = { type: "process" };
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 { beforeEach, describe, expect, mock, test } from "bun:test";
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({
@@ -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
+ });
@@ -148,6 +148,7 @@ import { persistQueuedMessageBody } from "../daemon/conversation-messaging.js";
148
148
  import type { MessageQueue } from "../daemon/conversation-queue-manager.js";
149
149
  import type { UserMessageAttachment } from "../daemon/message-protocol.js";
150
150
  import { processMessage } from "../daemon/process-message.js";
151
+ import type { TrustContext } from "../daemon/trust-context-types.js";
151
152
  import type { Message } from "../providers/types.js";
152
153
 
153
154
  function makeTestConversation() {
@@ -554,3 +555,58 @@ describe("processMessage skipUserMessageIndexing", () => {
554
555
  expect(addMessageCalls[0]!.skipIndexing).toBeUndefined();
555
556
  });
556
557
  });
558
+
559
+ describe("processMessage author", () => {
560
+ const author: TrustContext = {
561
+ sourceChannel: "slack",
562
+ trustClass: "trusted_contact",
563
+ requesterContactId: "contact-alice",
564
+ };
565
+
566
+ beforeEach(() => {
567
+ addMessageCalls.length = 0;
568
+ activeConversation = undefined;
569
+ resolveSlashForTest = (content) => ({ kind: "passthrough", content });
570
+ });
571
+
572
+ test("a relayed message names its author on the user row", async () => {
573
+ activeConversation = makeTestConversation();
574
+
575
+ await processMessage("conv-display-content", "the export needs a token", {
576
+ author,
577
+ });
578
+
579
+ expect(addMessageCalls[0]!.role).toBe("user");
580
+ expect(addMessageCalls[0]!.metadata?.provenanceContactId).toBe(
581
+ "contact-alice",
582
+ );
583
+ });
584
+
585
+ test("a message nobody relayed names no author", async () => {
586
+ activeConversation = makeTestConversation();
587
+
588
+ await processMessage("conv-display-content", "kickoff prompt body", {});
589
+
590
+ expect(addMessageCalls[0]!.metadata?.provenanceContactId).toBeUndefined();
591
+ });
592
+
593
+ test("a relayed slash command names its author on the user row, not the reply", async () => {
594
+ resolveSlashForTest = () => ({
595
+ kind: "unknown",
596
+ message: "Unknown slash command.",
597
+ });
598
+ activeConversation = makeTestConversation();
599
+
600
+ await processMessage("conv-display-content", "/missing-command", {
601
+ author,
602
+ });
603
+
604
+ expect(addMessageCalls).toHaveLength(2);
605
+ expect(addMessageCalls[0]!.role).toBe("user");
606
+ expect(addMessageCalls[0]!.metadata?.provenanceContactId).toBe(
607
+ "contact-alice",
608
+ );
609
+ expect(addMessageCalls[1]!.role).toBe("assistant");
610
+ expect(addMessageCalls[1]!.metadata?.provenanceContactId).toBeUndefined();
611
+ });
612
+ });
@@ -116,9 +116,12 @@ function resetState(): void {
116
116
  _conversationMocks.clear();
117
117
  }
118
118
 
119
+ const REACTOR_CONTACT_ID = "contact-reactor";
120
+
119
121
  function seedActiveMember(): void {
120
122
  seedContactChannel({
121
123
  sourceChannel: "slack",
124
+ contactId: REACTOR_CONTACT_ID,
122
125
  externalUserId: SLACK_USER_ID,
123
126
  externalChatId: SLACK_CHANNEL_ID,
124
127
  status: "active",
@@ -276,6 +279,8 @@ describe("Slack reaction event persistence", () => {
276
279
  // filterMessagesForUntrustedActor drops rows with no trust class.
277
280
  expect(envelope.provenanceTrustClass).toBe("trusted_contact");
278
281
  expect(envelope.provenanceSourceChannel).toBe("slack");
282
+ // The reactor wrote this row, so it names them as its author.
283
+ expect(envelope.provenanceContactId).toBe(REACTOR_CONTACT_ID);
279
284
  const slackMetaRaw = envelope.slackMeta;
280
285
  expect(typeof slackMetaRaw).toBe("string");
281
286
 
@@ -270,7 +270,7 @@ describe("SSE reconnect replay (B7.2)", () => {
270
270
  test("replay excludes events the subscriber is not targeted to receive", async () => {
271
271
  // Stamp three events: two untargeted (seq 1, 3) + one with a
272
272
  // capability target (seq 2, host_bash). A process subscriber
273
- // does NOT match capability targeting (matchesSubscriber requires
273
+ // does NOT match capability targeting (matchesTargeting requires
274
274
  // type=client + matching capability), so the replay filters out
275
275
  // seq 2. The subscriber receives seq 1 and 3.
276
276
  const { conversationId } = getOrCreateConversation("replay-targeting");