@vellumai/assistant 0.4.15 → 0.4.17
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/Dockerfile +6 -6
- package/README.md +1 -2
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +383 -254
- package/src/__tests__/call-controller.test.ts +1074 -751
- package/src/__tests__/call-routes-http.test.ts +329 -279
- package/src/__tests__/channel-approval-routes.test.ts +2 -13
- package/src/__tests__/channel-approvals.test.ts +227 -182
- package/src/__tests__/channel-guardian.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +157 -114
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
- package/src/__tests__/conversation-routes.test.ts +71 -41
- package/src/__tests__/daemon-server-session-init.test.ts +258 -191
- package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -134
- package/src/__tests__/extract-email.test.ts +42 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +467 -368
- package/src/__tests__/gateway-only-guard.test.ts +54 -55
- package/src/__tests__/gmail-integration.test.ts +48 -46
- package/src/__tests__/guardian-action-followup-executor.test.ts +215 -150
- package/src/__tests__/guardian-outbound-http.test.ts +334 -208
- package/src/__tests__/guardian-routing-invariants.test.ts +680 -613
- package/src/__tests__/guardian-routing-state.test.ts +257 -209
- package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -40
- package/src/__tests__/handle-user-message-secret-resume.test.ts +44 -21
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +269 -195
- package/src/__tests__/inbound-invite-redemption.test.ts +194 -151
- package/src/__tests__/ingress-reconcile.test.ts +184 -142
- package/src/__tests__/non-member-access-request.test.ts +291 -247
- package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
- package/src/__tests__/pairing-concurrent.test.ts +78 -0
- package/src/__tests__/recording-intent-handler.test.ts +422 -291
- package/src/__tests__/runtime-attachment-metadata.test.ts +107 -69
- package/src/__tests__/runtime-events-sse.test.ts +67 -50
- package/src/__tests__/send-endpoint-busy.test.ts +314 -232
- package/src/__tests__/session-approval-overrides.test.ts +93 -91
- package/src/__tests__/sms-messaging-provider.test.ts +74 -47
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -274
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -372
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +261 -239
- package/src/__tests__/trusted-contact-multichannel.test.ts +179 -140
- package/src/__tests__/twilio-config.test.ts +49 -41
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +189 -162
- package/src/__tests__/twilio-routes.test.ts +389 -280
- package/src/calls/call-controller.ts +1 -1
- package/src/calls/guardian-action-sweep.ts +6 -6
- package/src/calls/twilio-routes.ts +2 -4
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +29 -4
- package/src/config/bundled-skills/messaging/SKILL.md +5 -4
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +69 -4
- package/src/config/env.ts +39 -29
- package/src/daemon/handlers/config-inbox.ts +5 -5
- package/src/daemon/handlers/skills.ts +18 -10
- package/src/daemon/ipc-contract/messages.ts +1 -0
- package/src/daemon/ipc-contract/surfaces.ts +7 -1
- package/src/daemon/pairing-store.ts +15 -2
- package/src/daemon/session-agent-loop-handlers.ts +5 -0
- package/src/daemon/session-agent-loop.ts +1 -1
- package/src/daemon/session-process.ts +1 -1
- package/src/daemon/session-slash.ts +4 -4
- package/src/daemon/session-surfaces.ts +42 -2
- package/src/runtime/auth/token-service.ts +95 -45
- package/src/runtime/channel-retry-sweep.ts +2 -2
- package/src/runtime/http-server.ts +8 -7
- package/src/runtime/http-types.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +1 -1
- package/src/runtime/routes/guardian-bootstrap-routes.ts +3 -2
- package/src/runtime/routes/guardian-expiry-sweep.ts +5 -5
- package/src/runtime/routes/pairing-routes.ts +4 -1
- package/src/sequence/reply-matcher.ts +14 -4
- package/src/skills/frontmatter.ts +9 -6
- package/src/tools/ui-surface/definitions.ts +3 -1
- package/src/util/platform.ts +0 -12
- package/docs/architecture/http-token-refresh.md +0 -274
|
@@ -1,56 +1,59 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync } from
|
|
2
|
-
import { tmpdir } from
|
|
3
|
-
import { join } from
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
import { afterAll, beforeEach, describe, expect, mock, test } from
|
|
6
|
-
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
6
|
+
|
|
7
|
+
mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true }));
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
7
9
|
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
9
11
|
// Test isolation: in-memory SQLite via temp directory
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
|
|
12
|
-
const testDir = mkdtempSync(join(tmpdir(),
|
|
14
|
+
const testDir = mkdtempSync(join(tmpdir(), "guardian-routing-state-test-"));
|
|
13
15
|
|
|
14
|
-
mock.module(
|
|
16
|
+
mock.module("../util/platform.js", () => ({
|
|
15
17
|
getRootDir: () => testDir,
|
|
16
18
|
getDataDir: () => testDir,
|
|
17
|
-
isMacOS: () => process.platform ===
|
|
18
|
-
isLinux: () => process.platform ===
|
|
19
|
-
isWindows: () => process.platform ===
|
|
20
|
-
getSocketPath: () => join(testDir,
|
|
21
|
-
getPidPath: () => join(testDir,
|
|
22
|
-
getDbPath: () => join(testDir,
|
|
23
|
-
getLogPath: () => join(testDir,
|
|
19
|
+
isMacOS: () => process.platform === "darwin",
|
|
20
|
+
isLinux: () => process.platform === "linux",
|
|
21
|
+
isWindows: () => process.platform === "win32",
|
|
22
|
+
getSocketPath: () => join(testDir, "test.sock"),
|
|
23
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
24
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
25
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
24
26
|
ensureDataDir: () => {},
|
|
25
27
|
}));
|
|
26
28
|
|
|
27
|
-
mock.module(
|
|
28
|
-
getLogger: () =>
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
mock.module("../util/logger.js", () => ({
|
|
30
|
+
getLogger: () =>
|
|
31
|
+
new Proxy({} as Record<string, unknown>, {
|
|
32
|
+
get: () => () => {},
|
|
33
|
+
}),
|
|
31
34
|
}));
|
|
32
35
|
|
|
33
36
|
// Mock security check to always pass
|
|
34
|
-
mock.module(
|
|
37
|
+
mock.module("../security/secret-ingress.js", () => ({
|
|
35
38
|
checkIngressForSecrets: () => ({ blocked: false }),
|
|
36
39
|
}));
|
|
37
40
|
|
|
38
41
|
// Mock ingress member store with a configurable member lookup.
|
|
39
42
|
// By default returns an active member so ACL passes.
|
|
40
43
|
let mockFindMember: (() => unknown) | null = null;
|
|
41
|
-
mock.module(
|
|
44
|
+
mock.module("../memory/ingress-member-store.js", () => ({
|
|
42
45
|
findMember: (..._args: unknown[]) => {
|
|
43
46
|
if (mockFindMember) return mockFindMember();
|
|
44
47
|
return {
|
|
45
|
-
id:
|
|
46
|
-
assistantId:
|
|
47
|
-
sourceChannel:
|
|
48
|
-
externalUserId:
|
|
48
|
+
id: "member-test-default",
|
|
49
|
+
assistantId: "self",
|
|
50
|
+
sourceChannel: "telegram",
|
|
51
|
+
externalUserId: "telegram-user-default",
|
|
49
52
|
externalChatId: null,
|
|
50
53
|
displayName: null,
|
|
51
54
|
username: null,
|
|
52
|
-
status:
|
|
53
|
-
policy:
|
|
55
|
+
status: "active",
|
|
56
|
+
policy: "allow",
|
|
54
57
|
inviteId: null,
|
|
55
58
|
createdBySessionId: null,
|
|
56
59
|
revokedReason: null,
|
|
@@ -64,49 +67,53 @@ mock.module('../memory/ingress-member-store.js', () => ({
|
|
|
64
67
|
upsertMember: () => {},
|
|
65
68
|
}));
|
|
66
69
|
|
|
67
|
-
import * as channelDeliveryStore from
|
|
68
|
-
import { createBinding } from
|
|
69
|
-
import { getDb, initializeDb, resetDb } from
|
|
70
|
-
import { channelInboundEvents, messages } from
|
|
71
|
-
import { sweepFailedEvents } from
|
|
70
|
+
import * as channelDeliveryStore from "../memory/channel-delivery-store.js";
|
|
71
|
+
import { createBinding } from "../memory/channel-guardian-store.js";
|
|
72
|
+
import { getDb, initializeDb, resetDb } from "../memory/db.js";
|
|
73
|
+
import { channelInboundEvents, messages } from "../memory/schema.js";
|
|
74
|
+
import { sweepFailedEvents } from "../runtime/channel-retry-sweep.js";
|
|
72
75
|
import {
|
|
73
76
|
type GuardianContext,
|
|
74
77
|
resolveRoutingState,
|
|
75
78
|
resolveRoutingStateFromRuntime,
|
|
76
|
-
} from
|
|
77
|
-
import { handleChannelInbound } from
|
|
79
|
+
} from "../runtime/guardian-context-resolver.js";
|
|
80
|
+
import { handleChannelInbound } from "../runtime/routes/channel-routes.js";
|
|
78
81
|
|
|
79
82
|
initializeDb();
|
|
80
83
|
|
|
81
84
|
afterAll(() => {
|
|
82
85
|
resetDb();
|
|
83
|
-
try {
|
|
86
|
+
try {
|
|
87
|
+
rmSync(testDir, { recursive: true });
|
|
88
|
+
} catch {
|
|
89
|
+
/* best effort */
|
|
90
|
+
}
|
|
84
91
|
});
|
|
85
92
|
|
|
86
93
|
function resetTables(): void {
|
|
87
94
|
const db = getDb();
|
|
88
|
-
db.run(
|
|
89
|
-
db.run(
|
|
90
|
-
db.run(
|
|
91
|
-
db.run(
|
|
92
|
-
db.run(
|
|
93
|
-
db.run(
|
|
94
|
-
db.run(
|
|
95
|
-
db.run(
|
|
96
|
-
db.run(
|
|
95
|
+
db.run("DELETE FROM channel_inbound_events");
|
|
96
|
+
db.run("DELETE FROM channel_guardian_bindings");
|
|
97
|
+
db.run("DELETE FROM channel_guardian_approval_requests");
|
|
98
|
+
db.run("DELETE FROM canonical_guardian_requests");
|
|
99
|
+
db.run("DELETE FROM conversation_keys");
|
|
100
|
+
db.run("DELETE FROM messages");
|
|
101
|
+
db.run("DELETE FROM conversations");
|
|
102
|
+
db.run("DELETE FROM assistant_ingress_members");
|
|
103
|
+
db.run("DELETE FROM external_conversation_bindings");
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
100
107
|
// Unit tests: resolveRoutingState
|
|
101
108
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
102
109
|
|
|
103
|
-
describe(
|
|
104
|
-
test(
|
|
110
|
+
describe("resolveRoutingState", () => {
|
|
111
|
+
test("guardian actors are always interactive and route-resolvable", () => {
|
|
105
112
|
const ctx: GuardianContext = {
|
|
106
|
-
sourceChannel:
|
|
107
|
-
trustClass:
|
|
108
|
-
guardianExternalUserId:
|
|
109
|
-
guardianChatId:
|
|
113
|
+
sourceChannel: "telegram",
|
|
114
|
+
trustClass: "guardian",
|
|
115
|
+
guardianExternalUserId: "guardian-123",
|
|
116
|
+
guardianChatId: "chat-123",
|
|
110
117
|
};
|
|
111
118
|
const state = resolveRoutingState(ctx);
|
|
112
119
|
expect(state).toEqual({
|
|
@@ -116,23 +123,23 @@ describe('resolveRoutingState', () => {
|
|
|
116
123
|
});
|
|
117
124
|
});
|
|
118
125
|
|
|
119
|
-
test(
|
|
126
|
+
test("guardian actors are interactive even without guardianExternalUserId", () => {
|
|
120
127
|
// Edge case: guardian is chatting in their own chat, no separate binding needed
|
|
121
128
|
const ctx: GuardianContext = {
|
|
122
|
-
sourceChannel:
|
|
123
|
-
trustClass:
|
|
129
|
+
sourceChannel: "telegram",
|
|
130
|
+
trustClass: "guardian",
|
|
124
131
|
};
|
|
125
132
|
const state = resolveRoutingState(ctx);
|
|
126
133
|
expect(state.canBeInteractive).toBe(true);
|
|
127
134
|
expect(state.promptWaitingAllowed).toBe(true);
|
|
128
135
|
});
|
|
129
136
|
|
|
130
|
-
test(
|
|
137
|
+
test("trusted contact with resolvable guardian route is interactive", () => {
|
|
131
138
|
const ctx: GuardianContext = {
|
|
132
|
-
sourceChannel:
|
|
133
|
-
trustClass:
|
|
134
|
-
guardianExternalUserId:
|
|
135
|
-
guardianChatId:
|
|
139
|
+
sourceChannel: "telegram",
|
|
140
|
+
trustClass: "trusted_contact",
|
|
141
|
+
guardianExternalUserId: "guardian-456",
|
|
142
|
+
guardianChatId: "guardian-chat-456",
|
|
136
143
|
};
|
|
137
144
|
const state = resolveRoutingState(ctx);
|
|
138
145
|
expect(state).toEqual({
|
|
@@ -142,10 +149,10 @@ describe('resolveRoutingState', () => {
|
|
|
142
149
|
});
|
|
143
150
|
});
|
|
144
151
|
|
|
145
|
-
test(
|
|
152
|
+
test("trusted contact without guardian route is NOT interactive (fail-fast)", () => {
|
|
146
153
|
const ctx: GuardianContext = {
|
|
147
|
-
sourceChannel:
|
|
148
|
-
trustClass:
|
|
154
|
+
sourceChannel: "telegram",
|
|
155
|
+
trustClass: "trusted_contact",
|
|
149
156
|
// No guardianExternalUserId — no guardian binding for this channel
|
|
150
157
|
};
|
|
151
158
|
const state = resolveRoutingState(ctx);
|
|
@@ -156,15 +163,15 @@ describe('resolveRoutingState', () => {
|
|
|
156
163
|
});
|
|
157
164
|
});
|
|
158
165
|
|
|
159
|
-
test(
|
|
166
|
+
test("unknown actors are never interactive regardless of guardian route", () => {
|
|
160
167
|
const withRoute: GuardianContext = {
|
|
161
|
-
sourceChannel:
|
|
162
|
-
trustClass:
|
|
163
|
-
guardianExternalUserId:
|
|
168
|
+
sourceChannel: "telegram",
|
|
169
|
+
trustClass: "unknown",
|
|
170
|
+
guardianExternalUserId: "guardian-789",
|
|
164
171
|
};
|
|
165
172
|
const withoutRoute: GuardianContext = {
|
|
166
|
-
sourceChannel:
|
|
167
|
-
trustClass:
|
|
173
|
+
sourceChannel: "telegram",
|
|
174
|
+
trustClass: "unknown",
|
|
168
175
|
};
|
|
169
176
|
|
|
170
177
|
expect(resolveRoutingState(withRoute).promptWaitingAllowed).toBe(false);
|
|
@@ -173,22 +180,22 @@ describe('resolveRoutingState', () => {
|
|
|
173
180
|
});
|
|
174
181
|
});
|
|
175
182
|
|
|
176
|
-
describe(
|
|
177
|
-
test(
|
|
183
|
+
describe("resolveRoutingStateFromRuntime", () => {
|
|
184
|
+
test("produces same result as resolveRoutingState for guardian runtime context", () => {
|
|
178
185
|
const runtimeCtx = {
|
|
179
|
-
sourceChannel:
|
|
180
|
-
trustClass:
|
|
181
|
-
guardianExternalUserId:
|
|
186
|
+
sourceChannel: "telegram" as const,
|
|
187
|
+
trustClass: "trusted_contact" as const,
|
|
188
|
+
guardianExternalUserId: "guardian-rt-1",
|
|
182
189
|
};
|
|
183
190
|
const state = resolveRoutingStateFromRuntime(runtimeCtx);
|
|
184
191
|
expect(state.promptWaitingAllowed).toBe(true);
|
|
185
192
|
expect(state.guardianRouteResolvable).toBe(true);
|
|
186
193
|
});
|
|
187
194
|
|
|
188
|
-
test(
|
|
195
|
+
test("trusted contact runtime context without guardian binding is not interactive", () => {
|
|
189
196
|
const runtimeCtx = {
|
|
190
|
-
sourceChannel:
|
|
191
|
-
trustClass:
|
|
197
|
+
sourceChannel: "telegram" as const,
|
|
198
|
+
trustClass: "trusted_contact" as const,
|
|
192
199
|
// No guardianExternalUserId
|
|
193
200
|
};
|
|
194
201
|
const state = resolveRoutingStateFromRuntime(runtimeCtx);
|
|
@@ -201,68 +208,78 @@ describe('resolveRoutingStateFromRuntime', () => {
|
|
|
201
208
|
// Integration tests: inbound message handler interactivity
|
|
202
209
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
203
210
|
|
|
204
|
-
describe(
|
|
211
|
+
describe("inbound-message-handler trusted-contact interactivity", () => {
|
|
205
212
|
beforeEach(() => {
|
|
206
213
|
resetTables();
|
|
207
214
|
mockFindMember = null;
|
|
208
215
|
});
|
|
209
216
|
|
|
210
|
-
function makeInboundRequest(
|
|
211
|
-
|
|
212
|
-
|
|
217
|
+
function makeInboundRequest(
|
|
218
|
+
overrides: Record<string, unknown> = {},
|
|
219
|
+
): Request {
|
|
220
|
+
return new Request("http://localhost/channels/inbound", {
|
|
221
|
+
method: "POST",
|
|
213
222
|
headers: {
|
|
214
|
-
|
|
215
|
-
|
|
223
|
+
"Content-Type": "application/json",
|
|
224
|
+
"X-Gateway-Origin": "test-token",
|
|
216
225
|
},
|
|
217
226
|
body: JSON.stringify({
|
|
218
|
-
sourceChannel:
|
|
219
|
-
interface:
|
|
220
|
-
conversationExternalId:
|
|
227
|
+
sourceChannel: "telegram",
|
|
228
|
+
interface: "telegram",
|
|
229
|
+
conversationExternalId: "chat-123",
|
|
221
230
|
externalMessageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
222
|
-
content:
|
|
223
|
-
actorExternalId:
|
|
224
|
-
replyCallbackUrl:
|
|
231
|
+
content: "hello",
|
|
232
|
+
actorExternalId: "telegram-user-default",
|
|
233
|
+
replyCallbackUrl: "https://gateway.test/deliver/telegram",
|
|
225
234
|
...overrides,
|
|
226
235
|
}),
|
|
227
236
|
});
|
|
228
237
|
}
|
|
229
238
|
|
|
230
|
-
test(
|
|
239
|
+
test("trusted contact with guardian binding gets interactive turn", async () => {
|
|
231
240
|
// Create guardian binding so the trusted contact has a resolvable route
|
|
232
241
|
createBinding({
|
|
233
|
-
assistantId:
|
|
234
|
-
channel:
|
|
235
|
-
guardianExternalUserId:
|
|
236
|
-
guardianDeliveryChatId:
|
|
237
|
-
guardianPrincipalId:
|
|
242
|
+
assistantId: "self",
|
|
243
|
+
channel: "telegram",
|
|
244
|
+
guardianExternalUserId: "guardian-user-for-tc",
|
|
245
|
+
guardianDeliveryChatId: "guardian-chat-for-tc",
|
|
246
|
+
guardianPrincipalId: "guardian-user-for-tc",
|
|
238
247
|
});
|
|
239
248
|
|
|
240
249
|
const processCalls: Array<{ options?: Record<string, unknown> }> = [];
|
|
241
|
-
const processMessage = mock(
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
250
|
+
const processMessage = mock(
|
|
251
|
+
async (
|
|
252
|
+
conversationId: string,
|
|
253
|
+
_content: string,
|
|
254
|
+
_attachmentIds?: string[],
|
|
255
|
+
options?: Record<string, unknown>,
|
|
256
|
+
) => {
|
|
257
|
+
processCalls.push({ options });
|
|
258
|
+
const messageId = `msg-tc-interactive-${Date.now()}`;
|
|
259
|
+
const db = getDb();
|
|
260
|
+
db.insert(messages)
|
|
261
|
+
.values({
|
|
262
|
+
id: messageId,
|
|
263
|
+
conversationId,
|
|
264
|
+
role: "user",
|
|
265
|
+
content: JSON.stringify([{ type: "text", text: "hello" }]),
|
|
266
|
+
createdAt: Date.now(),
|
|
267
|
+
})
|
|
268
|
+
.run();
|
|
269
|
+
return { messageId };
|
|
270
|
+
},
|
|
271
|
+
);
|
|
259
272
|
|
|
260
273
|
const req = makeInboundRequest({
|
|
261
274
|
externalMessageId: `msg-tc-interactive-${Date.now()}`,
|
|
262
275
|
});
|
|
263
276
|
|
|
264
|
-
const res = await handleChannelInbound(
|
|
265
|
-
|
|
277
|
+
const res = await handleChannelInbound(
|
|
278
|
+
req,
|
|
279
|
+
processMessage as any,
|
|
280
|
+
"test-token",
|
|
281
|
+
);
|
|
282
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
266
283
|
expect(body.accepted).toBe(true);
|
|
267
284
|
|
|
268
285
|
// Wait for background processing
|
|
@@ -272,36 +289,44 @@ describe('inbound-message-handler trusted-contact interactivity', () => {
|
|
|
272
289
|
expect(processCalls[0].options?.isInteractive).toBe(true);
|
|
273
290
|
});
|
|
274
291
|
|
|
275
|
-
test(
|
|
292
|
+
test("trusted contact WITHOUT guardian binding gets non-interactive turn (fail-fast)", async () => {
|
|
276
293
|
// No guardian binding created — trusted contact has no guardian route
|
|
277
294
|
// but findMember still returns an active member (trusted_contact trust class)
|
|
278
295
|
|
|
279
296
|
const processCalls: Array<{ options?: Record<string, unknown> }> = [];
|
|
280
|
-
const processMessage = mock(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
297
|
+
const processMessage = mock(
|
|
298
|
+
async (
|
|
299
|
+
conversationId: string,
|
|
300
|
+
_content: string,
|
|
301
|
+
_attachmentIds?: string[],
|
|
302
|
+
options?: Record<string, unknown>,
|
|
303
|
+
) => {
|
|
304
|
+
processCalls.push({ options });
|
|
305
|
+
const messageId = `msg-tc-noroute-${Date.now()}`;
|
|
306
|
+
const db = getDb();
|
|
307
|
+
db.insert(messages)
|
|
308
|
+
.values({
|
|
309
|
+
id: messageId,
|
|
310
|
+
conversationId,
|
|
311
|
+
role: "user",
|
|
312
|
+
content: JSON.stringify([{ type: "text", text: "hello" }]),
|
|
313
|
+
createdAt: Date.now(),
|
|
314
|
+
})
|
|
315
|
+
.run();
|
|
316
|
+
return { messageId };
|
|
317
|
+
},
|
|
318
|
+
);
|
|
298
319
|
|
|
299
320
|
const req = makeInboundRequest({
|
|
300
321
|
externalMessageId: `msg-tc-noroute-${Date.now()}`,
|
|
301
322
|
});
|
|
302
323
|
|
|
303
|
-
const res = await handleChannelInbound(
|
|
304
|
-
|
|
324
|
+
const res = await handleChannelInbound(
|
|
325
|
+
req,
|
|
326
|
+
processMessage as any,
|
|
327
|
+
"test-token",
|
|
328
|
+
);
|
|
329
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
305
330
|
expect(body.accepted).toBe(true);
|
|
306
331
|
|
|
307
332
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
@@ -312,42 +337,50 @@ describe('inbound-message-handler trusted-contact interactivity', () => {
|
|
|
312
337
|
expect(processCalls[0].options?.isInteractive).toBe(false);
|
|
313
338
|
});
|
|
314
339
|
|
|
315
|
-
test(
|
|
340
|
+
test("guardian actors remain interactive regardless", async () => {
|
|
316
341
|
// Guardian binding matches the sender
|
|
317
342
|
createBinding({
|
|
318
|
-
assistantId:
|
|
319
|
-
channel:
|
|
320
|
-
guardianExternalUserId:
|
|
321
|
-
guardianDeliveryChatId:
|
|
322
|
-
guardianPrincipalId:
|
|
343
|
+
assistantId: "self",
|
|
344
|
+
channel: "telegram",
|
|
345
|
+
guardianExternalUserId: "telegram-user-default",
|
|
346
|
+
guardianDeliveryChatId: "chat-123",
|
|
347
|
+
guardianPrincipalId: "telegram-user-default",
|
|
323
348
|
});
|
|
324
349
|
|
|
325
350
|
const processCalls: Array<{ options?: Record<string, unknown> }> = [];
|
|
326
|
-
const processMessage = mock(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
351
|
+
const processMessage = mock(
|
|
352
|
+
async (
|
|
353
|
+
conversationId: string,
|
|
354
|
+
_content: string,
|
|
355
|
+
_attachmentIds?: string[],
|
|
356
|
+
options?: Record<string, unknown>,
|
|
357
|
+
) => {
|
|
358
|
+
processCalls.push({ options });
|
|
359
|
+
const messageId = `msg-guardian-${Date.now()}`;
|
|
360
|
+
const db = getDb();
|
|
361
|
+
db.insert(messages)
|
|
362
|
+
.values({
|
|
363
|
+
id: messageId,
|
|
364
|
+
conversationId,
|
|
365
|
+
role: "user",
|
|
366
|
+
content: JSON.stringify([{ type: "text", text: "hello" }]),
|
|
367
|
+
createdAt: Date.now(),
|
|
368
|
+
})
|
|
369
|
+
.run();
|
|
370
|
+
return { messageId };
|
|
371
|
+
},
|
|
372
|
+
);
|
|
344
373
|
|
|
345
374
|
const req = makeInboundRequest({
|
|
346
375
|
externalMessageId: `msg-guardian-${Date.now()}`,
|
|
347
376
|
});
|
|
348
377
|
|
|
349
|
-
const res = await handleChannelInbound(
|
|
350
|
-
|
|
378
|
+
const res = await handleChannelInbound(
|
|
379
|
+
req,
|
|
380
|
+
processMessage as any,
|
|
381
|
+
"test-token",
|
|
382
|
+
);
|
|
383
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
351
384
|
expect(body.accepted).toBe(true);
|
|
352
385
|
|
|
353
386
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
@@ -356,22 +389,22 @@ describe('inbound-message-handler trusted-contact interactivity', () => {
|
|
|
356
389
|
expect(processCalls[0].options?.isInteractive).toBe(true);
|
|
357
390
|
});
|
|
358
391
|
|
|
359
|
-
test(
|
|
392
|
+
test("unknown actors remain non-interactive (denied at gate)", async () => {
|
|
360
393
|
// No member record => non-member denied at the ACL gate,
|
|
361
394
|
// which is the strongest form of "not interactive".
|
|
362
395
|
mockFindMember = () => null;
|
|
363
396
|
|
|
364
397
|
const req = makeInboundRequest({
|
|
365
398
|
externalMessageId: `msg-unknown-${Date.now()}`,
|
|
366
|
-
actorExternalId:
|
|
399
|
+
actorExternalId: "unknown-user-no-member",
|
|
367
400
|
});
|
|
368
401
|
|
|
369
|
-
const res = await handleChannelInbound(req, undefined,
|
|
370
|
-
const body = await res.json() as Record<string, unknown>;
|
|
402
|
+
const res = await handleChannelInbound(req, undefined, "test-token");
|
|
403
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
371
404
|
// Unknown actors are ACL-denied: accepted but denied with reason
|
|
372
405
|
expect(body.accepted).toBe(true);
|
|
373
406
|
expect(body.denied).toBe(true);
|
|
374
|
-
expect(body.reason).toBe(
|
|
407
|
+
expect(body.reason).toBe("not_a_member");
|
|
375
408
|
});
|
|
376
409
|
});
|
|
377
410
|
|
|
@@ -379,22 +412,29 @@ describe('inbound-message-handler trusted-contact interactivity', () => {
|
|
|
379
412
|
// Integration tests: channel-retry-sweep routing state
|
|
380
413
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
381
414
|
|
|
382
|
-
describe(
|
|
415
|
+
describe("channel-retry-sweep routing state", () => {
|
|
383
416
|
beforeEach(() => {
|
|
384
417
|
resetTables();
|
|
385
418
|
mockFindMember = null;
|
|
386
419
|
});
|
|
387
420
|
|
|
388
|
-
function seedFailedEvent(
|
|
389
|
-
|
|
421
|
+
function seedFailedEvent(
|
|
422
|
+
trustClass: "guardian" | "trusted_contact" | "unknown",
|
|
423
|
+
guardianExternalUserId?: string,
|
|
424
|
+
): string {
|
|
425
|
+
const inbound = channelDeliveryStore.recordInbound(
|
|
426
|
+
"telegram",
|
|
427
|
+
`chat-${trustClass}`,
|
|
428
|
+
`msg-${trustClass}-${Date.now()}`,
|
|
429
|
+
);
|
|
390
430
|
channelDeliveryStore.storePayload(inbound.eventId, {
|
|
391
|
-
content:
|
|
392
|
-
sourceChannel:
|
|
393
|
-
interface:
|
|
431
|
+
content: "retry me",
|
|
432
|
+
sourceChannel: "telegram",
|
|
433
|
+
interface: "telegram",
|
|
394
434
|
guardianCtx: {
|
|
395
435
|
trustClass,
|
|
396
|
-
sourceChannel:
|
|
397
|
-
requesterExternalUserId:
|
|
436
|
+
sourceChannel: "telegram",
|
|
437
|
+
requesterExternalUserId: "test-user",
|
|
398
438
|
requesterChatId: `chat-${trustClass}`,
|
|
399
439
|
...(guardianExternalUserId ? { guardianExternalUserId } : {}),
|
|
400
440
|
},
|
|
@@ -403,7 +443,7 @@ describe('channel-retry-sweep routing state', () => {
|
|
|
403
443
|
const db = getDb();
|
|
404
444
|
db.update(channelInboundEvents)
|
|
405
445
|
.set({
|
|
406
|
-
processingStatus:
|
|
446
|
+
processingStatus: "failed",
|
|
407
447
|
processingAttempts: 1,
|
|
408
448
|
retryAfter: Date.now() - 1,
|
|
409
449
|
})
|
|
@@ -413,8 +453,8 @@ describe('channel-retry-sweep routing state', () => {
|
|
|
413
453
|
return inbound.eventId;
|
|
414
454
|
}
|
|
415
455
|
|
|
416
|
-
test(
|
|
417
|
-
seedFailedEvent(
|
|
456
|
+
test("trusted_contact with guardian binding replays as interactive", async () => {
|
|
457
|
+
seedFailedEvent("trusted_contact", "guardian-for-sweep");
|
|
418
458
|
let capturedOptions: { isInteractive?: boolean } | undefined;
|
|
419
459
|
|
|
420
460
|
await sweepFailedEvents(
|
|
@@ -422,13 +462,15 @@ describe('channel-retry-sweep routing state', () => {
|
|
|
422
462
|
capturedOptions = options as { isInteractive?: boolean };
|
|
423
463
|
const messageId = `message-tc-sweep-${Date.now()}`;
|
|
424
464
|
const db = getDb();
|
|
425
|
-
db.insert(messages)
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
465
|
+
db.insert(messages)
|
|
466
|
+
.values({
|
|
467
|
+
id: messageId,
|
|
468
|
+
conversationId,
|
|
469
|
+
role: "user",
|
|
470
|
+
content: JSON.stringify([{ type: "text", text: "retry me" }]),
|
|
471
|
+
createdAt: Date.now(),
|
|
472
|
+
})
|
|
473
|
+
.run();
|
|
432
474
|
return { messageId };
|
|
433
475
|
},
|
|
434
476
|
undefined,
|
|
@@ -437,8 +479,8 @@ describe('channel-retry-sweep routing state', () => {
|
|
|
437
479
|
expect(capturedOptions?.isInteractive).toBe(true);
|
|
438
480
|
});
|
|
439
481
|
|
|
440
|
-
test(
|
|
441
|
-
seedFailedEvent(
|
|
482
|
+
test("trusted_contact without guardian binding replays as non-interactive", async () => {
|
|
483
|
+
seedFailedEvent("trusted_contact");
|
|
442
484
|
let capturedOptions: { isInteractive?: boolean } | undefined;
|
|
443
485
|
|
|
444
486
|
await sweepFailedEvents(
|
|
@@ -446,13 +488,15 @@ describe('channel-retry-sweep routing state', () => {
|
|
|
446
488
|
capturedOptions = options as { isInteractive?: boolean };
|
|
447
489
|
const messageId = `message-tc-no-binding-${Date.now()}`;
|
|
448
490
|
const db = getDb();
|
|
449
|
-
db.insert(messages)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
491
|
+
db.insert(messages)
|
|
492
|
+
.values({
|
|
493
|
+
id: messageId,
|
|
494
|
+
conversationId,
|
|
495
|
+
role: "user",
|
|
496
|
+
content: JSON.stringify([{ type: "text", text: "retry me" }]),
|
|
497
|
+
createdAt: Date.now(),
|
|
498
|
+
})
|
|
499
|
+
.run();
|
|
456
500
|
return { messageId };
|
|
457
501
|
},
|
|
458
502
|
undefined,
|
|
@@ -461,8 +505,8 @@ describe('channel-retry-sweep routing state', () => {
|
|
|
461
505
|
expect(capturedOptions?.isInteractive).toBe(false);
|
|
462
506
|
});
|
|
463
507
|
|
|
464
|
-
test(
|
|
465
|
-
seedFailedEvent(
|
|
508
|
+
test("guardian replays as interactive", async () => {
|
|
509
|
+
seedFailedEvent("guardian", "guardian-self");
|
|
466
510
|
let capturedOptions: { isInteractive?: boolean } | undefined;
|
|
467
511
|
|
|
468
512
|
await sweepFailedEvents(
|
|
@@ -470,13 +514,15 @@ describe('channel-retry-sweep routing state', () => {
|
|
|
470
514
|
capturedOptions = options as { isInteractive?: boolean };
|
|
471
515
|
const messageId = `message-guardian-sweep-${Date.now()}`;
|
|
472
516
|
const db = getDb();
|
|
473
|
-
db.insert(messages)
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
517
|
+
db.insert(messages)
|
|
518
|
+
.values({
|
|
519
|
+
id: messageId,
|
|
520
|
+
conversationId,
|
|
521
|
+
role: "user",
|
|
522
|
+
content: JSON.stringify([{ type: "text", text: "retry me" }]),
|
|
523
|
+
createdAt: Date.now(),
|
|
524
|
+
})
|
|
525
|
+
.run();
|
|
480
526
|
return { messageId };
|
|
481
527
|
},
|
|
482
528
|
undefined,
|
|
@@ -485,8 +531,8 @@ describe('channel-retry-sweep routing state', () => {
|
|
|
485
531
|
expect(capturedOptions?.isInteractive).toBe(true);
|
|
486
532
|
});
|
|
487
533
|
|
|
488
|
-
test(
|
|
489
|
-
seedFailedEvent(
|
|
534
|
+
test("unknown replays as non-interactive", async () => {
|
|
535
|
+
seedFailedEvent("unknown");
|
|
490
536
|
let capturedOptions: { isInteractive?: boolean } | undefined;
|
|
491
537
|
|
|
492
538
|
await sweepFailedEvents(
|
|
@@ -494,13 +540,15 @@ describe('channel-retry-sweep routing state', () => {
|
|
|
494
540
|
capturedOptions = options as { isInteractive?: boolean };
|
|
495
541
|
const messageId = `message-unknown-sweep-${Date.now()}`;
|
|
496
542
|
const db = getDb();
|
|
497
|
-
db.insert(messages)
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
543
|
+
db.insert(messages)
|
|
544
|
+
.values({
|
|
545
|
+
id: messageId,
|
|
546
|
+
conversationId,
|
|
547
|
+
role: "user",
|
|
548
|
+
content: JSON.stringify([{ type: "text", text: "retry me" }]),
|
|
549
|
+
createdAt: Date.now(),
|
|
550
|
+
})
|
|
551
|
+
.run();
|
|
504
552
|
return { messageId };
|
|
505
553
|
},
|
|
506
554
|
undefined,
|