flowent 0.1.3 → 0.1.5

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 (71) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/agent.py +23 -1
  23. package/backend/src/flowent/approval.py +148 -0
  24. package/backend/src/flowent/cli.py +16 -2
  25. package/backend/src/flowent/compact.py +183 -0
  26. package/backend/src/flowent/context.py +19 -1
  27. package/backend/src/flowent/llm.py +51 -11
  28. package/backend/src/flowent/logging.py +60 -0
  29. package/backend/src/flowent/main.py +696 -192
  30. package/backend/src/flowent/mcp.py +3 -1
  31. package/backend/src/flowent/patch.py +55 -31
  32. package/backend/src/flowent/paths.py +12 -0
  33. package/backend/src/flowent/permissions.py +185 -42
  34. package/backend/src/flowent/sandbox.py +146 -13
  35. package/backend/src/flowent/static/assets/index-Cl20cARb.css +2 -0
  36. package/backend/src/flowent/static/assets/index-dsDDsEym.js +81 -0
  37. package/backend/src/flowent/static/index.html +2 -2
  38. package/backend/src/flowent/storage.py +257 -9
  39. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  51. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  52. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  53. package/backend/tests/test_agent_tools.py +312 -1
  54. package/backend/tests/test_approval.py +283 -0
  55. package/backend/tests/test_llm_providers.py +216 -0
  56. package/backend/tests/test_logging.py +30 -0
  57. package/backend/tests/test_mcp.py +76 -10
  58. package/backend/tests/test_patch.py +112 -0
  59. package/backend/tests/test_permissions.py +198 -53
  60. package/backend/tests/test_persistence.py +78 -0
  61. package/backend/tests/test_startup_requirements.py +96 -0
  62. package/backend/tests/test_workspace_chat.py +1265 -144
  63. package/backend/uv.lock +1 -1
  64. package/dist/frontend/assets/index-Cl20cARb.css +2 -0
  65. package/dist/frontend/assets/index-dsDDsEym.js +81 -0
  66. package/dist/frontend/index.html +2 -2
  67. package/package.json +2 -2
  68. package/backend/src/flowent/static/assets/index-DjF2KBwE.js +0 -81
  69. package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
  70. package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
  71. package/dist/frontend/assets/index-P-bBpJG8.css +0 -2
@@ -1,3 +1,5 @@
1
+ import json
2
+
1
3
  import pytest
2
4
 
3
5
  from flowent.llm import (
@@ -8,10 +10,17 @@ from flowent.llm import (
8
10
  build_litellm_request,
9
11
  chunk_delta_reasoning,
10
12
  complete_chat,
13
+ normalize_system_messages,
11
14
  stream_chat,
12
15
  )
13
16
 
14
17
 
18
+ def read_single_llm_request_diagnostic(tmp_path):
19
+ files = sorted((tmp_path / "logs" / "llm-requests").glob("llm-request-*.json"))
20
+ assert len(files) == 1
21
+ return json.loads(files[0].read_text())
22
+
23
+
15
24
  def test_supported_provider_formats_match_product_choices() -> None:
16
25
  assert [provider.value for provider in ProviderFormat] == [
17
26
  "openai",
@@ -137,6 +146,63 @@ async def test_complete_chat_uses_injected_litellm_completion() -> None:
137
146
  assert answer == ChatMessage(role="assistant", content="Here is the checklist.")
138
147
 
139
148
 
149
+ @pytest.mark.anyio
150
+ async def test_development_mode_writes_completion_request_diagnostic_file(
151
+ tmp_path, monkeypatch
152
+ ) -> None:
153
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
154
+ monkeypatch.setenv("DEBUG", "true")
155
+
156
+ async def fake_completion(**request: object) -> dict[str, object]:
157
+ return {
158
+ "choices": [
159
+ {
160
+ "message": {
161
+ "content": "Here is the checklist.",
162
+ "role": "assistant",
163
+ },
164
+ }
165
+ ]
166
+ }
167
+
168
+ connection = ProviderConnection(
169
+ name="Responses",
170
+ provider=ProviderFormat.OPENAI_RESPONSES,
171
+ model="gpt-5.1",
172
+ secret_reference="sk-request-secret",
173
+ )
174
+ messages = [ChatMessage(role="user", content="Create a checklist.")]
175
+ tools = [
176
+ {
177
+ "type": "function",
178
+ "function": {
179
+ "name": "create_checklist",
180
+ "description": "Create a checklist.",
181
+ },
182
+ }
183
+ ]
184
+
185
+ await complete_chat(
186
+ connection,
187
+ messages,
188
+ completion=fake_completion,
189
+ tools=tools,
190
+ )
191
+
192
+ diagnostic = read_single_llm_request_diagnostic(tmp_path)
193
+
194
+ assert diagnostic == {
195
+ "base_url": None,
196
+ "litellm_model": "openai/gpt-5.1",
197
+ "messages": [{"content": "Create a checklist.", "role": "user"}],
198
+ "model": "gpt-5.1",
199
+ "provider": "openai_responses",
200
+ "reasoning_effort": "default",
201
+ "stream": False,
202
+ "tools": tools,
203
+ }
204
+
205
+
140
206
  @pytest.mark.anyio
141
207
  async def test_stream_chat_uses_litellm_streaming() -> None:
142
208
  captured_request: dict[str, object] = {}
@@ -169,3 +235,153 @@ async def test_stream_chat_uses_litellm_streaming() -> None:
169
235
  assert captured_request["stream"] is True
170
236
  assert captured_request["model"] == "openai/gpt-5.1"
171
237
  assert chunks == ["Here is ", "the checklist."]
238
+
239
+
240
+ @pytest.mark.anyio
241
+ async def test_development_mode_writes_one_streaming_request_diagnostic_file(
242
+ tmp_path, monkeypatch
243
+ ) -> None:
244
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
245
+ monkeypatch.setenv("DEBUG", "true")
246
+
247
+ async def fake_completion(**request: object) -> object:
248
+ async def chunks() -> object:
249
+ yield {"choices": [{"delta": {"content": "Here is "}}]}
250
+ yield {"choices": [{"delta": {"content": "the checklist."}}]}
251
+
252
+ return chunks()
253
+
254
+ connection = ProviderConnection(
255
+ name="Responses",
256
+ provider=ProviderFormat.OPENAI_RESPONSES,
257
+ model="gpt-5.1",
258
+ secret_reference="sk-request-secret",
259
+ )
260
+
261
+ chunks = [
262
+ chunk
263
+ async for chunk in stream_chat(
264
+ connection,
265
+ [ChatMessage(role="user", content="Create a checklist.")],
266
+ completion=fake_completion,
267
+ )
268
+ ]
269
+ diagnostic = read_single_llm_request_diagnostic(tmp_path)
270
+
271
+ assert chunks == ["Here is ", "the checklist."]
272
+ assert diagnostic["stream"] is True
273
+
274
+
275
+ @pytest.mark.anyio
276
+ async def test_development_request_diagnostic_omits_api_key_and_secret_values(
277
+ tmp_path, monkeypatch
278
+ ) -> None:
279
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
280
+ monkeypatch.setenv("DEBUG", "true")
281
+
282
+ async def fake_completion(**request: object) -> dict[str, object]:
283
+ return {
284
+ "choices": [
285
+ {
286
+ "message": {
287
+ "content": "Here is the checklist.",
288
+ "role": "assistant",
289
+ },
290
+ }
291
+ ]
292
+ }
293
+
294
+ connection = ProviderConnection(
295
+ name="Responses",
296
+ provider=ProviderFormat.OPENAI_RESPONSES,
297
+ model="gpt-5.1",
298
+ secret_reference="sk-provider-secret",
299
+ )
300
+ tools = [
301
+ {
302
+ "type": "function",
303
+ "function": {
304
+ "name": "create_checklist",
305
+ "description": "Uses api_key=sk-tool-secret when configured.",
306
+ },
307
+ }
308
+ ]
309
+
310
+ await complete_chat(
311
+ connection,
312
+ [ChatMessage(role="user", content="authorization=Bearer sk-message-secret")],
313
+ completion=fake_completion,
314
+ tools=tools,
315
+ )
316
+
317
+ rendered = next(
318
+ (tmp_path / "logs" / "llm-requests").glob("llm-request-*.json")
319
+ ).read_text()
320
+
321
+ assert "api_key" not in rendered
322
+ assert "sk-provider-secret" not in rendered
323
+ assert "sk-tool-secret" not in rendered
324
+ assert "sk-message-secret" not in rendered
325
+
326
+
327
+ @pytest.mark.anyio
328
+ async def test_non_development_mode_skips_request_diagnostic_file(
329
+ tmp_path, monkeypatch
330
+ ) -> None:
331
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
332
+ monkeypatch.delenv("DEBUG", raising=False)
333
+
334
+ async def fake_completion(**request: object) -> dict[str, object]:
335
+ return {
336
+ "choices": [
337
+ {
338
+ "message": {
339
+ "content": "Here is the checklist.",
340
+ "role": "assistant",
341
+ },
342
+ }
343
+ ]
344
+ }
345
+
346
+ connection = ProviderConnection(
347
+ name="Responses",
348
+ provider=ProviderFormat.OPENAI_RESPONSES,
349
+ model="gpt-5.1",
350
+ secret_reference="sk-request-secret",
351
+ )
352
+
353
+ await complete_chat(
354
+ connection,
355
+ [ChatMessage(role="user", content="Create a checklist.")],
356
+ completion=fake_completion,
357
+ )
358
+
359
+ assert not (tmp_path / "logs" / "llm-requests").exists()
360
+
361
+
362
+ def test_normalize_system_messages_keeps_multiple_system_messages_for_openai() -> None:
363
+ messages = [
364
+ {"role": "system", "content": "Base prompt."},
365
+ {"role": "system", "content": "Configured prompt."},
366
+ {"role": "user", "content": "Hello."},
367
+ ]
368
+
369
+ assert normalize_system_messages(messages, ProviderFormat.OPENAI) == messages
370
+
371
+
372
+ def test_normalize_system_messages_converts_additional_system_messages_for_anthropic() -> (
373
+ None
374
+ ):
375
+ messages = [
376
+ {"role": "system", "content": "Base prompt."},
377
+ {"role": "system", "content": "Configured prompt."},
378
+ {"role": "system", "content": "Project prompt."},
379
+ {"role": "user", "content": "Hello."},
380
+ ]
381
+
382
+ assert normalize_system_messages(messages, ProviderFormat.ANTHROPIC) == [
383
+ {"role": "system", "content": "Base prompt."},
384
+ {"role": "user", "content": "Configured prompt."},
385
+ {"role": "user", "content": "Project prompt."},
386
+ {"role": "user", "content": "Hello."},
387
+ ]
@@ -8,6 +8,7 @@ from flowent.logging import (
8
8
  configure_logging,
9
9
  ensure_logging_configured,
10
10
  redact_log_value,
11
+ sanitize_diagnostic_value,
11
12
  )
12
13
 
13
14
 
@@ -103,6 +104,35 @@ def test_logging_redacts_full_api_key_but_keeps_context(tmp_path, monkeypatch) -
103
104
  )
104
105
 
105
106
 
107
+ def test_diagnostic_sanitizer_removes_secret_fields_and_values() -> None:
108
+ sanitized = sanitize_diagnostic_value(
109
+ {
110
+ "api_key": "sk-root-secret",
111
+ "messages": [
112
+ {
113
+ "role": "user",
114
+ "content": "authorization=Bearer sk-message-secret",
115
+ }
116
+ ],
117
+ "tools": [
118
+ {
119
+ "function": {
120
+ "name": "send_message",
121
+ "description": "Needs api_key=sk-tool-secret.",
122
+ }
123
+ }
124
+ ],
125
+ }
126
+ )
127
+
128
+ rendered = str(sanitized)
129
+
130
+ assert "api_key" not in rendered
131
+ assert "sk-root-secret" not in rendered
132
+ assert "sk-message-secret" not in rendered
133
+ assert "sk-tool-secret" not in rendered
134
+
135
+
106
136
  def test_direct_main_app_import_creates_data_log_file(tmp_path, monkeypatch) -> None:
107
137
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
108
138
  sys.modules.pop("flowent.main", None)
@@ -411,7 +411,10 @@ def test_disabled_mcp_server_does_not_connect_or_expose_tools(
411
411
  assert response.json()["status"] == "disabled"
412
412
 
413
413
 
414
- def test_enabled_mcp_server_connects_and_lists_tools(tmp_path, monkeypatch) -> None:
414
+ @pytest.mark.anyio
415
+ async def test_enabled_mcp_server_save_returns_starting_and_connects_in_background(
416
+ tmp_path, monkeypatch
417
+ ) -> None:
415
418
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
416
419
  transport = FakeMcpTransport()
417
420
  transport.tools_by_server["mcp-files"] = [
@@ -426,11 +429,20 @@ def test_enabled_mcp_server_connects_and_lists_tools(tmp_path, monkeypatch) -> N
426
429
  response = client.put("/api/mcp/servers", json=command_server_payload())
427
430
 
428
431
  assert response.status_code == 200
429
- assert response.json()["status"] == "ready"
430
- assert response.json()["tools"][0]["name"] == "read_file"
432
+ assert response.json()["status"] == "starting"
433
+ assert response.json()["tools"] == []
434
+ manager = client.app.state.mcp_manager
435
+ connected = await wait_for_status(
436
+ manager,
437
+ StoredMcpServer.model_validate(response.json()),
438
+ "ready",
439
+ )
440
+ assert connected.status == "ready"
441
+ assert connected.tools[0].name == "read_file"
431
442
 
432
443
 
433
- def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> None:
444
+ @pytest.mark.anyio
445
+ async def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> None:
434
446
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
435
447
  transport = FakeMcpTransport()
436
448
  transport.errors["mcp-files"] = "Command failed"
@@ -439,11 +451,19 @@ def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> Non
439
451
  response = client.put("/api/mcp/servers", json=command_server_payload())
440
452
 
441
453
  assert response.status_code == 200
442
- assert response.json()["status"] == "error"
443
- assert response.json()["error"] == "Command failed"
454
+ assert response.json()["status"] == "starting"
455
+ manager = client.app.state.mcp_manager
456
+ errored = await wait_for_status(
457
+ manager,
458
+ StoredMcpServer.model_validate(response.json()),
459
+ "error",
460
+ )
461
+ assert errored.status == "error"
462
+ assert errored.error == "Command failed"
444
463
 
445
464
 
446
- def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
465
+ @pytest.mark.anyio
466
+ async def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
447
467
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
448
468
  transport = FakeMcpTransport()
449
469
  transport.tools_by_server["mcp-files"] = [
@@ -451,6 +471,13 @@ def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
451
471
  ]
452
472
  client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
453
473
  client.put("/api/mcp/servers", json=command_server_payload())
474
+
475
+ connected = await wait_for_status(
476
+ client.app.state.mcp_manager,
477
+ StoredMcpServer.model_validate(command_server_payload()),
478
+ "ready",
479
+ )
480
+ assert connected.status == "ready"
454
481
  transport.tools_by_server["mcp-files"] = [
455
482
  {"inputSchema": {"type": "object"}, "name": "read_file"},
456
483
  {"inputSchema": {"type": "object"}, "name": "write_file"},
@@ -552,7 +579,8 @@ def test_mcp_server_delete_removes_saved_server_when_disconnect_fails(
552
579
  assert transport.disconnect_calls == ["mcp-files"]
553
580
 
554
581
 
555
- def test_ready_mcp_tools_are_included_in_workspace_request(
582
+ @pytest.mark.anyio
583
+ async def test_ready_mcp_tools_are_included_in_workspace_request(
556
584
  tmp_path, monkeypatch
557
585
  ) -> None:
558
586
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
@@ -583,6 +611,12 @@ def test_ready_mcp_tools_are_included_in_workspace_request(
583
611
  )
584
612
  configure_provider(client)
585
613
  client.put("/api/mcp/servers", json=command_server_payload())
614
+ connected = await wait_for_status(
615
+ client.app.state.mcp_manager,
616
+ StoredMcpServer.model_validate(command_server_payload()),
617
+ "ready",
618
+ )
619
+ assert connected.status == "ready"
586
620
 
587
621
  response = client.post("/api/workspace/respond", json={"content": "Read file"})
588
622
 
@@ -595,7 +629,8 @@ def test_ready_mcp_tools_are_included_in_workspace_request(
595
629
  assert mcp_tool_name("mcp-files", "read_file") in tool_names
596
630
 
597
631
 
598
- def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
632
+ @pytest.mark.anyio
633
+ async def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
599
634
  tmp_path, monkeypatch
600
635
  ) -> None:
601
636
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
@@ -632,6 +667,12 @@ def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
632
667
  )
633
668
  configure_provider(client)
634
669
  client.put("/api/mcp/servers", json=command_server_payload())
670
+ connected = await wait_for_status(
671
+ client.app.state.mcp_manager,
672
+ StoredMcpServer.model_validate(command_server_payload()),
673
+ "ready",
674
+ )
675
+ assert connected.status == "ready"
635
676
 
636
677
  response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
637
678
 
@@ -651,7 +692,10 @@ def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
651
692
  assert events[3]["data"]["data"]["tool"] == "read_file"
652
693
 
653
694
 
654
- def test_mcp_tool_call_failure_is_reported_in_workspace(tmp_path, monkeypatch) -> None:
695
+ @pytest.mark.anyio
696
+ async def test_mcp_tool_call_failure_is_reported_in_workspace(
697
+ tmp_path, monkeypatch
698
+ ) -> None:
655
699
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
656
700
  captured_requests: list[dict[str, object]] = []
657
701
  transport = FakeMcpTransport()
@@ -686,6 +730,12 @@ def test_mcp_tool_call_failure_is_reported_in_workspace(tmp_path, monkeypatch) -
686
730
  )
687
731
  configure_provider(client)
688
732
  client.put("/api/mcp/servers", json=command_server_payload())
733
+ connected = await wait_for_status(
734
+ client.app.state.mcp_manager,
735
+ StoredMcpServer.model_validate(command_server_payload()),
736
+ "ready",
737
+ )
738
+ assert connected.status == "ready"
689
739
 
690
740
  response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
691
741
 
@@ -720,3 +770,19 @@ async def test_mcp_server_reload_reconnects_saved_enabled_servers(
720
770
  assert connected.status == "ready"
721
771
  assert connected.tools[0].name == "read_file"
722
772
  assert transport.connect_calls[0].id == "mcp-files"
773
+
774
+
775
+ @pytest.mark.anyio
776
+ async def test_enabled_mcp_server_save_does_not_block_response(
777
+ tmp_path, monkeypatch
778
+ ) -> None:
779
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
780
+ transport = FakeMcpTransport()
781
+ transport.sleep_on_connect.add("mcp-files")
782
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
783
+
784
+ response = client.put("/api/mcp/servers", json=command_server_payload())
785
+
786
+ assert response.status_code == 200
787
+ assert response.json()["status"] == "starting"
788
+ assert response.json()["tools"] == []
@@ -0,0 +1,112 @@
1
+ import pytest
2
+
3
+ from flowent.patch import PatchError, affected_paths, apply_patch
4
+
5
+
6
+ def test_apply_patch_applies_context_hunk_with_interleaved_changes(tmp_path) -> None:
7
+ target = tmp_path / "notes.txt"
8
+ target.write_text("start\nalpha\nmiddle\nbeta\nend\n")
9
+ patch = """*** Begin Patch
10
+ *** Update File: notes.txt
11
+ @@
12
+ start
13
+ -alpha
14
+ +one
15
+ middle
16
+ -beta
17
+ +two
18
+ end
19
+ *** End Patch
20
+ """
21
+
22
+ result = apply_patch(patch, tmp_path)
23
+
24
+ assert result == {"files": [{"path": str(target), "status": "modified"}]}
25
+ assert target.read_text() == "start\none\nmiddle\ntwo\nend\n"
26
+
27
+
28
+ def test_apply_patch_reports_context_mismatch(tmp_path) -> None:
29
+ target = tmp_path / "notes.txt"
30
+ target.write_text("start\nalpha\nend\n")
31
+ patch = """*** Begin Patch
32
+ *** Update File: notes.txt
33
+ @@
34
+ missing
35
+ -alpha
36
+ +beta
37
+ end
38
+ *** End Patch
39
+ """
40
+
41
+ with pytest.raises(PatchError, match=r"Patch context was not found\."):
42
+ apply_patch(patch, tmp_path)
43
+
44
+ assert target.read_text() == "start\nalpha\nend\n"
45
+
46
+
47
+ def test_apply_patch_applies_multiple_hunks_in_order(tmp_path) -> None:
48
+ target = tmp_path / "notes.txt"
49
+ target.write_text("first\nsame\nend first\nsecond\nsame\nend second\n")
50
+ patch = """*** Begin Patch
51
+ *** Update File: notes.txt
52
+ @@
53
+ first
54
+ -same
55
+ +one
56
+ end first
57
+ @@
58
+ second
59
+ -same
60
+ +two
61
+ end second
62
+ *** End Patch
63
+ """
64
+
65
+ apply_patch(patch, tmp_path)
66
+
67
+ assert target.read_text() == "first\none\nend first\nsecond\ntwo\nend second\n"
68
+
69
+
70
+ def test_apply_patch_keeps_simple_contiguous_replacement(tmp_path) -> None:
71
+ target = tmp_path / "notes.txt"
72
+ target.write_text("alpha\nbeta\n")
73
+ patch = """*** Begin Patch
74
+ *** Update File: notes.txt
75
+ @@
76
+ -alpha
77
+ -beta
78
+ +ready
79
+ *** End Patch
80
+ """
81
+
82
+ apply_patch(patch, tmp_path)
83
+
84
+ assert target.read_text() == "ready\n"
85
+
86
+
87
+ def test_affected_paths_reads_structured_patch_write_targets(tmp_path) -> None:
88
+ patch = """*** Begin Patch
89
+ *** Update File: notes.txt
90
+ @@
91
+ -alpha
92
+ +beta
93
+ *** Add File: created.txt
94
+ +hello
95
+ *** Delete File: old.txt
96
+ *** Update File: before.txt
97
+ *** Move to: after.txt
98
+ @@
99
+ -before
100
+ +after
101
+ *** End Patch
102
+ """
103
+
104
+ paths = affected_paths(patch, tmp_path)
105
+
106
+ assert paths == [
107
+ (tmp_path / "notes.txt").resolve(strict=False),
108
+ (tmp_path / "created.txt").resolve(strict=False),
109
+ (tmp_path / "old.txt").resolve(strict=False),
110
+ (tmp_path / "before.txt").resolve(strict=False),
111
+ (tmp_path / "after.txt").resolve(strict=False),
112
+ ]