"""Tests covering gaps in WorkflowEngine: execute loop, state/condition/end nodes, template context, source data, structured output parsing, get_execution_summary.""" from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest from application.agents.workflows.schemas import ( ExecutionStatus, NodeType, WorkflowEdge, WorkflowGraph, WorkflowNode, Workflow, ) from application.agents.workflows.workflow_engine import WorkflowEngine def _make_graph(nodes, edges): wf = Workflow(name="Test", description="test workflow") return WorkflowGraph(workflow=wf, nodes=nodes, edges=edges) def _make_node(id, type, title="Node", config=None, position=None): return WorkflowNode( id=id, workflow_id="wf1", type=type, title=title, position=position or {"x": 0, "y": 0}, config=config or {}, ) def _make_edge(id, source, target, source_handle=None, target_handle=None): return WorkflowEdge( id=id, workflow_id="wf1", source=source, target=target, sourceHandle=source_handle, targetHandle=target_handle, ) def _make_agent(): agent = MagicMock() agent.chat_history = [] agent.endpoint = "https://api.example.com" agent.llm_name = "openai" agent.model_id = "gpt-4" agent.api_key = "key" agent.decoded_token = {"sub": "user1"} agent.retrieved_docs = None return agent class TestExecuteLoop: @pytest.mark.unit def test_no_start_node_yields_error(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) events = list(engine.execute({}, "query")) assert any(e.get("type") == "error" and "start node" in e.get("error", "") for e in events) @pytest.mark.unit def test_emits_workflow_run_id_first(self): """A ``workflow_run`` event carrying the run id precedes any step event.""" nodes = [ _make_node("n1", NodeType.START, "Start"), _make_node("n2", NodeType.END, "End", config={"config": {}}), ] edges = [_make_edge("e1", "n1", "n2")] engine = WorkflowEngine(_make_graph(nodes, edges), _make_agent()) events = list(engine.execute({}, "hello")) run_events = [e for e in events if e.get("type") == "workflow_run"] assert len(run_events) == 1 assert run_events[0]["workflow_run_id"] == engine.workflow_run_id assert events[0]["type"] == "workflow_run" @pytest.mark.unit def test_start_to_end(self): nodes = [ _make_node("n1", NodeType.START, "Start"), _make_node("n2", NodeType.END, "End", config={"config": {}}), ] edges = [_make_edge("e1", "n1", "n2")] graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) events = list(engine.execute({}, "hello")) step_events = [e for e in events if e.get("type") == "workflow_step"] assert len(step_events) >= 2 # At least start + end @pytest.mark.unit def test_node_not_found_yields_error(self): nodes = [_make_node("n1", NodeType.START)] edges = [_make_edge("e1", "n1", "nonexistent")] graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) events = list(engine.execute({}, "q")) assert any("not found" in e.get("error", "") for e in events) @pytest.mark.unit def test_node_execution_error_yields_error(self): nodes = [ _make_node("n1", NodeType.START), _make_node("n2", NodeType.STATE, "State", config={"config": {"operations": [{"expression": "bad!!!", "target_variable": "x"}]}}), ] edges = [_make_edge("e1", "n1", "n2")] graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) events = list(engine.execute({}, "q")) failed_events = [e for e in events if e.get("status") == "failed"] assert len(failed_events) >= 1 @pytest.mark.unit def test_max_steps_limit(self): # Create a cycle: start -> state -> state (loop) nodes = [ _make_node("n1", NodeType.START), _make_node("n2", NodeType.NOTE, "Note"), ] edges = [_make_edge("e1", "n1", "n2"), _make_edge("e2", "n2", "n2")] graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) engine.MAX_EXECUTION_STEPS = 5 events = list(engine.execute({}, "q")) # Should not run forever assert len(events) > 0 @pytest.mark.unit def test_branch_ends_without_end_node(self): nodes = [ _make_node("n1", NodeType.START), _make_node("n2", NodeType.NOTE, "Note"), ] edges = [_make_edge("e1", "n1", "n2")] # n2 has no outgoing edges graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) events = list(engine.execute({}, "q")) assert len(events) > 0 class TestInitializeState: @pytest.mark.unit def test_sets_query_and_history(self): graph = _make_graph([], []) agent = _make_agent() agent.chat_history = [{"prompt": "hi", "response": "hey"}] engine = WorkflowEngine(graph, agent) engine._initialize_state({"custom": "value"}, "test query") assert engine.state["query"] == "test query" assert "custom" in engine.state assert engine.state["chat_history"] is not None class TestGetNextNodeId: @pytest.mark.unit def test_no_edges_returns_none(self): nodes = [_make_node("n1", NodeType.START)] graph = _make_graph(nodes, []) engine = WorkflowEngine(graph, _make_agent()) assert engine._get_next_node_id("n1") is None @pytest.mark.unit def test_returns_first_edge_target(self): nodes = [_make_node("n1", NodeType.START), _make_node("n2", NodeType.END)] edges = [_make_edge("e1", "n1", "n2")] graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) assert engine._get_next_node_id("n1") == "n2" @pytest.mark.unit def test_condition_uses_matched_handle(self): nodes = [ _make_node("n1", NodeType.CONDITION), _make_node("n2", NodeType.END, "Yes End"), _make_node("n3", NodeType.END, "No End"), ] edges = [ _make_edge("e1", "n1", "n2", source_handle="yes"), _make_edge("e2", "n1", "n3", source_handle="no"), ] graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) engine._condition_result = "no" assert engine._get_next_node_id("n1") == "n3" assert engine._condition_result is None # Cleared after use @pytest.mark.unit def test_condition_no_matching_handle_returns_none(self): nodes = [_make_node("n1", NodeType.CONDITION)] edges = [_make_edge("e1", "n1", "n2", source_handle="yes")] graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) engine._condition_result = "nonexistent" assert engine._get_next_node_id("n1") is None class TestExecuteStateNode: @pytest.mark.unit def test_evaluates_operations(self): node = _make_node("n1", NodeType.STATE, config={ "config": { "operations": [ {"expression": "x + 1", "target_variable": "result"}, ] } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {"x": 5} list(engine._execute_state_node(node)) assert engine.state["result"] == 6 @pytest.mark.unit def test_skips_empty_expression(self): node = _make_node("n1", NodeType.STATE, config={ "config": { "operations": [ {"expression": "", "target_variable": "result"}, ] } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {} list(engine._execute_state_node(node)) assert "result" not in engine.state class TestEndNodeSeparator: @pytest.mark.unit def test_separates_from_prior_streamed_output(self): # A prior streaming node ran, so the end template must be preceded by a # paragraph break instead of mashing ("...growth.Sales analysis..."). node = _make_node("n1", NodeType.END, config={"config": {"output_template": "Final line."}}) engine = WorkflowEngine(_make_graph([node], []), _make_agent()) engine.state = {} engine._has_streamed = True answers = [e["answer"] for e in engine._execute_end_node(node)] assert answers[0] == "\n\n" assert answers[-1].strip() == "Final line." @pytest.mark.unit def test_no_separator_when_nothing_streamed(self): node = _make_node("n1", NodeType.END, config={"config": {"output_template": "Final line."}}) engine = WorkflowEngine(_make_graph([node], []), _make_agent()) engine.state = {} answers = [e["answer"] for e in engine._execute_end_node(node)] assert answers == ["Final line."] class TestExecuteConditionNode: @pytest.mark.unit def test_matches_first_true_case(self): node = _make_node("n1", NodeType.CONDITION, config={ "config": { "cases": [ {"expression": "x > 10", "source_handle": "high"}, {"expression": "x > 5", "source_handle": "medium"}, ] } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {"x": 7} list(engine._execute_condition_node(node)) assert engine._condition_result == "medium" @pytest.mark.unit def test_falls_through_to_else(self): node = _make_node("n1", NodeType.CONDITION, config={ "config": { "cases": [ {"expression": "x > 100", "source_handle": "high"}, ] } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {"x": 1} list(engine._execute_condition_node(node)) assert engine._condition_result == "else" @pytest.mark.unit def test_skips_empty_expression(self): node = _make_node("n1", NodeType.CONDITION, config={ "config": { "cases": [ {"expression": " ", "source_handle": "a"}, {"expression": "true", "source_handle": "b"}, ] } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {} list(engine._execute_condition_node(node)) assert engine._condition_result == "b" @pytest.mark.unit def test_cel_error_continues(self): node = _make_node("n1", NodeType.CONDITION, config={ "config": { "cases": [ {"expression": "bad!!!", "source_handle": "a"}, {"expression": "true", "source_handle": "b"}, ] } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {} list(engine._execute_condition_node(node)) assert engine._condition_result == "b" class TestExecuteEndNode: @pytest.mark.unit def test_with_output_template(self): node = _make_node("n1", NodeType.END, config={ "config": {"output_template": "Result: {{ query }}"} }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {"query": "hello"} engine._format_template = MagicMock(return_value="Result: hello") events = list(engine._execute_end_node(node)) assert len(events) == 1 assert events[0]["answer"] == "Result: hello" @pytest.mark.unit def test_without_output_template(self): node = _make_node("n1", NodeType.END, config={"config": {}}) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) events = list(engine._execute_end_node(node)) assert len(events) == 0 class TestParseStructuredOutput: @pytest.mark.unit def test_valid_json(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) success, data = engine._parse_structured_output('{"key": "value"}') assert success is True assert data == {"key": "value"} @pytest.mark.unit def test_invalid_json(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) success, data = engine._parse_structured_output("not json") assert success is False assert data is None @pytest.mark.unit def test_empty_string(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) success, data = engine._parse_structured_output("") assert success is False assert data is None @pytest.mark.unit def test_whitespace_only(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) success, data = engine._parse_structured_output(" ") assert success is False assert data is None class TestNormalizeNodeJsonSchema: @pytest.mark.unit def test_none_returns_none(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) assert engine._normalize_node_json_schema(None, "Node") is None @pytest.mark.unit def test_valid_schema(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) schema = {"type": "object", "properties": {"name": {"type": "string"}}} result = engine._normalize_node_json_schema(schema, "Node") assert result is not None @pytest.mark.unit def test_invalid_schema_raises(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) with patch("application.agents.workflows.workflow_engine.normalize_json_schema_payload") as mock_norm: from application.core.json_schema_utils import JsonSchemaValidationError mock_norm.side_effect = JsonSchemaValidationError("bad schema") with pytest.raises(ValueError, match="Invalid JSON schema"): engine._normalize_node_json_schema({"bad": True}, "TestNode") class TestValidateStructuredOutput: @pytest.mark.unit def test_valid_output_passes(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) schema = {"type": "object", "properties": {"name": {"type": "string"}}} engine._validate_structured_output(schema, {"name": "Alice"}) # Should not raise @pytest.mark.unit def test_invalid_output_raises(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} with pytest.raises(ValueError, match="did not match schema"): engine._validate_structured_output(schema, {}) @pytest.mark.unit def test_no_jsonschema_module(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) with patch("application.agents.workflows.workflow_engine.jsonschema", None): engine._validate_structured_output({"type": "object"}, {}) # Should not raise class TestFormatTemplate: @pytest.mark.unit def test_renders_template(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {"query": "hello"} engine._build_template_context = MagicMock(return_value={"query": "hello"}) engine._template_engine = MagicMock() engine._template_engine.render.return_value = "hello world" result = engine._format_template("{{ query }} world") assert result == "hello world" @pytest.mark.unit def test_render_error_returns_raw(self): from application.templates.template_engine import TemplateRenderError graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) engine._build_template_context = MagicMock(return_value={}) engine._template_engine = MagicMock() engine._template_engine.render.side_effect = TemplateRenderError("fail") result = engine._format_template("{{ bad }}") assert result == "{{ bad }}" class TestBuildTemplateContext: @pytest.mark.unit def test_includes_state_variables(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = None engine = WorkflowEngine(graph, agent) engine.state = {"query": "hello", "custom_var": "value"} context = engine._build_template_context() assert context["agent"]["query"] == "hello" assert "custom_var" in context @pytest.mark.unit def test_reserved_namespace_gets_prefixed(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = None engine = WorkflowEngine(graph, agent) engine.state = {"source": "my_source_val"} context = engine._build_template_context() assert context.get("agent_source") == "my_source_val" @pytest.mark.unit def test_passthrough_data(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = None engine = WorkflowEngine(graph, agent) engine.state = {"passthrough": {"key": "val"}} context = engine._build_template_context() assert "passthrough" in context or "agent_passthrough" in context @pytest.mark.unit def test_tools_data(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = None engine = WorkflowEngine(graph, agent) engine.state = {"tools": {"tool1": "result"}} context = engine._build_template_context() assert "agent" in context class TestGetSourceTemplateData: @pytest.mark.unit def test_no_docs_returns_none(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = None engine = WorkflowEngine(graph, agent) docs, together = engine._get_source_template_data() assert docs is None assert together is None @pytest.mark.unit def test_empty_docs_returns_none(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = [] engine = WorkflowEngine(graph, agent) docs, together = engine._get_source_template_data() assert docs is None @pytest.mark.unit def test_docs_with_filename(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = [{"text": "content", "filename": "doc.txt"}] engine = WorkflowEngine(graph, agent) docs, together = engine._get_source_template_data() assert docs is not None assert "doc.txt" in together assert "content" in together @pytest.mark.unit def test_docs_without_filename(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = [{"text": "content only"}] engine = WorkflowEngine(graph, agent) docs, together = engine._get_source_template_data() assert together == "content only" @pytest.mark.unit def test_skips_non_dict_docs(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = ["not a dict", {"text": "ok"}] engine = WorkflowEngine(graph, agent) docs, together = engine._get_source_template_data() assert together == "ok" @pytest.mark.unit def test_skips_non_string_text(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = [{"text": 123}] engine = WorkflowEngine(graph, agent) docs, together = engine._get_source_template_data() assert together is None @pytest.mark.unit def test_doc_with_title_fallback(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = [{"text": "content", "title": "doc_title"}] engine = WorkflowEngine(graph, agent) docs, together = engine._get_source_template_data() assert "doc_title" in together @pytest.mark.unit def test_doc_with_source_fallback(self): graph = _make_graph([], []) agent = _make_agent() agent.retrieved_docs = [{"text": "content", "source": "src"}] engine = WorkflowEngine(graph, agent) docs, together = engine._get_source_template_data() assert "src" in together class TestGetExecutionSummary: @pytest.mark.unit def test_returns_log_entries(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) now = datetime.now(timezone.utc) engine.execution_log = [ { "node_id": "n1", "node_type": "start", "status": "completed", "started_at": now, "completed_at": now, "error": None, "state_delta": {}, } ] summary = engine.get_execution_summary() assert len(summary) == 1 assert summary[0].node_id == "n1" assert summary[0].status == ExecutionStatus.COMPLETED @pytest.mark.unit def test_empty_log(self): graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) assert engine.get_execution_summary() == [] class TestAgentNodeExecution: """Cover lines 204, 213-215, 223, 232-233, 283-284, 289, 355, 375.""" @pytest.mark.unit def test_agent_node_without_prompt_template(self): """Cover line 204/206: agent node without prompt_template uses query.""" node = _make_node("n1", NodeType.AGENT, "Agent", config={ "config": { "agent_type": "classic", "stream_to_user": False, } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {"query": "test question"} mock_agent = MagicMock() mock_agent.gen.return_value = [{"answer": "response"}] with patch( "application.agents.workflows.workflow_engine.WorkflowNodeAgentFactory" ) as mock_factory, \ patch( "application.core.model_utils.get_provider_from_model_id", return_value="openai", ), \ patch( "application.core.model_utils.get_api_key_for_provider", return_value="key", ), \ patch( "application.core.model_utils.get_model_capabilities", return_value=None, ): mock_factory.create.return_value = mock_agent list(engine._execute_agent_node(node)) output_key = f"node_{node.id}_output" assert output_key in engine.state @pytest.mark.unit def test_agent_node_with_structured_output(self): """Cover lines 283-284, 289: structured output parsing.""" node = _make_node("n1", NodeType.AGENT, "Agent", config={ "config": { "agent_type": "classic", "stream_to_user": False, "json_schema": { "type": "object", "properties": {"name": {"type": "string"}}, }, } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {"query": "test"} mock_agent = MagicMock() mock_agent.gen.return_value = [ {"answer": '{"name": "Alice"}', "structured": True} ] with patch( "application.agents.workflows.workflow_engine.WorkflowNodeAgentFactory" ) as mock_factory, \ patch( "application.core.model_utils.get_provider_from_model_id", return_value="openai", ), \ patch( "application.core.model_utils.get_api_key_for_provider", return_value="key", ), \ patch( "application.core.model_utils.get_model_capabilities", return_value={"supports_structured_output": True}, ): mock_factory.create.return_value = mock_agent list(engine._execute_agent_node(node)) output_key = f"node_{node.id}_output" assert engine.state[output_key] == {"name": "Alice"} @pytest.mark.unit def test_agent_node_model_no_structured_support_raises(self): """Cover lines 223: model without structured output raises ValueError.""" node = _make_node("n1", NodeType.AGENT, "Agent", config={ "config": { "agent_type": "classic", "json_schema": { "type": "object", "properties": {"x": {"type": "string"}}, }, "model_id": "test-model", } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {"query": "test"} with patch( "application.core.model_utils.get_provider_from_model_id", return_value="openai", ), \ patch( "application.core.model_utils.get_api_key_for_provider", return_value="key", ), \ patch( "application.core.model_utils.get_model_capabilities", return_value={"supports_structured_output": False}, ): with pytest.raises(ValueError, match="does not support structured output"): list(engine._execute_agent_node(node)) @pytest.mark.unit def test_agent_node_output_variable(self): """Cover line 300: output_variable stores result.""" node = _make_node("n1", NodeType.AGENT, "Agent", config={ "config": { "agent_type": "classic", "stream_to_user": False, "output_variable": "my_result", } }) graph = _make_graph([node], []) engine = WorkflowEngine(graph, _make_agent()) engine.state = {"query": "test"} mock_agent = MagicMock() mock_agent.gen.return_value = [{"answer": "output text"}] with patch( "application.agents.workflows.workflow_engine.WorkflowNodeAgentFactory" ) as mock_factory, \ patch( "application.core.model_utils.get_provider_from_model_id", return_value="openai", ), \ patch( "application.core.model_utils.get_api_key_for_provider", return_value="key", ), \ patch( "application.core.model_utils.get_model_capabilities", return_value=None, ): mock_factory.create.return_value = mock_agent list(engine._execute_agent_node(node)) assert engine.state["my_result"] == "output text" @pytest.mark.unit def test_validate_structured_output_schema_error(self): """Cover line 375/382-383: invalid schema raises ValueError.""" graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) import jsonschema as js with patch( "application.agents.workflows.workflow_engine.normalize_json_schema_payload", return_value={"type": "invalid_schema_type"}, ), \ patch( "application.agents.workflows.workflow_engine.jsonschema" ) as mock_js: mock_js.validate.side_effect = js.exceptions.SchemaError("bad schema") mock_js.exceptions = js.exceptions with pytest.raises(ValueError, match="Invalid JSON schema"): engine._validate_structured_output( {"type": "object"}, {"name": "test"} ) @pytest.mark.unit def test_parse_structured_output_invalid_json(self): """Cover lines 349-352: invalid JSON returns False.""" graph = _make_graph([], []) engine = WorkflowEngine(graph, _make_agent()) success, data = engine._parse_structured_output("not json {") assert success is False assert data is None # --------------------------------------------------------------------------- # Additional coverage for workflow_engine.py # Lines: 96-114 (exception in node execution), 122-130 (branch/max steps) # --------------------------------------------------------------------------- @pytest.mark.unit class TestWorkflowNodeExecutionException: """Cover lines 96-114: exception during _execute_node yields error events.""" def test_node_raises_exception_yields_error(self): """Force _execute_node to raise, covering lines 96-114.""" nodes = [ _make_node("n1", NodeType.START), _make_node("n2", NodeType.AGENT, "Agent"), ] edges = [_make_edge("e1", "n1", "n2")] graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) # Patch _execute_node to raise on agent node original_execute = engine._execute_node def patched_execute(node): if node.type == NodeType.AGENT: raise RuntimeError("Agent exploded") yield from original_execute(node) engine._execute_node = patched_execute events = list(engine.execute({}, "test query")) error_events = [e for e in events if e.get("type") == "error"] assert len(error_events) >= 1 failed_steps = [e for e in events if e.get("status") == "failed"] assert len(failed_steps) >= 1 @pytest.mark.unit class TestWorkflowMaxStepsReached: """Cover lines 127-130: max steps limit warning.""" def test_max_steps_exactly_reached(self): nodes = [ _make_node("n1", NodeType.START), _make_node("n2", NodeType.NOTE, "Note"), ] edges = [_make_edge("e1", "n1", "n2"), _make_edge("e2", "n2", "n2")] graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) engine.MAX_EXECUTION_STEPS = 3 events = list(engine.execute({}, "q")) # The while loop runs 3 times then exits, steps >= MAX assert len(events) >= 3 @pytest.mark.unit class TestWorkflowBranchEndsNonEndNode: """Cover lines 122-125: branch ends at non-end node without outgoing edges.""" def test_branch_ends_at_state_node(self): nodes = [ _make_node("n1", NodeType.START), _make_node( "n2", NodeType.STATE, "State", config={"config": {"operations": []}}, ), ] edges = [_make_edge("e1", "n1", "n2")] # n2 has no outgoing graph = _make_graph(nodes, edges) engine = WorkflowEngine(graph, _make_agent()) events = list(engine.execute({}, "q")) # Should complete without crash, branch ended warning logged assert len(events) > 0 class TestStepTelemetry: """Steps persist state DELTAS plus duration_ms and a per-node tool summary.""" def _run_state_workflow(self): nodes = [ _make_node("n1", NodeType.START), _make_node( "n2", NodeType.STATE, "Set", config={ "config": { "operations": [ {"expression": "1 + 1", "target_variable": "x"} ] } }, ), _make_node("n3", NodeType.END, "End", config={"config": {}}), ] edges = [_make_edge("e1", "n1", "n2"), _make_edge("e2", "n2", "n3")] engine = WorkflowEngine(_make_graph(nodes, edges), _make_agent()) events = list(engine.execute({}, "q")) return engine, events @pytest.mark.unit def test_step_snapshots_are_deltas_not_full_state(self): engine, events = self._run_state_workflow() by_node = {log["node_id"]: log for log in engine.execution_log} # The start node's delta carries the initial state it set... assert "query" in by_node["n1"]["state_delta"] # ...and later nodes repeat none of it, only what they changed. assert by_node["n2"]["state_delta"] == {"x": 2} assert by_node["n3"]["state_delta"] == {} # The streamed step events carry the same deltas. streamed = { e["node_id"]: e for e in events if e.get("type") == "workflow_step" and e.get("status") == "completed" } assert streamed["n2"]["state_delta"] == {"x": 2} assert "query" not in streamed["n2"]["state_delta"] @pytest.mark.unit def test_steps_carry_duration_ms(self): engine, _ = self._run_state_workflow() for log in engine.execution_log: assert isinstance(log["duration_ms"], int) assert log["duration_ms"] >= 0 summary = engine.get_execution_summary() assert all(step.duration_ms is not None for step in summary) @pytest.mark.unit def test_failed_step_snapshot_is_a_delta_too(self): nodes = [ _make_node("n1", NodeType.START), _make_node( "n2", NodeType.STATE, "Bad", config={ "config": { "operations": [ {"expression": "bad!!!", "target_variable": "x"} ] } }, ), ] edges = [_make_edge("e1", "n1", "n2")] engine = WorkflowEngine(_make_graph(nodes, edges), _make_agent()) events = list(engine.execute({}, "q")) failed = [e for e in events if e.get("status") == "failed"] assert failed and "query" not in failed[0]["state_delta"] failed_log = engine.execution_log[-1] assert failed_log["status"] == "failed" assert failed_log["duration_ms"] >= 0 @pytest.mark.unit def test_summarize_tool_calls_compacts_executor_calls(self): engine = WorkflowEngine(_make_graph([], []), _make_agent()) node_agent = MagicMock() node_agent.tool_executor.tool_calls = [ { "tool_name": "artifact_generator", "action_name": "create_artifact", "arguments": {"kind": "html"}, "result": "x" * 5000, "status": "error", }, { "tool_name": "artifact_generator", "action_name": "create_artifact", "arguments": {"kind": "html"}, "result": "ok", }, ] summary = engine._summarize_tool_calls(node_agent) assert summary == [ { "tool_name": "artifact_generator", "action_name": "create_artifact", "status": "error", }, { "tool_name": "artifact_generator", "action_name": "create_artifact", "status": "completed", }, ] class TestNodeDocumentManifest: """The engine appends a per-node manifest of staged input documents to the node system prompt, scoped to that node's input_documents selection.""" _ART_1 = "11111111-1111-4111-8111-111111111111" _ART_2 = "22222222-2222-4222-8222-222222222222" def _engine_with_docs(self, monkeypatch): from contextlib import contextmanager engine = WorkflowEngine(_make_graph([], []), _make_agent()) engine.state["input_documents"] = [ {"artifact_id": self._ART_1, "ref": "A1", "filename": "portfolio_v1.csv"}, {"artifact_id": self._ART_2, "ref": "A2", "filename": "notes.txt"}, ] rows = { self._ART_1: ( {"current_version": 3, "metadata": {"ref_seq": 1}, "title": "Portfolio"}, {"filename": "portfolio_v1.csv", "mime_type": "text/csv"}, ), self._ART_2: ( {"current_version": 1, "metadata": {}, "title": "Notes"}, {"filename": "notes.txt", "mime_type": "text/plain"}, ), } class FakeRepo: def __init__(self, conn): pass def resolve_id_by_ref_seq(self, position, conversation_id=None, workflow_run_id=None): for artifact_id, (artifact, _version) in rows.items(): if artifact["metadata"].get("ref_seq") == position: return artifact_id return None def artifact_id_at_position(self, position, conversation_id=None, workflow_run_id=None): return None def get_artifact_in_parent(self, artifact_id, conversation_id=None, workflow_run_id=None): entry = rows.get(artifact_id) return entry[0] if entry else None def get_version(self, artifact_id, version): return rows[artifact_id][1] @contextmanager def fake_readonly(): yield object() monkeypatch.setattr("application.storage.db.session.db_readonly", fake_readonly) monkeypatch.setattr( "application.storage.db.repositories.artifacts.ArtifactsRepository", FakeRepo ) return engine @pytest.mark.unit def test_manifest_lists_refs_filenames_and_mimes(self, monkeypatch): from application.agents.workflows.schemas import AgentNodeConfig engine = self._engine_with_docs(monkeypatch) config = AgentNodeConfig(agent_type="classic", input_documents=["*"]) manifest = engine._node_document_manifest(config) assert "- A1: portfolio_v1.csv (text/csv)" in manifest # Legacy artifact without ref_seq falls back to the full id, which tools accept. assert f"- {self._ART_2}: notes.txt (text/plain)" in manifest assert "Pass a ref" in manifest @pytest.mark.unit def test_manifest_empty_without_selection(self, monkeypatch): from application.agents.workflows.schemas import AgentNodeConfig engine = self._engine_with_docs(monkeypatch) config = AgentNodeConfig(agent_type="classic", input_documents=[]) assert engine._node_document_manifest(config) == "" @pytest.mark.unit def test_manifest_failure_never_breaks_the_node(self, monkeypatch): from contextlib import contextmanager from application.agents.workflows.schemas import AgentNodeConfig engine = WorkflowEngine(_make_graph([], []), _make_agent()) engine.state["input_documents"] = [{"artifact_id": self._ART_1}] @contextmanager def broken_readonly(): raise RuntimeError("db down") yield monkeypatch.setattr("application.storage.db.session.db_readonly", broken_readonly) config = AgentNodeConfig(agent_type="classic", input_documents=["*"]) assert engine._node_document_manifest(config) == ""