Skip to content

Adaptive interruption: responses are not bound to the overlap they were requested for, so a late bargein can falsely interrupt a later overlap #2119

Description

@toubatbrian

Summary

bargein_detected / inference_done responses are matched to "whatever overlap is open right now" rather than to the overlap their request was cut for. A response that arrives after its own overlap ended, but while a later overlap is open, is accepted and attributed to that later overlap — which can emit a false isInterruption: true and cut off the agent for a user utterance the model never scored that way.

This is pre-existing on main and is not introduced by #2116 / #2117; it was raised by automated review on both of those PRs, so filing it here to track it separately.

Mechanism

handleMessage() gates only on the current global overlap flag:

case MSG_INTERRUPTION_DETECTED: {
  const createdAt = message.created_at;
  const overlapSpeechStartedAt = state.overlapSpeechStartedAt;
  if (state.overlapSpeechStarted && overlapSpeechStartedAt !== undefined) {
    const existing = state.cache.get(createdAt);
    ...
    const entry = state.cache.setOrUpdate(createdAt, () => new InterruptionCacheEntry({ createdAt }), { ... });

Two things make this insufficient:

  • Nothing ties created_at back to the overlap that produced it. The audio transformer clears the cache at overlap-speech-started, so a stale response finds existing === undefined — but setOrUpdate() then recreates an entry from scratch, so the clear is not a filter.
  • detectionDelayInS and the emitted event's overlapStartedAt are computed from the new overlap's overlapSpeechStartedAt, so the resulting event looks like a well-formed verdict for the current overlap.

Ordinary network latency is enough to hit this — no reconnect required:

  1. Overlap A is open; a slice is sent at T.
  2. The user pauses; overlap-speech-ended fires; overlapSpeechStartedfalse.
  3. The user speaks again; overlap-speech-started fires; overlapSpeechStartedtrue (overlap B), cache cleared.
  4. The response for the slice sent at T arrives. state.overlapSpeechStarted is true, so it is accepted — and if it is bargein_detected, overlap B is reported as an interruption on the strength of overlap A's audio.

Since the gap between two overlaps in one agent turn is often only a few hundred ms (the keeps making requests across repeated short overlaps in one agent turn test in interruption_pipeline.test.ts exercises exactly that pattern), this is reachable with normal gateway latency.

Relationship to #2116 / #2117

handleMessage() is byte-identical to main across both PRs; the only change in that file is the removal of the send-time gate in transform(). The send gate never protected the response path — it only decided which slices got sent, and the misattribution above happens for slices that were sent perfectly legitimately inside their own overlap.

#2116 does make one new variant reachable (a slice parked on an in-flight reconnect being sent after its overlap closed), but that is much rarer than plain response latency, and it is a strictly smaller instance of the same underlying gap.

The per-overlap request accounting added in #2116 introduces InterruptionAudioSlice.overlapGeneration, which is the natural hook for the fix: stamp the generation onto the InterruptionCacheEntry in sendAudioData() and reject responses in handleMessage() whose request belongs to a generation that is no longer open.

Note that this needs a store that survives cache.clear() (or the generation has to be recorded outside the bounded cache), since a stale response's entry is exactly the one that got cleared — and simply requiring existing !== undefined would be wrong, because the BoundedCache holds only 10 entries and can legitimately evict a live request during a long overlap, which would suppress real interruptions.

Origin

Reported by Codex review on #2116 and #2117 ("Keep late responses bound to their original overlap" / "Reject responses belonging to a completed overlap"). Verified against the code; not reproduced at runtime.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions