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:
- Overlap A is open; a slice is sent at
T.
- The user pauses;
overlap-speech-ended fires; overlapSpeechStarted → false.
- The user speaks again;
overlap-speech-started fires; overlapSpeechStarted → true (overlap B), cache cleared.
- 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.
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.
Summary
bargein_detected/inference_doneresponses 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 falseisInterruption: trueand cut off the agent for a user utterance the model never scored that way.This is pre-existing on
mainand 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:Two things make this insufficient:
created_atback to the overlap that produced it. The audio transformer clears the cache atoverlap-speech-started, so a stale response findsexisting === undefined— butsetOrUpdate()then recreates an entry from scratch, so the clear is not a filter.detectionDelayInSand the emitted event'soverlapStartedAtare computed from the new overlap'soverlapSpeechStartedAt, 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:
T.overlap-speech-endedfires;overlapSpeechStarted→false.overlap-speech-startedfires;overlapSpeechStarted→true(overlap B), cache cleared.Tarrives.state.overlapSpeechStartedistrue, so it is accepted — and if it isbargein_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 turntest ininterruption_pipeline.test.tsexercises exactly that pattern), this is reachable with normal gateway latency.Relationship to #2116 / #2117
handleMessage()is byte-identical tomainacross both PRs; the only change in that file is the removal of the send-time gate intransform(). 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 theInterruptionCacheEntryinsendAudioData()and reject responses inhandleMessage()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 requiringexisting !== undefinedwould be wrong, because theBoundedCacheholds 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.