vellum 0.2.1 → 0.2.2
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/README.md +15 -2
- package/bun.lock +5 -2
- package/package.json +4 -2
- package/scripts/capture-x-graphql.ts +562 -0
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
- package/scripts/test.sh +5 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +133 -34
- package/src/__tests__/account-registry.test.ts +2 -1
- package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
- package/src/__tests__/asset-materialize-tool.test.ts +16 -15
- package/src/__tests__/asset-search-tool.test.ts +23 -22
- package/src/__tests__/attachments-store.test.ts +56 -127
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
- package/src/__tests__/browser-skill-endstate.test.ts +4 -3
- package/src/__tests__/call-bridge.test.ts +385 -0
- package/src/__tests__/call-constants.test.ts +40 -0
- package/src/__tests__/call-orchestrator.test.ts +130 -4
- package/src/__tests__/call-recovery.test.ts +518 -0
- package/src/__tests__/call-routes-http.test.ts +459 -0
- package/src/__tests__/call-state-machine.test.ts +143 -0
- package/src/__tests__/call-store.test.ts +216 -1
- package/src/__tests__/cli-discover.test.ts +1 -1
- package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
- package/src/__tests__/compaction.benchmark.test.ts +176 -0
- package/src/__tests__/computer-use-tools.test.ts +250 -0
- package/src/__tests__/config-schema.test.ts +299 -3
- package/src/__tests__/conflict-store.test.ts +2 -1
- package/src/__tests__/contacts-tools.test.ts +331 -0
- package/src/__tests__/conversation-store.test.ts +30 -32
- package/src/__tests__/credential-security-invariants.test.ts +4 -0
- package/src/__tests__/date-context.test.ts +373 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
- package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
- package/src/__tests__/followup-tools.test.ts +303 -0
- package/src/__tests__/handlers-twitter-config.test.ts +718 -0
- package/src/__tests__/intent-routing.test.ts +64 -57
- package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
- package/src/__tests__/ipc-snapshot.test.ts +62 -28
- package/src/__tests__/llm-usage-store.test.ts +3 -8
- package/src/__tests__/media-generate-image.test.ts +1 -1
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
- package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
- package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
- package/src/__tests__/playbook-tools.test.ts +342 -0
- package/src/__tests__/profile-compiler.test.ts +2 -1
- package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
- package/src/__tests__/recurrence-engine.test.ts +69 -0
- package/src/__tests__/recurrence-types.test.ts +71 -0
- package/src/__tests__/registry.test.ts +5 -3
- package/src/__tests__/relay-server.test.ts +633 -0
- package/src/__tests__/reminder-store.test.ts +6 -3
- package/src/__tests__/reminder.test.ts +43 -77
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
- package/src/__tests__/run-orchestrator.test.ts +4 -4
- package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
- package/src/__tests__/runtime-runs-http.test.ts +4 -4
- package/src/__tests__/runtime-runs.test.ts +4 -4
- package/src/__tests__/schedule-store.test.ts +482 -0
- package/src/__tests__/schedule-tools.test.ts +700 -0
- package/src/__tests__/scheduler-recurrence.test.ts +329 -0
- package/src/__tests__/server-history-render.test.ts +14 -13
- package/src/__tests__/session-error.test.ts +28 -0
- package/src/__tests__/session-init.benchmark.test.ts +462 -0
- package/src/__tests__/session-queue.test.ts +71 -48
- package/src/__tests__/session-runtime-assembly.test.ts +161 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
- package/src/__tests__/signup-e2e.test.ts +2 -1
- package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
- package/src/__tests__/skill-script-runner.test.ts +159 -0
- package/src/__tests__/speaker-identification.test.ts +52 -0
- package/src/__tests__/subagent-manager-notify.test.ts +42 -10
- package/src/__tests__/subagent-tools.test.ts +141 -41
- package/src/__tests__/task-compiler.test.ts +2 -1
- package/src/__tests__/task-runner.test.ts +2 -1
- package/src/__tests__/task-scheduler.test.ts +2 -1
- package/src/__tests__/task-tools.test.ts +49 -56
- package/src/__tests__/tool-audit-listener.test.ts +1 -0
- package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
- package/src/__tests__/tool-executor.test.ts +13 -17
- package/src/__tests__/turn-commit.test.ts +218 -3
- package/src/__tests__/twilio-provider.test.ts +143 -0
- package/src/__tests__/twilio-routes.test.ts +789 -0
- package/src/__tests__/twitter-auth-handler.test.ts +581 -0
- package/src/__tests__/view-image-tool.test.ts +217 -0
- package/src/__tests__/workspace-git-service.test.ts +186 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
- package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
- package/src/bundler/app-bundler.ts +12 -8
- package/src/calls/call-bridge.ts +95 -0
- package/src/calls/call-constants.ts +43 -5
- package/src/calls/call-domain.ts +276 -0
- package/src/calls/call-orchestrator.ts +43 -17
- package/src/calls/call-recovery.ts +207 -0
- package/src/calls/call-state-machine.ts +68 -0
- package/src/calls/call-store.ts +192 -5
- package/src/calls/relay-server.ts +41 -4
- package/src/calls/speaker-identification.ts +213 -0
- package/src/calls/twilio-provider.ts +10 -6
- package/src/calls/twilio-routes.ts +90 -76
- package/src/calls/types.ts +1 -1
- package/src/cli/config-commands.ts +334 -0
- package/src/cli/core-commands.ts +776 -0
- package/src/cli/doordash.ts +251 -1
- package/src/cli/ipc-client.ts +82 -0
- package/src/cli/map.ts +246 -0
- package/src/cli/twitter.ts +575 -0
- package/src/cli.ts +7 -5
- package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
- package/src/commands/cc-command-registry.ts +209 -0
- package/src/config/bundled-skills/contacts/SKILL.md +39 -0
- package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
- package/src/config/bundled-skills/document/SKILL.md +18 -0
- package/src/config/bundled-skills/document/TOOLS.json +53 -0
- package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
- package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
- package/src/config/bundled-skills/doordash/SKILL.md +82 -23
- package/src/config/bundled-skills/followups/SKILL.md +32 -0
- package/src/config/bundled-skills/followups/TOOLS.json +100 -0
- package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
- package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
- package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
- package/src/config/bundled-skills/reminder/SKILL.md +20 -0
- package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
- package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
- package/src/config/bundled-skills/schedule/SKILL.md +74 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
- package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
- package/src/config/bundled-skills/subagent/SKILL.md +25 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
- package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
- package/src/config/bundled-skills/tasks/SKILL.md +28 -0
- package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
- package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
- package/src/config/bundled-skills/twitter/SKILL.md +134 -0
- package/src/config/bundled-skills/watcher/SKILL.md +27 -0
- package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
- package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
- package/src/config/defaults.ts +33 -0
- package/src/config/loader.ts +4 -1
- package/src/config/schema.ts +161 -1
- package/src/config/system-prompt.ts +61 -16
- package/src/config/templates/IDENTITY.md +7 -0
- package/src/config/types.ts +4 -0
- package/src/contacts/contact-store.ts +4 -4
- package/src/daemon/assistant-attachments.ts +10 -0
- package/src/daemon/classifier.ts +3 -1
- package/src/daemon/computer-use-session.ts +3 -1
- package/src/daemon/date-context.ts +136 -0
- package/src/daemon/handlers/apps.ts +16 -1
- package/src/daemon/handlers/browser.ts +54 -0
- package/src/daemon/handlers/computer-use.ts +7 -1
- package/src/daemon/handlers/config.ts +163 -5
- package/src/daemon/handlers/diagnostics.ts +5 -1
- package/src/daemon/handlers/documents.ts +18 -29
- package/src/daemon/handlers/home-base.ts +5 -1
- package/src/daemon/handlers/index.ts +40 -277
- package/src/daemon/handlers/misc.ts +9 -1
- package/src/daemon/handlers/publish.ts +6 -1
- package/src/daemon/handlers/sessions.ts +65 -12
- package/src/daemon/handlers/shared.ts +36 -1
- package/src/daemon/handlers/signing.ts +37 -0
- package/src/daemon/handlers/skills.ts +20 -6
- package/src/daemon/handlers/subagents.ts +8 -3
- package/src/daemon/handlers/twitter-auth.ts +169 -0
- package/src/daemon/handlers/work-items.ts +384 -68
- package/src/daemon/ipc-contract-inventory.json +28 -4
- package/src/daemon/ipc-contract.ts +133 -37
- package/src/daemon/ipc-protocol.ts +7 -2
- package/src/daemon/lifecycle.ts +21 -0
- package/src/daemon/main.ts +10 -4
- package/src/daemon/ride-shotgun-handler.ts +74 -10
- package/src/daemon/server.ts +143 -26
- package/src/daemon/session-agent-loop.ts +887 -0
- package/src/daemon/session-attachments.ts +28 -5
- package/src/daemon/session-error.ts +24 -3
- package/src/daemon/session-lifecycle.ts +147 -0
- package/src/daemon/session-media-retry.ts +147 -0
- package/src/daemon/session-messaging.ts +145 -0
- package/src/daemon/session-notifiers.ts +164 -0
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/session-queue-manager.ts +1 -0
- package/src/daemon/session-runtime-assembly.ts +52 -0
- package/src/daemon/session-skill-tools.ts +124 -5
- package/src/daemon/session-slash.ts +3 -0
- package/src/daemon/session-surfaces.ts +77 -2
- package/src/daemon/session-tool-setup.ts +216 -2
- package/src/daemon/session-usage.ts +0 -2
- package/src/daemon/session.ts +114 -1404
- package/src/daemon/video-thumbnail.ts +60 -0
- package/src/doordash/client.ts +121 -27
- package/src/doordash/queries.ts +1 -2
- package/src/export/formatter.ts +3 -1
- package/src/followups/followup-store.ts +4 -2
- package/src/followups/types.ts +6 -0
- package/src/hooks/templates.ts +1 -1
- package/src/index.ts +32 -1153
- package/src/memory/attachments-store.ts +28 -83
- package/src/memory/channel-delivery-store.ts +7 -21
- package/src/memory/clarification-resolver.ts +6 -5
- package/src/memory/contradiction-checker.ts +3 -2
- package/src/memory/conversation-key-store.ts +10 -29
- package/src/memory/conversation-store.ts +2 -1
- package/src/memory/db.ts +96 -2
- package/src/memory/entity-extractor.ts +6 -3
- package/src/memory/items-extractor.ts +5 -4
- package/src/memory/jobs-store.ts +3 -2
- package/src/memory/llm-usage-store.ts +1 -2
- package/src/memory/runs-store.ts +1 -2
- package/src/memory/schema.ts +23 -2
- package/src/messaging/style-analyzer.ts +3 -2
- package/src/messaging/thread-summarizer.ts +8 -12
- package/src/messaging/triage-engine.ts +4 -2
- package/src/providers/openrouter/client.ts +20 -0
- package/src/providers/registry.ts +8 -0
- package/src/runtime/http-server.ts +108 -20
- package/src/runtime/routes/attachment-routes.ts +2 -3
- package/src/runtime/routes/call-routes.ts +140 -0
- package/src/runtime/routes/channel-routes.ts +5 -10
- package/src/runtime/routes/conversation-routes.ts +5 -5
- package/src/runtime/routes/run-routes.ts +2 -2
- package/src/runtime/run-orchestrator.ts +9 -3
- package/src/schedule/recurrence-engine.ts +138 -0
- package/src/schedule/recurrence-types.ts +67 -0
- package/src/schedule/schedule-store.ts +102 -57
- package/src/schedule/scheduler.ts +9 -6
- package/src/security/oauth2.ts +29 -4
- package/src/security/secret-allowlist.ts +46 -0
- package/src/skills/clawhub.ts +1 -1
- package/src/subagent/manager.ts +40 -8
- package/src/swarm/backend-claude-code.ts +64 -9
- package/src/swarm/worker-prompts.ts +2 -1
- package/src/tasks/SPEC.md +34 -28
- package/src/tasks/ephemeral-permissions.ts +16 -7
- package/src/tasks/task-compiler.ts +5 -4
- package/src/tasks/task-runner.ts +10 -5
- package/src/tasks/task-scheduler.ts +1 -1
- package/src/tasks/tool-sanitizer.ts +36 -0
- package/src/tools/assets/search.ts +4 -4
- package/src/tools/browser/api-map.ts +220 -0
- package/src/tools/browser/auto-navigate.ts +270 -0
- package/src/tools/browser/browser-execution.ts +2 -1
- package/src/tools/browser/browser-manager.ts +2 -2
- package/src/tools/browser/network-recorder.ts +5 -4
- package/src/tools/browser/x-auto-navigate.ts +207 -0
- package/src/tools/calls/call-end.ts +17 -67
- package/src/tools/calls/call-start.ts +24 -85
- package/src/tools/calls/call-status.ts +35 -51
- package/src/tools/claude-code/claude-code.ts +77 -11
- package/src/tools/contacts/contact-merge.ts +46 -78
- package/src/tools/contacts/contact-search.ts +35 -79
- package/src/tools/contacts/contact-upsert.ts +35 -108
- package/src/tools/credentials/vault.ts +20 -4
- package/src/tools/document/document-tool.ts +71 -144
- package/src/tools/executor.ts +129 -10
- package/src/tools/followups/followup_create.ts +46 -88
- package/src/tools/followups/followup_list.ts +34 -74
- package/src/tools/followups/followup_resolve.ts +31 -66
- package/src/tools/host-terminal/cli-discover.ts +2 -1
- package/src/tools/host-terminal/host-shell.ts +10 -0
- package/src/tools/memory/handlers.ts +5 -4
- package/src/tools/network/__tests__/web-search.test.ts +427 -0
- package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
- package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
- package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
- package/src/tools/network/web-fetch.ts +18 -6
- package/src/tools/playbooks/index.ts +4 -5
- package/src/tools/playbooks/playbook-create.ts +3 -47
- package/src/tools/playbooks/playbook-delete.ts +1 -25
- package/src/tools/playbooks/playbook-list.ts +1 -28
- package/src/tools/playbooks/playbook-update.ts +3 -51
- package/src/tools/reminder/reminder.ts +5 -78
- package/src/tools/schedule/create.ts +69 -74
- package/src/tools/schedule/delete.ts +21 -47
- package/src/tools/schedule/list.ts +55 -74
- package/src/tools/schedule/update.ts +77 -84
- package/src/tools/subagent/abort.ts +29 -58
- package/src/tools/subagent/message.ts +30 -63
- package/src/tools/subagent/read.ts +53 -84
- package/src/tools/subagent/spawn.ts +43 -82
- package/src/tools/subagent/status.ts +42 -71
- package/src/tools/swarm/delegate.ts +2 -1
- package/src/tools/tasks/index.ts +8 -8
- package/src/tools/tasks/task-delete.ts +60 -88
- package/src/tools/tasks/task-list.ts +31 -52
- package/src/tools/tasks/task-run.ts +72 -108
- package/src/tools/tasks/task-save.ts +33 -65
- package/src/tools/tasks/work-item-enqueue.ts +183 -215
- package/src/tools/tasks/work-item-list.ts +33 -63
- package/src/tools/tasks/work-item-remove.ts +45 -97
- package/src/tools/tasks/work-item-update.ts +91 -163
- package/src/tools/terminal/backends/native.ts +3 -1
- package/src/tools/tool-manifest.ts +0 -62
- package/src/tools/types.ts +6 -0
- package/src/tools/ui-surface/definitions.ts +3 -1
- package/src/tools/watch/screen-watch.ts +3 -1
- package/src/tools/watcher/create.ts +52 -98
- package/src/tools/watcher/delete.ts +20 -46
- package/src/tools/watcher/digest.ts +36 -70
- package/src/tools/watcher/list.ts +49 -79
- package/src/tools/watcher/update.ts +45 -91
- package/src/twitter/client.ts +690 -0
- package/src/twitter/session.ts +91 -0
- package/src/usage/types.ts +0 -1
- package/src/util/truncate.ts +6 -0
- package/src/watcher/providers/slack.ts +2 -1
- package/src/watcher/watcher-store.ts +3 -2
- package/src/work-items/work-item-store.ts +27 -2
- package/src/workspace/commit-message-enrichment-service.ts +31 -7
- package/src/workspace/git-service.ts +87 -22
- package/src/workspace/provider-commit-message-generator.ts +242 -0
- package/src/workspace/turn-commit.ts +62 -3
- package/src/tools/contacts/index.ts +0 -4
- package/src/tools/document/index.ts +0 -5
- package/src/tools/followups/index.ts +0 -3
- package/src/tools/subagent/index.ts +0 -5
- /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { EventEmitter } from 'node:events';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'call-bridge-test-'));
|
|
8
|
+
|
|
9
|
+
// ── Platform + logger mocks (must come before any source imports) ────
|
|
10
|
+
|
|
11
|
+
mock.module('../util/platform.js', () => ({
|
|
12
|
+
getDataDir: () => testDir,
|
|
13
|
+
isMacOS: () => process.platform === 'darwin',
|
|
14
|
+
isLinux: () => process.platform === 'linux',
|
|
15
|
+
isWindows: () => process.platform === 'win32',
|
|
16
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
17
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
18
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
19
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
20
|
+
ensureDataDir: () => {},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
mock.module('../util/logger.js', () => ({
|
|
24
|
+
getLogger: () =>
|
|
25
|
+
new Proxy({} as Record<string, unknown>, {
|
|
26
|
+
get: () => () => {},
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// ── Config mock ─────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
mock.module('../config/loader.js', () => ({
|
|
33
|
+
getConfig: () => ({
|
|
34
|
+
apiKeys: { anthropic: 'test-key' },
|
|
35
|
+
memory: { enabled: false },
|
|
36
|
+
calls: {
|
|
37
|
+
enabled: true,
|
|
38
|
+
provider: 'twilio',
|
|
39
|
+
maxDurationSeconds: 3600,
|
|
40
|
+
userConsultTimeoutSeconds: 120,
|
|
41
|
+
disclosure: { enabled: false, text: '' },
|
|
42
|
+
safety: { denyCategories: [] },
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
// ── Anthropic SDK mock ──────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function createMockStream(tokens: string[]) {
|
|
50
|
+
const emitter = new EventEmitter();
|
|
51
|
+
const fullText = tokens.join('');
|
|
52
|
+
|
|
53
|
+
const stream = {
|
|
54
|
+
on: (event: string, handler: (...args: unknown[]) => void) => {
|
|
55
|
+
emitter.on(event, handler);
|
|
56
|
+
return stream;
|
|
57
|
+
},
|
|
58
|
+
finalMessage: () => {
|
|
59
|
+
for (const token of tokens) {
|
|
60
|
+
emitter.emit('text', token);
|
|
61
|
+
}
|
|
62
|
+
return Promise.resolve({
|
|
63
|
+
content: [{ type: 'text', text: fullText }],
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return stream;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const mockStreamFn = mock((..._args: unknown[]) => createMockStream(['Hello']));
|
|
72
|
+
|
|
73
|
+
mock.module('@anthropic-ai/sdk', () => ({
|
|
74
|
+
default: class MockAnthropic {
|
|
75
|
+
messages = {
|
|
76
|
+
stream: (...args: unknown[]) => mockStreamFn(...args),
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
// ── Import source modules after all mocks ───────────────────────────
|
|
82
|
+
|
|
83
|
+
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
84
|
+
import { conversations } from '../memory/schema.js';
|
|
85
|
+
import {
|
|
86
|
+
createCallSession,
|
|
87
|
+
getPendingQuestion,
|
|
88
|
+
updateCallSession,
|
|
89
|
+
recordCallEvent,
|
|
90
|
+
createPendingQuestion,
|
|
91
|
+
} from '../calls/call-store.js';
|
|
92
|
+
import {
|
|
93
|
+
registerCallQuestionNotifier,
|
|
94
|
+
unregisterCallQuestionNotifier,
|
|
95
|
+
registerCallCompletionNotifier,
|
|
96
|
+
unregisterCallCompletionNotifier,
|
|
97
|
+
fireCallQuestionNotifier,
|
|
98
|
+
fireCallCompletionNotifier,
|
|
99
|
+
} from '../calls/call-state.js';
|
|
100
|
+
import { CallOrchestrator } from '../calls/call-orchestrator.js';
|
|
101
|
+
import { tryHandlePendingCallAnswer } from '../calls/call-bridge.js';
|
|
102
|
+
import * as conversationStore from '../memory/conversation-store.js';
|
|
103
|
+
import type { RelayConnection } from '../calls/relay-server.js';
|
|
104
|
+
|
|
105
|
+
initializeDb();
|
|
106
|
+
|
|
107
|
+
afterAll(() => {
|
|
108
|
+
resetDb();
|
|
109
|
+
try {
|
|
110
|
+
rmSync(testDir, { recursive: true });
|
|
111
|
+
} catch {
|
|
112
|
+
/* best effort */
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── Relay mock factory ──────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
interface MockRelay extends RelayConnection {
|
|
119
|
+
sentTokens: Array<{ token: string; last: boolean }>;
|
|
120
|
+
endCalled: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function createMockRelay(): MockRelay {
|
|
124
|
+
const state = {
|
|
125
|
+
sentTokens: [] as Array<{ token: string; last: boolean }>,
|
|
126
|
+
_endCalled: false,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
get sentTokens() { return state.sentTokens; },
|
|
131
|
+
get endCalled() { return state._endCalled; },
|
|
132
|
+
sendTextToken(token: string, last: boolean) {
|
|
133
|
+
state.sentTokens.push({ token, last });
|
|
134
|
+
},
|
|
135
|
+
endSession(_reason?: string) {
|
|
136
|
+
state._endCalled = true;
|
|
137
|
+
},
|
|
138
|
+
} as unknown as MockRelay;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
let ensuredConvIds = new Set<string>();
|
|
144
|
+
function ensureConversation(id: string): void {
|
|
145
|
+
if (ensuredConvIds.has(id)) return;
|
|
146
|
+
const db = getDb();
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
db.insert(conversations).values({
|
|
149
|
+
id,
|
|
150
|
+
title: `Test conversation ${id}`,
|
|
151
|
+
createdAt: now,
|
|
152
|
+
updatedAt: now,
|
|
153
|
+
}).run();
|
|
154
|
+
ensuredConvIds.add(id);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resetTables() {
|
|
158
|
+
const db = getDb();
|
|
159
|
+
db.run('DELETE FROM call_pending_questions');
|
|
160
|
+
db.run('DELETE FROM call_events');
|
|
161
|
+
db.run('DELETE FROM call_sessions');
|
|
162
|
+
db.run('DELETE FROM messages');
|
|
163
|
+
db.run('DELETE FROM conversations');
|
|
164
|
+
ensuredConvIds = new Set();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getMessagesForConversation(conversationId: string) {
|
|
168
|
+
return conversationStore.getMessages(conversationId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe('call-bridge', () => {
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
resetTables();
|
|
174
|
+
mockStreamFn.mockImplementation(() => createMockStream(['Hello']));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── tryHandlePendingCallAnswer ──────────────────────────────────
|
|
178
|
+
|
|
179
|
+
test('returns handled:false when no active call exists', async () => {
|
|
180
|
+
ensureConversation('conv-no-call');
|
|
181
|
+
const result = await tryHandlePendingCallAnswer('conv-no-call', 'some answer');
|
|
182
|
+
expect(result.handled).toBe(false);
|
|
183
|
+
expect(result.reason).toBe('no_active_call');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('returns handled:false when call exists but no pending question', async () => {
|
|
187
|
+
ensureConversation('conv-no-question');
|
|
188
|
+
createCallSession({
|
|
189
|
+
conversationId: 'conv-no-question',
|
|
190
|
+
provider: 'twilio',
|
|
191
|
+
fromNumber: '+15551111111',
|
|
192
|
+
toNumber: '+15552222222',
|
|
193
|
+
});
|
|
194
|
+
const result = await tryHandlePendingCallAnswer('conv-no-question', 'some answer');
|
|
195
|
+
expect(result.handled).toBe(false);
|
|
196
|
+
expect(result.reason).toBe('no_pending_question');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('returns handled:false when orchestrator is not found (call still active but no orchestrator)', async () => {
|
|
200
|
+
ensureConversation('conv-ended');
|
|
201
|
+
const callSession = createCallSession({
|
|
202
|
+
conversationId: 'conv-ended',
|
|
203
|
+
provider: 'twilio',
|
|
204
|
+
fromNumber: '+15551111111',
|
|
205
|
+
toNumber: '+15552222222',
|
|
206
|
+
});
|
|
207
|
+
// Leave the session in an active (non-terminal) state but do NOT register an orchestrator.
|
|
208
|
+
// This simulates a race where the orchestrator was destroyed but the session hasn't
|
|
209
|
+
// been marked terminal yet.
|
|
210
|
+
updateCallSession(callSession.id, { status: 'in_progress' });
|
|
211
|
+
|
|
212
|
+
// Create a pending question without an orchestrator
|
|
213
|
+
createPendingQuestion(callSession.id, 'What time?');
|
|
214
|
+
|
|
215
|
+
const result = await tryHandlePendingCallAnswer('conv-ended', 'Too late');
|
|
216
|
+
expect(result.handled).toBe(false);
|
|
217
|
+
expect(result.reason).toBe('orchestrator_not_found');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('returns no_active_call when call has already completed', async () => {
|
|
221
|
+
ensureConversation('conv-completed');
|
|
222
|
+
const callSession = createCallSession({
|
|
223
|
+
conversationId: 'conv-completed',
|
|
224
|
+
provider: 'twilio',
|
|
225
|
+
fromNumber: '+15551111111',
|
|
226
|
+
toNumber: '+15552222222',
|
|
227
|
+
});
|
|
228
|
+
// Mark the call as completed — getActiveCallSessionForConversation will return null
|
|
229
|
+
updateCallSession(callSession.id, { status: 'completed', endedAt: Date.now() });
|
|
230
|
+
|
|
231
|
+
const result = await tryHandlePendingCallAnswer('conv-completed', 'Too late');
|
|
232
|
+
expect(result.handled).toBe(false);
|
|
233
|
+
expect(result.reason).toBe('no_active_call');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('returns handled:false when orchestrator is not in waiting_on_user state', async () => {
|
|
237
|
+
ensureConversation('conv-not-waiting');
|
|
238
|
+
const callSession = createCallSession({
|
|
239
|
+
conversationId: 'conv-not-waiting',
|
|
240
|
+
provider: 'twilio',
|
|
241
|
+
fromNumber: '+15551111111',
|
|
242
|
+
toNumber: '+15552222222',
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Create orchestrator (state=idle by default)
|
|
246
|
+
const relay = createMockRelay();
|
|
247
|
+
const orchestrator = new CallOrchestrator(callSession.id, relay as unknown as RelayConnection, null);
|
|
248
|
+
|
|
249
|
+
// Create a pending question in the DB but orchestrator is idle, not waiting_on_user
|
|
250
|
+
createPendingQuestion(callSession.id, 'What time?');
|
|
251
|
+
|
|
252
|
+
const result = await tryHandlePendingCallAnswer('conv-not-waiting', 'answer');
|
|
253
|
+
expect(result.handled).toBe(false);
|
|
254
|
+
expect(result.reason).toBe('orchestrator_not_waiting');
|
|
255
|
+
|
|
256
|
+
orchestrator.destroy();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('routes answer to orchestrator when waiting and returns handled:true', async () => {
|
|
260
|
+
// Setup: trigger ASK_USER to put orchestrator in waiting_on_user state
|
|
261
|
+
mockStreamFn.mockImplementation(() =>
|
|
262
|
+
createMockStream(['Hold on. [ASK_USER: Preferred date?]']),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
ensureConversation('conv-bridge');
|
|
266
|
+
const callSession = createCallSession({
|
|
267
|
+
conversationId: 'conv-bridge',
|
|
268
|
+
provider: 'twilio',
|
|
269
|
+
fromNumber: '+15551111111',
|
|
270
|
+
toNumber: '+15552222222',
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const relay = createMockRelay();
|
|
274
|
+
const orchestrator = new CallOrchestrator(callSession.id, relay as unknown as RelayConnection, 'test task');
|
|
275
|
+
|
|
276
|
+
await orchestrator.handleCallerUtterance('I need a reservation');
|
|
277
|
+
|
|
278
|
+
// Verify the orchestrator is now waiting
|
|
279
|
+
expect(orchestrator.getState()).toBe('waiting_on_user');
|
|
280
|
+
|
|
281
|
+
// Now provide the answer — set up mock for the LLM call after answer
|
|
282
|
+
mockStreamFn.mockImplementation(() => createMockStream(['Great, booking for tomorrow.']));
|
|
283
|
+
|
|
284
|
+
const result = await tryHandlePendingCallAnswer('conv-bridge', 'Tomorrow at noon');
|
|
285
|
+
expect(result.handled).toBe(true);
|
|
286
|
+
|
|
287
|
+
// Wait for the fire-and-forget LLM call
|
|
288
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
289
|
+
|
|
290
|
+
// Verify the pending question was answered
|
|
291
|
+
const question = getPendingQuestion(callSession.id);
|
|
292
|
+
// After answering, there should be no pending question left
|
|
293
|
+
expect(question).toBeNull();
|
|
294
|
+
|
|
295
|
+
orchestrator.destroy();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── Call question notifier ──────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
test('call question notifier persists assistant message and emits events', () => {
|
|
301
|
+
ensureConversation('conv-notifier-q');
|
|
302
|
+
|
|
303
|
+
const emittedEvents: Array<{ type: string; text?: string }> = [];
|
|
304
|
+
const sendToClient = (msg: { type: string; text?: string }) => {
|
|
305
|
+
emittedEvents.push(msg);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Register notifier (as Session would)
|
|
309
|
+
registerCallQuestionNotifier('conv-notifier-q', (_callSessionId: string, question: string) => {
|
|
310
|
+
const questionText = `**Live call question**:\n\n${question}\n\n_Reply in this thread to answer._`;
|
|
311
|
+
conversationStore.addMessage(
|
|
312
|
+
'conv-notifier-q',
|
|
313
|
+
'assistant',
|
|
314
|
+
JSON.stringify([{ type: 'text', text: questionText }]),
|
|
315
|
+
);
|
|
316
|
+
sendToClient({ type: 'assistant_text_delta', text: questionText });
|
|
317
|
+
sendToClient({ type: 'message_complete' });
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Fire the notifier
|
|
321
|
+
fireCallQuestionNotifier('conv-notifier-q', 'call-session-1', 'What time works best?');
|
|
322
|
+
|
|
323
|
+
// Verify message was persisted
|
|
324
|
+
const msgs = getMessagesForConversation('conv-notifier-q');
|
|
325
|
+
expect(msgs.length).toBe(1);
|
|
326
|
+
expect(msgs[0].role).toBe('assistant');
|
|
327
|
+
expect(msgs[0].content).toContain('What time works best?');
|
|
328
|
+
|
|
329
|
+
// Verify events were emitted
|
|
330
|
+
expect(emittedEvents.length).toBe(2);
|
|
331
|
+
expect(emittedEvents[0].type).toBe('assistant_text_delta');
|
|
332
|
+
expect(emittedEvents[0].text).toContain('What time works best?');
|
|
333
|
+
expect(emittedEvents[1].type).toBe('message_complete');
|
|
334
|
+
|
|
335
|
+
unregisterCallQuestionNotifier('conv-notifier-q');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ── Call completion notifier ────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
test('call completion notifier persists summary and emits events', () => {
|
|
341
|
+
ensureConversation('conv-notifier-c');
|
|
342
|
+
|
|
343
|
+
const emittedEvents: Array<{ type: string; text?: string }> = [];
|
|
344
|
+
const sendToClient = (msg: { type: string; text?: string }) => {
|
|
345
|
+
emittedEvents.push(msg);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// Create a call session so getCallSession works
|
|
349
|
+
const callSession = createCallSession({
|
|
350
|
+
conversationId: 'conv-notifier-c',
|
|
351
|
+
provider: 'twilio',
|
|
352
|
+
fromNumber: '+15551111111',
|
|
353
|
+
toNumber: '+15552222222',
|
|
354
|
+
});
|
|
355
|
+
updateCallSession(callSession.id, { status: 'completed', startedAt: Date.now() - 30000, endedAt: Date.now() });
|
|
356
|
+
recordCallEvent(callSession.id, 'call_started', {});
|
|
357
|
+
recordCallEvent(callSession.id, 'call_ended', {});
|
|
358
|
+
|
|
359
|
+
registerCallCompletionNotifier('conv-notifier-c', (_callSessionId: string) => {
|
|
360
|
+
const summaryText = `**Call completed**. Events recorded.`;
|
|
361
|
+
conversationStore.addMessage(
|
|
362
|
+
'conv-notifier-c',
|
|
363
|
+
'assistant',
|
|
364
|
+
JSON.stringify([{ type: 'text', text: summaryText }]),
|
|
365
|
+
);
|
|
366
|
+
sendToClient({ type: 'assistant_text_delta', text: summaryText });
|
|
367
|
+
sendToClient({ type: 'message_complete' });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
fireCallCompletionNotifier('conv-notifier-c', callSession.id);
|
|
371
|
+
|
|
372
|
+
// Verify message persisted
|
|
373
|
+
const msgs = getMessagesForConversation('conv-notifier-c');
|
|
374
|
+
expect(msgs.length).toBe(1);
|
|
375
|
+
expect(msgs[0].role).toBe('assistant');
|
|
376
|
+
expect(msgs[0].content).toContain('Call completed');
|
|
377
|
+
|
|
378
|
+
// Verify events emitted
|
|
379
|
+
expect(emittedEvents.length).toBe(2);
|
|
380
|
+
expect(emittedEvents[0].type).toBe('assistant_text_delta');
|
|
381
|
+
expect(emittedEvents[1].type).toBe('message_complete');
|
|
382
|
+
|
|
383
|
+
unregisterCallCompletionNotifier('conv-notifier-c');
|
|
384
|
+
});
|
|
385
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { isDeniedNumber } from '../calls/call-constants.js';
|
|
3
|
+
|
|
4
|
+
describe('isDeniedNumber', () => {
|
|
5
|
+
// Numbers that MUST be blocked
|
|
6
|
+
const blocked = [
|
|
7
|
+
'911',
|
|
8
|
+
'112',
|
|
9
|
+
'999',
|
|
10
|
+
'000',
|
|
11
|
+
'110',
|
|
12
|
+
'119',
|
|
13
|
+
'+112', // '+' stripped → '112' exact match
|
|
14
|
+
'+911', // '+' stripped → '911' exact match
|
|
15
|
+
'+1911', // country code 1 + 911
|
|
16
|
+
'+44999', // country code 44 + 999
|
|
17
|
+
'+61000', // country code 61 + 000
|
|
18
|
+
'+49110', // country code 49 + 110
|
|
19
|
+
'+81119', // country code 81 + 119
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
for (const num of blocked) {
|
|
23
|
+
test(`blocks ${num}`, () => {
|
|
24
|
+
expect(isDeniedNumber(num)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Numbers that MUST be allowed (legitimate phone numbers)
|
|
29
|
+
const allowed = [
|
|
30
|
+
'+14155551212', // US number — digits after any CC split don't match short codes
|
|
31
|
+
'+442071234567', // UK number
|
|
32
|
+
'+15559998888', // US number
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const num of allowed) {
|
|
36
|
+
test(`allows ${num}`, () => {
|
|
37
|
+
expect(isDeniedNumber(num)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
@@ -30,7 +30,19 @@ mock.module('../util/logger.js', () => ({
|
|
|
30
30
|
// ── Config mock ─────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
32
|
mock.module('../config/loader.js', () => ({
|
|
33
|
-
getConfig: () => ({
|
|
33
|
+
getConfig: () => ({
|
|
34
|
+
apiKeys: { anthropic: 'test-key' },
|
|
35
|
+
calls: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
provider: 'twilio',
|
|
38
|
+
maxDurationSeconds: 12 * 60,
|
|
39
|
+
userConsultTimeoutSeconds: 90,
|
|
40
|
+
userConsultationTimeoutSeconds: 90,
|
|
41
|
+
silenceTimeoutSeconds: 30,
|
|
42
|
+
disclosure: { enabled: false, text: '' },
|
|
43
|
+
safety: { denyCategories: [] },
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
34
46
|
}));
|
|
35
47
|
|
|
36
48
|
// ── Helpers for building mock streaming responses ───────────────────
|
|
@@ -80,12 +92,13 @@ mock.module('@anthropic-ai/sdk', () => {
|
|
|
80
92
|
|
|
81
93
|
// ── Import source modules after all mocks are registered ────────────
|
|
82
94
|
|
|
83
|
-
import { initializeDb, getDb } from '../memory/db.js';
|
|
95
|
+
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
84
96
|
import { conversations } from '../memory/schema.js';
|
|
85
97
|
import {
|
|
86
98
|
createCallSession,
|
|
87
99
|
getCallSession,
|
|
88
100
|
getPendingQuestion,
|
|
101
|
+
updateCallSession,
|
|
89
102
|
} from '../calls/call-store.js';
|
|
90
103
|
import {
|
|
91
104
|
getCallOrchestrator,
|
|
@@ -96,6 +109,7 @@ import type { RelayConnection } from '../calls/relay-server.js';
|
|
|
96
109
|
initializeDb();
|
|
97
110
|
|
|
98
111
|
afterAll(() => {
|
|
112
|
+
resetDb();
|
|
99
113
|
try {
|
|
100
114
|
rmSync(testDir, { recursive: true });
|
|
101
115
|
} catch {
|
|
@@ -169,6 +183,7 @@ function setupOrchestrator(task?: string) {
|
|
|
169
183
|
toNumber: '+15552222222',
|
|
170
184
|
task,
|
|
171
185
|
});
|
|
186
|
+
updateCallSession(session.id, { status: 'in_progress' });
|
|
172
187
|
const relay = createMockRelay();
|
|
173
188
|
const orchestrator = new CallOrchestrator(session.id, relay as unknown as RelayConnection, task ?? null);
|
|
174
189
|
return { session, relay, orchestrator };
|
|
@@ -212,6 +227,27 @@ describe('call-orchestrator', () => {
|
|
|
212
227
|
orchestrator.destroy();
|
|
213
228
|
});
|
|
214
229
|
|
|
230
|
+
test('handleCallerUtterance: includes speaker context in model message', async () => {
|
|
231
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
232
|
+
const firstArg = args[0] as { messages: Array<{ role: string; content: string }> };
|
|
233
|
+
const userMessage = firstArg.messages.find((m) => m.role === 'user');
|
|
234
|
+
expect(userMessage?.content).toContain('[SPEAKER id="speaker-1" label="Aaron" source="provider" confidence="0.91"]');
|
|
235
|
+
expect(userMessage?.content).toContain('Can you summarize this meeting?');
|
|
236
|
+
return createMockStream(['Sure, here is a summary.']);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const { orchestrator } = setupOrchestrator();
|
|
240
|
+
|
|
241
|
+
await orchestrator.handleCallerUtterance('Can you summarize this meeting?', {
|
|
242
|
+
speakerId: 'speaker-1',
|
|
243
|
+
speakerLabel: 'Aaron',
|
|
244
|
+
speakerConfidence: 0.91,
|
|
245
|
+
source: 'provider',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
orchestrator.destroy();
|
|
249
|
+
});
|
|
250
|
+
|
|
215
251
|
// ── ASK_USER pattern ──────────────────────────────────────────────
|
|
216
252
|
|
|
217
253
|
test('ASK_USER pattern: detects pattern, creates pending question, enters waiting_on_user', async () => {
|
|
@@ -266,7 +302,7 @@ describe('call-orchestrator', () => {
|
|
|
266
302
|
|
|
267
303
|
// ── handleUserAnswer ──────────────────────────────────────────────
|
|
268
304
|
|
|
269
|
-
test('handleUserAnswer:
|
|
305
|
+
test('handleUserAnswer: returns true immediately and fires LLM asynchronously', async () => {
|
|
270
306
|
// First utterance triggers ASK_USER
|
|
271
307
|
mockStreamFn.mockImplementation(() =>
|
|
272
308
|
createMockStream(['Hold on. [ASK_USER: Preferred time?]']),
|
|
@@ -284,7 +320,12 @@ describe('call-orchestrator', () => {
|
|
|
284
320
|
return createMockStream(['Great, I have scheduled for 3pm tomorrow.']);
|
|
285
321
|
});
|
|
286
322
|
|
|
287
|
-
await orchestrator.handleUserAnswer('3pm tomorrow');
|
|
323
|
+
const accepted = await orchestrator.handleUserAnswer('3pm tomorrow');
|
|
324
|
+
expect(accepted).toBe(true);
|
|
325
|
+
|
|
326
|
+
// handleUserAnswer fires runLlm without awaiting, so give the
|
|
327
|
+
// microtask queue a tick to let the async LLM work complete.
|
|
328
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
288
329
|
|
|
289
330
|
// Should have streamed a response for the answer
|
|
290
331
|
const tokensAfterAnswer = relay.sentTokens.filter((t) => t.token.includes('3pm'));
|
|
@@ -293,6 +334,91 @@ describe('call-orchestrator', () => {
|
|
|
293
334
|
orchestrator.destroy();
|
|
294
335
|
});
|
|
295
336
|
|
|
337
|
+
// ── Full mid-call question flow ──────────────────────────────────
|
|
338
|
+
|
|
339
|
+
test('mid-call question flow: unavailable time → ask user → user confirms → resumed call', async () => {
|
|
340
|
+
// Step 1: Caller says "7:30" but it's unavailable. The LLM asks the user.
|
|
341
|
+
mockStreamFn.mockImplementation(() =>
|
|
342
|
+
createMockStream(['I\'m sorry, 7:30 is not available. ', '[ASK_USER: Is 8:00 okay instead?]']),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const { session, relay, orchestrator } = setupOrchestrator('Schedule a haircut');
|
|
346
|
+
|
|
347
|
+
await orchestrator.handleCallerUtterance('Can I book for 7:30?');
|
|
348
|
+
|
|
349
|
+
// Verify we're in waiting_on_user state
|
|
350
|
+
expect(orchestrator.getState()).toBe('waiting_on_user');
|
|
351
|
+
const question = getPendingQuestion(session.id);
|
|
352
|
+
expect(question).not.toBeNull();
|
|
353
|
+
expect(question!.questionText).toBe('Is 8:00 okay instead?');
|
|
354
|
+
|
|
355
|
+
// Verify session status
|
|
356
|
+
const midSession = getCallSession(session.id);
|
|
357
|
+
expect(midSession!.status).toBe('waiting_on_user');
|
|
358
|
+
|
|
359
|
+
// Step 2: User answers "Yes, 8:00 works"
|
|
360
|
+
mockStreamFn.mockImplementation(() =>
|
|
361
|
+
createMockStream(['Great, I\'ve booked you for 8:00. See you then! ', '[END_CALL]']),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
const accepted = await orchestrator.handleUserAnswer('Yes, 8:00 works for me');
|
|
365
|
+
expect(accepted).toBe(true);
|
|
366
|
+
|
|
367
|
+
// Give the fire-and-forget LLM call time to complete
|
|
368
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
369
|
+
|
|
370
|
+
// Step 3: Verify call completed
|
|
371
|
+
const endSession = getCallSession(session.id);
|
|
372
|
+
expect(endSession!.status).toBe('completed');
|
|
373
|
+
expect(endSession!.endedAt).not.toBeNull();
|
|
374
|
+
|
|
375
|
+
// Verify the END_CALL marker triggered endSession on relay
|
|
376
|
+
expect(relay.endCalled).toBe(true);
|
|
377
|
+
|
|
378
|
+
orchestrator.destroy();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ── Provider / LLM failure paths ───────────────────────────────
|
|
382
|
+
|
|
383
|
+
test('LLM error: sends error message to caller and returns to idle', async () => {
|
|
384
|
+
// Make the stream throw an error on finalMessage
|
|
385
|
+
mockStreamFn.mockImplementation(() => {
|
|
386
|
+
const emitter = new EventEmitter();
|
|
387
|
+
return {
|
|
388
|
+
on: (event: string, handler: (...args: unknown[]) => void) => {
|
|
389
|
+
emitter.on(event, handler);
|
|
390
|
+
return { on: () => ({ on: () => ({}) }) };
|
|
391
|
+
},
|
|
392
|
+
finalMessage: () => Promise.reject(new Error('API rate limit exceeded')),
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const { relay, orchestrator } = setupOrchestrator();
|
|
397
|
+
|
|
398
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
399
|
+
|
|
400
|
+
// Should have sent an error recovery message
|
|
401
|
+
const errorTokens = relay.sentTokens.filter((t) =>
|
|
402
|
+
t.token.includes('technical issue'),
|
|
403
|
+
);
|
|
404
|
+
expect(errorTokens.length).toBeGreaterThan(0);
|
|
405
|
+
|
|
406
|
+
// State should return to idle after error
|
|
407
|
+
expect(orchestrator.getState()).toBe('idle');
|
|
408
|
+
|
|
409
|
+
orchestrator.destroy();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('handleUserAnswer: returns false when not in waiting_on_user state', async () => {
|
|
413
|
+
const { orchestrator } = setupOrchestrator();
|
|
414
|
+
|
|
415
|
+
// Orchestrator starts in idle state
|
|
416
|
+
const result = await orchestrator.handleUserAnswer('some answer');
|
|
417
|
+
expect(result).toBe(false);
|
|
418
|
+
|
|
419
|
+
orchestrator.destroy();
|
|
420
|
+
});
|
|
421
|
+
|
|
296
422
|
// ── handleInterrupt ───────────────────────────────────────────────
|
|
297
423
|
|
|
298
424
|
test('handleInterrupt: resets state to idle', () => {
|