feat: add session updates and message dialogs

This commit is contained in:
Xin Wang
2026-07-31 00:01:01 +08:00
parent 913435785e
commit c2f0f5eb04
14 changed files with 850 additions and 8 deletions

View File

@@ -13,6 +13,7 @@ from pipecat.frames.frames import (
LLMMessagesUpdateFrame,
LLMRunFrame,
LLMTextFrame,
LLMUpdateSettingsFrame,
OutputTransportMessageUrgentFrame,
TTSSpeakFrame,
)
@@ -28,7 +29,7 @@ from services.brains.dify_llm import (
)
from services.brains.workflow_brain import WorkflowBrain
from services.runtime_variables import prepare_dynamic_config
from services.workflow.models import LLMRouteResult, RouteStatus
from services.workflow.models import LLMRouteResult, RouteStatus, WorkflowStatus
class FakeLLM:
@@ -267,6 +268,47 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
f"{GREETING_CONTEXT_MARKER}\n您好,王先生",
)
async def test_session_update_refreshes_prompt_without_running_llm(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="prompt",
runtimeMode="pipeline",
prompt="面板状态:{{panel_open}}",
dynamic_variable_definitions={
"panel_open": {"type": "boolean", "default": False}
},
),
{},
assistant_id="asst_session_update",
)
brain = build_brain(cfg)
prompts = []
queued_frames = []
async def queue_frame(frame):
queued_frames.append(frame)
await brain.setup(
cfg,
BrainRuntime(
context=LLMContext(
messages=[{"role": "system", "content": brain.system_prompt(cfg)}]
),
llm=FakeLLM(),
queue_frame=queue_frame,
set_system_prompt=prompts.append,
set_tools=lambda _tools: None,
call_end=FakeCallEnd(),
),
)
result = await brain.on_session_update({"panel_open": True})
self.assertEqual(result.changed, ["panel_open"])
self.assertEqual(result.dynamic_variables, {"panel_open": True})
self.assertEqual(prompts, ["面板状态true"])
self.assertEqual(queued_frames, [])
async def test_end_call_tool_is_owned_by_prompt_brain(self):
brain = build_brain(
AssistantConfig(
@@ -472,6 +514,71 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
async def test_session_update_refreshes_current_agent_without_routing(self):
cfg = prepare_dynamic_config(
AssistantConfig(
type="workflow",
graph={
"specVersion": 3,
"settings": {"globalPrompt": "面板 {{panel_open}}"},
"nodes": [
{"id": "start", "type": "start", "data": {}},
{
"id": "agent",
"type": "agent",
"data": {"prompt": "等待用户操作"},
},
],
"edges": [
{
"id": "begin",
"source": "start",
"target": "agent",
"data": {"mode": "always", "priority": 0},
}
],
},
dynamic_variable_definitions={
"panel_open": {"type": "boolean", "default": False}
},
),
{},
assistant_id="asst_workflow_session_update",
)
brain = WorkflowBrain(cfg)
queued = []
async def queue_frame(frame):
queued.append(frame)
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=FakeCallEnd(),
)
brain._state.enter("agent", WorkflowStatus.WAITING_USER)
result = await brain.on_session_update({"panel_open": True})
self.assertEqual(result.changed, ["panel_open"])
prompt_updates = [
frame for frame in queued if isinstance(frame, LLMUpdateSettingsFrame)
]
self.assertEqual(len(prompt_updates), 1)
self.assertIn("面板 true", prompt_updates[0].delta.system_instruction)
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in queued))
variable_event = next(
frame.message
for frame in queued
if isinstance(frame, OutputTransportMessageUrgentFrame)
and frame.message.get("type") == "workflow-variables"
)
self.assertEqual(variable_event["reason"], "session_update")
self.assertEqual(variable_event["changed"], ["panel_open"])
async def test_agent_vision_tool_is_scoped_to_effective_stage(self):
brain = WorkflowBrain(
{

View File

@@ -146,6 +146,34 @@ class DynamicVariableTests(unittest.TestCase):
self.assertEqual(store.values["system__agent_turns"], 1)
self.assertIn("查订单", store.values["system__conversation_history"])
def test_session_assignment_is_declared_typed_and_atomic(self):
store = DynamicVariableStore(
{"panel_open": False, "selection": "none"},
variable_types={
"panel_open": "boolean",
"selection": "string",
},
)
changed = store.assign_declared_many(
{"panel_open": True, "selection": "confirmed"}
)
self.assertEqual(changed, ["panel_open", "selection"])
self.assertEqual(
store.public_values(),
{"panel_open": True, "selection": "confirmed"},
)
with self.assertRaisesRegex(DynamicVariableError, "类型应为 boolean"):
store.assign_declared_many(
{"selection": "cancelled", "panel_open": "yes"}
)
self.assertEqual(store.values["selection"], "confirmed")
self.assertIs(store.values["panel_open"], True)
with self.assertRaisesRegex(DynamicVariableError, "未声明"):
store.assign_declared_many({"unknown_state": True})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,138 @@
from __future__ import annotations
import unittest
from pipecat.frames.frames import (
InputTransportMessageFrame,
OutputTransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from services.brains.base import SessionVariableUpdate
from services.pipecat.processors import (
SessionUpdateError,
SessionUpdateProcessor,
parse_session_update,
)
class SessionUpdateParserTests(unittest.TestCase):
def test_parses_declared_primitive_values(self):
value = parse_session_update(
{
"type": "session-update",
"schema_version": 1,
"update_id": "update_1",
"dynamic_variables": {
"panel_open": True,
"selected_option": "approve",
},
}
)
self.assertIsNotNone(value)
self.assertEqual(value.update_id, "update_1")
self.assertEqual(
value.dynamic_variables,
{"panel_open": True, "selected_option": "approve"},
)
def test_rejects_empty_or_nested_updates(self):
with self.assertRaisesRegex(SessionUpdateError, "不能为空"):
parse_session_update(
{
"type": "session-update",
"schema_version": 1,
"update_id": "update_empty",
"dynamic_variables": {},
}
)
with self.assertRaisesRegex(SessionUpdateError, "仅支持"):
parse_session_update(
{
"type": "session-update",
"schema_version": 1,
"update_id": "update_nested",
"dynamic_variables": {"dialog": {"open": True}},
}
)
class SessionUpdateProcessorTests(unittest.IsolatedAsyncioTestCase):
async def test_returns_snapshot_without_starting_a_turn(self):
calls = []
async def apply_update(dynamic_variables):
calls.append(dynamic_variables)
return SessionVariableUpdate(
changed=["panel_open"],
dynamic_variables={
"panel_open": True,
"selected_option": "none",
},
)
processor = SessionUpdateProcessor(apply_update)
outbound = []
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
outbound.append((frame, direction))
processor.push_frame = push_frame
await processor.process_frame(
InputTransportMessageFrame(
message={
"type": "session-update",
"schema_version": 1,
"update_id": "update_accepted",
"dynamic_variables": {"panel_open": True},
}
),
FrameDirection.DOWNSTREAM,
)
self.assertEqual(calls, [{"panel_open": True}])
self.assertEqual(len(outbound), 1)
self.assertIsInstance(outbound[0][0], OutputTransportMessageUrgentFrame)
self.assertEqual(
outbound[0][0].message,
{
"type": "session-update-result",
"update_id": "update_accepted",
"status": "accepted",
"changed": ["panel_open"],
"dynamic_variables": {
"panel_open": True,
"selected_option": "none",
},
},
)
async def test_validation_failure_returns_error(self):
async def reject_update(_dynamic_variables):
raise ValueError("动态变量未声明: panel_open")
processor = SessionUpdateProcessor(reject_update)
outbound = []
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
outbound.append((frame, direction))
processor.push_frame = push_frame
await processor.process_frame(
InputTransportMessageFrame(
message={
"type": "session-update",
"schema_version": 1,
"update_id": "update_rejected",
"dynamic_variables": {"panel_open": True},
}
),
FrameDirection.DOWNSTREAM,
)
self.assertEqual(outbound[0][0].message["status"], "error")
self.assertIn("未声明", outbound[0][0].message["message"])
if __name__ == "__main__":
unittest.main()