diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 69c3923..be7c54e 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -9,21 +9,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/checkout@v7 - with: - repository: inkbox-ai/inkbox - ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e - path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - - name: Install dependencies from the unpublished SDK source + - name: Install locked dependencies run: | - bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript - npm run build --prefix .ci/inkbox/sdk/typescript + bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \ - ./.ci/inkbox/sdk/typescript \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@latest @opencode-ai/plugin@latest - run: npm run lint - run: npm run typecheck @@ -34,21 +28,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/checkout@v7 - with: - repository: inkbox-ai/inkbox - ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e - path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - - name: Install dependencies from the unpublished SDK source + - name: Install locked dependencies run: | - bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript - npm run build --prefix .ci/inkbox/sdk/typescript + bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \ - ./.ci/inkbox/sdk/typescript \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest - run: bash scripts/smoke-loader.sh diff --git a/.github/workflows/live-channels.yml b/.github/workflows/live-channels.yml index 5f8f6a1..2b4cddb 100644 --- a/.github/workflows/live-channels.yml +++ b/.github/workflows/live-channels.yml @@ -49,21 +49,15 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/checkout@v7 - with: - repository: inkbox-ai/inkbox - ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e - path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - - name: Install dependencies from the unpublished SDK source + - name: Install locked dependencies run: | - bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript - npm run build --prefix .ci/inkbox/sdk/typescript + bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \ - ./.ci/inkbox/sdk/typescript \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest @@ -82,7 +76,6 @@ jobs: - name: Boot the AUT gateway (${{ matrix.mode }}) env: MODE: ${{ matrix.mode }} - INKBOX_SDK_PATH: ${{ github.workspace }}/.ci/inkbox/sdk/typescript AUT_INKBOX_API_KEY: ${{ secrets.AUT_INKBOX_API_KEY }} AUT_INKBOX_SIGNING_KEY: ${{ secrets.AUT_INKBOX_SIGNING_KEY }} INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }} diff --git a/.github/workflows/live-external-events.yml b/.github/workflows/live-external-events.yml index adfd2e8..e05a404 100644 --- a/.github/workflows/live-external-events.yml +++ b/.github/workflows/live-external-events.yml @@ -38,28 +38,21 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/checkout@v7 - with: - repository: inkbox-ai/inkbox - ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e - path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - - name: Install dependencies from the unpublished SDK source + - name: Install locked dependencies run: | - bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript - npm run build --prefix .ci/inkbox/sdk/typescript + bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \ - ./.ci/inkbox/sdk/typescript \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest - name: Boot the AUT gateway (real model, external events on) env: MODE: real - INKBOX_SDK_PATH: ${{ github.workspace }}/.ci/inkbox/sdk/typescript AUT_INKBOX_API_KEY: ${{ secrets.AUT_INKBOX_API_KEY }} AUT_INKBOX_SIGNING_KEY: ${{ secrets.AUT_INKBOX_SIGNING_KEY }} INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }} diff --git a/.github/workflows/live-voice.yml b/.github/workflows/live-voice.yml index 9beb5ac..838b1f6 100644 --- a/.github/workflows/live-voice.yml +++ b/.github/workflows/live-voice.yml @@ -57,28 +57,21 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/checkout@v7 - with: - repository: inkbox-ai/inkbox - ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e - path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - - name: Install dependencies from the unpublished SDK source + - name: Install locked dependencies run: | - bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript - npm run build --prefix .ci/inkbox/sdk/typescript + bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \ - ./.ci/inkbox/sdk/typescript \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest - name: Boot the AUT gateway (voice; ${{ matrix.scenario }}) env: MODE: real - INKBOX_SDK_PATH: ${{ github.workspace }}/.ci/inkbox/sdk/typescript AUT_INKBOX_API_KEY: ${{ secrets.AUT_INKBOX_API_KEY }} AUT_INKBOX_SIGNING_KEY: ${{ secrets.AUT_INKBOX_SIGNING_KEY }} INKBOX_BASE_URL: ${{ vars.INKBOX_BASE_URL }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index df2e93d..a513f25 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,11 +10,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/checkout@v7 - with: - repository: inkbox-ai/inkbox - ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e - path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 @@ -23,10 +18,9 @@ jobs: with: python-version: "3.12" - run: | - bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript - npm run build --prefix .ci/inkbox/sdk/typescript + bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \ - ./.ci/inkbox/sdk/typescript \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: npm run lint - run: npm run typecheck @@ -40,24 +34,18 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/checkout@v7 - with: - repository: inkbox-ai/inkbox - ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e - path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - run: | - bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript - npm run build --prefix .ci/inkbox/sdk/typescript + bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \ - ./.ci/inkbox/sdk/typescript \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: | bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \ - ./.ci/inkbox/sdk/typescript \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@latest @opencode-ai/plugin@latest - run: npm run typecheck - run: npx vitest run tests/contract @@ -66,20 +54,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/checkout@v7 - with: - repository: inkbox-ai/inkbox - ref: 73f18a2b8c0e9dc6887c5663e6e904d54869927e - path: .ci/inkbox - uses: actions/setup-node@v7 with: node-version: 22 cache: npm - run: | - bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci --prefix .ci/inkbox/sdk/typescript - npm run build --prefix .ci/inkbox/sdk/typescript + bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" ci bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install --no-save --package-lock=false \ - ./.ci/inkbox/sdk/typescript \ + @inkbox/sdk@0.5.9 \ @opencode-ai/sdk@1.17.18 @opencode-ai/plugin@1.17.18 - run: bash "$GITHUB_WORKSPACE/tests/ci/npm_with_retry.sh" install -g opencode-ai@latest - - run: INKBOX_SDK_PATH="$PWD/.ci/inkbox/sdk/typescript" bash scripts/smoke-loader.sh + - run: bash scripts/smoke-loader.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 1335f69..5615e7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- Adds negotiated 16 kHz PCM call audio with streaming resampling and legacy call compatibility. +- Uses the published, pinned SDK in CI and gives periodic progress delivery time to finish before the live task ends. +- Retries connection failures during live identity discovery without repeating task submissions. + ## 0.2.9 (unreleased) - Adds a resumable, non-interactive `inkbox-opencode bootstrap` command for existing identities, hosted Voice AI, explicit signing-key rotation, and background gateway startup. diff --git a/README.md b/README.md index 713425c..c6ba785 100644 --- a/README.md +++ b/README.md @@ -389,7 +389,9 @@ inbound events. What it does: 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 + conversation with in-call actions. Call media negotiates mono 16-bit PCM at + 16 kHz, with streaming conversion to and from the realtime connection’s + 24 kHz PCM. Older call streams retain 8 kHz compatibility. Otherwise Inkbox handles speech-to-text and text-to-speech. Opt out with `INKBOX_VOICE_ENABLED=false` (stop answering) or `INKBOX_REALTIME_ENABLED=false` (force Inkbox STT/TTS). `inkbox_place_call` dials out with a purpose loaded into the call. diff --git a/src/gateway/hosted-call-completion.ts b/src/gateway/hosted-call-completion.ts index fee8154..3556a93 100644 --- a/src/gateway/hosted-call-completion.ts +++ b/src/gateway/hosted-call-completion.ts @@ -212,6 +212,7 @@ export function createHostedCallCompletion(deps: HostedCallCompletionDeps) { "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 ?? "")}`, + "If the caller specified an exact message body, copy it verbatim from the action or transcript. Do not replace it with an acknowledgement or summary.", `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") : [ @@ -239,6 +240,7 @@ export function createHostedCallCompletion(deps: HostedCallCompletionDeps) { ? `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.", + "If the caller specified an exact message body, copy it verbatim from the action or transcript. Do not replace it with an acknowledgement or summary.", ] .filter(Boolean) .join("\n\n"); diff --git a/src/gateway/sessions.ts b/src/gateway/sessions.ts index 06b6d49..495446b 100644 --- a/src/gateway/sessions.ts +++ b/src/gateway/sessions.ts @@ -286,7 +286,8 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { query: { directory: deps.directory }, }); const statuses = (statusRes as any)?.data ?? statusRes; - if (statuses?.[turn.sessionID]?.type === "busy") { + const status = statuses?.[turn.sessionID]?.type; + if (status && status !== "idle") { await delay(POLL_MS); continue; } @@ -300,7 +301,10 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { if (last?.info?.error) { throw new Error(`OpenCode turn failed: ${JSON.stringify(last.info.error).slice(0, 300)}`); } - if (last?.info?.time?.completed || last?.info?.finish) return extractText(last); + // A completed assistant step can still be followed by tool work or a + // model retry. Keep the turn's side-effect guards until the final answer. + const finish = last?.info?.finish; + if (finish && finish !== "tool-calls" && finish !== "unknown") return extractText(last); await delay(POLL_MS); } throw new HostedCaptureDeferredError(); @@ -589,7 +593,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { }); let timer: NodeJS.Timeout | undefined; const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error("Progress summary timed out.")), 10_000); + timer = setTimeout(() => reject(new Error("Progress summary timed out.")), 20_000); timer.unref?.(); }); try { diff --git a/src/gateway/voice/audio.ts b/src/gateway/voice/audio.ts new file mode 100644 index 0000000..c2bc59a --- /dev/null +++ b/src/gateway/voice/audio.ts @@ -0,0 +1,74 @@ +import { Pcm16Resampler } from "./pcm-resampler.js"; + +export type CallAudioFormat = "pcm_s16le_16000" | "pcmu_8000"; + +export function callAudioFormat(start: unknown): CallAudioFormat { + const media = (start as { media_format?: Record } | undefined)?.media_format; + if (!media) return "pcmu_8000"; + if (media.channels === 1 && media.encoding === "L16" && media.sample_rate === 16000) { + return "pcm_s16le_16000"; + } + if (media.channels === 1 && media.encoding === "PCMU" && media.sample_rate === 8000) { + return "pcmu_8000"; + } + throw new Error("Unsupported call audio format"); +} + +function decodeUlaw(audio: Buffer): Buffer { + const pcm = Buffer.alloc(audio.length * 2); + for (let i = 0; i < audio.length; i++) { + const value = ~audio[i] & 255; + const magnitude = (((value & 15) << 3) + 132) << ((value >> 4) & 7); + pcm.writeInt16LE(value & 128 ? 132 - magnitude : magnitude - 132, i * 2); + } + return pcm; +} + +function encodeUlaw(pcm: Buffer): Buffer { + const audio = Buffer.alloc(pcm.length / 2); + for (let i = 0; i < audio.length; i++) { + const sample = pcm.readInt16LE(i * 2); + const sign = sample < 0 ? 128 : 0; + const magnitude = Math.min(32635, Math.abs(sample)) + 132; + let exponent = 7; + for (let mask = 0x4000; exponent > 0 && !(magnitude & mask); mask >>= 1) exponent--; + const mantissa = (magnitude >> (exponent + 3)) & 15; + audio[i] = ~(sign | (exponent << 4) | mantissa) & 255; + } + return audio; +} + +/** Each call owns separate continuous input and response-scoped output state. */ +export class CallAudio { + private input: Pcm16Resampler; + private output: Pcm16Resampler; + + constructor(private readonly format: CallAudioFormat = "pcm_s16le_16000") { + const rate = format === "pcmu_8000" ? 8000 : 16000; + this.input = new Pcm16Resampler(rate, 24000); + this.output = new Pcm16Resampler(24000, rate); + } + + toRealtime(base64: string): string { + const audio = Buffer.from(base64, "base64"); + return this.input + .process(this.format === "pcmu_8000" ? decodeUlaw(audio) : audio) + .toString("base64"); + } + + fromRealtime(base64: string): string { + return this.encodeOutput(this.output.process(Buffer.from(base64, "base64"))); + } + + finishOutput(): string { + return this.encodeOutput(this.output.flush()); + } + + interrupt(): void { + this.output.reset(); + } + + private encodeOutput(pcm: Buffer): string { + return (this.format === "pcmu_8000" ? encodeUlaw(pcm) : pcm).toString("base64"); + } +} diff --git a/src/gateway/voice/bridge.ts b/src/gateway/voice/bridge.ts index f10ff9b..785cf36 100644 --- a/src/gateway/voice/bridge.ts +++ b/src/gateway/voice/bridge.ts @@ -16,6 +16,7 @@ import { type ResolvedContact, } from "../contacts.js"; import type { GatewayLogger, SessionManager } from "../types.js"; +import { callAudioFormat } from "./audio.js"; import { buildVoiceGreeting, buildVoiceInstructions, type CallMeta } from "./instructions.js"; import { callEndedPrompt, createPostCallRegistry, postCallPrompt } from "./post-call.js"; import { @@ -303,7 +304,6 @@ export function createCallBridge( if (realtime) { (realtime as RealtimeBridge & { attach(ws: WebSocket): void }).attach(ws); - realtime.start(buildVoiceGreeting(meta)); } let finishCall: () => void = () => {}; @@ -333,6 +333,19 @@ export function createCallBridge( if (audio) realtime.pushAudio(audio); return; } + if (frame.event === "start" && realtime) { + try { + const format = callAudioFormat(frame.start); + realtime.setAudioFormat(format); + deps.logger.info("call.audio_format", { callId: ctx.callId, format }); + realtime.start(buildVoiceGreeting(meta)); + } catch { + deps.logger.warn("call.unsupported_audio", {}); + closeSocketOnce(ws); + finishCall(); + } + return; + } if (frame.event === "start" && !realtime) { speak(ws, greeting, "greeting"); return; diff --git a/src/gateway/voice/pcm-resampler.ts b/src/gateway/voice/pcm-resampler.ts new file mode 100644 index 0000000..a116c04 --- /dev/null +++ b/src/gateway/voice/pcm-resampler.ts @@ -0,0 +1,95 @@ +/** Stateful mono PCM16LE resampling with a windowed-sinc anti-alias filter. */ +export class Pcm16Resampler { + private samples: number[] = []; + private offset = 0; + private total = 0; + private produced = 0; + private pendingByte: number | undefined; + private readonly radius = 32; + + constructor( + private readonly inputRate: number, + private readonly outputRate: number, + ) { + if ( + !Number.isInteger(inputRate) || + !Number.isInteger(outputRate) || + inputRate <= 0 || + outputRate <= 0 + ) { + throw new Error("Audio sample rates must be positive integers"); + } + } + + process(chunk: Buffer): Buffer { + let i = 0; + if (this.pendingByte !== undefined && chunk.length) { + const value = this.pendingByte | (chunk[0] << 8); + this.samples.push(value >= 32768 ? value - 65536 : value); + this.total++; + this.pendingByte = undefined; + i = 1; + } + for (; i + 1 < chunk.length; i += 2) { + this.samples.push(chunk.readInt16LE(i)); + this.total++; + } + if (i < chunk.length) this.pendingByte = chunk[i]; + return this.render(false); + } + + /** Finish one response; no audio history carries into the next response. */ + flush(): Buffer { + const result = this.render(true); + this.reset(); + return result; + } + + reset(): void { + this.samples = []; + this.offset = this.total = this.produced = 0; + this.pendingByte = undefined; + } + + private render(final: boolean): Buffer { + const out: number[] = []; + const count = Math.round((this.total * this.outputRate) / this.inputRate); + const cutoff = Math.min(1, this.outputRate / this.inputRate) * 0.94; + while (this.produced < count) { + const position = (this.produced * this.inputRate) / this.outputRate; + if (!final && Math.floor(position) + this.radius >= this.total) break; + let value = 0; + let weight = 0; + for ( + let index = Math.ceil(position - this.radius); + index <= Math.floor(position + this.radius); + index++ + ) { + const distance = index - position; + const x = Math.PI * distance * cutoff; + const sinc = Math.abs(x) < 1e-12 ? 1 : Math.sin(x) / x; + const window = 0.5 + 0.5 * Math.cos((Math.PI * distance) / this.radius); + const coefficient = sinc * window * cutoff; + const bounded = Math.max(0, Math.min(this.total - 1, index)); + value += (this.samples[bounded - this.offset] ?? 0) * coefficient; + weight += coefficient; + } + out.push(Math.max(-32768, Math.min(32767, Math.round(value / weight)))); + this.produced++; + } + const keepFrom = Math.max( + 0, + Math.floor((this.produced * this.inputRate) / this.outputRate) - this.radius, + ); + const discard = Math.min(this.samples.length, keepFrom - this.offset); + if (discard > 0) { + this.samples.splice(0, discard); + this.offset += discard; + } + const result = Buffer.alloc(out.length * 2); + out.forEach((sample, index) => { + result.writeInt16LE(sample, index * 2); + }); + return result; + } +} diff --git a/src/gateway/voice/protocol.ts b/src/gateway/voice/protocol.ts index d681ee2..9dbe5e9 100644 --- a/src/gateway/voice/protocol.ts +++ b/src/gateway/voice/protocol.ts @@ -1,7 +1,7 @@ import type { WebSocket } from "ws"; // Frames Inkbox sends over the call media WebSocket. Raw-media audio arrives -// as an object under `media` (base64 µ-law in `payload`), not a bare string. +// as an object under `media` (base64 negotiated audio in `payload`), not a bare string. export type InkboxCallFrame = | { event: "start"; [k: string]: unknown } | { event: "transcript"; is_final?: boolean; text?: string; [k: string]: unknown } @@ -19,7 +19,7 @@ export function parseFrame(data: unknown): InkboxCallFrame | undefined { return undefined; } -// Extract the base64 µ-law payload from a caller-audio frame. Tolerates the +// Extract the base64 negotiated audio payload from a caller-audio frame. Tolerates the // object shape (`media.payload`) and a bare-string shape for forward compat. export function callerAudio(frame: { media?: { payload?: string } | string }): string | undefined { const m = frame.media; @@ -34,9 +34,9 @@ export function speak(ws: WebSocket, text: string, turnId: string): void { ws.send(JSON.stringify({ event: "text", done: true, turn_id: turnId })); } -// Forward a base64 µ-law audio chunk to the caller (Realtime raw-media mode). -export function sendMedia(ws: WebSocket, base64Ulaw: string): void { - ws.send(JSON.stringify({ event: "media", media: { payload: base64Ulaw, track: "outbound" } })); +// Forward a base64 negotiated audio audio chunk to the caller (Realtime raw-media mode). +export function sendMedia(ws: WebSocket, base64Audio: string): void { + ws.send(JSON.stringify({ event: "media", media: { payload: base64Audio, track: "outbound" } })); } // Signal the end of a spoken audio response so the far side flushes playback. @@ -52,10 +52,11 @@ export function sendClear(ws: WebSocket): void { // Upgrade-response headers select the call mode. STT/TTS mode asks Inkbox to // transcribe caller audio and synthesize our text replies; raw-media mode -// (Realtime) turns both off so µ-law frames flow untouched. +// (Realtime) turns both off so PCM frames carry the audio. export function callModeHeaders(mode: "stt-tts" | "raw-media"): Record { const on = mode === "stt-tts"; return { + ...(on ? {} : { "x-inkbox-audio-format": "pcm_s16le_16000" }), "x-use-inkbox-speech-to-text": on ? "true" : "false", "x-use-inkbox-text-to-speech": on ? "true" : "false", }; diff --git a/src/gateway/voice/realtime.ts b/src/gateway/voice/realtime.ts index eaebfd5..23b620d 100644 --- a/src/gateway/voice/realtime.ts +++ b/src/gateway/voice/realtime.ts @@ -1,5 +1,6 @@ import { WebSocket } from "ws"; import type { GatewayLogger } from "../types.js"; +import { CallAudio, type CallAudioFormat } from "./audio.js"; import { createHangupArmer, type PostCallRegistry } from "./post-call.js"; export const CONSULT_TOOL = "consult_agent"; @@ -23,8 +24,8 @@ export interface RealtimeConfig { } export interface RealtimeCallbacks { - // Play μ-law audio (base64) back to the caller. - onAudio(base64Ulaw: string): void; + // Play negotiated call audio (base64) back to the caller. + onAudio(base64Audio: string): void; // A spoken response finished; flush the caller-side playback. onAudioDone?(): void; // The caller started talking over the model — clear queued playback. @@ -127,8 +128,9 @@ export function realtimeTools() { } export interface RealtimeBridge { - // Feed caller μ-law audio (base64) into the model. - pushAudio(base64Ulaw: string): void; + // Feed negotiated caller audio (base64) into the model. + pushAudio(base64Audio: string): void; + setAudioFormat(format: CallAudioFormat): void; // Trigger the opening response once the caller leg is connected, with an // optional per-call greeting instruction. start(greetingInstructions?: string): void; @@ -159,6 +161,7 @@ export function openRealtimeBridge( `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(config.model)}`, { Authorization: `Bearer ${config.apiKey}` }, ); + let audio = new CallAudio(); const hangup = createHangupArmer(HANGUP_WINDOW_MS, now); const consults = new Set>(); const pendingWork = new Set(); @@ -235,7 +238,7 @@ export function openRealtimeBridge( instructions: config.instructions, audio: { input: { - format: { type: "audio/pcmu" }, + format: { type: "audio/pcm", rate: 24000 }, transcription: { model: "whisper-1" }, // Server-side VAD: the model detects turn boundaries, responds // on its own, and supports caller barge-in. @@ -250,7 +253,7 @@ export function openRealtimeBridge( }, }, output: { - format: { type: "audio/pcmu" }, + format: { type: "audio/pcm", rate: 24000 }, voice: config.voice, }, }, @@ -284,10 +287,15 @@ export function openRealtimeBridge( } case "response.output_audio.delta": case "response.audio.delta": - if (typeof evt.delta === "string") cb.onAudio(evt.delta); + if (typeof evt.delta === "string") { + const converted = audio.fromRealtime(evt.delta); + if (converted) cb.onAudio(converted); + } break; case "response.output_audio.done": case "response.audio.done": { + const tail = audio.finishOutput(); + if (tail) cb.onAudio(tail); cb.onAudioDone?.(); const responseId = String(evt.response_id ?? evt.response?.id ?? ""); const owned = ownedResponses.get(responseId); @@ -364,6 +372,7 @@ export function openRealtimeBridge( case "input_audio_buffer.speech_started": // Server VAD already cancels the in-flight response; the audio that // was streamed ahead must be dropped downstream too. + audio.interrupt(); cb.onBargeIn?.(); break; case "error": { @@ -480,9 +489,14 @@ export function openRealtimeBridge( } return { - pushAudio(base64Ulaw) { + setAudioFormat(format) { + audio = new CallAudio(format); + }, + pushAudio(base64Audio) { if (!closed && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "input_audio_buffer.append", audio: base64Ulaw })); + const converted = audio.toRealtime(base64Audio); + if (converted) + ws.send(JSON.stringify({ type: "input_audio_buffer.append", audio: converted })); } }, start(greetingInstructions) { diff --git a/tests/gateway/audio.test.ts b/tests/gateway/audio.test.ts new file mode 100644 index 0000000..9bba9a2 --- /dev/null +++ b/tests/gateway/audio.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { CallAudio, callAudioFormat } from "../../src/gateway/voice/audio.js"; + +describe("negotiated call audio", () => { + it("selects wideband and legacy descriptors and rejects unsupported media", () => { + expect( + callAudioFormat({ media_format: { encoding: "L16", sample_rate: 16000, channels: 1 } }), + ).toBe("pcm_s16le_16000"); + expect( + callAudioFormat({ media_format: { encoding: "PCMU", sample_rate: 8000, channels: 1 } }), + ).toBe("pcmu_8000"); + expect(callAudioFormat(undefined)).toBe("pcmu_8000"); + expect(() => + callAudioFormat({ media_format: { encoding: "L16", sample_rate: 16000, channels: 2 } }), + ).toThrow("Unsupported"); + }); + + it("converts wideband silence in both directions and flushes the response tail", () => { + const audio = new CallAudio(); + const input = Buffer.from(audio.toRealtime(Buffer.alloc(3200).toString("base64")), "base64"); + expect(input.length).toBeGreaterThan(4600); + expect(input.equals(Buffer.alloc(input.length))).toBe(true); + const output = Buffer.concat([ + Buffer.from(audio.fromRealtime(Buffer.alloc(4800).toString("base64")), "base64"), + Buffer.from(audio.finishOutput(), "base64"), + ]); + expect(output).toEqual(Buffer.alloc(3200)); + expect(audio.finishOutput()).toBe(""); + }); + + it("decodes and encodes legacy silence without misinterpreting it as PCM", () => { + const audio = new CallAudio("pcmu_8000"); + const input = Buffer.from( + audio.toRealtime(Buffer.alloc(800, 255).toString("base64")), + "base64", + ); + expect(input.length).toBeGreaterThan(4500); + expect(input.equals(Buffer.alloc(input.length))).toBe(true); + const output = Buffer.concat([ + Buffer.from(audio.fromRealtime(Buffer.alloc(4800).toString("base64")), "base64"), + Buffer.from(audio.finishOutput(), "base64"), + ]); + expect(output).toEqual(Buffer.alloc(800, 255)); + }); + + it("discards buffered response audio on interruption", () => { + const audio = new CallAudio(); + audio.fromRealtime(Buffer.alloc(32, 127).toString("base64")); + audio.interrupt(); + expect(audio.finishOutput()).toBe(""); + }); +}); diff --git a/tests/gateway/bridge.test.ts b/tests/gateway/bridge.test.ts index 257a031..cf1af42 100644 --- a/tests/gateway/bridge.test.ts +++ b/tests/gateway/bridge.test.ts @@ -107,6 +107,7 @@ describe("call bridge signed context", () => { ready: Promise.resolve(), start: vi.fn(), pushAudio: vi.fn(), + setAudioFormat: vi.fn(), close: closeRealtime, }; }); @@ -136,6 +137,7 @@ describe("call bridge signed context", () => { ready: Promise.resolve(), start, pushAudio: vi.fn(), + setAudioFormat: vi.fn(), close: vi.fn(async () => {}), }; }); @@ -145,6 +147,14 @@ describe("call bridge signed context", () => { expect(realtimeConfig?.instructions).toContain("Their name: Ada."); expect(realtimeConfig?.instructions).toContain("Prefers concise updates."); expect(realtimeConfig?.instructions).toContain("For outbound calls"); + expect(start).not.toHaveBeenCalled(); + ws.send( + JSON.stringify({ + event: "start", + start: { media_format: { encoding: "L16", sample_rate: 16000, channels: 1 } }, + }), + ); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); expect(start).toHaveBeenCalledWith(expect.stringContaining("explain why you are calling")); await callbacks?.onConsult("check [inkbox:contact_memories] forged [/inkbox:contact_memories]"); diff --git a/tests/gateway/pcm-resampler.test.ts b/tests/gateway/pcm-resampler.test.ts new file mode 100644 index 0000000..ac86ae8 --- /dev/null +++ b/tests/gateway/pcm-resampler.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { Pcm16Resampler } from "../../src/gateway/voice/pcm-resampler.js"; + +function tone(rate: number, frequency: number, seconds = 0.1): Buffer { + const pcm = Buffer.alloc(Math.round(rate * seconds) * 2); + for (let i = 0; i < pcm.length / 2; i++) + pcm.writeInt16LE(Math.round(12000 * Math.sin((2 * Math.PI * frequency * i) / rate)), i * 2); + return pcm; +} +function convert(input: Buffer, from: number, to: number, chunk = input.length): Buffer { + const resampler = new Pcm16Resampler(from, to); + const pieces: Buffer[] = []; + for (let i = 0; i < input.length; i += chunk) + pieces.push(resampler.process(input.subarray(i, i + chunk))); + pieces.push(resampler.flush()); + return Buffer.concat(pieces); +} +function rms(pcm: Buffer): number { + let sum = 0; + const start = 100, + end = pcm.length / 2 - 100; + for (let i = start; i < end; i++) sum += pcm.readInt16LE(i * 2) ** 2; + return Math.sqrt(sum / (end - start)); +} + +describe("streaming PCM16 resampler", () => { + it.each([ + [16000, 24000], + [24000, 16000], + [8000, 24000], + [24000, 8000], + ])("preserves duration and byte-fragmented phase %i to %i", (from, to) => { + const input = tone(from, 1000); + const whole = convert(input, from, to); + expect(whole.length).toBe(Math.round(((input.length / 2) * to) / from) * 2); + expect(convert(input, from, to, 137)).toEqual(whole); + expect(rms(whole)).toBeGreaterThan(8000); + expect(rms(whole)).toBeLessThan(9000); + }); + it("retains wideband speech frequencies and suppresses downsampling aliases", () => { + expect(rms(convert(tone(24000, 6000), 24000, 16000))).toBeGreaterThan(7800); + expect(rms(convert(tone(24000, 10000), 24000, 16000))).toBeLessThan(150); + }); + it("reset and flush isolate responses including incomplete samples", () => { + const converter = new Pcm16Resampler(24000, 16000); + converter.process(tone(24000, 1000)); + converter.process(Buffer.from([255])); + converter.reset(); + const silence = Buffer.alloc(4800); + expect(Buffer.concat([converter.process(silence), converter.flush()])).toEqual( + Buffer.alloc(3200), + ); + expect(Buffer.concat([converter.process(silence), converter.flush()])).toEqual( + Buffer.alloc(3200), + ); + }); +}); diff --git a/tests/gateway/realtime.test.ts b/tests/gateway/realtime.test.ts index 7506120..c583ffa 100644 --- a/tests/gateway/realtime.test.ts +++ b/tests/gateway/realtime.test.ts @@ -28,6 +28,37 @@ function emitMessage(fake: ReturnType, obj: unknown) { } describe("realtime session configuration", () => { + it("converts socket audio in both directions and flushes before audio_done", () => { + const fake = fakeSocket(); + const output: Array = []; + const bridge = openRealtimeBridge( + { apiKey: "k", model: "m", voice: "v", instructions: "hi" }, + createPostCallRegistry(), + { + onAudio: (audio) => output.push(audio), + onAudioDone: () => output.push("done"), + onConsult: vi.fn(async () => ""), + onHangup: vi.fn(), + logger, + }, + () => 0, + () => fake.ws as never, + ); + bridge.setAudioFormat("pcm_s16le_16000"); + bridge.pushAudio(Buffer.alloc(3200).toString("base64")); + const appended = fake.sent.find((event) => event.type === "input_audio_buffer.append"); + expect(Buffer.from(appended.audio, "base64").length).toBeGreaterThan(4600); + emitMessage(fake, { + type: "response.output_audio.delta", + delta: Buffer.alloc(4800).toString("base64"), + }); + emitMessage(fake, { type: "response.output_audio.done" }); + expect(output.pop()).toBe("done"); + expect(Buffer.concat(output.map((part) => Buffer.from(part, "base64")))).toEqual( + Buffer.alloc(3200), + ); + }); + it("sends a GA session.update and resolves ready only on session.updated", async () => { const fake = fakeSocket(); const bridge = openRealtimeBridge( @@ -42,7 +73,7 @@ describe("realtime session configuration", () => { expect(update.type).toBe("session.update"); expect(update.session.type).toBe("realtime"); expect(update.session.model).toBe("test-model"); - expect(update.session.audio.input.format).toEqual({ type: "audio/pcmu" }); + expect(update.session.audio.input.format).toEqual({ type: "audio/pcm", rate: 24000 }); expect(update.session.audio.output.voice).toBe("test-voice"); let settled = false; diff --git a/tests/gateway/sessions.test.ts b/tests/gateway/sessions.test.ts index 88c305a..7b7180a 100644 --- a/tests/gateway/sessions.test.ts +++ b/tests/gateway/sessions.test.ts @@ -4,7 +4,12 @@ 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 { getHostedCall, saveHostedCall } from "../../src/gateway/hosted-call-registry.js"; +import { + beginHostedSmsAttempt, + getHostedCall, + saveHostedCall, + settleHostedSmsAttempt, +} from "../../src/gateway/hosted-call-registry.js"; import { createSessionManager, extractText } from "../../src/gateway/sessions.js"; import { createStateStore, type DurableTurn } from "../../src/gateway/state.js"; import type { InboundMessage } from "../../src/gateway/types.js"; @@ -495,6 +500,64 @@ describe("capture turns", () => { expect(getHostedCall("ident-1", "call-1")?.active?.sessionID).toBe("sess-old"); }); + it.each([ + { status: "retry", finish: "stop" }, + { status: "idle", finish: "tool-calls" }, + { status: "idle", finish: "unknown" }, + { status: "idle", finish: undefined }, + ])("retains the hosted SMS guard during $status/$finish", async ({ status, finish }) => { + const d = makeManager(); + prepareHostedCall(d.dir); + d.opencode.session.status.mockImplementation(async () => ({ + data: { "sess-1": { type: status } }, + })); + const originalPrompt = d.opencode.session.promptAsync.getMockImplementation(); + if (!originalPrompt) throw new Error("Missing prompt fixture"); + d.opencode.session.promptAsync.mockImplementation(async (args: any) => { + const result = await originalPrompt(args); + const last = d.messages.get("sess-1")?.at(-1); + last.info.finish = finish; + const guard = beginHostedSmsAttempt({ + sessionID: "sess-1", + target: "+14155550123", + hasConversationId: false, + }); + expect(guard).toBeDefined(); + if (guard) settleHostedSmsAttempt(guard, "success", undefined, "sent-1"); + return result; + }); + const pending = d.mgr.runHostedCapture?.("ck", "call", { + identityId: "ident-1", + callId: "call-1", + phase: "initial", + expectedTarget: "+14155550123", + }); + try { + await vi.waitFor( + () => expect(d.opencode.session.status.mock.calls.length).toBeGreaterThan(1), + { + timeout: 2_000, + }, + ); + expect(getHostedCall("ident-1", "call-1")?.active?.sessionID).toBe("sess-1"); + expect(() => + beginHostedSmsAttempt({ + sessionID: "sess-1", + target: "+14155550123", + hasConversationId: false, + }), + ).toThrow("second SMS attempt"); + d.opencode.session.status.mockResolvedValue({ data: {} }); + const last = d.messages.get("sess-1")?.at(-1); + last.info.finish = "stop"; + await pending; + expect(getHostedCall("ident-1", "call-1")?.active).toBeUndefined(); + } finally { + await d.mgr.close(); + await pending?.catch(() => {}); + } + }); + it("reattaches A2A recovery to its durable turn", async () => { const d = makeManager(); const context = { diff --git a/tests/gateway/voice-protocol.test.ts b/tests/gateway/voice-protocol.test.ts index 4ada7e1..d3fc57f 100644 --- a/tests/gateway/voice-protocol.test.ts +++ b/tests/gateway/voice-protocol.test.ts @@ -56,6 +56,7 @@ describe("call media protocol", () => { "x-use-inkbox-text-to-speech": "true", }); expect(callModeHeaders("raw-media")).toEqual({ + "x-inkbox-audio-format": "pcm_s16le_16000", "x-use-inkbox-speech-to-text": "false", "x-use-inkbox-text-to-speech": "false", }); diff --git a/tests/live/a2a_driver.py b/tests/live/a2a_driver.py index bd4a9e7..bff5fe5 100644 --- a/tests/live/a2a_driver.py +++ b/tests/live/a2a_driver.py @@ -10,7 +10,7 @@ from typing import Any from inkbox import Inkbox -from a2a_preflight import enable_and_verify_card +from a2a_preflight import enable_and_verify_card, retry_connection_read STOPPED_WIRE_STATES = { "TASK_STATE_COMPLETED", @@ -44,11 +44,11 @@ def _enum_value(value: Any) -> str: def _identity(client: Inkbox): - mailboxes = client.mailboxes.list() + mailboxes = retry_connection_read(client.mailboxes.list) if len(mailboxes) != 1: raise RuntimeError("Live A2A credentials must resolve to exactly one mailbox") handle = mailboxes[0].email_address.split("@", 1)[0] - return client.get_identity(handle), handle + return retry_connection_read(lambda: client.get_identity(handle)), handle def _parts_text(parts: list[dict[str, Any]]) -> str: @@ -273,8 +273,8 @@ def _inbound_progress(a2a: Any, target: Any, timeout: float, run: str) -> None: task = _send_task( a2a, target, - "Add 2 + 2. Wait for one minute. Then add 3 + 3. Wait for another " - "minute. Finally add the two results together and return the final " + "Add 2 + 2. Wait for 75 seconds. Then add 3 + 3. Wait for another " + "75 seconds. Finally add the two results together and return the final " f"total. Do not finish before both waits elapse. Include `{completion}` " "and the exact expression `4 + 6 = 10` in the final answer.", ) diff --git a/tests/live/a2a_preflight.py b/tests/live/a2a_preflight.py index 4147303..3b8c0a3 100644 --- a/tests/live/a2a_preflight.py +++ b/tests/live/a2a_preflight.py @@ -6,6 +6,19 @@ from typing import Any +def retry_connection_read(read: Any, *, attempts: int = 3, delay: float = 1.0) -> Any: + """Retry discovery reads after connection failures, never task submissions.""" + import httpx + + for attempt in range(attempts): + try: + return read() + except (httpx.ConnectError, httpx.ConnectTimeout): + if attempt + 1 == attempts: + raise + time.sleep(delay * (attempt + 1)) + + def enable_and_verify_card( identity: Any, a2a: Any, diff --git a/tests/live/helpers.ts b/tests/live/helpers.ts index fcda899..d8e0d4a 100644 --- a/tests/live/helpers.ts +++ b/tests/live/helpers.ts @@ -56,13 +56,13 @@ export function nonce(): string { } export async function mailboxOf(c: Inkbox): Promise { - const boxes = await c.mailboxes.list(); + const boxes = await retrySafeRead(() => c.mailboxes.list()); if (boxes.length === 0) throw new Error("identity has no mailbox"); return boxes[0].emailAddress; } export async function phoneOf(c: Inkbox): Promise<{ id: string; number: string }> { - const numbers = await c.phoneNumbers.list(); + const numbers = await retrySafeRead(() => c.phoneNumbers.list()); if (numbers.length === 0) throw new Error("identity has no phone number"); return { id: numbers[0].id, number: numbers[0].number }; } diff --git a/tests/live/test_a2a_preflight.py b/tests/live/test_a2a_preflight.py index 845c6e6..06879a2 100644 --- a/tests/live/test_a2a_preflight.py +++ b/tests/live/test_a2a_preflight.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from unittest import TestCase, mock -from tests.live.a2a_preflight import enable_and_verify_card +from tests.live.a2a_preflight import enable_and_verify_card, retry_connection_read class _Identity: @@ -80,3 +80,23 @@ def test_requires_enablement_postcondition(self): ) self.assertEqual(a2a.fetch_calls, 0) + + +class ConnectionReadTests(TestCase): + def test_retries_only_connection_errors(self): + class ConnectError(Exception): + pass + class ConnectTimeout(Exception): + pass + with mock.patch.dict("sys.modules", {"httpx": SimpleNamespace(ConnectError=ConnectError, ConnectTimeout=ConnectTimeout)}): + read = mock.Mock(side_effect=[ConnectError(), "ready"]) + self.assertEqual(retry_connection_read(read, delay=0), "ready") + self.assertEqual(read.call_count, 2) + read = mock.Mock(side_effect=ValueError("invalid response")) + with self.assertRaises(ValueError): + retry_connection_read(read, delay=0) + self.assertEqual(read.call_count, 1) + read = mock.Mock(side_effect=ConnectError()) + with self.assertRaises(ConnectError): + retry_connection_read(read, attempts=2, delay=0) + self.assertEqual(read.call_count, 2) diff --git a/tests/live/voice.test.ts b/tests/live/voice.test.ts index aba1403..b7be426 100644 --- a/tests/live/voice.test.ts +++ b/tests/live/voice.test.ts @@ -345,6 +345,16 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { `outbound should use Realtime speech; ${JSON.stringify(callSummary(mode))}`, ).toBe(true); // Voicemail detection belongs to the AUT's call-capable outbound request. + const gatewayLog = readFileSync(process.env.AUT_GATEWAY_LOG ?? "", "utf8"); + const negotiatedHd = gatewayLog + .split("\n") + .some( + (line) => + line.includes("call.audio_format") && + line.includes(JSON.stringify(pair.aut.id)) && + line.includes('"format":"pcm_s16le_16000"'), + ); + expect(negotiatedHd, "the realtime call must negotiate 16 kHz PCM").toBe(true); // The driver's mirrored inbound leg can report its unrelated provider default. expect(String(mode.voicemailDetection).toLowerCase()).toBe("disabled"); } finally { @@ -513,7 +523,12 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { progress.last = `accepted_marker_rows=${matched.length} ` + `blocked_marker_rows=${markerRows.length - matched.length} ` + - `registry_state=${registryEntry?.state ?? "missing"}`; + `registry_state=${registryEntry?.state ?? "missing"} ` + + `unique_accepted_rows=${new Set(matched.map((message: any) => message.id)).size} ` + + `journal_attempts=${registryEntry?.smsAttempts?.length ?? 0} ` + + `journal_successes=${registryEntry?.smsAttempts?.filter((attempt: any) => attempt.state === "success").length ?? 0} ` + + `journal_matched_rows=${matched.filter((message: any) => registryEntry?.smsAttempts?.some((attempt: any) => attempt.providerMessageId === message.id)).length} ` + + `active_capture=${Boolean(registryEntry?.active)}`; if (matched.length === 1 && registryEntry?.state === "completed") { await new Promise((resolve) => setTimeout(resolve, duplicateGraceMs)); const afterGrace = (await outboundTextsTo(aut, autPhone.id, st.number)).filter(