Implement response handling and speech tracking in CallEnd functionality

- Add begin_response and finish_after_current_speech methods to CallEndCoordinator for better management of speech events.
- Update PromptBrain to utilize new methods, ensuring proper handling of generated closing speech and tool-only calls.
- Enhance tests to verify the correct behavior of speech tracking and response handling in various scenarios, including waiting for audio to finish before ending calls.
- Introduce a new test suite for CallEndCoordinator to validate the interaction with speech frames.
This commit is contained in:
Xin Wang
2026-07-14 13:43:42 +08:00
parent d069e5282e
commit e78dc4088a
5 changed files with 151 additions and 12 deletions

View File

@@ -44,14 +44,26 @@ class FakeCallEnd:
self.reason = ""
self.armed = False
self.finished = False
self.response_started = False
self.waited_for_text: bool | None = None
def begin(self, reason: str) -> None:
self.ending = True
self.reason = reason
def begin_response(self) -> None:
self.response_started = True
def arm_after_speech(self) -> None:
self.armed = True
async def finish_after_current_speech(self, *, has_text: bool) -> None:
self.waited_for_text = has_text
if has_text:
self.armed = True
else:
await self.finish()
async def finish(self) -> None:
self.finished = True
@@ -295,11 +307,52 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(visible_tools[0].name, "end_call")
params = FakeFunctionParams({"reason": "用户已完成咨询"})
await brain.on_assistant_text_start("closing-turn")
await llm.functions["end_call"](params)
self.assertEqual(call_end.reason, "用户已完成咨询")
self.assertFalse(call_end.finished)
await brain.on_assistant_text_end("closing-turn", "", False)
self.assertTrue(call_end.finished)
self.assertFalse(call_end.waited_for_text)
self.assertTrue(call_end.response_started)
self.assertEqual(params.result["action"], "ending_call")
async def test_end_call_waits_for_prompt_generated_closing_speech(self):
tool = RuntimeTool(
id="end-call",
name="结束通话",
function_name="end_call",
type="end_call",
definition={"config": {"message_type": "none"}},
)
cfg = AssistantConfig(type="prompt", tools=[tool])
brain = build_brain(cfg)
llm = FakeLLM()
call_end = FakeCallEnd()
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(messages=[]),
llm=llm,
queue_frame=lambda _frame: None,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
),
)
await brain.on_assistant_text_start("closing-turn")
await llm.functions["end_call"](FakeFunctionParams({}))
await brain.on_assistant_text_end(
"closing-turn",
"感谢您的来电。祝您生活愉快,再见。",
False,
)
self.assertFalse(call_end.finished)
self.assertTrue(call_end.armed)
self.assertTrue(call_end.waited_for_text)
async def test_http_tool_renders_secrets_and_updates_prompt_variable(self):
requests = []

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import unittest
from pipecat.frames.frames import BotStartedSpeakingFrame, BotStoppedSpeakingFrame
from services.pipecat.call_lifecycle import CallEndCoordinator
class CallEndCoordinatorTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.reasons: list[str] = []
async def queue_end(reason: str) -> None:
self.reasons.append(reason)
self.coordinator = CallEndCoordinator(queue_end)
async def test_generated_closing_text_waits_for_audio_stop(self):
self.coordinator.begin_response()
self.coordinator.begin("prompt_end_call")
await self.coordinator.finish_after_current_speech(has_text=True)
self.assertEqual(self.reasons, [])
await self.coordinator.observe(BotStartedSpeakingFrame())
self.assertEqual(self.reasons, [])
await self.coordinator.observe(BotStoppedSpeakingFrame())
self.assertEqual(self.reasons, ["prompt_end_call"])
async def test_already_played_generated_text_finishes_immediately(self):
self.coordinator.begin_response()
await self.coordinator.observe(BotStartedSpeakingFrame())
await self.coordinator.observe(BotStoppedSpeakingFrame())
self.coordinator.begin("prompt_end_call")
await self.coordinator.finish_after_current_speech(has_text=True)
self.assertEqual(self.reasons, ["prompt_end_call"])
async def test_tool_only_end_call_finishes_without_waiting(self):
self.coordinator.begin_response()
self.coordinator.begin("tool_only")
await self.coordinator.finish_after_current_speech(has_text=False)
self.assertEqual(self.reasons, ["tool_only"])
if __name__ == "__main__":
unittest.main()