Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions livekit-agents/livekit/agents/voice/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,10 +1058,11 @@ async def start(
run_state = RunResult(output_type=None)
self._global_run_state = run_state

# it is ok to await it directly, there is no previous task to drain
tasks.append(
asyncio.create_task(self._update_activity(self._agent, wait_on_enter=False))
)
# it is ok to await it directly, there is no previous task to drain.
# _update_activity_task also watches on_enter on the run state: without it
# the run completes as soon as the first speech does, dropping whatever
# on_enter produces next — and never completes when on_enter says nothing.
tasks.append(asyncio.create_task(self._update_activity_task(None, self._agent)))

try:
await asyncio.gather(*tasks)
Expand Down
70 changes: 70 additions & 0 deletions tests/test_start_capture_run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from __future__ import annotations

import asyncio

import pytest

from livekit.agents import Agent, AgentSession

from .fake_llm import FakeLLM, FakeLLMResponse

pytestmark = [pytest.mark.unit, pytest.mark.virtual_time, pytest.mark.no_concurrent]


class ChattyAgent(Agent):
"""Speaks twice from on_enter: a deterministic say, then a generated reply."""

def __init__(self) -> None:
super().__init__(instructions="chatty agent")

async def on_enter(self) -> None:
await self.session.say("deterministic greeting")
await self.session.generate_reply(instructions="generated_greeting")


class SilentAgent(Agent):
"""Produces no speech at all from on_enter."""

def __init__(self) -> None:
super().__init__(instructions="silent agent")


def _llm() -> FakeLLM:
return FakeLLM(
fake_responses=[
FakeLLMResponse(
input="generated_greeting", content="generated greeting", ttft=0.1, duration=0.1
),
]
)


async def test_start_capture_run_records_every_on_enter_speech() -> None:
"""``start(capture_run=True)`` must keep the run open for the whole of on_enter.

The run used to complete as soon as the first speech did, so a
``generate_reply`` issued after an awaited ``say`` never reached the
RunResult (#4662).
"""
async with AgentSession(llm=_llm()) as sess:
result = await asyncio.wait_for(sess.start(ChattyAgent(), capture_run=True), timeout=10.0)

result.expect.next_event().is_agent_handoff(new_agent_type=ChattyAgent)
first = result.expect.next_event().is_message(role="assistant")
assert first.event().item.text_content == "deterministic greeting"
second = result.expect.next_event().is_message(role="assistant")
assert second.event().item.text_content == "generated greeting"
result.expect.no_more_events()


async def test_start_capture_run_completes_with_a_silent_on_enter() -> None:
"""An on_enter that produces no speech must still complete the run.

Without a watched handle the RunResult never resolved, and
``start(capture_run=True)`` hung forever.
"""
async with AgentSession(llm=_llm()) as sess:
result = await asyncio.wait_for(sess.start(SilentAgent(), capture_run=True), timeout=5.0)

result.expect.next_event().is_agent_handoff(new_agent_type=SilentAgent)
result.expect.no_more_events()