Skip to content

Realtime interrupt skips conversation.item.truncate when it arrives within one Windows timer tick (elapsed_ms == 0) #4456

Closed

Description

@Chirag6722

_interrupt_audio_playback gates the whole truncation path on elapsed_ms > 0, and elapsed_ms is wall-clock derived. On a Windows host at the default timer resolution that value is exactly 0.0 for the first ~16 ms after audio starts, so an interrupt in that window silently skips conversation.item.truncate.

The path

src/agents/realtime/openai_realtime.py:995

elapsed_ms = (time.monotonic() - audio_state.initial_received_time) * 1000

and the gate at :1072

if elapsed_ms > 0:
    ...              # emits audio-interrupted, computes truncated_ms,
                     # and sends conversation.item.truncate

time.monotonic() on Windows is GetTickCount64():

monotonic impl      : GetTickCount64()
monotonic resolution: 0.015625 s
identical back-to-back reads: 20000/20000

So two reads inside the same tick are the same number, and the subtraction is exactly zero.

Measured on the real object, not in the abstract

m = OpenAIRealtimeWebSocketModel()
m._audio_state_tracker.set_audio_format("pcm16")
m._audio_state_tracker.on_audio_delta("item_1", 0, b"\x00" * 4800)
m._get_playback_state()
playback_state elapsed_ms : 0.0
current_item_id           : item_1
-> the guard is  if elapsed_ms > 0:  so this path is SKIPPED

iterations until elapsed_ms > 0: 9916 | wall: 16.0 ms

16.0 ms is one GetTickCount64 tick.

Why this matters beyond the tests

Truncation is what keeps the model's conversation state in step with what the user actually heard. When it is skipped, the item keeps its full audio, so the model believes it said more than was played — which is the exact condition truncation exists to prevent. A barge-in that lands promptly is more likely to hit this window, not less, so the failure is biased toward the responsive case.

Note the guard is > 0 rather than >= 0: zero elapsed is treated as "nothing to truncate", when on this platform it means "less than one tick has been measurable".

Four tests are load-bearing on ambient timer resolution

On main at 9aba900, uv run pytest tests/realtime/test_openai_realtime.py::TestSendEventAndConfig -p no:randomly:

FAILED ... ::test_normal_interrupt_targets_response_and_interrupts_audio        - assert 0 == 1
FAILED ... ::test_interrupt_respects_auto_cancellation_when_not_forced          - assert 0 == 1
FAILED ... ::test_interrupt_force_cancel_overrides_auto_cancellation
FAILED ... ::test_interrupt_honors_falsy_present_session_auto_cancellation
4 failed, 58 passed

Each seeds audio and interrupts on the next statement, so each assumes the clock advanced in between.

These pass in CI — tests-windows is green on main. I take that to mean the hosted runner is running with the system timer raised (some process calling timeBeginPeriod), which is common on Windows CI. That is the uncomfortable part: whether this code path runs at all depends on a global system setting any other process on the machine can change, and the suite passes or fails accordingly. It is not a property of the code under test.

Possible directions

Not proposing one, since the right call depends on how you want the clock treated:

  1. Inject the clock. The tests could then advance it explicitly and assert the truncate deterministically, on every platform.
  2. Use a higher-resolution source — time.perf_counter() is ~100 ns on Windows and monotonic, and this is a duration measurement, not a timestamp.
  3. Reconsider > 0. If the intent is "skip when no audio has played", zero-elapsed-but-audio-present is not that case.

1 and 3 are independent and both look worth doing; 2 alone would make the window much smaller without removing the class.

Happy to send a PR for whichever shape you prefer — I would default to 2 plus 3, and leave clock injection to you since it touches the test contract.

Environment

Windows 11, Python 3.12.10, main at 9aba900. uv sync only (the dev group cannot install on this host — see #4442), so voice/litellm extras are absent; the failures above are unrelated to those, and reproduce with the four named tests alone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions