@vellumai/assistant 0.11.7-dev.202609011637.5f3cbff → 0.11.7-dev.202609011820.193fbf9

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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/channel-availability-routes.test.ts +15 -0
  3. package/src/__tests__/channel-delivery-store.test.ts +78 -24
  4. package/src/__tests__/channel-readiness-routes.test.ts +96 -3
  5. package/src/__tests__/channel-readiness-service.test.ts +19 -4
  6. package/src/__tests__/channel-reply-delivery.test.ts +9 -0
  7. package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
  8. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  9. package/src/__tests__/delete-propagation.test.ts +300 -0
  10. package/src/__tests__/email-invite-adapter.test.ts +53 -12
  11. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +28 -0
  12. package/src/__tests__/outbound-slack-persistence.test.ts +14 -0
  13. package/src/__tests__/reaction-persistence.test.ts +19 -2
  14. package/src/daemon/conversation.ts +24 -2
  15. package/src/daemon/process-message.ts +28 -2
  16. package/src/daemon/reaction-record.ts +2 -9
  17. package/src/email/byo-email-credential.test.ts +39 -0
  18. package/src/email/byo-email-credential.ts +76 -0
  19. package/src/email/registered-inbox.test.ts +272 -0
  20. package/src/email/registered-inbox.ts +169 -0
  21. package/src/messaging/provider-message-metadata.ts +13 -1
  22. package/src/messaging/reaction-envelopes.ts +16 -0
  23. package/src/persistence/delivery-crud.ts +182 -36
  24. package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +1 -1
  25. package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
  26. package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
  27. package/src/persistence/schema/conversations.ts +39 -0
  28. package/src/persistence/steps.ts +2 -0
  29. package/src/runtime/AGENTS.md +11 -2
  30. package/src/runtime/channel-invite-transports/email.ts +10 -9
  31. package/src/runtime/channel-readiness-service.ts +83 -23
  32. package/src/runtime/channel-reply-delivery.ts +17 -4
  33. package/src/runtime/routes/channel-availability-routes.ts +18 -31
  34. package/src/runtime/routes/credential-routes.ts +3 -0
  35. package/src/runtime/routes/email-routes.ts +35 -47
  36. package/src/runtime/routes/inbound-message-handler.ts +98 -28
  37. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +98 -0
  38. package/src/runtime/routes/inbound-stages/background-dispatch.ts +45 -1
  39. package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +269 -14
  40. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +283 -60
  41. package/src/runtime/verification-outbound-actions.ts +8 -12
  42. package/src/tools/credentials/store.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.7-dev.202609011637.5f3cbff",
3
+ "version": "0.11.7-dev.202609011820.193fbf9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,7 @@
13
13
  import { beforeEach, describe, expect, mock, test } from "bun:test";
14
14
 
15
15
  import { CHANNEL_METADATA } from "../channels/types.js";
16
+ import { credentialKey } from "../security/credential-key.js";
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Mock state — flipped per-test
@@ -29,6 +30,11 @@ let mockEmailAddressesResponse: {
29
30
  body: { count: 0, results: [] },
30
31
  };
31
32
  let mockFetchThrows = false;
33
+ let mockSecureKeys: Record<string, string> = {};
34
+
35
+ mock.module("../security/secure-keys.js", () => ({
36
+ getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
37
+ }));
32
38
 
33
39
  mock.module("../platform/client.js", () => ({
34
40
  VellumPlatformClient: {
@@ -83,6 +89,7 @@ describe("channels/available", () => {
83
89
  body: { count: 0, results: [] },
84
90
  };
85
91
  mockFetchThrows = false;
92
+ mockSecureKeys = {};
86
93
  });
87
94
 
88
95
  test("base list only when no email address registered", async () => {
@@ -126,6 +133,14 @@ describe("channels/available", () => {
126
133
  expect(result.channels.map((c) => c.id)).toContain("email");
127
134
  });
128
135
 
136
+ test("appends email when a your-own provider credential is stored", async () => {
137
+ mockSecureKeys[credentialKey("resend", "api_key")] = "stored";
138
+
139
+ const result = (await handler({})) as HandlerResult;
140
+
141
+ expect(result.channels.map((c) => c.id)).toContain("email");
142
+ });
143
+
129
144
  test("base list only when platform returns non-ok", async () => {
130
145
  mockEmailAddressesResponse = {
131
146
  ok: false,
@@ -10,10 +10,11 @@ import { getDb } from "../persistence/db-connection.js";
10
10
  import { initializeDb } from "../persistence/db-init.js";
11
11
  import {
12
12
  clearPayload,
13
- findConversationByProviderMessageId,
13
+ findMessageByProviderMessageId,
14
14
  findMessageBySourceId,
15
15
  linkMessage,
16
16
  recordInbound,
17
+ recordOutboundPost,
17
18
  storePayload,
18
19
  } from "../persistence/delivery-crud.js";
19
20
  import {
@@ -52,6 +53,7 @@ await initializeDb();
52
53
  function resetTables() {
53
54
  const db = getDb();
54
55
  db.run("DELETE FROM channel_inbound_events");
56
+ db.run("DELETE FROM channel_outbound_posts");
55
57
  db.run("DELETE FROM messages");
56
58
  db.run("DELETE FROM conversation_keys");
57
59
  db.run("DELETE FROM external_conversation_bindings");
@@ -147,32 +149,26 @@ describe("channel-delivery-store", () => {
147
149
  .run();
148
150
 
149
151
  expect(
150
- findConversationByProviderMessageId(
151
- "discord",
152
- chatId,
153
- "1234567890123456789",
154
- ),
155
- ).toBe(minted.conversationId);
152
+ findMessageByProviderMessageId("discord", chatId, "1234567890123456789"),
153
+ ).toEqual({
154
+ messageId: "assistant-post-1",
155
+ conversationId: minted.conversationId,
156
+ });
156
157
  // A split reply posts several provider messages from one row; any of
157
158
  // their ids resolves it.
158
159
  expect(
159
- findConversationByProviderMessageId(
160
- "discord",
161
- chatId,
162
- "2234567890123456789",
163
- ),
164
- ).toBe(minted.conversationId);
160
+ findMessageByProviderMessageId("discord", chatId, "2234567890123456789"),
161
+ ).toEqual({
162
+ messageId: "assistant-post-1",
163
+ conversationId: minted.conversationId,
164
+ });
165
165
  // The scan is scoped to the reaction's own channel address: the same id
166
166
  // is invisible from another channel's or another chat's key space.
167
167
  expect(
168
- findConversationByProviderMessageId(
169
- "telegram",
170
- chatId,
171
- "1234567890123456789",
172
- ),
168
+ findMessageByProviderMessageId("telegram", chatId, "1234567890123456789"),
173
169
  ).toBeNull();
174
170
  expect(
175
- findConversationByProviderMessageId(
171
+ findMessageByProviderMessageId(
176
172
  "discord",
177
173
  "other-chat",
178
174
  "1234567890123456789",
@@ -206,7 +202,7 @@ describe("channel-delivery-store", () => {
206
202
  .run();
207
203
  linkMessage(minted.eventId, "linked-user-row");
208
204
  expect(
209
- findConversationByProviderMessageId("telegram", chatId, "tg-linked-1"),
205
+ findMessageByProviderMessageId("telegram", chatId, "tg-linked-1"),
210
206
  ).toBeNull();
211
207
 
212
208
  // A crash-window row: persisted with its envelope but its event link
@@ -218,8 +214,11 @@ describe("channel-delivery-store", () => {
218
214
  .where(eq(messages.id, "orphan-user-row"))
219
215
  .run();
220
216
  expect(
221
- findConversationByProviderMessageId("telegram", chatId, "tg-orphan-1"),
222
- ).toBe(minted.conversationId);
217
+ findMessageByProviderMessageId("telegram", chatId, "tg-orphan-1"),
218
+ ).toEqual({
219
+ messageId: "orphan-user-row",
220
+ conversationId: minted.conversationId,
221
+ });
223
222
 
224
223
  // A legacy linked row whose event carries no source_message_id:
225
224
  // findMessageBySourceId matches only that column, so the fallback must
@@ -233,8 +232,63 @@ describe("channel-delivery-store", () => {
233
232
  .run();
234
233
  linkMessage(legacy.eventId, "legacy-user-row");
235
234
  expect(
236
- findConversationByProviderMessageId("telegram", chatId, "tg-legacy-1"),
237
- ).toBe(legacy.conversationId);
235
+ findMessageByProviderMessageId("telegram", chatId, "tg-legacy-1"),
236
+ ).toEqual({
237
+ messageId: "legacy-user-row",
238
+ conversationId: legacy.conversationId,
239
+ });
240
+ });
241
+
242
+ test("the outbound-posts index resolves a post exactly, at any age", () => {
243
+ const chatId = "999000111222333444";
244
+ const minted = recordInbound("discord", chatId, "evt-index-seed");
245
+ const db = getDb();
246
+ // The row's envelope names nothing: resolution comes from the index
247
+ // alone, proving the table is the contract rather than a cache in
248
+ // front of the scan.
249
+ db.insert(messages)
250
+ .values({
251
+ id: "assistant-indexed-post",
252
+ conversationId: minted.conversationId,
253
+ role: "assistant",
254
+ content: "indexed reply",
255
+ createdAt: Date.now(),
256
+ metadata: null,
257
+ })
258
+ .run();
259
+ recordOutboundPost({
260
+ sourceChannel: "discord",
261
+ externalChatId: chatId,
262
+ providerMessageId: "555666777888999000",
263
+ messageId: "assistant-indexed-post",
264
+ conversationId: minted.conversationId,
265
+ });
266
+ // Idempotent on redelivery.
267
+ recordOutboundPost({
268
+ sourceChannel: "discord",
269
+ externalChatId: chatId,
270
+ providerMessageId: "555666777888999000",
271
+ messageId: "assistant-indexed-post",
272
+ conversationId: minted.conversationId,
273
+ });
274
+
275
+ expect(
276
+ findMessageByProviderMessageId("discord", chatId, "555666777888999000"),
277
+ ).toEqual({
278
+ messageId: "assistant-indexed-post",
279
+ conversationId: minted.conversationId,
280
+ });
281
+ // Channel and chat scoping hold on the index path too.
282
+ expect(
283
+ findMessageByProviderMessageId("telegram", chatId, "555666777888999000"),
284
+ ).toBeNull();
285
+ expect(
286
+ findMessageByProviderMessageId(
287
+ "discord",
288
+ "other-chat",
289
+ "555666777888999000",
290
+ ),
291
+ ).toBeNull();
238
292
  });
239
293
 
240
294
  test("same chat on same channel reuses the same conversation", () => {
@@ -10,9 +10,11 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
10
10
  // ---------------------------------------------------------------------------
11
11
  // Mocks — must be set up before importing the service
12
12
  // ---------------------------------------------------------------------------
13
+ import type { RegisteredInboxState } from "../email/registered-inbox.js";
13
14
 
14
15
  let mockSecureKeys: Record<string, string>;
15
16
  let mockHasTwilioCredentials: boolean;
17
+ let mockRegisteredInbox: RegisteredInboxState;
16
18
 
17
19
  mock.module("../calls/twilio-rest.js", () => ({
18
20
  hasTwilioCredentials: () => mockHasTwilioCredentials,
@@ -25,6 +27,11 @@ mock.module("../security/secure-keys.js", () => ({
25
27
  getSecureKeyAsync: async (key: string) => mockSecureKeys[key] ?? null,
26
28
  }));
27
29
 
30
+ mock.module("../email/registered-inbox.js", () => ({
31
+ resolveRegisteredInbox: async () => mockRegisteredInbox,
32
+ invalidateRegisteredInboxCache: () => {},
33
+ }));
34
+
28
35
  // ---------------------------------------------------------------------------
29
36
  // Import under test
30
37
  // ---------------------------------------------------------------------------
@@ -45,6 +52,7 @@ describe("channel readiness routes — email and WhatsApp probes", () => {
45
52
  setConfig("email", {});
46
53
  mockSecureKeys = {};
47
54
  mockHasTwilioCredentials = false;
55
+ mockRegisteredInbox = { status: "none" };
48
56
  });
49
57
 
50
58
  // -------------------------------------------------------------------------
@@ -102,12 +110,15 @@ describe("channel readiness routes — email and WhatsApp probes", () => {
102
110
  expect(ingressCheck!.passed).toBe(false);
103
111
  });
104
112
 
105
- test("ready when all prerequisites are met (including inbox)", async () => {
113
+ test("ready when the platform reports a registered inbox", async () => {
106
114
  setConfig("ingress", {
107
115
  publicBaseUrl: "https://example.com",
108
116
  enabled: true,
109
117
  });
110
- setConfig("email", { address: "user@example.com" });
118
+ mockRegisteredInbox = {
119
+ status: "registered",
120
+ address: "assistant@example.com",
121
+ };
111
122
  const service = createReadinessService();
112
123
  const [snapshot] = await service.getReadiness("email", true);
113
124
 
@@ -115,7 +126,26 @@ describe("channel readiness routes — email and WhatsApp probes", () => {
115
126
  expect(snapshot.reasons).toHaveLength(0);
116
127
  });
117
128
 
118
- test("not ready when inbox is missing (remote check)", async () => {
129
+ test("platform registration decides the check; local config cannot", async () => {
130
+ // Registration lives on the platform. A stray local `email.address`
131
+ // config value (a key nothing writes) must not make an unregistered
132
+ // inbox pass.
133
+ setConfig("ingress", {
134
+ publicBaseUrl: "https://example.com",
135
+ enabled: true,
136
+ });
137
+ setConfig("email", { address: "stale@example.com" });
138
+ mockRegisteredInbox = { status: "none" };
139
+ const service = createReadinessService();
140
+ const [snapshot] = await service.getReadiness("email", true);
141
+
142
+ expect(snapshot.ready).toBe(false);
143
+ expect(snapshot.reasons.some((r) => r.code === "inbox_configured")).toBe(
144
+ true,
145
+ );
146
+ });
147
+
148
+ test("not ready when the platform reports no inbox (remote check)", async () => {
119
149
  setConfig("ingress", {
120
150
  publicBaseUrl: "https://example.com",
121
151
  enabled: true,
@@ -129,6 +159,69 @@ describe("channel readiness routes — email and WhatsApp probes", () => {
129
159
  );
130
160
  });
131
161
 
162
+ test("missing platform credentials fail the inbox check", async () => {
163
+ setConfig("ingress", {
164
+ publicBaseUrl: "https://example.com",
165
+ enabled: true,
166
+ });
167
+ mockRegisteredInbox = { status: "no_platform" };
168
+ const service = createReadinessService();
169
+ const [snapshot] = await service.getReadiness("email", true);
170
+
171
+ expect(snapshot.ready).toBe(false);
172
+ expect(snapshot.reasons.some((r) => r.code === "inbox_configured")).toBe(
173
+ true,
174
+ );
175
+ });
176
+
177
+ test("a your-own provider credential passes the inbox check", async () => {
178
+ setConfig("ingress", {
179
+ publicBaseUrl: "https://example.com",
180
+ enabled: true,
181
+ });
182
+ mockRegisteredInbox = { status: "none" };
183
+ mockSecureKeys[credentialKey("resend", "api_key")] = "stored";
184
+ const service = createReadinessService();
185
+ const [snapshot] = await service.getReadiness("email", true);
186
+
187
+ expect(snapshot.ready).toBe(true);
188
+ expect(snapshot.reasons).toHaveLength(0);
189
+ });
190
+
191
+ test("a your-own credential decides even when the platform is unreachable", async () => {
192
+ setConfig("ingress", {
193
+ publicBaseUrl: "https://example.com",
194
+ enabled: true,
195
+ });
196
+ mockRegisteredInbox = { status: "unavailable", detail: "HTTP 503" };
197
+ mockSecureKeys[credentialKey("mailgun", "api_key")] = "stored";
198
+ const service = createReadinessService();
199
+ const [snapshot] = await service.getReadiness("email", true);
200
+
201
+ expect(snapshot.ready).toBe(true);
202
+ });
203
+
204
+ test("unreachable platform is indeterminate, not a fault", async () => {
205
+ setConfig("ingress", {
206
+ publicBaseUrl: "https://example.com",
207
+ enabled: true,
208
+ });
209
+ mockRegisteredInbox = { status: "unavailable", detail: "HTTP 503" };
210
+ const service = createReadinessService();
211
+ const [snapshot] = await service.getReadiness("email", true);
212
+
213
+ // No positive confirmation, so the channel cannot report ready, but an
214
+ // unanswerable question is not a fault and must not be listed as one.
215
+ expect(snapshot.ready).toBe(false);
216
+ expect(snapshot.reasons.some((r) => r.code === "inbox_configured")).toBe(
217
+ false,
218
+ );
219
+ const remoteCheck = snapshot.remoteChecks?.find(
220
+ (c) => c.name === "inbox_configured",
221
+ );
222
+ expect(remoteCheck?.indeterminate).toBe(true);
223
+ });
224
+
132
225
  test("local-only readiness still passes without inbox check", async () => {
133
226
  setConfig("ingress", {
134
227
  publicBaseUrl: "https://example.com",
@@ -369,18 +369,33 @@ describe("ChannelReadinessService", () => {
369
369
  });
370
370
  });
371
371
 
372
- test("email readiness ignores platform connectivity", async () => {
373
- // Email passes `allowManagedCallbacks: false`, so the managed tiers are
374
- // not offered to it and only a real ingress URL counts.
372
+ test("email readiness accepts a platform-connected assistant with no ingress", async () => {
373
+ // Inbound email arrives through the platform callback route the gateway
374
+ // registers (`registerEmailCallbackRoute`, the same pattern as Telegram),
375
+ // so a platform-connected assistant with no public ingress URL still
376
+ // receives email and must not report missing ingress.
375
377
  mockPlatformConnected = true;
376
378
 
377
379
  const readiness = createReadinessService();
378
380
  const [snapshot] = await readiness.getReadiness("email");
379
381
 
382
+ expect(snapshot.localChecks).toContainEqual({
383
+ name: "ingress",
384
+ passed: true,
385
+ message: "Managed platform callback routing is configured",
386
+ });
387
+ });
388
+
389
+ test("email readiness reports missing ingress when nothing can route inbound", async () => {
390
+ mockPlatformConnected = false;
391
+
392
+ const readiness = createReadinessService();
393
+ const [snapshot] = await readiness.getReadiness("email");
394
+
380
395
  expect(snapshot.localChecks).toContainEqual({
381
396
  name: "ingress",
382
397
  passed: false,
383
- message: "Public ingress URL is not configured or disabled",
398
+ message: "No public ingress URL or managed callback route is configured",
384
399
  });
385
400
  });
386
401
 
@@ -159,6 +159,15 @@ mock.module("../persistence/conversation-crud.js", () => ({
159
159
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
160
160
  }));
161
161
 
162
+ // The reconciler writes the outbound-posts index through delivery-crud;
163
+ // stub it so this suite stays DB-free while capturing the writes.
164
+ const recordedOutboundPosts: Array<Record<string, string>> = [];
165
+ mock.module("../persistence/delivery-crud.js", () => ({
166
+ recordOutboundPost: (post: Record<string, string>) => {
167
+ recordedOutboundPosts.push(post);
168
+ },
169
+ }));
170
+
162
171
  mock.module("../persistence/attachments-store.js", () => ({
163
172
  getAttachmentMetadataForMessage: (messageId: string) =>
164
173
  attachmentsByMessageId.get(messageId) ?? [],
@@ -589,6 +589,53 @@ describe("loadFromDb history repair", () => {
589
589
  expect(allText).not.toContain("hunter2");
590
590
  });
591
591
 
592
+ test("the assistant's own deleted post keeps its content plus a visibility marker", async () => {
593
+ mockConversation = {
594
+ id: "conv-1",
595
+ contextSummary: null,
596
+ contextCompactedMessageCount: 0,
597
+ totalInputTokens: 0,
598
+ totalOutputTokens: 0,
599
+ totalEstimatedCost: 0,
600
+ };
601
+ mockDbMessages = [
602
+ {
603
+ id: "m1",
604
+ role: "user",
605
+ content: [{ type: "text", text: "What time is the meeting?" }],
606
+ },
607
+ {
608
+ id: "m2",
609
+ role: "assistant",
610
+ content: [{ type: "text", text: "The meeting is at 3pm." }],
611
+ metadata: JSON.stringify({
612
+ providerMeta: JSON.stringify({
613
+ source: "discord",
614
+ conversationExternalId: "chan-1",
615
+ messageId: "555.2",
616
+ eventKind: "message",
617
+ deletedAt: 1700000001000,
618
+ }),
619
+ }),
620
+ },
621
+ ];
622
+
623
+ const conversation = makeConversation();
624
+ await conversation.loadFromDb();
625
+ const assistantRow = conversation
626
+ .getMessages()
627
+ .find((m) => m.role === "assistant");
628
+ const texts = (assistantRow?.content ?? [])
629
+ .filter((b) => b.type === "text")
630
+ .map((b) => (b.type === "text" ? b.text : ""));
631
+ // Erasure is for a retracted user message; the assistant's own words
632
+ // stay (it did say them), and the rendered fact is lost visibility.
633
+ expect(texts.join("\n")).toContain("The meeting is at 3pm.");
634
+ expect(texts.join("\n")).toContain(
635
+ "[This message was deleted from the channel and is no longer visible to participants]",
636
+ );
637
+ });
638
+
592
639
  test("a reaction never quotes a target that was later deleted", async () => {
593
640
  mockConversation = {
594
641
  id: "conv-1",
@@ -703,6 +750,77 @@ describe("loadFromDb history repair", () => {
703
750
  expect(allText).not.toContain("an earlier message");
704
751
  });
705
752
 
753
+ test("a partially deleted split reply quotes only its surviving posts", async () => {
754
+ mockConversation = {
755
+ id: "conv-1",
756
+ contextSummary: null,
757
+ contextCompactedMessageCount: 0,
758
+ totalInputTokens: 0,
759
+ totalOutputTokens: 0,
760
+ totalEstimatedCost: 0,
761
+ };
762
+ const reactionRow = (target: string, emoji: string, id: string) => ({
763
+ id,
764
+ role: "user",
765
+ content: [{ type: "text", text: "[reaction]" }],
766
+ metadata: JSON.stringify({
767
+ providerMeta: JSON.stringify({
768
+ source: "discord",
769
+ conversationExternalId: "chan-1",
770
+ eventKind: "reaction",
771
+ reaction: {
772
+ targetMessageId: target,
773
+ emoji,
774
+ op: "added",
775
+ actorDisplayName: "Alice",
776
+ },
777
+ }),
778
+ }),
779
+ });
780
+ mockDbMessages = [
781
+ {
782
+ id: "m1",
783
+ role: "assistant",
784
+ content: [{ type: "text", text: "Deploy finished cleanly." }],
785
+ metadata: JSON.stringify({
786
+ providerMeta: JSON.stringify({
787
+ source: "discord",
788
+ conversationExternalId: "chan-1",
789
+ messageId: "555.1",
790
+ additionalMessageIds: ["555.2"],
791
+ deletedMessageIds: ["555.2"],
792
+ eventKind: "message",
793
+ }),
794
+ }),
795
+ },
796
+ // Reaction on the surviving post: still quotable.
797
+ reactionRow("555.1", "tada", "m2"),
798
+ // Reaction on the deleted post: degrades to the unresolved form
799
+ // rather than quoting content no longer on the channel.
800
+ reactionRow("555.2", "eyes", "m3"),
801
+ ];
802
+
803
+ const conversation = makeConversation();
804
+ await conversation.loadFromDb();
805
+ const allText = conversation
806
+ .getMessages()
807
+ .flatMap((m) => m.content)
808
+ .filter((b) => b.type === "text")
809
+ .map((b) => (b.type === "text" ? b.text : ""))
810
+ .join("\n");
811
+ // The row itself is NOT fully deleted: its content stays.
812
+ expect(allText).toContain("Deploy finished cleanly.");
813
+ // :tada: (surviving target) quotes; :eyes: (deleted target) does not.
814
+ const tadaLine = allText
815
+ .split("\n")
816
+ .find((line) => line.includes(":tada:"));
817
+ const eyesLine = allText
818
+ .split("\n")
819
+ .find((line) => line.includes(":eyes:"));
820
+ expect(tadaLine).toContain("Deploy finished cleanly.");
821
+ expect(eyesLine).toContain("an earlier message");
822
+ });
823
+
706
824
  test("the assistant's own reaction row renders second-person", async () => {
707
825
  mockConversation = {
708
826
  id: "conv-1",
@@ -238,6 +238,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
238
238
  "providers/inference/connection-availability.ts", // shared (provider, connection) availability status (credential presence check only; value never leaves the helper)
239
239
  "plugin-api/resolve-credential.ts", // plugin-facing resolveCredential: reveal-equivalent plaintext read, scoped to the in-context plugin's own service
240
240
  "tools/credentials/store.ts", // shared credential write path (setSecureKeyAsync only; no reads) behind credentials/set and plugin-facing storeCredential
241
+ "email/byo-email-credential.ts", // BYO email provider configuration check (credential presence only; value never leaves the helper)
241
242
  ]);
242
243
 
243
244
  const thisDir = dirname(fileURLToPath(import.meta.url));
@@ -486,7 +487,6 @@ describe("Invariant 4: credentials only used for allowed purpose", () => {
486
487
  });
487
488
  });
488
489
 
489
-
490
490
  // ---------------------------------------------------------------------------
491
491
  // Invariant 6 — oauth2ClientSecret never in plaintext metadata
492
492
  // ---------------------------------------------------------------------------