from __future__ import annotations import unittest from models import AssistantConfig, RuntimeModelResource from services.pipecat.service_factory import config_with_resource from services.node_specs import graph_references, normalize_graph, validate_graph from services.runtime_variables import DynamicVariableStore, prepare_dynamic_config from services.workflow_engine import WorkflowEngine def valid_graph(): return { "specVersion": 3, "settings": {"globalPrompt": "服务 {{customer}}"}, "nodes": [ {"id": "start", "type": "start", "data": {"name": "Start"}}, { "id": "agent", "type": "agent", "data": {"name": "Agent", "prompt": "处理订单", "contextPolicy": "fresh"}, }, {"id": "end", "type": "end", "data": {"name": "End", "scope": "flow"}}, ], "edges": [ { "id": "begin", "source": "start", "target": "agent", "data": {"mode": "always", "priority": 0}, }, { "id": "paid", "source": "agent", "target": "end", "data": { "mode": "expression", "priority": 10, "expression": { "combinator": "and", "rules": [ {"variable": "order_status", "operator": "eq", "value": "paid"} ], }, }, }, ], } class WorkflowGraphTests(unittest.TestCase): def test_agent_entry_mode_defaults_and_validation(self): graph = valid_graph() normalized = normalize_graph(graph) agent = next(node for node in normalized["nodes"] if node["type"] == "agent") self.assertEqual(agent["data"]["entryMode"], "wait_user") self.assertEqual(agent["data"]["entrySpeech"], "") self.assertTrue(agent["data"]["inheritGlobalConfig"]) self.assertEqual(agent["data"]["contextPolicy"], "fresh") agent["data"]["entryMode"] = "fixed_speech" self.assertTrue( any("固定进入语不能为空" in error for error in validate_graph(normalized)) ) agent["data"]["entrySpeech"] = "您好,{{customer}}" self.assertEqual(validate_graph(normalized), []) def test_voice_resource_creates_isolated_runtime_config(self): base = AssistantConfig(type="workflow", asr="default", voice="default") asr = RuntimeModelResource( id="asr_1", capability="ASR", interface_type="openai-asr", values={"modelId": "sensevoice", "language": "zh", "apiUrl": "https://asr.test"}, secrets={"apiKey": "secret"}, ) resolved = config_with_resource(base, asr) self.assertEqual(resolved.asr, "sensevoice") self.assertEqual(resolved.stt_api_key, "secret") self.assertEqual(base.asr, "default") llm = RuntimeModelResource( id="llm_1", capability="LLM", interface_type="openai-llm", values={"modelId": "deepseek-chat", "apiUrl": "https://llm.test/v1"}, secrets={"apiKey": "llm-secret"}, ) llm_resolved = config_with_resource(base, llm) self.assertEqual(llm_resolved.model, "deepseek-chat") self.assertEqual(llm_resolved.llm_api_key, "llm-secret") def test_global_and_custom_agent_references_are_preserved(self): graph = valid_graph() graph["settings"].update( { "defaultLlmResourceId": "llm_global", "defaultAsrResourceId": "asr_global", "defaultTtsResourceId": "tts_global", "toolIds": ["tool_global"], "knowledgeBaseId": "kb_global", } ) agent = next(node for node in graph["nodes"] if node["type"] == "agent") agent["data"].update( { "inheritGlobalConfig": False, "llmResourceId": "llm_agent", "asrResourceId": "asr_agent", "ttsResourceId": "tts_agent", "toolIds": ["tool_agent"], "knowledgeBaseId": "kb_agent", } ) refs = graph_references(graph) self.assertEqual( refs["model_resources"], { "llm_global", "asr_global", "tts_global", "llm_agent", "asr_agent", "tts_agent", }, ) self.assertEqual(refs["tools"], {"tool_global", "tool_agent"}) self.assertEqual(refs["knowledge_bases"], {"kb_global", "kb_agent"}) def test_existing_agent_override_disables_implicit_inheritance(self): graph = valid_graph() agent = next(node for node in graph["nodes"] if node["type"] == "agent") agent["data"]["toolIds"] = ["legacy_tool"] normalized = normalize_graph(graph) normalized_agent = next( node for node in normalized["nodes"] if node["type"] == "agent" ) self.assertFalse(normalized_agent["data"]["inheritGlobalConfig"]) def test_inherited_agent_ignores_stale_custom_references(self): graph = valid_graph() agent = next(node for node in graph["nodes"] if node["type"] == "agent") agent["data"].update( { "inheritGlobalConfig": True, "llmResourceId": "stale_llm", "asrResourceId": "stale_asr", "ttsResourceId": "stale_tts", "toolIds": ["stale_tool"], "knowledgeBaseId": "stale_kb", } ) refs = graph_references(graph) self.assertNotIn("stale_llm", refs["model_resources"]) self.assertNotIn("stale_tool", refs["tools"]) self.assertNotIn("stale_kb", refs["knowledge_bases"]) def test_agent_effective_config_inherits_then_switches_to_override(self): graph = valid_graph() graph["settings"].update( { "defaultLlmResourceId": "llm_global", "defaultAsrResourceId": "asr_global", "defaultTtsResourceId": "tts_global", "toolIds": ["tool_global"], "knowledgeBaseId": "kb_global", "knowledgeMode": "on_demand", "knowledgeTopN": 8, "knowledgeScoreThreshold": 0.4, "enableInterrupt": False, "turnConfig": { "bargeIn": {"strategy": "transcription"}, "turnDetection": {"strategy": "silence", "silenceTimeoutSecs": 1.2}, }, } ) engine = WorkflowEngine(graph) inherited = engine.agent_stage_config("agent") self.assertEqual(inherited.llm_resource_id, "llm_global") self.assertEqual(inherited.tool_ids, ("tool_global",)) self.assertEqual(inherited.knowledge_mode, "on_demand") self.assertFalse(inherited.enable_interrupt) self.assertEqual( inherited.turn_config["bargeIn"]["strategy"], "transcription", ) engine.data("agent").update( { "inheritGlobalConfig": False, "llmResourceId": "llm_agent", "toolIds": ["tool_agent"], "knowledgeBaseId": "", "enableInterrupt": True, "turnConfig": { "bargeIn": {"strategy": "vad"}, "turnDetection": {"strategy": "smart_turn"}, }, } ) custom = engine.agent_stage_config("agent") self.assertEqual(custom.llm_resource_id, "llm_agent") self.assertEqual(custom.tool_ids, ("tool_agent",)) self.assertEqual(custom.knowledge_mode, "disabled") self.assertTrue(custom.enable_interrupt) self.assertEqual( custom.turn_config["turnDetection"]["strategy"], "smart_turn", ) def test_start_agent_and_handoff_may_have_no_outgoing_edge(self): terminal_graphs = [ { "specVersion": 3, "settings": {}, "nodes": [{"id": "start", "type": "start", "data": {}}], "edges": [], }, { "specVersion": 3, "settings": {}, "nodes": [ {"id": "start", "type": "start", "data": {}}, { "id": "agent", "type": "agent", "data": {"prompt": "持续处理用户问题"}, }, ], "edges": [ { "id": "begin", "source": "start", "target": "agent", "data": {"mode": "always", "priority": 0}, } ], }, { "specVersion": 3, "settings": {}, "nodes": [ {"id": "start", "type": "start", "data": {}}, {"id": "handoff", "type": "handoff", "data": {}}, ], "edges": [ { "id": "begin", "source": "start", "target": "handoff", "data": {"mode": "always", "priority": 0}, } ], }, ] for graph in terminal_graphs: with self.subTest(node=graph["nodes"][-1]["type"]): self.assertEqual(validate_graph(graph), []) action_without_exit = { "specVersion": 3, "settings": {}, "nodes": [ {"id": "start", "type": "start", "data": {}}, {"id": "action", "type": "action", "data": {}}, ], "edges": [ { "id": "begin", "source": "start", "target": "action", "data": {"mode": "always", "priority": 0}, } ], } self.assertTrue( any( "action 的出边不能少于 1" in error for error in validate_graph(action_without_exit) ) ) def test_v2_start_prompt_is_preserved_in_synthetic_agent(self): graph = normalize_graph( { "nodes": [ {"id": "s", "type": "startCall", "data": {"prompt": "询问需求"}}, {"id": "e", "type": "endCall", "data": {"prompt": "再见"}}, ], "edges": [ {"id": "done", "source": "s", "target": "e", "data": {"condition": "完成"}} ], } ) migrated = next(node for node in graph["nodes"] if node["type"] == "agent") self.assertEqual(migrated["data"]["prompt"], "询问需求") self.assertEqual(validate_graph(graph), []) def test_rejects_automatic_cycle_and_duplicate_priorities(self): graph = valid_graph() graph["nodes"].insert(1, {"id": "action", "type": "action", "data": {}}) graph["edges"] = [ {"id": "a", "source": "start", "target": "action", "data": {"mode": "always", "priority": 0}}, {"id": "b", "source": "action", "target": "start", "data": {"mode": "always", "priority": 0}}, {"id": "c", "source": "agent", "target": "end", "data": {"mode": "always", "priority": 10}}, ] errors = validate_graph(graph) self.assertTrue(any("无等待循环" in error for error in errors)) def test_expression_routes_using_session_variables(self): cfg = prepare_dynamic_config( AssistantConfig( type="workflow", graph=valid_graph(), dynamic_variable_definitions={ "customer": {"type": "string", "default": "王先生"}, "order_status": {"type": "string", "default": "pending"}, }, ), {}, assistant_id="asst_test", ) store = DynamicVariableStore.from_config(cfg) engine = WorkflowEngine(cfg.graph) self.assertIsNone(engine.deterministic_edge("agent", store, include_default=False)) store.assign("order_status", "paid") self.assertEqual( engine.deterministic_edge("agent", store, include_default=False)["target"], "end", ) self.assertIn("王先生", engine.prompt_for("agent", store)) inherited_prompt = engine.prompt_for("agent", store) self.assertIn("服务 王先生", inherited_prompt) self.assertIn("处理订单", inherited_prompt) engine.data("agent")["inheritGlobalConfig"] = False custom_prompt = engine.prompt_for("agent", store) self.assertNotIn("服务 王先生", custom_prompt) self.assertIn("处理订单", custom_prompt) if __name__ == "__main__": unittest.main()