diff --git a/.env.example b/.env.example index e45ec2a..a8fb535 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,10 @@ INKBOX_SIGNING_KEY=whsec_xxxxxxxxxxxx # INKBOX_WEBHOOK_SECRET_GITHUB=... # verification secret for a registered source # INKBOX_GATEWAY_PORT=8767 -# --- Voice (on by default; Realtime auto-enables when a key is set) --- +# --- Phone call voice stack --- +# INKBOX_VOICE_STACK=inkbox_voice_ai # or openai_realtime / inkbox_tts_stt +# INKBOX_VOICE_AI_AUTHORITY_MODE=contact_scoped # or yolo; local mirror checked by doctor +# INKBOX_VOICEMAIL_DETECTION=enabled # set disabled for calls that must ignore detection # OPENAI_API_KEY=sk-openai # enables OpenAI Realtime calls # INKBOX_REALTIME_API_KEY=sk-realtime # dedicated key; OPENAI_API_KEY backstops # INKBOX_VOICE_ENABLED=false # stop answering calls entirely diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 3493fd7..b702ee8 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: a4dd76b534e33c0a8352148d8f0e9ab25199f05b + ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: a4dd76b534e33c0a8352148d8f0e9ab25199f05b + ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e path: .ci/inkbox - uses: actions/setup-node@v7 with: diff --git a/.github/workflows/live-a2a.yml b/.github/workflows/live-a2a.yml index 8a8dc02..bd17bb9 100644 --- a/.github/workflows/live-a2a.yml +++ b/.github/workflows/live-a2a.yml @@ -58,13 +58,13 @@ jobs: python-version: "3.12" - name: Install protocol driver - run: pip install 'inkbox==0.5.8' + run: pip install 'inkbox==0.5.9' - name: Install plugin and host run: | npm ci npm install --no-save --package-lock=false \ - @inkbox/sdk@0.5.8 \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 npm install -g opencode-ai@latest @@ -75,6 +75,7 @@ jobs: AUT_INKBOX_SIGNING_KEY: ${{ secrets.AUT_INKBOX_SIGNING_KEY }} INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL || 'https://inkbox.ai' }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + INKBOX_VOICEMAIL_DETECTION: "disabled" run: bash scripts/live-aut.sh - name: Run ${{ matrix.scenario }} diff --git a/.github/workflows/live-channels.yml b/.github/workflows/live-channels.yml index 257b4cd..f23da1d 100644 --- a/.github/workflows/live-channels.yml +++ b/.github/workflows/live-channels.yml @@ -40,6 +40,7 @@ concurrency: jobs: live: runs-on: ubuntu-latest + timeout-minutes: 25 strategy: fail-fast: false max-parallel: 1 # legs share the AUT identity → one at a time @@ -51,7 +52,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: a4dd76b534e33c0a8352148d8f0e9ab25199f05b + ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -72,7 +73,8 @@ jobs: nohup node tests/live/mock-openai.mjs 8088 > "$RUNNER_TEMP/mock.log" 2>&1 & echo $! > "$RUNNER_TEMP/mock.pid" for i in $(seq 1 10); do - curl -sf http://127.0.0.1:8088/v1/models >/dev/null && { echo "mock model ready"; exit 0; } + curl -sf --connect-timeout 1 --max-time 3 \ + http://127.0.0.1:8088/v1/models >/dev/null && { echo "mock model ready"; exit 0; } sleep 1 done echo "::error::mock model did not start"; cat "$RUNNER_TEMP/mock.log"; exit 1 @@ -85,6 +87,7 @@ jobs: AUT_INKBOX_SIGNING_KEY: ${{ secrets.AUT_INKBOX_SIGNING_KEY }} INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + INKBOX_VOICEMAIL_DETECTION: "disabled" run: bash scripts/live-aut.sh - name: Run live channel tests (${{ matrix.mode }}) @@ -101,7 +104,7 @@ jobs: # Failure-only: these logs carry live phone/email content and this # repo's Action logs are public. - name: Dump logs (on failure only) - if: failure() + if: failure() || cancelled() run: | echo "=== gateway.log ==="; cat "$AUT_GATEWAY_LOG" || true echo "=== serve.log ==="; tail -n 100 "$AUT_SERVE_LOG" || true @@ -115,7 +118,7 @@ jobs: kill "$(cat "$RUNNER_TEMP/mock.pid" 2>/dev/null)" 2>/dev/null || true - name: Upload artifacts (on failure only) - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: live-logs-${{ matrix.mode }} diff --git a/.github/workflows/live-external-events.yml b/.github/workflows/live-external-events.yml index 7b410ec..c60301c 100644 --- a/.github/workflows/live-external-events.yml +++ b/.github/workflows/live-external-events.yml @@ -41,7 +41,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: a4dd76b534e33c0a8352148d8f0e9ab25199f05b + ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -65,6 +65,7 @@ jobs: INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} INKBOX_WEBHOOK_SECRET_GITHUB: ${{ secrets.LIVE_GITHUB_WEBHOOK_SECRET }} + INKBOX_VOICEMAIL_DETECTION: "disabled" run: bash scripts/live-aut.sh - name: Run external-event test diff --git a/.github/workflows/live-voice.yml b/.github/workflows/live-voice.yml index e12e179..0fff312 100644 --- a/.github/workflows/live-voice.yml +++ b/.github/workflows/live-voice.yml @@ -1,9 +1,10 @@ -name: Live — voice calls (Inkbox STT/TTS + Realtime) +name: Live — voice calls (Voice AI + Realtime + Inkbox TTS/STT) # Boots the AUT gateway plus a driver process that bridges the other side of a -# real phone call over its own Inkbox tunnel. Two matrix legs: +# real phone call over its own Inkbox tunnel. Three available matrix legs: # inbound_inkbox — driver calls the agent; agent answers Inkbox STT/TTS. # outbound_realtime — driver texts "call me"; agent calls back on Realtime. +# outbound_hosted — driver requests a Voice AI call and one exact post-call SMS. # Each leg verifies the stored call transcript shows the agent spoke to the # caller. Real model + real calls — the priciest suite — so it runs only on # ready (non-draft) PRs + manual dispatch, and shares the AUT tunnel lock. @@ -47,18 +48,19 @@ concurrency: jobs: voice: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: fail-fast: false max-parallel: 1 # legs share the AUT + driver identities → one at a time matrix: - scenario: ${{ fromJSON(inputs.include_inbound && '["inbound_inkbox","outbound_realtime"]' || '["outbound_realtime"]') }} + scenario: ${{ fromJSON(inputs.include_inbound && '["inbound_inkbox","outbound_realtime","outbound_hosted"]' || '["outbound_realtime","outbound_hosted"]') }} steps: - uses: actions/checkout@v7 - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: a4dd76b534e33c0a8352148d8f0e9ab25199f05b + ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -82,8 +84,9 @@ jobs: INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} INKBOX_VOICE_ENABLED: "true" - # Inbound leg proves the Inkbox STT/TTS path; the others prove Realtime. - INKBOX_REALTIME_ENABLED: ${{ matrix.scenario == 'inbound_inkbox' && 'false' || 'true' }} + INKBOX_VOICE_STACK: ${{ matrix.scenario == 'outbound_hosted' && 'inkbox_voice_ai' || (matrix.scenario == 'outbound_realtime' && 'openai_realtime' || 'inkbox_tts_stt') }} + INKBOX_VOICEMAIL_DETECTION: "disabled" + INKBOX_REALTIME_ENABLED: ${{ matrix.scenario == 'outbound_realtime' && 'true' || 'false' }} INKBOX_REALTIME_API_KEY: ${{ secrets.OPENAI_API_KEY }} run: bash scripts/live-aut.sh @@ -93,6 +96,13 @@ jobs: INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }} VOICE_DRIVER_STATE: ${{ runner.temp }}/voice_driver_state.json run: | + if [ "${{ matrix.scenario }}" = "outbound_hosted" ]; then + HOSTED_MARKER="$(node scripts/nato-marker.mjs "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT")" + echo "HOSTED_POST_CALL_MARKER=$HOSTED_MARKER" >> "$GITHUB_ENV" + export VOICE_DRIVER_LINE="After we hang up, send me one SMS. Create the post-call action now with this exact SMS body: $HOSTED_MARKER. Read those five words back to me after the action is saved. Do not send it during the call." + export VOICE_DRIVER_LISTEN=180 + export VOICE_DRIVER_AUTO_STOP=false + fi nohup node tests/live/voice-driver.mjs > "$RUNNER_TEMP/driver.log" 2>&1 & echo $! > "$RUNNER_TEMP/driver.pid" for _ in $(seq 1 30); do # up to ~90s @@ -115,7 +125,7 @@ jobs: # Failure-only: these logs carry live call content and this repo is public. - name: Dump logs (on failure only) - if: failure() + if: failure() || cancelled() run: | echo "=== gateway.log ==="; cat "$AUT_GATEWAY_LOG" || true echo "=== serve.log ==="; tail -n 100 "$AUT_SERVE_LOG" || true @@ -130,7 +140,7 @@ jobs: sleep 3 # let the driver revert its number on exit - name: Upload artifacts (on failure only) - if: failure() + if: failure() || cancelled() uses: actions/upload-artifact@v7 with: name: voice-logs-${{ matrix.scenario }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ffdb31c..2791c5d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: a4dd76b534e33c0a8352148d8f0e9ab25199f05b + ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -39,7 +39,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: a4dd76b534e33c0a8352148d8f0e9ab25199f05b + ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e path: .ci/inkbox - uses: actions/setup-node@v7 with: @@ -65,7 +65,7 @@ jobs: - uses: actions/checkout@v7 with: repository: inkbox-ai/inkbox - ref: a4dd76b534e33c0a8352148d8f0e9ab25199f05b + ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e path: .ci/inkbox - uses: actions/setup-node@v7 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f50903f..befed28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.8 (unreleased) + +- Adds explicit Inkbox Voice AI, OpenAI Realtime API, and Inkbox TTS/STT phone-call stacks to setup. +- Routes hosted calls through Voice AI and reconciles `call.ended` commitments in the OpenCode session. +- Guards hosted post-call SMS with an exact-target durable journal and a bounded correction policy. +- Uses `@inkbox/sdk` 0.5.9 and disables voicemail detection throughout live call CI. + ## 0.2.7 (unreleased) - Adds safely framed matched-contact memories to inbound email, SMS, iMessage, @@ -31,7 +38,7 @@ existing approval and recipient-allowlist controls. - Adds paginated task and message history with direction, participant, lifecycle, context, role, keyword, and timestamp filters. -- The plugin requires `@inkbox/sdk` 0.5.8 or newer. +- The plugin uses exactly `@inkbox/sdk` 0.5.9. ## 0.1.0 (unreleased) diff --git a/README.md b/README.md index 96a1942..7752819 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ That's the whole setup. The clone lives in `~/.inkbox-opencode/app`; the plugin is installed into your **global opencode config** (`~/.config/opencode`) with a one-file wrapper. The wizard creates a fresh Inkbox agent via self-signup (or takes an existing API key), enables iMessage, provisions a -dedicated phone number, waits for your START opt-in, validates an OpenAI key -for Realtime voice, mints the webhook signing key, picks the agent's working +dedicated phone number, waits for your START opt-in, configures the phone call +voice stack, mints the webhook signing key, picks the agent's working directory, and offers to **keep the gateway running on every boot**. When it finishes, text, email, or call your agent and it answers. @@ -223,10 +223,14 @@ Outbound calls can originate from either line, chosen with `origination` on iMessage; the underlying number is never surfaced. When `origination` is omitted the plugin uses whichever line exists, and -prefers the dedicated number when both do. Note that `inkbox_place_call` -currently requires an audio bridge: pass `clientWebsocketUrl` per call or set -the `callWebsocketUrl` option (env `INKBOX_CALL_WEBSOCKET_URL`) — Inkbox -connects to that WebSocket for the call's media. +prefers the dedicated number when both do. OpenAI Realtime and Inkbox TTS/STT +calls require an audio bridge: pass `clientWebsocketUrl` per call or set the +`callWebsocketUrl` option (env `INKBOX_CALL_WEBSOCKET_URL`). Inkbox Voice AI +handles media itself and receives the call's required `purpose` as its task brief. +Hosted outbound calls do not send a per-call authority override: Inkbox applies +the saved Voice AI default. The wizard changes that server-side default only +with an admin credential and records `INKBOX_VOICE_AI_AUTHORITY_MODE` as a local +mirror so `doctor` can report configuration drift. ## Configuration reference @@ -236,7 +240,10 @@ connects to that WebSocket for the call's media. | `identity` | `INKBOX_IDENTITY` (also `INKBOX_AGENT_IDENTITY`, `INKBOX_AGENT_HANDLE`) | Agent handle (required) | | `baseUrl` | `INKBOX_BASE_URL` | API base URL override | | `signingKey` | `INKBOX_SIGNING_KEY` | Webhook signature key (future inbound use) | -| `callWebsocketUrl` | `INKBOX_CALL_WEBSOCKET_URL` | Audio-bridge WebSocket for `inkbox_place_call` | +| `callWebsocketUrl` | `INKBOX_CALL_WEBSOCKET_URL` | Audio bridge used by OpenAI Realtime and Inkbox TTS/STT calls | +| `phoneVoiceStack` | `INKBOX_VOICE_STACK` | `inkbox_voice_ai`, `openai_realtime`, or `inkbox_tts_stt` | +| `voiceAiAuthorityMode` | `INKBOX_VOICE_AI_AUTHORITY_MODE` | Local mirror of saved Voice AI authority: `contact_scoped` or `yolo` | +| `voicemailDetection` | `INKBOX_VOICEMAIL_DETECTION` | Optional explicit `enabled` / `disabled`; omission uses the Inkbox API default | | `vault.keyEnvVar` | — (default `INKBOX_VAULT_KEY`) | Which env var holds the vault unlock key | | `tools.enable` / `tools.disable` | — | Tool gating (names, groups, `"all"`) | | `outbound.approval` | — | `"ask"` (default) / `"allowlist"` / `"auto"` | @@ -312,8 +319,10 @@ inbound events. What it does: decline") and time out to a decline. - **Control commands** (whole-message): `/clear`, `/stop`, `/status`, `/health`, `/resume`, `/usage`. -- **Voice** (on by default with the gateway): the agent answers calls. Realtime - auto-enables when an OpenAI key is present (`INKBOX_REALTIME_API_KEY`, or +- **Voice** (on by default with the gateway): setup offers Inkbox Voice AI, + OpenAI Realtime API, and Inkbox TTS/STT. Voice AI handles the live call and + notifies OpenCode after it ends; the two local stacks keep the call attached + to the OpenCode gateway. Realtime uses `INKBOX_REALTIME_API_KEY` (or `OPENAI_API_KEY` as the backstop) and runs the call as a live raw-audio conversation with in-call actions; otherwise Inkbox handles speech-to-text and text-to-speech. Opt out with `INKBOX_VOICE_ENABLED=false` (stop answering) diff --git a/docs/live-ci.md b/docs/live-ci.md new file mode 100644 index 0000000..055b753 --- /dev/null +++ b/docs/live-ci.md @@ -0,0 +1,75 @@ +# Live CI + +These Actions exercise the installed plugin against live Inkbox identities. Each component Action supports reusable and manual execution; credential-gated tests skip outside configured live jobs. Tests use current-run markers or pre-request snapshots so stale records cannot pass. + +## Full stack e2e + +Runs the reusable Actions in sequence for ready same-repository pull requests, manual dispatches, and successful canary runs on `main`. The orchestrated voice call includes the conditional inbound scenario. + +### `full-stack` + +**Proves:** Every live suite passes as one required gate. **Flow:** 1. Run channels. 2. Run Agent2Agent. 3. Run voice. 4. Run external events. 5. Fail unless every suite succeeded. + +## Live — Agent2Agent + +Runs all four scenarios serially with both live identity credentials. + +### `inbound-single` + +**Proves:** The plugin completes one inbound A2A task. **Flow:** 1. Send a tagged task. 2. Wait for completion. 3. Require the tag in task history. + +### `inbound-multi` + +**Proves:** An inbound task can request and consume follow-up input. **Flow:** 1. Send a tagged task. 2. Wait for `input-required`. 3. Reply in the same task. 4. Require both tags at completion. + +### `outbound-single` + +**Proves:** The agent delegates work without completing its outer task early. **Flow:** 1. Request delegation. 2. Find the tagged worker task. 3. Complete it remotely. 4. Require its result in the outer completion. + +### `outbound-multi` + +**Proves:** Delegation preserves a worker's input round trip. **Flow:** 1. Start a delegated task. 2. Receive its input request. 3. Reply through the agent. 4. Complete the worker. 5. Require its result in the outer task. + +## Live — agent channels (email + SMS) + +The `mock` matrix leg runs only the deterministic tests; the `real` leg runs only the real-model tests. Both require live identity credentials. + +### `email — mock model: the nonce travels inbound → model → reply → delivery` + +**Proves:** The complete email transport works deterministically. **Flow:** 1. Snapshot inbound email IDs. 2. Send a unique nonce. 3. Wait for a fresh reply. 4. Require the nonce and mock marker. + +### `email — real model: replies with actual content` + +**Proves:** The real agent can answer over email. **Flow:** 1. Snapshot inbound email IDs. 2. Request a fixed acknowledgement. 3. Wait for a fresh reply. 4. Reject error text and require the acknowledgement. + +### `SMS — mock model: the nonce travels inbound → model → reply → delivery` + +**Proves:** The complete SMS transport works deterministically. **Flow:** 1. Send a unique nonce. 2. Wait for a fresh inbound reply. 3. Require the nonce and mock marker. + +### `SMS — real model: reports its own identity when asked` + +**Proves:** The real agent receives context and answers over SMS. **Flow:** 1. Read the agent mailbox. 2. Ask for that address by SMS. 3. Wait for a fresh reply. 4. Require the exact address. + +## Live — voice calls (Voice AI + Realtime + Inkbox TTS/STT) + +Requires both live identity credentials and a real model. Outbound Realtime and Voice AI always run; inbound TTS/STT runs only when `include_inbound` is true. + +### `inbound: driver calls, agent answers via Inkbox TTS/STT and replies` + +**Proves:** Inbound client-media calling uses Inkbox speech services. **Flow:** 1. Snapshot agent calls. 2. Place a call with voicemail detection disabled. 3. Require two-way speech. 4. Verify the persisted call policy and speech mode. 5. Hang up. + +### `outbound: 'call me' text → agent calls back on the Realtime path and replies` + +**Proves:** A message-triggered callback uses Realtime. **Flow:** 1. Snapshot both owners' calls. 2. Text the request. 3. Require exactly one fresh paired call after duplicate grace. 4. Require two-way speech, Realtime flags, and disabled voicemail detection. 5. Hang up. + +### `outbound: Voice AI call settles one exact-target post-call SMS` + +**Proves:** Hosted calling completes one durable post-call action. **Flow:** 1. Snapshot both call owners and sender-side SMS rows. 2. Request a hosted call. 3. Require one fresh pair, reason, saved authority, and disabled voicemail detection. 4. Before hangup, require caller intent and a matching open action. 5. Hang up. 6. Require completed reconciliation and exactly one current-marker SMS to the caller after duplicate grace. + +## Live — external events (webhook → agent acts) + +Runs only with live identity credentials, a real model, the webhook signing secret, and the gateway log used to correlate the exact turn. + +### `rejects forged GitHub hooks and completes a valid real-model turn` + +**Proves:** External events are authenticated before agent execution. **Flow:** 1. Send an invalidly signed event and require rejection with no turn. 2. Send a valid event. 3. Require acceptance. 4. Wait for the exact request's completed agent turn. diff --git a/package-lock.json b/package-lock.json index c810292..e226cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@inkbox/opencode-plugin", - "version": "0.2.7", + "version": "0.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@inkbox/opencode-plugin", - "version": "0.2.7", + "version": "0.2.8", "license": "MIT", "dependencies": { - "@inkbox/sdk": ">=0.5.8 <1.0.0", + "@inkbox/sdk": "0.5.9", "@opencode-ai/sdk": ">=1.17.18 <1.19.0", "ws": "^8.21.0", "zod": "4.1.8" @@ -18,7 +18,7 @@ "inkbox-opencode": "bin/inkbox-opencode.js" }, "devDependencies": { - "@biomejs/biome": "^2.5.0", + "@biomejs/biome": "2.5.6", "@opencode-ai/plugin": "1.17.18", "@types/node": "^22.0.0", "@types/ws": "^8.18.1", @@ -51,9 +51,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.3.tgz", - "integrity": "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.6.tgz", + "integrity": "sha512-lxVNjv7UF6KfhMJfL9gaUHbWdJdHbsAj6OSmwSYNdhRuG67NxNQ4Xdvh3TUxsSK9sBzJBQhEJj3AopmmNJ5pSA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -67,20 +67,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.5.3", - "@biomejs/cli-darwin-x64": "2.5.3", - "@biomejs/cli-linux-arm64": "2.5.3", - "@biomejs/cli-linux-arm64-musl": "2.5.3", - "@biomejs/cli-linux-x64": "2.5.3", - "@biomejs/cli-linux-x64-musl": "2.5.3", - "@biomejs/cli-win32-arm64": "2.5.3", - "@biomejs/cli-win32-x64": "2.5.3" + "@biomejs/cli-darwin-arm64": "2.5.6", + "@biomejs/cli-darwin-x64": "2.5.6", + "@biomejs/cli-linux-arm64": "2.5.6", + "@biomejs/cli-linux-arm64-musl": "2.5.6", + "@biomejs/cli-linux-x64": "2.5.6", + "@biomejs/cli-linux-x64-musl": "2.5.6", + "@biomejs/cli-win32-arm64": "2.5.6", + "@biomejs/cli-win32-x64": "2.5.6" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.3.tgz", - "integrity": "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-zMOLZP4oMrjh6m1zcSj1ud2awUPgTuMVbmQhYYWL7J8HwCnbHHBvTm7VBTRuY7epT5bez76IpKYQ11ZAqHFlnw==", "cpu": [ "arm64" ], @@ -95,9 +95,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.3.tgz", - "integrity": "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.6.tgz", + "integrity": "sha512-JAC1VqzvO7Th5ZplU0G2uGfkZbxEe9uDDektPAhF0JLusoz1w+T4okp2bkykI0bbaO2vslKiRfj4gU43JaGreA==", "cpu": [ "x64" ], @@ -112,13 +112,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.3.tgz", - "integrity": "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.6.tgz", + "integrity": "sha512-6XsYwCFkp5sMxl85ffhgeGpGgs6A7dRYFnkceZ7WVxvycuTnGdD5xa534Z3xfrBQ0JCMK/mujT6ZNPJoghedwg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -129,13 +132,16 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.3.tgz", - "integrity": "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-eUa3jeeYvfMt19LBeh6E5PUZpxnTC4JqNWo+EDjTtQjAr2xLGnWaxACtVU1DQqmHYbvThlJzLX+ZsYgrqh2qVw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -146,13 +152,16 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.3.tgz", - "integrity": "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.6.tgz", + "integrity": "sha512-Pop9VXCFUhFTMfFefZ39S+u2rOPyNp5iHlxbZRwXGACHLy2r0jjiRgJHmaEKJzL3SyxlVeGShXhvvElvWowonA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -163,13 +172,16 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.3.tgz", - "integrity": "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-2Vp13QdKysH3HIWLaYLhUUwbK+jbZonJD1K+Lr0d0RO4wH7mkYd43vJixEDm8cUWrowoRz4UUHF1nm9Ae7ym8A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -180,9 +192,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.3.tgz", - "integrity": "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.6.tgz", + "integrity": "sha512-tDGshcm6BdkZOCGnTDX0Y8/U4IfBSlnUU7T56nNDuPEfed+aHg+u8G36NB43fJVl0Os6+QURXIE1yuD7AaEofA==", "cpu": [ "arm64" ], @@ -197,9 +209,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.3.tgz", - "integrity": "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q==", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.6.tgz", + "integrity": "sha512-WN05KwXnTO/2J45RQPvzZMXf7tZUIofHoR35xIPfCo7pQ2RFidxI8sfb5mGsaTxdMmEOzHzOPRCdA5/fCpc7xQ==", "cpu": [ "x64" ], @@ -605,9 +617,9 @@ } }, "node_modules/@inkbox/sdk": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@inkbox/sdk/-/sdk-0.5.8.tgz", - "integrity": "sha512-7M9JToS4JE4UMMR5zwzapye4+n7ctTXR+nu8skWpCXNrdyPlt444xzdqGsj7GApIz9AGTaD+PiXzwucNzh//BQ==", + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@inkbox/sdk/-/sdk-0.5.9.tgz", + "integrity": "sha512-+cF9XYGXkNj9h4hRA2ix2647g6HrO3yAqd/KFtd485iz9rgc42hnZ7Mwpu1BuApY4ed6D3DAEdamkwvaO2WIoA==", "license": "MIT", "dependencies": { "@peculiar/x509": "^2.0.0", diff --git a/package.json b/package.json index 49bd895..20590af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inkbox/opencode-plugin", - "version": "0.2.7", + "version": "0.2.8", "private": true, "description": "Inkbox for opencode \u2014 give your agent an email address, a phone number (SMS/MMS + voice), iMessage, contacts, notes, and an encrypted credential vault.", "license": "MIT", @@ -55,7 +55,7 @@ "opencode": ">=1.15.0 <1.19.0" }, "dependencies": { - "@inkbox/sdk": ">=0.5.8 <1.0.0", + "@inkbox/sdk": "0.5.9", "@opencode-ai/sdk": ">=1.17.18 <1.19.0", "ws": "^8.21.0", "zod": "4.1.8" @@ -69,7 +69,7 @@ } }, "devDependencies": { - "@biomejs/biome": "^2.5.0", + "@biomejs/biome": "2.5.6", "@opencode-ai/plugin": "1.17.18", "@types/node": "^22.0.0", "@types/ws": "^8.18.1", diff --git a/scripts/live-aut.sh b/scripts/live-aut.sh index b119c43..ac17e24 100755 --- a/scripts/live-aut.sh +++ b/scripts/live-aut.sh @@ -111,13 +111,17 @@ echo "==> starting opencode serve on :$SERVE_PORT" INKBOX_SIGNING_KEY="$AUT_INKBOX_SIGNING_KEY" \ INKBOX_BASE_URL="$BASE_URL" \ INKBOX_CALL_WEBSOCKET_URL="$AUT_CALL_WS" \ + INKBOX_VOICE_STACK="${INKBOX_VOICE_STACK:-}" \ + INKBOX_VOICEMAIL_DETECTION="${INKBOX_VOICEMAIL_DETECTION:-disabled}" \ nohup opencode serve --port "$SERVE_PORT" > "$SERVE_LOG" 2>&1 & echo $! > "$WORKDIR/serve.pid") for _ in $(seq 1 30); do - curl -sf "http://127.0.0.1:$SERVE_PORT/config" >/dev/null 2>&1 && break + curl -sf --connect-timeout 1 --max-time 3 \ + "http://127.0.0.1:$SERVE_PORT/config" >/dev/null 2>&1 && break sleep 2 done -curl -sf "http://127.0.0.1:$SERVE_PORT/config" >/dev/null || { +curl -sf --connect-timeout 1 --max-time 3 \ + "http://127.0.0.1:$SERVE_PORT/config" >/dev/null || { echo "::error::opencode serve did not come up"; cat "$SERVE_LOG"; exit 1; } echo "==> starting the gateway sidecar ($MODE model: $GATEWAY_MODEL)" @@ -132,6 +136,8 @@ echo "==> starting the gateway sidecar ($MODE model: $GATEWAY_MODEL)" INKBOX_GATEWAY_AGENT=inkbox-channel \ INKBOX_GATEWAY_MODEL="$GATEWAY_MODEL" \ INKBOX_VOICE_ENABLED="${INKBOX_VOICE_ENABLED:-}" \ + INKBOX_VOICE_STACK="${INKBOX_VOICE_STACK:-}" \ + INKBOX_VOICEMAIL_DETECTION="${INKBOX_VOICEMAIL_DETECTION:-disabled}" \ INKBOX_REALTIME_ENABLED="${INKBOX_REALTIME_ENABLED:-}" \ INKBOX_REALTIME_API_KEY="${INKBOX_REALTIME_API_KEY:-}" \ OPENCODE_SERVER_URL="http://127.0.0.1:$SERVE_PORT" \ diff --git a/scripts/nato-marker.mjs b/scripts/nato-marker.mjs new file mode 100644 index 0000000..f2a6fb0 --- /dev/null +++ b/scripts/nato-marker.mjs @@ -0,0 +1,52 @@ +import { pathToFileURL } from "node:url"; + +const RADIO_WORDS = [ + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliet", + "kilo", + "lima", + "mike", + "november", + "oscar", + "papa", + "quebec", + "romeo", + "sierra", + "tango", + "uniform", + "victor", + "whiskey", + "xray", + "yankee", + "zulu", +]; + +export function natoMarker(runId, runAttempt) { + let value = BigInt(runId) * 10n + BigInt(runAttempt); + const used = new Set(); + const marker = []; + for (let count = 0; count < 5; count += 1) { + let index = Number(value % BigInt(RADIO_WORDS.length)); + value /= BigInt(RADIO_WORDS.length); + while (used.has(index)) index = (index + 1) % RADIO_WORDS.length; + used.add(index); + marker.push(RADIO_WORDS[index]); + } + return marker.join(" "); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const [runId, runAttempt] = process.argv.slice(2); + if (!/^\d+$/.test(runId ?? "") || !/^\d+$/.test(runAttempt ?? "")) { + throw new Error("usage: node scripts/nato-marker.mjs "); + } + process.stdout.write(`${natoMarker(runId, runAttempt)}\n`); +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 898c01b..91d3ad4 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -2,6 +2,7 @@ import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; import { createInkboxRuntime, type InkboxRuntime, NOT_CONFIGURED_MESSAGE } from "../client.js"; import type { ResolvedConfig } from "../config.js"; import { inkboxErrorMessage } from "../errors.js"; +import { CALL_MEDIA_WS_PATH, WEBHOOK_PATH } from "../gateway/subscriptions.js"; import { envFileCandidates, readEnvFile } from "./env-file.js"; import { DEFAULT_OPENCODE_SERVER_URL, opencodeBinAvailable, opencodeReachable } from "./serve.js"; @@ -72,6 +73,60 @@ export async function runDoctor( "info", `Identity "${id.agentHandle}" resolves (email: ${id.emailAddress ?? "none"}, phone: ${id.phoneNumber?.number ?? "none"}).`, ); + if (config.gateway.voice.enabled && (id.phoneNumber || id.imessageEnabled)) { + if (typeof id.getIncomingCallAction !== "function") { + add("error", "The installed Inkbox SDK cannot inspect incoming-call routing."); + } else { + const incoming = await id.getIncomingCallAction(); + const actual = String(incoming.incomingCallAction); + const hosted = config.phoneVoiceStack === "inkbox_voice_ai"; + const expected = hosted ? "hosted_agent" : "auto_accept"; + const publicUrl = config.gateway.publicUrl?.trim().replace(/\/+$/, ""); + const expectedWebsocketUrl = publicUrl + ? `${publicUrl.replace(/^http/, "ws")}${CALL_MEDIA_WS_PATH}` + : undefined; + const expectedWebhookUrl = publicUrl ? `${publicUrl}${WEBHOOK_PATH}` : undefined; + if (actual !== expected) { + add( + "error", + `Incoming-call routing mismatch: voice stack ${config.phoneVoiceStack} expects ${expected}, but Inkbox reports ${actual}.`, + ); + } else if (hosted && (incoming.clientWebsocketUrl || incoming.incomingCallWebhookUrl)) { + add("error", "Inkbox Voice AI routing still has obsolete local callback URLs."); + } else if ( + !hosted && + (!incoming.clientWebsocketUrl || !incoming.incomingCallWebhookUrl) + ) { + add("error", `${config.phoneVoiceStack} routing is missing a local callback URL.`); + } else if ( + !hosted && + expectedWebsocketUrl && + (incoming.clientWebsocketUrl !== expectedWebsocketUrl || + incoming.incomingCallWebhookUrl !== expectedWebhookUrl) + ) { + add("error", `${config.phoneVoiceStack} routing points at stale local callback URLs.`); + } else { + add("info", `Incoming-call routing matches ${config.phoneVoiceStack}.`); + } + if (hosted) { + if (typeof id.getHostedAgentConfig !== "function") { + add("error", "The installed Inkbox SDK cannot inspect Voice AI authority."); + } else { + const hostedConfig = await id.getHostedAgentConfig(); + const actualAuthority = String(hostedConfig.authorityMode); + const expectedAuthority = config.voiceAiAuthorityMode ?? "contact_scoped"; + if (actualAuthority !== expectedAuthority) { + add( + "error", + `Voice AI authority drift: local config expects ${expectedAuthority}, but Inkbox reports ${actualAuthority}.`, + ); + } else { + add("info", `Voice AI authority matches ${expectedAuthority}.`); + } + } + } + } + } } catch (err) { add("error", `Identity "${config.identity}" did not resolve: ${inkboxErrorMessage(err)}`); } @@ -218,6 +273,7 @@ function printReport( print(` publicUrl: ${g.publicUrl ?? `(tunnel: ${g.tunnelName ?? "auto"})`}`); print(` bind: ${g.host}:${g.port}`); print(` voice: ${g.voice.enabled ? "enabled" : "disabled"}`); + print(` voiceStack: ${config.phoneVoiceStack}`); print(""); print(ok ? "doctor: ok" : "doctor: issues found"); } diff --git a/src/cli/realtime-validation.ts b/src/cli/realtime-validation.ts new file mode 100644 index 0000000..a6971c4 --- /dev/null +++ b/src/cli/realtime-validation.ts @@ -0,0 +1,122 @@ +import WebSocket from "ws"; + +export interface RealtimeValidationResult { + ok: boolean; + detail: string; +} + +export interface RealtimeValidationSocket { + on(event: "open", listener: () => void): this; + on(event: "message", listener: (data: unknown) => void): this; + on(event: "error", listener: (error: Error) => void): this; + on(event: "close", listener: (code: number, reason: Buffer) => void): this; + send(data: string): void; + close(): void; + terminate?(): void; +} + +export type RealtimeSocketFactory = ( + url: string, + options: { headers: Record; handshakeTimeout: number }, +) => RealtimeValidationSocket; + +export interface RealtimeValidationOptions { + timeoutMs?: number; + socketFactory?: RealtimeSocketFactory; +} + +const DEFAULT_TIMEOUT_MS = 12_000; + +function safeDetail(value: unknown, apiKey: string): string { + const text = value instanceof Error ? value.message : String(value); + return text.replaceAll(apiKey, "***").slice(0, 500); +} + +function messageText(value: unknown): string { + if (typeof value === "string") return value; + if (Buffer.isBuffer(value)) return value.toString("utf8"); + if (value instanceof ArrayBuffer) return Buffer.from(value).toString("utf8"); + return String(value); +} + +/** Prove that a key can establish and update a real OpenAI Realtime session. */ +export async function validateOpenAIRealtime( + apiKey: string, + model: string, + options: RealtimeValidationOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const socketFactory: RealtimeSocketFactory = + options.socketFactory ?? + ((url, socketOptions) => new WebSocket(url, socketOptions) as RealtimeValidationSocket); + const url = `wss://api.openai.com/v1/realtime?${new URLSearchParams({ model })}`; + + return await new Promise((resolve) => { + let settled = false; + let socket: RealtimeValidationSocket | undefined; + const finish = (result: RealtimeValidationResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + socket?.close(); + } catch { + socket?.terminate?.(); + } + resolve({ ...result, detail: safeDetail(result.detail, apiKey) }); + }; + const timer = setTimeout( + () => + finish({ ok: false, detail: "Timed out waiting for an OpenAI Realtime session response." }), + timeoutMs, + ); + + try { + socket = socketFactory(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + handshakeTimeout: timeoutMs, + }); + socket.on("open", () => { + try { + socket?.send( + JSON.stringify({ + type: "session.update", + session: { type: "realtime", model, output_modalities: ["audio"] }, + }), + ); + } catch (error) { + finish({ ok: false, detail: safeDetail(error, apiKey) }); + } + }); + socket.on("message", (data) => { + let event: any; + try { + event = JSON.parse(messageText(data)); + } catch { + return; + } + if (event?.type === "session.updated") { + finish({ ok: true, detail: "OpenAI Realtime session update succeeded." }); + return; + } + if (event?.type === "error") { + const error = event.error && typeof event.error === "object" ? event.error : event; + const code = typeof error.code === "string" && error.code ? `${error.code}: ` : ""; + finish({ + ok: false, + detail: `${code}${error.message ?? "OpenAI Realtime rejected the session."}`, + }); + } + }); + socket.on("error", (error) => finish({ ok: false, detail: safeDetail(error, apiKey) })); + socket.on("close", (code, reason) => { + finish({ + ok: false, + detail: `OpenAI Realtime websocket closed before validation (code ${code}${reason.length ? `: ${reason.toString("utf8")}` : ""}).`, + }); + }); + } catch (error) { + finish({ ok: false, detail: safeDetail(error, apiKey) }); + } + }); +} diff --git a/src/cli/wizard.ts b/src/cli/wizard.ts index d0f48a8..46c0089 100644 --- a/src/cli/wizard.ts +++ b/src/cli/wizard.ts @@ -2,9 +2,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { DEFAULT_REALTIME_MODEL, type ResolvedConfig } from "../config.js"; import { gatewayHome } from "../gateway/state.js"; +import { CALL_MEDIA_WS_PATH, normalizePublicUrl, WEBHOOK_PATH } from "../gateway/subscriptions.js"; +import type { PhoneVoiceStack } from "../voice-stack.js"; import { installAutostart } from "./autostart.js"; import { restartDaemon, runningDaemonPid, startDaemon } from "./daemon.js"; import { saveEnvVar } from "./env-file.js"; +import { type RealtimeValidationResult, validateOpenAIRealtime } from "./realtime-validation.js"; // Interactive setup wizard, ported from the claude-code/codex bridges: // self-signup (or bring a key), iMessage, a dedicated number, the START @@ -46,7 +49,7 @@ export interface WizardDeps { // loadEnvFile). Vars set in `env` but absent here came from the shell. envSources?: Map; sdk?: (baseUrl: string | undefined) => WizardSdk; - fetchFn?: typeof fetch; + realtimeValidatorFn?: (apiKey: string, model: string) => Promise; installAutostartFn?: typeof installAutostart; startDaemonFn?: typeof startDaemon; restartDaemonFn?: typeof restartDaemon; @@ -64,7 +67,7 @@ interface Ctx { envSources: Map; sdk: WizardSdk; baseUrl: string | undefined; - fetchFn: typeof fetch; + realtimeValidatorFn: (apiKey: string, model: string) => Promise; installAutostartFn: typeof installAutostart; startDaemonFn: typeof startDaemon; restartDaemonFn: typeof restartDaemon; @@ -72,6 +75,7 @@ interface Ctx { confirmTimeoutMs: number; sleep: (ms: number) => Promise; cwd: string; + rejectedRealtimeKeys: Set; } function defaultSdk(baseUrl: string | undefined): WizardSdk { @@ -122,7 +126,7 @@ export async function runWizard(config: ResolvedConfig, deps: WizardDeps = {}): envSources: deps.envSources ?? new Map(), sdk: (deps.sdk ?? defaultSdk)(baseUrl), baseUrl, - fetchFn: deps.fetchFn ?? fetch, + realtimeValidatorFn: deps.realtimeValidatorFn ?? validateOpenAIRealtime, installAutostartFn: deps.installAutostartFn ?? installAutostart, startDaemonFn: deps.startDaemonFn ?? startDaemon, restartDaemonFn: deps.restartDaemonFn ?? restartDaemon, @@ -130,6 +134,7 @@ export async function runWizard(config: ResolvedConfig, deps: WizardDeps = {}): confirmTimeoutMs: deps.confirmTimeoutMs ?? START_CONFIRM_TIMEOUT_MS, sleep: deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))), cwd: deps.cwd ?? process.cwd(), + rejectedRealtimeKeys: new Set(), }; try { return await wizard(c, config); @@ -214,7 +219,11 @@ async function wizard(c: Ctx, config: ResolvedConfig): Promise { printSummary(c, fullIdentity, imessageOn); if (provisioned.didProvision) await waitForSmsOptIn(c, client, fullIdentity.phoneNumber); - if (fullIdentity.phoneNumber || imessageOn) await configureRealtime(c); + if (fullIdentity.phoneNumber || imessageOn) { + await io.ask(" Press Enter to continue and set up phone call handling"); + if (!(await configurePhoneVoiceStack(c, config, fullIdentity, resolved.authorityIdentity))) + return 1; + } if (!(await setupSigningKey(c, fullIdentity))) return 1; @@ -240,6 +249,7 @@ async function wizard(c: Ctx, config: ResolvedConfig): Promise { interface ResolvedIdentity { identity: any; apiKey: string; + authorityIdentity?: any; } async function selfSignupFlow(c: Ctx): Promise { @@ -413,7 +423,7 @@ async function adminFlow(c: Ctx, client: any): Promise { // --- voice / signing key / project dir / autostart ---------------------------- -async function configureRealtime(c: Ctx): Promise { - const { io } = c; - io.print(""); - io.print(" --- OpenAI Realtime calls ---"); - io.print(" Realtime sends raw phone audio to OpenAI for a natural, low-latency"); - io.print(" voice. Skip it to use Inkbox's built-in STT/TTS instead."); +type VoiceAiAuthorityMode = "contact_scoped" | "yolo"; - const detected = c.env.INKBOX_REALTIME_API_KEY || c.env.OPENAI_API_KEY || ""; - if (detected) io.print(" Found an OpenAI API key in your environment."); - else io.print(" No OpenAI API key detected for Realtime."); +async function adminIdentityForAuthority(c: Ctx, handle: string): Promise { + const key = ( + await c.io.ask(" Paste an admin-scoped Inkbox API key for this authority change", { + password: true, + }) + ).trim(); + if (!key) { + c.io.print(" An admin-scoped API key is required to change saved Voice AI authority."); + return undefined; + } + try { + const client = await c.sdk.client(key); + const info = await client.whoami(); + if (String(info?.authSubtype ?? "") !== "api_key.admin_scoped") { + c.io.print(" That credential is not an admin-scoped API key."); + return undefined; + } + return await client.getIdentity(handle); + } catch (err) { + c.io.print( + ` Could not validate the admin-scoped API key: ${errText(err).replaceAll(key, "***")}`, + ); + return undefined; + } +} - if (!(await io.confirm(" Use OpenAI Realtime for phone calls?", Boolean(detected)))) { - save(c, "INKBOX_REALTIME_ENABLED", "false"); - io.print(" Realtime disabled. Calls will use Inkbox STT/TTS."); - return; +async function configureVoiceAi( + c: Ctx, + identity: any, + authorityIdentity?: any, +): Promise<{ configured: boolean; authorityIdentity?: any }> { + if ( + typeof identity.getHostedAgentConfig !== "function" || + typeof identity.getIncomingCallAction !== "function" || + typeof identity.setIncomingCallAction !== "function" + ) { + c.io.print(" Inkbox Voice AI setup requires @inkbox/sdk 0.5.9."); + return { configured: false, authorityIdentity }; + } + let hosted: any; + let incoming: any; + try { + [hosted, incoming] = await Promise.all([ + identity.getHostedAgentConfig(), + identity.getIncomingCallAction(), + ]); + } catch (err) { + c.io.print(` Could not read the current Voice AI configuration: ${errText(err)}`); + return { configured: false, authorityIdentity }; + } + const previous: VoiceAiAuthorityMode = + hosted?.authorityMode === "yolo" ? "yolo" : "contact_scoped"; + const authorityIndex = await c.io.choose( + " How much authority should Inkbox Voice AI have?", + [ + "Contact-scoped — tools are limited to the current caller and conversation.", + "YOLO mode — tools can use the identity's wider authorized capabilities.", + ], + previous === "yolo" ? 1 : 0, + ); + const selected: VoiceAiAuthorityMode = authorityIndex === 1 ? "yolo" : "contact_scoped"; + let adminIdentity = authorityIdentity; + if (selected !== previous && !adminIdentity) { + adminIdentity = await adminIdentityForAuthority(c, identity.agentHandle); + if (!adminIdentity) return { configured: false, authorityIdentity }; + } + let authorityChanged = false; + let routingAttempted = false; + try { + if (selected !== previous) { + await adminIdentity.setHostedAgentAuthorityMode({ authorityMode: selected }); + authorityChanged = true; + } + routingAttempted = true; + await identity.setIncomingCallAction({ + incomingCallAction: "hosted_agent", + clientWebsocketUrl: null, + incomingCallWebhookUrl: null, + }); + } catch (err) { + const rollbackErrors: string[] = []; + if (routingAttempted) { + try { + await identity.setIncomingCallAction({ + incomingCallAction: incoming.incomingCallAction, + clientWebsocketUrl: incoming.clientWebsocketUrl ?? null, + incomingCallWebhookUrl: incoming.incomingCallWebhookUrl ?? null, + }); + } catch (rollbackError) { + rollbackErrors.push(`incoming routing: ${errText(rollbackError)}`); + } + } + if (authorityChanged) { + try { + await adminIdentity.setHostedAgentAuthorityMode({ authorityMode: previous }); + } catch (rollbackError) { + rollbackErrors.push(`authority: ${errText(rollbackError)}`); + } + } + c.io.print(` Inkbox Voice AI configuration failed: ${errText(err)}`); + if (rollbackErrors.length) { + c.io.print(` warning: remote rollback was incomplete (${rollbackErrors.join("; ")}).`); + } + return { configured: false, authorityIdentity: adminIdentity }; + } + save(c, "INKBOX_VOICE_STACK", "inkbox_voice_ai"); + save(c, "INKBOX_VOICE_AI_AUTHORITY_MODE", selected); + save(c, "INKBOX_REALTIME_ENABLED", "false"); + c.io.print(" Inkbox Voice AI is configured for phone calls."); + c.io.print(" OpenCode will be notified when each call ends."); + return { configured: true, authorityIdentity: adminIdentity }; +} + +function setupGatewayPublicUrl(c: Ctx, config: ResolvedConfig, identity: any): string | undefined { + const configured = config.gateway.publicUrl?.trim(); + if (configured) { + try { + return normalizePublicUrl(configured); + } catch { + c.io.print( + " The configured gateway.publicUrl is invalid; enter an HTTP(S) public URL and rerun setup.", + ); + return undefined; + } + } + + const tunnelPublicHost = identity?.tunnel?.publicHost; + if (typeof tunnelPublicHost !== "string" || !tunnelPublicHost.trim()) { + c.io.print( + " Inkbox did not return a server-issued tunnel public host; provision the identity tunnel and rerun setup.", + ); + return undefined; } + try { + const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(tunnelPublicHost.trim()) + ? tunnelPublicHost.trim() + : `https://${tunnelPublicHost.trim()}`; + return normalizePublicUrl(candidate); + } catch { + c.io.print( + " Inkbox returned an invalid tunnel public host; repair the identity tunnel and rerun setup.", + ); + return undefined; + } +} + +function localCallWebsocketUrl(publicUrl: string): string { + return `${publicUrl.replace(/^http/, "ws")}${CALL_MEDIA_WS_PATH}`; +} + +async function configureLocalIncomingCalls( + c: Ctx, + config: ResolvedConfig, + identity: any, +): Promise { + const publicUrl = setupGatewayPublicUrl(c, config, identity); + if (!publicUrl) return false; + if (typeof identity.setIncomingCallAction !== "function") { + c.io.print(" The installed Inkbox SDK cannot configure incoming calls."); + return false; + } + try { + await identity.setIncomingCallAction({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: localCallWebsocketUrl(publicUrl), + incomingCallWebhookUrl: `${publicUrl}${WEBHOOK_PATH}`, + }); + return true; + } catch (err) { + c.io.print(` Could not configure incoming calls: ${errText(err)}`); + return false; + } +} + +async function configureRealtime(c: Ctx): Promise { + const { io } = c; + const candidate = c.env.INKBOX_REALTIME_API_KEY || c.env.OPENAI_API_KEY || ""; + const detected = c.rejectedRealtimeKeys.has(candidate) ? "" : candidate; + if (detected) io.print(" Found an OpenAI API key in your environment."); const apiKey = detected || (await io.ask(" Paste your OpenAI API key for Realtime calls", { password: true })).trim(); if (!apiKey) { - save(c, "INKBOX_REALTIME_ENABLED", "false"); - io.print(" No key entered. Realtime disabled; calls will use Inkbox STT/TTS."); - return; + io.print(" No key entered. Choose a phone call voice stack again."); + return undefined; } - io.print(` Testing OpenAI access with ${DEFAULT_REALTIME_MODEL}...`); + io.print(` Testing OpenAI Realtime access with ${DEFAULT_REALTIME_MODEL}...`); try { - const res = await c.fetchFn(`https://api.openai.com/v1/models/${DEFAULT_REALTIME_MODEL}`, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + const result = await c.realtimeValidatorFn(apiKey, DEFAULT_REALTIME_MODEL); + if (!result.ok) throw new Error(result.detail); } catch (err) { + c.rejectedRealtimeKeys.add(apiKey); + io.print(` error: OpenAI validation failed (${errText(err).replaceAll(apiKey, "***")}).`); + io.print(" Choose a phone call voice stack again."); + return undefined; + } + return apiKey; +} + +async function configurePhoneVoiceStack( + c: Ctx, + config: ResolvedConfig, + identity: any, + authorityIdentity?: any, +): Promise { + let reusableAuthorityIdentity = authorityIdentity; + const configured = c.env.INKBOX_VOICE_STACK ?? config.phoneVoiceStack; + let defaultIndex = + configured === "inkbox_voice_ai" ? 0 : configured === "openai_realtime" ? 1 : 2; + for (let attempt = 0; attempt < 5; attempt += 1) { + c.io.print(""); + c.io.print(" --- Phone call voice stack ---"); + const selected = await c.io.choose( + " Choose how this agent should handle phone calls:", + [ + "Inkbox Voice AI — Inkbox handles calls on behalf of your agent; OpenCode is notified when the call ends.", + "OpenAI Realtime API — Bring your own API key; the realtime agent can consult OpenCode for complex tasks.", + "Inkbox TTS/STT — OpenCode talks through the Inkbox voice stack with increased latency.", + ], + defaultIndex, + ); + defaultIndex = selected; + const selectedStack: PhoneVoiceStack = + selected === 0 ? "inkbox_voice_ai" : selected === 1 ? "openai_realtime" : "inkbox_tts_stt"; + if (config.phoneVoiceStackOption && selectedStack !== config.phoneVoiceStackOption) { + c.io.print( + ` The plugin option phoneVoiceStack=${config.phoneVoiceStackOption} overrides saved environment selections.`, + ); + c.io.print(" Update or remove that plugin option, then choose the matching stack here."); + defaultIndex = + config.phoneVoiceStackOption === "inkbox_voice_ai" + ? 0 + : config.phoneVoiceStackOption === "openai_realtime" + ? 1 + : 2; + continue; + } + if (selected === 0) { + const result = await configureVoiceAi(c, identity, reusableAuthorityIdentity); + reusableAuthorityIdentity = result.authorityIdentity; + if (result.configured) return true; + continue; + } + if (selected === 1) { + const realtimeApiKey = await configureRealtime(c); + if (!realtimeApiKey) continue; + if (!(await configureLocalIncomingCalls(c, config, identity))) continue; + save(c, "INKBOX_REALTIME_ENABLED", "true"); + save(c, "INKBOX_REALTIME_API_KEY", realtimeApiKey); + save(c, "INKBOX_VOICE_STACK", "openai_realtime"); + c.io.print(" OpenAI Realtime validated — enabled for calls."); + return true; + } + if (!(await configureLocalIncomingCalls(c, config, identity))) continue; + save(c, "INKBOX_VOICE_STACK", "inkbox_tts_stt"); save(c, "INKBOX_REALTIME_ENABLED", "false"); - io.print(` error: OpenAI validation failed (${errText(err)}).`); - io.print(" Realtime disabled; calls will use Inkbox STT/TTS. Rerun setup to retry."); - return; + c.io.print(" Inkbox TTS/STT is configured for phone calls."); + return true; } - save(c, "INKBOX_REALTIME_ENABLED", "true"); - save(c, "INKBOX_REALTIME_API_KEY", apiKey); - io.print(" OpenAI Realtime validated — enabled for calls."); + c.io.print(" error: phone call handling could not be configured after 5 attempts."); + return false; } async function setupSigningKey(c: Ctx, identity: any): Promise { diff --git a/src/config.ts b/src/config.ts index 90a16dd..14781ec 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,10 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { isPhoneVoiceStack, type PhoneVoiceStack } from "./voice-stack.js"; export type OutboundApproval = "ask" | "allowlist" | "auto"; +export type VoiceAiAuthorityMode = "contact_scoped" | "yolo"; // Options passed to InkboxPlugin(input, { ...options }) from your // .opencode/plugins/inkbox.ts wrapper. Every credential also resolves from env @@ -15,6 +17,9 @@ export interface InkboxPluginOptions { // WebSocket URL (wss://) Inkbox connects to for outbound-call audio. // Only needed for inkbox_place_call when no per-call URL is passed. callWebsocketUrl?: string; + phoneVoiceStack?: PhoneVoiceStack; + voiceAiAuthorityMode?: VoiceAiAuthorityMode; + voicemailDetection?: "enabled" | "disabled"; vault?: { keyEnvVar?: string; }; @@ -148,6 +153,12 @@ export interface ResolvedConfig { baseUrl?: string; signingKey?: string; callWebsocketUrl?: string; + phoneVoiceStack?: PhoneVoiceStack; + // Retained so the setup wizard can warn when a plugin option would shadow + // a newly saved INKBOX_VOICE_STACK selection. + phoneVoiceStackOption?: PhoneVoiceStack; + voiceAiAuthorityMode?: VoiceAiAuthorityMode; + voicemailDetection?: "enabled" | "disabled"; vaultKeyEnvVar: string; tools: { enable: string[]; @@ -246,6 +257,37 @@ export function resolveConfig( fromFile("signing_key"); const callWebsocketUrl = nonEmptyString(opts.callWebsocketUrl) ?? nonEmptyString(env.INKBOX_CALL_WEBSOCKET_URL); + const phoneVoiceStackOption = isPhoneVoiceStack(opts.phoneVoiceStack) + ? opts.phoneVoiceStack + : undefined; + const configuredVoiceStack = phoneVoiceStackOption ?? env.INKBOX_VOICE_STACK; + const configuredAuthority = + opts.voiceAiAuthorityMode ?? nonEmptyString(env.INKBOX_VOICE_AI_AUTHORITY_MODE); + const voiceAiAuthorityMode: VoiceAiAuthorityMode = + configuredAuthority === "yolo" ? "yolo" : "contact_scoped"; + const gatewayOptions = isRecord(opts.gateway) ? opts.gateway : {}; + const voiceOptions = isRecord(gatewayOptions.voice) ? gatewayOptions.voice : {}; + const realtimeOptions = isRecord(voiceOptions.realtime) ? voiceOptions.realtime : {}; + const configuredRealtimeKeyEnvVar = + nonEmptyString(realtimeOptions.apiKeyEnvVar) ?? "INKBOX_REALTIME_API_KEY"; + const legacyRealtimeKeyPresent = Boolean( + nonEmptyString(env[configuredRealtimeKeyEnvVar]) ?? nonEmptyString(env.OPENAI_API_KEY), + ); + const legacyRealtimeEnabled = boolEnv(env.INKBOX_REALTIME_ENABLED); + const phoneVoiceStack = isPhoneVoiceStack(configuredVoiceStack) + ? configuredVoiceStack + : legacyRealtimeEnabled !== undefined + ? legacyRealtimeEnabled + ? "openai_realtime" + : "inkbox_tts_stt" + : legacyRealtimeKeyPresent + ? "openai_realtime" + : "inkbox_tts_stt"; + const configuredVoicemailDetection = opts.voicemailDetection ?? env.INKBOX_VOICEMAIL_DETECTION; + const voicemailDetection = + configuredVoicemailDetection === "enabled" || configuredVoicemailDetection === "disabled" + ? configuredVoicemailDetection + : undefined; const outbound = isRecord(opts.outbound) ? opts.outbound : {}; const approval = @@ -268,6 +310,10 @@ export function resolveConfig( baseUrl, signingKey, callWebsocketUrl, + phoneVoiceStack, + phoneVoiceStackOption, + voiceAiAuthorityMode, + voicemailDetection, vaultKeyEnvVar: nonEmptyString(vault.keyEnvVar) ?? DEFAULT_VAULT_KEY_ENV_VAR, tools: { enable: stringArray(tools.enable), @@ -278,7 +324,7 @@ export function resolveConfig( approval, askTimeoutMs, }, - gateway: resolveGatewayConfig(opts.gateway, env, identity), + gateway: resolveGatewayConfig(opts.gateway, env, identity, phoneVoiceStack), }; } @@ -317,6 +363,7 @@ function resolveGatewayConfig( options: unknown, env: NodeJS.ProcessEnv, identity: string | undefined, + phoneVoiceStack?: PhoneVoiceStack, ): ResolvedGatewayConfig { const opts: GatewayOptions = isRecord(options) ? (options as GatewayOptions) : {}; const voice = isRecord(opts.voice) ? opts.voice : {}; @@ -377,7 +424,11 @@ function resolveGatewayConfig( // the gateway (Inkbox STT/TTS needs no extra key); explicit false wins. enabled: voice.enabled ?? boolEnv(env.INKBOX_VOICE_ENABLED) ?? true, realtime: { - enabled: realtime.enabled ?? boolEnv(env.INKBOX_REALTIME_ENABLED) ?? realtimeKeyPresent, + enabled: + realtime.enabled ?? + (phoneVoiceStack + ? phoneVoiceStack === "openai_realtime" + : (boolEnv(env.INKBOX_REALTIME_ENABLED) ?? realtimeKeyPresent)), model: nonEmptyString(realtime.model) ?? nonEmptyString(env.INKBOX_REALTIME_MODEL) ?? diff --git a/src/gateway/a2a.ts b/src/gateway/a2a.ts index 2171e44..137b516 100644 --- a/src/gateway/a2a.ts +++ b/src/gateway/a2a.ts @@ -211,7 +211,7 @@ export function createA2AHandler(deps: { typeof id.a2aReply !== "function" ) { deps.logger.warn("a2a.sdk_upgrade_required", { - requiredVersion: "0.5.6", + requiredVersion: "0.5.9", }); return; } diff --git a/src/gateway/delivery-policy.ts b/src/gateway/delivery-policy.ts new file mode 100644 index 0000000..4f17c3b --- /dev/null +++ b/src/gateway/delivery-policy.ts @@ -0,0 +1,124 @@ +export type DeliveryFailureClassification = "retryable" | "terminal" | "unknown"; + +const terminal = [ + "not opted in", + "opted out", + "invalid number", + "invalid phone", + "unreachable", + "blocked", + "unsafe", + "harmful", + "abusive", + "threat", + "illegal content", +]; +const retryable = [ + "40002", + "spam", + "content policy", + "content rejected", + "content violation", + "too_long", + "too long", + "markdown", + "emoji", + "profanity", +]; + +export function classifyDeliveryFailure(value: unknown): DeliveryFailureClassification { + const text = (value instanceof Error ? value.message : String(value ?? "")).toLowerCase(); + if (terminal.some((marker) => text.includes(marker))) return "terminal"; + if (retryable.some((marker) => text.includes(marker))) return "retryable"; + return "unknown"; +} + +interface Attempt { + count: number; + at: number; +} +let attempts = new Map(); +const TTL = 30 * 60 * 1000; + +export function deliveryFailureKey( + channel: string, + target?: string, + conversationId?: string, +): string | undefined { + const conversation = conversationId?.trim().toLowerCase(); + if (conversation) return `${channel}:conversation:${conversation}`; + const raw = target?.trim().toLowerCase(); + const digits = channel === "email" ? undefined : raw?.replace(/\D/g, ""); + const normalized = channel === "email" ? raw : digits || raw; + return normalized ? `${channel}:target:${normalized}` : undefined; +} + +export function clearDeliveryFailures(key: string | undefined): void { + if (key) attempts.delete(key); +} + +export interface FailureRecovery { + attempt: number; + classification: DeliveryFailureClassification; + prompt?: string; + mandatory: boolean; +} + +export function deliveryFailureRecovery(params: { + key?: string; + channel: string; + target?: string; + failure: unknown; + failedBody?: string; + now?: number; +}): FailureRecovery { + const classification = classifyDeliveryFailure(params.failure); + if (!params.key) return { attempt: 0, classification, mandatory: false }; + const now = params.now ?? Date.now(); + const previous = attempts.get(params.key); + const count = previous && now - previous.at <= TTL ? previous.count + 1 : 1; + attempts.set(params.key, { count, at: now }); + if (count >= 3) return { attempt: count, classification, mandatory: false }; + const body = (params.failedBody ?? "").trim().slice(0, 400); + const header = + `[inkbox:delivery_failure channel=${params.channel} attempt=${count}/3` + + `${params.target ? ` to=${params.target}` : ""}]`; + if (classification === "retryable" && count === 1) { + return { + attempt: count, + classification, + mandatory: true, + prompt: [ + header, + `Your outbound ${params.channel} message was not delivered: ${String(params.failure)}`, + body ? `Undelivered message:\n«${body}»` : undefined, + `This is both the first failure and a retryable failure. You MUST send exactly one safe, materially corrected ${params.channel} message now. Do not reuse the failed wording. Do not return [SILENT], skip the correction, or defer it.`, + ] + .filter(Boolean) + .join("\n\n"), + }; + } + const policy = + classification === "terminal" + ? "Do not retry this destination or content." + : classification === "retryable" + ? "The mandatory first correction also failed. You may make one final materially different safe attempt, or stop." + : "Retry only if a review shows it is safe and unlikely to duplicate a committed send."; + return { + attempt: count, + classification, + mandatory: false, + prompt: [ + header, + `Your outbound ${params.channel} message was not delivered: ${String(params.failure)}`, + body ? `Undelivered message:\n«${body}»` : undefined, + `${policy} If you do not make a permitted recovery, reply exactly [SILENT].`, + ] + .filter(Boolean) + .join("\n\n"), + }; +} + +export function resetDeliveryPolicyForTest(): void { + attempts = new Map(); +} diff --git a/src/gateway/dispatch.ts b/src/gateway/dispatch.ts index a85450e..111b93a 100644 --- a/src/gateway/dispatch.ts +++ b/src/gateway/dispatch.ts @@ -1,3 +1,4 @@ +import type { CallEndedWebhookPayload } from "@inkbox/sdk"; import type { InkboxRuntime } from "../client.js"; import type { ResolvedConfig, ResolvedGatewayConfig } from "../config.js"; import type { BurstBuffer } from "./burst.js"; @@ -5,6 +6,7 @@ import { matchedContactMemories } from "./contact-memories.js"; import type { ContactResolver } from "./contacts.js"; import { normalizeAddress } from "./contacts.js"; import type { NotifyOnce } from "./dedup.js"; +import { deliveryFailureKey, deliveryFailureRecovery } from "./delivery-policy.js"; import { downloadMedia, mediaDir } from "./media.js"; import { SILENT } from "./prompts.js"; import type { @@ -40,6 +42,7 @@ export interface DispatchDeps { bursts?: BurstBuffer; // Handle a verified non-Inkbox (external) webhook. onExternal?(event: VerifiedEvent): Promise; + onHostedCallEnded?(event: CallEndedWebhookPayload): Promise; } // Route a verified event to the right handler. Returns false only on a @@ -65,6 +68,11 @@ export async function dispatchEvent(deps: DispatchDeps, event: VerifiedEvent): P case "message.bounced": case "message.failed": return handleDeliveryFailure(deps, type, event); + case "call.ended": + if (deps.onHostedCallEnded) { + await deps.onHostedCallEnded(event.body as unknown as CallEndedWebhookPayload); + } + return true; // Carrier uncertainty, not a failure — the message usually landed, so a // capture here would prompt a resend of a message that was delivered. // Ack and log only. @@ -411,11 +419,16 @@ async function handleDeliveryFailure( const isText = type.startsWith("text"); const isImessage = type.startsWith("imessage"); const r = resourceOf(event.body, isText ? "text_message" : "message"); + if (str(r?.direction)?.toLowerCase() === "inbound") return true; const messageId = str(r?.id); + const recipientRows = Array.isArray(r?.recipients) ? r.recipients : []; + const failedRecipient = recipientRows + .map((item) => record(item)) + .find((item) => str(item?.error_code) || str(item?.error_message) || str(item?.error_reason)); const to = isText ? str(r?.remote_phone_number) : isImessage - ? str(r?.remote_number) + ? (str(failedRecipient?.remote_number) ?? str(r?.remote_number)) : firstString(r?.to_addresses); const from = to; // Check recoverability before consuming the once-per-TTL notify slot. @@ -431,12 +444,28 @@ async function handleDeliveryFailure( : "email") as Exclude, from, }); - const reason = str(r?.error_detail) ?? str(r?.error_code) ?? str(r?.error_reason) ?? type; + const reason = + str(r?.error_detail) ?? + str(r?.error_code) ?? + str(r?.error_reason) ?? + str(failedRecipient?.error_message) ?? + str(failedRecipient?.error_code) ?? + type; + const conversationId = str(r?.conversation_id); + const recovery = deliveryFailureRecovery({ + key: deliveryFailureKey( + isImessage ? "imessage" : isText ? "sms" : "email", + from, + conversationId, + ), + channel: isImessage ? "imessage" : isText ? "sms" : "email", + target: from, + failure: reason, + failedBody: str(r?.text) ?? str(r?.content) ?? str(r?.snippet) ?? str(r?.subject), + }); + if (!recovery.prompt) return true; void deps.sessions - .runCapture( - chatKey, - `A message to ${from} failed to deliver (${type}: ${reason}). Consider retrying or switching channel.`, - ) + .runCapture(chatKey, recovery.prompt) .catch((err) => deps.logger.error("turn.dispatch_failed", { error: String(err) })); return true; } diff --git a/src/gateway/hosted-call-completion.ts b/src/gateway/hosted-call-completion.ts new file mode 100644 index 0000000..8228c97 --- /dev/null +++ b/src/gateway/hosted-call-completion.ts @@ -0,0 +1,486 @@ +import type { CallEndedWebhookPayload } from "@inkbox/sdk"; +import type { InkboxRuntime } from "../client.js"; +import { type ContactResolver, contactCard } from "./contacts.js"; +import { + getHostedCall, + type HostedCallEntry, + type HostedSmsAttempt, + listRecoverableHostedCalls, + saveHostedCall, +} from "./hosted-call-registry.js"; +import { frameCapture } from "./prompts.js"; +import { type GatewayLogger, HostedCaptureDeferredError, type SessionManager } from "./types.js"; + +const running = new Set(); +let chain: Promise = Promise.resolve(); + +const POST_CALL_TIMING = String.raw`(?:after|when|once)\s+(?:(?:i|we|you)\s+hang\s*up|(?:this|the)\s+call\s+(?:ends?|is\s+over))`; +const TEXT_VERB = String.raw`text\s+me\b`; +const SEND_SMS = String.raw`(?:send\s+me\b.{0,80}\b(?:an?\s+)?(?:sms|text(?:\s+message)?)\b|send\b.{0,80}\b(?:sms|text(?:\s+message)?)\b.{0,40}\bto\s+me\b)`; +const NEGATED = + /\b(?:do\s+not|don['’]?t|never|must\s+not|should\s+not|will\s+not|won['’]?t|can(?:not|['’]?t))\s+(?:(?:ever|again)\s+)?(?:text\b|send\b.{0,80}\b(?:sms|text\s+message)\b)/i; +const TRANSCRIPT_PATTERNS = [ + new RegExp(String.raw`\b${POST_CALL_TIMING}\b.{0,160}\b${TEXT_VERB}`, "i"), + new RegExp(String.raw`\b${TEXT_VERB}.{0,160}\b${POST_CALL_TIMING}\b`, "i"), + new RegExp(String.raw`\b${POST_CALL_TIMING}\b.{0,160}\b${SEND_SMS}`, "i"), + new RegExp(String.raw`\b${SEND_SMS}.{0,160}\b${POST_CALL_TIMING}\b`, "i"), +]; +const ACTION_PATTERNS = [ + /\bsend\b.{0,80}\b(?:an?\s+)?(?:sms|text(?:\s+message)?)\b/i, + /\btext\s+(?:me|the\s+(?:caller|user)|caller|user)\b/i, +]; + +export function hasHostedSmsCommitment(value: string, source: "action" | "transcript"): boolean { + const patterns = source === "action" ? ACTION_PATTERNS : TRANSCRIPT_PATTERNS; + const clauses = value + .split(/(?:[.!?;:\n]+|\s+[—–]\s+|\s+--\s+)/) + .map((part) => part.trim()) + .filter(Boolean); + const candidates = + source === "transcript" + ? clauses.flatMap((part, index) => [ + part, + ...(clauses[index + 1] ? [`${part}. ${clauses[index + 1]}`] : []), + ]) + : clauses; + return candidates.some( + (candidate) => !NEGATED.test(candidate) && patterns.some((pattern) => pattern.test(candidate)), + ); +} + +export interface HostedCallCompletionDeps { + inkbox: InkboxRuntime; + contacts: ContactResolver; + sessions: SessionManager; + logger: GatewayLogger; + sleep?: (ms: number) => Promise; +} + +const PRE_DISPATCH_RETRY_DELAYS_MS = [250, 1_000] as const; + +function escapePromptData(value: string): string { + return value.replaceAll("[inkbox:", "[inkbox\u200b:"); +} + +function decision( + attempt: HostedSmsAttempt | undefined, + phase: "initial" | "correction", +): + | { outcome: "success" } + | { + outcome: "correction"; + reason: "missing_attempt" | "pre_send_validation" | "content_rejected"; + } + | { outcome: "terminal"; reason: string } { + if (!attempt) { + return phase === "initial" + ? { outcome: "correction", reason: "missing_attempt" } + : { outcome: "terminal", reason: "correction_missing_attempt" }; + } + if (!attempt.targetMatches) return { outcome: "terminal", reason: "wrong_target" }; + if (attempt.state === "success") return { outcome: "success" }; + if ( + phase === "initial" && + attempt.state === "failed" && + (attempt.errorKind === "pre_send_validation" || attempt.errorKind === "content_rejected") + ) { + return { outcome: "correction", reason: attempt.errorKind }; + } + return { outcome: "terminal", reason: attempt.errorKind ?? "ambiguous_tool_outcome" }; +} + +function recovery(entry: HostedCallEntry): "initial" | "correction" | "complete" | "terminal" { + if (entry.smsAttempts.some((attempt) => attempt.targetMatches && attempt.state === "success")) + return "complete"; + if (entry.retryable && entry.outcome === "initial_deferred_for_shutdown") return "initial"; + if (entry.retryable && entry.outcome === "correction_deferred_for_shutdown") return "correction"; + if (entry.retryable && entry.outcome === "initial_pre_dispatch_retries_exhausted") + return "initial"; + if (entry.retryable && entry.outcome === "correction_pre_dispatch_retries_exhausted") + return "correction"; + if (entry.outcome === "initial_dispatch_started" || entry.outcome === "correction_started") + return "terminal"; + if (entry.smsAttempts.length === 0) return "initial"; + const only = entry.smsAttempts[0]; + if ( + entry.smsAttempts.length === 1 && + only.phase === "initial" && + only.targetMatches && + only.state === "failed" && + (only.errorKind === "pre_send_validation" || only.errorKind === "content_rejected") + ) + return "correction"; + return "terminal"; +} + +export function createHostedCallCompletion(deps: HostedCallCompletionDeps) { + async function run( + identityId: string, + event: CallEndedWebhookPayload, + resume: "initial" | "correction" = "initial", + preparationAttempt = 0, + ): Promise { + const eventCall = event.data.call; + const key = `${identityId}:${eventCall.id}`; + let dispatchStarted = false; + try { + saveHostedCall({ + identityId, + callId: eventCall.id, + eventId: event.id, + state: "running", + event, + }); + const identity = await deps.inkbox.getIdentity(); + const client = await deps.inkbox.getClient(); + const call = await client.calls.get(eventCall.id); + if (String(call.mode) !== "hosted_agent") { + saveHostedCall({ + identityId, + callId: eventCall.id, + eventId: event.id, + state: "failed", + outcome: "authoritative_call_is_not_hosted", + retryable: false, + event, + }); + return; + } + const remote = String(call.remotePhoneNumber ?? "").trim(); + const contact = remote ? await deps.contacts.resolve(remote) : {}; + let transcriptRows: Array<{ party: string; text: string }> = []; + try { + transcriptRows = (await identity.listTranscripts(call.id)) + .map((row: any) => ({ + party: String(row.party ?? "unknown"), + text: String(row.text ?? "").trim(), + })) + .filter((row: { text: string }) => row.text.length > 0); + } catch (error) { + deps.logger.warn("hosted_call.transcript_fetch_failed", { + callId: call.id, + error: String(error), + }); + } + if (transcriptRows.length === 0 && event.data.transcript) { + transcriptRows = event.data.transcript.entries + .filter((row: any) => !("marker" in row)) + .map((row: any) => ({ + party: String(row.party ?? "unknown"), + text: String(row.text ?? "").trim(), + })) + .filter((row: { text: string }) => row.text.length > 0); + } + const transcript = transcriptRows + .map((row) => `- ${escapePromptData(row.party)}: ${escapePromptData(row.text)}`) + .join("\n"); + const actions = (call.postCallActionItems ?? []).filter( + (item) => String(item.status || "open") === "open", + ); + const explicit = actions.find((item) => + hasHostedSmsCommitment(`${item.action ?? ""} ${item.details ?? ""}`, "action"), + ); + const spokenCandidates = transcriptRows.flatMap((row, index) => { + const next = transcriptRows[index + 1]; + return [ + row.text, + ...(next && next.party === row.party ? [`${row.text}. ${next.text}`] : []), + ]; + }); + const spoken = spokenCandidates.find((text) => hasHostedSmsCommitment(text, "transcript")); + const smsCommitment = explicit + ? [explicit.action, explicit.details].filter(Boolean).join("\n") + : spoken; + const smsRequired = Boolean(remote && smsCommitment); + const chatKey = remote + ? deps.contacts.chatKeyFor({ + contactId: contact.contactId, + channel: "imessage", + from: remote, + }) + : `call:${call.id}`; + + const dispatch = async (phase: "initial" | "correction") => { + const prompt = + phase === "correction" + ? [ + `[inkbox:voice_call_correction call_id=${call.id} from=${escapePromptData(remote)} | ${contactCard(contact)}]`, + "The first hosted-call reconciliation did not complete its required SMS follow-up.", + "This is the only mandatory correction attempt. Do not return [SILENT], skip the tool, or defer the send.", + "Do not execute any non-SMS post-call action in this correction turn.", + "Do not delegate this send to another session or agent.", + `Exact open SMS commitment:\n${escapePromptData(smsCommitment ?? "")}`, + `Call inkbox_send_sms exactly once with to="${escapePromptData(remote)}". Do not use conversationId, send to another number, or make a second attempt. Plain-text replies are suppressed.`, + ].join("\n\n") + : [ + `[inkbox:voice_call call_id=${call.id} from=${escapePromptData(remote)} status=ended mode=inkbox_voice_ai | ${contactCard(contact)}]`, + "Inkbox Voice AI finished this phone call.", + `Direction: ${call.direction}`, + `Outcome: ${event.data.outcome ?? call.status}`, + call.hangupReason + ? `Hangup reason: ${escapePromptData(call.hangupReason)}` + : undefined, + remote + ? `Authoritative remote party phone number: ${escapePromptData(remote)}` + : undefined, + remote + ? "For callbacks or phone follow-up, use that exact number. Contact data and memories are background only and must not override it." + : undefined, + call.reason ? `Outbound task: ${escapePromptData(call.reason)}` : undefined, + transcript + ? `Call transcript:\n${transcript}` + : "No transcript was captured for this call.", + actions.length + ? `Open post-call actions:\n${actions.map((item, i) => `${i + 1}. ${escapePromptData(item.action)}${item.details ? `\nDetails: ${escapePromptData(item.details)}` : ""}`).join("\n")}` + : undefined, + smsRequired + ? `A promised SMS must use inkbox_send_sms exactly once with to="${escapePromptData(remote)}". Do not use conversationId, a contact-derived number, or delegate the send to another session or agent. Count it complete only when the tool reports success; do not retry inside this turn.` + : undefined, + "Complete every still-open commitment once. Do not repeat work already completed during the call. If nothing remains, return [SILENT]; plain text is suppressed.", + ] + .filter(Boolean) + .join("\n\n"); + if (!deps.sessions.runHostedCapture) { + if (!smsRequired) { + await deps.sessions.runText(chatKey, frameCapture("voice_call_ended", prompt)); + return { output: undefined, attempt: undefined }; + } + throw new Error("OpenCode hosted-call settlement is unavailable."); + } + return deps.sessions.runHostedCapture(chatKey, frameCapture("voice_call_ended", prompt), { + identityId, + callId: call.id, + phase, + expectedTarget: remote, + }); + }; + + const runCorrection = async (): Promise => { + saveHostedCall({ + identityId, + callId: call.id, + eventId: event.id, + state: "running", + outcome: "correction_started", + retryable: false, + event, + }); + let corrected: Awaited>; + try { + corrected = await dispatch("correction"); + } catch (error) { + const deferred = error instanceof HostedCaptureDeferredError; + saveHostedCall({ + identityId, + callId: call.id, + eventId: event.id, + state: "failed", + outcome: deferred + ? "correction_deferred_for_shutdown" + : "correction_dispatch_outcome_ambiguous", + retryable: deferred, + event, + }); + deps.logger.warn("hosted_call.correction_failed", { + callId: call.id, + error: String(error), + }); + return false; + } + const result = decision(corrected.attempt, "correction"); + if (result.outcome === "success") return true; + saveHostedCall({ + identityId, + callId: call.id, + eventId: event.id, + state: "failed", + outcome: result.reason, + retryable: false, + event, + }); + return false; + }; + + if (resume === "correction") { + if (!smsRequired) throw new Error("Hosted SMS correction context is unavailable."); + dispatchStarted = true; + if (!(await runCorrection())) return; + } else { + let initial: Awaited>; + try { + // Persist the ambiguous boundary before giving the model any tool + // access. A process death after this point must never replay the + // whole initial turn, which may contain non-SMS side effects. + dispatchStarted = true; + saveHostedCall({ + identityId, + callId: call.id, + eventId: event.id, + state: "running", + outcome: "initial_dispatch_started", + retryable: false, + event, + }); + initial = await dispatch("initial"); + } catch (error) { + const deferred = error instanceof HostedCaptureDeferredError; + saveHostedCall({ + identityId, + callId: call.id, + eventId: event.id, + state: "failed", + outcome: deferred + ? "initial_deferred_for_shutdown" + : "initial_dispatch_outcome_ambiguous", + retryable: deferred, + event, + }); + deps.logger.warn("hosted_call.reconciliation_failed", { + callId: call.id, + error: String(error), + }); + return; + } + if (smsRequired) { + const result = decision(initial.attempt, "initial"); + if (result.outcome === "correction") { + if (!(await runCorrection())) return; + } else if (result.outcome === "terminal") { + saveHostedCall({ + identityId, + callId: call.id, + eventId: event.id, + state: "failed", + outcome: result.reason, + retryable: false, + event, + }); + return; + } + } + } + saveHostedCall({ + identityId, + callId: call.id, + eventId: event.id, + state: "completed", + outcome: "success", + retryable: false, + event, + }); + deps.logger.info("hosted_call.reconciliation_completed", { callId: call.id }); + } catch (error) { + if (!dispatchStarted && preparationAttempt < PRE_DISPATCH_RETRY_DELAYS_MS.length) { + const delayMs = PRE_DISPATCH_RETRY_DELAYS_MS[preparationAttempt]; + deps.logger.warn("hosted_call.preparation_retry", { + callId: eventCall.id, + attempt: preparationAttempt + 1, + delayMs, + error: String(error), + }); + await (deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(delayMs); + await run(identityId, event, resume, preparationAttempt + 1); + return; + } + if (!dispatchStarted) { + try { + saveHostedCall({ + identityId, + callId: eventCall.id, + eventId: event.id, + state: "failed", + outcome: `${resume}_pre_dispatch_retries_exhausted`, + retryable: true, + event, + }); + } catch { + // Preserve the original preparation failure in the log; a journal + // lock failure will also leave the earlier replayable entry intact. + } + } + deps.logger.warn("hosted_call.reconciliation_failed", { + callId: eventCall.id, + error: String(error), + }); + } finally { + running.delete(key); + } + } + + function schedule( + identityId: string, + event: CallEndedWebhookPayload, + phase: "initial" | "correction" = "initial", + ): void { + const key = `${identityId}:${event.data.call.id}`; + if (running.has(key)) return; + running.add(key); + chain = chain.catch(() => {}).then(() => run(identityId, event, phase)); + } + + return { + async ingest(event: CallEndedWebhookPayload): Promise { + if (event.data.call.mode !== "hosted_agent" || !event.data.call.id.trim()) return; + const identity = await deps.inkbox.getIdentity(); + const key = `${identity.id}:${event.data.call.id}`; + if (running.has(key)) return; + const existing = getHostedCall(identity.id, event.data.call.id); + if (existing?.state === "completed" || (existing?.state === "failed" && !existing.retryable)) + return; + if (!existing) { + saveHostedCall({ + identityId: identity.id, + callId: event.data.call.id, + eventId: event.id, + state: "queued", + event, + }); + schedule(identity.id, event); + return; + } + const phase = recovery(existing); + if (phase === "complete") { + saveHostedCall({ + ...existing, + state: "completed", + outcome: "success", + retryable: false, + }); + return; + } + if (phase === "terminal") { + saveHostedCall({ + ...existing, + state: "failed", + outcome: "durable_sms_attempt_is_ambiguous", + retryable: false, + }); + return; + } + schedule(identity.id, existing.event, phase); + }, + async catchUp(): Promise { + const identity = await deps.inkbox.getIdentity(); + for (const entry of listRecoverableHostedCalls(identity.id)) { + const phase = recovery(entry); + if (phase === "complete") { + saveHostedCall({ + ...entry, + state: "completed", + outcome: "success", + retryable: false, + }); + } else if (phase === "terminal") { + saveHostedCall({ + ...entry, + state: "failed", + outcome: "durable_sms_attempt_is_ambiguous", + retryable: false, + }); + } else { + schedule(identity.id, entry.event, phase); + } + } + }, + }; +} diff --git a/src/gateway/hosted-call-registry.ts b/src/gateway/hosted-call-registry.ts new file mode 100644 index 0000000..e3b45fd --- /dev/null +++ b/src/gateway/hosted-call-registry.ts @@ -0,0 +1,423 @@ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { CallEndedWebhookPayload } from "@inkbox/sdk"; +import { gatewayHome } from "./state.js"; + +export type HostedSmsErrorKind = + | "pre_send_validation" + | "content_rejected" + | "recipient_terminal" + | "ambiguous_provider_failure"; + +export interface HostedSmsAttempt { + phase: "initial" | "correction"; + id: string; + messageId?: string; + target?: string; + targetMatches: boolean; + state: "pending" | "success" | "failed"; + errorKind?: HostedSmsErrorKind; +} + +export interface HostedCallEntry { + identityId: string; + callId: string; + eventId: string; + state: "queued" | "running" | "completed" | "failed"; + outcome?: string; + retryable?: boolean; + event: CallEndedWebhookPayload; + active?: { + sessionID: string; + phase: "initial" | "correction"; + expectedTarget: string; + ownerPid: number; + startedAt: number; + }; + smsAttempts: HostedSmsAttempt[]; + updatedAt: number; +} + +type Registry = Record; + +export const HOSTED_REGISTRY_DIRECTORY_MODE = 0o700; +export const HOSTED_REGISTRY_FILE_MODE = 0o600; + +function registryPath(): string { + return path.join(gatewayHome(), "hosted-call-completions.json"); +} + +function ensurePrivateRegistryDirectory(file: string): void { + const directory = path.dirname(file); + fs.mkdirSync(directory, { recursive: true, mode: HOSTED_REGISTRY_DIRECTORY_MODE }); + fs.chmodSync(directory, HOSTED_REGISTRY_DIRECTORY_MODE); +} + +function read(): Registry { + try { + const value = JSON.parse(fs.readFileSync(registryPath(), "utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("The hosted-call journal must contain a JSON object."); + } + for (const entry of Object.values(value)) { + if ( + !entry || + typeof entry !== "object" || + typeof (entry as HostedCallEntry).identityId !== "string" || + typeof (entry as HostedCallEntry).callId !== "string" || + typeof (entry as HostedCallEntry).eventId !== "string" || + !["queued", "running", "completed", "failed"].includes((entry as HostedCallEntry).state) || + !Array.isArray((entry as HostedCallEntry).smsAttempts) || + typeof (entry as HostedCallEntry).updatedAt !== "number" + ) { + throw new Error("The hosted-call journal contains an invalid entry."); + } + } + return value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw error; + } +} + +function acquireRegistryLock(lock: string): number { + // Registry mutations are synchronous and very short. Fail closed instead + // of blocking OpenCode's event loop behind another gateway process; the + // webhook/provider retry can safely replay against the durable journal. + for (let attempt = 0; attempt < 3; attempt += 1) { + let handle: number | undefined; + try { + handle = fs.openSync(lock, "wx", HOSTED_REGISTRY_FILE_MODE); + try { + fs.writeFileSync(handle, `${process.pid}\n`); + } catch (error) { + fs.closeSync(handle); + try { + fs.unlinkSync(lock); + } catch { + // Preserve the original initialization error. + } + throw error; + } + return handle; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + try { + if (Date.now() - fs.statSync(lock).mtimeMs <= 60_000) { + throw new Error("The hosted-call journal is busy; retry this event."); + } + fs.unlinkSync(lock); + } catch (lockError) { + if ((lockError as NodeJS.ErrnoException).code === "ENOENT") continue; + throw lockError; + } + } + } + throw new Error("Could not acquire the hosted-call journal lock."); +} + +function withRegistryMutation(change: (registry: Registry) => T): T { + const file = registryPath(); + ensurePrivateRegistryDirectory(file); + const lock = `${file}.lock`; + const handle = acquireRegistryLock(lock); + try { + const registry = read(); + const result = change(registry); + write(registry); + return result; + } finally { + fs.closeSync(handle); + try { + fs.unlinkSync(lock); + } catch { + // A stale-lock recovery may already have removed it. + } + } +} + +function write(registry: Registry): void { + const file = registryPath(); + ensurePrivateRegistryDirectory(file); + const bounded = Object.fromEntries( + Object.entries(registry) + .filter(([, entry]) => Date.now() - entry.updatedAt < 30 * 24 * 60 * 60 * 1000) + .sort((a, b) => b[1].updatedAt - a[1].updatedAt) + .slice(0, 1_000), + ); + const temp = `${file}.${process.pid}.${randomUUID()}.tmp`; + fs.writeFileSync(temp, `${JSON.stringify(bounded, null, 2)}\n`, { + mode: HOSTED_REGISTRY_FILE_MODE, + flag: "wx", + }); + fs.renameSync(temp, file); + fs.chmodSync(file, HOSTED_REGISTRY_FILE_MODE); +} + +function bounded(value: unknown, max: number): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, max) : undefined; +} + +function replayEvent(event: CallEndedWebhookPayload): CallEndedWebhookPayload { + const source = event as any; + const call = source.data?.call ?? {}; + return { + id: bounded(source.id, 256) ?? `call:${bounded(call.id, 256) ?? "unknown"}`, + event_type: "call.ended", + timestamp: bounded(source.timestamp, 128) ?? new Date().toISOString(), + data: { + call: { + id: bounded(call.id, 256) ?? "", + mode: call.mode, + }, + contacts: [], + post_call_action_items: [], + }, + } as unknown as CallEndedWebhookPayload; +} + +function receiptEvent(event: CallEndedWebhookPayload): CallEndedWebhookPayload { + return replayEvent(event); +} + +export function hostedCallKey(identityId: string, callId: string): string { + return `${identityId}:${callId}`; +} + +export function getHostedCall(identityId: string, callId: string): HostedCallEntry | undefined { + return read()[hostedCallKey(identityId, callId)]; +} + +export function listRecoverableHostedCalls(identityId: string): HostedCallEntry[] { + return Object.values(read()).filter( + (entry) => + entry.identityId === identityId && + entry.state !== "completed" && + !(entry.state === "failed" && !entry.retryable), + ); +} + +export function saveHostedCall( + entry: Omit & { smsAttempts?: HostedSmsAttempt[] }, +): void { + withRegistryMutation((registry) => { + const key = hostedCallKey(entry.identityId, entry.callId); + const existing = registry[key]; + const replayable = entry.state === "queued" || entry.state === "running" || entry.retryable; + registry[key] = { + ...entry, + event: replayable ? replayEvent(entry.event) : receiptEvent(entry.event), + smsAttempts: entry.smsAttempts ?? existing?.smsAttempts ?? [], + updatedAt: Date.now(), + }; + }); +} + +export function activateHostedSmsCapture(params: { + identityId: string; + callId: string; + sessionID: string; + phase: "initial" | "correction"; + expectedTarget: string; +}): void { + withRegistryMutation((registry) => { + const key = hostedCallKey(params.identityId, params.callId); + const entry = registry[key]; + if (!entry) throw new Error("Hosted call registry entry is missing."); + entry.active = { + sessionID: params.sessionID, + phase: params.phase, + expectedTarget: params.expectedTarget, + ownerPid: process.pid, + startedAt: Date.now(), + }; + entry.updatedAt = Date.now(); + }); +} + +export function clearHostedSmsCapture(identityId: string, callId: string): void { + withRegistryMutation((registry) => { + const entry = registry[hostedCallKey(identityId, callId)]; + if (!entry) return; + delete entry.active; + entry.updatedAt = Date.now(); + }); +} + +export interface HostedSmsGuard { + identityId: string; + callId: string; + attemptId: string; + expectedTarget: string; +} + +function phoneDigits(value: string): string { + return value.replace(/\D/g, ""); +} + +function activeHostedEntry(registry: Registry, sessionID: string): HostedCallEntry | undefined { + const matches = Object.values(registry).filter( + (entry) => + entry.state === "running" && + entry.active?.sessionID === sessionID && + !activeCaptureIsExpired(entry), + ); + if (matches.length > 1) { + throw new Error("Blocked side effect because the hosted-call capture is ambiguous."); + } + const match = matches[0]; + if (match && !activeCaptureOwnerIsLive(match)) { + throw new Error("Blocked hosted-call side effect because its gateway owner is unavailable."); + } + return match; +} + +function activeCaptureIsExpired(entry: HostedCallEntry): boolean { + const active = entry.active; + return ( + !active || + !Number.isInteger(active.ownerPid) || + !Number.isFinite(active.startedAt) || + Date.now() - active.startedAt > 60 * 60 * 1000 + ); +} + +function activeCaptureOwnerIsLive(entry: HostedCallEntry): boolean { + const ownerPid = entry.active?.ownerPid; + if (!ownerPid) return false; + try { + process.kill(ownerPid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +export function assertHostedCallTarget(sessionID: string, target: string): boolean { + const match = activeHostedEntry(read(), sessionID); + if (!match?.active) return false; + if (target !== match.active.expectedTarget) { + throw new Error("Blocked hosted callback to a non-authoritative call target."); + } + return true; +} + +export function assertHostedToolAllowed(sessionID: string, toolName: string): void { + const registry = read(); + const matches = Object.values(registry).filter( + (entry) => + entry.state === "running" && + entry.active?.sessionID === sessionID && + !activeCaptureIsExpired(entry), + ); + if (matches.length > 1) { + throw new Error("Blocked tool call because the hosted-call capture is ambiguous."); + } + if (matches[0] && !activeCaptureOwnerIsLive(matches[0])) { + throw new Error("Blocked hosted-call tool because its gateway owner is unavailable."); + } + if (matches[0]?.active?.phase === "correction" && toolName !== "inkbox_send_sms") { + throw new Error("The hosted-call correction turn permits only inkbox_send_sms."); + } +} + +export function beginHostedSmsAttempt(params: { + sessionID: string; + messageId?: string; + target?: string; + hasConversationId: boolean; +}): HostedSmsGuard | undefined { + // Ordinary sends never need the cross-process mutation lock. Hosted + // capture is activated before its prompt starts, so no match means this + // session is outside settlement. + if (!activeHostedEntry(read(), params.sessionID)?.active) return undefined; + const result = withRegistryMutation<{ guard?: HostedSmsGuard; error?: string }>((registry) => { + const match = activeHostedEntry(registry, params.sessionID); + if (!match?.active) return {}; + const phaseAttempts = match.smsAttempts.filter( + (attempt) => attempt.phase === match.active?.phase, + ); + if (phaseAttempts.length > 0) { + throw new Error("Blocked a second SMS attempt during hosted-call reconciliation."); + } + const missingTarget = !params.hasConversationId && params.target === undefined; + const targetMatches = + missingTarget || + (!params.hasConversationId && + params.target !== undefined && + phoneDigits(params.target) === phoneDigits(match.active.expectedTarget)); + const attempt: HostedSmsAttempt = { + phase: match.active.phase, + id: randomUUID(), + messageId: bounded(params.messageId, 256), + target: params.target, + targetMatches, + state: missingTarget ? "failed" : targetMatches ? "pending" : "failed", + ...(missingTarget + ? { errorKind: "pre_send_validation" as const } + : !targetMatches + ? { errorKind: "recipient_terminal" as const } + : {}), + }; + match.smsAttempts.push(attempt); + match.updatedAt = Date.now(); + if (missingTarget) { + return { error: "Hosted post-call SMS requires the explicit authoritative target." }; + } + if (!targetMatches) { + return { error: "Blocked hosted SMS to a non-authoritative call target." }; + } + return { + guard: { + identityId: match.identityId, + callId: match.callId, + attemptId: attempt.id, + expectedTarget: match.active.expectedTarget, + }, + }; + }); + if (result.error) throw new Error(result.error); + return result.guard; +} + +export function settleHostedSmsAttempt( + guard: HostedSmsGuard, + state: "success" | "failed", + errorKind?: HostedSmsErrorKind, +): void { + withRegistryMutation((registry) => { + const entry = registry[hostedCallKey(guard.identityId, guard.callId)]; + const attempt = entry?.smsAttempts.find((item) => item.id === guard.attemptId); + if (!entry || !attempt || attempt.state !== "pending") { + throw new Error("Hosted SMS pending journal entry is missing."); + } + attempt.state = state; + attempt.errorKind = errorKind; + entry.updatedAt = Date.now(); + }); +} + +export function classifyHostedSmsError(error: unknown): HostedSmsErrorKind { + const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); + if ( + /specify exactly one of|must include at least one|maximum is \d+|too long|validation error \(422\)|http 422/.test( + message, + ) + ) { + return "pre_send_validation"; + } + if (/content[_ -]?(?:policy|rejected|violation)|markdown|emoji|profanity/.test(message)) { + return "content_rejected"; + } + if ( + /not opted in|opted out|invalid (?:phone|number)|unreachable|blocked|not on the outbound allowlist/.test( + message, + ) + ) { + return "recipient_terminal"; + } + return "ambiguous_provider_failure"; +} diff --git a/src/gateway/index.ts b/src/gateway/index.ts index d5fe928..2beb9c0 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -8,6 +8,7 @@ import { createContactResolver } from "./contacts.js"; import { createNotifyOnce, createRequestDedup } from "./dedup.js"; import { dispatchEvent } from "./dispatch.js"; import { createEscalationBridge } from "./escalation.js"; +import { createHostedCallCompletion } from "./hosted-call-completion.js"; import { createPendingReplies } from "./pending.js"; import { deliverReply } from "./reply.js"; import { createWebhookServer } from "./server.js"; @@ -77,6 +78,12 @@ export async function startGateway(opts: StartGatewayOptions): Promise {}); @@ -181,6 +189,7 @@ export async function startGateway(opts: StartGatewayOptions): Promise hostedCalls.ingest(event), }, event, ); diff --git a/src/gateway/sessions.ts b/src/gateway/sessions.ts index 1ff0374..0dfe98b 100644 --- a/src/gateway/sessions.ts +++ b/src/gateway/sessions.ts @@ -2,6 +2,16 @@ import type { OpencodeClient } from "@opencode-ai/sdk"; import { type ActiveA2ATurn, clearActiveA2ATurn, setActiveA2ATurn } from "../a2a-context.js"; import type { InkboxRuntime } from "../client.js"; import type { ResolvedConfig } from "../config.js"; +import { + clearDeliveryFailures, + deliveryFailureKey, + deliveryFailureRecovery, +} from "./delivery-policy.js"; +import { + activateHostedSmsCapture, + clearHostedSmsCapture, + getHostedCall, +} from "./hosted-call-registry.js"; import { buildIdentitySystem, frameCapture, frameInbound } from "./prompts.js"; import { deliverReply } from "./reply.js"; import type { StateStore } from "./state.js"; @@ -12,6 +22,7 @@ import type { SessionManager, TurnKind, } from "./types.js"; +import { HostedCaptureDeferredError } from "./types.js"; interface QueuedTurn { kind: TurnKind; @@ -20,10 +31,17 @@ interface QueuedTurn { // Per-contact/per-channel opencode agent override for this turn. agent?: string; replyTarget?: ReplyTarget; - // True for a follow-up turn enqueued after a delivery failure, so a second - // failure doesn't spawn another recovery (bounded to one attempt). - recovered?: boolean; a2aContext?: ActiveA2ATurn; + hostedCapture?: { + identityId: string; + callId: string; + phase: "initial" | "correction"; + expectedTarget: string; + }; + hostedResolve?: (result: { + output?: string; + attempt?: import("./hosted-call-registry.js").HostedSmsAttempt; + }) => void; resolve: (out: string | undefined) => void; reject: (err: unknown) => void; } @@ -131,16 +149,33 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { sessionID: string, text: string, agentOverride?: string, + hostedPhase?: "initial" | "correction", ): Promise { const g = deps.config.gateway; const agent = agentOverride ?? g.agent; const system = await identitySystem(); + let hostedTools: Record | undefined; + if (hostedPhase === "initial") { + // A delegated task runs under another session id and would escape the + // durable SMS guard. Keep the initial turn otherwise fully capable of + // completing its non-SMS commitments. + hostedTools = { task: false, inkbox_a2a_call: false }; + } else if (hostedPhase === "correction") { + const listed = await deps.opencode.tool.ids({ query: { directory: deps.directory } }); + const ids = (listed as any)?.data ?? listed; + if (!Array.isArray(ids)) { + throw new Error("Could not restrict the hosted correction turn to the SMS tool."); + } + hostedTools = Object.fromEntries(ids.map((id) => [String(id), false])); + hostedTools.inkbox_send_sms = true; + } const res = await deps.opencode.session.prompt({ path: { id: sessionID }, query: { directory: deps.directory }, body: { ...(agent ? { agent } : {}), ...(system ? { system } : {}), + ...(hostedTools ? { tools: hostedTools } : {}), ...(g.model?.includes("/") ? { model: { @@ -175,8 +210,15 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { } let out: string | undefined; try { - out = await runPrompt(sessionID, turn.text, turn.agent); + if (turn.hostedCapture) { + activateHostedSmsCapture({ + ...turn.hostedCapture, + sessionID, + }); + } + out = await runPrompt(sessionID, turn.text, turn.agent, turn.hostedCapture?.phase); } catch (err) { + if (turn.hostedCapture) throw err; // A session that passed validation can still fail server-side // (stale project state); one retry on a brand-new session keeps // the contact reachable instead of failing the turn. @@ -195,26 +237,59 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { await deliverReply(deps.inkbox, turn.replyTarget, out, deps.logger); } catch (err) { deps.logger.error("reply.failed", { chatKey, error: String(err) }); - // One bounded recovery turn: tell the agent the send failed so it - // can shorten or switch channel. A recovery that also fails stops. - if (!turn.recovered && turn.replyTarget) { - entry.queue.push({ - kind: "normal", - text: `Your previous reply could not be delivered (${err instanceof Error ? err.message : String(err)}). Send a shorter plain-text reply, or handle it another way.`, - deliver: true, - replyTarget: turn.replyTarget, - recovered: true, - resolve: () => {}, - reject: () => {}, + if (turn.replyTarget) { + const recovery = deliveryFailureRecovery({ + key: deliveryFailureKey( + turn.replyTarget.channel, + turn.replyTarget.to, + turn.replyTarget.conversationId, + ), + channel: turn.replyTarget.channel, + target: turn.replyTarget.to, + failure: err, + failedBody: out, }); + if (recovery.prompt) { + entry.queue.push({ + kind: "normal", + text: recovery.prompt, + deliver: true, + replyTarget: turn.replyTarget, + resolve: () => {}, + reject: () => {}, + }); + } } } } + if (turn.hostedCapture && turn.hostedResolve) { + const entry = getHostedCall(turn.hostedCapture.identityId, turn.hostedCapture.callId); + turn.hostedResolve({ + output: out, + attempt: entry?.smsAttempts.find( + (attempt) => attempt.phase === turn.hostedCapture?.phase, + ), + }); + clearHostedSmsCapture(turn.hostedCapture.identityId, turn.hostedCapture.callId); + } turn.resolve(out); } catch (err) { deps.logger.error("turn.failed", { chatKey, error: String(err) }); turn.reject(err); } finally { + if (turn.hostedCapture) { + try { + clearHostedSmsCapture(turn.hostedCapture.identityId, turn.hostedCapture.callId); + } catch (err) { + // The turn has already settled. Contention must not reject the + // entire drain and strand later turns; capture ownership is + // bounded and a replay or later cleanup can remove it. + deps.logger.warn("hosted_call.capture_cleanup_failed", { + callId: turn.hostedCapture.callId, + error: String(err), + }); + } + } const sessionID = deps.state.getSession(chatKey); if (sessionID && turn.a2aContext) { clearActiveA2ATurn(sessionID, turn.a2aContext); @@ -256,6 +331,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { subject: msg.subject, rfcMessageId: msg.rfcMessageId, }; + clearDeliveryFailures(deliveryFailureKey(msg.channel, msg.from, msg.conversationId)); const entry = per(msg.chatKey); // A new inbound while a NORMAL turn runs interrupts it. Await the abort // before enqueuing so it can't outlive its turn and truncate the next. @@ -300,6 +376,23 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { }); }, + async runHostedCapture(chatKey, framedText, capture) { + if (closing) throw new HostedCaptureDeferredError(); + const entry = per(chatKey); + return new Promise((resolve, reject) => { + entry.queue.push({ + kind: "capture", + text: framedText, + deliver: false, + hostedCapture: capture, + hostedResolve: resolve, + resolve: () => {}, + reject, + }); + void drain(chatKey); + }); + }, + async runA2A(chatKey, framedText, context) { if (closing) return undefined; const entry = per(chatKey); diff --git a/src/gateway/subscriptions.ts b/src/gateway/subscriptions.ts index 2f0f83e..735c105 100644 --- a/src/gateway/subscriptions.ts +++ b/src/gateway/subscriptions.ts @@ -25,6 +25,7 @@ export const A2A_EVENT_TYPES = [ "a2a.task.canceled", "a2a.sent_task.updated", ]; +export const CALL_EVENT_TYPES = ["call.ended"]; export interface ReconcileResult { created: number; updated: number; @@ -70,15 +71,26 @@ function isUnsupportedA2AEventTypes(err: unknown): boolean { ); } -function normalizePublicUrl(publicUrl: string): string { +function invalidPublicUrlError(): Error { + return new Error( + "Gateway public URL must be an http(s) URL. " + + "Check gateway.publicUrl (or INKBOX_PUBLIC_URL) or let the tunnel provide one.", + ); +} + +export function normalizePublicUrl(publicUrl: string): string { const base = publicUrl.trim().replace(/\/+$/, ""); - if (!/^https?:\/\//.test(base)) { - throw new Error( - `Gateway public URL must be an http(s) URL, got '${publicUrl}'. ` + - "Check gateway.publicUrl (or INKBOX_PUBLIC_URL) or let the tunnel provide one.", - ); + let parsed: URL; + try { + if (!/^https?:\/\//i.test(base)) throw new Error("missing HTTP(S) scheme"); + parsed = new URL(base); + } catch { + throw invalidPublicUrlError(); + } + if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || !parsed.hostname) { + throw invalidPublicUrlError(); } - return base; + return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`; } /** @@ -179,6 +191,10 @@ export async function reconcileSubscriptions( await reconcileOwner("imessage", { agentIdentityId: identity.id }, IMESSAGE_EVENT_TYPES); } + if (identity.phoneNumber || identity.imessageEnabled) { + await reconcileOwner("calls", { agentIdentityId: identity.id }, CALL_EVENT_TYPES); + } + if (deps.config.gateway.voice.enabled) { await wireIncomingCalls(deps, identity, base, webhookUrl); } @@ -201,17 +217,28 @@ async function wireIncomingCalls( ); return; } - // https -> wss (http -> ws in local dev); Inkbox dials this URL with call audio. + const hosted = deps.config.phoneVoiceStack === "inkbox_voice_ai"; + // https -> wss (http -> ws in local dev); local stacks receive call audio here. const wsUrl = `${base.replace(/^http/, "ws")}${CALL_MEDIA_WS_PATH}`; try { // Identity-scoped config covers the dedicated number and any shared // iMessage line in one row. auto_accept opens the audio WS directly. - await identity.setIncomingCallAction({ - incomingCallAction: IncomingCallAction.AUTO_ACCEPT, - clientWebsocketUrl: wsUrl, - incomingCallWebhookUrl: webhookUrl, - }); - deps.logger.info("incoming-call action set to auto-accept", { clientWebsocketUrl: wsUrl }); + const action = hosted + ? ({ + incomingCallAction: IncomingCallAction.HOSTED_AGENT, + clientWebsocketUrl: null, + incomingCallWebhookUrl: null, + } as unknown as Parameters[0]) + : { + incomingCallAction: IncomingCallAction.AUTO_ACCEPT, + clientWebsocketUrl: wsUrl, + incomingCallWebhookUrl: webhookUrl, + }; + await identity.setIncomingCallAction(action); + deps.logger.info( + hosted ? "incoming calls use Inkbox Voice AI" : "incoming-call action set to auto-accept", + hosted ? undefined : { clientWebsocketUrl: wsUrl }, + ); } catch (err) { throw new Error(`Failed to set the incoming-call action: ${inkboxErrorMessage(err)}`); } diff --git a/src/gateway/types.ts b/src/gateway/types.ts index d604062..7591281 100644 --- a/src/gateway/types.ts +++ b/src/gateway/types.ts @@ -2,6 +2,7 @@ import type { OpencodeClient } from "@opencode-ai/sdk"; import type { ActiveA2ATurn } from "../a2a-context.js"; import type { InkboxRuntime } from "../client.js"; import type { ResolvedConfig } from "../config.js"; +import type { HostedSmsAttempt } from "./hosted-call-registry.js"; import type { StateStore } from "./state.js"; export interface GatewayLogger { @@ -87,6 +88,13 @@ export interface ReplyTarget { // to completion and never deliver their text as a channel reply by default. export type TurnKind = "normal" | "capture"; +export class HostedCaptureDeferredError extends Error { + constructor() { + super("Hosted-call capture deferred because the gateway is closing."); + this.name = "HostedCaptureDeferredError"; + } +} + export interface TurnRequest { kind: TurnKind; // Fully framed message text (channel tag + body + media paths). @@ -105,6 +113,16 @@ export interface SessionManager { // Run an already-framed turn and return the assistant text without // delivering it anywhere (used by the voice bridge to speak the reply). runText(chatKey: string, framedText: string): Promise; + runHostedCapture?( + chatKey: string, + framedText: string, + capture: { + identityId: string; + callId: string; + phase: "initial" | "correction"; + expectedTarget: string; + }, + ): Promise<{ output?: string; attempt?: HostedSmsAttempt }>; runA2A(chatKey: string, framedText: string, context: ActiveA2ATurn): Promise; abortA2A(chatKey: string, taskId: string): Promise; // Control-command support. diff --git a/src/tools/index.ts b/src/tools/index.ts index b0dbead..4899b7b 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,4 +1,5 @@ import type { ToolDefinition } from "@opencode-ai/plugin"; +import { assertHostedToolAllowed } from "../gateway/hosted-call-registry.js"; import { a2aTools } from "./a2a.js"; import { accessTools } from "./access.js"; import { callReadTools } from "./call-reads.js"; @@ -59,5 +60,15 @@ export function registerTools(deps: ToolDeps): { const all = buildGroups(deps, () => gating); const result = selectTools(all, deps.config.tools); gating = result.summary; + for (const [name, definition] of Object.entries(result.tools)) { + const execute = definition.execute; + result.tools[name] = { + ...definition, + async execute(args, context) { + assertHostedToolAllowed(context.sessionID, name); + return execute(args, context); + }, + }; + } return result; } diff --git a/src/tools/place-call.ts b/src/tools/place-call.ts index 7f2d718..4a474c0 100644 --- a/src/tools/place-call.ts +++ b/src/tools/place-call.ts @@ -1,10 +1,11 @@ -import { CallOrigin, VoicemailDetection } from "@inkbox/sdk"; +import { CallMode, CallOrigin, VoicemailDetection } from "@inkbox/sdk"; import { z } from "zod"; import { runTool } from "../errors.js"; -import { approveOutbound } from "../permissions.js"; +import { assertHostedCallTarget } from "../gateway/hosted-call-registry.js"; +import { approveOutbound, checkOutboundRecipients } from "../permissions.js"; import type { RegisteredTool, ToolDeps } from "./types.js"; -const placeCallArgs = { +const commonPlaceCallArgs = { toNumber: z.string().describe("Recipient phone number in E.164 format."), origination: z .enum(["dedicated_number", "shared_imessage_number"]) @@ -12,12 +13,6 @@ const placeCallArgs = { 'Which line to call from. Use "dedicated_number" to call from your own phone number (the same line SMS/voice conversations use). Use "shared_imessage_number" to call someone over the shared iMessage line you are already messaging them on — this only works if they are connected to you over iMessage (otherwise the call is rejected). If omitted, it is resolved automatically: the only available line, or the dedicated number when both are available.', ) .optional(), - purpose: z - .string() - .describe( - "Why this call is being placed. Loaded into the live call so it opens with context instead of a generic greeting. If no topic was given, say the user asked for a general call.", - ) - .optional(), openingMessage: z .string() .describe("Optional exact or near-exact first thing to say when the call connects.") @@ -26,20 +21,54 @@ const placeCallArgs = { .string() .describe("Optional background facts the voice agent may need after the opening.") .optional(), + voicemailDetection: z + .enum(["enabled", "disabled"]) + .describe( + "Whether the call should end when voicemail is detected. Omit to keep the configured default.", + ) + .optional(), +}; + +const localPlaceCallArgs = { + ...commonPlaceCallArgs, + purpose: z + .string() + .describe("Optional purpose loaded into the live OpenCode call context.") + .optional(), clientWebsocketUrl: z .string() .describe( "Optional WebSocket URL (wss://...) that Inkbox will connect to for the call stream. Omit to use the callWebsocketUrl configured for the plugin.", ) .optional(), - voicemailDetection: z - .enum(["enabled", "disabled"]) +}; + +const hostedPlaceCallArgs = { + ...commonPlaceCallArgs, + purpose: z + .string() + .min(1) .describe( - "Whether the call should end when voicemail is detected. Omit to keep detection enabled.", - ) - .optional(), + "Why Inkbox Voice AI is placing this call. If no topic was given, say the user asked for a general call.", + ), }; +export function buildVoiceAiReason(params: { + purpose: string; + openingMessage?: string; + context?: string; +}): string { + const reason = [ + ["Purpose", params.purpose], + ["Opening message", params.openingMessage], + ["Context", params.context], + ] + .filter((entry): entry is [string, string] => Boolean(entry[1]?.trim())) + .map(([label, value]) => `${label}: ${value.trim()}`) + .join("\n"); + return reason.length <= 2_000 ? reason : `${reason.slice(0, 1_999).trimEnd()}…`; +} + // Fold call context onto the media WebSocket URL as query params. This is the // only channel that survives to the call bridge, which may run in a separate // process, so it reads purpose/opening/context from the upgrade request URL. @@ -58,7 +87,15 @@ function decorateCallUrl( } } -type PlaceCallArgs = z.infer>; +interface PlaceCallArgs { + toNumber: string; + origination?: "dedicated_number" | "shared_imessage_number"; + purpose?: string; + openingMessage?: string; + context?: string; + clientWebsocketUrl?: string; + voicemailDetection?: "enabled" | "disabled"; +} // Pick which line an outbound call originates from: an explicit choice always // wins; otherwise the only available line (dedicated number vs shared @@ -89,44 +126,76 @@ function resolveCallOrigination( // per-call or via the plugin's callWebsocketUrl option. export function placeCallTools(deps: ToolDeps): RegisteredTool[] { const { runtime, config } = deps; + const hosted = config.phoneVoiceStack === "inkbox_voice_ai"; return [ { name: "inkbox_place_call", group: "calls", defaultEnabled: false, definition: { - description: - "Place an outbound voice call. Calls can go out over two lines: your own dedicated phone number, or the shared Inkbox iMessage line you are already messaging the recipient on. Match the channel you're talking on — call SMS/phone contacts from your dedicated number, and call an iMessage contact over the shared iMessage line (set `origination` accordingly). Returns the queued call's id + status + origination + rate-limit info.", - args: placeCallArgs, - async execute(args: PlaceCallArgs, ctx) { + description: hosted + ? "Ask Inkbox Voice AI to place an outbound call and complete the stated task. OpenCode is notified after the call ends." + : "Place an outbound voice call through the configured OpenCode phone call voice stack. Calls can use the dedicated number or shared iMessage line.", + args: hosted ? hostedPlaceCallArgs : localPlaceCallArgs, + async execute(rawArgs, ctx) { + const args = rawArgs as unknown as PlaceCallArgs; return runTool(async () => { // Resolve the audio bridge before asking for approval so the // approver sees exactly where the call's media will stream. - const clientWebsocketUrl = args.clientWebsocketUrl ?? config.callWebsocketUrl; - if (!clientWebsocketUrl) { + const purpose = args.purpose?.trim() || ""; + if (hosted && !purpose) { + throw new Error( + "Inkbox Voice AI calls require a purpose. If no topic was given, say the user asked for a general call.", + ); + } + const clientWebsocketUrl = hosted + ? undefined + : (args.clientWebsocketUrl ?? config.callWebsocketUrl); + if (!hosted && !clientWebsocketUrl) { throw new Error( - "No call WebSocket configured. Pass clientWebsocketUrl (wss://...) or set the callWebsocketUrl plugin option / INKBOX_CALL_WEBSOCKET_URL so Inkbox has an audio bridge to connect to.", + "No call WebSocket configured. Pass clientWebsocketUrl (wss://...) or set INKBOX_CALL_WEBSOCKET_URL.", ); } - if (!/^wss?:\/\//.test(clientWebsocketUrl)) { + if (clientWebsocketUrl && !/^wss?:\/\//.test(clientWebsocketUrl)) { throw new Error("clientWebsocketUrl must be a ws:// or wss:// URL."); } - await approveOutbound(ctx, config, { - tool: "inkbox_place_call", - recipients: [args.toNumber], - summary: `Place voice call to ${args.toNumber} (audio bridge: ${clientWebsocketUrl})`, - metadata: { - origination: args.origination ?? "auto", - clientWebsocketUrl, - ...(args.purpose ? { purpose: args.purpose } : {}), - }, - }); + const hostedCompletion = assertHostedCallTarget(ctx.sessionID, args.toNumber); + if (hostedCompletion) { + if ( + config.outbound.approval === "allowlist" && + config.outbound.allowedRecipients.length === 0 + ) { + throw new Error( + 'outbound.approval is "allowlist" but outbound.allowedRecipients is empty.', + ); + } + const block = checkOutboundRecipients( + [args.toNumber], + config.outbound.allowedRecipients, + ); + if (block) throw new Error(block); + } else { + await approveOutbound(ctx, config, { + tool: "inkbox_place_call", + recipients: [args.toNumber], + summary: hosted + ? `Ask Inkbox Voice AI to call ${args.toNumber}: ${purpose}` + : `Place voice call to ${args.toNumber} (audio bridge: ${clientWebsocketUrl})`, + metadata: { + origination: args.origination ?? "auto", + ...(clientWebsocketUrl ? { clientWebsocketUrl } : {}), + ...(args.purpose ? { purpose: args.purpose } : {}), + }, + }); + } - const decoratedUrl = decorateCallUrl(clientWebsocketUrl, { - purpose: args.purpose, - openingMessage: args.openingMessage, - context: args.context, - }); + const decoratedUrl = clientWebsocketUrl + ? decorateCallUrl(clientWebsocketUrl, { + purpose: args.purpose, + openingMessage: args.openingMessage, + context: args.context, + }) + : undefined; const identity = await runtime.getIdentity(); // Resolve the outbound line (dedicated number vs shared iMessage line). const origination = resolveCallOrigination(identity, args.origination ?? ""); @@ -136,6 +205,7 @@ export function placeCallTools(deps: ToolDeps): RegisteredTool[] { ); } let call: Awaited>; + const voicemailDetection = args.voicemailDetection ?? config.voicemailDetection; try { call = await identity.placeCall({ toNumber: args.toNumber, @@ -143,11 +213,20 @@ export function placeCallTools(deps: ToolDeps): RegisteredTool[] { origination === "shared_imessage_number" ? CallOrigin.SHARED_IMESSAGE_NUMBER : CallOrigin.DEDICATED_NUMBER, - clientWebsocketUrl: decoratedUrl, - ...(args.voicemailDetection !== undefined + mode: hosted ? CallMode.HOSTED_AGENT : CallMode.CLIENT_WEBSOCKET, + ...(hosted + ? { + reason: buildVoiceAiReason({ + purpose, + openingMessage: args.openingMessage, + context: args.context, + }), + } + : { clientWebsocketUrl: decoratedUrl }), + ...(voicemailDetection ? { voicemailDetection: - args.voicemailDetection === "disabled" + voicemailDetection === "disabled" ? VoicemailDetection.DISABLED : VoicemailDetection.ENABLED, } @@ -170,7 +249,7 @@ export function placeCallTools(deps: ToolDeps): RegisteredTool[] { return { title: `Call placed to ${args.toNumber}`, output: - `Placed call id=${call.id} to=${args.toNumber} status=${call.status} origination=${origination}` + + `Placed call id=${call.id} to=${args.toNumber} status=${call.status} origination=${origination} mode=${hosted ? "inkbox_voice_ai" : "client_websocket"}` + (remaining !== undefined ? ` callsRemaining=${remaining}` : ""), }; }); diff --git a/src/tools/send-sms.ts b/src/tools/send-sms.ts index fa39657..29db112 100644 --- a/src/tools/send-sms.ts +++ b/src/tools/send-sms.ts @@ -1,8 +1,14 @@ import { z } from "zod"; import { runTool } from "../errors.js"; +import { + beginHostedSmsAttempt, + classifyHostedSmsError, + type HostedSmsGuard, + settleHostedSmsAttempt, +} from "../gateway/hosted-call-registry.js"; import { uploadLocalMedia } from "../gateway/media.js"; import { assertSmsTextWithinLimit, SMS_MAX_TEXT_CHARS } from "../limits.js"; -import { approveOutbound } from "../permissions.js"; +import { approveOutbound, checkOutboundRecipients } from "../permissions.js"; import type { RegisteredTool, ToolDeps } from "./types.js"; const sendSmsArgs = { @@ -81,62 +87,105 @@ export function sendSmsTools(deps: ToolDeps): RegisteredTool[] { args: sendSmsArgs, async execute(args: SendSmsArgs, ctx) { return runTool(async () => { - const conversationId = - typeof args.conversationId === "string" ? args.conversationId.trim() : ""; - const toList = normalizeRecipients(args.to); - const hasTo = toList !== undefined && toList.length > 0; - const hasConversation = Boolean(conversationId); - if (hasTo === hasConversation) { - throw new Error("Specify exactly one of `to` or `conversationId`."); - } - if (toList?.length === 0) { - throw new Error("`to` must include at least one recipient."); - } - if (toList && toList.length > 8) { - throw new Error("Inkbox group texts support at most 8 recipients."); - } - assertSmsTextWithinLimit(args.text); + let hostedGuard: HostedSmsGuard | undefined; + let providerAccepted = false; + try { + const conversationId = + typeof args.conversationId === "string" ? args.conversationId.trim() : ""; + const toList = normalizeRecipients(args.to); + hostedGuard = beginHostedSmsAttempt({ + sessionID: ctx.sessionID, + messageId: ctx.messageID, + target: toList?.length === 1 ? toList[0] : undefined, + hasConversationId: Boolean(conversationId), + }); + if (hostedGuard && toList?.length === 1) { + // The guard proved this is the authoritative caller; send its + // canonical E.164 form rather than a model-formatted variant. + toList[0] = hostedGuard.expectedTarget; + } + const hasTo = toList !== undefined && toList.length > 0; + const hasConversation = Boolean(conversationId); + if (hasTo === hasConversation) { + throw new Error("Specify exactly one of `to` or `conversationId`."); + } + if (toList?.length === 0) { + throw new Error("`to` must include at least one recipient."); + } + if (toList && toList.length > 8) { + throw new Error("Inkbox group texts support at most 8 recipients."); + } + assertSmsTextWithinLimit(args.text); - // A conversation send resolves recipients server-side, so a local - // allowlist cannot vet them — refuse rather than silently bypass. - if (hasConversation && config.outbound.allowedRecipients.length > 0) { - throw new Error( - "`conversationId` sends cannot be checked against the local outbound recipient allowlist. Use explicit `to` recipients or adjust the allowlist.", - ); - } - const recipients = hasConversation ? [] : (toList ?? []); - await approveOutbound(ctx, config, { - tool: "inkbox_send_sms", - recipients, - ...(hasConversation ? { patterns: [`conversation:${conversationId}`] } : {}), - summary: hasConversation - ? `Send text to conversation ${conversationId} (${args.text.length} chars)` - : `Send text to ${recipients.join(", ")} (${args.text.length} chars)`, - metadata: { textChars: args.text.length }, - }); + // A conversation send resolves recipients server-side, so a local + // allowlist cannot vet them — refuse rather than silently bypass. + if (hasConversation && config.outbound.allowedRecipients.length > 0) { + throw new Error( + "`conversationId` sends cannot be checked against the local outbound recipient allowlist. Use explicit `to` recipients or adjust the allowlist.", + ); + } + const recipients = hasConversation ? [] : (toList ?? []); + if (hostedGuard) { + if ( + config.outbound.approval === "allowlist" && + config.outbound.allowedRecipients.length === 0 + ) { + throw new Error( + 'outbound.approval is "allowlist" but outbound.allowedRecipients is empty.', + ); + } + const block = checkOutboundRecipients( + recipients, + config.outbound.allowedRecipients, + ); + if (block) throw new Error(block); + } else { + await approveOutbound(ctx, config, { + tool: "inkbox_send_sms", + recipients, + ...(hasConversation ? { patterns: [`conversation:${conversationId}`] } : {}), + summary: hasConversation + ? `Send text to conversation ${conversationId} (${args.text.length} chars)` + : `Send text to ${recipients.join(", ")} (${args.text.length} chars)`, + metadata: { textChars: args.text.length }, + }); + } - const identity = await runtime.getIdentity(); - // Uploaded local files lead, then any caller-supplied URLs. - const uploaded = args.mediaPaths?.length - ? await uploadLocalMedia(identity, args.mediaPaths) - : []; - const mediaUrls = [...uploaded, ...(args.mediaUrls ?? [])]; - const payload = { - text: args.text, - ...(mediaUrls.length ? { mediaUrls } : {}), - ...(hasConversation - ? { conversationId } - : { to: recipients.length === 1 ? recipients[0] : recipients }), - }; - const msg = await identity.sendText(payload); - const target = formatTargetSummary(msg, args); - const status = msg.deliveryStatus ?? "unknown"; - return { - title: hasConversation - ? `Text sent to conversation ${conversationId}` - : `Text sent to ${recipients.join(", ")}`, - output: `Sent text id=${msg.id} ${target} status=${status} (${args.text.length} chars)`, - }; + const identity = await runtime.getIdentity(); + // Uploaded local files lead, then any caller-supplied URLs. + const uploaded = args.mediaPaths?.length + ? await uploadLocalMedia(identity, args.mediaPaths) + : []; + const mediaUrls = [...uploaded, ...(args.mediaUrls ?? [])]; + const payload = { + text: args.text, + ...(mediaUrls.length ? { mediaUrls } : {}), + ...(hasConversation + ? { conversationId } + : { to: recipients.length === 1 ? recipients[0] : recipients }), + }; + const msg = await identity.sendText(payload); + providerAccepted = true; + if (hostedGuard) settleHostedSmsAttempt(hostedGuard, "success"); + const target = formatTargetSummary(msg, args); + const status = msg.deliveryStatus ?? "unknown"; + return { + title: hasConversation + ? `Text sent to conversation ${conversationId}` + : `Text sent to ${recipients.join(", ")}`, + output: `Sent text id=${msg.id} ${target} status=${status} (${args.text.length} chars)`, + }; + } catch (error) { + if (hostedGuard && !providerAccepted) { + settleHostedSmsAttempt(hostedGuard, "failed", classifyHostedSmsError(error)); + } + if (hostedGuard && providerAccepted) { + throw new Error( + `SMS provider accepted the message, but durable hosted-call settlement failed; do not retry this send. ${error instanceof Error ? error.message : String(error)}`, + ); + } + throw error; + } }); }, }, diff --git a/src/voice-stack.ts b/src/voice-stack.ts new file mode 100644 index 0000000..a396259 --- /dev/null +++ b/src/voice-stack.ts @@ -0,0 +1,8 @@ +export const PHONE_VOICE_STACKS = ["inkbox_voice_ai", "openai_realtime", "inkbox_tts_stt"] as const; + +export type PhoneVoiceStack = (typeof PHONE_VOICE_STACKS)[number]; +export type VoiceAiAuthorityMode = "contact_scoped" | "yolo"; + +export function isPhoneVoiceStack(value: unknown): value is PhoneVoiceStack { + return typeof value === "string" && PHONE_VOICE_STACKS.includes(value as PhoneVoiceStack); +} diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts index 83116ee..d9068d7 100644 --- a/tests/cli/doctor.test.ts +++ b/tests/cli/doctor.test.ts @@ -33,6 +33,13 @@ function healthyRuntime() { displayName: "Agent", emailAddress: "agent@inkbox.ai", phoneNumber: { number: "+15550001111" }, + imessageEnabled: false, + getIncomingCallAction: vi.fn(async () => ({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://agent.inkboxwire.com/phone/media/ws", + incomingCallWebhookUrl: "https://agent.inkboxwire.com/webhook", + })), + getHostedAgentConfig: vi.fn(async () => ({ authorityMode: "contact_scoped" })), })), } as any; } @@ -168,6 +175,143 @@ describe("runDoctor", () => { ).toBe(true); }); + it("fails when remote incoming-call routing does not match Voice AI", async () => { + const runtime = healthyRuntime(); + const identity = await runtime.getIdentity(); + identity.getIncomingCallAction.mockResolvedValue({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://agent.inkboxwire.com/phone/media/ws", + incomingCallWebhookUrl: null, + }); + runtime.getIdentity.mockResolvedValue(identity); + const result = await runDoctor(makeConfig({ phoneVoiceStack: "inkbox_voice_ai" }), { + runtime, + opencode: reachableOpencode(), + print: () => {}, + }); + expect(result.ok).toBe(false); + expect(result.findings.some((finding) => /routing mismatch/.test(finding.message))).toBe(true); + }); + + it("reports Voice AI authority drift against the saved local mirror", async () => { + const runtime = healthyRuntime(); + const identity = await runtime.getIdentity(); + identity.getIncomingCallAction.mockResolvedValue({ + incomingCallAction: "hosted_agent", + clientWebsocketUrl: null, + incomingCallWebhookUrl: null, + }); + identity.getHostedAgentConfig.mockResolvedValue({ authorityMode: "contact_scoped" }); + runtime.getIdentity.mockResolvedValue(identity); + const result = await runDoctor( + makeConfig({ phoneVoiceStack: "inkbox_voice_ai", voiceAiAuthorityMode: "yolo" }), + { runtime, opencode: reachableOpencode(), print: () => {} }, + ); + expect(result.ok).toBe(false); + expect(result.findings.some((finding) => /authority drift/.test(finding.message))).toBe(true); + }); + + it("fails when a local voice stack has no media WebSocket route", async () => { + const runtime = healthyRuntime(); + const identity = await runtime.getIdentity(); + identity.getIncomingCallAction.mockResolvedValue({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: null, + incomingCallWebhookUrl: null, + }); + runtime.getIdentity.mockResolvedValue(identity); + const result = await runDoctor(makeConfig({ phoneVoiceStack: "inkbox_tts_stt" }), { + runtime, + opencode: reachableOpencode(), + print: () => {}, + }); + expect(result.ok).toBe(false); + expect( + result.findings.some((finding) => /missing a local callback URL/.test(finding.message)), + ).toBe(true); + }); + + it("fails when a local voice stack has no completion webhook route", async () => { + const runtime = healthyRuntime(); + const identity = await runtime.getIdentity(); + identity.getIncomingCallAction.mockResolvedValue({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://agent.inkboxwire.com/phone/media/ws", + incomingCallWebhookUrl: null, + }); + runtime.getIdentity.mockResolvedValue(identity); + const result = await runDoctor(makeConfig({ phoneVoiceStack: "openai_realtime" }), { + runtime, + opencode: reachableOpencode(), + print: () => {}, + }); + expect(result.ok).toBe(false); + expect( + result.findings.some((finding) => /missing a local callback URL/.test(finding.message)), + ).toBe(true); + }); + + it("fails when configured public routing points at stale local callback URLs", async () => { + const result = await runDoctor( + makeConfig({ + phoneVoiceStack: "inkbox_tts_stt", + gateway: { + ...defaultGatewayConfig(), + publicUrl: "https://current.example", + }, + }), + { + runtime: healthyRuntime(), + opencode: reachableOpencode(), + print: () => {}, + }, + ); + expect(result.ok).toBe(false); + expect( + result.findings.some((finding) => /stale local callback URLs/.test(finding.message)), + ).toBe(true); + }); + + it("fails when Voice AI retains an obsolete media WebSocket route", async () => { + const runtime = healthyRuntime(); + const identity = await runtime.getIdentity(); + identity.getIncomingCallAction.mockResolvedValue({ + incomingCallAction: "hosted_agent", + clientWebsocketUrl: "wss://stale.example/ws", + incomingCallWebhookUrl: null, + }); + runtime.getIdentity.mockResolvedValue(identity); + const result = await runDoctor(makeConfig({ phoneVoiceStack: "inkbox_voice_ai" }), { + runtime, + opencode: reachableOpencode(), + print: () => {}, + }); + expect(result.ok).toBe(false); + expect( + result.findings.some((finding) => /obsolete local callback URLs/.test(finding.message)), + ).toBe(true); + }); + + it("fails when Voice AI retains an obsolete completion webhook route", async () => { + const runtime = healthyRuntime(); + const identity = await runtime.getIdentity(); + identity.getIncomingCallAction.mockResolvedValue({ + incomingCallAction: "hosted_agent", + clientWebsocketUrl: null, + incomingCallWebhookUrl: "https://agent.inkboxwire.com/webhook", + }); + runtime.getIdentity.mockResolvedValue(identity); + const result = await runDoctor(makeConfig({ phoneVoiceStack: "inkbox_voice_ai" }), { + runtime, + opencode: reachableOpencode(), + print: () => {}, + }); + expect(result.ok).toBe(false); + expect( + result.findings.some((finding) => /obsolete local callback URLs/.test(finding.message)), + ).toBe(true); + }); + it("names the source each credential resolved from, without leaking secrets", async () => { const { lines, print } = collect(); await runDoctor( @@ -188,7 +332,7 @@ describe("runDoctor", () => { }); it("calls out a shell export shadowing a different key in the wizard's env file", async () => { - // Dima's setup: the wizard saved a fresh key to the state-dir .env, but a + // A common setup: the wizard saved a fresh key to the state-dir .env, but a // stale shell export wins for every new process and the API 401s. const home = fs.mkdtempSync(path.join(os.tmpdir(), "inkbox-doctor-home-")); const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "inkbox-doctor-cwd-")); diff --git a/tests/cli/realtime-validation.test.ts b/tests/cli/realtime-validation.test.ts new file mode 100644 index 0000000..ee3d249 --- /dev/null +++ b/tests/cli/realtime-validation.test.ts @@ -0,0 +1,75 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { + type RealtimeSocketFactory, + validateOpenAIRealtime, +} from "../../src/cli/realtime-validation.js"; + +class FakeSocket extends EventEmitter { + sent: string[] = []; + close = vi.fn(); + terminate = vi.fn(); + + send(value: string): void { + this.sent.push(value); + } +} + +describe("OpenAI Realtime setup validation", () => { + it("opens a real Realtime endpoint and succeeds only after session.updated", async () => { + const socket = new FakeSocket(); + const factory = vi.fn(((url, options) => { + expect(url).toBe("wss://api.openai.com/v1/realtime?model=gpt-realtime-2"); + expect(options.headers.Authorization).toBe("Bearer sk-valid"); + queueMicrotask(() => { + socket.emit("open"); + socket.emit("message", Buffer.from(JSON.stringify({ type: "session.created" }))); + socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + }); + return socket; + }) as RealtimeSocketFactory); + + await expect( + validateOpenAIRealtime("sk-valid", "gpt-realtime-2", { socketFactory: factory }), + ).resolves.toEqual({ ok: true, detail: "OpenAI Realtime session update succeeded." }); + expect(JSON.parse(socket.sent[0])).toMatchObject({ + type: "session.update", + session: { type: "realtime", model: "gpt-realtime-2" }, + }); + }); + + it("reports a Realtime rejection and redacts the key from server errors", async () => { + const socket = new FakeSocket(); + const resultPromise = validateOpenAIRealtime("sk-secret", "gpt-realtime-2", { + socketFactory: () => { + queueMicrotask(() => { + socket.emit("open"); + socket.emit( + "message", + JSON.stringify({ + type: "error", + error: { code: "invalid_api_key", message: "Rejected sk-secret" }, + }), + ); + }); + return socket; + }, + }); + const result = await resultPromise; + expect(result).toEqual({ ok: false, detail: "invalid_api_key: Rejected ***" }); + expect(JSON.stringify(result)).not.toContain("sk-secret"); + }); + + it("fails within the bounded timeout when Realtime never responds", async () => { + const socket = new FakeSocket(); + const result = await validateOpenAIRealtime("sk-timeout", "gpt-realtime-2", { + timeoutMs: 5, + socketFactory: () => socket, + }); + expect(result).toEqual({ + ok: false, + detail: "Timed out waiting for an OpenAI Realtime session response.", + }); + expect(socket.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/cli/wizard.test.ts b/tests/cli/wizard.test.ts index 62c8b21..03d697a 100644 --- a/tests/cli/wizard.test.ts +++ b/tests/cli/wizard.test.ts @@ -24,6 +24,7 @@ afterEach(() => { function makeConfig(overrides?: Partial): ResolvedConfig { return { + voiceAiAuthorityMode: "contact_scoped", vaultKeyEnvVar: "INKBOX_VAULT_KEY", tools: { enable: [], disable: [] }, outbound: { allowedRecipients: [], approval: "auto", askTimeoutMs: 0 }, @@ -36,6 +37,8 @@ function makeConfig(overrides?: Partial): ResolvedConfig { function scriptedIO(answers: Array) { const queue = [...answers]; const lines: string[] = []; + const questions: string[] = []; + const choiceDefaults: number[] = []; const next = () => { if (queue.length === 0) throw new Error(`IO queue exhausted after: ${lines.at(-1)}`); return queue.shift(); @@ -44,11 +47,24 @@ function scriptedIO(answers: Array) { print: (line = "") => { lines.push(line); }, - ask: async () => String(next()), + ask: async (question) => { + questions.push(question); + return question.includes("Press Enter to continue and set up phone call handling") + ? "" + : String(next()); + }, confirm: async () => Boolean(next()), - choose: async () => Number(next()), + choose: async (question, _options, def) => { + choiceDefaults.push(def); + const answer = next(); + if (question.includes("Choose how this agent should handle phone calls")) { + if (answer === true) return 1; + if (answer === false) return 2; + } + return Number(answer); + }, }; - return { io, lines, queue }; + return { io, lines, questions, choiceDefaults, queue }; } interface FakeWorld { @@ -64,12 +80,15 @@ interface FakeWorld { resend: ReturnType; } -function fakeWorld(over: { phone?: unknown; imessageEnabled?: boolean } = {}): FakeWorld { +function fakeWorld( + over: { phone?: unknown; imessageEnabled?: boolean; tunnel?: unknown } = {}, +): FakeWorld { const identity: FakeWorld["identity"] = { agentHandle: "test-agent", emailAddress: "test-agent@inkboxmail.com", phoneNumber: over.phone ?? null, imessageEnabled: over.imessageEnabled ?? false, + tunnel: "tunnel" in over ? over.tunnel : { publicHost: "test-agent.inkboxwire.com" }, id: "id-1", update: vi.fn(async () => ({})), provisionPhoneNumber: vi.fn(async () => ({ @@ -78,6 +97,11 @@ function fakeWorld(over: { phone?: unknown; imessageEnabled?: boolean } = {}): F type: "local", })), createSigningKey: vi.fn(async () => ({ signingKey: "whsec_minted" })), + getHostedAgentConfig: vi.fn(async () => ({ authorityMode: "contact_scoped" })), + setHostedAgentConfig: vi.fn(async () => ({})), + setHostedAgentAuthorityMode: vi.fn(async () => ({})), + getIncomingCallAction: vi.fn(async () => ({ incomingCallAction: "auto_accept" })), + setIncomingCallAction: vi.fn(async () => ({})), }; const client = { whoami: vi.fn(async () => ({ @@ -120,7 +144,7 @@ function deps( env: {}, envFilePath: path.join(tmp, ".env"), sdk: () => world.sdk, - fetchFn: vi.fn(async () => ({ ok: true, status: 200 })) as unknown as typeof fetch, + realtimeValidatorFn: vi.fn(async () => ({ ok: true, detail: "session updated" })), installAutostartFn: vi.fn(async () => true), startDaemonFn: vi.fn(async () => 0), restartDaemonFn: vi.fn(async () => 0), @@ -192,13 +216,20 @@ describe("runWizard", () => { true, // autostart on boot ]); const d = deps(world, io, { env: { OPENAI_API_KEY: "sk-test" } }); - const code = await runWizard(makeConfig(), d); + const code = await runWizard( + makeConfig({ + callWebsocketUrl: "wss://outbound-only.example/audio", + gateway: { ...defaultGatewayConfig(), publicUrl: "https://test-agent.example" }, + }), + d, + ); expect(code).toBe(0); const saved = savedEnv(d.envFilePath); expect(saved.INKBOX_API_KEY).toBe("ApiKey_new"); expect(saved.INKBOX_IDENTITY).toBe("test-agent"); expect(saved.INKBOX_ALLOW_ALL_USERS).toBe("true"); + expect(saved.INKBOX_VOICE_STACK).toBe("openai_realtime"); expect(saved.INKBOX_REALTIME_ENABLED).toBe("true"); expect(saved.INKBOX_REALTIME_API_KEY).toBe("sk-test"); expect(saved.INKBOX_SIGNING_KEY).toBe("whsec_minted"); @@ -208,6 +239,11 @@ describe("runWizard", () => { expect(world.identity.update).toHaveBeenCalledWith({ imessageEnabled: true }); expect(world.identity.provisionPhoneNumber).toHaveBeenCalled(); + expect(world.identity.setIncomingCallAction).toHaveBeenCalledWith({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://test-agent.example/phone/media/ws", + incomingCallWebhookUrl: "https://test-agent.example/webhook", + }); expect(d.installAutostartFn).toHaveBeenCalledWith( expect.objectContaining({ projectDirectory: tmp }), ); @@ -310,6 +346,361 @@ describe("runWizard", () => { expect(savedEnv(d.envFilePath).INKBOX_API_KEY).toBe("ApiKey_scoped"); }); + it("reuses the initial admin credential to approve YOLO without asking twice", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + (world.identity.getHostedAgentConfig as ReturnType).mockResolvedValue({ + authorityMode: "contact_scoped", + voice: "cedar", + model: "voice-model", + instructions: "Keep the call concise.", + }); + (world.client.whoami as ReturnType).mockResolvedValue({ + authType: "api_key", + authSubtype: "api_key.admin_scoped", + organizationId: "org-1", + }); + const { io, questions } = scriptedIO([ + true, + "ApiKey_admin", + 0, // existing identity + false, // iMessage + 0, // Inkbox Voice AI + 1, // YOLO + false, // no signing key + true, // mint + "", + false, + false, + ]); + const d = deps(world, io); + fs.writeFileSync( + d.envFilePath, + [ + "INKBOX_REALTIME_API_KEY=sk-validated-existing", + "INKBOX_REALTIME_MODEL=gpt-realtime-2", + "INKBOX_REALTIME_VOICE=cedar", + "", + ].join("\n"), + { mode: 0o600 }, + ); + expect(await runWizard(makeConfig(), d)).toBe(0); + expect((world.identity as any).setHostedAgentAuthorityMode).toHaveBeenCalledWith({ + authorityMode: "yolo", + }); + expect((world.identity as any).setHostedAgentConfig).not.toHaveBeenCalled(); + expect((world.identity as any).setIncomingCallAction).toHaveBeenCalledWith({ + incomingCallAction: "hosted_agent", + clientWebsocketUrl: null, + incomingCallWebhookUrl: null, + }); + expect(savedEnv(d.envFilePath)).toMatchObject({ + INKBOX_VOICE_STACK: "inkbox_voice_ai", + INKBOX_VOICE_AI_AUTHORITY_MODE: "yolo", + INKBOX_REALTIME_API_KEY: "sk-validated-existing", + INKBOX_REALTIME_MODEL: "gpt-realtime-2", + INKBOX_REALTIME_VOICE: "cedar", + }); + expect(questions).not.toContain( + " Paste an admin-scoped Inkbox API key for this authority change", + ); + }); + + it("uses the existing valid stack as the rerun selector default", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + const { io, choiceDefaults } = scriptedIO([ + true, // reconfigure + true, + "ApiKey_agent", + false, + 0, // accept Voice AI selection + 0, // contact-scoped + false, + true, + "", + false, + false, + ]); + const d = deps(world, io); + expect( + await runWizard( + makeConfig({ + apiKey: "ApiKey_agent", + identity: "test-agent", + phoneVoiceStack: "inkbox_voice_ai", + }), + d, + ), + ).toBe(0); + expect(choiceDefaults[0]).toBe(0); + }); + + it("routes a fixed local stack immediately through the server-issued tunnel", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + const { io, lines } = scriptedIO([ + true, // reconfigure + true, // existing key + "ApiKey_agent", + false, // iMessage + 0, // Voice AI conflicts with the fixed TTS/STT option + 2, // choose the effective fixed option + false, // no signing key + true, // mint + "", + false, + false, + ]); + const d = deps(world, io); + expect( + await runWizard( + makeConfig({ + apiKey: "ApiKey_agent", + identity: "test-agent", + phoneVoiceStack: "inkbox_tts_stt", + phoneVoiceStackOption: "inkbox_tts_stt", + }), + d, + ), + ).toBe(0); + expect(lines.join("\n")).toContain( + "plugin option phoneVoiceStack=inkbox_tts_stt overrides saved environment selections", + ); + expect(savedEnv(d.envFilePath).INKBOX_VOICE_STACK).toBe("inkbox_tts_stt"); + expect(world.identity.setIncomingCallAction).toHaveBeenCalledWith({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://test-agent.inkboxwire.com/phone/media/ws", + incomingCallWebhookUrl: "https://test-agent.inkboxwire.com/webhook", + }); + }); + + it("routes OpenAI Realtime immediately through the server-issued tunnel", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + const { io } = scriptedIO([ + true, + "ApiKey_agent", + false, + 1, // OpenAI Realtime + false, + true, + "", + false, + false, + ]); + const d = deps(world, io, { env: { OPENAI_API_KEY: "sk-test" } }); + expect(await runWizard(makeConfig(), d)).toBe(0); + expect(world.identity.setIncomingCallAction).toHaveBeenCalledWith({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://test-agent.inkboxwire.com/phone/media/ws", + incomingCallWebhookUrl: "https://test-agent.inkboxwire.com/webhook", + }); + expect(savedEnv(d.envFilePath).INKBOX_VOICE_STACK).toBe("openai_realtime"); + }); + + it("prefers the configured gateway public URL over the server-issued tunnel", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + const { io } = scriptedIO([ + true, + "ApiKey_agent", + false, + 2, // Inkbox TTS/STT + false, + true, + "", + false, + false, + ]); + const d = deps(world, io); + expect( + await runWizard( + makeConfig({ + gateway: { + ...defaultGatewayConfig(), + publicUrl: "https://configured.example/some/path/", + }, + }), + d, + ), + ).toBe(0); + expect(world.identity.setIncomingCallAction).toHaveBeenCalledWith({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://configured.example/some/path/phone/media/ws", + incomingCallWebhookUrl: "https://configured.example/some/path/webhook", + }); + }); + + it("fails closed without saving a local stack when no tunnel public host is available", async () => { + const world = fakeWorld({ + phone: { id: "pn-1", number: "+15550001111", type: "local" }, + tunnel: null, + }); + const { io, lines } = scriptedIO([true, "ApiKey_agent", false, 2, 2, 2, 2, 2]); + const d = deps(world, io); + expect(await runWizard(makeConfig(), d)).toBe(1); + expect(world.identity.setIncomingCallAction).not.toHaveBeenCalled(); + expect(savedEnv(d.envFilePath).INKBOX_VOICE_STACK).toBeUndefined(); + expect(lines.join("\n")).toContain("did not return a server-issued tunnel public host"); + expect(lines.join("\n")).toContain("could not be configured after 5 attempts"); + }); + + it("fails closed without saving a local stack when the tunnel public host is malformed", async () => { + const world = fakeWorld({ + phone: { id: "pn-1", number: "+15550001111", type: "local" }, + tunnel: { publicHost: "ftp://test-agent.inkboxwire.com" }, + }); + const { io, lines } = scriptedIO([true, "ApiKey_agent", false, 2, 2, 2, 2, 2]); + const d = deps(world, io); + expect(await runWizard(makeConfig(), d)).toBe(1); + expect(world.identity.setIncomingCallAction).not.toHaveBeenCalled(); + expect(savedEnv(d.envFilePath).INKBOX_VOICE_STACK).toBeUndefined(); + expect(lines.join("\n")).toContain("returned an invalid tunnel public host"); + }); + + it("rejects a malformed configured public URL without falling back to the tunnel", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + const { io, lines } = scriptedIO([true, "ApiKey_agent", false, 2, 2, 2, 2, 2]); + const d = deps(world, io); + expect( + await runWizard( + makeConfig({ + gateway: { ...defaultGatewayConfig(), publicUrl: "configured.example" }, + }), + d, + ), + ).toBe(1); + expect(world.identity.setIncomingCallAction).not.toHaveBeenCalled(); + expect(savedEnv(d.envFilePath).INKBOX_VOICE_STACK).toBeUndefined(); + expect(lines.join("\n")).toContain("configured gateway.publicUrl is invalid"); + }); + + it("keeps an agent key on the saved contact-scoped default without asking for admin", async () => { + const world = fakeWorld({ + phone: { id: "pn-1", number: "+15550001111", type: "local" }, + tunnel: null, + }); + const { io, questions } = scriptedIO([ + true, + "ApiKey_agent", + false, + 0, // Voice AI + 0, // saved contact-scoped authority + false, + true, + "", + false, + false, + ]); + const d = deps(world, io); + expect(await runWizard(makeConfig(), d)).toBe(0); + expect((world.identity as any).setHostedAgentAuthorityMode).not.toHaveBeenCalled(); + expect(questions).not.toContain( + " Paste an admin-scoped Inkbox API key for this authority change", + ); + expect(savedEnv(d.envFilePath).INKBOX_VOICE_STACK).toBe("inkbox_voice_ai"); + }); + + it("rejects agent-key YOLO elevation and loops back to the stack selector", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + const { io, lines } = scriptedIO([ + true, + "ApiKey_agent", + false, + 0, // Voice AI + 1, // attempt YOLO + "ApiKey_still_agent", + 2, // rejected: choose TTS/STT instead + false, + true, + "", + false, + false, + ]); + const d = deps(world, io); + expect(await runWizard(makeConfig(), d)).toBe(0); + expect(lines.join("\n")).toContain("not an admin-scoped API key"); + expect((world.identity as any).setHostedAgentAuthorityMode).not.toHaveBeenCalled(); + expect(savedEnv(d.envFilePath).INKBOX_VOICE_STACK).toBe("inkbox_tts_stt"); + }); + + it("rolls authority and routing back when Voice AI routing fails without persisting selection", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + (world.client.whoami as ReturnType).mockResolvedValue({ + authType: "api_key", + authSubtype: "api_key.admin_scoped", + organizationId: "org-1", + }); + (world.identity.getIncomingCallAction as ReturnType).mockResolvedValue({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://old.example/phone/media/ws", + incomingCallWebhookUrl: "https://old.example/webhook", + }); + (world.identity.setIncomingCallAction as ReturnType) + .mockRejectedValueOnce(new Error("routing failed")) + .mockResolvedValueOnce({}); + const { io, lines } = scriptedIO([ + true, + "ApiKey_admin", + 0, + false, + 0, + 1, + 2, + false, + true, + "", + false, + false, + ]); + const d = deps(world, io); + expect(await runWizard(makeConfig(), d)).toBe(0); + expect(world.identity.setHostedAgentAuthorityMode).toHaveBeenNthCalledWith(1, { + authorityMode: "yolo", + }); + expect(world.identity.setHostedAgentAuthorityMode).toHaveBeenNthCalledWith(2, { + authorityMode: "contact_scoped", + }); + expect(world.identity.setIncomingCallAction).toHaveBeenNthCalledWith(2, { + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://old.example/phone/media/ws", + incomingCallWebhookUrl: "https://old.example/webhook", + }); + expect(savedEnv(d.envFilePath).INKBOX_VOICE_AI_AUTHORITY_MODE).toBeUndefined(); + expect(savedEnv(d.envFilePath).INKBOX_VOICE_STACK).toBe("inkbox_tts_stt"); + expect(lines.join("\n")).toContain("routing failed"); + }); + + it("preserves validated Realtime credentials when selecting a non-Realtime stack", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + const { io } = scriptedIO([ + true, + "ApiKey_agent", + false, // iMessage + 2, // Inkbox TTS/STT + false, // no signing key + true, // mint + "", + false, + false, + ]); + const d = deps(world, io); + fs.writeFileSync( + d.envFilePath, + [ + "INKBOX_REALTIME_API_KEY=sk-validated-existing", + "INKBOX_REALTIME_MODEL=gpt-realtime-2", + "INKBOX_REALTIME_VOICE=cedar", + "", + ].join("\n"), + { mode: 0o600 }, + ); + expect(await runWizard(makeConfig(), d)).toBe(0); + expect(savedEnv(d.envFilePath)).toMatchObject({ + INKBOX_VOICE_STACK: "inkbox_tts_stt", + INKBOX_REALTIME_ENABLED: "false", + INKBOX_REALTIME_API_KEY: "sk-validated-existing", + INKBOX_REALTIME_MODEL: "gpt-realtime-2", + INKBOX_REALTIME_VOICE: "cedar", + }); + }); + it("fails setup when no signing key is pasted or minted", async () => { const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); const { io } = scriptedIO([ @@ -326,11 +717,12 @@ describe("runWizard", () => { it("disables realtime when key validation fails", async () => { const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); - const { io } = scriptedIO([ + const { io, lines } = scriptedIO([ true, "ApiKey_agent", false, // iMessage no true, // use realtime + 2, // validation failed → choose Inkbox TTS/STT false, // have signing key? no true, // mint "", @@ -339,10 +731,101 @@ describe("runWizard", () => { ]); const d = deps(world, io, { env: { OPENAI_API_KEY: "sk-bad" }, - fetchFn: vi.fn(async () => ({ ok: false, status: 401 })) as unknown as typeof fetch, + realtimeValidatorFn: vi.fn(async () => ({ + ok: false, + detail: "HTTP 401 for sk-bad", + })), }); expect(await runWizard(makeConfig(), d)).toBe(0); - expect(savedEnv(d.envFilePath).INKBOX_REALTIME_ENABLED).toBe("false"); + expect(savedEnv(d.envFilePath)).toMatchObject({ + INKBOX_REALTIME_ENABLED: "false", + INKBOX_VOICE_STACK: "inkbox_tts_stt", + }); + expect(savedEnv(d.envFilePath).INKBOX_REALTIME_API_KEY).toBeUndefined(); + expect(world.identity.setIncomingCallAction).toHaveBeenCalledWith({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://test-agent.inkboxwire.com/phone/media/ws", + incomingCallWebhookUrl: "https://test-agent.inkboxwire.com/webhook", + }); + expect(lines.join("\n")).not.toContain("sk-bad"); + }); + + it("lets the user replace a detected Realtime key after the handshake rejects it", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + const { io } = scriptedIO([ + true, + "ApiKey_agent", + false, + 1, + 1, + "sk-good", + false, + true, + "", + false, + false, + ]); + const validator = vi + .fn() + .mockResolvedValueOnce({ ok: false, detail: "invalid_api_key" }) + .mockResolvedValueOnce({ ok: true, detail: "session updated" }); + const d = deps(world, io, { + env: { OPENAI_API_KEY: "sk-stale" }, + realtimeValidatorFn: validator, + }); + expect(await runWizard(makeConfig(), d)).toBe(0); + expect(validator).toHaveBeenNthCalledWith(1, "sk-stale", "gpt-realtime-2"); + expect(validator).toHaveBeenNthCalledWith(2, "sk-good", "gpt-realtime-2"); + expect(savedEnv(d.envFilePath)).toMatchObject({ + INKBOX_VOICE_STACK: "openai_realtime", + INKBOX_REALTIME_API_KEY: "sk-good", + INKBOX_REALTIME_ENABLED: "true", + }); + }); + + it("reuses a newly validated admin identity after a failed Voice AI routing attempt", async () => { + const world = fakeWorld({ phone: { id: "pn-1", number: "+15550001111", type: "local" } }); + (world.client.whoami as ReturnType) + .mockResolvedValueOnce({ + authType: "api_key", + authSubtype: "api_key.agent_scoped.claimed", + organizationId: "org-1", + }) + .mockResolvedValueOnce({ + authType: "api_key", + authSubtype: "api_key.admin_scoped", + organizationId: "org-1", + }); + (world.identity.setIncomingCallAction as ReturnType).mockRejectedValueOnce( + new Error("routing failed"), + ); + const { io, questions } = scriptedIO([ + true, + "ApiKey_agent", + false, + 0, + 1, + "ApiKey_admin", + 0, + 1, + false, + true, + "", + false, + false, + ]); + const d = deps(world, io); + expect(await runWizard(makeConfig(), d)).toBe(0); + expect( + questions.filter( + (question) => + question === " Paste an admin-scoped Inkbox API key for this authority change", + ), + ).toHaveLength(1); + expect(savedEnv(d.envFilePath)).toMatchObject({ + INKBOX_VOICE_STACK: "inkbox_voice_ai", + INKBOX_VOICE_AI_AUTHORITY_MODE: "yolo", + }); }); it("warns when a differing shell export will shadow the saved key", async () => { diff --git a/tests/contract/live-harness.test.ts b/tests/contract/live-harness.test.ts new file mode 100644 index 0000000..0861d0a --- /dev/null +++ b/tests/contract/live-harness.test.ts @@ -0,0 +1,72 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const liveAut = readFileSync("scripts/live-aut.sh", "utf8"); +const liveChannels = readFileSync(".github/workflows/live-channels.yml", "utf8"); +const liveVoice = readFileSync(".github/workflows/live-voice.yml", "utf8"); + +function shellCommands(source: string): string[] { + return source.replace(/\\\n\s*/g, " ").split("\n"); +} + +function yamlJob(source: string, name: string): string { + const lines = source.split("\n"); + const start = lines.indexOf(` ${name}:`); + if (start < 0) return ""; + const nextJob = lines.findIndex((line, index) => index > start && /^ {2}\S[^:]*:$/.test(line)); + return lines.slice(start, nextJob < 0 ? undefined : nextJob).join("\n"); +} + +describe("live harness readiness bounds", () => { + it("bounds both opencode /config readiness probes", () => { + const configProbes = shellCommands(liveAut).filter( + (command) => /\bcurl\b/.test(command) && command.includes("/config"), + ); + const boundedConfigProbes = configProbes.filter((command) => + command.includes("curl -sf --connect-timeout 1 --max-time 3"), + ); + + expect(configProbes).toHaveLength(2); + expect(boundedConfigProbes).toHaveLength(configProbes.length); + }); + + it("bounds the channel mock-model readiness probe", () => { + const modelProbes = shellCommands(liveChannels).filter( + (command) => /\bcurl\b/.test(command) && command.includes("/v1/models"), + ); + const boundedModelProbes = modelProbes.filter((command) => + command.includes("curl -sf --connect-timeout 1 --max-time 3"), + ); + + expect(modelProbes).toHaveLength(1); + expect(boundedModelProbes).toHaveLength(modelProbes.length); + }); + + it("caps the live-channel matrix job at twenty-five minutes", () => { + const liveJob = yamlJob(liveChannels, "live"); + + expect(liveJob).toContain("matrix:"); + expect(liveJob).toMatch(/^ {4}timeout-minutes: 25$/m); + }); + + it("caps every voice matrix job at fifteen minutes", () => { + const voiceJob = yamlJob(liveVoice, "voice"); + + expect(voiceJob).toContain("matrix:"); + expect(voiceJob).toMatch(/^ {4}timeout-minutes: 15$/m); + }); + + it("requires the hosted caller to persist and read back the exact SMS body", () => { + expect(liveVoice).toContain( + 'export VOICE_DRIVER_LINE="After we hang up, send me one SMS. Create the post-call action now with this exact SMS body: $HOSTED_MARKER. Read those five words back to me after the action is saved. Do not send it during the call."', + ); + }); + + it("preserves diagnostics when the voice job is cancelled by its timeout", () => { + expect(liveVoice.match(/if: failure\(\) \|\| cancelled\(\)/g)).toHaveLength(2); + }); + + it("preserves diagnostics when the live-channel job is cancelled by its timeout", () => { + expect(liveChannels.match(/if: failure\(\) \|\| cancelled\(\)/g)).toHaveLength(2); + }); +}); diff --git a/tests/gateway/delivery-policy.test.ts b/tests/gateway/delivery-policy.test.ts new file mode 100644 index 0000000..cd7bac4 --- /dev/null +++ b/tests/gateway/delivery-policy.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + classifyDeliveryFailure, + deliveryFailureKey, + deliveryFailureRecovery, + resetDeliveryPolicyForTest, +} from "../../src/gateway/delivery-policy.js"; + +beforeEach(resetDeliveryPolicyForTest); + +describe("delivery failure retry policy", () => { + it("requires correction only when the failure is both first and retryable", () => { + const first = deliveryFailureRecovery({ + key: "sms:target:1", + channel: "sms", + target: "+1", + failure: "content policy: markdown", + failedBody: "**hello**", + }); + expect(first).toMatchObject({ attempt: 1, classification: "retryable", mandatory: true }); + expect(first.prompt).toContain("MUST send exactly one"); + expect(first.prompt).toContain("Do not return [SILENT]"); + + const second = deliveryFailureRecovery({ + key: "sms:target:1", + channel: "sms", + target: "+1", + failure: "content policy: markdown", + }); + expect(second).toMatchObject({ attempt: 2, classification: "retryable", mandatory: false }); + expect(second.prompt).toContain("reply exactly [SILENT]"); + }); + + it("never mandates a terminal or ambiguous first failure", () => { + const terminal = deliveryFailureRecovery({ + key: "sms:target:2", + channel: "sms", + failure: "recipient opted out", + }); + const unknown = deliveryFailureRecovery({ + key: "sms:target:3", + channel: "sms", + failure: "connection closed after request", + }); + expect(terminal.mandatory).toBe(false); + expect(terminal.prompt).toContain("Do not retry"); + expect(unknown.mandatory).toBe(false); + expect(unknown.prompt).toContain("unlikely to duplicate"); + }); + + it("caps the loop after three failed sends", () => { + for (let i = 0; i < 2; i++) { + expect( + deliveryFailureRecovery({ key: "k", channel: "sms", failure: "spam" }).prompt, + ).toBeDefined(); + } + expect( + deliveryFailureRecovery({ key: "k", channel: "sms", failure: "spam" }).prompt, + ).toBeUndefined(); + }); + + it("lets terminal safety signals win over broad content markers", () => { + expect(classifyDeliveryFailure("unsafe content blocked")).toBe("terminal"); + }); + + it("does not blindly retry an ambiguous server error that only mentions Content-Type", () => { + const result = deliveryFailureRecovery({ + key: "sms:target:4", + channel: "sms", + target: "+14155550123", + failure: "HTTP 500 while parsing Content-Type", + }); + expect(result).toMatchObject({ classification: "unknown", mandatory: false }); + }); + + it("keeps a stable fallback key when a malformed phone target has no digits", () => { + expect(deliveryFailureKey("sms", "unknown-target")).toBe("sms:target:unknown-target"); + }); +}); diff --git a/tests/gateway/dispatch.test.ts b/tests/gateway/dispatch.test.ts index 3d269b5..32fe0e8 100644 --- a/tests/gateway/dispatch.test.ts +++ b/tests/gateway/dispatch.test.ts @@ -452,3 +452,26 @@ describe("dispatchEvent external providers", () => { expect(deps.sessions.handleInbound).not.toHaveBeenCalled(); }); }); + +describe("dispatchEvent call completion", () => { + it("routes call.ended to hosted settlement and acknowledges the webhook", async () => { + const onHostedCallEnded = vi.fn(async () => {}); + const deps = makeDeps({ onHostedCallEnded }); + const ended: VerifiedEvent = { + provider: "inkbox", + verified: true, + eventType: "call.ended", + requestId: "req-call-ended", + body: { + id: "evt-call-ended", + event_type: "call.ended", + data: { call: { id: "call-1", mode: "hosted_agent" } }, + }, + headers: {}, + }; + + expect(await dispatchEvent(deps, ended)).toBe(true); + expect(onHostedCallEnded).toHaveBeenCalledOnce(); + expect(onHostedCallEnded).toHaveBeenCalledWith(ended.body); + }); +}); diff --git a/tests/gateway/hosted-call-completion.test.ts b/tests/gateway/hosted-call-completion.test.ts new file mode 100644 index 0000000..3d185f7 --- /dev/null +++ b/tests/gateway/hosted-call-completion.test.ts @@ -0,0 +1,573 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { CallEndedWebhookPayload } from "@inkbox/sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createHostedCallCompletion, + hasHostedSmsCommitment, +} from "../../src/gateway/hosted-call-completion.js"; +import { + getHostedCall, + type HostedSmsAttempt, + saveHostedCall, +} from "../../src/gateway/hosted-call-registry.js"; +import { HostedCaptureDeferredError } from "../../src/gateway/types.js"; + +let dir: string; + +function event(overrides: Record = {}): CallEndedWebhookPayload { + return { + id: "evt-1", + event_type: "call.ended", + timestamp: "2026-08-01T00:00:00Z", + data: { + call: { + id: "call-1", + mode: "hosted_agent", + direction: "inbound", + status: "completed", + remote_phone_number: "+14155550123", + local_phone_number: "+14155550124", + ...overrides, + }, + outcome: "completed", + contacts: [], + agent_identities: [], + transcript: { entries: [] }, + transcript_url: null, + post_call_action_items: [ + { id: "a-1", action: "Text me the phonetic marker after this call ends", status: "open" }, + ], + }, + } as unknown as CallEndedWebhookPayload; +} + +async function waitForState(state: string): Promise { + for (let i = 0; i < 100; i++) { + if (getHostedCall("ident-1", "call-1")?.state === state) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`hosted call did not reach ${state}`); +} + +function deps(attempts: Array) { + const runHostedCapture = vi.fn(async () => ({ attempt: attempts.shift() })); + const callsGet = vi.fn(async () => authoritativeCall); + const authoritativeCall = { + id: "call-1", + mode: "hosted_agent", + direction: "inbound", + status: "completed", + remotePhoneNumber: "+14155550123", + reason: null, + hangupReason: "local", + postCallActionItems: [ + { id: "a-1", action: "Text me the phonetic marker after this call ends", status: "open" }, + ], + }; + return { + inkbox: { + getIdentity: vi.fn(async () => ({ + id: "ident-1", + listTranscripts: vi.fn(async () => [ + { party: "remote", text: "Please text me the phonetic marker when this call ends." }, + ]), + })), + getClient: vi.fn(async () => ({ calls: { get: callsGet } })), + }, + contacts: { + resolve: vi.fn(async () => ({ + contactId: "contact-wrong", + contactName: "Wrong Contact", + contactPhones: ["+15550009999"], + })), + chatKeyFor: vi.fn( + (input: { contactId?: string; from: string }) => input.contactId ?? input.from, + ), + }, + sessions: { runHostedCapture, runText: vi.fn(async () => undefined) }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + sleep: vi.fn(async () => {}), + runHostedCapture, + callsGet, + authoritativeCall, + } as any; +} + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-hosted-completion-")); + process.env.INKBOX_OPENCODE_HOME = dir; +}); + +afterEach(() => { + delete process.env.INKBOX_OPENCODE_HOME; + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("hosted call completion", () => { + it("rejects a corrupt durable journal before dispatching any reconciliation", async () => { + fs.writeFileSync(path.join(dir, "hosted-call-completions.json"), "{broken", { mode: 0o600 }); + const d = deps([]); + await expect(createHostedCallCompletion(d).ingest(event())).rejects.toThrow(); + expect(d.runHostedCapture).not.toHaveBeenCalled(); + }); + + it("does not turn a caller's third-party texting plan into an agent commitment", () => { + expect( + hasHostedSmsCommitment( + "When this call ends, I will text my wife the release address.", + "transcript", + ), + ).toBe(false); + expect( + hasHostedSmsCommitment("After we hang up, text me the release address.", "transcript"), + ).toBe(true); + }); + + it("uses the call record's authoritative remote number despite conflicting contact data", async () => { + const d = deps([ + { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + ]); + const service = createHostedCallCompletion(d); + await service.ingest(event()); + await waitForState("completed"); + const [chatKey, prompt, capture] = d.runHostedCapture.mock.calls[0]; + expect(chatKey).toContain("contact-wrong"); + expect(prompt).toContain("from=+14155550123"); + expect(prompt).toContain('to="+14155550123"'); + expect(capture.expectedTarget).toBe("+14155550123"); + expect(prompt).not.toContain('to="+15550009999"'); + expect(d.contacts.chatKeyFor).toHaveBeenCalledWith({ + contactId: "contact-wrong", + channel: "imessage", + from: "+14155550123", + }); + }); + + it("retries transient preparation failures in-process before dispatch", async () => { + const d = deps([ + { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + ]); + d.inkbox.getClient + .mockRejectedValueOnce(new Error("temporary API outage")) + .mockRejectedValueOnce(new Error("temporary API outage")); + await createHostedCallCompletion(d).ingest(event()); + await waitForState("completed"); + expect(d.inkbox.getClient).toHaveBeenCalledTimes(3); + expect(d.sleep).toHaveBeenNthCalledWith(1, 250); + expect(d.sleep).toHaveBeenNthCalledWith(2, 1_000); + expect(d.runHostedCapture).toHaveBeenCalledOnce(); + }); + + it("acknowledges a duplicate webhook without disturbing the in-flight settlement", async () => { + const d = deps([]); + let finish: (value: unknown) => void = () => {}; + d.runHostedCapture.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const service = createHostedCallCompletion(d); + await service.ingest(event()); + await waitForState("running"); + await service.ingest(event()); + expect(getHostedCall("ident-1", "call-1")?.state).toBe("running"); + finish({ + attempt: { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + }); + await waitForState("completed"); + expect(d.runHostedCapture).toHaveBeenCalledOnce(); + }); + + it("issues one mandatory correction when the initial turn makes no attempt", async () => { + const d = deps([ + undefined, + { + phase: "correction", + id: "attempt-2", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + ]); + await createHostedCallCompletion(d).ingest(event()); + await waitForState("completed"); + expect(d.runHostedCapture).toHaveBeenCalledTimes(2); + const correction = d.runHostedCapture.mock.calls[1][1]; + expect(correction).toContain("only mandatory correction attempt"); + expect(correction).toContain("Do not return [SILENT]"); + expect(correction).toContain("Do not execute any non-SMS"); + }); + + it("does not retry an ambiguous provider outcome", async () => { + const d = deps([ + { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "failed", + errorKind: "ambiguous_provider_failure", + }, + ]); + await createHostedCallCompletion(d).ingest(event()); + await waitForState("failed"); + expect(d.runHostedCapture).toHaveBeenCalledTimes(1); + expect(getHostedCall("ident-1", "call-1")?.retryable).toBe(false); + }); + + it("recognizes an after-call SMS promise split across adjacent caller segments", async () => { + const d = deps([ + { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + ]); + d.inkbox.getIdentity.mockResolvedValue({ + id: "ident-1", + listTranscripts: vi.fn(async () => [ + { party: "remote", text: "After we hang up" }, + { party: "remote", text: "send me an SMS with the marker" }, + ]), + }); + d.authoritativeCall.postCallActionItems = []; + await createHostedCallCompletion(d).ingest(event()); + await waitForState("completed"); + expect(d.runHostedCapture).toHaveBeenCalledOnce(); + expect(d.runHostedCapture.mock.calls[0][1]).toContain("After we hang up"); + }); + + it("does not create hosted reconciliation work for a local audio call", async () => { + const d = deps([]); + await createHostedCallCompletion(d).ingest(event({ mode: "client_websocket" })); + expect(d.runHostedCapture).not.toHaveBeenCalled(); + expect(getHostedCall("ident-1", "call-1")).toBeUndefined(); + }); + + it("rejects a stale hosted webhook when the authoritative call is local audio", async () => { + const d = deps([]); + d.authoritativeCall.mode = "client_websocket"; + await createHostedCallCompletion(d).ingest(event()); + await waitForState("failed"); + expect(d.runHostedCapture).not.toHaveBeenCalled(); + expect(getHostedCall("ident-1", "call-1")).toMatchObject({ + outcome: "authoritative_call_is_not_hosted", + retryable: false, + }); + }); + + it("does not persist the raw call transcript in the durable replay journal", async () => { + const d = deps([ + { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + ]); + const withSecretTranscript = event() as any; + withSecretTranscript.data.transcript.entries = [ + { party: "remote", text: "raw transcript secret must not be journaled" }, + ]; + withSecretTranscript.data.call.reason = "private call reason must not be journaled"; + withSecretTranscript.data.post_call_action_items = [ + { action: "private action details must not be journaled", status: "open" }, + ]; + await createHostedCallCompletion(d).ingest(withSecretTranscript); + await waitForState("completed"); + const journal = fs.readFileSync(path.join(dir, "hosted-call-completions.json"), "utf8"); + expect(journal).not.toContain("raw transcript secret must not be journaled"); + expect(journal).not.toContain("private call reason must not be journaled"); + expect(journal).not.toContain("private action details must not be journaled"); + }); + + it("defers a correction interrupted by shutdown and completes it after catch-up", async () => { + const d = deps([undefined]); + d.runHostedCapture + .mockResolvedValueOnce({ attempt: undefined }) + .mockRejectedValueOnce(new HostedCaptureDeferredError()) + .mockResolvedValueOnce({ + attempt: { + phase: "correction", + id: "attempt-2", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + }); + const service = createHostedCallCompletion(d); + await service.ingest(event()); + await waitForState("failed"); + expect(getHostedCall("ident-1", "call-1")).toMatchObject({ + outcome: "correction_deferred_for_shutdown", + retryable: true, + }); + await service.catchUp(); + await waitForState("completed"); + expect(d.runHostedCapture).toHaveBeenCalledTimes(3); + }); + + it("resumes correction after its preparation retries were exhausted", async () => { + saveHostedCall({ + identityId: "ident-1", + callId: "call-1", + eventId: "evt-1", + state: "failed", + outcome: "correction_pre_dispatch_retries_exhausted", + retryable: true, + event: event(), + }); + const d = deps([ + { + phase: "correction", + id: "attempt-2", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + ]); + await createHostedCallCompletion(d).catchUp(); + await waitForState("completed"); + expect(d.runHostedCapture).toHaveBeenCalledOnce(); + expect(d.runHostedCapture.mock.calls[0][2].phase).toBe("correction"); + expect(d.runHostedCapture.mock.calls[0][1]).toContain("only mandatory correction attempt"); + }); + + it("does not repeat a correction whose dispatch outcome became ambiguous", async () => { + const d = deps([]); + d.runHostedCapture + .mockResolvedValueOnce({ attempt: undefined }) + .mockRejectedValueOnce(new Error("session transport closed during prompt")); + const service = createHostedCallCompletion(d); + await service.ingest(event()); + await waitForState("failed"); + expect(getHostedCall("ident-1", "call-1")).toMatchObject({ + outcome: "correction_dispatch_outcome_ambiguous", + retryable: false, + }); + await service.catchUp(); + expect(d.runHostedCapture).toHaveBeenCalledTimes(2); + }); + + it("restarts an initial turn only when shutdown deferred it before execution", async () => { + const d = deps([]); + d.runHostedCapture + .mockRejectedValueOnce(new HostedCaptureDeferredError()) + .mockResolvedValueOnce({ + attempt: { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + }); + const service = createHostedCallCompletion(d); + await service.ingest(event()); + await waitForState("failed"); + expect(getHostedCall("ident-1", "call-1")).toMatchObject({ + outcome: "initial_deferred_for_shutdown", + retryable: true, + }); + await service.catchUp(); + await waitForState("completed"); + expect(d.runHostedCapture).toHaveBeenCalledTimes(2); + }); + + it("does not replay an initial turn whose execution outcome is ambiguous", async () => { + const d = deps([]); + d.runHostedCapture.mockRejectedValueOnce(new Error("session transport closed during prompt")); + const service = createHostedCallCompletion(d); + await service.ingest(event()); + await waitForState("failed"); + expect(getHostedCall("ident-1", "call-1")).toMatchObject({ + outcome: "initial_dispatch_outcome_ambiguous", + retryable: false, + }); + await service.catchUp(); + expect(d.runHostedCapture).toHaveBeenCalledOnce(); + }); + + it("settles completed after restart when the successful provider result was already journaled", async () => { + saveHostedCall({ + identityId: "ident-1", + callId: "call-1", + eventId: "evt-1", + state: "running", + outcome: "initial_dispatch_started", + retryable: false, + event: event(), + smsAttempts: [ + { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + ], + }); + const d = deps([]); + await createHostedCallCompletion(d).catchUp(); + expect(getHostedCall("ident-1", "call-1")).toMatchObject({ + state: "completed", + outcome: "success", + retryable: false, + }); + expect(d.runHostedCapture).not.toHaveBeenCalled(); + }); + + it.each([ + ["queued before initial dispatch", { state: "queued" }, "initial", "completed", "success"], + [ + "retryable initial preparation failure", + { state: "failed", outcome: "initial_pre_dispatch_retries_exhausted", retryable: true }, + "initial", + "completed", + "success", + ], + [ + "correctable initial provider result", + { + state: "running", + smsAttempts: [ + { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "failed", + errorKind: "content_rejected", + }, + ], + }, + "correction", + "completed", + "success", + ], + [ + "retryable correction preparation failure", + { state: "failed", outcome: "correction_pre_dispatch_retries_exhausted", retryable: true }, + "correction", + "completed", + "success", + ], + [ + "initial dispatch ambiguous boundary", + { state: "running", outcome: "initial_dispatch_started" }, + undefined, + "failed", + "durable_sms_attempt_is_ambiguous", + ], + [ + "correction dispatch ambiguous boundary", + { state: "running", outcome: "correction_started" }, + undefined, + "failed", + "durable_sms_attempt_is_ambiguous", + ], + [ + "pending provider attempt", + { + state: "running", + smsAttempts: [ + { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "pending", + }, + ], + }, + undefined, + "failed", + "durable_sms_attempt_is_ambiguous", + ], + [ + "successful provider result", + { + state: "running", + smsAttempts: [ + { + phase: "initial", + id: "attempt-1", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + ], + }, + undefined, + "completed", + "success", + ], + ] as const)( + "recovers safely after restart at the %s transition", + async (_label, snapshot, expectedPhase, expectedState, expectedOutcome) => { + const smsAttempts: HostedSmsAttempt[] | undefined = + "smsAttempts" in snapshot + ? snapshot.smsAttempts.map((attempt) => ({ ...attempt })) + : undefined; + saveHostedCall({ + identityId: "ident-1", + callId: "call-1", + eventId: "evt-1", + retryable: false, + event: event(), + ...snapshot, + smsAttempts, + }); + const d = deps( + expectedPhase + ? [ + { + phase: expectedPhase, + id: "attempt-after-restart", + target: "+14155550123", + targetMatches: true, + state: "success", + }, + ] + : [], + ); + await createHostedCallCompletion(d).catchUp(); + await waitForState(expectedState); + expect(getHostedCall("ident-1", "call-1")).toMatchObject({ + state: expectedState, + outcome: expectedOutcome, + retryable: false, + }); + if (expectedPhase) { + expect(d.runHostedCapture).toHaveBeenCalledOnce(); + expect(d.runHostedCapture.mock.calls[0][2].phase).toBe(expectedPhase); + } else { + expect(d.runHostedCapture).not.toHaveBeenCalled(); + } + }, + ); +}); diff --git a/tests/gateway/hosted-call-registry.test.ts b/tests/gateway/hosted-call-registry.test.ts new file mode 100644 index 0000000..b4c8745 --- /dev/null +++ b/tests/gateway/hosted-call-registry.test.ts @@ -0,0 +1,277 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { CallEndedWebhookPayload } from "@inkbox/sdk"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + activateHostedSmsCapture, + assertHostedCallTarget, + assertHostedToolAllowed, + beginHostedSmsAttempt, + classifyHostedSmsError, + clearHostedSmsCapture, + getHostedCall, + HOSTED_REGISTRY_DIRECTORY_MODE, + HOSTED_REGISTRY_FILE_MODE, + saveHostedCall, + settleHostedSmsAttempt, +} from "../../src/gateway/hosted-call-registry.js"; + +let dir: string; + +function event(): CallEndedWebhookPayload { + return { + id: "evt-1", + event_type: "call.ended", + timestamp: "2026-08-01T00:00:00Z", + data: { + call: { + id: "call-1", + mode: "hosted_agent", + direction: "inbound", + status: "completed", + remote_phone_number: "+14155550123", + }, + outcome: "completed", + contacts: [], + agent_identities: [], + transcript: { entries: [] }, + transcript_url: null, + post_call_action_items: [], + }, + } as unknown as CallEndedWebhookPayload; +} + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-hosted-")); + process.env.INKBOX_OPENCODE_HOME = dir; + saveHostedCall({ + identityId: "ident-1", + callId: "call-1", + eventId: "evt-1", + state: "running", + event: event(), + }); + activateHostedSmsCapture({ + identityId: "ident-1", + callId: "call-1", + sessionID: "session-1", + phase: "initial", + expectedTarget: "+14155550123", + }); +}); + +afterEach(() => { + delete process.env.INKBOX_OPENCODE_HOME; + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("hosted SMS durable guard", () => { + it("fails closed on corrupt or non-object journal contents", () => { + const file = path.join(dir, "hosted-call-completions.json"); + fs.writeFileSync(file, "{not-json", { mode: 0o600 }); + expect(() => getHostedCall("ident-1", "call-1")).toThrow(); + fs.writeFileSync(file, "[]\n", { mode: 0o600 }); + expect(() => getHostedCall("ident-1", "call-1")).toThrow("must contain a JSON object"); + fs.writeFileSync(file, '{"bad":42}\n', { mode: 0o600 }); + expect(() => getHostedCall("ident-1", "call-1")).toThrow("contains an invalid entry"); + }); + + it("fails closed when the journal path cannot be read as a file", () => { + const file = path.join(dir, "hosted-call-completions.json"); + fs.unlinkSync(file); + fs.mkdirSync(file); + expect(() => getHostedCall("ident-1", "call-1")).toThrow(); + }); + + it("keeps its directory, journal, temp, and lock private", () => { + fs.chmodSync(dir, 0o755); + clearHostedSmsCapture("ident-1", "call-1"); + expect(HOSTED_REGISTRY_DIRECTORY_MODE).toBe(0o700); + expect(HOSTED_REGISTRY_FILE_MODE).toBe(0o600); + expect(fs.statSync(dir).mode & 0o777).toBe(HOSTED_REGISTRY_DIRECTORY_MODE); + expect(fs.statSync(path.join(dir, "hosted-call-completions.json")).mode & 0o777).toBe( + HOSTED_REGISTRY_FILE_MODE, + ); + }); + + it("fails closed immediately while another process owns the journal lock", () => { + const lock = path.join(dir, "hosted-call-completions.json.lock"); + fs.writeFileSync(lock, "99999\n"); + const started = Date.now(); + expect(() => clearHostedSmsCapture("ident-1", "call-1")).toThrow("hosted-call journal is busy"); + expect(Date.now() - started).toBeLessThan(100); + fs.unlinkSync(lock); + }); + + it("recovers a stale journal lock before mutating", () => { + const lock = path.join(dir, "hosted-call-completions.json.lock"); + fs.writeFileSync(lock, "99999\n"); + const stale = new Date(Date.now() - 61_000); + fs.utimesSync(lock, stale, stale); + expect(() => clearHostedSmsCapture("ident-1", "call-1")).not.toThrow(); + expect(fs.existsSync(lock)).toBe(false); + }); + + it("journals the exact target before it can be settled successful", () => { + const guard = beginHostedSmsAttempt({ + sessionID: "session-1", + messageId: "message-1", + target: "+14155550123", + hasConversationId: false, + }); + expect(guard).toBeDefined(); + expect(getHostedCall("ident-1", "call-1")?.smsAttempts[0]).toMatchObject({ + target: "+14155550123", + targetMatches: true, + state: "pending", + messageId: "message-1", + }); + if (!guard) throw new Error("expected hosted SMS guard"); + settleHostedSmsAttempt(guard, "success"); + expect(getHostedCall("ident-1", "call-1")?.smsAttempts[0].state).toBe("success"); + }); + + it("blocks a contact-derived wrong number before provider work", () => { + expect(() => + beginHostedSmsAttempt({ + sessionID: "session-1", + target: "+15550009999", + hasConversationId: false, + }), + ).toThrow("non-authoritative"); + expect(getHostedCall("ident-1", "call-1")?.smsAttempts[0]).toMatchObject({ + targetMatches: false, + state: "failed", + }); + }); + + it("blocks conversation addressing and a second provider attempt", () => { + expect(() => + beginHostedSmsAttempt({ + sessionID: "session-1", + hasConversationId: true, + }), + ).toThrow("non-authoritative"); + expect(() => + beginHostedSmsAttempt({ + sessionID: "session-1", + target: "+14155550123", + hasConversationId: false, + }), + ).toThrow("second SMS attempt"); + }); + + it("does not apply one call's settlement guard to an unrelated session", () => { + const lock = path.join(dir, "hosted-call-completions.json.lock"); + fs.writeFileSync(lock, "another-owner\n"); + expect( + beginHostedSmsAttempt({ + sessionID: "unrelated-session", + target: "+14155550123", + hasConversationId: false, + }), + ).toBeUndefined(); + fs.unlinkSync(lock); + clearHostedSmsCapture("ident-1", "call-1"); + expect( + beginHostedSmsAttempt({ + sessionID: "ordinary-session", + target: "+15550000000", + hasConversationId: false, + }), + ).toBeUndefined(); + }); + + it("blocks a matching session while its recent capture owner is gone", () => { + saveHostedCall({ + identityId: "ident-1", + callId: "call-1", + eventId: "evt-1", + state: "running", + event: event(), + active: { + sessionID: "session-1", + phase: "initial", + expectedTarget: "+14155550123", + ownerPid: Number.MAX_SAFE_INTEGER, + startedAt: Date.now(), + }, + }); + expect(() => + beginHostedSmsAttempt({ + sessionID: "session-1", + target: "+14155550123", + hasConversationId: false, + }), + ).toThrow("gateway owner is unavailable"); + }); + + it("expires an abandoned capture marker instead of blocking a session forever", () => { + saveHostedCall({ + identityId: "ident-1", + callId: "call-1", + eventId: "evt-1", + state: "running", + event: event(), + active: { + sessionID: "session-1", + phase: "initial", + expectedTarget: "+14155550123", + ownerPid: Number.MAX_SAFE_INTEGER, + startedAt: Date.now() - 2 * 60 * 60 * 1000, + }, + }); + expect( + beginHostedSmsAttempt({ + sessionID: "session-1", + target: "+14155550123", + hasConversationId: false, + }), + ).toBeUndefined(); + }); + + it("classifies a server 422 as a correctable pre-send rejection", () => { + expect(classifyHostedSmsError("Validation error (422): text format rejected")).toBe( + "pre_send_validation", + ); + }); + + it.each([ + ["request timeout", "Inkbox API error (408): request timed out"], + ["rate limit", "Inkbox API error (429): carrier rate limit"], + ["upstream outage", "Inkbox API error (503): carrier unavailable"], + ["unknown duplicate commit", "duplicate request with unknown commit status"], + ])("classifies a commit-ambiguous %s as terminal settlement", (_label, message) => { + expect(classifyHostedSmsError(message)).toBe("ambiguous_provider_failure"); + }); + + it.each([ + ["missing consent", "Recipient is not opted in for SMS"], + ["revoked consent", "Recipient opted out of SMS"], + ["invalid carrier destination", "Carrier says invalid phone number"], + ])("classifies %s as a terminal recipient failure", (_label, message) => { + expect(classifyHostedSmsError(message)).toBe("recipient_terminal"); + }); + + it("allows a hosted callback only to the authoritative current caller", () => { + expect(assertHostedCallTarget("session-1", "+14155550123")).toBe(true); + expect(() => assertHostedCallTarget("session-1", "+15550009999")).toThrow("non-authoritative"); + }); + + it("allows only the SMS tool during the mandatory correction turn", () => { + clearHostedSmsCapture("ident-1", "call-1"); + activateHostedSmsCapture({ + identityId: "ident-1", + callId: "call-1", + sessionID: "session-1", + phase: "correction", + expectedTarget: "+14155550123", + }); + expect(() => assertHostedToolAllowed("session-1", "inkbox_send_sms")).not.toThrow(); + expect(() => assertHostedToolAllowed("session-1", "inkbox_send_email")).toThrow( + "permits only inkbox_send_sms", + ); + expect(() => assertHostedToolAllowed("ordinary-session", "inkbox_send_email")).not.toThrow(); + }); +}); diff --git a/tests/gateway/sessions.test.ts b/tests/gateway/sessions.test.ts index 62d1c77..d6183ee 100644 --- a/tests/gateway/sessions.test.ts +++ b/tests/gateway/sessions.test.ts @@ -6,6 +6,7 @@ import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { ResolvedConfig } from "../../src/config.js"; import { defaultGatewayConfig } from "../../src/config.js"; +import { saveHostedCall } from "../../src/gateway/hosted-call-registry.js"; import { createSessionManager, extractText } from "../../src/gateway/sessions.js"; import { createStateStore } from "../../src/gateway/state.js"; import type { InboundMessage } from "../../src/gateway/types.js"; @@ -13,6 +14,7 @@ import type { InboundMessage } from "../../src/gateway/types.js"; const tmpDirs: string[] = []; afterEach(() => { + delete process.env.INKBOX_OPENCODE_HOME; for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); vi.useRealTimers(); }); @@ -32,7 +34,7 @@ function makeIdentity() { interface PromptArg { path: { id: string }; query: { directory: string }; - body: { parts: Array<{ type: string; text: string }> }; + body: { parts: Array<{ type: string; text: string }>; tools?: Record }; } function makeManager() { @@ -43,6 +45,11 @@ function makeManager() { const inkbox = { getIdentity: vi.fn(async () => identity), getClient: vi.fn() }; let created = 0; const opencode = { + tool: { + ids: vi.fn(async () => ({ + data: ["bash", "edit", "task", "inkbox_send_sms", "inkbox_send_email"], + })), + }, session: { create: vi.fn(async (_o: { body: { title: string }; query: { directory: string } }) => ({ data: { id: `sess-${++created}` }, @@ -74,7 +81,23 @@ function makeManager() { logger, directory: "/proj", }); - return { mgr, opencode, identity, state, inkbox }; + return { mgr, opencode, identity, state, inkbox, dir }; +} + +function prepareHostedCall(dir: string): void { + process.env.INKBOX_OPENCODE_HOME = dir; + saveHostedCall({ + identityId: "ident-1", + callId: "call-1", + eventId: "evt-1", + state: "running", + event: { + id: "evt-1", + event_type: "call.ended", + timestamp: "2026-08-01T00:00:00Z", + data: { call: { id: "call-1", mode: "hosted_agent" } }, + } as any, + }); } function sms(text: string, over: Partial = {}): InboundMessage { @@ -199,6 +222,42 @@ describe("runCapture", () => { }); }); +describe("runHostedCapture", () => { + it("disables delegation during the initial hosted settlement turn", async () => { + const { mgr, opencode, dir } = makeManager(); + prepareHostedCall(dir); + await mgr.runHostedCapture?.("contact-1", "settle the call", { + identityId: "ident-1", + callId: "call-1", + phase: "initial", + expectedTarget: "+14155550123", + }); + expect(opencode.session.prompt.mock.calls[0][0].body.tools).toMatchObject({ + task: false, + inkbox_a2a_call: false, + }); + }); + + it("exposes only the journaled SMS tool during correction", async () => { + const { mgr, opencode, dir } = makeManager(); + prepareHostedCall(dir); + await mgr.runHostedCapture?.("contact-1", "correct the SMS", { + identityId: "ident-1", + callId: "call-1", + phase: "correction", + expectedTarget: "+14155550123", + }); + expect(opencode.tool.ids).toHaveBeenCalledWith({ query: { directory: "/proj" } }); + expect(opencode.session.prompt.mock.calls[0][0].body.tools).toEqual({ + bash: false, + edit: false, + task: false, + inkbox_send_sms: true, + inkbox_send_email: false, + }); + }); +}); + describe("abortTurn", () => { it("aborts the in-flight session and clears the queue when busy", async () => { const { mgr, opencode, state } = makeManager(); diff --git a/tests/gateway/subscriptions.test.ts b/tests/gateway/subscriptions.test.ts index 5dd42cc..afd03fc 100644 --- a/tests/gateway/subscriptions.test.ts +++ b/tests/gateway/subscriptions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ResolvedConfig } from "../../src/config.js"; import { A2A_EVENT_TYPES, + CALL_EVENT_TYPES, IMESSAGE_EVENT_TYPES, MAILBOX_EVENT_TYPES, PHONE_EVENT_TYPES, @@ -73,10 +74,14 @@ function makeIdentity(overrides: Record = {}) { function makeDeps( identity: Record, subscriptions: ReturnType, - options: { voiceEnabled?: boolean } = {}, + options: { + voiceEnabled?: boolean; + phoneVoiceStack?: "inkbox_voice_ai" | "openai_realtime" | "inkbox_tts_stt"; + } = {}, ): GatewayDeps & { logger: { [K in keyof GatewayLogger]: ReturnType } } { const client = { webhooks: { subscriptions } }; const config = { + phoneVoiceStack: options.phoneVoiceStack ?? "inkbox_tts_stt", vaultKeyEnvVar: "INKBOX_VAULT_KEY", tools: { enable: [], disable: [] }, outbound: { allowedRecipients: [], approval: "auto", askTimeoutMs: 0 }, @@ -135,8 +140,8 @@ describe("reconcileSubscriptions", () => { const subs = makeSubscriptions(); const result = await reconcileSubscriptions(makeDeps(makeIdentity(), subs), PUBLIC_URL); - expect(result).toEqual({ created: 4, updated: 0, unchanged: 0 }); - expect(subs.create).toHaveBeenCalledTimes(4); + expect(result).toEqual({ created: 5, updated: 0, unchanged: 0 }); + expect(subs.create).toHaveBeenCalledTimes(5); expect(subs.create).toHaveBeenCalledWith({ mailboxId: "mb-1", url: WEBHOOK_URL, @@ -157,6 +162,11 @@ describe("reconcileSubscriptions", () => { url: WEBHOOK_URL, eventTypes: IMESSAGE_EVENT_TYPES, }); + expect(subs.create).toHaveBeenCalledWith({ + agentIdentityId: "ident-1", + url: WEBHOOK_URL, + eventTypes: CALL_EVENT_TYPES, + }); expect(subs.update).not.toHaveBeenCalled(); }); @@ -188,10 +198,15 @@ describe("reconcileSubscriptions", () => { ]); const result = await reconcileSubscriptions(makeDeps(makeIdentity(), subs), PUBLIC_URL); - expect(result).toEqual({ created: 0, updated: 1, unchanged: 3 }); + expect(result).toEqual({ created: 1, updated: 1, unchanged: 3 }); expect(subs.update).toHaveBeenCalledTimes(1); expect(subs.update).toHaveBeenCalledWith("sub-mb", { eventTypes: MAILBOX_EVENT_TYPES }); - expect(subs.create).not.toHaveBeenCalled(); + expect(subs.create).toHaveBeenCalledOnce(); + expect(subs.create).toHaveBeenCalledWith({ + agentIdentityId: "ident-1", + url: WEBHOOK_URL, + eventTypes: CALL_EVENT_TYPES, + }); }); it("leaves a subscription unchanged when event types match in a different order", async () => { @@ -233,7 +248,7 @@ describe("reconcileSubscriptions", () => { const result = await reconcileSubscriptions(makeDeps(makeIdentity(), subs), PUBLIC_URL); // Foreign subscriptions are ignored entirely; ours are created alongside. - expect(result).toEqual({ created: 4, updated: 0, unchanged: 0 }); + expect(result).toEqual({ created: 5, updated: 0, unchanged: 0 }); expect(subs.update).not.toHaveBeenCalled(); expect(subs.delete).not.toHaveBeenCalled(); }); @@ -243,7 +258,7 @@ describe("reconcileSubscriptions", () => { const identity = makeIdentity({ phoneNumber: null }); const result = await reconcileSubscriptions(makeDeps(identity, subs), PUBLIC_URL); - expect(result).toEqual({ created: 3, updated: 0, unchanged: 0 }); + expect(result).toEqual({ created: 4, updated: 0, unchanged: 0 }); const owners = subs.create.mock.calls.map(([options]) => options); expect(owners.some((o: Record) => "phoneNumberId" in o)).toBe(false); expect(subs.list).not.toHaveBeenCalledWith( @@ -256,7 +271,7 @@ describe("reconcileSubscriptions", () => { const identity = makeIdentity({ imessageEnabled: false }); const result = await reconcileSubscriptions(makeDeps(identity, subs), PUBLIC_URL); - expect(result).toEqual({ created: 3, updated: 0, unchanged: 0 }); + expect(result).toEqual({ created: 4, updated: 0, unchanged: 0 }); const owners = subs.create.mock.calls.map(([options]) => options); expect(owners).toContainEqual({ agentIdentityId: "ident-1", @@ -282,12 +297,17 @@ describe("reconcileSubscriptions", () => { const result = await reconcileSubscriptions(deps, PUBLIC_URL); - expect(result).toEqual({ created: 3, updated: 0, unchanged: 0 }); - expect(subs.create).toHaveBeenLastCalledWith({ + expect(result).toEqual({ created: 4, updated: 0, unchanged: 0 }); + expect(subs.create).toHaveBeenCalledWith({ agentIdentityId: "ident-1", url: WEBHOOK_URL, eventTypes: IMESSAGE_EVENT_TYPES, }); + expect(subs.create).toHaveBeenCalledWith({ + agentIdentityId: "ident-1", + url: WEBHOOK_URL, + eventTypes: CALL_EVENT_TYPES, + }); expect(deps.logger.warn).toHaveBeenCalledWith( expect.stringContaining("does not support A2A webhook events yet"), ); @@ -310,7 +330,7 @@ describe("reconcileSubscriptions", () => { const result = await reconcileSubscriptions(deps, PUBLIC_URL); - expect(result).toEqual({ created: 2, updated: 0, unchanged: 0 }); + expect(result).toEqual({ created: 3, updated: 0, unchanged: 0 }); expect(deps.logger.warn).toHaveBeenCalledWith( expect.stringContaining("skipping the A2A subscription"), ); @@ -341,6 +361,23 @@ describe("reconcileSubscriptions", () => { }); }); + it("points incoming calls at Voice AI and clears stale local callback URLs", async () => { + const identity = makeIdentity(); + await reconcileSubscriptions( + makeDeps(identity, makeSubscriptions(), { + voiceEnabled: true, + phoneVoiceStack: "inkbox_voice_ai", + }), + PUBLIC_URL, + ); + + expect(identity.setIncomingCallAction).toHaveBeenCalledWith({ + incomingCallAction: "hosted_agent", + clientWebsocketUrl: null, + incomingCallWebhookUrl: null, + }); + }); + it("skips incoming-call wiring when voice is enabled but no line can receive calls", async () => { const identity = makeIdentity({ phoneNumber: null, imessageEnabled: false }); const deps = makeDeps(identity, makeSubscriptions(), { voiceEnabled: true }); @@ -376,4 +413,38 @@ describe("reconcileSubscriptions", () => { reconcileSubscriptions(makeDeps(makeIdentity(), makeSubscriptions()), "scout.example.com"), ).rejects.toThrow(/http\(s\)/); }); + + it("canonicalizes the URL, preserves its path, and drops credentials/query/fragment", async () => { + const identity = makeIdentity(); + const deps = makeDeps(identity, makeSubscriptions(), { voiceEnabled: true }); + await reconcileSubscriptions( + deps, + "HTTPS://user:secret@EXAMPLE.COM/some/path/?key=secret#fragment", + ); + expect(identity.setIncomingCallAction).toHaveBeenCalledWith({ + incomingCallAction: "auto_accept", + clientWebsocketUrl: "wss://example.com/some/path/phone/media/ws", + incomingCallWebhookUrl: "https://example.com/some/path/webhook", + }); + expect(JSON.stringify(identity.setIncomingCallAction.mock.calls)).not.toContain("secret"); + }); + + it.each(["https:/example.com", "ftp://example.com", "https://"])( + "rejects a malformed or non-HTTP public URL: %s", + async (publicUrl) => { + await expect( + reconcileSubscriptions(makeDeps(makeIdentity(), makeSubscriptions()), publicUrl), + ).rejects.toThrow("Gateway public URL must be an http(s) URL"); + }, + ); + + it("does not disclose credentials from an invalid configured public URL", async () => { + const secret = "do-not-log-this-token"; + await expect( + reconcileSubscriptions( + makeDeps(makeIdentity(), makeSubscriptions()), + `ftp://user:${secret}@example.com/path?key=${secret}`, + ), + ).rejects.not.toThrow(secret); + }); }); diff --git a/tests/live/call-pairing.ts b/tests/live/call-pairing.ts new file mode 100644 index 0000000..df2b215 --- /dev/null +++ b/tests/live/call-pairing.ts @@ -0,0 +1,51 @@ +export interface PairableCall { + id: string; + direction?: string; + remotePhoneNumber?: string | null; + createdAt?: Date | string | null; + created_at?: Date | string | null; +} + +function createdAt(call: PairableCall): number | undefined { + const value = call.createdAt ?? call.created_at; + if (value instanceof Date) return value.getTime(); + const parsed = Date.parse(String(value ?? "")); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function describe(calls: PairableCall[]) { + return calls.map((call) => ({ + id: call.id, + direction: call.direction, + createdAt: createdAt(call), + })); +} + +/** Select a call pair only when ownership is unambiguous after duplicate grace. */ +export function requireExactCallPair( + driverCalls: TDriver[], + autCalls: TAut[], + options: { scenarioStartedAt: number; maxCreationSkewMs: number }, +): { driver: TDriver; aut: TAut } { + const diagnostics = JSON.stringify({ + scenarioStartedAt: options.scenarioStartedAt, + driver: describe(driverCalls), + aut: describe(autCalls), + }); + if (driverCalls.length !== 1 || autCalls.length !== 1) { + throw new Error(`expected exactly one driver leg and one AUT leg; ${diagnostics}`); + } + const driverCreatedAt = createdAt(driverCalls[0]); + const autCreatedAt = createdAt(autCalls[0]); + if (driverCreatedAt === undefined || autCreatedAt === undefined) { + throw new Error(`paired call is missing a creation timestamp; ${diagnostics}`); + } + if ( + driverCreatedAt < options.scenarioStartedAt || + autCreatedAt < options.scenarioStartedAt || + Math.abs(driverCreatedAt - autCreatedAt) > options.maxCreationSkewMs + ) { + throw new Error(`paired call timestamps do not belong to this scenario; ${diagnostics}`); + } + return { driver: driverCalls[0], aut: autCalls[0] }; +} diff --git a/tests/live/helpers.ts b/tests/live/helpers.ts index f5d62ef..1be99a1 100644 --- a/tests/live/helpers.ts +++ b/tests/live/helpers.ts @@ -192,21 +192,40 @@ export async function autSpeechMode( aut: Inkbox, direction: "inbound" | "outbound", driverNumber: string, -): Promise<{ tts: boolean | null; stt: boolean | null } | undefined> { + excludedCallIds: Set = new Set(), +): Promise< + | { + id: string; + tts: boolean | null; + stt: boolean | null; + voicemailDetection?: string | null; + } + | undefined +> { const tail = driverNumber.replace(/\D/g, "").slice(-10); const calls = (await aut.calls.list({ limit: 10 })) as Array<{ + id: string; direction?: string; remotePhoneNumber?: string; useInkboxTts: boolean | null; useInkboxStt: boolean | null; + voicemailDetection?: string | null; }>; const c = calls.find( (x) => (x.direction ?? "").toLowerCase() === direction && + !excludedCallIds.has(x.id) && (x.remotePhoneNumber ?? "").replace(/\D/g, "").slice(-10) === tail && x.useInkboxTts !== null, ); - return c ? { tts: c.useInkboxTts, stt: c.useInkboxStt } : undefined; + return c + ? { + id: c.id, + tts: c.useInkboxTts, + stt: c.useInkboxStt, + voicemailDetection: c.voicemailDetection, + } + : undefined; } // Settle, send an SMS to the AUT, and return the first NEW inbound reply. diff --git a/tests/live/voice-driver.mjs b/tests/live/voice-driver.mjs index 0167833..dfee677 100644 --- a/tests/live/voice-driver.mjs +++ b/tests/live/voice-driver.mjs @@ -11,7 +11,8 @@ // file (ws url + phone-number id) the test reads. // // Env: REMOTE_INKBOX_API_KEY, INKBOX_BASE_URL, VOICE_DRIVER_STATE, -// VOICE_DRIVER_LINE, VOICE_DRIVER_SPEAK_AFTER (s), VOICE_DRIVER_LISTEN (s) +// VOICE_DRIVER_LINE, VOICE_DRIVER_SPEAK_AFTER (s), VOICE_DRIVER_LISTEN (s), +// VOICE_DRIVER_AUTO_STOP (false lets the test own hangup timing) import { writeFileSync } from "node:fs"; import { Inkbox } from "@inkbox/sdk"; import { connect } from "@inkbox/sdk/tunnels/connect"; @@ -32,6 +33,7 @@ const GREETING = process.env.VOICE_DRIVER_GREETING || "Hello?"; // explicit stop is required or the leg lingers to the server max-duration cap). const SPEAK_AFTER_MS = Number(process.env.VOICE_DRIVER_SPEAK_AFTER || "5") * 1000; const LISTEN_MS = Number(process.env.VOICE_DRIVER_LISTEN || "12") * 1000; +const AUTO_STOP = process.env.VOICE_DRIVER_AUTO_STOP !== "false"; if (!API_KEY) { console.error("REMOTE_INKBOX_API_KEY required"); @@ -71,6 +73,7 @@ async function callWsHandler(ws) { await sleep(SPEAK_AFTER_MS); await speak(LINE); await sleep(LISTEN_MS); + if (!AUTO_STOP) return; try { await ws.send(JSON.stringify({ event: "stop" })); console.log("sent stop (hangup)"); diff --git a/tests/live/voice-proof.ts b/tests/live/voice-proof.ts new file mode 100644 index 0000000..f3e53fd --- /dev/null +++ b/tests/live/voice-proof.ts @@ -0,0 +1,28 @@ +export function normalizedVoiceTokens(value: string): string[] { + return value.toLowerCase().match(/[a-z0-9]+/g) ?? []; +} + +export function containsVoiceMarker(value: string, marker: string): boolean { + const haystack = normalizedVoiceTokens(value); + const needle = normalizedVoiceTokens(marker); + if (needle.length === 0 || haystack.length < needle.length) return false; + return haystack.some((_, index) => + needle.every((token, offset) => haystack[index + offset] === token), + ); +} + +export function hasAfterCallSmsIntent(value: string): boolean { + const normalized = normalizedVoiceTokens(value).join(" "); + const afterCall = + /\bafter (?:we |you |i )?hang up\b/.test(normalized) || + /\b(?:after|when|once) (?:this |the )?call (?:ends|is over)\b/.test(normalized); + return afterCall && hasSmsIntent(normalized); +} + +export function hasSmsIntent(value: string): boolean { + const normalized = normalizedVoiceTokens(value).join(" "); + return ( + /\bsend\b.{0,80}\b(?:sms|text(?: message)?)\b/.test(normalized) || + /\b(?:text|sms) (?:me|the caller|the user|them|him|her)\b/.test(normalized) + ); +} diff --git a/tests/live/voice.test.ts b/tests/live/voice.test.ts index c6a89c8..6290488 100644 --- a/tests/live/voice.test.ts +++ b/tests/live/voice.test.ts @@ -2,30 +2,34 @@ // // A companion driver process (voice-driver.mjs) bridges the driver's side of a // real call over its own Inkbox tunnel and speaks one line; we read the stored -// call transcript and assert both parties spoke. Two scenarios, each run +// call transcript and assert both parties spoke. Three scenarios, each run // against a gateway booted in the matching speech mode and selected by // VOICE_SCENARIO: // inbound_inkbox — driver calls the agent; agent answers Inkbox STT/TTS. // outbound_realtime — driver texts "call me"; agent calls back on Realtime. +// outbound_hosted — Voice AI calls, then settles one exact post-call SMS. import { readFileSync } from "node:fs"; import { PhoneRuleAction, PhoneRuleMatchType, VoicemailDetection } from "@inkbox/sdk"; import { describe, expect, it } from "vitest"; +import { requireExactCallPair } from "./call-pairing.js"; import { AUT_KEY, autSpeechMode, + callSegments, client, inboundTextsFrom, LIVE, phoneOf, - pollUntil, REAL_MODEL, REMOTE_KEY, waitTwoWayCall, } from "./helpers.js"; +import { containsVoiceMarker, hasAfterCallSmsIntent, hasSmsIntent } from "./voice-proof.js"; const SCENARIO = process.env.VOICE_SCENARIO ?? ""; const STATE_FILE = process.env.VOICE_DRIVER_STATE || "/tmp/voice_driver_state.json"; const VOICE_TIMEOUT_MS = Number(process.env.LIVE_VOICE_TIMEOUT_S || "220") * 1000; +const HOSTED_MARKER = process.env.HOSTED_POST_CALL_MARKER || ""; interface DriverState { ws_url: string; @@ -84,6 +88,29 @@ async function ensureDriverAllowed( } const tail = (s: string) => s.replace(/\D/g, "").slice(-10); +function recordCreatedAt(record: any): number | undefined { + const value = record?.createdAt ?? record?.created_at; + if (value instanceof Date) return value.getTime(); + const parsed = Date.parse(String(value ?? "")); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function smsTargets(message: any): Set { + const values = [message?.remotePhoneNumber ?? message?.remote_phone_number ?? ""]; + for (const recipient of message?.recipients ?? []) { + values.push(recipient?.recipientPhoneNumber ?? recipient?.recipient_phone_number ?? ""); + } + return new Set(values.map((value) => String(value).replace(/\D/g, "")).filter(Boolean)); +} + +async function outboundTextsTo(inkbox: ReturnType, numberId: string, to: string) { + const target = to.replace(/\D/g, ""); + return (await inkbox.texts.list(numberId, { limit: 200 })).filter( + (message: any) => + String(message.direction ?? "").toLowerCase() === "outbound" && + smsTargets(message).has(target), + ); +} async function hangupCall( inkbox: ReturnType, @@ -119,6 +146,7 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { const remote = client(REMOTE_KEY as string); const aut = client(AUT_KEY as string); const autPhone = await phoneOf(aut); + const beforeAutCalls = new Set((await aut.calls.list({ limit: 30 })).map((item) => item.id)); // Server-side contact rules run before the plugin or its local allow-all // setting. Whitelisted smoke identities therefore need the driver allowed @@ -157,13 +185,18 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { ); } expect(agentSaid.length).toBeGreaterThan(0); + const persistedDriverCall = await remote.calls.get(call.id); + expect(String(persistedDriverCall.voicemailDetection).toLowerCase()).toBe("disabled"); - const mode = await autSpeechMode(aut, "inbound", st.number); + const mode = await autSpeechMode(aut, "inbound", st.number, beforeAutCalls); expect(mode, "no answered inbound AUT call with the driver").toBeDefined(); expect( mode?.tts && mode?.stt, `inbound should be Inkbox STT/TTS, got ${JSON.stringify(mode)}`, ).toBe(true); + // Voicemail detection applies to the driver's outbound dial and is + // proven on persistedDriverCall above. The mirrored AUT row is an + // inbound carrier record and does not carry that outbound setting. } finally { await hangupCall(remote, call.id); } @@ -179,6 +212,7 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { const aut = client(AUT_KEY as string); const autPhone = await phoneOf(aut); const autTail = tail(autPhone.number); + const driverTail = tail(st.number); const inboundFromAut = async () => (await remote.calls.list({ limit: 30 })).filter( @@ -188,11 +222,20 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { ); const before = new Set((await inboundFromAut()).map((c) => c.id)); + const outboundFromAut = async () => + (await aut.calls.list({ limit: 30 })).filter( + (c) => + (c.direction ?? "").toLowerCase() === "outbound" && + tail(c.remotePhoneNumber ?? "") === driverTail, + ); + const beforeAut = new Set((await outboundFromAut()).map((c) => c.id)); const beforeTexts = new Set( (await inboundTextsFrom(remote, st.number_id, autPhone.number)).map( (message) => message.id, ), ); + const scenarioStartedAt = Date.now() - 5_000; + const deadline = Date.now() + VOICE_TIMEOUT_MS; await remote.texts.send(st.number_id, { to: autPhone.number, text: "Please call me right now by phone and set voicemailDetection to disabled.", @@ -201,11 +244,25 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { let call: Awaited>[number] | undefined; try { try { - call = await pollUntil( - "agent call-back", - async () => (await inboundFromAut()).find((c) => !before.has(c.id)), - VOICE_TIMEOUT_MS, - ); + const duplicateGraceMs = 10_000; + let firstPairAt: number | undefined; + while (Date.now() < deadline) { + const driverCalls = (await inboundFromAut()).filter((c) => !before.has(c.id)); + const autCalls = (await outboundFromAut()).filter((c) => !beforeAut.has(c.id)); + if (driverCalls.length > 0 && autCalls.length > 0) { + firstPairAt ??= Date.now(); + if (Date.now() - firstPairAt >= duplicateGraceMs) { + const pair = requireExactCallPair(driverCalls, autCalls, { + scenarioStartedAt, + maxCreationSkewMs: 60_000, + }); + call = pair.driver; + break; + } + } + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + if (!call) throw new Error("Timed out waiting for one unambiguous Realtime call pair."); } catch (error) { const replies = (await inboundTextsFrom(remote, st.number_id, autPhone.number)).filter( (message) => !beforeTexts.has(message.id), @@ -214,16 +271,203 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { } const agentSaid = await waitTwoWayCall(remote, call.id, VOICE_TIMEOUT_MS); expect(agentSaid.length).toBeGreaterThan(0); + const persistedDriverCall = await remote.calls.get(call.id); - const mode = await autSpeechMode(aut, "outbound", st.number); - expect(mode, "no answered outbound AUT call with the driver").toBeDefined(); + const freshAutCalls = (await outboundFromAut()).filter((c) => !beforeAut.has(c.id)); + const pair = requireExactCallPair([persistedDriverCall], freshAutCalls, { + scenarioStartedAt, + maxCreationSkewMs: 60_000, + }); + const mode: any = await aut.calls.get(pair.aut.id); expect( - mode?.tts === false && mode?.stt === false, + mode.useInkboxTts === false && mode.useInkboxStt === false, `outbound should be Realtime, got ${JSON.stringify(mode)}`, ).toBe(true); + // Voicemail detection belongs to the AUT's call-capable outbound request. + // The driver's mirrored inbound leg can report its unrelated provider default. + expect(String(mode.voicemailDetection).toLowerCase()).toBe("disabled"); } finally { await hangupCall(remote, call?.id); } }, ); + + it.skipIf(SCENARIO !== "outbound_hosted")( + "outbound: Voice AI call settles one exact-target post-call SMS", + { timeout: VOICE_TIMEOUT_MS + 60_000 }, + async () => { + expect(HOSTED_MARKER, "HOSTED_POST_CALL_MARKER is required").not.toBe(""); + const st = driverState(); + const remote = client(REMOTE_KEY as string); + const aut = client(AUT_KEY as string); + const autPhone = await phoneOf(aut); + const autTail = tail(autPhone.number); + const driverTail = tail(st.number); + const autMailbox = (await aut.mailboxes.list())[0]; + if (!autMailbox) throw new Error("AUT identity has no mailbox"); + const autHandle = autMailbox.emailAddress.split("@", 1)[0]; + const autIdentity = await aut.getIdentity(autHandle); + const savedAuthority = (await autIdentity.getHostedAgentConfig()).authorityMode; + const expectedAuthority = String((savedAuthority as any)?.value ?? savedAuthority); + const deadline = Date.now() + VOICE_TIMEOUT_MS; + const progress = { phase: "baseline", last: "" }; + + const driverLegs = async () => + (await remote.calls.list({ limit: 30 })).filter( + (call) => + String(call.direction ?? "").toLowerCase() === "inbound" && + tail(call.remotePhoneNumber ?? "") === autTail, + ); + const autLegs = async () => + (await aut.calls.list({ limit: 30 })).filter( + (call) => + String(call.direction ?? "").toLowerCase() === "outbound" && + tail(call.remotePhoneNumber ?? "") === driverTail, + ); + + const baselineDriverCalls = await driverLegs(); + const baselineAutCalls = await autLegs(); + const beforeDriverCalls = new Set(baselineDriverCalls.map((call) => call.id)); + const beforeAutCalls = new Set(baselineAutCalls.map((call) => call.id)); + const baseline = await outboundTextsTo(aut, autPhone.id, st.number); + const beforeSmsIds = new Set(baseline.map((message: any) => message.id)); + const watermark = Math.max( + 0, + ...baseline.map(recordCreatedAt).filter((value): value is number => value !== undefined), + ); + + const scenarioStartedAt = Date.now() - 5_000; + await remote.texts.send(st.number_id, { + to: autPhone.number, + text: + "Use inkbox_place_call to call me now. Inkbox Voice AI must handle the call. " + + "The purpose is to complete my spoken request and record any post-call action. " + + `Do not text before calling. Request ref ${Date.now().toString(36)}.`, + }); + + let driverCallId: string | undefined; + let autCallId: string | undefined; + try { + const duplicateGraceMs = 10_000; + let firstPairAt: number | undefined; + while (Date.now() < deadline) { + progress.phase = "hosted call placement"; + const freshDriver = (await driverLegs()).filter( + (call) => !beforeDriverCalls.has(call.id), + ); + const freshAut = (await autLegs()).filter((call) => !beforeAutCalls.has(call.id)); + progress.last = `driver_records=${freshDriver.length} aut_records=${freshAut.length}`; + if (freshDriver.length > 0 && freshAut.length > 0) { + firstPairAt ??= Date.now(); + if (Date.now() - firstPairAt >= duplicateGraceMs) { + const pair = requireExactCallPair(freshDriver, freshAut, { + scenarioStartedAt, + maxCreationSkewMs: 60_000, + }); + driverCallId = pair.driver.id; + autCallId = pair.aut.id; + break; + } + } + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + expect(driverCallId && autCallId, JSON.stringify(progress)).toBeTruthy(); + if (!driverCallId || !autCallId) throw new Error(JSON.stringify(progress)); + const call: any = await aut.calls.get(autCallId); + expect(String(call.mode?.value ?? call.mode).toLowerCase()).toBe("hosted_agent"); + expect( + String(call.voicemailDetection?.value ?? call.voicemailDetection).toLowerCase(), + ).toBe("disabled"); + expect(call.reason).toBeTruthy(); + expect(String(call.hostedAgentAuthorityMode?.value ?? call.hostedAgentAuthorityMode)).toBe( + expectedAuthority, + ); + + while (Date.now() < deadline) { + progress.phase = "pre-hangup caller and open-action readiness"; + const [segments, currentAutCall] = await Promise.all([ + callSegments(remote, driverCallId).catch(() => ({ agent: [], driver: [] })), + aut.calls.get(autCallId), + ]); + const caller = segments.driver + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " "); + const openActions = (currentAutCall.postCallActionItems ?? []).filter( + (item: any) => String(item.status ?? "").toLowerCase() === "open", + ); + const actionEvidence = openActions.map((item: any) => + [item.action, item.details].filter(Boolean).join(" "), + ); + const callerReady = + segments.agent.length > 0 && + hasAfterCallSmsIntent(caller) && + containsVoiceMarker(caller, HOSTED_MARKER); + const actionReady = actionEvidence.some( + (value: string) => hasSmsIntent(value) && containsVoiceMarker(value, HOSTED_MARKER), + ); + const smsActionCount = actionEvidence.filter((value: string) => + hasSmsIntent(value), + ).length; + const markerActionCount = actionEvidence.filter((value: string) => + containsVoiceMarker(value, HOSTED_MARKER), + ).length; + progress.last = + `agent_segments=${segments.agent.length} caller_ready=${callerReady} ` + + `action_ready=${actionReady} open_actions=${openActions.length} ` + + `sms_actions=${smsActionCount} marker_actions=${markerActionCount}`; + if (callerReady && actionReady) break; + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + expect(progress.phase).toBe("pre-hangup caller and open-action readiness"); + expect(progress.last).toContain("caller_ready=true"); + expect(progress.last).toContain("action_ready=true"); + } finally { + await hangupCall(remote, driverCallId); + } + + const duplicateGraceMs = 10_000; + let matched: any[] = []; + let registryEntry: any; + while (Date.now() < deadline - duplicateGraceMs) { + progress.phase = "post-call tool settlement"; + const fresh = (await outboundTextsTo(aut, autPhone.id, st.number)).filter( + (message: any) => { + const created = recordCreatedAt(message); + return !beforeSmsIds.has(message.id) && created !== undefined && created >= watermark; + }, + ); + matched = fresh.filter((message: any) => + containsVoiceMarker(String(message.text ?? ""), HOSTED_MARKER), + ); + try { + const registry = JSON.parse( + readFileSync( + `${process.env.HOME}/.inkbox-opencode/hosted-call-completions.json`, + "utf8", + ), + ); + registryEntry = Object.values(registry).find((entry: any) => entry.callId === autCallId); + } catch { + registryEntry = undefined; + } + progress.last = `marker_rows=${matched.length} registry_state=${registryEntry?.state ?? "missing"}`; + if (matched.length === 1 && registryEntry?.state === "completed") { + await new Promise((resolve) => setTimeout(resolve, duplicateGraceMs)); + const afterGrace = (await outboundTextsTo(aut, autPhone.id, st.number)).filter( + (message: any) => + !beforeSmsIds.has(message.id) && + (recordCreatedAt(message) ?? -1) >= watermark && + containsVoiceMarker(String(message.text ?? ""), HOSTED_MARKER), + ); + expect(afterGrace).toHaveLength(1); + return; + } + if (registryEntry?.state === "failed") + throw new Error(`hosted settlement failed: ${JSON.stringify(registryEntry)}`); + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + throw new Error(`hosted SMS settlement timed out: ${JSON.stringify(progress)}`); + }, + ); }); diff --git a/tests/unit/call-pairing.test.ts b/tests/unit/call-pairing.test.ts new file mode 100644 index 0000000..5a645a0 --- /dev/null +++ b/tests/unit/call-pairing.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { requireExactCallPair } from "../live/call-pairing.js"; + +const started = Date.parse("2026-08-01T00:00:00Z"); +const call = (id: string, offset = 1_000) => ({ + id, + direction: "inbound", + remotePhoneNumber: "+14155550123", + createdAt: new Date(started + offset), +}); + +describe("live call ownership pairing", () => { + it("accepts exactly one current driver/AUT pair", () => { + expect( + requireExactCallPair([call("driver")], [call("aut", 2_000)], { + scenarioStartedAt: started, + maxCreationSkewMs: 5_000, + }), + ).toMatchObject({ driver: { id: "driver" }, aut: { id: "aut" } }); + }); + + it("rejects duplicate driver or AUT legs with identifying diagnostics", () => { + let diagnostic = ""; + try { + requireExactCallPair([call("driver-1"), call("driver-2")], [call("aut")], { + scenarioStartedAt: started, + maxCreationSkewMs: 5_000, + }); + } catch (error) { + diagnostic = String(error); + } + expect(diagnostic).toMatch(/driver-1.*driver-2.*aut/); + expect(diagnostic).not.toContain("14155550123"); + expect(() => + requireExactCallPair([call("driver")], [call("aut-1"), call("aut-2")], { + scenarioStartedAt: started, + maxCreationSkewMs: 5_000, + }), + ).toThrow(/driver.*aut-1.*aut-2/); + }); + + it("rejects stale and creation-skewed pairs", () => { + expect(() => + requireExactCallPair([call("driver", -1)], [call("aut")], { + scenarioStartedAt: started, + maxCreationSkewMs: 5_000, + }), + ).toThrow("do not belong to this scenario"); + expect(() => + requireExactCallPair([call("driver", 1_000)], [call("aut", 20_000)], { + scenarioStartedAt: started, + maxCreationSkewMs: 5_000, + }), + ).toThrow("do not belong to this scenario"); + }); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index cce97e8..bec61e4 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -184,6 +184,75 @@ describe("resolveConfig", () => { }); }); + describe("phone call voice stack", () => { + it("keeps the legacy local stack unless Realtime was already enabled", () => { + expect(resolveConfig({}, FULL_ENV).phoneVoiceStack).toBe("inkbox_tts_stt"); + expect( + resolveConfig({}, { ...FULL_ENV, INKBOX_REALTIME_ENABLED: "true" }).phoneVoiceStack, + ).toBe("openai_realtime"); + }); + + it("accepts the explicit hosted stack and lets plugin options beat the environment", () => { + const envConfig = resolveConfig({}, { ...FULL_ENV, INKBOX_VOICE_STACK: "inkbox_voice_ai" }); + expect(envConfig.phoneVoiceStack).toBe("inkbox_voice_ai"); + expect(envConfig.phoneVoiceStackOption).toBeUndefined(); + const optionConfig = resolveConfig( + { phoneVoiceStack: "inkbox_tts_stt" }, + { + ...FULL_ENV, + INKBOX_VOICE_STACK: "inkbox_voice_ai", + OPENAI_API_KEY: "sk-present", + }, + ); + expect(optionConfig.phoneVoiceStack).toBe("inkbox_tts_stt"); + expect(optionConfig.phoneVoiceStackOption).toBe("inkbox_tts_stt"); + expect( + resolveConfig( + { phoneVoiceStack: "inkbox_tts_stt" }, + { ...FULL_ENV, OPENAI_API_KEY: "sk-present" }, + ).gateway.voice.realtime.enabled, + ).toBe(false); + expect( + resolveConfig( + { phoneVoiceStack: "inkbox_voice_ai" }, + { ...FULL_ENV, OPENAI_API_KEY: "sk-present" }, + ).gateway.voice.realtime.enabled, + ).toBe(false); + }); + + it("resolves the canonical Voice AI authority mirror with option precedence", () => { + expect(resolveConfig({}, FULL_ENV).voiceAiAuthorityMode).toBe("contact_scoped"); + expect( + resolveConfig({}, { ...FULL_ENV, INKBOX_VOICE_AI_AUTHORITY_MODE: "yolo" }) + .voiceAiAuthorityMode, + ).toBe("yolo"); + expect( + resolveConfig( + { voiceAiAuthorityMode: "contact_scoped" }, + { ...FULL_ENV, INKBOX_VOICE_AI_AUTHORITY_MODE: "yolo" }, + ).voiceAiAuthorityMode, + ).toBe("contact_scoped"); + }); + + it("omits voicemail detection by default and accepts exact explicit values", () => { + expect(resolveConfig({}, FULL_ENV).voicemailDetection).toBeUndefined(); + expect( + resolveConfig({}, { ...FULL_ENV, INKBOX_VOICEMAIL_DETECTION: "disabled" }) + .voicemailDetection, + ).toBe("disabled"); + expect( + resolveConfig( + { voicemailDetection: "enabled" }, + { ...FULL_ENV, INKBOX_VOICEMAIL_DETECTION: "disabled" }, + ).voicemailDetection, + ).toBe("enabled"); + expect( + resolveConfig({}, { ...FULL_ENV, INKBOX_VOICEMAIL_DETECTION: "Disabled" }) + .voicemailDetection, + ).toBeUndefined(); + }); + }); + describe("gateway voice defaults", () => { it("answers calls by default, with realtime off when no key exists", () => { const cfg = resolveConfig({}, FULL_ENV); diff --git a/tests/unit/hosted-send-sms.test.ts b/tests/unit/hosted-send-sms.test.ts new file mode 100644 index 0000000..6765c0c --- /dev/null +++ b/tests/unit/hosted-send-sms.test.ts @@ -0,0 +1,178 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { CallEndedWebhookPayload } from "@inkbox/sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + activateHostedSmsCapture, + getHostedCall, + saveHostedCall, +} from "../../src/gateway/hosted-call-registry.js"; +import { sendSmsTools } from "../../src/tools/send-sms.js"; +import type { ToolDeps } from "../../src/tools/types.js"; + +let dir: string; +let sendText: ReturnType; + +function makeDeps(allowedRecipients: string[] = []): ToolDeps { + sendText = vi.fn(async () => ({ id: "sms-1", deliveryStatus: "queued" })); + return { + runtime: { + getIdentity: vi.fn(async () => ({ sendText })), + getClient: vi.fn(async () => ({})), + } as any, + config: { + apiKey: "k", + identity: "agent", + phoneVoiceStack: "inkbox_voice_ai", + vaultKeyEnvVar: "INKBOX_VAULT_KEY", + tools: { enable: [], disable: [] }, + outbound: { allowedRecipients, approval: "ask", askTimeoutMs: 1 }, + gateway: {} as any, + }, + vault: {} as any, + }; +} + +function setupCapture(): void { + const event = { + id: "evt-1", + event_type: "call.ended", + timestamp: "2026-08-01T00:00:00Z", + data: { + call: { id: "call-1", mode: "hosted_agent", remote_phone_number: "+14155550123" }, + contacts: [], + agent_identities: [], + transcript: { entries: [] }, + transcript_url: null, + post_call_action_items: [], + }, + } as unknown as CallEndedWebhookPayload; + saveHostedCall({ + identityId: "ident-1", + callId: "call-1", + eventId: "evt-1", + state: "running", + event, + }); + activateHostedSmsCapture({ + identityId: "ident-1", + callId: "call-1", + sessionID: "session-1", + phase: "initial", + expectedTarget: "+14155550123", + }); +} + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-hosted-send-")); + process.env.INKBOX_OPENCODE_HOME = dir; + setupCapture(); +}); + +afterEach(() => { + delete process.env.INKBOX_OPENCODE_HOME; + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("hosted send SMS boundary", () => { + it("skips interactive approval only for the exact call target and settles success", async () => { + const [tool] = sendSmsTools(makeDeps()); + const ctx = { + sessionID: "session-1", + messageID: "message-1", + ask: vi.fn(async () => { + throw new Error("approval should not run"); + }), + abort: new AbortController().signal, + } as any; + await tool.definition.execute({ to: "+14155550123", text: "bravo maple" }, ctx); + expect(sendText).toHaveBeenCalledOnce(); + expect(getHostedCall("ident-1", "call-1")?.smsAttempts[0].state).toBe("success"); + }); + + it("does not rewrite a provider-accepted SMS as failed when success journaling fails", async () => { + const [tool] = sendSmsTools(makeDeps()); + sendText.mockImplementationOnce(async () => { + fs.writeFileSync(path.join(dir, "hosted-call-completions.json"), "{broken", { mode: 0o600 }); + return { id: "sms-accepted", deliveryStatus: "queued" }; + }); + await expect( + tool.definition.execute({ to: "+14155550123", text: "bravo maple" }, { + sessionID: "session-1", + messageID: "message-1", + ask: vi.fn(), + abort: new AbortController().signal, + } as any), + ).rejects.toThrow(/provider accepted.*do not retry/); + expect(sendText).toHaveBeenCalledOnce(); + }); + + it("canonicalizes a formatting-only target variant to the authoritative E.164 number", async () => { + const [tool] = sendSmsTools(makeDeps()); + await tool.definition.execute({ to: "+1 (415) 555-0123", text: "bravo maple" }, { + sessionID: "session-1", + messageID: "message-1", + ask: vi.fn(), + abort: new AbortController().signal, + } as any); + expect(sendText).toHaveBeenCalledWith({ text: "bravo maple", to: "+14155550123" }); + expect(getHostedCall("ident-1", "call-1")?.smsAttempts[0]).toMatchObject({ + target: "+1 (415) 555-0123", + targetMatches: true, + state: "success", + }); + }); + + it("blocks a wrong target before loading the identity or calling the provider", async () => { + const deps = makeDeps(); + const [tool] = sendSmsTools(deps); + await expect( + tool.definition.execute({ to: "+15550009999", text: "bravo maple" }, { + sessionID: "session-1", + messageID: "m", + ask: vi.fn(), + abort: new AbortController().signal, + } as any), + ).rejects.toThrow("non-authoritative"); + expect(deps.runtime.getIdentity).not.toHaveBeenCalled(); + expect(sendText).not.toHaveBeenCalled(); + }); + + it("journals a missing target as a safe pre-send failure eligible for correction", async () => { + const deps = makeDeps(); + const [tool] = sendSmsTools(deps); + await expect( + tool.definition.execute({ text: "bravo maple" }, { + sessionID: "session-1", + messageID: "message-missing-target", + ask: vi.fn(), + abort: new AbortController().signal, + } as any), + ).rejects.toThrow("requires the explicit authoritative target"); + expect(deps.runtime.getIdentity).not.toHaveBeenCalled(); + expect(getHostedCall("ident-1", "call-1")?.smsAttempts[0]).toMatchObject({ + targetMatches: true, + state: "failed", + errorKind: "pre_send_validation", + }); + }); + + it("enforces a configured outbound allowlist without opening an interactive prompt", async () => { + const deps = makeDeps(["+15550009999"]); + const [tool] = sendSmsTools(deps); + await expect( + tool.definition.execute({ to: "+14155550123", text: "bravo maple" }, { + sessionID: "session-1", + messageID: "m", + ask: vi.fn(), + abort: new AbortController().signal, + } as any), + ).rejects.toThrow("not on the outbound allowlist"); + expect(deps.runtime.getIdentity).not.toHaveBeenCalled(); + expect(getHostedCall("ident-1", "call-1")?.smsAttempts[0]).toMatchObject({ + state: "failed", + errorKind: "recipient_terminal", + }); + }); +}); diff --git a/tests/unit/nato-marker.test.ts b/tests/unit/nato-marker.test.ts new file mode 100644 index 0000000..1bc5db3 --- /dev/null +++ b/tests/unit/nato-marker.test.ts @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +const NATO = new Set( + "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mike november oscar papa quebec romeo sierra tango uniform victor whiskey xray yankee zulu".split( + " ", + ), +); + +function marker(runId: string, attempt: string): string[] { + return execFileSync(process.execPath, ["scripts/nato-marker.mjs", runId, attempt], { + encoding: "utf8", + }) + .trim() + .split(" "); +} + +describe("hosted voice NATO marker", () => { + it.each([ + ["0", "0"], + ["1", "1"], + ["676", "2"], + ["999999999999", "9"], + ])("produces five distinct speech-safe words for run %s attempt %s", (runId, attempt) => { + const words = marker(runId, attempt); + expect(words).toHaveLength(5); + expect(new Set(words).size).toBe(5); + expect(words.every((word) => NATO.has(word))).toBe(true); + }); +}); diff --git a/tests/unit/place-call.test.ts b/tests/unit/place-call.test.ts index 9cad88c..251e903 100644 --- a/tests/unit/place-call.test.ts +++ b/tests/unit/place-call.test.ts @@ -1,6 +1,13 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import type { ResolvedConfig } from "../../src/config.js"; +import { + activateHostedSmsCapture, + saveHostedCall, +} from "../../src/gateway/hosted-call-registry.js"; import { placeCallTools } from "../../src/tools/place-call.js"; import type { ToolDeps } from "../../src/tools/types.js"; @@ -64,6 +71,7 @@ describe("placeCallTools", () => { expect(identity.placeCall).toHaveBeenCalledWith({ toNumber: "+14155550123", origination: "dedicated_number", + mode: "client_websocket", clientWebsocketUrl: "wss://bridge.example.com/audio", }); expect(result).toMatchObject({ title: expect.stringContaining("+14155550123") }); @@ -75,6 +83,39 @@ describe("placeCallTools", () => { expect(output).toContain("callsRemaining=4"); }); + it("places a hosted Voice AI call with a task reason and no media WebSocket", async () => { + const identity = makeIdentity(); + const [tool] = placeCallTools( + makeDeps(identity, { phoneVoiceStack: "inkbox_voice_ai", callWebsocketUrl: undefined }), + ); + const result = await tool.definition.execute( + { + toNumber: "+14155550123", + purpose: "Confirm the release", + openingMessage: "Hi — quick release check.", + context: "The release is planned for Friday.", + }, + makeCtx(), + ); + expect(identity.placeCall).toHaveBeenCalledWith({ + toNumber: "+14155550123", + origination: "dedicated_number", + mode: "hosted_agent", + reason: + "Purpose: Confirm the release\nOpening message: Hi — quick release check.\nContext: The release is planned for Friday.", + }); + expect(outputOf(result)).toContain("mode=inkbox_voice_ai"); + }); + + it("requires a purpose for hosted calls", async () => { + const identity = makeIdentity(); + const [tool] = placeCallTools(makeDeps(identity, { phoneVoiceStack: "inkbox_voice_ai" })); + await expect(tool.definition.execute({ toNumber: "+14155550123" }, makeCtx())).rejects.toThrow( + /require a purpose/, + ); + expect(identity.placeCall).not.toHaveBeenCalled(); + }); + it("omits rate-limit info when the response has none", async () => { const identity = makeIdentity({ placeCall: vi.fn(async () => ({ id: "call-2", status: "queued" })), @@ -209,6 +250,69 @@ describe("placeCallTools", () => { ); }); + it("exposes only hosted-call inputs and requires the Voice AI purpose", () => { + const [tool] = placeCallTools(makeDeps(makeIdentity(), { phoneVoiceStack: "inkbox_voice_ai" })); + const schema = z.object(tool.definition.args); + expect(Object.keys(tool.definition.args)).not.toContain("clientWebsocketUrl"); + expect(schema.safeParse({ toNumber: "+14155550123" }).success).toBe(false); + expect( + schema.safeParse({ toNumber: "+14155550123", purpose: "Confirm the release" }).success, + ).toBe(true); + }); + + it("allows a post-call callback only to the authoritative remote number", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-hosted-callback-")); + process.env.INKBOX_OPENCODE_HOME = dir; + try { + const event = { + id: "evt-1", + event_type: "call.ended", + timestamp: "2026-08-01T00:00:00Z", + data: { + call: { id: "call-1", mode: "hosted_agent", remote_phone_number: "+14155550123" }, + contacts: [], + post_call_action_items: [], + }, + } as any; + saveHostedCall({ + identityId: "ident-1", + callId: "call-1", + eventId: "evt-1", + state: "running", + event, + }); + activateHostedSmsCapture({ + identityId: "ident-1", + callId: "call-1", + sessionID: "session-1", + phase: "initial", + expectedTarget: "+14155550123", + }); + const identity = makeIdentity(); + const [tool] = placeCallTools( + makeDeps(identity, { phoneVoiceStack: "inkbox_voice_ai", callWebsocketUrl: undefined }), + ); + const ctx = { ...makeCtx(), sessionID: "session-1" }; + + await expect( + tool.definition.execute( + { toNumber: "+14155550999", purpose: "Return the caller's call" }, + ctx, + ), + ).rejects.toThrow("non-authoritative"); + expect(identity.placeCall).not.toHaveBeenCalled(); + + await tool.definition.execute( + { toNumber: "+14155550123", purpose: "Return the caller's call" }, + ctx, + ); + expect(identity.placeCall).toHaveBeenCalledOnce(); + } finally { + delete process.env.INKBOX_OPENCODE_HOME; + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("rejects recipients missing from the allowlist", async () => { const identity = makeIdentity(); const deps = makeDeps(identity, { diff --git a/tests/unit/voice-proof.test.ts b/tests/unit/voice-proof.test.ts new file mode 100644 index 0000000..fa43239 --- /dev/null +++ b/tests/unit/voice-proof.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + containsVoiceMarker, + hasAfterCallSmsIntent, + hasSmsIntent, + normalizedVoiceTokens, +} from "../live/voice-proof.js"; + +describe("hosted live voice proof normalization", () => { + it("normalizes punctuation without accepting reordered marker words", () => { + expect(normalizedVoiceTokens("Zulu, Alpha-Bravo! 42")).toEqual([ + "zulu", + "alpha", + "bravo", + "42", + ]); + expect(containsVoiceMarker("marker: zulu, alpha—bravo", "zulu alpha bravo")).toBe(true); + expect(containsVoiceMarker("zulu bravo alpha", "zulu alpha bravo")).toBe(false); + }); + + it("requires both after-call timing and an SMS intent for caller evidence", () => { + expect(hasAfterCallSmsIntent("After we hang up, send me an SMS with the marker.")).toBe(true); + expect(hasAfterCallSmsIntent("Send me an SMS now.")).toBe(false); + expect(hasAfterCallSmsIntent("After the call ends, remember the marker.")).toBe(false); + }); + + it("recognizes open-action SMS wording independently of timing", () => { + expect(hasSmsIntent("Send a text message containing the marker after the call.")).toBe(true); + expect(hasSmsIntent("Review the text-message history.")).toBe(false); + }); +});