@langwatch/scenario
    Preparing search index...

    Class TwilioAgentAdapter

    Abstract base for voice agents that exchange audio with the agent under test.

    Subclasses must implement connect, disconnect, sendAudio, and receiveAudio. They must also publish an AdapterCapabilities instance as the capabilities field — declared once per concrete adapter, not per instance.

    The default call implementation lives in defaultVoiceCall: it extracts audio from the latest user message, transmits via sendAudio, drains the agent response on tail silence, and records one user + one agent segment into the executor state. Subclasses can override call() for specialised flows but will usually inherit it.

    Hierarchy (View Summary)

    Index
    _voiceTurnContext?: Context

    Live OTel context of the CURRENT voice.turn, published by defaultVoiceCall for background-receive-loop adapters (Pipecat/Twilio) to parent their detached-callback recv spans under the turn (#774 — the reusable pattern Twilio PR5 inherits). undefined between turns, so a callback firing outside a turn skips its span rather than parenting under a closed turn. Internal (underscore) — not a public API.

    accountSid: string
    agentHungUp: boolean = false

    SET when the AGENT deliberately ended the call (e.g. an ElevenLabs hosted agent invoking the end_call system tool), as opposed to the transport dropping. A scripted turn arriving after this concludes the conversation instead of failing the run — the agent behaved as designed. Assertions and judges can read it to reason about WHO ended the call.

    agentSpeakingEvent?: AgentSpeakingEvent

    Set when the adapter has emitted its first agent audio chunk for the current turn — gates timing-based barge-in. Concrete adapters expose this so scenario.interrupt can wait for real speech before firing the interruption. Optional: adapters without server-VAD-style interrupt sequencing can leave it undefined.

    allowedCallees?: ReadonlySet<string>
    allowedCallers?: ReadonlySet<string>
    authToken: string
    capabilities: AdapterCapabilities = ...

    Declaration of what this adapter can and cannot do. Concrete subclasses MUST publish a non-default value; the base instance defaults to "nothing supported" so capability-gated steps fail safely when an adapter forgets to declare.

    fetchImpl: (
        input: string | URL | Request,
        init?: RequestInit,
    ) => Promise<Response>
    httpPort: number
    name?: string

    The name that LangWatch shows as the target of the run. When you do not set it, the framework uses the class name of the adapter.

    onDtmf?: (digit: string) => void
    phoneNumber: string
    publicBaseUrl?: string
    rejectedCount: number = 0

    Called by the server when an inbound webhook is rejected (caller filter or bad signature). Exposed for tests; production callers see the HTTP response and never look at this counter.

    responseMaxDuration: number = 30.0

    Hard cap on a single agent turn's audio. Prevents runaway loops if a transport never signals end-of-stream. 30s = a long sentence.

    responseTailSilence: number = 0.6

    Tail silence: once the first agent chunk arrives, keep draining receiveAudio until no chunk shows up within this many seconds — that's how we detect the agent finished talking.

    responseTimeout: number = 60.0

    Seconds to wait for agent audio after sending user audio: the STT + LLM + TTS budget for one agent turn. Kept identical to Python's VoiceAgentAdapter.response_timeout so the same scenario passes or fails the same way in both SDKs.

    Raise it for an agent that runs a tool call or a retrieval step before it speaks:

    const agent = elevenLabsAgent({ agentId, apiKey });
    agent.responseTimeout = 180; // wait up to 3 minutes
    role: AgentRole
    streamingTranscript?: string

    Incremental transcript text emitted while the agent speaks. Populated by adapters that advertise capabilities.streamingTranscripts. Read by scenario.interrupt when afterWords: N is set.

    transcriptGraceWait: number = 2.0

    Bounded grace-wait (seconds) for the agent turn's transcript AFTER audio drains (#734). Audio silence closes the turn (responseTailSilence), but a live voice agent (hosted ElevenLabs) delivers the turn's text on a SEPARATE socket event (agent_response → lastAgentTranscript). When that event lands after the audio-silence boundary, snapshotting lastAgentTranscript at drain-close reads null and the turn reaches the text-only simulator as a bare [audio message] — the simulator then fabricates.

    The default call() flow (defaultVoiceCall) polls this field up to this ceiling for a pending transcript before reading it. It short-circuits the INSTANT lastAgentTranscript is already set (zero added latency on the happy path — the common case where the transcript won the race) and only elapses when the transcript genuinely never arrives, so a real ElevenLabs drop still terminates the turn. Set to 0 to disable the wait.

    tunnelReadiness?: TunnelReadiness
    validateSignature: boolean
    • get _streamNonceForServer(): string | undefined
      Internal

      A-leg WS auth expectation the media loop enforces (#762): the per-call nonce minted at origination, or undefined in every un-gated mode (b-leg, originator-only, inbound). Presence ARMS enforcement.

      Returns string | undefined

    • get _streamWsForServer(): MediaStreamWebSocket | null
      Internal

      The socket that IS this adapter's live transport, or null.

      runStreamSession compares against it so only the owning socket may null the transport on teardown; tests read it as their view of that same state.

      Returns MediaStreamWebSocket | null

    • Wait until the duration cap elapses.

      Test seam (same role as _driveMediaStream): tests replace this on the instance to drive expiry on controlled time instead of waiting for real.

      Parameters

      • ms: number

      Returns Promise<void>

    • Test seam: drive the FULL production per-connection wrapper (TwilioWebhookServer.runStreamSession) over a provided socket — the loop PLUS the finally that nulls _streamWs/_streamSid, exactly as the real /twilio/stream handler does after a call ends. Unlike _driveMediaStream (loop only), this reproduces the #695 teardown race so a follow-up receiveAudio runs against nulled transport state.

      Parameters

      • ws: MediaStreamWebSocket

      Returns Promise<void>

    • Internal

      Single inbound entry point for the media loop. With the speech gate on, only chunks the gate admits (speech, hangover, pre-roll, the terminal sentinel) reach the queue — that arrival gap during the callee's silence is what lets the drain's tail-silence path end the turn.

      Parameters

      Returns void

    • Internal

      Re-arm per-CALL state at media-stream-loop entry. All three parts are per-call, not per-connection, so a second session on the same connected adapter must not inherit any of them. The ended-reason matters as much as the flag: "max_duration" wins every tie by design, so left standing it would report a later, cleanly-stopped session as having hit the cap.

      The flag alone is not enough: the previous call's finally ENQUEUED a terminal sentinel, and if that call ended while no drain was running (the caller hung up between turns) the sentinel is still buffered. receiveAudio drains a non-empty queue without checking liveness, so the new call's first receiveAudio would hand that stale empty chunk to drainAgentResponse as its first chunk — and the drain breaks on an empty chunk, truncating the new call's first agent turn to silence.

      No frame of this call has been enqueued yet, so buffered chunks are the previous session's residue. clearBuffered (not reset) so a consumer already parked in take() stays parked for the new call's real audio.

      Returns void

    • Invalidate any socket adopted under a prior call generation (#762 P1).

      Bumping _callGeneration evicts a socket that connected — and even ADOPTED — before this dial armed the nonce: the media loop captured the old generation at adoption and closes itself on its next frame once the counter moves. We also reset the per-dial connection state the loop writes, so this dial starts clean and, critically, cannot resolve on a stale socket's connected signal: replacing _streamConnected with a fresh deferred discards an early un-gated socket's resolve so placeCall blocks until a genuine socket authenticates; dropping _streamWs/_streamSid stops sendAudio writing to the stale socket; clearing buffered inbound audio and zeroing the frame counter keeps the stale socket's media out of this call; clearing the terminal flag/reason stops its stop reading as this call ending. Inbound/b-leg behaviour is unchanged: no socket has adopted yet when the genuine one arrives, so it adopts under the current generation.

      Returns void

    • Internal

      Disconnect-counter (#775 Tier 2b): how the media session ended.

      The max-duration watchdog closes the socket itself, so the media loop's own "close" verdict lands moments later and would otherwise mask WHY the call ended — the cap's verdict wins ties. connect()/disconnect() assign the field directly and so still clear it for the next session.

      Parameters

      • reason: TwilioStreamEndedReason

      Returns void

    • Whether the Media Stream transport is open (Gap #11). Also false once the stream has ended (_streamEnded) even if disconnect() was never called — a remote hangup (Twilio stop / socket close) must trip the runtime's isConnected() gate on its own, not only after we tear down.

      Exception: while a farewell recorded before the hangup is still sitting in the inbound queue, this stays true so defaultVoiceCall drains it instead of taking the agentHungUp early return and dropping it on the floor. Once the queue holds nothing but the terminal sentinel (or is empty), later turns see false and conclude via agentHungUp.

      Returns boolean

    • Parameters

      • args: {
            attachStream?: "a-leg" | "b-leg";
            attachStreamToSelf?: boolean;
            maxCallDurationSeconds?: number;
            record?: boolean;
            streamNonce?: string;
            timeoutMs?: number;
            to: string;
        }
        • OptionalattachStream?: "a-leg" | "b-leg"

          Stream-attach mode. "b-leg" (default) rewrites the callee's voice_url — owned numbers only. "a-leg" originates with inline <Connect><Stream> so the stream rides our own leg and to can be any external number. Because that reaches numbers this account does not own, to must appear in the adapter's allowedCallees (deny-by-default) and the public base URL must be reachable from the edge — both checked before origination.

        • OptionalattachStreamToSelf?: boolean

          Legacy stream-attach toggle. Superseded by attachStream: true/unset → "b-leg", false → originator-only. Passing both with disagreeing intent throws.

        • OptionalmaxCallDurationSeconds?: number

          How long the CALL may live, in seconds (a-leg only; default DEFAULT_MAX_CALL_DURATION_SECONDS, hard cap MAX_CALL_DURATION_CAP_SECONDS — a larger request throws).

          Not to be confused with timeoutMs, which bounds how long we WAIT FOR THE MEDIA STREAM TO CONNECT and says nothing about the call's length. Under a-leg's <Connect> the call lives exactly as long as the WebSocket, so without a ceiling a hung executor keeps a billing PSTN call open forever. Two mechanisms enforce it: Twilio's own TimeLimit on Calls.create (the load-bearing half — it still fires if this process hangs or is killed) and an adapter-side wall-clock timer that hangs the call up via REST and closes the socket.

        • Optionalrecord?: boolean

          Request Twilio-side call recording (Record=true on Calls.create). Stamped onto the voice.adapter.dial span when set so "was this call recorded" is answerable from the trace alone.

        • OptionalstreamNonce?: string

          Caller-supplied a-leg stream nonce, overriding this adapter's own mintStreamNonce call. Exists for a host platform that runs the media listener Twilio actually dials back in a SEPARATE process from the one placing this call (e.g. a parent worker process fronting a pool of scenario-execution child processes): that platform must register the nonce with its own listener BEFORE Twilio can possibly connect, which means it must know the nonce before placeCall runs — impossible if this adapter mints it internally. Ignored outside a-leg mode, the only mode a stream nonce means anything in.

        • OptionaltimeoutMs?: number
        • to: string

      Returns Promise<void>

    • Production entry for a host platform that accepts Twilio's public connection in a SEPARATE process from the one this adapter runs in (an a-leg call whose parent worker process owns the public listener and routes by nonce to the child that placed the call). That parent accepts the raw TCP upgrade, does NOT complete the WebSocket handshake, and hands the still-unhandshaked socket to this process over IPC — this method is where it arrives. Delegates to TwilioWebhookServer.receiveExternalUpgrade, which re-enters this adapter's ordinary upgrade path, so the a-leg nonce check and the media loop behave identically to a locally-received upgrade.

      A call placed with no host-supplied placeCall({ streamNonce }) never needs this: this adapter's own local server receives Twilio's dial-back directly, the ordinary way.

      Parameters

      Returns void