-
Notifications
You must be signed in to change notification settings - Fork 3.5k
fix(voice): stop the interrupt walk at a queued speech that disallows interruptions #6644
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
biztex
wants to merge
5
commits into
livekit:main
Choose a base branch
from
biztex:fix/interrupt-partial-application
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+179
−4
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
e02ad3b
fix(voice): never half-apply an interrupt on a protected speech
biztex 0a6109d
fix(voice): interrupt in playout order, stopping at the first protect…
biztex 1ed7452
fix(voice): decide the realtime cancel from the speech that plays next
biztex e28ca1a
fix(voice): walk past speeches that are never going to play
biztex 5602c51
fix(voice): stop the queue walk at a protected speech, always cancel …
biztex File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| """``AgentActivity.interrupt()`` and queued speeches that disallow interruptions. | ||
|
|
||
| ``SpeechHandle.interrupt()`` raises for such a handle, and the queue loop used | ||
| to let that raise escape: the remaining queued speeches were left playing and | ||
| the returned future never resolved. Interrupting *past* the protected speech | ||
| would be wrong too — the ones behind it still play, so skipping one in the | ||
| middle would leave a gap in the conversation. | ||
| """ | ||
|
|
||
| import heapq | ||
| import logging | ||
| import time | ||
| from unittest.mock import Mock | ||
|
|
||
| import pytest | ||
|
|
||
| from livekit.agents.voice.agent_activity import AgentActivity | ||
| from livekit.agents.voice.speech_handle import SpeechHandle | ||
|
|
||
| from .fake_session import FakeActions, create_session | ||
| from .test_agent_session import MyAgent, _close_test_session | ||
|
|
||
| pytestmark = pytest.mark.unit | ||
|
|
||
|
|
||
| def _make_activity() -> AgentActivity: | ||
| return AgentActivity(MyAgent(), create_session(FakeActions())) | ||
|
|
||
|
|
||
| def _enqueue(activity: AgentActivity, speech: SpeechHandle, *, priority: int = 0) -> None: | ||
| """Queue a speech the way _schedule_speech does (a heap, not a list).""" | ||
| heapq.heappush(activity._speech_q, (-priority, time.perf_counter_ns(), speech)) | ||
|
|
||
|
|
||
| class TestInterruptQueuedSpeeches: | ||
| async def test_queue_stops_at_the_protected_speech( | ||
| self, caplog: pytest.LogCaptureFixture | ||
| ) -> None: | ||
| activity = _make_activity() | ||
| activity._rt_session = Mock() | ||
| current = SpeechHandle.create(allow_interruptions=True) | ||
| first = SpeechHandle.create(allow_interruptions=True) | ||
| protected = SpeechHandle.create(allow_interruptions=False) | ||
| behind = SpeechHandle.create(allow_interruptions=True) | ||
| activity._current_speech = current | ||
| for speech in (first, protected, behind): | ||
| _enqueue(activity, speech) | ||
|
|
||
| try: | ||
| with caplog.at_level(logging.WARNING, logger="livekit.agents"): | ||
| activity.interrupt() # must not raise | ||
|
|
||
| assert current.interrupted | ||
| assert first.interrupted | ||
| assert not protected.interrupted | ||
| # no hole: what plays after the protected speech is untouched | ||
| assert not behind.interrupted | ||
| # the rest of the sequence still ran | ||
| activity._rt_session.interrupt.assert_called_once() | ||
| assert any("force=True" in record.message for record in caplog.records) | ||
| finally: | ||
| await _close_test_session(activity._session) | ||
|
|
||
| async def test_a_protected_head_shields_the_whole_queue(self) -> None: | ||
| # [protected, interruptible, protected]: interrupting only the middle | ||
| # one would play the first and third with a gap between them | ||
| activity = _make_activity() | ||
| activity._rt_session = Mock() | ||
| head = SpeechHandle.create(allow_interruptions=False) | ||
| middle = SpeechHandle.create(allow_interruptions=True) | ||
| tail = SpeechHandle.create(allow_interruptions=False) | ||
| for speech in (head, middle, tail): | ||
| _enqueue(activity, speech) | ||
|
|
||
| try: | ||
| activity.interrupt() | ||
|
|
||
| assert not head.interrupted | ||
| assert not middle.interrupted | ||
| assert not tail.interrupted | ||
| finally: | ||
| await _close_test_session(activity._session) | ||
|
|
||
| async def test_the_queue_is_walked_in_playout_order_not_heap_order(self) -> None: | ||
| # a higher-priority speech is queued last but plays first; the heap's | ||
| # list order does not reflect that, the walk must | ||
| activity = _make_activity() | ||
| activity._rt_session = Mock() | ||
| low = SpeechHandle.create(allow_interruptions=True) | ||
| urgent_protected = SpeechHandle.create(allow_interruptions=False) | ||
| _enqueue(activity, low, priority=SpeechHandle.SPEECH_PRIORITY_NORMAL) | ||
| _enqueue(activity, urgent_protected, priority=SpeechHandle.SPEECH_PRIORITY_HIGH) | ||
|
|
||
| try: | ||
| activity.interrupt() | ||
|
|
||
| # the protected speech plays first, so the walk stops immediately | ||
| assert not urgent_protected.interrupted | ||
| assert not low.interrupted | ||
| finally: | ||
| await _close_test_session(activity._session) | ||
|
|
||
| async def test_a_protected_playing_speech_still_raises(self) -> None: | ||
| # unchanged behaviour: SpeechHandle.interrupt() is explicit about it | ||
| activity = _make_activity() | ||
| activity._current_speech = SpeechHandle.create(allow_interruptions=False) | ||
|
|
||
| try: | ||
| with pytest.raises(RuntimeError): | ||
| activity.interrupt() | ||
| finally: | ||
| await _close_test_session(activity._session) | ||
|
|
||
| async def test_force_interrupts_the_whole_chain(self) -> None: | ||
| activity = _make_activity() | ||
| activity._rt_session = Mock() | ||
| current = SpeechHandle.create(allow_interruptions=False) | ||
| queued = SpeechHandle.create(allow_interruptions=False) | ||
| behind = SpeechHandle.create(allow_interruptions=True) | ||
| activity._current_speech = current | ||
| for speech in (queued, behind): | ||
| _enqueue(activity, speech) | ||
|
|
||
| try: | ||
| activity.interrupt(force=True) | ||
|
|
||
| assert current.interrupted | ||
| assert queued.interrupted | ||
| assert behind.interrupted | ||
| activity._rt_session.interrupt.assert_called_once() | ||
| finally: | ||
| await _close_test_session(activity._session) | ||
|
|
||
| async def test_interruptible_chain_is_unaffected(self) -> None: | ||
| activity = _make_activity() | ||
| activity._rt_session = Mock() | ||
| current = SpeechHandle.create(allow_interruptions=True) | ||
| queued = SpeechHandle.create(allow_interruptions=True) | ||
| activity._current_speech = current | ||
| _enqueue(activity, queued) | ||
|
|
||
| try: | ||
| activity.interrupt() | ||
|
|
||
| assert current.interrupted | ||
| assert queued.interrupted | ||
| activity._rt_session.interrupt.assert_called_once() | ||
| finally: | ||
| await _close_test_session(activity._session) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.