@vellumai/assistant 0.3.4 → 0.3.5
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 +2 -0
- package/README.md +37 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +70 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +21 -17
- package/src/__tests__/channel-approvals.test.ts +48 -1
- package/src/__tests__/channel-guardian.test.ts +74 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +13 -12
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/handlers-twilio-config.test.ts +407 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +22 -11
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +21 -6
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/system-prompt.ts +24 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/vellum-skills/catalog.json +6 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
- package/src/daemon/handlers/config.ts +783 -9
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +108 -4
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/ride-shotgun-handler.ts +1 -1
- package/src/daemon/server.ts +6 -2
- package/src/daemon/session-agent-loop.ts +5 -1
- package/src/daemon/session-runtime-assembly.ts +55 -0
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +11 -1
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-init.ts +144 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +4 -0
- package/src/memory/media-store.ts +759 -0
- package/src/memory/retriever.ts +6 -1
- package/src/memory/schema.ts +98 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +24 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +12 -4
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/http-server.ts +53 -27
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +67 -21
- package/src/runtime/run-orchestrator.ts +35 -2
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +35 -0
|
@@ -80,10 +80,10 @@ mock.module('../calls/twilio-provider.js', () => ({
|
|
|
80
80
|
|
|
81
81
|
// Mock Twilio config
|
|
82
82
|
mock.module('../calls/twilio-config.js', () => ({
|
|
83
|
-
getTwilioConfig: () => ({
|
|
83
|
+
getTwilioConfig: (assistantId?: string) => ({
|
|
84
84
|
accountSid: 'AC_test',
|
|
85
85
|
authToken: 'test_token',
|
|
86
|
-
phoneNumber: '+15550001111',
|
|
86
|
+
phoneNumber: assistantId === 'asst-alpha' ? '+15550009999' : '+15550001111',
|
|
87
87
|
webhookBaseUrl: 'https://test.example.com',
|
|
88
88
|
wssBaseUrl: 'wss://test.example.com',
|
|
89
89
|
}),
|
|
@@ -168,6 +168,10 @@ describe('runtime call routes — HTTP layer', () => {
|
|
|
168
168
|
return `http://127.0.0.1:${port}/v1/calls${path}`;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
function assistantCallsUrl(assistantId: string, path = ''): string {
|
|
172
|
+
return `http://127.0.0.1:${port}/v1/assistants/${assistantId}/calls${path}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
171
175
|
// ── POST /v1/calls/start ────────────────────────────────────────────
|
|
172
176
|
|
|
173
177
|
test('POST /v1/calls/start returns 201 with call session', async () => {
|
|
@@ -222,6 +226,27 @@ describe('runtime call routes — HTTP layer', () => {
|
|
|
222
226
|
await stopServer();
|
|
223
227
|
});
|
|
224
228
|
|
|
229
|
+
test('POST /v1/assistants/:assistantId/calls/start uses assistant-scoped caller number', async () => {
|
|
230
|
+
await startServer();
|
|
231
|
+
ensureConversation('conv-start-scoped-1');
|
|
232
|
+
|
|
233
|
+
const res = await fetch(assistantCallsUrl('asst-alpha', '/start'), {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: { 'Content-Type': 'application/json', ...AUTH_HEADERS },
|
|
236
|
+
body: JSON.stringify({
|
|
237
|
+
phoneNumber: '+15559997777',
|
|
238
|
+
task: 'Check order status',
|
|
239
|
+
conversationId: 'conv-start-scoped-1',
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(res.status).toBe(201);
|
|
244
|
+
const body = await res.json() as { fromNumber: string };
|
|
245
|
+
expect(body.fromNumber).toBe('+15550009999');
|
|
246
|
+
|
|
247
|
+
await stopServer();
|
|
248
|
+
});
|
|
249
|
+
|
|
225
250
|
test('POST /v1/calls/start returns 400 for invalid phone number', async () => {
|
|
226
251
|
await startServer();
|
|
227
252
|
ensureConversation('conv-start-2');
|
|
@@ -1635,7 +1635,9 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1635
1635
|
const replyArgs = deliverSpy.mock.calls[0];
|
|
1636
1636
|
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
1637
1637
|
expect(replyPayload.chatId).toBe('sms-chat-verify');
|
|
1638
|
-
expect(replyPayload.text).
|
|
1638
|
+
expect(typeof replyPayload.text).toBe('string');
|
|
1639
|
+
expect(replyPayload.text.toLowerCase()).toContain('guardian');
|
|
1640
|
+
expect(replyPayload.text.toLowerCase()).toContain('verif');
|
|
1639
1641
|
|
|
1640
1642
|
deliverSpy.mockRestore();
|
|
1641
1643
|
});
|
|
@@ -1668,7 +1670,9 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1668
1670
|
expect(deliverSpy).toHaveBeenCalled();
|
|
1669
1671
|
const replyArgs = deliverSpy.mock.calls[0];
|
|
1670
1672
|
const replyPayload = replyArgs[1] as { chatId: string; text: string };
|
|
1671
|
-
expect(replyPayload.text).
|
|
1673
|
+
expect(typeof replyPayload.text).toBe('string');
|
|
1674
|
+
expect(replyPayload.text.toLowerCase()).toContain('verif');
|
|
1675
|
+
expect(replyPayload.text.toLowerCase()).toContain('failed');
|
|
1672
1676
|
|
|
1673
1677
|
deliverSpy.mockRestore();
|
|
1674
1678
|
});
|
|
@@ -1944,11 +1948,11 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1944
1948
|
|
|
1945
1949
|
// The deny decision should carry guardian setup context for assistant reply generation.
|
|
1946
1950
|
expect(typeof decisionArgs[2]).toBe('string');
|
|
1947
|
-
expect((decisionArgs[2] as string)).toContain('no guardian
|
|
1951
|
+
expect((decisionArgs[2] as string).toLowerCase()).toContain('no guardian');
|
|
1948
1952
|
|
|
1949
1953
|
// The runtime should not send a second deterministic denial notice.
|
|
1950
1954
|
const deterministicNoticeCalls = deliverSpy.mock.calls.filter(
|
|
1951
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian
|
|
1955
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
|
|
1952
1956
|
);
|
|
1953
1957
|
expect(deterministicNoticeCalls.length).toBe(0);
|
|
1954
1958
|
|
|
@@ -2045,11 +2049,11 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
2045
2049
|
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
2046
2050
|
expect(lastDecision[1]).toBe('deny');
|
|
2047
2051
|
expect(typeof lastDecision[2]).toBe('string');
|
|
2048
|
-
expect((lastDecision[2] as string)).toContain('no guardian
|
|
2052
|
+
expect((lastDecision[2] as string).toLowerCase()).toContain('no guardian');
|
|
2049
2053
|
|
|
2050
2054
|
// Interception should not emit a separate deterministic denial notice.
|
|
2051
2055
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
2052
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian
|
|
2056
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('no guardian'),
|
|
2053
2057
|
);
|
|
2054
2058
|
expect(denialCalls.length).toBe(0);
|
|
2055
2059
|
|
|
@@ -2092,9 +2096,9 @@ describe('guardian-with-binding path regression', () => {
|
|
|
2092
2096
|
const approvalArgs = approvalSpy.mock.calls[0];
|
|
2093
2097
|
expect(approvalArgs[1]).toBe('guardian-chat-1');
|
|
2094
2098
|
|
|
2095
|
-
// Requester should have been notified the request was
|
|
2099
|
+
// Requester should have been notified the request was forwarded to the guardian
|
|
2096
2100
|
const notifyCalls = deliverSpy.mock.calls.filter(
|
|
2097
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('
|
|
2101
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('guardian'),
|
|
2098
2102
|
);
|
|
2099
2103
|
expect(notifyCalls.length).toBeGreaterThanOrEqual(1);
|
|
2100
2104
|
|
|
@@ -2223,14 +2227,14 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2223
2227
|
|
|
2224
2228
|
// Requester should have been notified that delivery failed
|
|
2225
2229
|
const failureCalls = deliverSpy.mock.calls.filter(
|
|
2226
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('
|
|
2230
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('denied'),
|
|
2227
2231
|
);
|
|
2228
2232
|
expect(failureCalls.length).toBeGreaterThanOrEqual(1);
|
|
2229
2233
|
|
|
2230
|
-
// The
|
|
2231
|
-
//
|
|
2234
|
+
// The guardian_request_forwarded success notice should NOT have been
|
|
2235
|
+
// delivered (since delivery failed).
|
|
2232
2236
|
const successCalls = deliverSpy.mock.calls.filter(
|
|
2233
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('
|
|
2237
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('forwarded'),
|
|
2234
2238
|
);
|
|
2235
2239
|
expect(successCalls.length).toBe(0);
|
|
2236
2240
|
|
|
@@ -2487,7 +2491,7 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
2487
2491
|
|
|
2488
2492
|
// A disambiguation message should have been sent to the guardian
|
|
2489
2493
|
const disambigCalls = deliverSpy.mock.calls.filter(
|
|
2490
|
-
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('pending
|
|
2494
|
+
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.toLowerCase().includes('pending'),
|
|
2491
2495
|
);
|
|
2492
2496
|
expect(disambigCalls.length).toBeGreaterThanOrEqual(1);
|
|
2493
2497
|
|
|
@@ -3178,12 +3182,12 @@ describe('guardian enforcement behavior', () => {
|
|
|
3178
3182
|
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
3179
3183
|
expect(lastDecision[1]).toBe('deny');
|
|
3180
3184
|
expect(typeof lastDecision[2]).toBe('string');
|
|
3181
|
-
expect((lastDecision[2] as string)).toContain('identity
|
|
3185
|
+
expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
|
|
3182
3186
|
|
|
3183
3187
|
// No separate deterministic denial notice should be emitted here.
|
|
3184
3188
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
3185
3189
|
(call) => typeof call[1] === 'object'
|
|
3186
|
-
&& ((call[1] as { text?: string }).text ?? '').includes('identity
|
|
3190
|
+
&& ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
|
|
3187
3191
|
);
|
|
3188
3192
|
expect(denialCalls.length).toBe(0);
|
|
3189
3193
|
|
|
@@ -3217,11 +3221,11 @@ describe('guardian enforcement behavior', () => {
|
|
|
3217
3221
|
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
3218
3222
|
expect(lastDecision[1]).toBe('deny');
|
|
3219
3223
|
expect(typeof lastDecision[2]).toBe('string');
|
|
3220
|
-
expect((lastDecision[2] as string)).toContain('identity
|
|
3224
|
+
expect((lastDecision[2] as string).toLowerCase()).toContain('identity');
|
|
3221
3225
|
|
|
3222
3226
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
3223
3227
|
(call) => typeof call[1] === 'object'
|
|
3224
|
-
&& ((call[1] as { text?: string }).text ?? '').includes('identity
|
|
3228
|
+
&& ((call[1] as { text?: string }).text ?? '').toLowerCase().includes('identity'),
|
|
3225
3229
|
);
|
|
3226
3230
|
expect(denialCalls.length).toBe(0);
|
|
3227
3231
|
expect(approvalSpy).not.toHaveBeenCalled();
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
buildApprovalUIMetadata,
|
|
42
42
|
handleChannelDecision,
|
|
43
43
|
buildReminderPrompt,
|
|
44
|
+
buildGuardianApprovalPrompt,
|
|
44
45
|
channelSupportsRichApprovalUI,
|
|
45
46
|
} from '../runtime/channel-approvals.js';
|
|
46
47
|
import type { ApprovalDecisionResult, ChannelApprovalPrompt } from '../runtime/channel-approval-types.js';
|
|
@@ -547,7 +548,53 @@ describe('buildReminderPrompt', () => {
|
|
|
547
548
|
});
|
|
548
549
|
|
|
549
550
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
550
|
-
// 5.
|
|
551
|
+
// 5. buildGuardianApprovalPrompt
|
|
552
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
553
|
+
|
|
554
|
+
describe('buildGuardianApprovalPrompt', () => {
|
|
555
|
+
test('prompt includes requester identifier and tool name', () => {
|
|
556
|
+
const runInfo: PendingRunInfo = {
|
|
557
|
+
runId: 'run-g1',
|
|
558
|
+
requestId: 'req-g1',
|
|
559
|
+
toolName: 'deploy',
|
|
560
|
+
input: {},
|
|
561
|
+
riskLevel: 'high',
|
|
562
|
+
};
|
|
563
|
+
const prompt = buildGuardianApprovalPrompt(runInfo, 'alice');
|
|
564
|
+
expect(prompt.promptText).toContain('alice');
|
|
565
|
+
expect(prompt.promptText).toContain('deploy');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test('excludes approve_always action', () => {
|
|
569
|
+
const runInfo: PendingRunInfo = {
|
|
570
|
+
runId: 'run-g2',
|
|
571
|
+
requestId: 'req-g2',
|
|
572
|
+
toolName: 'shell',
|
|
573
|
+
input: {},
|
|
574
|
+
riskLevel: 'medium',
|
|
575
|
+
};
|
|
576
|
+
const prompt = buildGuardianApprovalPrompt(runInfo, 'bob');
|
|
577
|
+
expect(prompt.actions.map((a) => a.id)).not.toContain('approve_always');
|
|
578
|
+
expect(prompt.actions.map((a) => a.id)).toContain('approve_once');
|
|
579
|
+
expect(prompt.actions.map((a) => a.id)).toContain('reject');
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test('plainTextFallback contains parser-compatible keywords', () => {
|
|
583
|
+
const runInfo: PendingRunInfo = {
|
|
584
|
+
runId: 'run-g3',
|
|
585
|
+
requestId: 'req-g3',
|
|
586
|
+
toolName: 'write_file',
|
|
587
|
+
input: {},
|
|
588
|
+
riskLevel: 'high',
|
|
589
|
+
};
|
|
590
|
+
const prompt = buildGuardianApprovalPrompt(runInfo, 'charlie');
|
|
591
|
+
expect(prompt.plainTextFallback).toContain('yes');
|
|
592
|
+
expect(prompt.plainTextFallback).toContain('no');
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
597
|
+
// 6. channelSupportsRichApprovalUI
|
|
551
598
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
552
599
|
|
|
553
600
|
describe('channelSupportsRichApprovalUI', () => {
|
|
@@ -311,28 +311,32 @@ describe('guardian service challenge validation', () => {
|
|
|
311
311
|
resetTables();
|
|
312
312
|
});
|
|
313
313
|
|
|
314
|
-
test('createVerificationChallenge returns a secret and instruction', () => {
|
|
314
|
+
test('createVerificationChallenge returns a secret, verifyCommand, ttlSeconds, and instruction', () => {
|
|
315
315
|
const result = createVerificationChallenge('asst-1', 'telegram');
|
|
316
316
|
|
|
317
317
|
expect(result.challengeId).toBeDefined();
|
|
318
318
|
expect(result.secret).toBeDefined();
|
|
319
319
|
expect(result.secret.length).toBe(64); // 32 bytes hex-encoded
|
|
320
|
+
expect(result.verifyCommand).toBe(`/guardian_verify ${result.secret}`);
|
|
321
|
+
expect(result.ttlSeconds).toBe(600);
|
|
322
|
+
expect(result.instruction).toBeDefined();
|
|
323
|
+
expect(result.instruction.length).toBeGreaterThan(0);
|
|
320
324
|
expect(result.instruction).toContain('/guardian_verify');
|
|
321
|
-
expect(result.instruction).toContain(result.secret);
|
|
322
325
|
});
|
|
323
326
|
|
|
324
|
-
test('createVerificationChallenge
|
|
327
|
+
test('createVerificationChallenge produces a non-empty instruction for telegram channel', () => {
|
|
325
328
|
const result = createVerificationChallenge('asst-1', 'telegram');
|
|
326
|
-
expect(result.instruction).
|
|
327
|
-
expect(result.instruction).
|
|
329
|
+
expect(result.instruction).toBeDefined();
|
|
330
|
+
expect(result.instruction.length).toBeGreaterThan(0);
|
|
331
|
+
expect(result.instruction).toContain(result.verifyCommand);
|
|
328
332
|
});
|
|
329
333
|
|
|
330
|
-
test('createVerificationChallenge
|
|
334
|
+
test('createVerificationChallenge produces a non-empty instruction for sms channel', () => {
|
|
331
335
|
const result = createVerificationChallenge('asst-1', 'sms');
|
|
332
|
-
expect(result.instruction).
|
|
333
|
-
expect(result.instruction).
|
|
336
|
+
expect(result.instruction).toBeDefined();
|
|
337
|
+
expect(result.instruction.length).toBeGreaterThan(0);
|
|
334
338
|
expect(result.instruction).toContain('/guardian_verify');
|
|
335
|
-
expect(result.instruction).toContain(result.
|
|
339
|
+
expect(result.instruction).toContain(result.verifyCommand);
|
|
336
340
|
});
|
|
337
341
|
|
|
338
342
|
test('validateAndConsumeChallenge succeeds with correct secret', () => {
|
|
@@ -377,8 +381,10 @@ describe('guardian service challenge validation', () => {
|
|
|
377
381
|
|
|
378
382
|
expect(result.success).toBe(false);
|
|
379
383
|
if (!result.success) {
|
|
380
|
-
//
|
|
381
|
-
expect(result.reason).
|
|
384
|
+
// Composed failure message — check it is non-empty and contains "failed"
|
|
385
|
+
expect(result.reason).toBeDefined();
|
|
386
|
+
expect(result.reason.length).toBeGreaterThan(0);
|
|
387
|
+
expect(result.reason.toLowerCase()).toContain('failed');
|
|
382
388
|
}
|
|
383
389
|
});
|
|
384
390
|
|
|
@@ -404,8 +410,10 @@ describe('guardian service challenge validation', () => {
|
|
|
404
410
|
|
|
405
411
|
expect(result.success).toBe(false);
|
|
406
412
|
if (!result.success) {
|
|
407
|
-
//
|
|
408
|
-
expect(result.reason).
|
|
413
|
+
// Composed failure message — check it is non-empty and contains "failed"
|
|
414
|
+
expect(result.reason).toBeDefined();
|
|
415
|
+
expect(result.reason.length).toBeGreaterThan(0);
|
|
416
|
+
expect(result.reason.toLowerCase()).toContain('failed');
|
|
409
417
|
}
|
|
410
418
|
});
|
|
411
419
|
|
|
@@ -943,7 +951,9 @@ describe('guardian service rate limiting', () => {
|
|
|
943
951
|
'asst-1', 'telegram', 'another-wrong', 'user-42', 'chat-42',
|
|
944
952
|
);
|
|
945
953
|
expect(result.success).toBe(false);
|
|
946
|
-
expect((result as { reason: string }).reason).
|
|
954
|
+
expect((result as { reason: string }).reason).toBeDefined();
|
|
955
|
+
expect((result as { reason: string }).reason.length).toBeGreaterThan(0);
|
|
956
|
+
expect((result as { reason: string }).reason.toLowerCase()).toContain('failed');
|
|
947
957
|
|
|
948
958
|
// Verify the rate limit record
|
|
949
959
|
const rl = getRateLimit('asst-1', 'telegram', 'user-42', 'chat-42');
|
|
@@ -975,21 +985,38 @@ describe('guardian service rate limiting', () => {
|
|
|
975
985
|
test('rate-limit uses generic failure message (no oracle leakage)', () => {
|
|
976
986
|
createVerificationChallenge('asst-1', 'telegram');
|
|
977
987
|
|
|
978
|
-
//
|
|
979
|
-
|
|
988
|
+
// Capture a normal invalid-code failure response
|
|
989
|
+
const normalFailure = validateAndConsumeChallenge(
|
|
990
|
+
'asst-1', 'telegram', 'wrong-first', 'user-42', 'chat-42',
|
|
991
|
+
);
|
|
992
|
+
expect(normalFailure.success).toBe(false);
|
|
993
|
+
const normalReason = (normalFailure as { reason: string }).reason;
|
|
994
|
+
|
|
995
|
+
// Trigger rate limit (4 more attempts to reach 5 total)
|
|
996
|
+
for (let i = 0; i < 4; i++) {
|
|
980
997
|
validateAndConsumeChallenge(
|
|
981
998
|
'asst-1', 'telegram', `wrong-${i}`, 'user-42', 'chat-42',
|
|
982
999
|
);
|
|
983
1000
|
}
|
|
984
1001
|
|
|
985
|
-
|
|
1002
|
+
// Verify lockout is actually active before testing the rate-limited response
|
|
1003
|
+
const rl = getRateLimit('asst-1', 'telegram', 'user-42', 'chat-42');
|
|
1004
|
+
expect(rl).not.toBeNull();
|
|
1005
|
+
expect(rl!.lockedUntil).toBeGreaterThan(Date.now());
|
|
1006
|
+
|
|
1007
|
+
// The rate-limited response should be indistinguishable from normal failure
|
|
1008
|
+
const rateLimitedResult = validateAndConsumeChallenge(
|
|
986
1009
|
'asst-1', 'telegram', 'anything', 'user-42', 'chat-42',
|
|
987
1010
|
);
|
|
988
|
-
expect(
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
expect(
|
|
1011
|
+
expect(rateLimitedResult.success).toBe(false);
|
|
1012
|
+
const rateLimitedReason = (rateLimitedResult as { reason: string }).reason;
|
|
1013
|
+
|
|
1014
|
+
// Anti-oracle: both responses must be identical
|
|
1015
|
+
expect(rateLimitedReason).toBe(normalReason);
|
|
1016
|
+
|
|
1017
|
+
// Neither should reveal rate-limiting info
|
|
1018
|
+
expect(rateLimitedReason).not.toContain('rate limit');
|
|
1019
|
+
expect(normalReason).not.toContain('rate limit');
|
|
993
1020
|
});
|
|
994
1021
|
|
|
995
1022
|
test('rate limit does not affect different actors', () => {
|
|
@@ -1295,6 +1322,31 @@ describe('IPC handler channel-aware guardian status', () => {
|
|
|
1295
1322
|
expect(resp!.assistantId).toBe('self');
|
|
1296
1323
|
});
|
|
1297
1324
|
|
|
1325
|
+
test('status action returns guardian username/displayName from binding metadata', () => {
|
|
1326
|
+
createBinding({
|
|
1327
|
+
assistantId: 'self',
|
|
1328
|
+
channel: 'telegram',
|
|
1329
|
+
guardianExternalUserId: 'user-43',
|
|
1330
|
+
guardianDeliveryChatId: 'chat-43',
|
|
1331
|
+
metadataJson: JSON.stringify({ username: 'guardian_handle', displayName: 'Guardian Name' }),
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
const { ctx, lastResponse } = createMockCtx();
|
|
1335
|
+
const msg: GuardianVerificationRequest = {
|
|
1336
|
+
type: 'guardian_verification',
|
|
1337
|
+
action: 'status',
|
|
1338
|
+
channel: 'telegram',
|
|
1339
|
+
assistantId: 'self',
|
|
1340
|
+
};
|
|
1341
|
+
|
|
1342
|
+
handleGuardianVerification(msg, mockSocket, ctx);
|
|
1343
|
+
|
|
1344
|
+
const resp = lastResponse();
|
|
1345
|
+
expect(resp).not.toBeNull();
|
|
1346
|
+
expect(resp!.guardianUsername).toBe('guardian_handle');
|
|
1347
|
+
expect(resp!.guardianDisplayName).toBe('Guardian Name');
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1298
1350
|
test('status action defaults channel to telegram when omitted (backward compat)', () => {
|
|
1299
1351
|
const { ctx, lastResponse } = createMockCtx();
|
|
1300
1352
|
const msg: GuardianVerificationRequest = {
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, mock } from 'bun:test';
|
|
2
|
+
import { ChannelReadinessService, REMOTE_TTL_MS } from '../runtime/channel-readiness-service.js';
|
|
3
|
+
import type { ChannelProbe, ReadinessCheckResult } from '../runtime/channel-readiness-types.js';
|
|
4
|
+
|
|
5
|
+
// ── Test helpers ────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function makeProbe(
|
|
8
|
+
channel: string,
|
|
9
|
+
localResults: ReadinessCheckResult[],
|
|
10
|
+
remoteResults?: ReadinessCheckResult[],
|
|
11
|
+
): ChannelProbe & { localCallCount: number; remoteCallCount: number } {
|
|
12
|
+
const probe = {
|
|
13
|
+
channel,
|
|
14
|
+
localCallCount: 0,
|
|
15
|
+
remoteCallCount: 0,
|
|
16
|
+
runLocalChecks(): ReadinessCheckResult[] {
|
|
17
|
+
probe.localCallCount++;
|
|
18
|
+
return localResults;
|
|
19
|
+
},
|
|
20
|
+
...(remoteResults !== undefined
|
|
21
|
+
? {
|
|
22
|
+
async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
|
|
23
|
+
probe.remoteCallCount++;
|
|
24
|
+
return remoteResults;
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
: {}),
|
|
28
|
+
};
|
|
29
|
+
return probe;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe('ChannelReadinessService', () => {
|
|
35
|
+
let service: ChannelReadinessService;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
service = new ChannelReadinessService();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('local checks run on every call (no caching of local results)', async () => {
|
|
42
|
+
const probe = makeProbe('sms', [
|
|
43
|
+
{ name: 'creds', passed: true, message: 'ok' },
|
|
44
|
+
]);
|
|
45
|
+
service.registerProbe(probe);
|
|
46
|
+
|
|
47
|
+
await service.getReadiness('sms');
|
|
48
|
+
await service.getReadiness('sms');
|
|
49
|
+
|
|
50
|
+
expect(probe.localCallCount).toBe(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('cache miss runs local checks and returns snapshot', async () => {
|
|
54
|
+
const probe = makeProbe('sms', [
|
|
55
|
+
{ name: 'creds', passed: true, message: 'ok' },
|
|
56
|
+
{ name: 'phone', passed: false, message: 'missing' },
|
|
57
|
+
]);
|
|
58
|
+
service.registerProbe(probe);
|
|
59
|
+
|
|
60
|
+
const [snapshot] = await service.getReadiness('sms');
|
|
61
|
+
|
|
62
|
+
expect(probe.localCallCount).toBe(1);
|
|
63
|
+
expect(snapshot.channel).toBe('sms');
|
|
64
|
+
expect(snapshot.ready).toBe(false);
|
|
65
|
+
expect(snapshot.localChecks).toHaveLength(2);
|
|
66
|
+
expect(snapshot.reasons).toEqual([
|
|
67
|
+
{ code: 'phone', text: 'missing' },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('includeRemote=true runs remote checks on cache miss', async () => {
|
|
72
|
+
const probe = makeProbe(
|
|
73
|
+
'sms',
|
|
74
|
+
[{ name: 'creds', passed: true, message: 'ok' }],
|
|
75
|
+
[{ name: 'api_check', passed: true, message: 'remote ok' }],
|
|
76
|
+
);
|
|
77
|
+
service.registerProbe(probe);
|
|
78
|
+
|
|
79
|
+
const [snapshot] = await service.getReadiness('sms', true);
|
|
80
|
+
|
|
81
|
+
expect(probe.remoteCallCount).toBe(1);
|
|
82
|
+
expect(snapshot.remoteChecks).toHaveLength(1);
|
|
83
|
+
expect(snapshot.remoteChecks![0].passed).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('cached remote checks reused within TTL', async () => {
|
|
87
|
+
const probe = makeProbe(
|
|
88
|
+
'sms',
|
|
89
|
+
[{ name: 'creds', passed: true, message: 'ok' }],
|
|
90
|
+
[{ name: 'api_check', passed: true, message: 'remote ok' }],
|
|
91
|
+
);
|
|
92
|
+
service.registerProbe(probe);
|
|
93
|
+
|
|
94
|
+
// First call populates cache
|
|
95
|
+
await service.getReadiness('sms', true);
|
|
96
|
+
expect(probe.remoteCallCount).toBe(1);
|
|
97
|
+
|
|
98
|
+
// Second call within TTL should reuse cache
|
|
99
|
+
const [snapshot] = await service.getReadiness('sms', true);
|
|
100
|
+
expect(probe.remoteCallCount).toBe(1);
|
|
101
|
+
expect(snapshot.remoteChecks).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('stale cache triggers remote check re-run', async () => {
|
|
105
|
+
const probe = makeProbe(
|
|
106
|
+
'sms',
|
|
107
|
+
[{ name: 'creds', passed: true, message: 'ok' }],
|
|
108
|
+
[{ name: 'api_check', passed: true, message: 'remote ok' }],
|
|
109
|
+
);
|
|
110
|
+
service.registerProbe(probe);
|
|
111
|
+
|
|
112
|
+
// First call
|
|
113
|
+
await service.getReadiness('sms', true);
|
|
114
|
+
expect(probe.remoteCallCount).toBe(1);
|
|
115
|
+
|
|
116
|
+
// Manually age the cached snapshot beyond TTL
|
|
117
|
+
const cached = (service as unknown as { snapshots: Map<string, { checkedAt: number }> }).snapshots.get('sms')!;
|
|
118
|
+
cached.checkedAt = Date.now() - REMOTE_TTL_MS - 1;
|
|
119
|
+
|
|
120
|
+
// Second call should re-run remote checks
|
|
121
|
+
await service.getReadiness('sms', true);
|
|
122
|
+
expect(probe.remoteCallCount).toBe(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('invalidateChannel clears cache for specific channel', async () => {
|
|
126
|
+
const probe = makeProbe(
|
|
127
|
+
'sms',
|
|
128
|
+
[{ name: 'creds', passed: true, message: 'ok' }],
|
|
129
|
+
[{ name: 'api_check', passed: true, message: 'remote ok' }],
|
|
130
|
+
);
|
|
131
|
+
service.registerProbe(probe);
|
|
132
|
+
|
|
133
|
+
await service.getReadiness('sms', true);
|
|
134
|
+
expect(probe.remoteCallCount).toBe(1);
|
|
135
|
+
|
|
136
|
+
service.invalidateChannel('sms');
|
|
137
|
+
|
|
138
|
+
// After invalidation, remote checks should run again
|
|
139
|
+
await service.getReadiness('sms', true);
|
|
140
|
+
expect(probe.remoteCallCount).toBe(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('invalidateAll clears all cached snapshots', async () => {
|
|
144
|
+
const smsProbe = makeProbe(
|
|
145
|
+
'sms',
|
|
146
|
+
[{ name: 'creds', passed: true, message: 'ok' }],
|
|
147
|
+
[{ name: 'api', passed: true, message: 'ok' }],
|
|
148
|
+
);
|
|
149
|
+
const telegramProbe = makeProbe(
|
|
150
|
+
'telegram',
|
|
151
|
+
[{ name: 'token', passed: true, message: 'ok' }],
|
|
152
|
+
[{ name: 'webhook', passed: true, message: 'ok' }],
|
|
153
|
+
);
|
|
154
|
+
service.registerProbe(smsProbe);
|
|
155
|
+
service.registerProbe(telegramProbe);
|
|
156
|
+
|
|
157
|
+
await service.getReadiness(undefined, true);
|
|
158
|
+
expect(smsProbe.remoteCallCount).toBe(1);
|
|
159
|
+
expect(telegramProbe.remoteCallCount).toBe(1);
|
|
160
|
+
|
|
161
|
+
service.invalidateAll();
|
|
162
|
+
|
|
163
|
+
await service.getReadiness(undefined, true);
|
|
164
|
+
expect(smsProbe.remoteCallCount).toBe(2);
|
|
165
|
+
expect(telegramProbe.remoteCallCount).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('unknown channel returns unsupported_channel reason', async () => {
|
|
169
|
+
const [snapshot] = await service.getReadiness('carrier_pigeon');
|
|
170
|
+
|
|
171
|
+
expect(snapshot.channel).toBe('carrier_pigeon');
|
|
172
|
+
expect(snapshot.ready).toBe(false);
|
|
173
|
+
expect(snapshot.reasons).toEqual([
|
|
174
|
+
{ code: 'unsupported_channel', text: 'Channel carrier_pigeon is not supported' },
|
|
175
|
+
]);
|
|
176
|
+
expect(snapshot.localChecks).toHaveLength(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('all checks passing yields ready=true', async () => {
|
|
180
|
+
const probe = makeProbe('test', [
|
|
181
|
+
{ name: 'a', passed: true, message: 'ok' },
|
|
182
|
+
{ name: 'b', passed: true, message: 'ok' },
|
|
183
|
+
]);
|
|
184
|
+
service.registerProbe(probe);
|
|
185
|
+
|
|
186
|
+
const [snapshot] = await service.getReadiness('test');
|
|
187
|
+
|
|
188
|
+
expect(snapshot.ready).toBe(true);
|
|
189
|
+
expect(snapshot.reasons).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('getReadiness with no channel returns all registered channels', async () => {
|
|
193
|
+
service.registerProbe(makeProbe('sms', [{ name: 'a', passed: true, message: 'ok' }]));
|
|
194
|
+
service.registerProbe(makeProbe('telegram', [{ name: 'b', passed: true, message: 'ok' }]));
|
|
195
|
+
|
|
196
|
+
const snapshots = await service.getReadiness();
|
|
197
|
+
|
|
198
|
+
expect(snapshots).toHaveLength(2);
|
|
199
|
+
const channels = snapshots.map((s) => s.channel).sort();
|
|
200
|
+
expect(channels).toEqual(['sms', 'telegram']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('cached remote checks preserve original checkedAt (TTL not reset on reuse)', async () => {
|
|
204
|
+
const probe = makeProbe(
|
|
205
|
+
'sms',
|
|
206
|
+
[{ name: 'creds', passed: true, message: 'ok' }],
|
|
207
|
+
[{ name: 'api_check', passed: true, message: 'remote ok' }],
|
|
208
|
+
);
|
|
209
|
+
service.registerProbe(probe);
|
|
210
|
+
|
|
211
|
+
// First call populates cache with freshly fetched remote checks
|
|
212
|
+
const [first] = await service.getReadiness('sms', true);
|
|
213
|
+
const originalCheckedAt = first.checkedAt;
|
|
214
|
+
expect(probe.remoteCallCount).toBe(1);
|
|
215
|
+
|
|
216
|
+
// Second call within TTL reuses cache — checkedAt must stay at the original value
|
|
217
|
+
const [second] = await service.getReadiness('sms', true);
|
|
218
|
+
expect(probe.remoteCallCount).toBe(1);
|
|
219
|
+
expect(second.checkedAt).toBe(originalCheckedAt);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('includeRemote runs remote checks when cache exists without remote data', async () => {
|
|
223
|
+
const probe = makeProbe(
|
|
224
|
+
'sms',
|
|
225
|
+
[{ name: 'creds', passed: true, message: 'ok' }],
|
|
226
|
+
[{ name: 'api_check', passed: true, message: 'remote ok' }],
|
|
227
|
+
);
|
|
228
|
+
service.registerProbe(probe);
|
|
229
|
+
|
|
230
|
+
// First call without includeRemote — cache has no remote data
|
|
231
|
+
await service.getReadiness('sms', false);
|
|
232
|
+
expect(probe.remoteCallCount).toBe(0);
|
|
233
|
+
|
|
234
|
+
// Second call with includeRemote — should run remote checks even though
|
|
235
|
+
// the cached snapshot exists (because it has no remoteChecks)
|
|
236
|
+
const [snapshot] = await service.getReadiness('sms', true);
|
|
237
|
+
expect(probe.remoteCallCount).toBe(1);
|
|
238
|
+
expect(snapshot.remoteChecks).toHaveLength(1);
|
|
239
|
+
expect(snapshot.remoteChecks![0].passed).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('failed remote check makes channel not ready', async () => {
|
|
243
|
+
const probe = makeProbe(
|
|
244
|
+
'sms',
|
|
245
|
+
[{ name: 'creds', passed: true, message: 'ok' }],
|
|
246
|
+
[{ name: 'api_check', passed: false, message: 'API unreachable' }],
|
|
247
|
+
);
|
|
248
|
+
service.registerProbe(probe);
|
|
249
|
+
|
|
250
|
+
const [snapshot] = await service.getReadiness('sms', true);
|
|
251
|
+
|
|
252
|
+
expect(snapshot.ready).toBe(false);
|
|
253
|
+
expect(snapshot.reasons).toEqual([
|
|
254
|
+
{ code: 'api_check', text: 'API unreachable' },
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
@@ -206,6 +206,7 @@ describe('AssistantConfigSchema', () => {
|
|
|
206
206
|
maxEdges: 40,
|
|
207
207
|
neighborScoreMultiplier: 0.7,
|
|
208
208
|
maxDepth: 3,
|
|
209
|
+
depthDecay: true,
|
|
209
210
|
});
|
|
210
211
|
});
|
|
211
212
|
|
|
@@ -681,7 +682,7 @@ describe('AssistantConfigSchema', () => {
|
|
|
681
682
|
userConsultTimeoutSeconds: 120,
|
|
682
683
|
disclosure: {
|
|
683
684
|
enabled: true,
|
|
684
|
-
text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the
|
|
685
|
+
text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent. Do not say "AI assistant".',
|
|
685
686
|
},
|
|
686
687
|
safety: {
|
|
687
688
|
denyCategories: [],
|