flowent 0.1.4 → 0.2.0

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 (68) 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 +117 -34
  23. package/backend/src/flowent/approval.py +148 -0
  24. package/backend/src/flowent/cli.py +4 -2
  25. package/backend/src/flowent/context.py +19 -1
  26. package/backend/src/flowent/llm.py +176 -16
  27. package/backend/src/flowent/logging.py +60 -0
  28. package/backend/src/flowent/main.py +639 -210
  29. package/backend/src/flowent/patch.py +55 -31
  30. package/backend/src/flowent/permissions.py +185 -42
  31. package/backend/src/flowent/sandbox.py +55 -1
  32. package/backend/src/flowent/static/assets/index-BlaCigkZ.js +82 -0
  33. package/backend/src/flowent/static/assets/index-CRvbsH4K.css +2 -0
  34. package/backend/src/flowent/static/index.html +2 -2
  35. package/backend/src/flowent/storage.py +113 -18
  36. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/conftest.py +39 -0
  51. package/backend/tests/test_agent_tools.py +213 -1
  52. package/backend/tests/test_approval.py +283 -0
  53. package/backend/tests/test_llm_providers.py +377 -0
  54. package/backend/tests/test_logging.py +30 -0
  55. package/backend/tests/test_patch.py +112 -0
  56. package/backend/tests/test_permissions.py +198 -53
  57. package/backend/tests/test_persistence.py +78 -0
  58. package/backend/tests/test_startup_requirements.py +54 -0
  59. package/backend/tests/test_workspace_chat.py +902 -36
  60. package/backend/uv.lock +1 -1
  61. package/dist/frontend/assets/index-BlaCigkZ.js +82 -0
  62. package/dist/frontend/assets/index-CRvbsH4K.css +2 -0
  63. package/dist/frontend/index.html +2 -2
  64. package/package.json +1 -1
  65. package/backend/src/flowent/static/assets/index-BREidonU.css +0 -2
  66. package/backend/src/flowent/static/assets/index-DSniOrhL.js +0 -81
  67. package/dist/frontend/assets/index-BREidonU.css +0 -2
  68. package/dist/frontend/assets/index-DSniOrhL.js +0 -81
@@ -1,17 +1,29 @@
1
+ import json
2
+
1
3
  import pytest
2
4
 
3
5
  from flowent.llm import (
4
6
  ChatMessage,
7
+ LLMStreamError,
5
8
  ProviderConnection,
6
9
  ProviderFormat,
7
10
  ReasoningEffort,
8
11
  build_litellm_request,
9
12
  chunk_delta_reasoning,
10
13
  complete_chat,
14
+ list_provider_models,
15
+ normalize_system_messages,
11
16
  stream_chat,
17
+ stream_chat_chunks,
12
18
  )
13
19
 
14
20
 
21
+ def read_single_llm_request_diagnostic(tmp_path):
22
+ files = sorted((tmp_path / "logs" / "llm-requests").glob("llm-request-*.json"))
23
+ assert len(files) == 1
24
+ return json.loads(files[0].read_text())
25
+
26
+
15
27
  def test_supported_provider_formats_match_product_choices() -> None:
16
28
  assert [provider.value for provider in ProviderFormat] == [
17
29
  "openai",
@@ -47,6 +59,120 @@ def test_build_litellm_request_maps_provider_connection_to_completion_args() ->
47
59
  }
48
60
 
49
61
 
62
+ def test_build_litellm_request_appends_default_api_version_to_base_url() -> None:
63
+ connection = ProviderConnection(
64
+ name="OpenAI Compatible",
65
+ provider=ProviderFormat.OPENAI,
66
+ model="gpt-5.1",
67
+ secret_reference="connection-primary",
68
+ base_url="https://example.test",
69
+ )
70
+
71
+ request = build_litellm_request(
72
+ connection, [ChatMessage(role="user", content="Draft a checklist.")]
73
+ )
74
+
75
+ assert request["api_base"] == "https://example.test/v1"
76
+
77
+
78
+ def test_build_litellm_request_appends_anthropic_api_version_to_base_url() -> None:
79
+ connection = ProviderConnection(
80
+ name="Anthropic",
81
+ provider=ProviderFormat.ANTHROPIC,
82
+ model="claude-sonnet-4-5",
83
+ secret_reference="connection-primary",
84
+ base_url="https://example.test",
85
+ )
86
+
87
+ request = build_litellm_request(
88
+ connection, [ChatMessage(role="user", content="Draft a checklist.")]
89
+ )
90
+
91
+ assert request["api_base"] == "https://example.test/v1"
92
+
93
+
94
+ def test_build_litellm_request_does_not_duplicate_existing_api_version() -> None:
95
+ connection = ProviderConnection(
96
+ name="OpenAI Compatible",
97
+ provider=ProviderFormat.OPENAI,
98
+ model="gpt-5.1",
99
+ secret_reference="connection-primary",
100
+ base_url="https://example.test/v1/",
101
+ )
102
+
103
+ request = build_litellm_request(
104
+ connection, [ChatMessage(role="user", content="Draft a checklist.")]
105
+ )
106
+
107
+ assert request["api_base"] == "https://example.test/v1"
108
+
109
+
110
+ def test_build_litellm_request_preserves_custom_versioned_base_path() -> None:
111
+ connection = ProviderConnection(
112
+ name="OpenAI Compatible",
113
+ provider=ProviderFormat.OPENAI,
114
+ model="gpt-5.1",
115
+ secret_reference="connection-primary",
116
+ base_url="https://example.test/api/v3",
117
+ )
118
+
119
+ request = build_litellm_request(
120
+ connection, [ChatMessage(role="user", content="Draft a checklist.")]
121
+ )
122
+
123
+ assert request["api_base"] == "https://example.test/api/v3"
124
+
125
+
126
+ def test_build_litellm_request_allows_base_url_version_opt_out() -> None:
127
+ connection = ProviderConnection(
128
+ name="OpenAI Compatible",
129
+ provider=ProviderFormat.OPENAI,
130
+ model="gpt-5.1",
131
+ secret_reference="connection-primary",
132
+ base_url="https://example.test#",
133
+ )
134
+
135
+ request = build_litellm_request(
136
+ connection, [ChatMessage(role="user", content="Draft a checklist.")]
137
+ )
138
+
139
+ assert request["api_base"] == "https://example.test"
140
+
141
+
142
+ def test_build_litellm_request_appends_gemini_api_version_to_base_url() -> None:
143
+ connection = ProviderConnection(
144
+ name="Gemini",
145
+ provider=ProviderFormat.GEMINI,
146
+ model="gemini-3-pro-preview",
147
+ secret_reference="connection-primary",
148
+ base_url="https://example.test",
149
+ )
150
+
151
+ request = build_litellm_request(
152
+ connection, [ChatMessage(role="user", content="Draft a checklist.")]
153
+ )
154
+
155
+ assert request["api_base"] == "https://example.test/v1beta"
156
+
157
+
158
+ def test_list_provider_models_uses_normalized_base_url() -> None:
159
+ captured_request: dict[str, object] = {}
160
+
161
+ def fake_model_lister(**request: object) -> list[str]:
162
+ captured_request.update(request)
163
+ return ["openai/gpt-5.1", "gpt-5.1-mini"]
164
+
165
+ models = list_provider_models(
166
+ provider=ProviderFormat.OPENAI,
167
+ secret_reference="connection-primary",
168
+ base_url="https://example.test",
169
+ model_lister=fake_model_lister,
170
+ )
171
+
172
+ assert captured_request["api_base"] == "https://example.test/v1"
173
+ assert models == ["gpt-5.1", "gpt-5.1-mini"]
174
+
175
+
50
176
  def test_build_litellm_request_omits_default_reasoning_effort() -> None:
51
177
  connection = ProviderConnection(
52
178
  name="Primary",
@@ -137,6 +263,63 @@ async def test_complete_chat_uses_injected_litellm_completion() -> None:
137
263
  assert answer == ChatMessage(role="assistant", content="Here is the checklist.")
138
264
 
139
265
 
266
+ @pytest.mark.anyio
267
+ async def test_development_mode_writes_completion_request_diagnostic_file(
268
+ tmp_path, monkeypatch
269
+ ) -> None:
270
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
271
+ monkeypatch.setenv("DEBUG", "true")
272
+
273
+ async def fake_completion(**request: object) -> dict[str, object]:
274
+ return {
275
+ "choices": [
276
+ {
277
+ "message": {
278
+ "content": "Here is the checklist.",
279
+ "role": "assistant",
280
+ },
281
+ }
282
+ ]
283
+ }
284
+
285
+ connection = ProviderConnection(
286
+ name="Responses",
287
+ provider=ProviderFormat.OPENAI_RESPONSES,
288
+ model="gpt-5.1",
289
+ secret_reference="sk-request-secret",
290
+ )
291
+ messages = [ChatMessage(role="user", content="Create a checklist.")]
292
+ tools = [
293
+ {
294
+ "type": "function",
295
+ "function": {
296
+ "name": "create_checklist",
297
+ "description": "Create a checklist.",
298
+ },
299
+ }
300
+ ]
301
+
302
+ await complete_chat(
303
+ connection,
304
+ messages,
305
+ completion=fake_completion,
306
+ tools=tools,
307
+ )
308
+
309
+ diagnostic = read_single_llm_request_diagnostic(tmp_path)
310
+
311
+ assert diagnostic == {
312
+ "base_url": None,
313
+ "litellm_model": "openai/gpt-5.1",
314
+ "messages": [{"content": "Create a checklist.", "role": "user"}],
315
+ "model": "gpt-5.1",
316
+ "provider": "openai_responses",
317
+ "reasoning_effort": "default",
318
+ "stream": False,
319
+ "tools": tools,
320
+ }
321
+
322
+
140
323
  @pytest.mark.anyio
141
324
  async def test_stream_chat_uses_litellm_streaming() -> None:
142
325
  captured_request: dict[str, object] = {}
@@ -169,3 +352,197 @@ async def test_stream_chat_uses_litellm_streaming() -> None:
169
352
  assert captured_request["stream"] is True
170
353
  assert captured_request["model"] == "openai/gpt-5.1"
171
354
  assert chunks == ["Here is ", "the checklist."]
355
+
356
+
357
+ @pytest.mark.anyio
358
+ async def test_stream_chat_chunks_raises_when_responses_stream_fails(
359
+ fake_litellm_responses_transformer,
360
+ ) -> None:
361
+ async def fake_completion(**request: object) -> object:
362
+ async def chunks() -> object:
363
+ from litellm.completion_extras.litellm_responses_transformation.transformation import (
364
+ OpenAiResponsesToChatCompletionStreamIterator,
365
+ )
366
+
367
+ yield {"choices": [{"delta": {"content": "Partial answer."}}]}
368
+ yield OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
369
+ {
370
+ "response": {
371
+ "error": {
372
+ "code": "upstream_error",
373
+ "message": "Upstream request failed",
374
+ },
375
+ "status": "failed",
376
+ },
377
+ "type": "response.failed",
378
+ }
379
+ )
380
+
381
+ return chunks()
382
+
383
+ connection = ProviderConnection(
384
+ name="Responses",
385
+ provider=ProviderFormat.OPENAI_RESPONSES,
386
+ model="gpt-5.1",
387
+ secret_reference="connection-responses",
388
+ )
389
+
390
+ with pytest.raises(LLMStreamError, match="Upstream request failed"):
391
+ [
392
+ chunk
393
+ async for chunk in stream_chat_chunks(
394
+ connection,
395
+ [ChatMessage(role="user", content="Create a checklist.")],
396
+ completion=fake_completion,
397
+ )
398
+ ]
399
+
400
+
401
+ @pytest.mark.anyio
402
+ async def test_development_mode_writes_one_streaming_request_diagnostic_file(
403
+ tmp_path, monkeypatch
404
+ ) -> None:
405
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
406
+ monkeypatch.setenv("DEBUG", "true")
407
+
408
+ async def fake_completion(**request: object) -> object:
409
+ async def chunks() -> object:
410
+ yield {"choices": [{"delta": {"content": "Here is "}}]}
411
+ yield {"choices": [{"delta": {"content": "the checklist."}}]}
412
+
413
+ return chunks()
414
+
415
+ connection = ProviderConnection(
416
+ name="Responses",
417
+ provider=ProviderFormat.OPENAI_RESPONSES,
418
+ model="gpt-5.1",
419
+ secret_reference="sk-request-secret",
420
+ )
421
+
422
+ chunks = [
423
+ chunk
424
+ async for chunk in stream_chat(
425
+ connection,
426
+ [ChatMessage(role="user", content="Create a checklist.")],
427
+ completion=fake_completion,
428
+ )
429
+ ]
430
+ diagnostic = read_single_llm_request_diagnostic(tmp_path)
431
+
432
+ assert chunks == ["Here is ", "the checklist."]
433
+ assert diagnostic["stream"] is True
434
+
435
+
436
+ @pytest.mark.anyio
437
+ async def test_development_request_diagnostic_omits_api_key_and_secret_values(
438
+ tmp_path, monkeypatch
439
+ ) -> None:
440
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
441
+ monkeypatch.setenv("DEBUG", "true")
442
+
443
+ async def fake_completion(**request: object) -> dict[str, object]:
444
+ return {
445
+ "choices": [
446
+ {
447
+ "message": {
448
+ "content": "Here is the checklist.",
449
+ "role": "assistant",
450
+ },
451
+ }
452
+ ]
453
+ }
454
+
455
+ connection = ProviderConnection(
456
+ name="Responses",
457
+ provider=ProviderFormat.OPENAI_RESPONSES,
458
+ model="gpt-5.1",
459
+ secret_reference="sk-provider-secret",
460
+ )
461
+ tools = [
462
+ {
463
+ "type": "function",
464
+ "function": {
465
+ "name": "create_checklist",
466
+ "description": "Uses api_key=sk-tool-secret when configured.",
467
+ },
468
+ }
469
+ ]
470
+
471
+ await complete_chat(
472
+ connection,
473
+ [ChatMessage(role="user", content="authorization=Bearer sk-message-secret")],
474
+ completion=fake_completion,
475
+ tools=tools,
476
+ )
477
+
478
+ rendered = next(
479
+ (tmp_path / "logs" / "llm-requests").glob("llm-request-*.json")
480
+ ).read_text()
481
+
482
+ assert "api_key" not in rendered
483
+ assert "sk-provider-secret" not in rendered
484
+ assert "sk-tool-secret" not in rendered
485
+ assert "sk-message-secret" not in rendered
486
+
487
+
488
+ @pytest.mark.anyio
489
+ async def test_non_development_mode_skips_request_diagnostic_file(
490
+ tmp_path, monkeypatch
491
+ ) -> None:
492
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
493
+ monkeypatch.delenv("DEBUG", raising=False)
494
+
495
+ async def fake_completion(**request: object) -> dict[str, object]:
496
+ return {
497
+ "choices": [
498
+ {
499
+ "message": {
500
+ "content": "Here is the checklist.",
501
+ "role": "assistant",
502
+ },
503
+ }
504
+ ]
505
+ }
506
+
507
+ connection = ProviderConnection(
508
+ name="Responses",
509
+ provider=ProviderFormat.OPENAI_RESPONSES,
510
+ model="gpt-5.1",
511
+ secret_reference="sk-request-secret",
512
+ )
513
+
514
+ await complete_chat(
515
+ connection,
516
+ [ChatMessage(role="user", content="Create a checklist.")],
517
+ completion=fake_completion,
518
+ )
519
+
520
+ assert not (tmp_path / "logs" / "llm-requests").exists()
521
+
522
+
523
+ def test_normalize_system_messages_keeps_multiple_system_messages_for_openai() -> None:
524
+ messages = [
525
+ {"role": "system", "content": "Base prompt."},
526
+ {"role": "system", "content": "Configured prompt."},
527
+ {"role": "user", "content": "Hello."},
528
+ ]
529
+
530
+ assert normalize_system_messages(messages, ProviderFormat.OPENAI) == messages
531
+
532
+
533
+ def test_normalize_system_messages_converts_additional_system_messages_for_anthropic() -> (
534
+ None
535
+ ):
536
+ messages = [
537
+ {"role": "system", "content": "Base prompt."},
538
+ {"role": "system", "content": "Configured prompt."},
539
+ {"role": "system", "content": "Project prompt."},
540
+ {"role": "user", "content": "Hello."},
541
+ ]
542
+
543
+ assert normalize_system_messages(messages, ProviderFormat.ANTHROPIC) == [
544
+ {"role": "system", "content": "Base prompt."},
545
+ {"role": "user", "content": "Configured prompt."},
546
+ {"role": "user", "content": "Project prompt."},
547
+ {"role": "user", "content": "Hello."},
548
+ ]
@@ -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)
@@ -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
+ ]