@vellumai/assistant 0.11.3-dev.202608111523.8a0fdc8 → 0.11.3-dev.202608111821.01ecb1d

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.3-dev.202608111523.8a0fdc8",
3
+ "version": "0.11.3-dev.202608111821.01ecb1d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -360,7 +360,6 @@ describe("CallSetupFlow name capture", () => {
360
360
  ).toEqual({ from: PHONE_NUMBER, callerName: CALLER_NAME });
361
361
  expect(f.notifyCalls).toEqual([
362
362
  {
363
- canonicalAssistantId: "self",
364
363
  sourceChannel: "phone",
365
364
  conversationExternalId: PHONE_NUMBER,
366
365
  actorExternalId: PHONE_NUMBER,
@@ -501,7 +501,6 @@ describe("access-request-helper unit tests", () => {
501
501
 
502
502
  test("notifyGuardianOfAccessRequest returns no_sender_id when actorExternalId is absent", async () => {
503
503
  const result = await notifyGuardianOfAccessRequest({
504
- canonicalAssistantId: "self",
505
504
  sourceChannel: "telegram",
506
505
  conversationExternalId: "chat-123",
507
506
  actorExternalId: undefined,
@@ -522,7 +521,6 @@ describe("access-request-helper unit tests", () => {
522
521
 
523
522
  test("notifyGuardianOfAccessRequest creates request with self-healed principal when no binding exists", async () => {
524
523
  const result = await notifyGuardianOfAccessRequest({
525
- canonicalAssistantId: "self",
526
524
  sourceChannel: "telegram",
527
525
  conversationExternalId: "chat-123",
528
526
  actorExternalId: "unknown-user",
@@ -565,7 +563,6 @@ describe("access-request-helper unit tests", () => {
565
563
  });
566
564
 
567
565
  const result = await notifyGuardianOfAccessRequest({
568
- canonicalAssistantId: "self",
569
566
  sourceChannel: "telegram",
570
567
  conversationExternalId: "tg-chat",
571
568
  actorExternalId: "unknown-tg-user",
@@ -619,7 +616,6 @@ describe("access-request-helper unit tests", () => {
619
616
  });
620
617
 
621
618
  const result = await notifyGuardianOfAccessRequest({
622
- canonicalAssistantId: "self",
623
619
  sourceChannel: "telegram",
624
620
  conversationExternalId: "chat-123",
625
621
  actorExternalId: "unknown-user",
@@ -660,7 +656,6 @@ describe("access-request-helper unit tests", () => {
660
656
  });
661
657
 
662
658
  const result = await notifyGuardianOfAccessRequest({
663
- canonicalAssistantId: "self",
664
659
  sourceChannel: "telegram",
665
660
  conversationExternalId: "chat-123",
666
661
  actorExternalId: "unknown-user",
@@ -688,7 +683,6 @@ describe("access-request-helper unit tests", () => {
688
683
  test("notifyGuardianOfAccessRequest resolves the vellum anchor from the gateway delivery", async () => {
689
684
  // Only the vellum anchor (seeded in resetState) is served by the gateway.
690
685
  const result = await notifyGuardianOfAccessRequest({
691
- canonicalAssistantId: "self",
692
686
  sourceChannel: "telegram",
693
687
  conversationExternalId: "chat-123",
694
688
  actorExternalId: "unknown-user",
@@ -722,7 +716,6 @@ describe("access-request-helper unit tests", () => {
722
716
 
723
717
  await expect(
724
718
  notifyGuardianOfAccessRequest({
725
- canonicalAssistantId: "self",
726
719
  sourceChannel: "telegram",
727
720
  conversationExternalId: "chat-123",
728
721
  actorExternalId: "unknown-user",
@@ -739,7 +732,6 @@ describe("access-request-helper unit tests", () => {
739
732
 
740
733
  test("notifyGuardianOfAccessRequest for voice channel includes actorDisplayName in contextPayload", async () => {
741
734
  const result = await notifyGuardianOfAccessRequest({
742
- canonicalAssistantId: "self",
743
735
  sourceChannel: "phone",
744
736
  conversationExternalId: "+15559998888",
745
737
  actorExternalId: "+15559998888",
@@ -770,7 +762,6 @@ describe("access-request-helper unit tests", () => {
770
762
 
771
763
  test("notifyGuardianOfAccessRequest includes requestCode in contextPayload", async () => {
772
764
  const result = await notifyGuardianOfAccessRequest({
773
- canonicalAssistantId: "self",
774
765
  sourceChannel: "telegram",
775
766
  conversationExternalId: "chat-123",
776
767
  actorExternalId: "unknown-user",
@@ -794,7 +785,6 @@ describe("access-request-helper unit tests", () => {
794
785
  // is created and the status is forwarded to the card payload. (A `revoked`
795
786
  // status would instead suppress — covered by the keep-out tests.)
796
787
  const result = await notifyGuardianOfAccessRequest({
797
- canonicalAssistantId: "self",
798
788
  sourceChannel: "telegram",
799
789
  conversationExternalId: "chat-123",
800
790
  actorExternalId: "pending-user",
@@ -834,7 +824,6 @@ describe("access-request-helper unit tests", () => {
834
824
  };
835
825
 
836
826
  const result = await notifyGuardianOfAccessRequest({
837
- canonicalAssistantId: "self",
838
827
  sourceChannel: "phone",
839
828
  conversationExternalId: "+15556667777",
840
829
  actorExternalId: "+15556667777",
@@ -897,7 +886,6 @@ describe("access-request-helper unit tests", () => {
897
886
  };
898
887
 
899
888
  const result = await notifyGuardianOfAccessRequest({
900
- canonicalAssistantId: "self",
901
889
  sourceChannel: "telegram",
902
890
  conversationExternalId: "chat-123",
903
891
  actorExternalId: "unknown-user",
@@ -929,7 +917,6 @@ describe("access-request-helper unit tests", () => {
929
917
  // request row. A revoked/blocked contact is deliberately kept out — no new
930
918
  // prompt, signal, or pending request.
931
919
  const result = await notifyGuardianOfAccessRequest({
932
- canonicalAssistantId: "self",
933
920
  sourceChannel: "telegram",
934
921
  conversationExternalId: "chat-kept-out",
935
922
  actorExternalId: "revoked-user",
@@ -969,7 +956,6 @@ describe("access-request-helper unit tests", () => {
969
956
  });
970
957
 
971
958
  const result = await notifyGuardianOfAccessRequest({
972
- canonicalAssistantId: "self",
973
959
  sourceChannel: "telegram",
974
960
  conversationExternalId: "chat-parked",
975
961
  actorExternalId: "parked-user",
@@ -1006,7 +992,6 @@ describe("access-request-helper unit tests", () => {
1006
992
  });
1007
993
 
1008
994
  const result = await notifyGuardianOfAccessRequest({
1009
- canonicalAssistantId: "self",
1010
995
  sourceChannel: "telegram",
1011
996
  conversationExternalId: "chat-approved",
1012
997
  actorExternalId: "approved-user",
@@ -1047,7 +1032,6 @@ describe("access-request-helper unit tests", () => {
1047
1032
  bridgeState.requests.get(requestId)!.updatedAt = staleUpdatedAt;
1048
1033
 
1049
1034
  const result = await notifyGuardianOfAccessRequest({
1050
- canonicalAssistantId: "self",
1051
1035
  sourceChannel: "telegram",
1052
1036
  conversationExternalId: "chat-stale",
1053
1037
  actorExternalId: "stale-user",
@@ -1082,7 +1066,6 @@ describe("access-request-helper unit tests", () => {
1082
1066
  });
1083
1067
 
1084
1068
  const result = await notifyGuardianOfAccessRequest({
1085
- canonicalAssistantId: "self",
1086
1069
  sourceChannel: "telegram",
1087
1070
  conversationExternalId: "chat-flip",
1088
1071
  actorExternalId: "flip-user",
@@ -1101,7 +1084,6 @@ describe("access-request-helper unit tests", () => {
1101
1084
  // Suppression is scoped to the sender via their own contact status, so a
1102
1085
  // kept-out contact never bleeds into a different sender's prompt.
1103
1086
  const keptOut = await notifyGuardianOfAccessRequest({
1104
- canonicalAssistantId: "self",
1105
1087
  sourceChannel: "telegram",
1106
1088
  conversationExternalId: "chat-kept",
1107
1089
  actorExternalId: "revoked-user",
@@ -1112,7 +1094,6 @@ describe("access-request-helper unit tests", () => {
1112
1094
 
1113
1095
  // A different sender with no keep-out still gets a fresh prompt.
1114
1096
  const result = await notifyGuardianOfAccessRequest({
1115
- canonicalAssistantId: "self",
1116
1097
  sourceChannel: "telegram",
1117
1098
  conversationExternalId: "chat-fresh",
1118
1099
  actorExternalId: "fresh-user",
@@ -1141,7 +1122,6 @@ describe("maybeNotifyGuardianOfAdmittedContact", () => {
1141
1122
  });
1142
1123
 
1143
1124
  const baseParams = {
1144
- canonicalAssistantId: "self",
1145
1125
  sourceChannel: "telegram" as const,
1146
1126
  conversationExternalId: "chat-123",
1147
1127
  actorExternalId: "user-unknown-456",
@@ -1,9 +1,32 @@
1
- import { beforeEach, describe, expect, mock, test } from "bun:test";
2
-
1
+ /**
2
+ * `runConversationTurn` against the real conversation store, the real agent
3
+ * loop and the real database. The only thing stood in for is the provider's
4
+ * HTTP boundary — `resolveProviderFromConnection` hands back a scripted mock
5
+ * — so everything these tests read back (the `conversations` row, the user
6
+ * message and the metadata stamped on it, the channel binding) is what
7
+ * production wrote, not a stub mirroring what production is believed to do.
8
+ */
9
+ import {
10
+ afterEach,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ mock,
15
+ spyOn,
16
+ test,
17
+ } from "bun:test";
18
+
19
+ import type { AssistantEvent } from "../api/index.js";
20
+ import { clearAllActiveConversations } from "../daemon/conversation-store.js";
21
+ import {
22
+ conversationMetadataSyncTag,
23
+ SYNC_TAGS,
24
+ } from "../daemon/message-types/sync.js";
3
25
  import {
4
26
  createConversation,
5
- ensureConversationExists,
6
27
  getConversation,
28
+ getMessages,
29
+ parseMessageMetadata,
7
30
  } from "../persistence/conversation-crud.js";
8
31
  import { getConversationByKey } from "../persistence/conversation-key-store.js";
9
32
  import {
@@ -14,97 +37,142 @@ import { getDb } from "../persistence/db-connection.js";
14
37
  import { initializeDb } from "../persistence/db-init.js";
15
38
  import { buildScopedConversationKey } from "../persistence/delivery-crud.js";
16
39
  import { getBindingByConversation } from "../persistence/external-conversation-store.js";
40
+ import { createConnection } from "../providers/inference/connections.js";
41
+ import * as providerRegistry from "../providers/registry.js";
42
+ import type { Provider, ProviderResponse } from "../providers/types.js";
43
+ import { createMockProvider, textResponse } from "./helpers/mock-provider.js";
44
+ import { setConfig } from "./helpers/set-config.js";
45
+ import { waitFor } from "./helpers/wait-for.js";
17
46
 
18
47
  await initializeDb();
19
48
 
20
- // Capture the list-invalidation calls `runConversationTurn` fires when it
21
- // creates a brand-new conversation row, without pulling in the real sync
22
- // publisher (which reaches for live SSE subscribers).
23
- const listChangedCalls: Array<{ kind: string; conversationId: string }> = [];
24
- mock.module("../runtime/sync/resource-sync-events.js", () => ({
25
- publishConversationListAndMetadataChanged: (
26
- kind: string,
27
- conversationId: string,
28
- ) => {
29
- listChangedCalls.push({ kind, conversationId });
30
- },
31
- }));
32
-
33
- // Stub the heavy machinery: the in-memory conversation build (provider wiring,
34
- // system prompt, history hydration) and the SSE event fan-out. The agent turn
35
- // itself is a no-op — this test only asserts that the `conversations` row is
36
- // persisted before the turn runs. The real `getOrCreateConversation` now
37
- // creates the DB row before hydrating, so the mock mirrors that by calling
38
- // `ensureConversationExists`. The persistence module is intentionally NOT
39
- // mocked so the real `ensureConversationExists` runs against the real DB.
40
- let lastProcessMessageConversationId: string | undefined;
41
- let lastProcessMessageOptions: Record<string, unknown> | undefined;
42
- let lastEnqueueOptions: Record<string, unknown> | undefined;
43
- let lastTurnChannelContext: unknown;
44
- let conversationIsProcessing = false;
45
- mock.module("../daemon/conversation-store.js", () => ({
46
- getOrCreateConversation: async (
47
- conversationId: string,
48
- options?: { conversationType?: string },
49
- ) => {
50
- if (!getConversation(conversationId)) {
51
- if (options?.conversationType) {
52
- createConversation({
53
- id: conversationId,
54
- conversationType: options.conversationType as
55
- | "standard"
56
- | "background",
57
- });
58
- } else {
59
- ensureConversationExists(conversationId, "vellum");
60
- }
61
- }
62
- return {
63
- abortController: undefined,
64
- setTurnChannelContext: (ctx: unknown) => {
65
- lastTurnChannelContext = ctx;
66
- },
67
- isProcessing: () => conversationIsProcessing,
68
- async processMessage(processOptions: Record<string, unknown>) {
69
- // The row must already exist by the time the turn persists its user
70
- // message — record the id so the FK precondition can be asserted.
71
- lastProcessMessageConversationId = conversationId;
72
- lastProcessMessageOptions = processOptions;
73
- return "user-message-id";
74
- },
75
- enqueueMessage: (enqueueOptions: Record<string, unknown>) => {
76
- lastEnqueueOptions = enqueueOptions;
77
- return { rejected: false };
78
- },
79
- };
49
+ // A conversation is built against the default provider, which resolves from
50
+ // config through a connection row. Both are seeded for real so the store takes
51
+ // the same path it takes in the daemon; only the provider the connection
52
+ // resolves to is scripted.
53
+ setConfig("llm", {
54
+ profiles: {
55
+ test: {
56
+ provider: "anthropic",
57
+ provider_connection: "test-conn",
58
+ model: "claude-opus-4-6",
59
+ },
80
60
  },
81
- }));
61
+ activeProfile: "test",
62
+ });
63
+ setConfig("memory", { enabled: false, v2: { enabled: false } });
64
+
65
+ createConnection(getDb(), {
66
+ name: "test-conn",
67
+ provider: "anthropic",
68
+ auth: { type: "api_key", credential: "credential/test/api_key" },
69
+ });
82
70
 
83
- mock.module("../runtime/assistant-event-hub.js", () => ({
84
- broadcastMessage: () => {},
85
- }));
71
+ // The SSE fan-out is the one collaborator with nowhere to write in a test —
72
+ // every publish the turn makes (including the list invalidations asserted
73
+ // below, which reach clients through the real `resource-sync-events`) funnels
74
+ // through here.
75
+ const broadcasts: AssistantEvent[] = [];
76
+ mock.module(
77
+ "../runtime/assistant-event-hub.js",
78
+ (): Partial<typeof import("../runtime/assistant-event-hub.js")> => ({
79
+ broadcastMessage: (msg: AssistantEvent) => {
80
+ broadcasts.push(msg);
81
+ },
82
+ }),
83
+ );
84
+
85
+ const { provider: scriptedProvider } = createMockProvider([
86
+ textResponse("scripted reply"),
87
+ ]);
88
+
89
+ let gate: Promise<void> | undefined;
90
+ let releaseGate: (() => void) | undefined;
86
91
 
87
- mock.module("../providers/media-resolve.js", () => ({
88
- resolveMediaSourceData: () => null,
89
- }));
92
+ /**
93
+ * Park the next turn inside the provider call until released, so a second turn
94
+ * arrives while the conversation is genuinely processing rather than while a
95
+ * stub reports that it is.
96
+ */
97
+ function holdTheTurnOpen(): void {
98
+ let resolveGate!: () => void;
99
+ gate = new Promise<void>((resolve) => {
100
+ resolveGate = resolve;
101
+ });
102
+ releaseGate = () => {
103
+ gate = undefined;
104
+ releaseGate = undefined;
105
+ resolveGate();
106
+ };
107
+ }
108
+
109
+ // A test that fails before releasing must not leave the next one parked on a
110
+ // gate it never opened.
111
+ afterEach(() => releaseGate?.());
112
+
113
+ const provider: Provider = {
114
+ name: "scripted",
115
+ async sendMessage(messages, options): Promise<ProviderResponse> {
116
+ await gate;
117
+ return scriptedProvider.sendMessage(messages, options);
118
+ },
119
+ };
120
+
121
+ spyOn(providerRegistry, "resolveProviderFromConnection").mockResolvedValue(
122
+ provider,
123
+ );
90
124
 
91
- // Import under test AFTER the mocks are registered so its dynamic imports
92
- // resolve to the stubs above.
93
125
  const { runConversationTurn } =
94
126
  await import("../plugin-api/conversation-turn.js");
95
127
 
128
+ /**
129
+ * The list-level invalidations a turn published, as the tag sets clients
130
+ * consume. Only a shape-changing reason carries the list umbrella tag, so a
131
+ * reason that merely edits an existing row (a generated title) is excluded —
132
+ * these are the announcements that a conversation appeared or vanished.
133
+ */
134
+ function listInvalidations(): string[][] {
135
+ return broadcasts
136
+ .filter((msg) => msg.type === "sync_changed")
137
+ .map((msg) => msg.tags)
138
+ .filter((tags) => tags.includes(SYNC_TAGS.conversationsList));
139
+ }
140
+
141
+ /** The tag set `publishConversationListAndMetadataChanged` emits for a new row. */
142
+ function creationTags(conversationId: string): string[] {
143
+ return [
144
+ SYNC_TAGS.conversationsList,
145
+ conversationMetadataSyncTag(conversationId),
146
+ ];
147
+ }
148
+
149
+ /** User message rows of a conversation, oldest first. */
150
+ function userRows(conversationId: string) {
151
+ return getMessages(conversationId).filter((row) => row.role === "user");
152
+ }
153
+
154
+ /** The metadata a turn persisted on a message row, parsed as consumers read it. */
155
+ function metadataOf(
156
+ row: { metadata: string | null } | undefined,
157
+ ): Record<string, unknown> | undefined {
158
+ return parseMessageMetadata(row?.metadata ?? null);
159
+ }
160
+
161
+ function resetDb(): void {
162
+ // In-memory conversations outlive the rows they were hydrated from, and a
163
+ // reused id would otherwise hand the next test a Conversation whose
164
+ // `conversations` row this wipe just deleted.
165
+ clearAllActiveConversations();
166
+ const db = getDb();
167
+ db.run("DELETE FROM messages");
168
+ db.run("DELETE FROM external_conversation_bindings");
169
+ db.run("DELETE FROM conversation_keys");
170
+ db.run("DELETE FROM conversations");
171
+ broadcasts.length = 0;
172
+ }
173
+
96
174
  describe("runConversationTurn persistence", () => {
97
- beforeEach(() => {
98
- const db = getDb();
99
- db.run("DELETE FROM messages");
100
- db.run("DELETE FROM conversations");
101
- listChangedCalls.length = 0;
102
- lastProcessMessageConversationId = undefined;
103
- lastProcessMessageOptions = undefined;
104
- lastEnqueueOptions = undefined;
105
- lastTurnChannelContext = undefined;
106
- conversationIsProcessing = false;
107
- });
175
+ beforeEach(resetDb);
108
176
 
109
177
  test("persists a conversations row for a freshly-minted conversation", async () => {
110
178
  const result = await runConversationTurn({
@@ -112,16 +180,20 @@ describe("runConversationTurn persistence", () => {
112
180
  });
113
181
 
114
182
  // The row exists on disk — not just as an in-memory Conversation object —
115
- // so the user-message persist inside the turn has its FK target.
116
- const row = getConversation(result.conversationId);
117
- expect(row?.id).toBe(result.conversationId);
118
- expect(lastProcessMessageConversationId).toBe(result.conversationId);
183
+ // and the user message the turn persisted found its FK target there.
184
+ expect(getConversation(result.conversationId)?.id).toBe(
185
+ result.conversationId,
186
+ );
187
+ expect(userRows(result.conversationId).map((row) => row.id)).toEqual([
188
+ result.userMessageId,
189
+ ]);
119
190
 
120
191
  // Siblings/sidebars are told about the new conversation, mirroring the
121
192
  // send-message route.
122
- expect(listChangedCalls).toEqual([
123
- { kind: "created", conversationId: result.conversationId },
124
- ]);
193
+ expect(listInvalidations()).toEqual([creationTags(result.conversationId)]);
194
+ expect(
195
+ broadcasts.filter((msg) => msg.type === "conversation_list_invalidated"),
196
+ ).toEqual([{ type: "conversation_list_invalidated", reason: "created" }]);
125
197
  });
126
198
 
127
199
  test("adopts a caller-supplied conversation id verbatim when no row exists", async () => {
@@ -134,12 +206,12 @@ describe("runConversationTurn persistence", () => {
134
206
 
135
207
  expect(result.conversationId).toBe(conversationId);
136
208
  expect(getConversation(conversationId)?.id).toBe(conversationId);
137
- expect(listChangedCalls).toEqual([{ kind: "created", conversationId }]);
209
+ expect(listInvalidations()).toEqual([creationTags(conversationId)]);
138
210
  });
139
211
 
140
212
  test("is a no-op for an already-persisted conversation row", async () => {
141
213
  const existing = createConversation({ title: "already here" });
142
- listChangedCalls.length = 0;
214
+ broadcasts.length = 0;
143
215
 
144
216
  const result = await runConversationTurn({
145
217
  conversationId: existing.id,
@@ -149,7 +221,7 @@ describe("runConversationTurn persistence", () => {
149
221
  expect(result.conversationId).toBe(existing.id);
150
222
  // Row is untouched and no duplicate "created" invalidation fires.
151
223
  expect(getConversation(existing.id)?.title).toBe("already here");
152
- expect(listChangedCalls).toEqual([]);
224
+ expect(listInvalidations()).toEqual([]);
153
225
  });
154
226
  });
155
227
 
@@ -159,28 +231,24 @@ describe("runConversationTurn persistence", () => {
159
231
  // eligibility predicate's verdict on the stamped metadata, so the marker and
160
232
  // the gate that reads it cannot drift apart.
161
233
  describe("runConversationTurn provenance", () => {
162
- beforeEach(() => {
163
- const db = getDb();
164
- db.run("DELETE FROM messages");
165
- db.run("DELETE FROM conversations");
166
- lastProcessMessageOptions = undefined;
167
- lastEnqueueOptions = undefined;
168
- conversationIsProcessing = false;
169
- });
234
+ beforeEach(resetDb);
170
235
 
171
236
  test("stamps the initiating row automated so its reply raises no push", async () => {
172
237
  const existing = createConversation({ title: "standard conversation" });
173
238
 
174
- await runConversationTurn({
239
+ const result = await runConversationTurn({
175
240
  conversationId: existing.id,
176
241
  content: [{ type: "text", text: "transcript excerpt" }],
177
242
  });
178
243
 
179
- const metadata = lastProcessMessageOptions?.metadata as Record<
180
- string,
181
- unknown
182
- >;
183
- expect(metadata).toEqual({ automated: true });
244
+ // The scripted reply came back, so the marker below was stamped by a turn
245
+ // that ran rather than by one that fell over into an error row.
246
+ expect(result.content).toMatchObject([
247
+ { type: "text", text: "scripted reply" },
248
+ ]);
249
+
250
+ const metadata = metadataOf(userRows(existing.id)[0]);
251
+ expect(metadata).toMatchObject({ automated: true });
184
252
  expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
185
253
  // Not an echo-suppression marker: the row still renders in the transcript.
186
254
  expect(isEchoSuppressedUserMessage(metadata)).toBe(false);
@@ -188,18 +256,37 @@ describe("runConversationTurn provenance", () => {
188
256
 
189
257
  test("stamps the same marker on a turn queued behind a busy conversation", async () => {
190
258
  const existing = createConversation({ title: "busy conversation" });
191
- conversationIsProcessing = true;
259
+ holdTheTurnOpen();
260
+
261
+ const inFlight = runConversationTurn({
262
+ conversationId: existing.id,
263
+ content: [{ type: "text", text: "first excerpt" }],
264
+ });
265
+ await waitFor(() => userRows(existing.id).length === 1, {
266
+ timeoutMs: 5_000,
267
+ message: "the first turn never reached the provider",
268
+ });
192
269
 
193
270
  const result = await runConversationTurn({
194
271
  conversationId: existing.id,
195
272
  content: [{ type: "text", text: "transcript excerpt" }],
196
273
  });
197
-
198
274
  expect(result.queued).toBe(true);
199
- const metadata = lastEnqueueOptions?.metadata as Record<string, unknown>;
200
- expect(metadata).toEqual({ automated: true });
275
+
276
+ // The queue drains after the in-flight turn releases, and the marker has
277
+ // to survive that trip: it is stamped now but read off the persisted row
278
+ // much later.
279
+ releaseGate?.();
280
+ await inFlight;
281
+ await waitFor(() => userRows(existing.id).length === 2, {
282
+ timeoutMs: 5_000,
283
+ message: "the queued turn never drained",
284
+ });
285
+
286
+ const metadata = metadataOf(userRows(existing.id)[1]);
287
+ expect(metadata).toMatchObject({ automated: true });
201
288
  expect(isReplyPushIneligibleUserMessage(metadata)).toBe(true);
202
- });
289
+ }, 20_000);
203
290
  });
204
291
 
205
292
  /**
@@ -219,18 +306,7 @@ describe("runConversationTurn channel binding", () => {
219
306
  displayName: "Ada",
220
307
  };
221
308
 
222
- beforeEach(() => {
223
- const db = getDb();
224
- db.run("DELETE FROM messages");
225
- db.run("DELETE FROM external_conversation_bindings");
226
- db.run("DELETE FROM conversation_keys");
227
- db.run("DELETE FROM conversations");
228
- listChangedCalls.length = 0;
229
- lastProcessMessageOptions = undefined;
230
- lastEnqueueOptions = undefined;
231
- lastTurnChannelContext = undefined;
232
- conversationIsProcessing = false;
233
- });
309
+ beforeEach(resetDb);
234
310
 
235
311
  test("resolves the chat to the conversation inbound would have used", async () => {
236
312
  const result = await runConversationTurn({
@@ -262,9 +338,7 @@ describe("runConversationTurn channel binding", () => {
262
338
 
263
339
  expect(second.conversationId).toBe(first.conversationId);
264
340
  // Only the first turn minted a conversation.
265
- expect(listChangedCalls).toEqual([
266
- { kind: "created", conversationId: first.conversationId },
267
- ]);
341
+ expect(listInvalidations()).toEqual([creationTags(first.conversationId)]);
268
342
  });
269
343
 
270
344
  test("gives a different chat its own conversation", async () => {
@@ -314,18 +388,20 @@ describe("runConversationTurn channel binding", () => {
314
388
  // Runtime assembly reads the per-turn channel first and only then the
315
389
  // conversation's origin, so a turn into a conversation whose origin was
316
390
  // never recorded would otherwise run as `vellum`. That is the wrong
317
- // channel for the message row and for the permission cascade the tools
391
+ // channel for the message rows and for the permission cascade the tools
318
392
  // are approved against.
319
- await runConversationTurn({
393
+ const result = await runConversationTurn({
320
394
  channel: CHANNEL,
321
395
  content: [{ type: "text", text: "hello" }],
322
396
  });
323
397
 
324
- expect(lastTurnChannelContext).toEqual({
398
+ const rows = getMessages(result.conversationId);
399
+ expect(metadataOf(rows[0])).toMatchObject({
325
400
  userMessageChannel: "plugin",
326
401
  assistantMessageChannel: "plugin",
327
402
  });
328
- expect(lastProcessMessageOptions?.metadata).toMatchObject({
403
+ expect(rows[1].role).toBe("assistant");
404
+ expect(metadataOf(rows[1])).toMatchObject({
329
405
  userMessageChannel: "plugin",
330
406
  assistantMessageChannel: "plugin",
331
407
  });
@@ -333,32 +409,63 @@ describe("runConversationTurn channel binding", () => {
333
409
 
334
410
  test("a queued turn carries its own channel rather than inheriting one", async () => {
335
411
  // The drain happens after this call returns and reads the channel off the
336
- // queued message, falling back to whichever turn was in flight. On a
337
- // conversation shared with another channel that fallback is someone
338
- // else's channel.
339
- conversationIsProcessing = true;
412
+ // queued message, falling back to whichever turn was in flight. Here that
413
+ // fallback would be `vellum` — someone else's channel on a conversation
414
+ // shared with another channel.
415
+ const conversationId = "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d";
416
+ holdTheTurnOpen();
417
+
418
+ const inFlight = runConversationTurn({
419
+ conversationId,
420
+ content: [{ type: "text", text: "native turn" }],
421
+ });
422
+ await waitFor(() => userRows(conversationId).length === 1, {
423
+ timeoutMs: 5_000,
424
+ message: "the first turn never reached the provider",
425
+ });
340
426
 
341
427
  const result = await runConversationTurn({
428
+ conversationId,
342
429
  channel: CHANNEL,
343
430
  content: [{ type: "text", text: "hello" }],
344
431
  });
345
-
346
432
  expect(result.queued).toBe(true);
347
- expect(lastEnqueueOptions?.metadata).toMatchObject({
433
+
434
+ releaseGate?.();
435
+ await inFlight;
436
+ await waitFor(() => userRows(conversationId).length === 2, {
437
+ timeoutMs: 5_000,
438
+ message: "the queued turn never drained",
439
+ });
440
+
441
+ expect(metadataOf(userRows(conversationId)[1])).toMatchObject({
348
442
  userMessageChannel: "plugin",
349
443
  assistantMessageChannel: "plugin",
350
444
  });
351
- });
445
+ }, 20_000);
352
446
 
353
447
  test("leaves the channel unset when the turn names none", async () => {
354
448
  // Clearing rather than leaving whatever the last turn set: a stale
355
449
  // context would report this turn on a channel it has nothing to do with.
356
450
  await runConversationTurn({
451
+ channel: CHANNEL,
357
452
  content: [{ type: "text", text: "hello" }],
358
453
  });
359
454
 
360
- expect(lastTurnChannelContext).toBeNull();
361
- expect(lastProcessMessageOptions?.metadata).toEqual({ automated: true });
455
+ const result = await runConversationTurn({
456
+ content: [{ type: "text", text: "hello" }],
457
+ });
458
+
459
+ const rows = getMessages(result.conversationId);
460
+ const userMetadata = metadataOf(rows[0]);
461
+ expect(userMetadata).toMatchObject({ automated: true });
462
+ expect(userMetadata?.userMessageChannel).toBeUndefined();
463
+ // The conversation carries no origin either, so the turn falls back to the
464
+ // native channel rather than to the previous turn's.
465
+ expect(metadataOf(rows[1])).toMatchObject({
466
+ userMessageChannel: "vellum",
467
+ assistantMessageChannel: "vellum",
468
+ });
362
469
  });
363
470
 
364
471
  test("keeps a known sender when a later turn omits it", async () => {
@@ -62,7 +62,6 @@ function makeParams(
62
62
  sourceChannel: "slack" as const,
63
63
  } as any,
64
64
  replyCallbackUrl: undefined,
65
- canonicalAssistantId: "self",
66
65
  ...overrides,
67
66
  };
68
67
  }
@@ -1393,7 +1393,6 @@ export class CallSetupFlow {
1393
1393
 
1394
1394
  try {
1395
1395
  const accessResult = await ndeps.notifyGuardianOfAccessRequest({
1396
- canonicalAssistantId: accessRequest.assistantId,
1397
1396
  sourceChannel: "phone",
1398
1397
  conversationExternalId: accessRequest.fromNumber,
1399
1398
  actorExternalId: accessRequest.fromNumber,
@@ -29,6 +29,7 @@ import { buildVellumCardAffinity } from "../notifications/vellum-card-affinity.j
29
29
  import { IntegrityError } from "../util/errors.js";
30
30
  import { getLogger } from "../util/logger.js";
31
31
  import { resolveAnchoredGuardian } from "./anchored-guardian.js";
32
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "./assistant-scope.js";
32
33
  import { CHALLENGE_TTL_MS } from "./channel-verification-service.js";
33
34
  import {
34
35
  type AccessRequestTrigger,
@@ -46,7 +47,6 @@ const log = getLogger("access-request-helper");
46
47
  export type { AccessRequestTrigger } from "./introduction-policy.js";
47
48
 
48
49
  export interface AccessRequestParams {
49
- canonicalAssistantId: string;
50
50
  sourceChannel: ChannelId;
51
51
  conversationExternalId: string;
52
52
  actorExternalId?: string;
@@ -93,15 +93,18 @@ export type AccessRequestResult =
93
93
  // ---------------------------------------------------------------------------
94
94
 
95
95
  /**
96
- * Assistant-scoped conversation id for an actor's access requests. Stable key
97
- * that dedupes pending prompts for the same (assistant, channel, actor).
96
+ * Conversation id for an actor's access requests. Stable key that dedupes
97
+ * pending prompts for the same (channel, actor).
98
+ *
99
+ * The `self` segment is part of a persisted id format: guardian request rows
100
+ * carry these ids, so the shape is fixed even though the daemon scopes all its
101
+ * storage to the one assistant (`DAEMON_INTERNAL_ASSISTANT_ID`).
98
102
  */
99
103
  export function accessRequestConversationId(
100
- canonicalAssistantId: string,
101
104
  sourceChannel: string,
102
105
  actorExternalId: string,
103
106
  ): string {
104
- return `access-req-${canonicalAssistantId}-${sourceChannel}-${actorExternalId}`;
107
+ return `access-req-${DAEMON_INTERNAL_ASSISTANT_ID}-${sourceChannel}-${actorExternalId}`;
105
108
  }
106
109
 
107
110
  /**
@@ -126,7 +129,6 @@ export function accessRequestConversationId(
126
129
  * fail-closed.
127
130
  */
128
131
  export async function isApprovalHandshakeInProgress(params: {
129
- canonicalAssistantId: string;
130
132
  sourceChannel: string;
131
133
  actorExternalId: string;
132
134
  }): Promise<boolean> {
@@ -134,7 +136,6 @@ export async function isApprovalHandshakeInProgress(params: {
134
136
  return false;
135
137
  }
136
138
  const conversationId = accessRequestConversationId(
137
- params.canonicalAssistantId,
138
139
  params.sourceChannel,
139
140
  params.actorExternalId,
140
141
  );
@@ -171,7 +172,6 @@ export async function notifyGuardianOfAccessRequest(
171
172
  params: AccessRequestParams,
172
173
  ): Promise<AccessRequestResult> {
173
174
  const {
174
- canonicalAssistantId,
175
175
  sourceChannel,
176
176
  conversationExternalId,
177
177
  actorExternalId,
@@ -219,7 +219,6 @@ export async function notifyGuardianOfAccessRequest(
219
219
  // from assistant A could be returned for assistant B, allowing the caller
220
220
  // to piggyback on A's guardian approval.
221
221
  const conversationId = accessRequestConversationId(
222
- canonicalAssistantId,
223
222
  sourceChannel,
224
223
  actorExternalId,
225
224
  );
@@ -270,7 +269,6 @@ export async function notifyGuardianOfAccessRequest(
270
269
  // re-prompting is allowed again.
271
270
  if (
272
271
  await isApprovalHandshakeInProgress({
273
- canonicalAssistantId,
274
272
  sourceChannel,
275
273
  actorExternalId,
276
274
  })
@@ -283,7 +281,7 @@ export async function notifyGuardianOfAccessRequest(
283
281
  }
284
282
 
285
283
  const senderIdentifier = actorDisplayName || actorUsername || actorExternalId;
286
- const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${actorExternalId}-${Date.now()}`;
284
+ const requestId = `access-req-${DAEMON_INTERNAL_ASSISTANT_ID}-${sourceChannel}-${actorExternalId}-${Date.now()}`;
287
285
 
288
286
  // Access requests are decisionable: without a bound principal nobody could
289
287
  // ever decide them (mirrors the gateway create's integrity guard).
@@ -461,7 +459,6 @@ export async function maybeNotifyGuardianOfAdmittedContact(
461
459
  }
462
460
 
463
461
  const conversationId = accessRequestConversationId(
464
- params.canonicalAssistantId,
465
462
  params.sourceChannel,
466
463
  params.actorExternalId,
467
464
  );
@@ -2,7 +2,6 @@
2
2
  * Shared types, constants, and utilities used across channel route modules.
3
3
  */
4
4
  import type { ChannelId } from "../../channels/types.js";
5
- import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
6
5
  import {
7
6
  type ApprovalAction,
8
7
  type ApprovalDecisionResult,
@@ -10,11 +9,6 @@ import {
10
9
  isApprovalAction,
11
10
  } from "../channel-approval-types.js";
12
11
 
13
- /** Canonicalize assistantId for channel ingress paths. */
14
- export function canonicalChannelAssistantId(_assistantId: string): string {
15
- return DAEMON_INTERNAL_ASSISTANT_ID;
16
- }
17
-
18
12
  // ---------------------------------------------------------------------------
19
13
  // Actor role
20
14
  // ---------------------------------------------------------------------------
@@ -101,7 +101,6 @@ import {
101
101
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
102
102
  import { deliverChannelReply } from "../gateway-client.js";
103
103
  import { trustContextFromVerdict } from "../trust-verdict-consumer.js";
104
- import { canonicalChannelAssistantId } from "./channel-route-shared.js";
105
104
  import { BadRequestError } from "./errors.js";
106
105
  import { handleApprovalInterception } from "./guardian-approval-interception.js";
107
106
  import {
@@ -371,16 +370,6 @@ export async function handleChannelInbound({
371
370
  throw new BadRequestError("content or attachmentIds is required");
372
371
  }
373
372
 
374
- // Canonicalize the assistant ID so all DB-facing operations use the
375
- // consistent 'self' key regardless of what the gateway sent.
376
- const canonicalAssistantId = canonicalChannelAssistantId(assistantId);
377
- if (canonicalAssistantId !== assistantId) {
378
- log.debug(
379
- { raw: assistantId, canonical: canonicalAssistantId },
380
- "Canonicalized channel assistant ID",
381
- );
382
- }
383
-
384
373
  // Coerce actorExternalId to a string at the boundary — the field
385
374
  // comes from unvalidated JSON and may be a number, object, or other
386
375
  // non-string type. Non-string truthy values would throw inside
@@ -438,7 +427,6 @@ export async function handleChannelInbound({
438
427
  sourceInterface,
439
428
  conversationExternalId,
440
429
  externalMessageId,
441
- canonicalAssistantId,
442
430
  rawSenderId,
443
431
  canonicalSenderId,
444
432
  actorDisplayName: body.actorDisplayName,
@@ -486,7 +474,6 @@ export async function handleChannelInbound({
486
474
  rawSenderId,
487
475
  sourceChannel,
488
476
  conversationExternalId,
489
- canonicalAssistantId,
490
477
  trimmedContent,
491
478
  sourceMetadata: body.sourceMetadata,
492
479
  actorDisplayName: body.actorDisplayName,
@@ -839,7 +826,6 @@ export async function handleChannelInbound({
839
826
  if (isCallbackInteraction) {
840
827
  if (floorSenderId) {
841
828
  handshakeInProgress = await isApprovalHandshakeInProgress({
842
- canonicalAssistantId,
843
829
  sourceChannel,
844
830
  actorExternalId: floorSenderId,
845
831
  });
@@ -847,7 +833,6 @@ export async function handleChannelInbound({
847
833
  } else {
848
834
  try {
849
835
  const accessResult = await notifyGuardianOfAccessRequest({
850
- canonicalAssistantId,
851
836
  sourceChannel,
852
837
  conversationExternalId,
853
838
  actorExternalId: floorSenderId,
@@ -920,7 +905,7 @@ export async function handleChannelInbound({
920
905
  const replyPayload: Parameters<typeof deliverChannelReply>[1] = {
921
906
  chatId: conversationExternalId,
922
907
  text: replyText,
923
- assistantId: canonicalAssistantId,
908
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
924
909
  };
925
910
  if (sourceChannel === "slack" && (canonicalSenderId ?? rawSenderId)) {
926
911
  replyPayload.ephemeral = true;
@@ -982,7 +967,7 @@ export async function handleChannelInbound({
982
967
  const replyPayload: Parameters<typeof deliverChannelReply>[1] = {
983
968
  chatId: conversationExternalId,
984
969
  text: DISK_PRESSURE_REMOTE_BLOCK_REPLY,
985
- assistantId: canonicalAssistantId,
970
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
986
971
  };
987
972
  if (sourceChannel === "slack" && (canonicalSenderId ?? rawSenderId)) {
988
973
  replyPayload.ephemeral = true;
@@ -1054,7 +1039,6 @@ export async function handleChannelInbound({
1054
1039
  isDuplicate: result.duplicate,
1055
1040
  commandIntent,
1056
1041
  rawSenderId,
1057
- canonicalAssistantId,
1058
1042
  sourceChannel,
1059
1043
  conversationExternalId,
1060
1044
  eventId: result.eventId,
@@ -1076,7 +1060,6 @@ export async function handleChannelInbound({
1076
1060
  callbackData: body.callbackData,
1077
1061
  rawSenderId,
1078
1062
  canonicalSenderId,
1079
- canonicalAssistantId,
1080
1063
  sourceChannel,
1081
1064
  conversationExternalId,
1082
1065
  conversationId: result.conversationId,
@@ -1118,7 +1101,7 @@ export async function handleChannelInbound({
1118
1101
  actorExternalId: canonicalSenderId ?? rawSenderId,
1119
1102
  replyCallbackUrl,
1120
1103
  trustCtx,
1121
- assistantId: canonicalAssistantId,
1104
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
1122
1105
  approvalCopyGenerator,
1123
1106
  approvalConversationGenerator,
1124
1107
  approvalMessageTs,
@@ -1214,7 +1197,7 @@ export async function handleChannelInbound({
1214
1197
  chatId: conversationExternalId,
1215
1198
  text: "This approval request has been resolved.",
1216
1199
  messageTs: approvalMessageTs,
1217
- assistantId: canonicalAssistantId,
1200
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
1218
1201
  }).catch((err) => {
1219
1202
  log.error(
1220
1203
  { err, conversationId: result.conversationId },
@@ -1250,7 +1233,6 @@ export async function handleChannelInbound({
1250
1233
  actorUsername: body.actorUsername,
1251
1234
  trustCtx,
1252
1235
  replyCallbackUrl,
1253
- canonicalAssistantId,
1254
1236
  });
1255
1237
 
1256
1238
  if (ingressResult.blocked) {
@@ -1291,7 +1273,6 @@ export async function handleChannelInbound({
1291
1273
  const admittedSenderId = canonicalSenderId ?? rawSenderId;
1292
1274
  if (admittedSenderId) {
1293
1275
  void maybeNotifyGuardianOfAdmittedContact({
1294
- canonicalAssistantId,
1295
1276
  sourceChannel,
1296
1277
  conversationExternalId,
1297
1278
  actorExternalId: admittedSenderId,
@@ -1492,7 +1473,7 @@ export async function handleChannelInbound({
1492
1473
  commandIntent,
1493
1474
  sourceLanguageCode,
1494
1475
  replyCallbackUrl,
1495
- assistantId: canonicalAssistantId,
1476
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
1496
1477
  approvalCopyGenerator,
1497
1478
  chatType: sourceChatType,
1498
1479
  clientTimezone: inboundClientTimezone,
@@ -98,7 +98,6 @@ function makeParams(
98
98
  rawSenderId: "sender-1",
99
99
  sourceChannel: "telegram",
100
100
  conversationExternalId: "chat-1",
101
- canonicalAssistantId: "assistant-1",
102
101
  trimmedContent: "hello",
103
102
  sourceMetadata: undefined,
104
103
  actorDisplayName: "Sender One",
@@ -90,7 +90,6 @@ export interface AclEnforcementParams {
90
90
  rawSenderId: string | undefined;
91
91
  sourceChannel: ChannelId;
92
92
  conversationExternalId: string;
93
- canonicalAssistantId: string;
94
93
  trimmedContent: string;
95
94
  sourceMetadata: SourceMetadata | undefined;
96
95
  actorDisplayName: string | undefined;
@@ -190,7 +189,6 @@ export async function enforceIngressAcl(
190
189
  rawSenderId,
191
190
  sourceChannel,
192
191
  conversationExternalId,
193
- canonicalAssistantId,
194
192
  trimmedContent,
195
193
  sourceMetadata,
196
194
  actorDisplayName,
@@ -426,7 +424,6 @@ export async function enforceIngressAcl(
426
424
  // Still notify the guardian about the access attempt
427
425
  try {
428
426
  await notifyGuardianOfAccessRequest({
429
- canonicalAssistantId,
430
427
  sourceChannel,
431
428
  conversationExternalId,
432
429
  actorExternalId: canonicalSenderId ?? rawSenderId,
@@ -509,7 +506,6 @@ export async function enforceIngressAcl(
509
506
  if (emailVerifyResult.initiated) {
510
507
  try {
511
508
  await notifyGuardianOfAccessRequest({
512
- canonicalAssistantId,
513
509
  sourceChannel,
514
510
  conversationExternalId,
515
511
  actorExternalId: canonicalSenderId ?? rawSenderId,
@@ -554,14 +550,12 @@ export async function enforceIngressAcl(
554
550
  let handshakeInProgress = false;
555
551
  if (isCallbackInteraction) {
556
552
  handshakeInProgress = await isApprovalHandshakeInProgress({
557
- canonicalAssistantId,
558
553
  sourceChannel,
559
554
  actorExternalId: (canonicalSenderId ?? rawSenderId)!,
560
555
  });
561
556
  } else {
562
557
  try {
563
558
  const accessResult = await notifyGuardianOfAccessRequest({
564
- canonicalAssistantId,
565
559
  sourceChannel,
566
560
  conversationExternalId,
567
561
  actorExternalId: canonicalSenderId ?? rawSenderId,
@@ -730,7 +724,6 @@ export async function enforceIngressAcl(
730
724
  if (slackVerifyResult.initiated) {
731
725
  try {
732
726
  await notifyGuardianOfAccessRequest({
733
- canonicalAssistantId,
734
727
  sourceChannel,
735
728
  conversationExternalId,
736
729
  actorExternalId: canonicalSenderId ?? rawSenderId,
@@ -803,14 +796,12 @@ export async function enforceIngressAcl(
803
796
  if (!terminallyKeptOut) {
804
797
  if (isCallbackInteraction) {
805
798
  handshakeInProgress = await isApprovalHandshakeInProgress({
806
- canonicalAssistantId,
807
799
  sourceChannel,
808
800
  actorExternalId: (canonicalSenderId ?? rawSenderId)!,
809
801
  });
810
802
  } else {
811
803
  try {
812
804
  const accessResult = await notifyGuardianOfAccessRequest({
813
- canonicalAssistantId,
814
805
  sourceChannel,
815
806
  conversationExternalId,
816
807
  actorExternalId: canonicalSenderId ?? rawSenderId,
@@ -54,7 +54,6 @@ function makeParams(
54
54
  isDuplicate: false,
55
55
  commandIntent: { type: "start", payload: "gv_token123" },
56
56
  rawSenderId: "user-42",
57
- canonicalAssistantId: "self",
58
57
  sourceChannel: "telegram",
59
58
  conversationExternalId: "chat-123",
60
59
  eventId: "event-1",
@@ -24,6 +24,7 @@ import {
24
24
  import type { ChannelId } from "../../../channels/types.js";
25
25
  import { sendTelegramReply } from "../../../messaging/providers/telegram-bot/send.js";
26
26
  import { getLogger } from "../../../util/logger.js";
27
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../assistant-scope.js";
27
28
  import { RESEND_COOLDOWN_MS } from "../../verification-outbound-actions.js";
28
29
  import {
29
30
  composeVerificationText,
@@ -40,7 +41,6 @@ export interface BootstrapInterceptParams {
40
41
  isDuplicate: boolean;
41
42
  commandIntent: Record<string, unknown> | undefined;
42
43
  rawSenderId: string | undefined;
43
- canonicalAssistantId: string;
44
44
  sourceChannel: ChannelId;
45
45
  conversationExternalId: string;
46
46
  eventId: string;
@@ -96,7 +96,6 @@ export async function handleBootstrapIntercept(
96
96
  isDuplicate,
97
97
  commandIntent,
98
98
  rawSenderId,
99
- canonicalAssistantId,
100
99
  sourceChannel,
101
100
  conversationExternalId,
102
101
  eventId,
@@ -208,7 +207,7 @@ export async function handleBootstrapIntercept(
208
207
  deliverBootstrapVerificationTelegram(
209
208
  conversationExternalId,
210
209
  telegramBody,
211
- canonicalAssistantId,
210
+ DAEMON_INTERNAL_ASSISTANT_ID,
212
211
  );
213
212
 
214
213
  // Update delivery tracking (best-effort — the code is already on its way)
@@ -51,7 +51,6 @@ function makeParams(
51
51
  callbackData: undefined,
52
52
  rawSenderId: "user-42",
53
53
  canonicalSenderId: "user-42",
54
- canonicalAssistantId: "self",
55
54
  sourceChannel: "slack" as const,
56
55
  conversationExternalId: "chat-123",
57
56
  conversationId: "conv-abc",
@@ -15,6 +15,7 @@ import {
15
15
  } from "../../../channels/gateway-guardian-requests.js";
16
16
  import type { ChannelId } from "../../../channels/types.js";
17
17
  import { getLogger } from "../../../util/logger.js";
18
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../assistant-scope.js";
18
19
  import { deliverChannelReply } from "../../gateway-client.js";
19
20
  import {
20
21
  type GuardianPendingScope,
@@ -41,7 +42,6 @@ export interface GuardianReplyInterceptParams {
41
42
  reactedMessageTs?: string;
42
43
  rawSenderId: string | undefined;
43
44
  canonicalSenderId: string | null;
44
- canonicalAssistantId: string;
45
45
  sourceChannel: ChannelId;
46
46
  conversationExternalId: string;
47
47
  conversationId: string;
@@ -77,7 +77,6 @@ export async function handleGuardianReplyIntercept(
77
77
  reactedMessageTs,
78
78
  rawSenderId,
79
79
  canonicalSenderId,
80
- canonicalAssistantId,
81
80
  sourceChannel,
82
81
  conversationExternalId,
83
82
  conversationId,
@@ -178,7 +177,7 @@ export async function handleGuardianReplyIntercept(
178
177
  channelDeliveryContext: {
179
178
  replyCallbackUrl,
180
179
  guardianChatId: conversationExternalId,
181
- assistantId: canonicalAssistantId,
180
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
182
181
  },
183
182
  });
184
183
 
@@ -188,7 +187,7 @@ export async function handleGuardianReplyIntercept(
188
187
  const routerReplyPayload: Parameters<typeof deliverChannelReply>[1] = {
189
188
  chatId: conversationExternalId,
190
189
  text: routerResult.replyText,
191
- assistantId: canonicalAssistantId,
190
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
192
191
  };
193
192
  // On Slack, send guardian management replies (disambiguation, pending
194
193
  // request lists, etc.) as ephemeral so only the guardian sees them.
@@ -172,7 +172,6 @@ function buildParams(overrides: {
172
172
  sourceInterface: "slack" as const,
173
173
  conversationExternalId: SLACK_CHANNEL_ID,
174
174
  externalMessageId: `${SLACK_CHANNEL_ID}:1700000000.1:${msgCounter}`,
175
- canonicalAssistantId: "assistant-1",
176
175
  rawSenderId: overrides.rawSenderId,
177
176
  canonicalSenderId: overrides.rawSenderId,
178
177
  actorDisplayName: "Reactor",
@@ -35,7 +35,6 @@ import {
35
35
  import { markProcessed } from "../../../persistence/delivery-status.js";
36
36
  import { upsertBinding } from "../../../persistence/external-conversation-store.js";
37
37
  import { getLogger } from "../../../util/logger.js";
38
- import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../assistant-scope.js";
39
38
  import type { ApprovalConversationGenerator } from "../../http-types.js";
40
39
  import {
41
40
  actorTrustContextFromVerdict,
@@ -100,7 +99,6 @@ export interface ReactionInterceptParams {
100
99
  sourceInterface: InterfaceId | undefined;
101
100
  conversationExternalId: string;
102
101
  externalMessageId: string;
103
- canonicalAssistantId: string;
104
102
  rawSenderId: string | undefined;
105
103
  canonicalSenderId: string | null;
106
104
  actorDisplayName: string | undefined;
@@ -126,7 +124,6 @@ export async function handleSlackReactionIntercept(
126
124
  sourceInterface,
127
125
  conversationExternalId,
128
126
  externalMessageId,
129
- canonicalAssistantId,
130
127
  rawSenderId,
131
128
  canonicalSenderId,
132
129
  actorDisplayName,
@@ -215,21 +212,17 @@ export async function handleSlackReactionIntercept(
215
212
  };
216
213
  }
217
214
 
218
- // Maintain the conversation binding, matching the message pipeline. Scoped to
219
- // the daemon's own assistant so assistant-scoped legacy routes don't clobber
220
- // each other's binding metadata.
221
- if (canonicalAssistantId === DAEMON_INTERNAL_ASSISTANT_ID) {
222
- upsertBinding({
223
- conversationId: result.conversationId,
224
- sourceChannel,
225
- externalChatId: conversationExternalId,
226
- externalChatName: slackChannelName,
227
- externalThreadId: threadTs ?? null,
228
- externalUserId: canonicalSenderId ?? rawSenderId ?? null,
229
- displayName: actorDisplayName ?? null,
230
- username: actorUsername ?? null,
231
- });
232
- }
215
+ // Maintain the conversation binding, matching the message pipeline.
216
+ upsertBinding({
217
+ conversationId: result.conversationId,
218
+ sourceChannel,
219
+ externalChatId: conversationExternalId,
220
+ externalChatName: slackChannelName,
221
+ externalThreadId: threadTs ?? null,
222
+ externalUserId: canonicalSenderId ?? rawSenderId ?? null,
223
+ displayName: actorDisplayName ?? null,
224
+ username: actorUsername ?? null,
225
+ });
233
226
 
234
227
  // Guardian approval-by-reaction → guardian decision pipeline, exactly like
235
228
  // buttons and text replies. Only `reaction:` (added) expresses intent;
@@ -246,7 +239,6 @@ export async function handleSlackReactionIntercept(
246
239
  reactedMessageTs,
247
240
  rawSenderId,
248
241
  canonicalSenderId,
249
- canonicalAssistantId,
250
242
  sourceChannel,
251
243
  conversationExternalId,
252
244
  conversationId: result.conversationId,
@@ -14,6 +14,7 @@ import {
14
14
  } from "../../../persistence/delivery-crud.js";
15
15
  import { checkIngressForSecrets } from "../../../security/secret-ingress.js";
16
16
  import { getLogger } from "../../../util/logger.js";
17
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../../assistant-scope.js";
17
18
 
18
19
  const log = getLogger("runtime-http");
19
20
 
@@ -36,7 +37,6 @@ export interface SecretIngressCheckParams {
36
37
  actorUsername: string | undefined;
37
38
  trustCtx: TrustContext;
38
39
  replyCallbackUrl: string | undefined;
39
- canonicalAssistantId: string;
40
40
  }
41
41
 
42
42
  export interface SecretIngressCheckResult {
@@ -69,7 +69,6 @@ export function runSecretIngressCheck(
69
69
  actorUsername,
70
70
  trustCtx,
71
71
  replyCallbackUrl,
72
- canonicalAssistantId,
73
72
  } = params;
74
73
 
75
74
  // Persist the raw payload so dead-lettered events can always be replayed.
@@ -85,7 +84,7 @@ export function runSecretIngressCheck(
85
84
  senderUsername: actorUsername,
86
85
  trustCtx,
87
86
  replyCallbackUrl,
88
- assistantId: canonicalAssistantId,
87
+ assistantId: DAEMON_INTERNAL_ASSISTANT_ID,
89
88
  });
90
89
 
91
90
  // ── Secret ingress scan ──