diff --git a/.github/workflows/live-voice.yml b/.github/workflows/live-voice.yml index 23256f3..7e5698d 100644 --- a/.github/workflows/live-voice.yml +++ b/.github/workflows/live-voice.yml @@ -5,8 +5,8 @@ name: Live — voice calls (Voice AI + Realtime + Inkbox TTS/STT) # 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 +# Each leg verifies driver-local speech and a two-way paired AUT transcript. +# Real model + real calls — the priciest suite — so it runs only on # ready (non-draft) PRs + manual dispatch, and shares the AUT tunnel lock. on: workflow_call: @@ -99,7 +99,9 @@ jobs: 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 one post-call action now with the title Send SMS and put this exact five-word SMS body in the action details: $HOSTED_MARKER. Wait for the action tool to succeed, then read all five words back to me. Do not paraphrase, omit a word, or send the SMS during the call." + export VOICE_DRIVER_LINE="After we hang up, send me one SMS containing exactly $HOSTED_MARKER. Before we hang up, record one post-call action whose action title and details are both exactly Send SMS $HOSTED_MARKER. Read the five-word SMS body back after recording it. Do not send the SMS during the call." + export VOICE_DRIVER_FOLLOWUP_LINE="Verify the post-call action you just recorded. If its action title or details are not exactly Send SMS $HOSTED_MARKER, edit that same action now so both match exactly. Then read back exactly $HOSTED_MARKER." + export VOICE_DRIVER_FOLLOWUP_AFTER=45 export VOICE_DRIVER_LISTEN=180 export VOICE_DRIVER_AUTO_STOP=false fi diff --git a/scripts/nato-marker.mjs b/scripts/nato-marker.mjs index f2a6fb0..734c14e 100644 --- a/scripts/nato-marker.mjs +++ b/scripts/nato-marker.mjs @@ -1,32 +1,26 @@ 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", +const SPEECH_WORDS = [ + "banana", + "elephant", + "pineapple", + "alligator", + "motorcycle", + "umbrella", + "dinosaur", + "potato", + "computer", + "volcano", + "airplane", + "butterfly", + "kangaroo", + "octopus", + "calendar", + "chocolate", + "hospital", + "library", + "sandwich", + "telescope", ]; export function natoMarker(runId, runAttempt) { @@ -34,11 +28,11 @@ export function natoMarker(runId, 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; + let index = Number(value % BigInt(SPEECH_WORDS.length)); + value /= BigInt(SPEECH_WORDS.length); + while (used.has(index)) index = (index + 1) % SPEECH_WORDS.length; used.add(index); - marker.push(RADIO_WORDS[index]); + marker.push(SPEECH_WORDS[index]); } return marker.join(" "); } diff --git a/src/a2a-context.ts b/src/a2a-context.ts index 9d8b778..411c7cd 100644 --- a/src/a2a-context.ts +++ b/src/a2a-context.ts @@ -74,6 +74,12 @@ export function activeA2ATurn(sessionID: string): ActiveA2ATurn | undefined { return turns.get(sessionID) ?? readTurn(sessionID); } +export function a2aReplyIntentCommitted(sessionID: string, turn: ActiveA2ATurn): boolean { + if (turn.replyIntentCommitted) return true; + const persisted = readTurn(sessionID); + return Boolean(persisted && sameTurn(persisted, turn) && persisted.replyIntentCommitted); +} + export function commitActiveA2ATurn(sessionID: string, turn: ActiveA2ATurn): void { turn.replyIntentCommitted = true; writeTurn(sessionID, turn); diff --git a/src/client.ts b/src/client.ts index ec38ed2..9523974 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7,6 +7,15 @@ import { } from "@inkbox/sdk"; const USER_AGENT_NAME = "inkbox-opencode"; +const IDENTITY_RETRY_DELAYS_MS = [250, 750] as const; +const TRANSIENT_NETWORK_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "EAI_AGAIN", + "ENETDOWN", + "ENETUNREACH", +]); // Read at runtime rather than hardcoded so the token can't drift from the // package version. `tsc` emits to dist/, so ../package.json resolves from @@ -68,6 +77,50 @@ function runtimeCacheKey(cfg: InkboxCredentials): string { }); } +function isTransientIdentityError(error: unknown): boolean { + let current: unknown = error; + for (let depth = 0; depth < 4 && current; depth += 1) { + const value = current as { + code?: unknown; + status?: unknown; + statusCode?: unknown; + cause?: unknown; + message?: unknown; + }; + const code = typeof value.code === "string" ? value.code.toUpperCase() : ""; + if (TRANSIENT_NETWORK_CODES.has(code)) return true; + + const status = Number(value.status ?? value.statusCode); + if (status === 408 || status === 425 || status === 429 || status >= 500) return true; + + const message = typeof value.message === "string" ? value.message : String(current); + if ( + /\b(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETDOWN|ENETUNREACH)\b/i.test(message) || + /(?:socket hang up|network connection was lost|fetch failed)/i.test(message) + ) { + return true; + } + current = value.cause; + } + return false; +} + +export async function resolveIdentityWithRetry( + resolveIdentity: () => Promise, + wait: (delayMs: number) => Promise = (delayMs) => + new Promise((resolve) => setTimeout(resolve, delayMs)), +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await resolveIdentity(); + } catch (error) { + const delayMs = IDENTITY_RETRY_DELAYS_MS[attempt]; + if (delayMs === undefined || !isTransientIdentityError(error)) throw error; + await wait(delayMs); + } + } +} + // Build a lazy-cached runtime. The Inkbox SDK client and the identity // resolution happen on first tool call, not at plugin load. This keeps // startup cheap when a session never invokes an Inkbox tool. @@ -118,7 +171,7 @@ export function createInkboxRuntime(source: ConfigSource, logger?: PluginLogger) `Inkbox plugin: whoami() failed during init: ${e instanceof Error ? e.message : String(e)}`, ); } - const identity = await inkbox.getIdentity(identityHandle); + const identity = await resolveIdentityWithRetry(() => inkbox.getIdentity(identityHandle)); return { inkbox, identity }; })(); const entry = { diff --git a/src/gateway/sessions.ts b/src/gateway/sessions.ts index bc46928..4663f32 100644 --- a/src/gateway/sessions.ts +++ b/src/gateway/sessions.ts @@ -1,6 +1,11 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { OpencodeClient } from "@opencode-ai/sdk"; -import { type ActiveA2ATurn, clearActiveA2ATurn, setActiveA2ATurn } from "../a2a-context.js"; +import { + type ActiveA2ATurn, + a2aReplyIntentCommitted, + clearActiveA2ATurn, + setActiveA2ATurn, +} from "../a2a-context.js"; import type { InkboxRuntime } from "../client.js"; import type { ResolvedConfig } from "../config.js"; import { @@ -206,6 +211,15 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { ); } + async function stopToolCompletedSession(sessionID: string): Promise { + const aborted = await deps.opencode.session.abort({ + path: { id: sessionID }, + query: { directory: deps.directory }, + }); + const error = (aborted as any)?.error; + if (error) throw new Error(`session.abort failed: ${JSON.stringify(error).slice(0, 300)}`); + } + async function submit(turn: DurableTurn): Promise { const sessionID = turn.sessionID ?? (await ensureSession(turn.chatKey)); const next = deps.state.transitionTurn(turn.id, ["queued"], { @@ -262,6 +276,27 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { throw new Error("Durable turn lease was lost."); } if (deps.state.getTurn(turn.id)?.state === "interrupted") return undefined; + if (turn.hostedCapture) { + const entry = getHostedCall(turn.hostedCapture.identityId, turn.hostedCapture.callId); + const attempt = entry?.smsAttempts.find( + (candidate) => candidate.phase === turn.hostedCapture?.phase, + ); + if (attempt && attempt.state !== "pending") { + await stopToolCompletedSession(turn.sessionID); + deps.logger.info("hosted_call.turn_stopped_after_sms_attempt", { + callId: turn.hostedCapture.callId, + state: attempt.state, + }); + return undefined; + } + } + if (turn.a2aContext && a2aReplyIntentCommitted(turn.sessionID, turn.a2aContext)) { + await stopToolCompletedSession(turn.sessionID); + deps.logger.info("a2a.turn_stopped_after_reply", { + taskId: turn.a2aContext.taskId, + }); + return undefined; + } const messages = await listMessages(turn.sessionID); const userIndex = messages.findIndex((message) => message?.info?.id === turn.messageID); if (userIndex < 0) { diff --git a/tests/contract/live-harness.test.ts b/tests/contract/live-harness.test.ts index 250db81..7ff8c7f 100644 --- a/tests/contract/live-harness.test.ts +++ b/tests/contract/live-harness.test.ts @@ -4,6 +4,7 @@ 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"); +const voiceDriver = readFileSync("tests/live/voice-driver.mjs", "utf8"); function shellCommands(source: string): string[] { return source.replace(/\\\n\s*/g, " ").split("\n"); @@ -18,6 +19,10 @@ function yamlJob(source: string, name: string): string { } describe("live harness readiness bounds", () => { + it("allows the realtime model to answer before the media peer hangs up", () => { + expect(voiceDriver).toContain('VOICE_DRIVER_LISTEN || "30"'); + }); + it("bounds both opencode /config readiness probes", () => { const configProbes = shellCommands(liveAut).filter( (command) => /\bcurl\b/.test(command) && command.includes("/config"), @@ -56,10 +61,15 @@ describe("live harness readiness bounds", () => { expect(voiceJob).toMatch(/^ {4}timeout-minutes: 15$/m); }); - it("requires the hosted caller to persist and read back the exact SMS body", () => { + it("requires the hosted caller to persist, correct, and read back the exact SMS body", () => { + expect(liveVoice).toContain( + 'export VOICE_DRIVER_LINE="After we hang up, send me one SMS containing exactly $HOSTED_MARKER. Before we hang up, record one post-call action whose action title and details are both exactly Send SMS $HOSTED_MARKER. Read the five-word SMS body back after recording it. Do not send the SMS during the call."', + ); expect(liveVoice).toContain( - 'export VOICE_DRIVER_LINE="After we hang up, send me one SMS. Create one post-call action now with the title Send SMS and put this exact five-word SMS body in the action details: $HOSTED_MARKER. Wait for the action tool to succeed, then read all five words back to me. Do not paraphrase, omit a word, or send the SMS during the call."', + 'export VOICE_DRIVER_FOLLOWUP_LINE="Verify the post-call action you just recorded. If its action title or details are not exactly Send SMS $HOSTED_MARKER, edit that same action now so both match exactly. Then read back exactly $HOSTED_MARKER."', ); + expect(liveVoice).not.toContain("list the actions"); + expect(liveVoice).toContain("export VOICE_DRIVER_FOLLOWUP_AFTER=45"); }); it("preserves diagnostics when the voice job is cancelled by its timeout", () => { diff --git a/tests/gateway/sessions.test.ts b/tests/gateway/sessions.test.ts index b6935f0..7b51d93 100644 --- a/tests/gateway/sessions.test.ts +++ b/tests/gateway/sessions.test.ts @@ -2,9 +2,15 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { a2aTurnContextPath } from "../../src/a2a-context.js"; 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"; @@ -416,6 +422,36 @@ describe("capture turns", () => { expect(d.opencode.session.promptAsync).toHaveBeenCalledTimes(submitted); }); + it("releases a hosted session after its SMS attempt is durably settled", async () => { + const d = makeManager(); + prepareHostedCall(d.dir); + d.setAutoComplete(false); + const capture = { + identityId: "ident-1", + callId: "call-1", + phase: "initial" as const, + expectedTarget: "+14155550123", + }; + + const pending = d.mgr.runHostedCapture?.("ck", "call", capture); + await vi.waitFor(() => + expect(getHostedCall("ident-1", "call-1")?.active?.sessionID).toBe("sess-1"), + ); + const guard = beginHostedSmsAttempt({ + sessionID: "sess-1", + target: "+14155550123", + hasConversationId: false, + }); + if (!guard) throw new Error("Expected the hosted SMS attempt to start."); + settleHostedSmsAttempt(guard, "success"); + + await expect(pending).resolves.toMatchObject({ attempt: { state: "success" } }); + expect(d.opencode.session.abort).toHaveBeenCalledWith({ + path: { id: "sess-1" }, + query: { directory: "/proj" }, + }); + }); + it("restores the hosted SMS guard before monitoring a submitted turn", async () => { const d = makeManager(); prepareHostedCall(d.dir); @@ -467,6 +503,32 @@ describe("capture turns", () => { expect(d.opencode.session.promptAsync).toHaveBeenCalledTimes(submitted); }); + + it("releases an A2A session after a tool commits the remote reply", async () => { + const d = makeManager(); + process.env.INKBOX_OPENCODE_HOME = d.dir; + d.setAutoComplete(false); + const context = { + taskId: "task-1", + messageId: "message-1", + contextId: "context-1", + replyIntentCommitted: false, + }; + + const pending = d.mgr.runA2A("a2a:context-1", "task", context); + await vi.waitFor(() => expect(d.opencode.session.promptAsync).toHaveBeenCalledOnce()); + fs.writeFileSync( + a2aTurnContextPath("sess-1"), + `${JSON.stringify({ ...context, replyIntentCommitted: true })}\n`, + { mode: 0o600 }, + ); + + await expect(pending).resolves.toBeUndefined(); + expect(d.opencode.session.abort).toHaveBeenCalledWith({ + path: { id: "sess-1" }, + query: { directory: "/proj" }, + }); + }); }); describe("control", () => { diff --git a/tests/live/call-pairing.ts b/tests/live/call-pairing.ts index df2b215..7f71a04 100644 --- a/tests/live/call-pairing.ts +++ b/tests/live/call-pairing.ts @@ -4,6 +4,62 @@ export interface PairableCall { remotePhoneNumber?: string | null; createdAt?: Date | string | null; created_at?: Date | string | null; + pairedCallId?: string | null; + paired_call_id?: string | null; +} + +type CallsClient = { + calls: { + list(options: { limit: number }): Promise; + }; +}; + +function statusCode(error: unknown): number | undefined { + const candidate = error as { + status?: number; + statusCode?: number; + response?: { status?: number }; + }; + return candidate?.status ?? candidate?.statusCode ?? candidate?.response?.status; +} + +/** Query one paired leg through the identity that owns it. */ +export async function agentLegForPair( + driverCall: PairableCall, + aut: CallsClient, + fallbackAutCalls: TAut[], + options: { + direction: "inbound" | "outbound"; + scenarioStartedAt: number; + maxCreationSkewMs: number; + }, +): Promise { + const pairId = driverCall.pairedCallId ?? driverCall.paired_call_id; + if (pairId) { + try { + const list = aut.calls.list as unknown as (options: { + limit: number; + pairedCallId: string; + }) => Promise; + const paired = await list.call(aut.calls, { limit: 2, pairedCallId: pairId }); + if (paired.length !== 1) { + throw new Error( + `pairedCallId returned ${paired.length} AUT legs; ids=${JSON.stringify(paired.map((c) => c.id))}`, + ); + } + const direction = String(paired[0].direction ?? "").toLowerCase(); + if (direction !== options.direction) { + throw new Error( + `paired AUT leg has direction=${JSON.stringify(direction)}; expected=${options.direction}`, + ); + } + return paired[0]; + } catch (error) { + if (!(error instanceof TypeError) && statusCode(error) !== 422) throw error; + } + } + + return requireExactCallPair([driverCall], fallbackAutCalls, options).aut; } function createdAt(call: PairableCall): number | undefined { diff --git a/tests/live/helpers.ts b/tests/live/helpers.ts index 1be99a1..49152b8 100644 --- a/tests/live/helpers.ts +++ b/tests/live/helpers.ts @@ -123,26 +123,23 @@ export async function inboundTextsFrom( return out; } -// A call's transcript split by who spoke. Read from the DRIVER's client, so -// "remote" segments are the AGENT's speech and "local" are the driver's. +// A call's transcript split by its owning identity's local and remote parties. export async function callSegments( c: Inkbox, callId: string, -): Promise<{ agent: string[]; driver: string[] }> { +): Promise<{ remote: string[]; local: string[] }> { const segs = (await c.calls.transcripts(callId)) as Array<{ party?: string; text?: string }>; const pick = (party: string) => segs .filter((s) => (s.party ?? "").toLowerCase() === party && (s.text ?? "").trim() !== "") .map((s) => (s.text ?? "").trim()); - return { agent: pick("remote"), driver: pick("local") }; + return { remote: pick("remote"), local: pick("local") }; } -// Block until the transcript shows BOTH parties spoke, then return the agent's -// speech — proof the agent reached the caller out loud on a two-way call. -export async function waitTwoWayCall( - driver: Inkbox, +async function waitForCallSpeech( + owner: Inkbox, callId: string, - timeoutMs = TIMEOUT_MS, + options: { requireRemote: boolean; label: string; timeoutMs: number }, ): Promise { const terminalFailureStatuses = new Set(["canceled", "failed"]); // A call can end normally and still never carry a conversation — detection @@ -153,15 +150,17 @@ export async function waitTwoWayCall( const endedGraceMs = Number(process.env.LIVE_VOICE_ENDED_GRACE_S || "15") * 1000; let endedAt: number | undefined; return pollUntil( - "two-way call transcript", + `${options.label} transcript`, async () => { - const { agent, driver: drv } = await callSegments(driver, callId).catch(() => ({ - agent: [], - driver: [], + const { remote, local } = await callSegments(owner, callId).catch(() => ({ + remote: [], + local: [], })); - if (agent.length > 0 && drv.length > 0) return agent.join(" | "); + if (local.length > 0 && (!options.requireRemote || remote.length > 0)) { + return local.join(" | "); + } - const call = await driver.calls.get(callId).catch(() => undefined); + const call = await owner.calls.get(callId).catch(() => undefined); const status = (call?.status ?? "").toLowerCase(); const detail = () => JSON.stringify({ @@ -171,28 +170,50 @@ export async function waitTwoWayCall( endedAt: call?.endedAt, }); if (terminalFailureStatuses.has(status)) { - throw new Error(`two-way call ended before both parties spoke: ${detail()}`); + throw new Error(`${options.label} ended before transcript proof: ${detail()}`); } if (endedStatuses.has(status)) { if (endedAt === undefined) endedAt = Date.now(); else if (Date.now() - endedAt > endedGraceMs) { - throw new Error(`two-way call ended without both parties speaking: ${detail()}`); + throw new Error(`${options.label} ended without transcript proof: ${detail()}`); } } return undefined; }, - timeoutMs, + options.timeoutMs, ); } -// (useInkboxTts, useInkboxStt) of the AUT's most recent ANSWERED call in -// `direction` with the driver: (true,true) is Inkbox STT/TTS, (false,false) is -// the realtime path — so each leg can prove the speech path it claims. +// The driver-owned leg only has to preserve the driver's scripted local speech. +export async function waitDriverLocalSpeech( + driver: Inkbox, + callId: string, + timeoutMs = TIMEOUT_MS, +): Promise { + return waitForCallSpeech(driver, callId, { + requireRemote: false, + label: "driver leg", + timeoutMs, + }); +} + +// The AUT-owned leg must show both participants; local speech belongs to the agent. +export async function waitAgentTwoWayCall( + aut: Inkbox, + callId: string, + timeoutMs = TIMEOUT_MS, +): Promise { + return waitForCallSpeech(aut, callId, { + requireRemote: true, + label: "AUT leg", + timeoutMs, + }); +} + +// Speech mode from the exact AUT-owned call leg. export async function autSpeechMode( aut: Inkbox, - direction: "inbound" | "outbound", - driverNumber: string, - excludedCallIds: Set = new Set(), + callId: string, ): Promise< | { id: string; @@ -202,30 +223,20 @@ export async function autSpeechMode( } | undefined > { - const tail = driverNumber.replace(/\D/g, "").slice(-10); - const calls = (await aut.calls.list({ limit: 10 })) as Array<{ + const c = (await aut.calls.get(callId)) as { 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 - ? { - id: c.id, - tts: c.useInkboxTts, - stt: c.useInkboxStt, - voicemailDetection: c.voicemailDetection, - } - : undefined; + }; + return { + id: c.id, + tts: c.useInkboxTts, + stt: c.useInkboxStt, + voicemailDetection: c.voicemailDetection, + }; } // 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 dfee677..8389f75 100644 --- a/tests/live/voice-driver.mjs +++ b/tests/live/voice-driver.mjs @@ -3,7 +3,7 @@ // Opens the driver identity's Inkbox tunnel, serves the call-media WebSocket // behind it in Inkbox STT/TTS mode (text frames only — no local model), speaks // one scripted line so the agent-under-test gets a turn, then hangs up. The -// stored call transcript (read by the test) proves the agent replied out loud. +// driver transcript proves this local line; the paired AUT transcript proves the reply. // // Two directions share one bridge: the test places a call to the agent and // passes this driver's WS URL, or the agent calls the driver's number, which is @@ -11,7 +11,9 @@ // 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_FOLLOWUP_LINE, +// VOICE_DRIVER_FOLLOWUP_AFTER (s), 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"; @@ -23,6 +25,8 @@ const STATE_FILE = process.env.VOICE_DRIVER_STATE || "/tmp/voice_driver_state.js const LINE = process.env.VOICE_DRIVER_LINE || "Hi, this is a quick test call. Please reply out loud with one short sentence, then say goodbye."; +const FOLLOWUP_LINE = (process.env.VOICE_DRIVER_FOLLOWUP_LINE || "").trim(); +const FOLLOWUP_AFTER_MS = Number(process.env.VOICE_DRIVER_FOLLOWUP_AFTER || "45") * 1000; // Answering-machine detection scores whoever answers: a greeting longer than the // carrier's `greeting_duration_millis` (3.5s) reads as a voicemail announcement // and the call is hung up before the agent ever speaks. Answer the way a person @@ -32,7 +36,7 @@ const GREETING = process.env.VOICE_DRIVER_GREETING || "Hello?"; // then give the agent a turn and hang up (a dropped WS does NOT end the call — an // 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 LISTEN_MS = Number(process.env.VOICE_DRIVER_LISTEN || "30") * 1000; const AUTO_STOP = process.env.VOICE_DRIVER_AUTO_STOP !== "false"; if (!API_KEY) { @@ -72,7 +76,13 @@ async function callWsHandler(ws) { await say(GREETING); await sleep(SPEAK_AFTER_MS); await speak(LINE); - await sleep(LISTEN_MS); + if (FOLLOWUP_LINE) { + await sleep(Math.min(FOLLOWUP_AFTER_MS, LISTEN_MS)); + await say(FOLLOWUP_LINE); + await sleep(Math.max(0, LISTEN_MS - FOLLOWUP_AFTER_MS)); + } else { + await sleep(LISTEN_MS); + } if (!AUTO_STOP) return; try { await ws.send(JSON.stringify({ event: "stop" })); diff --git a/tests/live/voice.test.ts b/tests/live/voice.test.ts index 94e5d48..209f19c 100644 --- a/tests/live/voice.test.ts +++ b/tests/live/voice.test.ts @@ -1,8 +1,8 @@ // Live voice-call suite — real phone calls, real model, transcript-verified. // // 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. Three scenarios, each run +// real call over its own Inkbox tunnel and speaks one line. The driver-owned leg +// proves that local line; the paired AUT-owned leg must prove both parties. 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. @@ -11,7 +11,7 @@ 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 { agentLegForPair, requireExactCallPair } from "./call-pairing.js"; import { AUT_KEY, autSpeechMode, @@ -20,9 +20,11 @@ import { inboundTextsFrom, LIVE, phoneOf, + pollUntil, REAL_MODEL, REMOTE_KEY, - waitTwoWayCall, + waitAgentTwoWayCall, + waitDriverLocalSpeech, } from "./helpers.js"; import { containsVoiceMarker, hasAfterCallSmsIntent, hasSmsIntent } from "./voice-proof.js"; @@ -147,6 +149,7 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { 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)); + const scenarioStartedAt = Date.now() - 5_000; // Server-side contact rules run before the plugin or its local allow-all // setting. Whitelisted smoke identities therefore need the driver allowed @@ -164,7 +167,40 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { try { let agentSaid: string; try { - agentSaid = await waitTwoWayCall(remote, call.id, VOICE_TIMEOUT_MS); + const driverSaid = await waitDriverLocalSpeech(remote, call.id, VOICE_TIMEOUT_MS); + expect(driverSaid.length).toBeGreaterThan(0); + const persistedDriverCall = await remote.calls.get(call.id); + const agentCall = await pollUntil( + "paired AUT leg", + async () => { + const candidates = (await aut.calls.list({ limit: 30 })).filter( + (candidate) => + !beforeAutCalls.has(candidate.id) && + String(candidate.direction ?? "").toLowerCase() === "inbound" && + tail(candidate.remotePhoneNumber ?? "") === tail(st.number), + ); + try { + return await agentLegForPair(persistedDriverCall, aut, candidates, { + direction: "inbound", + scenarioStartedAt, + maxCreationSkewMs: 60_000, + }); + } catch (error) { + if (candidates.length === 0 || String(error).includes("returned 0 AUT legs")) { + return undefined; + } + throw error; + } + }, + VOICE_TIMEOUT_MS, + ); + agentSaid = await waitAgentTwoWayCall(aut, agentCall.id, VOICE_TIMEOUT_MS); + const mode = await autSpeechMode(aut, agentCall.id); + expect(mode, "no paired inbound AUT call with the driver").toBeDefined(); + expect( + mode?.tts && mode?.stt, + `inbound should be Inkbox STT/TTS, got ${JSON.stringify(mode)}`, + ).toBe(true); } catch (error) { const [driverCall, autCalls, incomingAction, rules] = await Promise.all([ remote.calls.get(call.id).catch((cause) => ({ error: String(cause) })), @@ -187,13 +223,6 @@ 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, 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. @@ -269,23 +298,25 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { ); throw new Error(`${String(error)}; AUT SMS replies=${JSON.stringify(replies)}`); } - const agentSaid = await waitTwoWayCall(remote, call.id, VOICE_TIMEOUT_MS); - expect(agentSaid.length).toBeGreaterThan(0); const persistedDriverCall = await remote.calls.get(call.id); - const freshAutCalls = (await outboundFromAut()).filter((c) => !beforeAut.has(c.id)); - const pair = requireExactCallPair([persistedDriverCall], freshAutCalls, { + const agentCall = await agentLegForPair(persistedDriverCall, aut, freshAutCalls, { + direction: "outbound", scenarioStartedAt, maxCreationSkewMs: 60_000, }); - const mode: any = await aut.calls.get(pair.aut.id); + const driverSaid = await waitDriverLocalSpeech(remote, call.id, VOICE_TIMEOUT_MS); + const agentSaid = await waitAgentTwoWayCall(aut, agentCall.id, VOICE_TIMEOUT_MS); + expect(driverSaid.length).toBeGreaterThan(0); + expect(agentSaid.length).toBeGreaterThan(0); + const mode = await autSpeechMode(aut, agentCall.id); expect( - mode.useInkboxTts === false && mode.useInkboxStt === false, + mode?.tts === false && mode?.stt === 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"); + expect(String(mode?.voicemailDetection).toLowerCase()).toBe("disabled"); } finally { await hangupCall(remote, call?.id); } @@ -374,6 +405,21 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { } expect(driverCallId && autCallId, JSON.stringify(progress)).toBeTruthy(); if (!driverCallId || !autCallId) throw new Error(JSON.stringify(progress)); + const persistedDriverCall = await remote.calls.get(driverCallId); + const fallbackAutCalls = (await autLegs()).filter( + (candidate) => !beforeAutCalls.has(candidate.id), + ); + const agentCall = await agentLegForPair(persistedDriverCall, aut, fallbackAutCalls, { + direction: "outbound", + scenarioStartedAt, + maxCreationSkewMs: 60_000, + }); + autCallId = agentCall.id; + const remainingMs = Math.max(1_000, deadline - Date.now()); + const driverSaid = await waitDriverLocalSpeech(remote, driverCallId, remainingMs); + const agentSaid = await waitAgentTwoWayCall(aut, autCallId, remainingMs); + expect(driverSaid.length).toBeGreaterThan(0); + expect(agentSaid.length).toBeGreaterThan(0); const call: any = await aut.calls.get(autCallId); expect(String(call.mode?.value ?? call.mode).toLowerCase()).toBe("hosted_agent"); expect( @@ -387,10 +433,10 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { 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: [] })), + callSegments(aut, autCallId).catch(() => ({ remote: [], local: [] })), aut.calls.get(autCallId), ]); - const caller = segments.driver + const caller = segments.remote .join(" ") .toLowerCase() .replace(/[^a-z0-9]+/g, " "); @@ -401,9 +447,7 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { [item.action, item.details].filter(Boolean).join(" "), ); const callerReady = - segments.agent.length > 0 && - hasAfterCallSmsIntent(caller) && - containsVoiceMarker(caller, HOSTED_MARKER); + hasAfterCallSmsIntent(caller) && containsVoiceMarker(caller, HOSTED_MARKER); const actionReady = actionEvidence.some( (value: string) => hasSmsIntent(value) && containsVoiceMarker(value, HOSTED_MARKER), ); @@ -414,7 +458,7 @@ describe.skipIf(!LIVE || !REAL_MODEL)("live voice", () => { containsVoiceMarker(value, HOSTED_MARKER), ).length; progress.last = - `agent_segments=${segments.agent.length} caller_ready=${callerReady} ` + + `agent_segments=${segments.local.length} caller_ready=${callerReady} ` + `action_ready=${actionReady} open_actions=${openActions.length} ` + `sms_actions=${smsActionCount} marker_actions=${markerActionCount}`; if (callerReady && actionReady) break; diff --git a/tests/unit/call-pairing.test.ts b/tests/unit/call-pairing.test.ts index 5a645a0..d4a24bc 100644 --- a/tests/unit/call-pairing.test.ts +++ b/tests/unit/call-pairing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { requireExactCallPair } from "../live/call-pairing.js"; +import { agentLegForPair, requireExactCallPair } from "../live/call-pairing.js"; const started = Date.parse("2026-08-01T00:00:00Z"); const call = (id: string, offset = 1_000) => ({ @@ -10,6 +10,43 @@ const call = (id: string, offset = 1_000) => ({ }); describe("live call ownership pairing", () => { + it("queries the paired call through the AUT client", async () => { + const seen: unknown[] = []; + const autLeg = { ...call("aut"), direction: "outbound" }; + const aut = { + calls: { + async list(options: unknown) { + seen.push(options); + return [autLeg]; + }, + }, + }; + + await expect( + agentLegForPair( + { ...call("driver"), pairedCallId: "33333333-3333-3333-3333-333333333333" }, + aut, + [], + { direction: "outbound", scenarioStartedAt: started, maxCreationSkewMs: 5_000 }, + ), + ).resolves.toBe(autLeg); + expect(seen).toEqual([{ limit: 2, pairedCallId: "33333333-3333-3333-3333-333333333333" }]); + }); + + it("keeps strict correlation while the additive pair filter rolls out", async () => { + const driver = call("driver"); + const autLeg = call("aut", 2_000); + const aut = { calls: { list: async () => [] } }; + + await expect( + agentLegForPair(driver, aut, [autLeg], { + direction: "inbound", + scenarioStartedAt: started, + maxCreationSkewMs: 5_000, + }), + ).resolves.toBe(autLeg); + }); + it("accepts exactly one current driver/AUT pair", () => { expect( requireExactCallPair([call("driver")], [call("aut", 2_000)], { diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index a4adc5b..90f523e 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import type { InkboxCredentials } from "../../src/client.js"; -import { createInkboxRuntime, NOT_CONFIGURED_MESSAGE } from "../../src/client.js"; +import { + createInkboxRuntime, + NOT_CONFIGURED_MESSAGE, + resolveIdentityWithRetry, +} from "../../src/client.js"; // Only the unconfigured paths are covered here: a configured runtime performs // a whoami() round-trip against the live API on first resolve, so the happy @@ -39,4 +43,39 @@ describe("createInkboxRuntime", () => { expect(NOT_CONFIGURED_MESSAGE).toContain("INKBOX_IDENTITY"); expect(NOT_CONFIGURED_MESSAGE).toContain("opencode.json"); }); + + it("retries a transient identity connection reset", async () => { + const reset = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }); + const resolveIdentity = vi + .fn<() => Promise<{ id: string }>>() + .mockRejectedValueOnce(reset) + .mockResolvedValue({ id: "identity-1" }); + const wait = vi.fn(async () => undefined); + + await expect(resolveIdentityWithRetry(resolveIdentity, wait)).resolves.toEqual({ + id: "identity-1", + }); + expect(resolveIdentity).toHaveBeenCalledTimes(2); + expect(wait).toHaveBeenCalledWith(250); + }); + + it("does not retry a terminal identity response", async () => { + const notFound = Object.assign(new Error("Identity not found"), { status: 404 }); + const resolveIdentity = vi.fn<() => Promise>().mockRejectedValue(notFound); + const wait = vi.fn(async () => undefined); + + await expect(resolveIdentityWithRetry(resolveIdentity, wait)).rejects.toBe(notFound); + expect(resolveIdentity).toHaveBeenCalledTimes(1); + expect(wait).not.toHaveBeenCalled(); + }); + + it("bounds repeated transient identity retries", async () => { + const reset = new Error("Request failed: socket hang up"); + const resolveIdentity = vi.fn<() => Promise>().mockRejectedValue(reset); + const wait = vi.fn(async () => undefined); + + await expect(resolveIdentityWithRetry(resolveIdentity, wait)).rejects.toBe(reset); + expect(resolveIdentity).toHaveBeenCalledTimes(3); + expect(wait.mock.calls).toEqual([[250], [750]]); + }); }); diff --git a/tests/unit/nato-marker.test.ts b/tests/unit/nato-marker.test.ts index 1bc5db3..3bf6036 100644 --- a/tests/unit/nato-marker.test.ts +++ b/tests/unit/nato-marker.test.ts @@ -1,8 +1,8 @@ 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( +const SPEECH_SAFE = new Set( + "banana elephant pineapple alligator motorcycle umbrella dinosaur potato computer volcano airplane butterfly kangaroo octopus calendar chocolate hospital library sandwich telescope".split( " ", ), ); @@ -15,7 +15,7 @@ function marker(runId: string, attempt: string): string[] { .split(" "); } -describe("hosted voice NATO marker", () => { +describe("hosted voice speech-safe marker", () => { it.each([ ["0", "0"], ["1", "1"], @@ -25,6 +25,6 @@ describe("hosted voice NATO marker", () => { 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); + expect(words.every((word) => SPEECH_SAFE.has(word))).toBe(true); }); }); diff --git a/tests/unit/voice-proof.test.ts b/tests/unit/voice-proof.test.ts index fa43239..c902d04 100644 --- a/tests/unit/voice-proof.test.ts +++ b/tests/unit/voice-proof.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { waitAgentTwoWayCall, waitDriverLocalSpeech } from "../live/helpers.js"; import { containsVoiceMarker, hasAfterCallSmsIntent, @@ -29,3 +30,30 @@ describe("hosted live voice proof normalization", () => { expect(hasSmsIntent("Review the text-message history.")).toBe(false); }); }); + +describe("owned-leg transcript proof", () => { + it("accepts local-only speech on the driver leg", async () => { + const owner = { + calls: { + transcripts: async () => [{ party: "local", text: "scripted driver line" }], + get: async () => ({ status: "answered" }), + }, + }; + await expect(waitDriverLocalSpeech(owner as never, "driver", 100)).resolves.toBe( + "scripted driver line", + ); + }); + + it("requires two-way speech on the AUT leg and returns agent-local speech", async () => { + const owner = { + calls: { + transcripts: async () => [ + { party: "remote", text: "caller line" }, + { party: "local", text: "agent reply" }, + ], + get: async () => ({ status: "answered" }), + }, + }; + await expect(waitAgentTwoWayCall(owner as never, "aut", 100)).resolves.toBe("agent reply"); + }); +});