Skip to content

fix(voice): terminate wait=False test drain on end-of-turn, re-enable in CI#691

Open
minh2416294 wants to merge 1 commit into
langwatch:mainfrom
minh2416294:flake/diagnose
Open

fix(voice): terminate wait=False test drain on end-of-turn, re-enable in CI#691
minh2416294 wants to merge 1 commit into
langwatch:mainfrom
minh2416294:flake/diagnose

Conversation

@minh2416294

Copy link
Copy Markdown

Why

The agent(wait=False) voice tests in tests/voice/test_agent_wait_false.py were skipped in CI via @pytest.mark.skipif(CI=="true") with the note "hangs in python-ci, can't reproduce locally." The hang isn't infinite — it's a ~90s turn. The test's _SlowAdapter.recv_audio returns a non-empty audio chunk on every call, so the base _drain_agent_response loop (scenario/voice/adapter.py) never tail-silences and runs until accumulated_audio >= response_max_duration (30s of audio ≈ 90s of wall-clock at this cadence). That trips CI's 60s timeout reliably under load while squeaking under it on faster local machines — a textbook timing flake. This restores the lost CI coverage of the wait=False concurrency primitive.

Relates to #648 (same audio-gated-drain class; this PR fixes the test fixture side, not the product-side adapter terminal path).

What changed

  • Fixed the test fixture to honor the real adapter end-of-turn contract. _SlowAdapter now emits one real chunk, then a terminal empty AudioChunk(data=b"") — the same end-of-turn signal real adapters send (OpenAI Realtime, Gemini Live, Composable) so the base drain loop exits on tail silence instead of running to response_max_duration. Chose fixing the unrealistic mock over adding a wall-clock cap to the base loop, because fix(voice): surface tool-only realtime turns (no audio chunk) #647 establishes that the drain terminates on an audio-terminal event, deliberately keeping response_timeout as the only time-guard.
  • Removed the skipif(CI) workaround (and the now-unused import os). With the fixture fixed, the tests run in CI again — closing the coverage gap the skip created.
  • Added @pytest.mark.timeout(30) to test_agent_wait_false_returns_before_turn_finishes so any regression of the ~90s drain fails fast and loud instead of wedging the runner.

Test plan

# from python/
CI=true uv run pytest tests/voice/test_agent_wait_false.py -p no:cacheprovider -v
# → 3 passed in ~1.2s
  • Reproduced the original hang cross-platform before the fix: Windows + Linux (WSL2 Ubuntu), Python 3.12 — scenario.run() with the wait=False script ran ~90s (measured: RETURNED after 90.3s success=True) and exceeded the 60s pytest-timeout.
  • After the fix, the full file passes in ~1.2s on both platforms with CI=true set (the condition that previously skipped it).
  • The non-blocking assertion is unchanged and still meaningful: _stamp fires before slow.recv_returned_at (the wait=False step returns control before the slow recv completes). The sibling test_double_wait_false_in_flight_raises race is preserved (the fixture change only affects calls after the first chunk).

How I can prove I was successful

No playable artifact — see Test plan. This is a test-fixture/control-flow change; the demonstration is the suite going from skipped-in-CI (~90s drain on the parent commit when the skip is removed) to 3 passed in ~1.2s on this branch with CI=true.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9f012350-9018-4945-ac8b-8ebc7e942a77

📥 Commits

Reviewing files that changed from the base of the PR and between 90bd4ff and 5cadd05.

📒 Files selected for processing (1)
  • python/tests/voice/test_agent_wait_false.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • python/tests/voice/test_agent_wait_false.py

Walkthrough

The test_agent_wait_false.py test is updated to run deterministically in CI. The _SlowAdapter gains a _spoke state flag and its recv_audio method now emits an empty AudioChunk after the first audio chunk to trigger tail-silence termination. The CI skip guard is removed, a pytest.mark.timeout(30) is added to the regression test, and module/test documentation is updated.

Changes

agent(wait=False) CI hang fix

Layer / File(s) Summary
_SlowAdapter tail-silence termination
python/tests/voice/test_agent_wait_false.py
Adds _spoke instance state; recv_audio returns a non-empty AudioChunk on the first call then returns an empty AudioChunk on subsequent calls to signal end-of-turn, enabling the response-drain loop to exit.
Timeout guard and documentation
python/tests/voice/test_agent_wait_false.py
Removes the CI=true skip guard and os import; updates the module docstring to describe the fix; adds pytest.mark.timeout(30) and an expanded docstring to test_agent_wait_false_returns_before_turn_finishes.

Suggested Reviewers

  • rogeriochaves
  • Aryansharma28
  • sergioestebance
  • 0xdeafcafe

Poem

🐇 A slow adapter once hung up the show,
Emitting its audio with nowhere to go.
Now _spoke flips true after the first sound,
An empty chunk follows, tail-silence found!
The test times out safely at thirty seconds flat —
No CI hang ever again, and that's that! 🎙️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: fixing the wait=False test to properly drain on end-of-turn signals and re-enabling it in CI.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the root cause of the hang, the fix applied, and how it was validated.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@minh2416294

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/tests/voice/test_agent_wait_false.py`:
- Line 32: Add explicit type annotations to comply with the repository's typing
requirements. For the class attribute `self._spoke` on line 32, add a type
annotation indicating it is a boolean type. For the async function
`test_agent_wait_false_returns_before_turn_finishes` on line 67, add a return
type annotation to specify what the function returns (typically None for async
test functions). Use Python type hint syntax to annotate both symbols
appropriately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5379e078-2cd1-4c7b-a4e7-fd568ec90731

📥 Commits

Reviewing files that changed from the base of the PR and between b819849 and 90bd4ff.

📒 Files selected for processing (1)
  • python/tests/voice/test_agent_wait_false.py

super().__init__()
self.recv_delay = recv_delay
self.recv_returned_at: float | None = None
self._spoke = False

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read-only check for missing annotations in the reviewed file.
python - <<'PY'
import ast
from pathlib import Path

p = Path("python/tests/voice/test_agent_wait_false.py")
tree = ast.parse(p.read_text(), filename=str(p))

missing_returns = []
for node in ast.walk(tree):
    if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
        if node.returns is None:
            missing_returns.append((node.name, node.lineno))

print("Functions missing return annotations:")
for name, line in missing_returns:
    print(f"  - {name} (line {line})")

print("\nPotential unannotated self-attributes in __init__:")
for cls in [n for n in tree.body if isinstance(n, ast.ClassDef)]:
    for fn in cls.body:
        if isinstance(fn, ast.FunctionDef) and fn.name == "__init__":
            for stmt in fn.body:
                if isinstance(stmt, ast.Assign):
                    for target in stmt.targets:
                        if (
                            isinstance(target, ast.Attribute)
                            and isinstance(target.value, ast.Name)
                            and target.value.id == "self"
                        ):
                            print(f"  - {cls.name}.{target.attr} (line {stmt.lineno})")
PY

Repository: langwatch/scenario

Length of output: 548


Add explicit type annotations for newly touched symbols.

The class attribute self._spoke (line 32) and async function test_agent_wait_false_returns_before_turn_finishes (line 67) lack type annotations, violating the repository's strict typing requirements.

Suggested patch
 class _SlowAdapter(VoiceAgentAdapter):
@@
-        self._spoke = False
+        self._spoke: bool = False
@@
-async def test_agent_wait_false_returns_before_turn_finishes():
+async def test_agent_wait_false_returns_before_turn_finishes() -> None:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/tests/voice/test_agent_wait_false.py` at line 32, Add explicit type
annotations to comply with the repository's typing requirements. For the class
attribute `self._spoke` on line 32, add a type annotation indicating it is a
boolean type. For the async function
`test_agent_wait_false_returns_before_turn_finishes` on line 67, add a return
type annotation to specify what the function returns (typically None for async
test functions). Use Python type hint syntax to annotate both symbols
appropriately.

Source: Coding guidelines

… in CI

_SlowAdapter.recv_audio returned audio on every call, so the base
_drain_agent_response loop never tail-silenced and ran to
response_max_duration (~90s wall-clock) — tripping CI's 60s timeout while
squeaking under it locally. The fixture now emits one chunk then an
end-of-turn empty chunk, the contract every real adapter honors, so the
turn terminates in ~1s. Removes the skipif(CI) workaround and adds a
timeout(30) regression guard.

Relates to langwatch#648.
@minh2416294

Copy link
Copy Markdown
Author

Thanks! Applied self._spoke: bool = False to match the adjacent recv_returned_at: float | None annotation in the same fixture.

I skipped the -> None on the test function: none of the 203 async def test_* functions under tests/voice/ annotate a return type, so adding it here would diverge from the suite's established convention rather than match it. Happy to add it if the maintainers prefer the explicit form.

@langwatch-agent

Copy link
Copy Markdown
Contributor

🐕 PR Hound review brief — assigned reviewer @drewdrewthis

Review mode: Fast Skim
Scariest prod risk: none found (test-only fixture change, single file, CodeRabbit clean).

Inspection targets (max 3, each names a file or behavior):

  • [Must Check] test_agent_wait_false.py — the whole point of the test is that scenario.run() returns before the turn finishes; with the mock now tail-silencing after one chunk the turn completes in ~1.2s, so confirm the _stamp fires before slow.recv_returned_at assertion is still meaningfully non-trivial and not passing by accident.
  • [Probably Fine] _spoke flag on _SlowAdapter — verify the sibling test_double_wait_false_in_flight_raises race still exercises an in-flight recv (the empty-chunk only affects calls after the first).

Author questions (optional, max 3):

@langwatch-agent langwatch-agent added the review: fast-skim PR Hound review mode label Jul 3, 2026
@minh2416294

minh2416294 commented Jul 3, 2026

Copy link
Copy Markdown
Author

Thanks for the brief — went back through the three points against the code.

1. Is returned_at < slow.recv_returned_at still meaningful, or passing by accident?

Still meaningful. The tail-silence change only affects when the drain loop exits, not when the first recv stamps. _stamp runs synchronously right after scenario.agent(wait=False) returns control; slow.recv_returned_at is only set after the await asyncio.sleep(0.3) inside the first recv_audio. The sleep(0.5) step then lets that first recv actually finish, so recv_returned_at is populated by assert time. The ordering assertion is testing the wait=False primitive itself (control returns before the slow recv completes), which is orthogonal to the empty-chunk termination. Dropping the fixture's turn from ~90s to ~1.2s doesn't touch that ordering — the 0.3s recv delay is what the stamp races, and it's unchanged.

2. Does test_double_wait_false_in_flight_raises still exercise an in-flight recv?

Yes. The empty chunk only returns on the second recv_audio call (gated by _spoke). The first call still sleeps recv_delay=0.5 and is in-flight when the second agent(wait=False) is scheduled, so the "already in flight" RuntimeError fires on a genuinely pending recv. The race is preserved.

3. Does empty-chunk-on-every-subsequent-call faithfully model the end-of-turn contract, or could it mask an #648 regression?

It's faithful, and it can't mask #648. The base _drain_agent_response loop (scenario/voice/adapter.py) breaks on the first empty chunk:

while accumulated < self.response_max_duration:
    try:
        nxt = await self.recv_audio(timeout=self.response_tail_silence)
    except asyncio.TimeoutError:
        break
    if not nxt.data:
        break        # <- exits here on the first data=b""
    ...

So whether the mock returns empty once or on every later call is immaterial to the drain — it exits on the first data=b"", which is exactly the end-of-turn signal real adapters send. Returning empty on every subsequent call is just defensive (nothing calls recv_audio again after the break), not a behavior the loop depends on.

On #648: that's the product-side path — a real adapter whose recv never yields tail silence, where the drain has no wall-clock guard beyond response_max_duration (30s). This PR only fixes the test fixture to honor the contract; it doesn't touch the product-side drain, so it can't hide a regression there. If anything the mock is now more faithful to the real contract than the previous "audio on every call" version, which is what created the 90s CI hang in the first place.

One note on the CodeRabbit docstring-coverage warning (16.67% < 80%): I left it as-is. The flagged symbols are test functions and the _CannedUser/_SlowAdapter stub methods — none of the ~200 async def test_* functions under tests/voice/ carry docstrings or return annotations, so adding them here would diverge from the suite's convention rather than match it (same reasoning as the -> None skip above). Happy to add them if you'd prefer the explicit form.

@drewdrewthis drewdrewthis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

@minh2416294

Copy link
Copy Markdown
Author

@drewdrewthis thank you brother

@minh2416294

Copy link
Copy Markdown
Author

This one is approved by @drewdrewthis, CI is green, and it shows as mergeable. It just needs a merge. @rogeriochaves would you be able to take a look? Happy to rebase if that helps. Thank you.

@drewdrewthis

Copy link
Copy Markdown
Collaborator

CI regression diagnosis

Root cause: main-moved breakage, not a problem with this PR's code.

The 3 failing checks are caused by an infra gap in python-ci.yml that surfaces when a fork PR's merge commit includes upstream's python/examples/test_testing_remote_agents_*.py files.

What happened

  1. PR branch (flake/diagnose) is 11 commits behind main — the first run was action_required on Jun 20, so CI never ran until drewdrewthis approved on Jul 6.
  2. By then, the merge commit (95153b2) includes upstream main's python/examples/test_testing_remote_agents_{sse,stateful,streaming}.py.
  3. Each of those files calls client = AsyncOpenAI() at module level — pytest collection fails with Missing credentials before any test body runs.
  4. Fork PRs get empty secrets (OPENAI_API_KEY = ""), so the Test (Examples) step always fails for fork PRs that hit these files.

The 3 failing checks

Check Cause
test (3.12) pytest examples/ collection error — openai.OpenAIError: Missing credentials on 3 files
python-complete Aggregator fails because test job failed
Validate PR Title Resource not accessible by integration — a GitHub permissions issue on fork PRs with the amannn/action-semantic-pull-request action; unrelated to the title format (title is valid conventional commit)

Fix needed in python-ci.yml (maintainer action)

Add an if: guard to the Test (Examples) step so it skips on fork PRs:

- name: Test (Examples)
  if: github.event.pull_request.head.repo.full_name == github.repository
  run: uv run pytest examples/ -v --tb=short --timeout=300
  env:
    LANGWATCH_API_KEY: ${{ secrets.LANGWATCH_API_KEY }}
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
    GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
  working-directory: python

The python-complete aggregator job also needs to handle this — the if: always() aggregation already handles skipped as a success, so adding the if: guard above is sufficient.

This PR's code is clean

The PR changes (tests/voice/test_agent_wait_false.py fixture fix + skipif removal) are unaffected. Once the python-ci.yml guard is merged to main and a rebase is done, CI will pass cleanly. Alternatively, a maintainer can push the guard fix to main and re-trigger CI on this PR.

drewdrewthis's approval should remain valid — no code in this PR changed.

@langwatch-agent langwatch-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No code/security findings from static external-PR review. The fixture change matches the documented end-of-turn contract and narrows the test-only CI skip. I did not execute fork code. Residual blockers: current CI shows failing Validate PR Title and test (3.12) / python-complete, so those need to be fixed or explained before merge.

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

Labels

hound-checked Triaged by the pr-hound agent at the current head SHA review: fast-skim PR Hound review mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants