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.
Files changed (185) hide show
  1. examples/__init__.py +1 -0
  2. examples/codegen/__init__.py +5 -0
  3. examples/codegen/models/__init__.py +13 -0
  4. examples/codegen/models/schemas.py +76 -0
  5. examples/codegen/tests/__init__.py +1 -0
  6. examples/codegen/tests/test_ai_helpers.py +235 -0
  7. examples/codegen/tests/test_ast_analysis.py +174 -0
  8. examples/codegen/tests/test_code_analysis.py +134 -0
  9. examples/codegen/tests/test_code_context.py +301 -0
  10. examples/codegen/tests/test_code_nav.py +89 -0
  11. examples/codegen/tests/test_dependency_tools.py +119 -0
  12. examples/codegen/tests/test_example_tools.py +185 -0
  13. examples/codegen/tests/test_git_tools.py +112 -0
  14. examples/codegen/tests/test_impl_agent_schemas.py +193 -0
  15. examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
  16. examples/codegen/tests/test_jedi_analysis.py +226 -0
  17. examples/codegen/tests/test_meta_tools.py +250 -0
  18. examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
  19. examples/codegen/tests/test_syntax_tools.py +85 -0
  20. examples/codegen/tests/test_synthesize_prompt.py +94 -0
  21. examples/codegen/tests/test_template_tools.py +244 -0
  22. examples/codegen/tools/__init__.py +80 -0
  23. examples/codegen/tools/ai_helpers.py +420 -0
  24. examples/codegen/tools/ast_analysis.py +92 -0
  25. examples/codegen/tools/code_context.py +180 -0
  26. examples/codegen/tools/code_nav.py +52 -0
  27. examples/codegen/tools/dependency_tools.py +120 -0
  28. examples/codegen/tools/example_tools.py +188 -0
  29. examples/codegen/tools/git_tools.py +151 -0
  30. examples/codegen/tools/impl_executor.py +614 -0
  31. examples/codegen/tools/jedi_analysis.py +311 -0
  32. examples/codegen/tools/meta_tools.py +202 -0
  33. examples/codegen/tools/syntax_tools.py +26 -0
  34. examples/codegen/tools/template_tools.py +356 -0
  35. examples/fastapi_interview.py +167 -0
  36. examples/npc/api/__init__.py +1 -0
  37. examples/npc/api/app.py +100 -0
  38. examples/npc/api/routes/__init__.py +5 -0
  39. examples/npc/api/routes/encounter.py +182 -0
  40. examples/npc/api/session.py +330 -0
  41. examples/npc/demo.py +387 -0
  42. examples/npc/nodes/__init__.py +5 -0
  43. examples/npc/nodes/image_node.py +92 -0
  44. examples/npc/run_encounter.py +230 -0
  45. examples/shared/__init__.py +0 -0
  46. examples/shared/replicate_tool.py +238 -0
  47. examples/storyboard/__init__.py +1 -0
  48. examples/storyboard/generate_videos.py +335 -0
  49. examples/storyboard/nodes/__init__.py +12 -0
  50. examples/storyboard/nodes/animated_character_node.py +248 -0
  51. examples/storyboard/nodes/animated_image_node.py +138 -0
  52. examples/storyboard/nodes/character_node.py +162 -0
  53. examples/storyboard/nodes/image_node.py +118 -0
  54. examples/storyboard/nodes/replicate_tool.py +49 -0
  55. examples/storyboard/retry_images.py +118 -0
  56. scripts/demo_async_executor.py +212 -0
  57. scripts/demo_interview_e2e.py +200 -0
  58. scripts/demo_streaming.py +140 -0
  59. scripts/run_interview_demo.py +94 -0
  60. scripts/test_interrupt_fix.py +26 -0
  61. tests/__init__.py +1 -0
  62. tests/conftest.py +178 -0
  63. tests/integration/__init__.py +1 -0
  64. tests/integration/test_animated_storyboard.py +63 -0
  65. tests/integration/test_cli_commands.py +242 -0
  66. tests/integration/test_colocated_prompts.py +139 -0
  67. tests/integration/test_map_demo.py +50 -0
  68. tests/integration/test_memory_demo.py +283 -0
  69. tests/integration/test_npc_api/__init__.py +1 -0
  70. tests/integration/test_npc_api/test_routes.py +357 -0
  71. tests/integration/test_npc_api/test_session.py +216 -0
  72. tests/integration/test_pipeline_flow.py +105 -0
  73. tests/integration/test_providers.py +163 -0
  74. tests/integration/test_resume.py +75 -0
  75. tests/integration/test_subgraph_integration.py +295 -0
  76. tests/integration/test_subgraph_interrupt.py +106 -0
  77. tests/unit/__init__.py +1 -0
  78. tests/unit/test_agent_nodes.py +355 -0
  79. tests/unit/test_async_executor.py +346 -0
  80. tests/unit/test_checkpointer.py +212 -0
  81. tests/unit/test_checkpointer_factory.py +212 -0
  82. tests/unit/test_cli.py +121 -0
  83. tests/unit/test_cli_package.py +81 -0
  84. tests/unit/test_compile_graph_map.py +132 -0
  85. tests/unit/test_conditions_routing.py +253 -0
  86. tests/unit/test_config.py +93 -0
  87. tests/unit/test_conversation_memory.py +276 -0
  88. tests/unit/test_database.py +145 -0
  89. tests/unit/test_deprecation.py +104 -0
  90. tests/unit/test_executor.py +172 -0
  91. tests/unit/test_executor_async.py +179 -0
  92. tests/unit/test_export.py +149 -0
  93. tests/unit/test_expressions.py +178 -0
  94. tests/unit/test_feature_brainstorm.py +194 -0
  95. tests/unit/test_format_prompt.py +145 -0
  96. tests/unit/test_generic_report.py +200 -0
  97. tests/unit/test_graph_commands.py +327 -0
  98. tests/unit/test_graph_linter.py +627 -0
  99. tests/unit/test_graph_loader.py +357 -0
  100. tests/unit/test_graph_schema.py +193 -0
  101. tests/unit/test_inline_schema.py +151 -0
  102. tests/unit/test_interrupt_node.py +182 -0
  103. tests/unit/test_issues.py +164 -0
  104. tests/unit/test_jinja2_prompts.py +85 -0
  105. tests/unit/test_json_extract.py +134 -0
  106. tests/unit/test_langsmith.py +600 -0
  107. tests/unit/test_langsmith_tools.py +204 -0
  108. tests/unit/test_llm_factory.py +109 -0
  109. tests/unit/test_llm_factory_async.py +118 -0
  110. tests/unit/test_loops.py +403 -0
  111. tests/unit/test_map_node.py +144 -0
  112. tests/unit/test_no_backward_compat.py +56 -0
  113. tests/unit/test_node_factory.py +348 -0
  114. tests/unit/test_passthrough_node.py +126 -0
  115. tests/unit/test_prompts.py +324 -0
  116. tests/unit/test_python_nodes.py +198 -0
  117. tests/unit/test_reliability.py +298 -0
  118. tests/unit/test_result_export.py +234 -0
  119. tests/unit/test_router.py +296 -0
  120. tests/unit/test_sanitize.py +99 -0
  121. tests/unit/test_schema_loader.py +295 -0
  122. tests/unit/test_shell_tools.py +229 -0
  123. tests/unit/test_state_builder.py +331 -0
  124. tests/unit/test_state_builder_map.py +104 -0
  125. tests/unit/test_state_config.py +197 -0
  126. tests/unit/test_streaming.py +307 -0
  127. tests/unit/test_subgraph.py +596 -0
  128. tests/unit/test_template.py +190 -0
  129. tests/unit/test_tool_call_integration.py +164 -0
  130. tests/unit/test_tool_call_node.py +178 -0
  131. tests/unit/test_tool_nodes.py +129 -0
  132. tests/unit/test_websearch.py +234 -0
  133. yamlgraph/__init__.py +35 -0
  134. yamlgraph/builder.py +110 -0
  135. yamlgraph/cli/__init__.py +159 -0
  136. yamlgraph/cli/__main__.py +6 -0
  137. yamlgraph/cli/commands.py +231 -0
  138. yamlgraph/cli/deprecation.py +92 -0
  139. yamlgraph/cli/graph_commands.py +541 -0
  140. yamlgraph/cli/validators.py +37 -0
  141. yamlgraph/config.py +67 -0
  142. yamlgraph/constants.py +70 -0
  143. yamlgraph/error_handlers.py +227 -0
  144. yamlgraph/executor.py +290 -0
  145. yamlgraph/executor_async.py +288 -0
  146. yamlgraph/graph_loader.py +451 -0
  147. yamlgraph/map_compiler.py +150 -0
  148. yamlgraph/models/__init__.py +36 -0
  149. yamlgraph/models/graph_schema.py +181 -0
  150. yamlgraph/models/schemas.py +124 -0
  151. yamlgraph/models/state_builder.py +236 -0
  152. yamlgraph/node_factory.py +768 -0
  153. yamlgraph/routing.py +87 -0
  154. yamlgraph/schema_loader.py +240 -0
  155. yamlgraph/storage/__init__.py +20 -0
  156. yamlgraph/storage/checkpointer.py +72 -0
  157. yamlgraph/storage/checkpointer_factory.py +123 -0
  158. yamlgraph/storage/database.py +320 -0
  159. yamlgraph/storage/export.py +269 -0
  160. yamlgraph/tools/__init__.py +1 -0
  161. yamlgraph/tools/agent.py +320 -0
  162. yamlgraph/tools/graph_linter.py +388 -0
  163. yamlgraph/tools/langsmith_tools.py +125 -0
  164. yamlgraph/tools/nodes.py +126 -0
  165. yamlgraph/tools/python_tool.py +179 -0
  166. yamlgraph/tools/shell.py +205 -0
  167. yamlgraph/tools/websearch.py +242 -0
  168. yamlgraph/utils/__init__.py +48 -0
  169. yamlgraph/utils/conditions.py +157 -0
  170. yamlgraph/utils/expressions.py +245 -0
  171. yamlgraph/utils/json_extract.py +104 -0
  172. yamlgraph/utils/langsmith.py +416 -0
  173. yamlgraph/utils/llm_factory.py +118 -0
  174. yamlgraph/utils/llm_factory_async.py +105 -0
  175. yamlgraph/utils/logging.py +104 -0
  176. yamlgraph/utils/prompts.py +171 -0
  177. yamlgraph/utils/sanitize.py +98 -0
  178. yamlgraph/utils/template.py +102 -0
  179. yamlgraph/utils/validators.py +181 -0
  180. yamlgraph-0.3.9.dist-info/METADATA +1105 -0
  181. yamlgraph-0.3.9.dist-info/RECORD +185 -0
  182. yamlgraph-0.3.9.dist-info/WHEEL +5 -0
  183. yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
  184. yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
  185. yamlgraph-0.3.9.dist-info/top_level.txt +4 -0
@@ -0,0 +1,596 @@
1
+ """Unit tests for subgraph node functionality.
2
+
3
+ TDD tests for the subgraph feature:
4
+ - create_subgraph_node function
5
+ - SubgraphNodeConfig schema
6
+ - Circular reference detection
7
+ - State mapping modes
8
+ """
9
+
10
+ from pathlib import Path
11
+ from unittest.mock import MagicMock
12
+
13
+ import pytest
14
+
15
+
16
+ class TestSubgraphNodeConfig:
17
+ """Tests for SubgraphNodeConfig schema validation."""
18
+
19
+ def test_valid_invoke_mode_config(self):
20
+ """Valid config with invoke mode and mappings."""
21
+ from yamlgraph.models.graph_schema import SubgraphNodeConfig
22
+
23
+ config = SubgraphNodeConfig(
24
+ type="subgraph",
25
+ graph="subgraphs/child.yaml",
26
+ mode="invoke",
27
+ input_mapping={"query": "user_input"},
28
+ output_mapping={"result": "analysis"},
29
+ )
30
+ assert config.mode == "invoke"
31
+ assert config.graph == "subgraphs/child.yaml"
32
+
33
+ def test_valid_direct_mode_config(self):
34
+ """Valid config with direct mode (no mappings)."""
35
+ from yamlgraph.models.graph_schema import SubgraphNodeConfig
36
+
37
+ config = SubgraphNodeConfig(
38
+ type="subgraph",
39
+ graph="subgraphs/child.yaml",
40
+ mode="direct",
41
+ )
42
+ assert config.mode == "direct"
43
+ assert config.input_mapping == {}
44
+ assert config.output_mapping == {}
45
+
46
+ def test_default_mode_is_invoke(self):
47
+ """Mode defaults to 'invoke' when not specified."""
48
+ from yamlgraph.models.graph_schema import SubgraphNodeConfig
49
+
50
+ config = SubgraphNodeConfig(
51
+ type="subgraph",
52
+ graph="child.yaml",
53
+ )
54
+ assert config.mode == "invoke"
55
+
56
+ def test_rejects_non_yaml_graph_path(self):
57
+ """Rejects graph paths that don't end in .yaml or .yml."""
58
+ from pydantic import ValidationError
59
+
60
+ from yamlgraph.models.graph_schema import SubgraphNodeConfig
61
+
62
+ with pytest.raises(ValidationError) as exc_info:
63
+ SubgraphNodeConfig(
64
+ type="subgraph",
65
+ graph="child.json",
66
+ )
67
+ assert "YAML file" in str(exc_info.value)
68
+
69
+ def test_rejects_mappings_with_direct_mode(self):
70
+ """Direct mode does not allow input/output mappings."""
71
+ from pydantic import ValidationError
72
+
73
+ from yamlgraph.models.graph_schema import SubgraphNodeConfig
74
+
75
+ with pytest.raises(ValidationError) as exc_info:
76
+ SubgraphNodeConfig(
77
+ type="subgraph",
78
+ graph="child.yaml",
79
+ mode="direct",
80
+ input_mapping={"foo": "bar"},
81
+ )
82
+ assert "direct" in str(exc_info.value).lower()
83
+
84
+ def test_accepts_yml_extension(self):
85
+ """Accepts .yml extension for graph path."""
86
+ from yamlgraph.models.graph_schema import SubgraphNodeConfig
87
+
88
+ config = SubgraphNodeConfig(
89
+ type="subgraph",
90
+ graph="child.yml",
91
+ )
92
+ assert config.graph == "child.yml"
93
+
94
+ def test_accepts_checkpointer_override(self):
95
+ """Accepts optional checkpointer override."""
96
+ from yamlgraph.models.graph_schema import SubgraphNodeConfig
97
+
98
+ config = SubgraphNodeConfig(
99
+ type="subgraph",
100
+ graph="child.yaml",
101
+ checkpointer="memory",
102
+ )
103
+ assert config.checkpointer == "memory"
104
+
105
+
106
+ class TestCreateSubgraphNode:
107
+ """Tests for create_subgraph_node function."""
108
+
109
+ @pytest.fixture
110
+ def child_graph_yaml(self, tmp_path: Path) -> Path:
111
+ """Create a minimal child graph YAML file."""
112
+ child_yaml = tmp_path / "subgraphs" / "child.yaml"
113
+ child_yaml.parent.mkdir(parents=True, exist_ok=True)
114
+ child_yaml.write_text(
115
+ """
116
+ version: "1.0"
117
+ name: child
118
+ state:
119
+ input_text: str
120
+ output_text: str
121
+ nodes:
122
+ process:
123
+ type: llm
124
+ prompt: test
125
+ state_key: output_text
126
+ edges:
127
+ - {from: START, to: process}
128
+ - {from: process, to: END}
129
+ """
130
+ )
131
+ return child_yaml
132
+
133
+ @pytest.fixture
134
+ def parent_graph_path(self, tmp_path: Path) -> Path:
135
+ """Return path to parent graph (for relative resolution)."""
136
+ return tmp_path / "parent.yaml"
137
+
138
+ def test_creates_callable_node_invoke_mode(
139
+ self, child_graph_yaml: Path, parent_graph_path: Path
140
+ ):
141
+ """Creates a callable node function in invoke mode."""
142
+ from yamlgraph.node_factory import create_subgraph_node
143
+
144
+ config = {
145
+ "type": "subgraph",
146
+ "mode": "invoke",
147
+ "graph": "subgraphs/child.yaml",
148
+ "input_mapping": {"parent_input": "input_text"},
149
+ "output_mapping": {"result": "output_text"},
150
+ }
151
+
152
+ node = create_subgraph_node(
153
+ "test_subgraph",
154
+ config,
155
+ parent_graph_path=parent_graph_path,
156
+ )
157
+ assert callable(node)
158
+
159
+ def test_raises_file_not_found_for_missing_graph(self, parent_graph_path: Path):
160
+ """Raises FileNotFoundError when subgraph doesn't exist."""
161
+ from yamlgraph.node_factory import create_subgraph_node
162
+
163
+ config = {
164
+ "type": "subgraph",
165
+ "graph": "nonexistent.yaml",
166
+ }
167
+
168
+ with pytest.raises(FileNotFoundError) as exc_info:
169
+ create_subgraph_node(
170
+ "test_subgraph",
171
+ config,
172
+ parent_graph_path=parent_graph_path,
173
+ )
174
+ assert "nonexistent.yaml" in str(exc_info.value)
175
+
176
+ def test_resolves_path_relative_to_parent(
177
+ self, child_graph_yaml: Path, parent_graph_path: Path
178
+ ):
179
+ """Graph path is resolved relative to parent graph file."""
180
+ from yamlgraph.node_factory import create_subgraph_node
181
+
182
+ config = {
183
+ "type": "subgraph",
184
+ "graph": "subgraphs/child.yaml", # Relative path
185
+ }
186
+
187
+ # Should resolve to tmp_path/subgraphs/child.yaml
188
+ node = create_subgraph_node(
189
+ "test_subgraph",
190
+ config,
191
+ parent_graph_path=parent_graph_path,
192
+ )
193
+ assert callable(node)
194
+
195
+
196
+ class TestSubgraphStateMapping:
197
+ """Tests for state mapping between parent and child."""
198
+
199
+ @pytest.fixture
200
+ def mock_compiled_graph(self):
201
+ """Create a mock compiled graph."""
202
+ mock = MagicMock()
203
+ mock.invoke.return_value = {"output_text": "result from child"}
204
+ return mock
205
+
206
+ def test_maps_input_state_explicit(self, mock_compiled_graph):
207
+ """Explicit input mapping transforms parent state to child input."""
208
+ from yamlgraph.node_factory import _map_input_state
209
+
210
+ parent_state = {"query": "hello", "context": "world", "other": "ignored"}
211
+ input_mapping = {"query": "user_input", "context": "conversation"}
212
+
213
+ child_input = _map_input_state(parent_state, input_mapping)
214
+
215
+ assert child_input == {"user_input": "hello", "conversation": "world"}
216
+ assert "other" not in child_input
217
+
218
+ def test_maps_input_state_auto(self, mock_compiled_graph):
219
+ """Auto mapping copies all parent state fields."""
220
+ from yamlgraph.node_factory import _map_input_state
221
+
222
+ parent_state = {"query": "hello", "context": "world"}
223
+
224
+ child_input = _map_input_state(parent_state, "auto")
225
+
226
+ assert child_input == {"query": "hello", "context": "world"}
227
+ assert child_input is not parent_state # Should be a copy
228
+
229
+ def test_maps_input_state_full(self, mock_compiled_graph):
230
+ """Star mapping passes entire state (same reference)."""
231
+ from yamlgraph.node_factory import _map_input_state
232
+
233
+ parent_state = {"query": "hello", "context": "world"}
234
+
235
+ child_input = _map_input_state(parent_state, "*")
236
+
237
+ assert child_input is parent_state
238
+
239
+ def test_maps_output_state_explicit(self):
240
+ """Explicit output mapping transforms child output to parent updates."""
241
+ from yamlgraph.node_factory import _map_output_state
242
+
243
+ child_output = {"analysis": "done", "meta": {"count": 1}, "internal": "ignored"}
244
+ output_mapping = {"result": "analysis", "metadata": "meta"}
245
+
246
+ parent_updates = _map_output_state(child_output, output_mapping)
247
+
248
+ assert parent_updates == {"result": "done", "metadata": {"count": 1}}
249
+ assert "internal" not in parent_updates
250
+
251
+
252
+ class TestCircularReferenceDetection:
253
+ """Tests for circular subgraph reference detection."""
254
+
255
+ def test_detects_direct_self_reference(self, tmp_path: Path):
256
+ """Detects A → A cycle (graph references itself)."""
257
+ from yamlgraph.node_factory import create_subgraph_node
258
+
259
+ # Create a graph that references itself
260
+ self_ref = tmp_path / "self.yaml"
261
+ self_ref.write_text(
262
+ """
263
+ version: "1.0"
264
+ name: self-referencing
265
+ state:
266
+ data: str
267
+ nodes:
268
+ recurse:
269
+ type: subgraph
270
+ graph: self.yaml
271
+ edges:
272
+ - {from: START, to: recurse}
273
+ - {from: recurse, to: END}
274
+ """
275
+ )
276
+
277
+ config = {"type": "subgraph", "graph": "self.yaml"}
278
+
279
+ with pytest.raises(ValueError) as exc_info:
280
+ create_subgraph_node("test", config, parent_graph_path=self_ref)
281
+
282
+ assert "Circular" in str(exc_info.value)
283
+
284
+ def test_detects_indirect_cycle(self, tmp_path: Path):
285
+ """Detects A → B → A cycle."""
286
+ from yamlgraph.node_factory import create_subgraph_node
287
+
288
+ # Create A that references B
289
+ graph_a = tmp_path / "a.yaml"
290
+ graph_a.write_text(
291
+ """
292
+ version: "1.0"
293
+ name: graph-a
294
+ state:
295
+ data: str
296
+ nodes:
297
+ call_b:
298
+ type: subgraph
299
+ graph: b.yaml
300
+ edges:
301
+ - {from: START, to: call_b}
302
+ - {from: call_b, to: END}
303
+ """
304
+ )
305
+
306
+ # Create B that references A
307
+ graph_b = tmp_path / "b.yaml"
308
+ graph_b.write_text(
309
+ """
310
+ version: "1.0"
311
+ name: graph-b
312
+ state:
313
+ data: str
314
+ nodes:
315
+ call_a:
316
+ type: subgraph
317
+ graph: a.yaml
318
+ edges:
319
+ - {from: START, to: call_a}
320
+ - {from: call_a, to: END}
321
+ """
322
+ )
323
+
324
+ config = {"type": "subgraph", "graph": "b.yaml"}
325
+
326
+ with pytest.raises(ValueError) as exc_info:
327
+ create_subgraph_node("test", config, parent_graph_path=graph_a)
328
+
329
+ error_msg = str(exc_info.value)
330
+ assert "Circular" in error_msg
331
+ assert "a.yaml" in error_msg
332
+
333
+ def test_allows_diamond_pattern(self, tmp_path: Path):
334
+ """Allows diamond pattern: A→B, A→C, B→D, C→D (not circular)."""
335
+ from yamlgraph.node_factory import create_subgraph_node
336
+
337
+ # Create D (leaf)
338
+ graph_d = tmp_path / "d.yaml"
339
+ graph_d.write_text(
340
+ """
341
+ version: "1.0"
342
+ name: graph-d
343
+ state:
344
+ data: str
345
+ nodes:
346
+ process:
347
+ type: llm
348
+ prompt: test
349
+ state_key: data
350
+ edges:
351
+ - {from: START, to: process}
352
+ - {from: process, to: END}
353
+ """
354
+ )
355
+
356
+ # Create B that references D
357
+ graph_b = tmp_path / "b.yaml"
358
+ graph_b.write_text(
359
+ """
360
+ version: "1.0"
361
+ name: graph-b
362
+ state:
363
+ data: str
364
+ nodes:
365
+ call_d:
366
+ type: subgraph
367
+ graph: d.yaml
368
+ edges:
369
+ - {from: START, to: call_d}
370
+ - {from: call_d, to: END}
371
+ """
372
+ )
373
+
374
+ # Create C that also references D
375
+ graph_c = tmp_path / "c.yaml"
376
+ graph_c.write_text(
377
+ """
378
+ version: "1.0"
379
+ name: graph-c
380
+ state:
381
+ data: str
382
+ nodes:
383
+ call_d:
384
+ type: subgraph
385
+ graph: d.yaml
386
+ edges:
387
+ - {from: START, to: call_d}
388
+ - {from: call_d, to: END}
389
+ """
390
+ )
391
+
392
+ # This should NOT raise (diamond is valid)
393
+ config_b = {"type": "subgraph", "graph": "b.yaml"}
394
+ config_c = {"type": "subgraph", "graph": "c.yaml"}
395
+
396
+ # Both should succeed (D is referenced twice but no cycle)
397
+ parent_path = tmp_path / "a.yaml"
398
+ node_b = create_subgraph_node("call_b", config_b, parent_graph_path=parent_path)
399
+ node_c = create_subgraph_node("call_c", config_c, parent_graph_path=parent_path)
400
+
401
+ assert callable(node_b)
402
+ assert callable(node_c)
403
+
404
+
405
+ class TestThreadIdPropagation:
406
+ """Tests for thread ID propagation to child graphs."""
407
+
408
+ def test_propagates_thread_id_from_config(self):
409
+ """Thread ID is propagated as parent_thread:node_name."""
410
+ from yamlgraph.node_factory import _build_child_config
411
+
412
+ parent_config = {"configurable": {"thread_id": "main-123"}}
413
+ node_name = "summarizer"
414
+
415
+ child_config = _build_child_config(parent_config, node_name)
416
+
417
+ assert child_config["configurable"]["thread_id"] == "main-123:summarizer"
418
+
419
+ def test_creates_thread_id_when_parent_has_none(self):
420
+ """Creates thread ID from node name when parent has none."""
421
+ from yamlgraph.node_factory import _build_child_config
422
+
423
+ parent_config = {"configurable": {}}
424
+ node_name = "summarizer"
425
+
426
+ child_config = _build_child_config(parent_config, node_name)
427
+
428
+ assert child_config["configurable"]["thread_id"] == "summarizer"
429
+
430
+ def test_preserves_other_config_values(self):
431
+ """Other config values are preserved in child config."""
432
+ from yamlgraph.node_factory import _build_child_config
433
+
434
+ parent_config = {
435
+ "configurable": {"thread_id": "main", "other": "value"},
436
+ "tags": ["test"],
437
+ }
438
+ node_name = "child"
439
+
440
+ child_config = _build_child_config(parent_config, node_name)
441
+
442
+ assert child_config["configurable"]["other"] == "value"
443
+ assert child_config["tags"] == ["test"]
444
+
445
+
446
+ class TestInterruptOutputMapping:
447
+ """Tests for interrupt_output_mapping feature (FR-006)."""
448
+
449
+ def test_schema_accepts_interrupt_output_mapping(self):
450
+ """SubgraphNodeConfig accepts interrupt_output_mapping."""
451
+ from yamlgraph.models.graph_schema import SubgraphNodeConfig
452
+
453
+ config = SubgraphNodeConfig(
454
+ type="subgraph",
455
+ graph="child.yaml",
456
+ mode="invoke",
457
+ output_mapping={"final_result": "result"},
458
+ interrupt_output_mapping={"current_phase": "phase"},
459
+ )
460
+ assert config.interrupt_output_mapping == {"current_phase": "phase"}
461
+
462
+ def test_schema_default_interrupt_output_mapping_empty(self):
463
+ """interrupt_output_mapping defaults to empty dict."""
464
+ from yamlgraph.models.graph_schema import SubgraphNodeConfig
465
+
466
+ config = SubgraphNodeConfig(
467
+ type="subgraph",
468
+ graph="child.yaml",
469
+ )
470
+ assert config.interrupt_output_mapping == {}
471
+
472
+ def test_applies_interrupt_mapping_when_subgraph_interrupts(self, tmp_path):
473
+ """interrupt_output_mapping is applied when subgraph returns __interrupt__."""
474
+ from unittest.mock import MagicMock, patch
475
+
476
+ from yamlgraph.node_factory import create_subgraph_node
477
+
478
+ # Create a minimal child graph file
479
+ child_yaml = tmp_path / "child.yaml"
480
+ child_yaml.write_text(
481
+ """
482
+ version: "1.0"
483
+ name: child
484
+ state:
485
+ phase: str
486
+ result: str
487
+ nodes:
488
+ ask_question:
489
+ type: interrupt
490
+ state_key: question
491
+ edges:
492
+ - {from: START, to: ask_question}
493
+ - {from: ask_question, to: END}
494
+ """
495
+ )
496
+
497
+ parent_path = tmp_path / "parent.yaml"
498
+
499
+ config = {
500
+ "type": "subgraph",
501
+ "graph": "child.yaml",
502
+ "mode": "invoke",
503
+ "output_mapping": {"final_result": "result"},
504
+ "interrupt_output_mapping": {"current_phase": "phase"},
505
+ }
506
+
507
+ # Mock the compiled subgraph to return an interrupted state
508
+ mock_compiled = MagicMock()
509
+ mock_compiled.invoke.return_value = {
510
+ "phase": "probing",
511
+ "result": None,
512
+ "__interrupt__": (MagicMock(value={"question": "What?"}),),
513
+ }
514
+
515
+ with patch(
516
+ "yamlgraph.graph_loader.compile_graph"
517
+ ) as mock_compile_graph, patch(
518
+ "yamlgraph.graph_loader.load_graph_config"
519
+ ) as mock_load:
520
+ mock_load.return_value = MagicMock()
521
+ mock_state_graph = MagicMock()
522
+ mock_state_graph.compile.return_value = mock_compiled
523
+ mock_compile_graph.return_value = mock_state_graph
524
+
525
+ node_fn = create_subgraph_node("demographics", config, parent_path)
526
+ result = node_fn({"user_input": "hello"}, {})
527
+
528
+ # Should use interrupt_output_mapping, not output_mapping
529
+ assert "current_phase" in result
530
+ assert result["current_phase"] == "probing"
531
+ # Should NOT have the completion mapping
532
+ assert "final_result" not in result
533
+ # Should forward the interrupt marker
534
+ assert "__interrupt__" in result
535
+
536
+ def test_applies_output_mapping_when_subgraph_completes(self, tmp_path):
537
+ """output_mapping is applied when subgraph completes (no __interrupt__)."""
538
+ from unittest.mock import MagicMock, patch
539
+
540
+ from yamlgraph.node_factory import create_subgraph_node
541
+
542
+ child_yaml = tmp_path / "child.yaml"
543
+ child_yaml.write_text(
544
+ """
545
+ version: "1.0"
546
+ name: child
547
+ state:
548
+ phase: str
549
+ result: str
550
+ nodes:
551
+ process:
552
+ type: llm
553
+ prompt: test
554
+ state_key: result
555
+ edges:
556
+ - {from: START, to: process}
557
+ - {from: process, to: END}
558
+ """
559
+ )
560
+
561
+ parent_path = tmp_path / "parent.yaml"
562
+
563
+ config = {
564
+ "type": "subgraph",
565
+ "graph": "child.yaml",
566
+ "mode": "invoke",
567
+ "output_mapping": {"final_result": "result"},
568
+ "interrupt_output_mapping": {"current_phase": "phase"},
569
+ }
570
+
571
+ # Mock the compiled subgraph to return a completed state (no __interrupt__)
572
+ mock_compiled = MagicMock()
573
+ mock_compiled.invoke.return_value = {
574
+ "phase": "complete",
575
+ "result": "Analysis done",
576
+ # No __interrupt__ key
577
+ }
578
+
579
+ with patch(
580
+ "yamlgraph.graph_loader.compile_graph"
581
+ ) as mock_compile_graph, patch(
582
+ "yamlgraph.graph_loader.load_graph_config"
583
+ ) as mock_load:
584
+ mock_load.return_value = MagicMock()
585
+ mock_state_graph = MagicMock()
586
+ mock_state_graph.compile.return_value = mock_compiled
587
+ mock_compile_graph.return_value = mock_state_graph
588
+
589
+ node_fn = create_subgraph_node("demographics", config, parent_path)
590
+ result = node_fn({"user_input": "hello"}, {})
591
+
592
+ # Should use output_mapping, not interrupt_output_mapping
593
+ assert "final_result" in result
594
+ assert result["final_result"] == "Analysis done"
595
+ # Should NOT have the interrupt mapping
596
+ assert "current_phase" not in result