@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.
- package/package.json +1 -1
- package/src/__tests__/channel-availability-routes.test.ts +15 -0
- package/src/__tests__/channel-delivery-store.test.ts +78 -24
- package/src/__tests__/channel-readiness-routes.test.ts +96 -3
- package/src/__tests__/channel-readiness-service.test.ts +19 -4
- package/src/__tests__/channel-reply-delivery.test.ts +9 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +118 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/delete-propagation.test.ts +300 -0
- package/src/__tests__/email-invite-adapter.test.ts +53 -12
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +28 -0
- package/src/__tests__/outbound-slack-persistence.test.ts +14 -0
- package/src/__tests__/reaction-persistence.test.ts +19 -2
- package/src/daemon/conversation.ts +24 -2
- package/src/daemon/process-message.ts +28 -2
- package/src/daemon/reaction-record.ts +2 -9
- package/src/email/byo-email-credential.test.ts +39 -0
- package/src/email/byo-email-credential.ts +76 -0
- package/src/email/registered-inbox.test.ts +272 -0
- package/src/email/registered-inbox.ts +169 -0
- package/src/messaging/provider-message-metadata.ts +13 -1
- package/src/messaging/reaction-envelopes.ts +16 -0
- package/src/persistence/delivery-crud.ts +182 -36
- package/src/persistence/migrations/374-channel-inbound-message-id-index.ts +1 -1
- package/src/persistence/migrations/375-create-channel-outbound-posts.ts +52 -0
- package/src/persistence/migrations/__tests__/375-create-channel-outbound-posts.test.ts +84 -0
- package/src/persistence/schema/conversations.ts +39 -0
- package/src/persistence/steps.ts +2 -0
- package/src/runtime/AGENTS.md +11 -2
- package/src/runtime/channel-invite-transports/email.ts +10 -9
- package/src/runtime/channel-readiness-service.ts +83 -23
- package/src/runtime/channel-reply-delivery.ts +17 -4
- package/src/runtime/routes/channel-availability-routes.ts +18 -31
- package/src/runtime/routes/credential-routes.ts +3 -0
- package/src/runtime/routes/email-routes.ts +35 -47
- package/src/runtime/routes/inbound-message-handler.ts +98 -28
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +98 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +45 -1
- package/src/runtime/routes/inbound-stages/reaction-intercept.test.ts +269 -14
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +283 -60
- package/src/runtime/verification-outbound-actions.ts +8 -12
- package/src/tools/credentials/store.ts +3 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
169
|
-
"telegram",
|
|
170
|
-
chatId,
|
|
171
|
-
"1234567890123456789",
|
|
172
|
-
),
|
|
168
|
+
findMessageByProviderMessageId("telegram", chatId, "1234567890123456789"),
|
|
173
169
|
).toBeNull();
|
|
174
170
|
expect(
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
).
|
|
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
|
-
|
|
237
|
-
).
|
|
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
|
|
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
|
-
|
|
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("
|
|
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
|
|
373
|
-
//
|
|
374
|
-
//
|
|
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: "
|
|
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
|
// ---------------------------------------------------------------------------
|