feat: make workflow messages resumable

This commit is contained in:
Xin Wang
2026-08-03 07:54:51 +08:00
parent e3035abcc2
commit f5c36a62aa
10 changed files with 423 additions and 175 deletions

View File

@@ -965,7 +965,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("fetch_user_image", custom_config["role_message"])
self.assertFalse(scopes[-1]["enabled"])
async def test_initial_fixed_speech_starts_without_workflow_greeting(self):
async def test_initial_message_starts_without_workflow_greeting(self):
brain = WorkflowBrain(
{
"specVersion": 3,
@@ -976,20 +976,30 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
"type": "start",
"data": {"greeting": "欢迎使用"},
},
{
"id": "message",
"type": "message",
"data": {
"speech": "请问您怎么称呼?",
"showMessage": False,
},
},
{
"id": "agent",
"type": "agent",
"data": {
"prompt": "收集用户信息",
"entryMode": "fixed_speech",
"entrySpeech": "请问您怎么称呼?",
},
"data": {"prompt": "收集用户信息"},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "message",
"data": {"mode": "always", "priority": 0},
},
{
"id": "after_message",
"source": "message",
"target": "agent",
"data": {"mode": "always", "priority": 0},
}
@@ -1032,15 +1042,17 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
)
await brain.on_connected()
self.assertEqual(brain._manager.current_node, "message")
for _ in range(3):
await asyncio.sleep(0)
self.assertEqual(brain._manager.current_node, "agent")
fixed_speech_frames = [
message_speech_frames = [
frame for frame in queued if isinstance(frame, TTSSpeakFrame)
]
self.assertEqual(len(fixed_speech_frames), 1)
self.assertEqual(fixed_speech_frames[0].text, "请问您怎么称呼?")
self.assertEqual(len(message_speech_frames), 1)
self.assertEqual(message_speech_frames[0].text, "请问您怎么称呼?")
# Workflow no longer owns a greeting playback lifecycle. Stray generic
# transport notifications must not repeat Agent entry behavior.
# Stray generic greeting notifications must not replay the Message.
await brain.on_greeting_finished()
self.assertEqual(
len([frame for frame in queued if isinstance(frame, TTSSpeakFrame)]),
@@ -1533,6 +1545,175 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(result.succeeded)
self.assertEqual(input_states, [False, True])
async def test_message_between_agents_resumes_after_playback(self):
graph = {
"specVersion": 3,
"settings": {},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "opening",
"type": "message",
"data": {"speech": "欢迎使用。"},
},
{
"id": "agent1",
"type": "agent",
"data": {"prompt": "收集基本信息"},
},
{
"id": "middle",
"type": "message",
"data": {"speech": "现在进入信息确认。"},
},
{
"id": "agent2",
"type": "agent",
"data": {"prompt": "确认信息", "contextPolicy": "fresh"},
},
{
"id": "end",
"type": "end",
"data": {"scope": "session"},
},
],
"edges": [
{
"id": "start-opening",
"source": "start",
"target": "opening",
"data": {"mode": "always"},
},
{
"id": "opening-agent1",
"source": "opening",
"target": "agent1",
"data": {"mode": "always"},
},
{
"id": "agent1-middle",
"source": "agent1",
"target": "middle",
"data": {
"mode": "llm",
"priority": 10,
"condition": "基本信息已经收集完成",
},
},
{
"id": "middle-agent2",
"source": "middle",
"target": "agent2",
"data": {"mode": "always"},
},
{
"id": "agent2-end",
"source": "agent2",
"target": "end",
"data": {
"mode": "llm",
"priority": 10,
"condition": "用户确认可以结束通话",
},
},
],
}
brain = WorkflowBrain(graph)
queued = []
input_states = []
class PlaybackCallEnd(FakeCallEnd):
def __init__(self):
super().__init__()
self.completions = []
def track_speech(self):
completion = asyncio.get_running_loop().create_future()
self.completions.append(completion)
return completion
class FakeManager:
def __init__(self):
self.current_node = None
self.configs = []
async def initialize(self, config):
self.current_node = config["name"]
self.configs.append(config)
async def set_node_from_config(self, config):
self.current_node = config["name"]
self.configs.append(config)
async def queue_frame(frame):
queued.append(frame)
call_end = PlaybackCallEnd()
manager = FakeManager()
class MatchingRouter:
async def select_edge(self, **kwargs):
edge = kwargs["edges"][0]
return LLMRouteResult(
status=RouteStatus.MATCHED,
function_name=kwargs["edge_name"](edge),
)
brain._router = MatchingRouter()
brain._runtime = BrainRuntime(
context=LLMContext(messages=[]),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=lambda _prompt: None,
set_tools=lambda _tools: None,
call_end=call_end,
set_input_enabled=input_states.append,
)
brain._manager = manager
await brain.on_connected()
await asyncio.sleep(0)
self.assertEqual(manager.current_node, "opening")
self.assertEqual(len(call_end.completions), 1)
call_end.completions[0].set_result(None)
for _ in range(5):
await asyncio.sleep(0)
if manager.current_node == "agent1":
break
self.assertEqual(manager.current_node, "agent1")
# The user-turn processor must return while the second Message is
# still waiting for its transport playback boundary.
await asyncio.wait_for(
brain.on_user_turn_end("基本信息已经收集完成"),
timeout=0.1,
)
await asyncio.sleep(0)
self.assertEqual(manager.current_node, "middle")
self.assertEqual(len(call_end.completions), 2)
self.assertFalse(call_end.completions[1].done())
call_end.completions[1].set_result(None)
for _ in range(5):
await asyncio.sleep(0)
if manager.current_node == "agent2":
break
self.assertEqual(manager.current_node, "agent2")
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
self.assertEqual(
manager.configs[-1]["task_messages"],
[
{"role": "user", "content": "基本信息已经收集完成"},
{"role": "assistant", "content": "现在进入信息确认。"},
],
)
await brain.on_assistant_text_end("agent2-turn", "信息确认完成", False)
await brain.on_user_turn_end("结束通话")
self.assertEqual(manager.current_node, "end")
self.assertTrue(call_end.finished)
async def test_nodes_without_outgoing_edges_remain_active(self):
queued = []
@@ -2087,6 +2268,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(context.get_messages(), [])
await brain.on_connected()
self.assertEqual(brain._manager.current_node, "agent")
await brain.on_client_ready()
variable_events = [
frame.message
for frame in queued
@@ -2150,58 +2332,14 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
await brain._after_node_activated(generate_config)
self.assertTrue(any(isinstance(frame, LLMRunFrame) for frame in queued))
brain._engine.data("agent").update(
{"entryMode": "fixed_speech", "entrySpeech": "您好,{{user_name}}"}
)
fixed_config = brain._agent_config("agent")
self.assertFalse(fixed_config["respond_immediately"])
self.assertNotIn("pre_actions", fixed_config)
self.assertEqual(
fixed_config["task_messages"],
[{"role": "assistant", "content": "您好,王先生"}],
)
brain._engine.data("agent")["entryMode"] = "wait_user"
self.assertEqual(
brain._agent_config(
"agent",
[{"role": "assistant", "content": "正在进入下一阶段"}],
)["task_messages"],
[
{"role": "assistant", "content": "正在进入下一阶段"},
{"role": "assistant", "content": "您好,王先生"},
],
[{"role": "assistant", "content": "正在进入下一阶段"}],
)
worker.frames.clear()
queued.clear()
await brain._manager.set_node_from_config(fixed_config)
await brain._after_node_activated(fixed_config)
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
context_updates = [
frame
for frame in worker.frames
if isinstance(frame, LLMMessagesUpdateFrame)
]
self.assertEqual(
context_updates[-1].messages,
[{"role": "assistant", "content": "您好,王先生"}],
)
self.assertFalse(
any(
isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("source") == "workflow-fixed-reply"
for frame in queued
)
)
await brain.on_client_ready()
fixed_reply_events = [
frame.message
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("source") == "workflow-fixed-reply"
]
self.assertEqual(fixed_reply_events[0]["content"], "您好,王先生")
self.assertEqual(fixed_reply_events[0]["nodeId"], "agent")
self.assertIn("您好,王先生", brain._store.values["system__conversation_history"])
self.assertFalse(
any(
@@ -2252,7 +2390,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
]
self.assertEqual(
assistant_transcripts,
["您好,王先生", "正在为你结束流程", "感谢来电"],
["正在为你结束流程", "感谢来电"],
)
self.assertIn(
"正在为你结束流程",