feat: add session updates and message dialogs
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
138
backend/tests/test_session_update.py
Normal file
138
backend/tests/test_session_update.py
Normal 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()
|
||||
Reference in New Issue
Block a user