@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.

    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
    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_responselastAgentTranscript). 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.

    validateSignature: boolean
    • 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

      Re-arm per-CALL state at media-stream-loop entry. Both halves are per-call, not per-connection, so a second session on the same connected adapter must not inherit either of them.

      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