@vellumai/assistant 0.4.2 → 0.4.4
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/.env.example +3 -0
- package/ARCHITECTURE.md +124 -10
- package/README.md +43 -35
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +1 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/actor-token-service.test.ts +1099 -0
- package/src/__tests__/agent-loop.test.ts +51 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
- package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
- package/src/__tests__/call-controller.test.ts +49 -0
- package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
- package/src/__tests__/call-pointer-messages.test.ts +93 -3
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/callback-handoff-copy.test.ts +186 -0
- package/src/__tests__/channel-approval-routes.test.ts +133 -12
- package/src/__tests__/channel-guardian.test.ts +0 -86
- package/src/__tests__/channel-readiness-service.test.ts +10 -16
- package/src/__tests__/checker.test.ts +33 -12
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
- package/src/__tests__/conversation-routes.test.ts +12 -3
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/daemon-server-session-init.test.ts +4 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
- package/src/__tests__/guardian-dispatch.test.ts +8 -0
- package/src/__tests__/guardian-outbound-http.test.ts +4 -5
- package/src/__tests__/guardian-question-mode.test.ts +200 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
- package/src/__tests__/guardian-routing-state.test.ts +525 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
- package/src/__tests__/handlers-telegram-config.test.ts +0 -83
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
- package/src/__tests__/headless-browser-navigate.test.ts +2 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/ipc-snapshot.test.ts +18 -51
- package/src/__tests__/non-member-access-request.test.ts +159 -9
- package/src/__tests__/notification-decision-fallback.test.ts +129 -4
- package/src/__tests__/notification-decision-strategy.test.ts +106 -2
- package/src/__tests__/notification-guardian-path.test.ts +3 -0
- package/src/__tests__/recording-intent-handler.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +1475 -33
- package/src/__tests__/send-endpoint-busy.test.ts +5 -0
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-confirmation-signals.test.ts +523 -0
- package/src/__tests__/session-init.benchmark.test.ts +0 -2
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
- package/src/__tests__/tool-executor.test.ts +21 -2
- package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +0 -1
- package/src/__tests__/twilio-config.test.ts +2 -13
- package/src/__tests__/twilio-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-decision-primitive.ts +12 -3
- package/src/approvals/guardian-request-resolvers.ts +169 -11
- package/src/calls/call-constants.ts +29 -0
- package/src/calls/call-controller.ts +11 -3
- package/src/calls/call-domain.ts +33 -11
- package/src/calls/call-pointer-message-composer.ts +154 -0
- package/src/calls/call-pointer-messages.ts +106 -27
- package/src/calls/guardian-dispatch.ts +4 -2
- package/src/calls/relay-server.ts +921 -112
- package/src/calls/twilio-config.ts +4 -11
- package/src/calls/twilio-routes.ts +4 -6
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/cli.ts +5 -4
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
- package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
- package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
- package/src/config/bundled-skills/messaging/SKILL.md +61 -12
- package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
- package/src/config/bundled-skills/twitter/SKILL.md +3 -3
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
- package/src/config/calls-schema.ts +36 -0
- package/src/config/env.ts +22 -0
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/schema.ts +2 -2
- package/src/config/skills.ts +11 -0
- package/src/config/system-prompt.ts +11 -1
- package/src/config/templates/SOUL.md +2 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
- package/src/daemon/call-pointer-generators.ts +59 -0
- package/src/daemon/computer-use-session.ts +2 -5
- package/src/daemon/handlers/apps.ts +76 -20
- package/src/daemon/handlers/config-channels.ts +9 -61
- package/src/daemon/handlers/config-inbox.ts +11 -3
- package/src/daemon/handlers/config-ingress.ts +28 -3
- package/src/daemon/handlers/config-telegram.ts +12 -0
- package/src/daemon/handlers/config.ts +2 -6
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/pairing.ts +2 -0
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +59 -5
- package/src/daemon/handlers/shared.ts +17 -2
- package/src/daemon/ipc-contract/apps.ts +1 -0
- package/src/daemon/ipc-contract/inbox.ts +4 -0
- package/src/daemon/ipc-contract/integrations.ts +1 -97
- package/src/daemon/ipc-contract/messages.ts +47 -1
- package/src/daemon/ipc-contract/notifications.ts +11 -0
- package/src/daemon/ipc-contract-inventory.json +2 -4
- package/src/daemon/lifecycle.ts +17 -0
- package/src/daemon/server.ts +16 -2
- package/src/daemon/session-agent-loop-handlers.ts +20 -0
- package/src/daemon/session-agent-loop.ts +24 -12
- package/src/daemon/session-lifecycle.ts +1 -1
- package/src/daemon/session-process.ts +11 -1
- package/src/daemon/session-runtime-assembly.ts +6 -1
- package/src/daemon/session-surfaces.ts +32 -3
- package/src/daemon/session.ts +88 -1
- package/src/daemon/tool-side-effects.ts +22 -0
- package/src/home-base/prebuilt/brain-graph.html +1483 -0
- package/src/home-base/prebuilt/index.html +40 -0
- package/src/inbound/platform-callback-registration.ts +157 -0
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +8 -0
- package/src/memory/delivery-crud.ts +2 -1
- package/src/memory/guardian-action-store.ts +2 -1
- package/src/memory/guardian-approvals.ts +3 -2
- package/src/memory/ingress-invite-store.ts +12 -2
- package/src/memory/ingress-member-store.ts +4 -3
- package/src/memory/migrations/038-actor-token-records.ts +39 -0
- package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema.ts +26 -5
- package/src/messaging/provider-types.ts +24 -0
- package/src/messaging/provider.ts +7 -0
- package/src/messaging/providers/gmail/adapter.ts +127 -0
- package/src/messaging/providers/sms/adapter.ts +40 -37
- package/src/notifications/adapters/macos.ts +45 -2
- package/src/notifications/broadcaster.ts +16 -0
- package/src/notifications/copy-composer.ts +50 -2
- package/src/notifications/decision-engine.ts +22 -9
- package/src/notifications/destination-resolver.ts +16 -2
- package/src/notifications/emit-signal.ts +18 -9
- package/src/notifications/guardian-question-mode.ts +419 -0
- package/src/notifications/signal.ts +14 -3
- package/src/permissions/checker.ts +13 -1
- package/src/permissions/prompter.ts +14 -0
- package/src/providers/anthropic/client.ts +20 -0
- package/src/providers/provider-send-message.ts +15 -3
- package/src/runtime/access-request-helper.ts +82 -4
- package/src/runtime/actor-token-service.ts +234 -0
- package/src/runtime/actor-token-store.ts +236 -0
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/channel-approvals.ts +5 -3
- package/src/runtime/channel-readiness-service.ts +23 -64
- package/src/runtime/channel-readiness-types.ts +3 -4
- package/src/runtime/channel-retry-sweep.ts +4 -1
- package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-context-resolver.ts +82 -0
- package/src/runtime/guardian-outbound-actions.ts +5 -7
- package/src/runtime/guardian-reply-router.ts +67 -30
- package/src/runtime/guardian-vellum-migration.ts +57 -0
- package/src/runtime/http-server.ts +75 -31
- package/src/runtime/http-types.ts +13 -0
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +10 -1
- package/src/runtime/local-actor-identity.ts +76 -0
- package/src/runtime/middleware/actor-token.ts +271 -0
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/approval-routes.ts +82 -7
- package/src/runtime/routes/brain-graph-routes.ts +222 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-readiness-routes.ts +71 -0
- package/src/runtime/routes/channel-route-shared.ts +3 -3
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +142 -53
- package/src/runtime/routes/events-routes.ts +22 -8
- package/src/runtime/routes/guardian-action-routes.ts +45 -3
- package/src/runtime/routes/guardian-approval-interception.ts +29 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +147 -5
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/runtime/routes/integration-routes.ts +7 -15
- package/src/runtime/routes/pairing-routes.ts +163 -0
- package/src/runtime/routes/twilio-routes.ts +934 -0
- package/src/runtime/tool-grant-request-helper.ts +3 -1
- package/src/security/oauth2.ts +27 -2
- package/src/security/token-manager.ts +46 -10
- package/src/tools/browser/browser-execution.ts +4 -3
- package/src/tools/browser/browser-handoff.ts +10 -18
- package/src/tools/browser/browser-manager.ts +80 -25
- package/src/tools/browser/browser-screencast.ts +35 -119
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/permission-checker.ts +15 -4
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +244 -19
- package/src/workspace/git-service.ts +19 -0
- package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
- package/src/daemon/handlers/config-twilio.ts +0 -1082
|
@@ -461,6 +461,57 @@ describe('AgentLoop', () => {
|
|
|
461
461
|
expect(warningBlock).toBeDefined();
|
|
462
462
|
});
|
|
463
463
|
|
|
464
|
+
test('runs without limit when maxToolUseTurns is 0', async () => {
|
|
465
|
+
// Use 20 turns (beyond old default of 8 used in other tests) to verify no cap
|
|
466
|
+
const turnCount = 20;
|
|
467
|
+
const responses: ProviderResponse[] = [];
|
|
468
|
+
for (let i = 0; i < turnCount; i++) {
|
|
469
|
+
responses.push(toolUseResponse(`t${i}`, 'read_file', { path: `/${i}.txt` }));
|
|
470
|
+
}
|
|
471
|
+
responses.push(textResponse('done'));
|
|
472
|
+
const { provider, calls } = createMockProvider(responses);
|
|
473
|
+
const toolExecutor = async () => ({ content: 'data', isError: false });
|
|
474
|
+
const loop = new AgentLoop(
|
|
475
|
+
provider,
|
|
476
|
+
'system',
|
|
477
|
+
{ maxToolUseTurns: 0, minTurnIntervalMs: 0 },
|
|
478
|
+
dummyTools,
|
|
479
|
+
toolExecutor,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const events: AgentEvent[] = [];
|
|
483
|
+
await loop.run([userMessage], collectEvents(events));
|
|
484
|
+
|
|
485
|
+
// All 20 tool turns + 1 final text response = 21 provider calls
|
|
486
|
+
expect(calls).toHaveLength(turnCount + 1);
|
|
487
|
+
|
|
488
|
+
// No hard-limit error events should have been emitted
|
|
489
|
+
const errorEvents = events.filter(
|
|
490
|
+
(e): e is Extract<AgentEvent, { type: 'error' }> => e.type === 'error',
|
|
491
|
+
);
|
|
492
|
+
expect(errorEvents).toHaveLength(0);
|
|
493
|
+
|
|
494
|
+
// Progress check reminders should still fire every 5 turns
|
|
495
|
+
const progressChecks = calls.filter((call) => {
|
|
496
|
+
const lastMsg = call.messages[call.messages.length - 1];
|
|
497
|
+
return lastMsg.content.some(
|
|
498
|
+
(b): b is Extract<ContentBlock, { type: 'text' }> =>
|
|
499
|
+
b.type === 'text' && b.text.includes('making meaningful progress'),
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
expect(progressChecks.length).toBeGreaterThanOrEqual(3);
|
|
503
|
+
|
|
504
|
+
// No approaching-limit warnings should appear
|
|
505
|
+
const limitWarnings = calls.filter((call) => {
|
|
506
|
+
const lastMsg = call.messages[call.messages.length - 1];
|
|
507
|
+
return lastMsg.content.some(
|
|
508
|
+
(b): b is Extract<ContentBlock, { type: 'text' }> =>
|
|
509
|
+
b.type === 'text' && b.text.includes('approaching the tool-use turn limit'),
|
|
510
|
+
);
|
|
511
|
+
});
|
|
512
|
+
expect(limitWarnings).toHaveLength(0);
|
|
513
|
+
});
|
|
514
|
+
|
|
464
515
|
// 9. Tool executor error results are forwarded correctly
|
|
465
516
|
test('forwards tool error results to provider', async () => {
|
|
466
517
|
const { provider, calls } = createMockProvider([
|
|
@@ -83,6 +83,7 @@ function makeIdleSession(opts?: {
|
|
|
83
83
|
setCommandIntent: () => {},
|
|
84
84
|
setTurnChannelContext: () => {},
|
|
85
85
|
setTurnInterfaceContext: () => {},
|
|
86
|
+
setStateSignalListener: () => {},
|
|
86
87
|
updateClient: () => {},
|
|
87
88
|
enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
|
|
88
89
|
hasAnyPendingConfirmation: () => false,
|
|
@@ -125,6 +126,7 @@ function makeConfirmationEmittingSession(opts?: {
|
|
|
125
126
|
setCommandIntent: () => {},
|
|
126
127
|
setTurnChannelContext: () => {},
|
|
127
128
|
setTurnInterfaceContext: () => {},
|
|
129
|
+
setStateSignalListener: () => {},
|
|
128
130
|
updateClient: () => {},
|
|
129
131
|
enqueueMessage: () => ({ queued: false, requestId: 'noop' }),
|
|
130
132
|
hasAnyPendingConfirmation: () => false,
|
|
@@ -150,7 +150,7 @@ describe('SSE route — capacity limit', () => {
|
|
|
150
150
|
|
|
151
151
|
test('new connection evicts oldest and returns 200', async () => {
|
|
152
152
|
const hub = new AssistantEventHub({ maxSubscribers: 1 });
|
|
153
|
-
const opts = { hub, heartbeatIntervalMs: 60_000 };
|
|
153
|
+
const opts = { hub, heartbeatIntervalMs: 60_000, skipActorVerification: true as const };
|
|
154
154
|
|
|
155
155
|
const ac1 = new AbortController();
|
|
156
156
|
const req1 = new Request('http://localhost/v1/events?conversationKey=evict-a', { signal: ac1.signal });
|
|
@@ -181,7 +181,7 @@ describe('SSE route — capacity limit', () => {
|
|
|
181
181
|
{ signal: new AbortController().signal },
|
|
182
182
|
);
|
|
183
183
|
|
|
184
|
-
const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
|
|
184
|
+
const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
|
|
185
185
|
expect(response.status).toBe(503);
|
|
186
186
|
const body = await response.json() as { error: { message: string; code?: string } };
|
|
187
187
|
expect(body.error.message).toMatch(/Too many concurrent connections/);
|
|
@@ -195,7 +195,7 @@ describe('SSE route — capacity limit', () => {
|
|
|
195
195
|
{ signal: ac.signal },
|
|
196
196
|
);
|
|
197
197
|
|
|
198
|
-
const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
|
|
198
|
+
const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
|
|
199
199
|
|
|
200
200
|
expect(response.status).toBe(200);
|
|
201
201
|
ac.abort(); // clean up the subscription
|
|
@@ -218,6 +218,7 @@ describe('SSE route — heartbeat', () => {
|
|
|
218
218
|
const response = handleSubscribeAssistantEvents(req, new URL(req.url), {
|
|
219
219
|
hub,
|
|
220
220
|
heartbeatIntervalMs: 10,
|
|
221
|
+
skipActorVerification: true,
|
|
221
222
|
});
|
|
222
223
|
|
|
223
224
|
// Wait for at least one heartbeat interval to fire.
|
|
@@ -243,6 +244,7 @@ describe('SSE route — heartbeat', () => {
|
|
|
243
244
|
const response = handleSubscribeAssistantEvents(req, new URL(req.url), {
|
|
244
245
|
hub,
|
|
245
246
|
heartbeatIntervalMs: 10,
|
|
247
|
+
skipActorVerification: true,
|
|
246
248
|
});
|
|
247
249
|
|
|
248
250
|
// Wait for several intervals.
|
|
@@ -283,7 +285,7 @@ describe('SSE route — disconnect cleanup', () => {
|
|
|
283
285
|
{ signal: ac.signal },
|
|
284
286
|
);
|
|
285
287
|
|
|
286
|
-
handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
|
|
288
|
+
handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
|
|
287
289
|
|
|
288
290
|
expect(hub.subscriberCount()).toBe(1);
|
|
289
291
|
|
|
@@ -303,7 +305,7 @@ describe('SSE route — disconnect cleanup', () => {
|
|
|
303
305
|
{ signal: ac.signal },
|
|
304
306
|
);
|
|
305
307
|
|
|
306
|
-
const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub });
|
|
308
|
+
const response = handleSubscribeAssistantEvents(req, new URL(req.url), { hub, skipActorVerification: true });
|
|
307
309
|
|
|
308
310
|
expect(hub.subscriberCount()).toBe(1);
|
|
309
311
|
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import { DAEMON_INTERNAL_ASSISTANT_ID } from '../runtime/assistant-scope.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Guard tests for the assistant identity boundary.
|
|
11
|
+
*
|
|
12
|
+
* The daemon uses a fixed internal scope constant (`DAEMON_INTERNAL_ASSISTANT_ID`)
|
|
13
|
+
* for all assistant-scoped storage. Public assistant IDs are an edge concern
|
|
14
|
+
* handled by the gateway/platform layer — they must not leak into daemon
|
|
15
|
+
* scoping logic.
|
|
16
|
+
*
|
|
17
|
+
* These tests prevent regressions by scanning source files for banned patterns:
|
|
18
|
+
* - No `normalizeAssistantId` usage in daemon/runtime scoping modules
|
|
19
|
+
* - No assistant-scoped route handlers in the daemon HTTP server
|
|
20
|
+
* - No hardcoded `'self'` string for assistant scoping (use the constant)
|
|
21
|
+
* - The constant itself equals `'self'`
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** Resolve repo root (tests run from assistant/). */
|
|
29
|
+
function getRepoRoot(): string {
|
|
30
|
+
return join(process.cwd(), '..');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Directories containing daemon/runtime source files that must not reference
|
|
35
|
+
* `normalizeAssistantId` or hardcode assistant scope strings.
|
|
36
|
+
*
|
|
37
|
+
* Each directory gets both a `*.ts` glob (top-level files) and a `**\/*.ts`
|
|
38
|
+
* glob (nested files) so that `git grep` matches at all directory depths.
|
|
39
|
+
*/
|
|
40
|
+
const SCANNED_DIRS = [
|
|
41
|
+
'assistant/src/runtime',
|
|
42
|
+
'assistant/src/daemon',
|
|
43
|
+
'assistant/src/memory',
|
|
44
|
+
'assistant/src/approvals',
|
|
45
|
+
'assistant/src/calls',
|
|
46
|
+
'assistant/src/tools',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const SCANNED_DIR_GLOBS = SCANNED_DIRS.flatMap((dir) => [`${dir}/*.ts`, `${dir}/**/*.ts`]);
|
|
50
|
+
|
|
51
|
+
function isTestFile(filePath: string): boolean {
|
|
52
|
+
return (
|
|
53
|
+
filePath.includes('/__tests__/') ||
|
|
54
|
+
filePath.endsWith('.test.ts') ||
|
|
55
|
+
filePath.endsWith('.test.js') ||
|
|
56
|
+
filePath.endsWith('.spec.ts') ||
|
|
57
|
+
filePath.endsWith('.spec.js')
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isMigrationFile(filePath: string): boolean {
|
|
62
|
+
return filePath.includes('/migrations/');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Tests
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
describe('assistant ID boundary', () => {
|
|
70
|
+
// -------------------------------------------------------------------------
|
|
71
|
+
// Rule (d): The DAEMON_INTERNAL_ASSISTANT_ID constant equals 'self'
|
|
72
|
+
// -------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
test('DAEMON_INTERNAL_ASSISTANT_ID equals "self"', () => {
|
|
75
|
+
expect(DAEMON_INTERNAL_ASSISTANT_ID).toBe('self');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// -------------------------------------------------------------------------
|
|
79
|
+
// Rule (a): No normalizeAssistantId in daemon scoping paths — spot check
|
|
80
|
+
// -------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
test('no normalizeAssistantId imports in daemon scoping paths', () => {
|
|
83
|
+
// Key daemon/runtime files that previously used normalizeAssistantId
|
|
84
|
+
// should now use DAEMON_INTERNAL_ASSISTANT_ID instead.
|
|
85
|
+
const daemonScopingFiles = [
|
|
86
|
+
'runtime/actor-trust-resolver.ts',
|
|
87
|
+
'runtime/guardian-outbound-actions.ts',
|
|
88
|
+
'daemon/handlers/config-channels.ts',
|
|
89
|
+
'runtime/routes/channel-route-shared.ts',
|
|
90
|
+
'calls/relay-server.ts',
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const srcDir = join(import.meta.dir, '..');
|
|
94
|
+
for (const relPath of daemonScopingFiles) {
|
|
95
|
+
const content = readFileSync(join(srcDir, relPath), 'utf-8');
|
|
96
|
+
expect(content).not.toContain("import { normalizeAssistantId }");
|
|
97
|
+
expect(content).not.toContain("import { normalizeAssistantId,");
|
|
98
|
+
expect(content).not.toContain("normalizeAssistantId(");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
// Rule (a): No normalizeAssistantId in daemon/runtime directories — broad scan
|
|
104
|
+
// -------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
test('no normalizeAssistantId usage across daemon/runtime source directories', () => {
|
|
107
|
+
const repoRoot = getRepoRoot();
|
|
108
|
+
|
|
109
|
+
// Scan all daemon/runtime source directories for any reference to
|
|
110
|
+
// normalizeAssistantId. The function is defined in util/platform.ts for
|
|
111
|
+
// gateway use — it must not appear in daemon scoping modules.
|
|
112
|
+
let grepOutput = '';
|
|
113
|
+
try {
|
|
114
|
+
grepOutput = execFileSync(
|
|
115
|
+
'git',
|
|
116
|
+
['grep', '-lE', 'normalizeAssistantId', '--', ...SCANNED_DIR_GLOBS],
|
|
117
|
+
{ encoding: 'utf-8', cwd: repoRoot },
|
|
118
|
+
).trim();
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Exit code 1 means no matches — happy path
|
|
121
|
+
if ((err as { status?: number }).status === 1) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const files = grepOutput.split('\n').filter((f) => f.length > 0);
|
|
128
|
+
const violations = files.filter((f) => !isTestFile(f));
|
|
129
|
+
|
|
130
|
+
if (violations.length > 0) {
|
|
131
|
+
const message = [
|
|
132
|
+
'Found daemon/runtime source files that reference `normalizeAssistantId`.',
|
|
133
|
+
'Daemon code should use the `DAEMON_INTERNAL_ASSISTANT_ID` constant instead.',
|
|
134
|
+
'The `normalizeAssistantId` function is for gateway/platform use only (defined in util/platform.ts).',
|
|
135
|
+
'',
|
|
136
|
+
'Violations:',
|
|
137
|
+
...violations.map((f) => ` - ${f}`),
|
|
138
|
+
].join('\n');
|
|
139
|
+
|
|
140
|
+
expect(violations, message).toEqual([]);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// -------------------------------------------------------------------------
|
|
145
|
+
// Rule (b): No assistant-scoped route registration in daemon HTTP server
|
|
146
|
+
// -------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
test('no /v1/assistants/:assistantId/ route handler registration in daemon HTTP server', () => {
|
|
149
|
+
const httpServerPath = join(import.meta.dir, '..', 'runtime', 'http-server.ts');
|
|
150
|
+
const content = readFileSync(httpServerPath, 'utf-8');
|
|
151
|
+
|
|
152
|
+
// The daemon HTTP server must not contain any assistant-scoped route
|
|
153
|
+
// patterns. All routes use flat /v1/<endpoint> paths; the gateway handles
|
|
154
|
+
// legacy assistant-scoped URL rewriting in its runtime proxy layer.
|
|
155
|
+
|
|
156
|
+
// Check that there's no regex extracting assistantId from a /v1/assistants/ path.
|
|
157
|
+
// Match both literal slashes (/v1/assistants/([) and escaped slashes in regex
|
|
158
|
+
// literals (\/v1\/assistants\/([) so we catch patterns like:
|
|
159
|
+
// endpoint.match(/^\/v1\/assistants\/([^/]+)\/(.+)$/)
|
|
160
|
+
const routeHandlerRegex = /\\?\/v1\\?\/assistants\\?\/\(\[/;
|
|
161
|
+
const match = content.match(routeHandlerRegex);
|
|
162
|
+
expect(
|
|
163
|
+
match,
|
|
164
|
+
'Found a route pattern matching /v1/assistants/([^/]+)/... that extracts an assistantId. ' +
|
|
165
|
+
'The daemon HTTP server should not have assistant-scoped route handlers — ' +
|
|
166
|
+
'use flat /v1/<endpoint> paths instead.',
|
|
167
|
+
).toBeNull();
|
|
168
|
+
|
|
169
|
+
// Scan the entire file for assistant-scoped path literals. No references
|
|
170
|
+
// to /v1/assistants/ should exist — the daemon uses flat paths only.
|
|
171
|
+
const lines = content.split('\n');
|
|
172
|
+
const violations: string[] = [];
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < lines.length; i++) {
|
|
175
|
+
const line = lines[i];
|
|
176
|
+
// Match both literal /v1/assistants/ and escaped \/v1\/assistants\/
|
|
177
|
+
if (line.includes('/v1/assistants/') || line.includes('\\/v1\\/assistants\\/')) {
|
|
178
|
+
violations.push(` line ${i + 1}: ${line.trim()}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
expect(
|
|
183
|
+
violations,
|
|
184
|
+
'Found /v1/assistants/ references in the daemon HTTP server — ' +
|
|
185
|
+
'the daemon should not have assistant-scoped path literals.\n' +
|
|
186
|
+
violations.join('\n'),
|
|
187
|
+
).toEqual([]);
|
|
188
|
+
|
|
189
|
+
// Guard against prefix-less assistants/ route patterns that extract an
|
|
190
|
+
// assistantId. dispatchEndpoint receives the endpoint *after* the /v1/
|
|
191
|
+
// prefix has been stripped, so a regex like `assistants\/([^/]+)` would
|
|
192
|
+
// capture an external assistant ID from the path — violating the
|
|
193
|
+
// assistant-scoping boundary.
|
|
194
|
+
const prefixLessViolations: string[] = [];
|
|
195
|
+
for (let i = 0; i < lines.length; i++) {
|
|
196
|
+
const line = lines[i];
|
|
197
|
+
// Match regex patterns like assistants\/([^/]+) that capture the ID
|
|
198
|
+
// segment. We look for the escaped-slash form used inside JS regex
|
|
199
|
+
// literals (e.g. /^assistants\/([^/]+)\//).
|
|
200
|
+
if (/assistants\\\/\(\[/.test(line)) {
|
|
201
|
+
prefixLessViolations.push(` line ${i + 1}: ${line.trim()}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
expect(
|
|
206
|
+
prefixLessViolations,
|
|
207
|
+
'Found prefix-less assistants/([^/]+) route pattern that extracts an assistantId. ' +
|
|
208
|
+
'The daemon should not parse assistant IDs from URL paths — use ' +
|
|
209
|
+
'DAEMON_INTERNAL_ASSISTANT_ID instead.\n' +
|
|
210
|
+
prefixLessViolations.join('\n'),
|
|
211
|
+
).toEqual([]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// -------------------------------------------------------------------------
|
|
215
|
+
// Rule (c): No hardcoded 'self' for assistant scoping in daemon files
|
|
216
|
+
// -------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
test('no hardcoded \'self\' string for assistant scoping in daemon source files', () => {
|
|
219
|
+
const repoRoot = getRepoRoot();
|
|
220
|
+
|
|
221
|
+
// Search for patterns where 'self' is used as an assistant ID value.
|
|
222
|
+
// We look for assignment / default / comparison patterns that suggest
|
|
223
|
+
// using the raw string instead of the DAEMON_INTERNAL_ASSISTANT_ID constant.
|
|
224
|
+
//
|
|
225
|
+
// Patterns matched:
|
|
226
|
+
// assistantId: 'self'
|
|
227
|
+
// assistantId = 'self'
|
|
228
|
+
// assistantId ?? 'self'
|
|
229
|
+
// ?? 'self' (fallback to self)
|
|
230
|
+
// || 'self' (fallback to self)
|
|
231
|
+
//
|
|
232
|
+
// Excluded:
|
|
233
|
+
// - Test files (they may legitimately assert against the value)
|
|
234
|
+
// - Migration files (SQL literals like DEFAULT 'self' are fine)
|
|
235
|
+
// - IPC contract files (comments documenting default values are fine)
|
|
236
|
+
// - CSP headers ('self' in Content-Security-Policy has nothing to do with assistant IDs)
|
|
237
|
+
const pattern = `(assistantId|assistant_id).*['"]self['"]`;
|
|
238
|
+
|
|
239
|
+
let grepOutput = '';
|
|
240
|
+
try {
|
|
241
|
+
grepOutput = execFileSync(
|
|
242
|
+
'git',
|
|
243
|
+
['grep', '-nE', pattern, '--', ...SCANNED_DIR_GLOBS],
|
|
244
|
+
{ encoding: 'utf-8', cwd: repoRoot },
|
|
245
|
+
).trim();
|
|
246
|
+
} catch (err) {
|
|
247
|
+
// Exit code 1 means no matches — happy path
|
|
248
|
+
if ((err as { status?: number }).status === 1) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const lines = grepOutput.split('\n').filter((l) => l.length > 0);
|
|
255
|
+
const violations = lines.filter((line) => {
|
|
256
|
+
const filePath = line.split(':')[0];
|
|
257
|
+
if (isTestFile(filePath)) return false;
|
|
258
|
+
if (isMigrationFile(filePath)) return false;
|
|
259
|
+
|
|
260
|
+
// Allow comments (lines where the code portion starts with //)
|
|
261
|
+
const parts = line.split(':');
|
|
262
|
+
// parts[0] = file, parts[1] = line number, rest = content
|
|
263
|
+
const content = parts.slice(2).join(':').trim();
|
|
264
|
+
if (content.startsWith('//') || content.startsWith('*') || content.startsWith('/*')) {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return true;
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (violations.length > 0) {
|
|
272
|
+
const message = [
|
|
273
|
+
"Found daemon/runtime source files with hardcoded 'self' for assistant scoping.",
|
|
274
|
+
'Use the `DAEMON_INTERNAL_ASSISTANT_ID` constant from `runtime/assistant-scope.ts` instead.',
|
|
275
|
+
'',
|
|
276
|
+
'Violations:',
|
|
277
|
+
...violations.map((v) => ` - ${v}`),
|
|
278
|
+
].join('\n');
|
|
279
|
+
|
|
280
|
+
expect(violations, message).toEqual([]);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// -------------------------------------------------------------------------
|
|
285
|
+
// Rule (d): Daemon storage keys don't contain external assistant IDs
|
|
286
|
+
// (verified by the constant value test above — if the constant is 'self',
|
|
287
|
+
// all daemon storage keyed by DAEMON_INTERNAL_ASSISTANT_ID uses the fixed
|
|
288
|
+
// internal value rather than externally-provided IDs).
|
|
289
|
+
// -------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
// -------------------------------------------------------------------------
|
|
292
|
+
// Rule (e): No assistantId on daemon control-plane request/param types
|
|
293
|
+
//
|
|
294
|
+
// Daemon IPC contracts and guardian outbound param interfaces must not
|
|
295
|
+
// accept an assistantId field -- the daemon always uses
|
|
296
|
+
// DAEMON_INTERNAL_ASSISTANT_ID internally. Accepting assistantId on these
|
|
297
|
+
// surfaces invites callers to pass external IDs into daemon scoping.
|
|
298
|
+
// -------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
test('IPC contract types do not contain assistantId for guardian requests', () => {
|
|
301
|
+
const ipcContractPath = join(import.meta.dir, '..', 'daemon', 'ipc-contract', 'integrations.ts');
|
|
302
|
+
const content = readFileSync(ipcContractPath, 'utf-8');
|
|
303
|
+
|
|
304
|
+
// Extract the interface blocks for the request types and verify
|
|
305
|
+
// none of them declare an assistantId property.
|
|
306
|
+
const requestTypeNames = [
|
|
307
|
+
'GuardianVerificationRequest',
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
for (const typeName of requestTypeNames) {
|
|
311
|
+
// Find the interface/type block — match from the type name to the next
|
|
312
|
+
// closing brace at the same indentation level. We use a simple heuristic:
|
|
313
|
+
// find the line declaring the type, then scan forward to the closing '}'.
|
|
314
|
+
const typeIndex = content.indexOf(typeName);
|
|
315
|
+
expect(typeIndex, `Expected to find ${typeName} in IPC contract`).toBeGreaterThan(-1);
|
|
316
|
+
|
|
317
|
+
// Extract from the type declaration to the next '}' line
|
|
318
|
+
const blockStart = content.indexOf('{', typeIndex);
|
|
319
|
+
if (blockStart === -1) continue;
|
|
320
|
+
let braceDepth = 0;
|
|
321
|
+
let blockEnd = blockStart;
|
|
322
|
+
for (let i = blockStart; i < content.length; i++) {
|
|
323
|
+
if (content[i] === '{') braceDepth++;
|
|
324
|
+
if (content[i] === '}') braceDepth--;
|
|
325
|
+
if (braceDepth === 0) {
|
|
326
|
+
blockEnd = i + 1;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const block = content.slice(blockStart, blockEnd);
|
|
331
|
+
|
|
332
|
+
// The block should not contain an assistantId property declaration
|
|
333
|
+
// (matches "assistantId?" or "assistantId:" on a non-comment line)
|
|
334
|
+
const lines = block.split('\n');
|
|
335
|
+
for (const line of lines) {
|
|
336
|
+
const trimmed = line.trim();
|
|
337
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
|
|
338
|
+
expect(
|
|
339
|
+
/\bassistantId\s*[?:]/.test(trimmed),
|
|
340
|
+
`${typeName} must not declare an assistantId property. Found: "${trimmed}"`,
|
|
341
|
+
).toBe(false);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('guardian outbound param interfaces do not contain assistantId', () => {
|
|
347
|
+
const actionsPath = join(import.meta.dir, '..', 'runtime', 'guardian-outbound-actions.ts');
|
|
348
|
+
const content = readFileSync(actionsPath, 'utf-8');
|
|
349
|
+
|
|
350
|
+
const interfaceNames = [
|
|
351
|
+
'StartOutboundParams',
|
|
352
|
+
'ResendOutboundParams',
|
|
353
|
+
'CancelOutboundParams',
|
|
354
|
+
];
|
|
355
|
+
|
|
356
|
+
for (const name of interfaceNames) {
|
|
357
|
+
const idx = content.indexOf(name);
|
|
358
|
+
expect(idx, `Expected to find ${name} in guardian-outbound-actions.ts`).toBeGreaterThan(-1);
|
|
359
|
+
|
|
360
|
+
const blockStart = content.indexOf('{', idx);
|
|
361
|
+
if (blockStart === -1) continue;
|
|
362
|
+
let braceDepth = 0;
|
|
363
|
+
let blockEnd = blockStart;
|
|
364
|
+
for (let i = blockStart; i < content.length; i++) {
|
|
365
|
+
if (content[i] === '{') braceDepth++;
|
|
366
|
+
if (content[i] === '}') braceDepth--;
|
|
367
|
+
if (braceDepth === 0) {
|
|
368
|
+
blockEnd = i + 1;
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const block = content.slice(blockStart, blockEnd);
|
|
373
|
+
|
|
374
|
+
const lines = block.split('\n');
|
|
375
|
+
for (const line of lines) {
|
|
376
|
+
const trimmed = line.trim();
|
|
377
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
|
|
378
|
+
expect(
|
|
379
|
+
/\bassistantId\s*[?:]/.test(trimmed),
|
|
380
|
+
`${name} must not declare an assistantId property. Found: "${trimmed}"`,
|
|
381
|
+
).toBe(false);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('channel readiness service does not accept assistantId parameter', () => {
|
|
387
|
+
const servicePath = join(import.meta.dir, '..', 'runtime', 'channel-readiness-service.ts');
|
|
388
|
+
const content = readFileSync(servicePath, 'utf-8');
|
|
389
|
+
|
|
390
|
+
// getReadiness and invalidateChannel signatures must not include assistantId
|
|
391
|
+
const signaturePatterns = [
|
|
392
|
+
/getReadiness\([^)]*assistantId/,
|
|
393
|
+
/invalidateChannel\([^)]*assistantId/,
|
|
394
|
+
];
|
|
395
|
+
for (const pattern of signaturePatterns) {
|
|
396
|
+
expect(
|
|
397
|
+
pattern.test(content),
|
|
398
|
+
`Channel readiness service must not accept assistantId parameter (matched: ${pattern})`,
|
|
399
|
+
).toBe(false);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ChannelProbeContext must not have assistantId.
|
|
403
|
+
// The interface is declared in channel-readiness-types.ts, not the service file.
|
|
404
|
+
const typesPath = join(import.meta.dir, '..', 'runtime', 'channel-readiness-types.ts');
|
|
405
|
+
const typesContent = readFileSync(typesPath, 'utf-8');
|
|
406
|
+
const probeContextMatch = typesContent.match(/interface\s+ChannelProbeContext\s*\{([^}]*)\}/);
|
|
407
|
+
expect(probeContextMatch, 'Expected to find ChannelProbeContext interface in channel-readiness-types.ts').not.toBeNull();
|
|
408
|
+
if (probeContextMatch) {
|
|
409
|
+
expect(
|
|
410
|
+
probeContextMatch[1],
|
|
411
|
+
'ChannelProbeContext must not contain assistantId',
|
|
412
|
+
).not.toContain('assistantId');
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -56,10 +56,12 @@ mock.module('../config/loader.js', () => ({
|
|
|
56
56
|
// ── Call constants mock ──────────────────────────────────────────────
|
|
57
57
|
|
|
58
58
|
let mockConsultationTimeoutMs = 90_000;
|
|
59
|
+
let mockSilenceTimeoutMs = 30_000;
|
|
59
60
|
|
|
60
61
|
mock.module('../calls/call-constants.js', () => ({
|
|
61
62
|
getMaxCallDurationMs: () => 12 * 60 * 1000,
|
|
62
63
|
getUserConsultationTimeoutMs: () => mockConsultationTimeoutMs,
|
|
64
|
+
getSilenceTimeoutMs: () => mockSilenceTimeoutMs,
|
|
63
65
|
SILENCE_TIMEOUT_MS: 30_000,
|
|
64
66
|
MAX_CALL_DURATION_MS: 3600 * 1000,
|
|
65
67
|
USER_CONSULTATION_TIMEOUT_MS: 120 * 1000,
|
|
@@ -154,6 +156,7 @@ interface MockRelay extends RelayConnection {
|
|
|
154
156
|
sentTokens: Array<{ token: string; last: boolean }>;
|
|
155
157
|
endCalled: boolean;
|
|
156
158
|
endReason: string | undefined;
|
|
159
|
+
mockConnectionState: string;
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
function createMockRelay(): MockRelay {
|
|
@@ -161,12 +164,15 @@ function createMockRelay(): MockRelay {
|
|
|
161
164
|
sentTokens: [] as Array<{ token: string; last: boolean }>,
|
|
162
165
|
_endCalled: false,
|
|
163
166
|
_endReason: undefined as string | undefined,
|
|
167
|
+
_connectionState: 'connected',
|
|
164
168
|
};
|
|
165
169
|
|
|
166
170
|
return {
|
|
167
171
|
get sentTokens() { return state.sentTokens; },
|
|
168
172
|
get endCalled() { return state._endCalled; },
|
|
169
173
|
get endReason() { return state._endReason; },
|
|
174
|
+
get mockConnectionState() { return state._connectionState; },
|
|
175
|
+
set mockConnectionState(v: string) { state._connectionState = v; },
|
|
170
176
|
sendTextToken(token: string, last: boolean) {
|
|
171
177
|
state.sentTokens.push({ token, last });
|
|
172
178
|
},
|
|
@@ -174,6 +180,9 @@ function createMockRelay(): MockRelay {
|
|
|
174
180
|
state._endCalled = true;
|
|
175
181
|
state._endReason = reason;
|
|
176
182
|
},
|
|
183
|
+
getConnectionState() {
|
|
184
|
+
return state._connectionState;
|
|
185
|
+
},
|
|
177
186
|
} as unknown as MockRelay;
|
|
178
187
|
}
|
|
179
188
|
|
|
@@ -236,6 +245,7 @@ describe('call-controller', () => {
|
|
|
236
245
|
mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(['Hello', ' there']));
|
|
237
246
|
// Reset consultation timeout to the default (long) value
|
|
238
247
|
mockConsultationTimeoutMs = 90_000;
|
|
248
|
+
mockSilenceTimeoutMs = 30_000;
|
|
239
249
|
});
|
|
240
250
|
|
|
241
251
|
// ── handleCallerUtterance ─────────────────────────────────────────
|
|
@@ -1697,4 +1707,43 @@ describe('call-controller', () => {
|
|
|
1697
1707
|
|
|
1698
1708
|
controller.destroy();
|
|
1699
1709
|
});
|
|
1710
|
+
|
|
1711
|
+
// ── Silence suppression during guardian wait ──────────────────────
|
|
1712
|
+
|
|
1713
|
+
test('silence timeout suppressed during guardian wait: does not say "Are you still there?"', async () => {
|
|
1714
|
+
mockSilenceTimeoutMs = 50; // Short timeout for testing
|
|
1715
|
+
const { relay, controller } = setupController();
|
|
1716
|
+
|
|
1717
|
+
// Simulate guardian wait state on the relay
|
|
1718
|
+
relay.mockConnectionState = 'awaiting_guardian_decision';
|
|
1719
|
+
|
|
1720
|
+
// Wait for the silence timeout to fire
|
|
1721
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1722
|
+
|
|
1723
|
+
// "Are you still there?" should NOT have been sent
|
|
1724
|
+
const silenceTokens = relay.sentTokens.filter((t) =>
|
|
1725
|
+
t.token.includes('Are you still there?'),
|
|
1726
|
+
);
|
|
1727
|
+
expect(silenceTokens.length).toBe(0);
|
|
1728
|
+
|
|
1729
|
+
controller.destroy();
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
test('silence timeout fires normally when not in guardian wait', async () => {
|
|
1733
|
+
mockSilenceTimeoutMs = 50; // Short timeout for testing
|
|
1734
|
+
const { relay, controller } = setupController();
|
|
1735
|
+
|
|
1736
|
+
// Default connection state is 'connected' (not guardian wait)
|
|
1737
|
+
|
|
1738
|
+
// Wait for the silence timeout to fire
|
|
1739
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1740
|
+
|
|
1741
|
+
// "Are you still there?" SHOULD have been sent
|
|
1742
|
+
const silenceTokens = relay.sentTokens.filter((t) =>
|
|
1743
|
+
t.token.includes('Are you still there?'),
|
|
1744
|
+
);
|
|
1745
|
+
expect(silenceTokens.length).toBe(1);
|
|
1746
|
+
|
|
1747
|
+
controller.destroy();
|
|
1748
|
+
});
|
|
1700
1749
|
});
|