yamlgraph 0.3.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/__init__.py +1 -0
- examples/codegen/__init__.py +5 -0
- examples/codegen/models/__init__.py +13 -0
- examples/codegen/models/schemas.py +76 -0
- examples/codegen/tests/__init__.py +1 -0
- examples/codegen/tests/test_ai_helpers.py +235 -0
- examples/codegen/tests/test_ast_analysis.py +174 -0
- examples/codegen/tests/test_code_analysis.py +134 -0
- examples/codegen/tests/test_code_context.py +301 -0
- examples/codegen/tests/test_code_nav.py +89 -0
- examples/codegen/tests/test_dependency_tools.py +119 -0
- examples/codegen/tests/test_example_tools.py +185 -0
- examples/codegen/tests/test_git_tools.py +112 -0
- examples/codegen/tests/test_impl_agent_schemas.py +193 -0
- examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
- examples/codegen/tests/test_jedi_analysis.py +226 -0
- examples/codegen/tests/test_meta_tools.py +250 -0
- examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
- examples/codegen/tests/test_syntax_tools.py +85 -0
- examples/codegen/tests/test_synthesize_prompt.py +94 -0
- examples/codegen/tests/test_template_tools.py +244 -0
- examples/codegen/tools/__init__.py +80 -0
- examples/codegen/tools/ai_helpers.py +420 -0
- examples/codegen/tools/ast_analysis.py +92 -0
- examples/codegen/tools/code_context.py +180 -0
- examples/codegen/tools/code_nav.py +52 -0
- examples/codegen/tools/dependency_tools.py +120 -0
- examples/codegen/tools/example_tools.py +188 -0
- examples/codegen/tools/git_tools.py +151 -0
- examples/codegen/tools/impl_executor.py +614 -0
- examples/codegen/tools/jedi_analysis.py +311 -0
- examples/codegen/tools/meta_tools.py +202 -0
- examples/codegen/tools/syntax_tools.py +26 -0
- examples/codegen/tools/template_tools.py +356 -0
- examples/fastapi_interview.py +167 -0
- examples/npc/api/__init__.py +1 -0
- examples/npc/api/app.py +100 -0
- examples/npc/api/routes/__init__.py +5 -0
- examples/npc/api/routes/encounter.py +182 -0
- examples/npc/api/session.py +330 -0
- examples/npc/demo.py +387 -0
- examples/npc/nodes/__init__.py +5 -0
- examples/npc/nodes/image_node.py +92 -0
- examples/npc/run_encounter.py +230 -0
- examples/shared/__init__.py +0 -0
- examples/shared/replicate_tool.py +238 -0
- examples/storyboard/__init__.py +1 -0
- examples/storyboard/generate_videos.py +335 -0
- examples/storyboard/nodes/__init__.py +12 -0
- examples/storyboard/nodes/animated_character_node.py +248 -0
- examples/storyboard/nodes/animated_image_node.py +138 -0
- examples/storyboard/nodes/character_node.py +162 -0
- examples/storyboard/nodes/image_node.py +118 -0
- examples/storyboard/nodes/replicate_tool.py +49 -0
- examples/storyboard/retry_images.py +118 -0
- scripts/demo_async_executor.py +212 -0
- scripts/demo_interview_e2e.py +200 -0
- scripts/demo_streaming.py +140 -0
- scripts/run_interview_demo.py +94 -0
- scripts/test_interrupt_fix.py +26 -0
- tests/__init__.py +1 -0
- tests/conftest.py +178 -0
- tests/integration/__init__.py +1 -0
- tests/integration/test_animated_storyboard.py +63 -0
- tests/integration/test_cli_commands.py +242 -0
- tests/integration/test_colocated_prompts.py +139 -0
- tests/integration/test_map_demo.py +50 -0
- tests/integration/test_memory_demo.py +283 -0
- tests/integration/test_npc_api/__init__.py +1 -0
- tests/integration/test_npc_api/test_routes.py +357 -0
- tests/integration/test_npc_api/test_session.py +216 -0
- tests/integration/test_pipeline_flow.py +105 -0
- tests/integration/test_providers.py +163 -0
- tests/integration/test_resume.py +75 -0
- tests/integration/test_subgraph_integration.py +295 -0
- tests/integration/test_subgraph_interrupt.py +106 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_agent_nodes.py +355 -0
- tests/unit/test_async_executor.py +346 -0
- tests/unit/test_checkpointer.py +212 -0
- tests/unit/test_checkpointer_factory.py +212 -0
- tests/unit/test_cli.py +121 -0
- tests/unit/test_cli_package.py +81 -0
- tests/unit/test_compile_graph_map.py +132 -0
- tests/unit/test_conditions_routing.py +253 -0
- tests/unit/test_config.py +93 -0
- tests/unit/test_conversation_memory.py +276 -0
- tests/unit/test_database.py +145 -0
- tests/unit/test_deprecation.py +104 -0
- tests/unit/test_executor.py +172 -0
- tests/unit/test_executor_async.py +179 -0
- tests/unit/test_export.py +149 -0
- tests/unit/test_expressions.py +178 -0
- tests/unit/test_feature_brainstorm.py +194 -0
- tests/unit/test_format_prompt.py +145 -0
- tests/unit/test_generic_report.py +200 -0
- tests/unit/test_graph_commands.py +327 -0
- tests/unit/test_graph_linter.py +627 -0
- tests/unit/test_graph_loader.py +357 -0
- tests/unit/test_graph_schema.py +193 -0
- tests/unit/test_inline_schema.py +151 -0
- tests/unit/test_interrupt_node.py +182 -0
- tests/unit/test_issues.py +164 -0
- tests/unit/test_jinja2_prompts.py +85 -0
- tests/unit/test_json_extract.py +134 -0
- tests/unit/test_langsmith.py +600 -0
- tests/unit/test_langsmith_tools.py +204 -0
- tests/unit/test_llm_factory.py +109 -0
- tests/unit/test_llm_factory_async.py +118 -0
- tests/unit/test_loops.py +403 -0
- tests/unit/test_map_node.py +144 -0
- tests/unit/test_no_backward_compat.py +56 -0
- tests/unit/test_node_factory.py +348 -0
- tests/unit/test_passthrough_node.py +126 -0
- tests/unit/test_prompts.py +324 -0
- tests/unit/test_python_nodes.py +198 -0
- tests/unit/test_reliability.py +298 -0
- tests/unit/test_result_export.py +234 -0
- tests/unit/test_router.py +296 -0
- tests/unit/test_sanitize.py +99 -0
- tests/unit/test_schema_loader.py +295 -0
- tests/unit/test_shell_tools.py +229 -0
- tests/unit/test_state_builder.py +331 -0
- tests/unit/test_state_builder_map.py +104 -0
- tests/unit/test_state_config.py +197 -0
- tests/unit/test_streaming.py +307 -0
- tests/unit/test_subgraph.py +596 -0
- tests/unit/test_template.py +190 -0
- tests/unit/test_tool_call_integration.py +164 -0
- tests/unit/test_tool_call_node.py +178 -0
- tests/unit/test_tool_nodes.py +129 -0
- tests/unit/test_websearch.py +234 -0
- yamlgraph/__init__.py +35 -0
- yamlgraph/builder.py +110 -0
- yamlgraph/cli/__init__.py +159 -0
- yamlgraph/cli/__main__.py +6 -0
- yamlgraph/cli/commands.py +231 -0
- yamlgraph/cli/deprecation.py +92 -0
- yamlgraph/cli/graph_commands.py +541 -0
- yamlgraph/cli/validators.py +37 -0
- yamlgraph/config.py +67 -0
- yamlgraph/constants.py +70 -0
- yamlgraph/error_handlers.py +227 -0
- yamlgraph/executor.py +290 -0
- yamlgraph/executor_async.py +288 -0
- yamlgraph/graph_loader.py +451 -0
- yamlgraph/map_compiler.py +150 -0
- yamlgraph/models/__init__.py +36 -0
- yamlgraph/models/graph_schema.py +181 -0
- yamlgraph/models/schemas.py +124 -0
- yamlgraph/models/state_builder.py +236 -0
- yamlgraph/node_factory.py +768 -0
- yamlgraph/routing.py +87 -0
- yamlgraph/schema_loader.py +240 -0
- yamlgraph/storage/__init__.py +20 -0
- yamlgraph/storage/checkpointer.py +72 -0
- yamlgraph/storage/checkpointer_factory.py +123 -0
- yamlgraph/storage/database.py +320 -0
- yamlgraph/storage/export.py +269 -0
- yamlgraph/tools/__init__.py +1 -0
- yamlgraph/tools/agent.py +320 -0
- yamlgraph/tools/graph_linter.py +388 -0
- yamlgraph/tools/langsmith_tools.py +125 -0
- yamlgraph/tools/nodes.py +126 -0
- yamlgraph/tools/python_tool.py +179 -0
- yamlgraph/tools/shell.py +205 -0
- yamlgraph/tools/websearch.py +242 -0
- yamlgraph/utils/__init__.py +48 -0
- yamlgraph/utils/conditions.py +157 -0
- yamlgraph/utils/expressions.py +245 -0
- yamlgraph/utils/json_extract.py +104 -0
- yamlgraph/utils/langsmith.py +416 -0
- yamlgraph/utils/llm_factory.py +118 -0
- yamlgraph/utils/llm_factory_async.py +105 -0
- yamlgraph/utils/logging.py +104 -0
- yamlgraph/utils/prompts.py +171 -0
- yamlgraph/utils/sanitize.py +98 -0
- yamlgraph/utils/template.py +102 -0
- yamlgraph/utils/validators.py +181 -0
- yamlgraph-0.3.9.dist-info/METADATA +1105 -0
- yamlgraph-0.3.9.dist-info/RECORD +185 -0
- yamlgraph-0.3.9.dist-info/WHEEL +5 -0
- yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
- yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
- yamlgraph-0.3.9.dist-info/top_level.txt +4 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Tests for Memory Demo - Multi-turn conversation with persistence.
|
|
2
|
+
|
|
3
|
+
Tests the memory features from Section 6 working together:
|
|
4
|
+
- Checkpointer for state persistence
|
|
5
|
+
- Message accumulation via dynamic state (Annotated reducers)
|
|
6
|
+
- Tool results storage
|
|
7
|
+
- Result export
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import MagicMock, patch
|
|
13
|
+
|
|
14
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestMemoryDemoGraphConfig:
|
|
18
|
+
"""Tests for memory-demo.yaml graph configuration."""
|
|
19
|
+
|
|
20
|
+
def test_graph_config_exists(self):
|
|
21
|
+
"""Graph config file exists."""
|
|
22
|
+
config_path = Path("graphs/memory-demo.yaml")
|
|
23
|
+
assert config_path.exists(), "graphs/memory-demo.yaml should exist"
|
|
24
|
+
|
|
25
|
+
def test_graph_config_loads(self):
|
|
26
|
+
"""Graph config loads without errors."""
|
|
27
|
+
from yamlgraph.graph_loader import load_graph_config
|
|
28
|
+
|
|
29
|
+
config = load_graph_config("graphs/memory-demo.yaml")
|
|
30
|
+
assert config.name == "memory_demo"
|
|
31
|
+
|
|
32
|
+
def test_graph_has_agent_node(self):
|
|
33
|
+
"""Graph includes an agent node."""
|
|
34
|
+
from yamlgraph.graph_loader import load_graph_config
|
|
35
|
+
|
|
36
|
+
config = load_graph_config("graphs/memory-demo.yaml")
|
|
37
|
+
assert "review" in config.nodes
|
|
38
|
+
assert config.nodes["review"]["type"] == "agent"
|
|
39
|
+
|
|
40
|
+
def test_graph_has_tools(self):
|
|
41
|
+
"""Graph defines git tools."""
|
|
42
|
+
from yamlgraph.graph_loader import load_graph_config
|
|
43
|
+
|
|
44
|
+
config = load_graph_config("graphs/memory-demo.yaml")
|
|
45
|
+
tools = config.tools or {}
|
|
46
|
+
assert "git_log" in tools
|
|
47
|
+
assert "git_diff" in tools
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestCodeReviewPrompt:
|
|
51
|
+
"""Tests for code_review.yaml prompt."""
|
|
52
|
+
|
|
53
|
+
def test_prompt_file_exists(self):
|
|
54
|
+
"""Prompt file exists."""
|
|
55
|
+
prompt_path = Path("prompts/code_review.yaml")
|
|
56
|
+
assert prompt_path.exists(), "prompts/code_review.yaml should exist"
|
|
57
|
+
|
|
58
|
+
def test_prompt_loads(self):
|
|
59
|
+
"""Prompt loads with system and user templates."""
|
|
60
|
+
from yamlgraph.executor import load_prompt
|
|
61
|
+
|
|
62
|
+
prompt = load_prompt("code_review")
|
|
63
|
+
assert "system" in prompt
|
|
64
|
+
assert "user" in prompt
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestCheckpointerIntegration:
|
|
68
|
+
"""Tests for checkpointer integration with graph builder."""
|
|
69
|
+
|
|
70
|
+
def test_build_graph_accepts_checkpointer(self):
|
|
71
|
+
"""build_graph accepts optional checkpointer parameter."""
|
|
72
|
+
import tempfile
|
|
73
|
+
|
|
74
|
+
from yamlgraph.builder import build_graph
|
|
75
|
+
from yamlgraph.storage.checkpointer import get_checkpointer
|
|
76
|
+
|
|
77
|
+
with tempfile.NamedTemporaryFile(suffix=".db") as f:
|
|
78
|
+
checkpointer = get_checkpointer(f.name)
|
|
79
|
+
# Should not raise
|
|
80
|
+
graph = build_graph(checkpointer=checkpointer)
|
|
81
|
+
assert graph is not None
|
|
82
|
+
|
|
83
|
+
def test_graph_with_checkpointer_accepts_thread_id(self):
|
|
84
|
+
"""Graph with checkpointer can be invoked with thread_id."""
|
|
85
|
+
import tempfile
|
|
86
|
+
|
|
87
|
+
from yamlgraph.builder import build_graph
|
|
88
|
+
from yamlgraph.storage.checkpointer import get_checkpointer
|
|
89
|
+
|
|
90
|
+
with tempfile.NamedTemporaryFile(suffix=".db") as f:
|
|
91
|
+
checkpointer = get_checkpointer(f.name)
|
|
92
|
+
_graph = build_graph(checkpointer=checkpointer) # noqa: F841
|
|
93
|
+
|
|
94
|
+
# Should accept configurable with thread_id
|
|
95
|
+
config = {"configurable": {"thread_id": "test-123"}}
|
|
96
|
+
# Just verify config structure is valid
|
|
97
|
+
assert "thread_id" in config["configurable"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestCLIThreadFlag:
|
|
101
|
+
"""Tests for CLI --thread flag."""
|
|
102
|
+
|
|
103
|
+
def test_graph_run_cli_has_thread_argument(self):
|
|
104
|
+
"""Graph run CLI accepts --thread argument."""
|
|
105
|
+
from yamlgraph.cli import create_parser
|
|
106
|
+
|
|
107
|
+
parser = create_parser()
|
|
108
|
+
# Parse with thread flag
|
|
109
|
+
args = parser.parse_args(
|
|
110
|
+
["graph", "run", "graphs/yamlgraph.yaml", "--thread", "abc123"]
|
|
111
|
+
)
|
|
112
|
+
assert args.thread == "abc123"
|
|
113
|
+
|
|
114
|
+
def test_graph_run_thread_defaults_to_none(self):
|
|
115
|
+
"""Thread defaults to None when not specified."""
|
|
116
|
+
from yamlgraph.cli import create_parser
|
|
117
|
+
|
|
118
|
+
parser = create_parser()
|
|
119
|
+
args = parser.parse_args(["graph", "run", "graphs/yamlgraph.yaml"])
|
|
120
|
+
assert args.thread is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestCLIExportFlag:
|
|
124
|
+
"""Tests for CLI --export flag."""
|
|
125
|
+
|
|
126
|
+
def test_graph_run_cli_has_export_argument(self):
|
|
127
|
+
"""Graph run CLI accepts --export flag."""
|
|
128
|
+
from yamlgraph.cli import create_parser
|
|
129
|
+
|
|
130
|
+
parser = create_parser()
|
|
131
|
+
args = parser.parse_args(["graph", "run", "graphs/yamlgraph.yaml", "--export"])
|
|
132
|
+
assert args.export is True
|
|
133
|
+
|
|
134
|
+
def test_graph_run_export_defaults_to_false(self):
|
|
135
|
+
"""Export defaults to False when not specified."""
|
|
136
|
+
from yamlgraph.cli import create_parser
|
|
137
|
+
|
|
138
|
+
parser = create_parser()
|
|
139
|
+
args = parser.parse_args(["graph", "run", "graphs/yamlgraph.yaml"])
|
|
140
|
+
assert args.export is False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestMemoryDemoEndToEnd:
|
|
144
|
+
"""End-to-end tests for memory demo (mocked LLM)."""
|
|
145
|
+
|
|
146
|
+
def test_single_turn_returns_messages(self):
|
|
147
|
+
"""Single turn execution returns messages in state."""
|
|
148
|
+
from yamlgraph.tools.agent import create_agent_node
|
|
149
|
+
from yamlgraph.tools.shell import ShellToolConfig
|
|
150
|
+
|
|
151
|
+
mock_response = AIMessage(content="Here are the recent commits...")
|
|
152
|
+
|
|
153
|
+
mock_llm = MagicMock()
|
|
154
|
+
mock_llm.bind_tools.return_value = mock_llm
|
|
155
|
+
mock_llm.invoke.return_value = mock_response
|
|
156
|
+
|
|
157
|
+
tool_config = ShellToolConfig(
|
|
158
|
+
command="git log --oneline -n {count}",
|
|
159
|
+
description="Get recent commits",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
with patch("yamlgraph.tools.agent.create_llm", return_value=mock_llm):
|
|
163
|
+
node_fn = create_agent_node(
|
|
164
|
+
"review",
|
|
165
|
+
{
|
|
166
|
+
"tools": ["git_log"],
|
|
167
|
+
"state_key": "response",
|
|
168
|
+
"tool_results_key": "_tool_results",
|
|
169
|
+
},
|
|
170
|
+
{"git_log": tool_config},
|
|
171
|
+
)
|
|
172
|
+
result = node_fn({"input": "Show recent commits"})
|
|
173
|
+
|
|
174
|
+
assert "messages" in result
|
|
175
|
+
assert "response" in result
|
|
176
|
+
|
|
177
|
+
def test_multi_turn_preserves_history(self):
|
|
178
|
+
"""Multi-turn conversation preserves message history."""
|
|
179
|
+
from yamlgraph.tools.agent import create_agent_node
|
|
180
|
+
|
|
181
|
+
mock_response = AIMessage(content="Based on our previous discussion...")
|
|
182
|
+
|
|
183
|
+
mock_llm = MagicMock()
|
|
184
|
+
mock_llm.bind_tools.return_value = mock_llm
|
|
185
|
+
mock_llm.invoke.return_value = mock_response
|
|
186
|
+
|
|
187
|
+
# Simulate existing conversation
|
|
188
|
+
existing_messages = [
|
|
189
|
+
SystemMessage(content="You are a code review assistant."),
|
|
190
|
+
HumanMessage(content="Show commits"),
|
|
191
|
+
AIMessage(content="Here are 5 commits..."),
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
with patch("yamlgraph.tools.agent.create_llm", return_value=mock_llm):
|
|
195
|
+
node_fn = create_agent_node(
|
|
196
|
+
"review",
|
|
197
|
+
{"tools": [], "state_key": "response"},
|
|
198
|
+
{},
|
|
199
|
+
)
|
|
200
|
+
result = node_fn(
|
|
201
|
+
{
|
|
202
|
+
"input": "What about tests?",
|
|
203
|
+
"messages": existing_messages,
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# New messages should be returned
|
|
208
|
+
assert len(result["messages"]) >= 2 # At least human + AI
|
|
209
|
+
|
|
210
|
+
def test_tool_results_stored_in_state(self):
|
|
211
|
+
"""Tool execution results are stored in state."""
|
|
212
|
+
from yamlgraph.tools.agent import create_agent_node
|
|
213
|
+
from yamlgraph.tools.shell import ShellToolConfig
|
|
214
|
+
|
|
215
|
+
tool_response = AIMessage(
|
|
216
|
+
content="",
|
|
217
|
+
tool_calls=[{"name": "git_log", "args": {"count": "5"}, "id": "call_1"}],
|
|
218
|
+
)
|
|
219
|
+
final_response = AIMessage(content="Found 5 commits")
|
|
220
|
+
|
|
221
|
+
mock_llm = MagicMock()
|
|
222
|
+
mock_llm.bind_tools.return_value = mock_llm
|
|
223
|
+
mock_llm.invoke.side_effect = [tool_response, final_response]
|
|
224
|
+
|
|
225
|
+
tool_config = ShellToolConfig(
|
|
226
|
+
command="git log --oneline -n {count}",
|
|
227
|
+
description="Get recent commits",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
with (
|
|
231
|
+
patch("yamlgraph.tools.agent.create_llm", return_value=mock_llm),
|
|
232
|
+
patch("yamlgraph.tools.agent.execute_shell_tool") as mock_exec,
|
|
233
|
+
):
|
|
234
|
+
mock_exec.return_value = MagicMock(
|
|
235
|
+
success=True, output="abc123 First commit\ndef456 Second commit"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
node_fn = create_agent_node(
|
|
239
|
+
"review",
|
|
240
|
+
{
|
|
241
|
+
"tools": ["git_log"],
|
|
242
|
+
"state_key": "response",
|
|
243
|
+
"tool_results_key": "_tool_results",
|
|
244
|
+
},
|
|
245
|
+
{"git_log": tool_config},
|
|
246
|
+
)
|
|
247
|
+
result = node_fn({"input": "Show commits"})
|
|
248
|
+
|
|
249
|
+
assert "_tool_results" in result
|
|
250
|
+
assert len(result["_tool_results"]) == 1
|
|
251
|
+
assert result["_tool_results"][0]["tool"] == "git_log"
|
|
252
|
+
|
|
253
|
+
def test_export_creates_files(self, tmp_path: Path):
|
|
254
|
+
"""Export flag creates output files."""
|
|
255
|
+
from yamlgraph.storage.export import export_result
|
|
256
|
+
|
|
257
|
+
state = {
|
|
258
|
+
"thread_id": "demo-123",
|
|
259
|
+
"response": "# Code Review Summary\n\nFound 5 commits.",
|
|
260
|
+
"_tool_results": [
|
|
261
|
+
{"tool": "git_log", "args": {"count": "5"}, "output": "..."}
|
|
262
|
+
],
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
config = {
|
|
266
|
+
"response": {"format": "markdown", "filename": "review.md"},
|
|
267
|
+
"_tool_results": {"format": "json", "filename": "tool_outputs.json"},
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
paths = export_result(state, config, base_path=tmp_path)
|
|
271
|
+
|
|
272
|
+
assert len(paths) == 2
|
|
273
|
+
|
|
274
|
+
# Check markdown file
|
|
275
|
+
md_path = tmp_path / "demo-123" / "review.md"
|
|
276
|
+
assert md_path.exists()
|
|
277
|
+
assert "Code Review Summary" in md_path.read_text()
|
|
278
|
+
|
|
279
|
+
# Check JSON file
|
|
280
|
+
json_path = tmp_path / "demo-123" / "tool_outputs.json"
|
|
281
|
+
assert json_path.exists()
|
|
282
|
+
data = json.loads(json_path.read_text())
|
|
283
|
+
assert data[0]["tool"] == "git_log"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Integration tests for NPC Web API."""
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Tests for NPC encounter API routes.
|
|
2
|
+
|
|
3
|
+
Tests endpoint behavior, HTMX responses, and session handling.
|
|
4
|
+
Uses mocking to avoid actual LLM calls.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from unittest.mock import AsyncMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from fastapi.responses import HTMLResponse
|
|
12
|
+
from httpx import ASGITransport, AsyncClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def mock_turn_result():
|
|
17
|
+
"""Create mock TurnResult."""
|
|
18
|
+
from examples.npc.api.session import TurnResult
|
|
19
|
+
|
|
20
|
+
return TurnResult(
|
|
21
|
+
turn_number=1,
|
|
22
|
+
narrations=[{"npc": "Grok", "text": "Hello traveler!"}],
|
|
23
|
+
scene_image="/static/images/tavern.png",
|
|
24
|
+
turn_summary="The party enters the tavern.",
|
|
25
|
+
is_complete=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestEncounterStart:
|
|
30
|
+
"""Tests for POST /encounter/start endpoint."""
|
|
31
|
+
|
|
32
|
+
@pytest.mark.asyncio
|
|
33
|
+
async def test_start_requires_session_id(self):
|
|
34
|
+
"""Start endpoint validates required session_id."""
|
|
35
|
+
from examples.npc.api.routes.encounter import router
|
|
36
|
+
|
|
37
|
+
app = FastAPI()
|
|
38
|
+
app.include_router(router)
|
|
39
|
+
|
|
40
|
+
transport = ASGITransport(app=app)
|
|
41
|
+
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
42
|
+
response = await client.post(
|
|
43
|
+
"/encounter/start",
|
|
44
|
+
data={"location": "tavern"}, # Missing session_id
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# FastAPI returns 422 for validation errors
|
|
48
|
+
assert response.status_code == 422
|
|
49
|
+
|
|
50
|
+
@pytest.mark.asyncio
|
|
51
|
+
async def test_start_accepts_valid_input(self, mock_turn_result):
|
|
52
|
+
"""Start endpoint accepts valid form data."""
|
|
53
|
+
from examples.npc.api.routes import encounter
|
|
54
|
+
|
|
55
|
+
# Mock the dependencies
|
|
56
|
+
mock_session = AsyncMock()
|
|
57
|
+
mock_session.start = AsyncMock(return_value=mock_turn_result)
|
|
58
|
+
|
|
59
|
+
async def mock_get_session(session_id):
|
|
60
|
+
return mock_session
|
|
61
|
+
|
|
62
|
+
# Mock create_npcs_from_concepts to return simple NPCs
|
|
63
|
+
async def mock_create_npcs(concepts):
|
|
64
|
+
return [{"name": "Grok", "race": "dwarf"}]
|
|
65
|
+
|
|
66
|
+
# Patch the module-level functions
|
|
67
|
+
with (
|
|
68
|
+
patch.object(encounter, "_get_session", mock_get_session),
|
|
69
|
+
patch.object(encounter, "create_npcs_from_concepts", mock_create_npcs),
|
|
70
|
+
patch.object(encounter, "templates") as mock_tmpl,
|
|
71
|
+
):
|
|
72
|
+
# Configure mock template to return proper Response
|
|
73
|
+
mock_tmpl.TemplateResponse.return_value = HTMLResponse(
|
|
74
|
+
content="<div>Started</div>",
|
|
75
|
+
headers={"HX-Trigger": "encounter-updated"},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
app = FastAPI()
|
|
79
|
+
app.include_router(encounter.router)
|
|
80
|
+
|
|
81
|
+
transport = ASGITransport(app=app)
|
|
82
|
+
async with AsyncClient(
|
|
83
|
+
transport=transport, base_url="http://test"
|
|
84
|
+
) as client:
|
|
85
|
+
response = await client.post(
|
|
86
|
+
"/encounter/start",
|
|
87
|
+
data={
|
|
88
|
+
"session_id": "test-123",
|
|
89
|
+
"location": "tavern",
|
|
90
|
+
"npc_concepts": ["a gruff dwarf"],
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
assert response.status_code == 200
|
|
95
|
+
assert mock_session.start.called
|
|
96
|
+
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
async def test_start_creates_npcs_from_concepts(self, mock_turn_result):
|
|
99
|
+
"""Start endpoint creates NPCs from concept strings."""
|
|
100
|
+
from examples.npc.api.routes import encounter
|
|
101
|
+
|
|
102
|
+
mock_session = AsyncMock()
|
|
103
|
+
mock_session.start = AsyncMock(return_value=mock_turn_result)
|
|
104
|
+
|
|
105
|
+
create_npcs_called_with = []
|
|
106
|
+
|
|
107
|
+
async def mock_create_npcs(concepts):
|
|
108
|
+
create_npcs_called_with.extend(concepts)
|
|
109
|
+
return [{"name": f"NPC-{i}", "race": "test"} for i in range(len(concepts))]
|
|
110
|
+
|
|
111
|
+
with (
|
|
112
|
+
patch.object(
|
|
113
|
+
encounter, "_get_session", AsyncMock(return_value=mock_session)
|
|
114
|
+
),
|
|
115
|
+
patch.object(encounter, "create_npcs_from_concepts", mock_create_npcs),
|
|
116
|
+
patch.object(encounter, "templates") as mock_tmpl,
|
|
117
|
+
):
|
|
118
|
+
mock_tmpl.TemplateResponse.return_value = HTMLResponse(
|
|
119
|
+
content="<div>OK</div>"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
app = FastAPI()
|
|
123
|
+
app.include_router(encounter.router)
|
|
124
|
+
|
|
125
|
+
transport = ASGITransport(app=app)
|
|
126
|
+
async with AsyncClient(
|
|
127
|
+
transport=transport, base_url="http://test"
|
|
128
|
+
) as client:
|
|
129
|
+
await client.post(
|
|
130
|
+
"/encounter/start",
|
|
131
|
+
data={
|
|
132
|
+
"session_id": "test-123",
|
|
133
|
+
"location": "tavern",
|
|
134
|
+
"npc_concepts": ["a dwarf", "an elf"],
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Should have called create_npcs with concept dicts
|
|
139
|
+
assert len(create_npcs_called_with) == 2
|
|
140
|
+
assert create_npcs_called_with[0]["concept"] == "a dwarf"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestEncounterTurn:
|
|
144
|
+
"""Tests for POST /encounter/turn endpoint."""
|
|
145
|
+
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_turn_requires_dm_input(self):
|
|
148
|
+
"""Turn endpoint validates required dm_input."""
|
|
149
|
+
from examples.npc.api.routes.encounter import router
|
|
150
|
+
|
|
151
|
+
app = FastAPI()
|
|
152
|
+
app.include_router(router)
|
|
153
|
+
|
|
154
|
+
transport = ASGITransport(app=app)
|
|
155
|
+
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
|
156
|
+
response = await client.post(
|
|
157
|
+
"/encounter/turn",
|
|
158
|
+
data={"session_id": "test-123"}, # Missing dm_input
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# FastAPI returns 422 for validation errors
|
|
162
|
+
assert response.status_code == 422
|
|
163
|
+
|
|
164
|
+
@pytest.mark.asyncio
|
|
165
|
+
async def test_turn_resumes_existing_session(self, mock_turn_result):
|
|
166
|
+
"""Turn endpoint resumes existing session."""
|
|
167
|
+
from examples.npc.api.routes import encounter
|
|
168
|
+
|
|
169
|
+
mock_session = AsyncMock()
|
|
170
|
+
mock_session._is_resume = AsyncMock(return_value=True) # Session exists
|
|
171
|
+
mock_session.turn = AsyncMock(return_value=mock_turn_result)
|
|
172
|
+
|
|
173
|
+
with (
|
|
174
|
+
patch.object(
|
|
175
|
+
encounter, "_get_session", AsyncMock(return_value=mock_session)
|
|
176
|
+
),
|
|
177
|
+
patch.object(encounter, "templates") as mock_tmpl,
|
|
178
|
+
):
|
|
179
|
+
mock_tmpl.TemplateResponse.return_value = HTMLResponse(
|
|
180
|
+
content="<div>Turn</div>"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
app = FastAPI()
|
|
184
|
+
app.include_router(encounter.router)
|
|
185
|
+
|
|
186
|
+
transport = ASGITransport(app=app)
|
|
187
|
+
async with AsyncClient(
|
|
188
|
+
transport=transport, base_url="http://test"
|
|
189
|
+
) as client:
|
|
190
|
+
response = await client.post(
|
|
191
|
+
"/encounter/turn",
|
|
192
|
+
data={
|
|
193
|
+
"session_id": "test-123",
|
|
194
|
+
"dm_input": "The party enters the tavern",
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
assert response.status_code == 200
|
|
199
|
+
assert mock_session.turn.called
|
|
200
|
+
mock_session.turn.assert_called_once_with("The party enters the tavern")
|
|
201
|
+
|
|
202
|
+
@pytest.mark.asyncio
|
|
203
|
+
async def test_turn_handles_no_session(self, mock_turn_result):
|
|
204
|
+
"""Turn on non-existent session returns error."""
|
|
205
|
+
from examples.npc.api.routes import encounter
|
|
206
|
+
|
|
207
|
+
mock_session = AsyncMock()
|
|
208
|
+
mock_session._is_resume = AsyncMock(return_value=False) # Session doesn't exist
|
|
209
|
+
|
|
210
|
+
with (
|
|
211
|
+
patch.object(
|
|
212
|
+
encounter, "_get_session", AsyncMock(return_value=mock_session)
|
|
213
|
+
),
|
|
214
|
+
patch.object(encounter, "templates") as mock_tmpl,
|
|
215
|
+
):
|
|
216
|
+
mock_tmpl.TemplateResponse.return_value = HTMLResponse(
|
|
217
|
+
content="<div>Error</div>",
|
|
218
|
+
status_code=400,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
app = FastAPI()
|
|
222
|
+
app.include_router(encounter.router)
|
|
223
|
+
|
|
224
|
+
transport = ASGITransport(app=app)
|
|
225
|
+
async with AsyncClient(
|
|
226
|
+
transport=transport, base_url="http://test"
|
|
227
|
+
) as client:
|
|
228
|
+
response = await client.post(
|
|
229
|
+
"/encounter/turn",
|
|
230
|
+
data={
|
|
231
|
+
"session_id": "nonexistent",
|
|
232
|
+
"dm_input": "Hello",
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Should return 400 error
|
|
237
|
+
assert response.status_code == 400
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class TestEncounterState:
|
|
241
|
+
"""Tests for GET /encounter/{session_id} endpoint."""
|
|
242
|
+
|
|
243
|
+
@pytest.mark.asyncio
|
|
244
|
+
async def test_get_nonexistent_session_returns_404(self):
|
|
245
|
+
"""GET non-existent session returns 404."""
|
|
246
|
+
from examples.npc.api.routes import encounter
|
|
247
|
+
|
|
248
|
+
mock_session = AsyncMock()
|
|
249
|
+
mock_session._is_resume = AsyncMock(return_value=False)
|
|
250
|
+
|
|
251
|
+
with patch.object(
|
|
252
|
+
encounter, "_get_session", AsyncMock(return_value=mock_session)
|
|
253
|
+
):
|
|
254
|
+
app = FastAPI()
|
|
255
|
+
app.include_router(encounter.router)
|
|
256
|
+
|
|
257
|
+
transport = ASGITransport(app=app)
|
|
258
|
+
async with AsyncClient(
|
|
259
|
+
transport=transport, base_url="http://test"
|
|
260
|
+
) as client:
|
|
261
|
+
response = await client.get("/encounter/nonexistent")
|
|
262
|
+
|
|
263
|
+
assert response.status_code == 404
|
|
264
|
+
|
|
265
|
+
@pytest.mark.asyncio
|
|
266
|
+
async def test_get_state_returns_current_state(self):
|
|
267
|
+
"""GET state endpoint returns current session state."""
|
|
268
|
+
from examples.npc.api.routes import encounter
|
|
269
|
+
|
|
270
|
+
mock_session = AsyncMock()
|
|
271
|
+
mock_session._is_resume = AsyncMock(return_value=True)
|
|
272
|
+
|
|
273
|
+
# Mock the graph for aget_state
|
|
274
|
+
mock_graph = AsyncMock()
|
|
275
|
+
mock_graph.aget_state = AsyncMock(
|
|
276
|
+
return_value=AsyncMock(
|
|
277
|
+
values={
|
|
278
|
+
"turn_number": 2,
|
|
279
|
+
"narrations": [],
|
|
280
|
+
"npcs": [],
|
|
281
|
+
"location": "tavern",
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
with (
|
|
287
|
+
patch.object(
|
|
288
|
+
encounter, "_get_session", AsyncMock(return_value=mock_session)
|
|
289
|
+
),
|
|
290
|
+
patch.object(
|
|
291
|
+
encounter, "get_encounter_graph", AsyncMock(return_value=mock_graph)
|
|
292
|
+
),
|
|
293
|
+
patch.object(encounter, "templates") as mock_tmpl,
|
|
294
|
+
):
|
|
295
|
+
mock_tmpl.TemplateResponse.return_value = HTMLResponse(
|
|
296
|
+
content="<div>State</div>"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
app = FastAPI()
|
|
300
|
+
app.include_router(encounter.router)
|
|
301
|
+
|
|
302
|
+
transport = ASGITransport(app=app)
|
|
303
|
+
async with AsyncClient(
|
|
304
|
+
transport=transport, base_url="http://test"
|
|
305
|
+
) as client:
|
|
306
|
+
response = await client.get("/encounter/test-123")
|
|
307
|
+
|
|
308
|
+
assert response.status_code == 200
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class TestHtmxIntegration:
|
|
312
|
+
"""Tests for HTMX-specific behavior."""
|
|
313
|
+
|
|
314
|
+
@pytest.mark.asyncio
|
|
315
|
+
async def test_response_has_hx_trigger_header(self, mock_turn_result):
|
|
316
|
+
"""Responses include HX-Trigger for client-side updates."""
|
|
317
|
+
from examples.npc.api.routes import encounter
|
|
318
|
+
|
|
319
|
+
mock_session = AsyncMock()
|
|
320
|
+
mock_session.start = AsyncMock(return_value=mock_turn_result)
|
|
321
|
+
|
|
322
|
+
with (
|
|
323
|
+
patch.object(
|
|
324
|
+
encounter, "_get_session", AsyncMock(return_value=mock_session)
|
|
325
|
+
),
|
|
326
|
+
patch.object(
|
|
327
|
+
encounter,
|
|
328
|
+
"create_npcs_from_concepts",
|
|
329
|
+
AsyncMock(return_value=[{"name": "Grok"}]),
|
|
330
|
+
),
|
|
331
|
+
patch.object(encounter, "templates") as mock_tmpl,
|
|
332
|
+
):
|
|
333
|
+
# Return response with HX-Trigger header
|
|
334
|
+
mock_tmpl.TemplateResponse.return_value = HTMLResponse(
|
|
335
|
+
content="<div>Response</div>",
|
|
336
|
+
headers={"HX-Trigger": "encounter-updated"},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
app = FastAPI()
|
|
340
|
+
app.include_router(encounter.router)
|
|
341
|
+
|
|
342
|
+
transport = ASGITransport(app=app)
|
|
343
|
+
async with AsyncClient(
|
|
344
|
+
transport=transport, base_url="http://test"
|
|
345
|
+
) as client:
|
|
346
|
+
response = await client.post(
|
|
347
|
+
"/encounter/start",
|
|
348
|
+
data={
|
|
349
|
+
"session_id": "test-123",
|
|
350
|
+
"location": "tavern",
|
|
351
|
+
"npc_concepts": ["a dwarf"],
|
|
352
|
+
},
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
assert response.status_code == 200
|
|
356
|
+
assert "HX-Trigger" in response.headers
|
|
357
|
+
assert response.headers["HX-Trigger"] == "encounter-updated"
|