flowent 0.1.4 → 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 (67) 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 +4 -2
  25. package/backend/src/flowent/context.py +19 -1
  26. package/backend/src/flowent/llm.py +51 -11
  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-Cl20cARb.css +2 -0
  33. package/backend/src/flowent/static/assets/index-dsDDsEym.js +81 -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/test_agent_tools.py +77 -1
  51. package/backend/tests/test_approval.py +283 -0
  52. package/backend/tests/test_llm_providers.py +216 -0
  53. package/backend/tests/test_logging.py +30 -0
  54. package/backend/tests/test_patch.py +112 -0
  55. package/backend/tests/test_permissions.py +198 -53
  56. package/backend/tests/test_persistence.py +78 -0
  57. package/backend/tests/test_startup_requirements.py +54 -0
  58. package/backend/tests/test_workspace_chat.py +855 -41
  59. package/backend/uv.lock +1 -1
  60. package/dist/frontend/assets/index-Cl20cARb.css +2 -0
  61. package/dist/frontend/assets/index-dsDDsEym.js +81 -0
  62. package/dist/frontend/index.html +2 -2
  63. package/package.json +1 -1
  64. package/backend/src/flowent/static/assets/index-BREidonU.css +0 -2
  65. package/backend/src/flowent/static/assets/index-DSniOrhL.js +0 -81
  66. package/dist/frontend/assets/index-BREidonU.css +0 -2
  67. package/dist/frontend/assets/index-DSniOrhL.js +0 -81
@@ -3,8 +3,9 @@ from pathlib import Path
3
3
  import pytest
4
4
  from fastapi.testclient import TestClient
5
5
 
6
+ from flowent.approval import ApprovalReviewDecision, ApprovalReviewRequest
6
7
  from flowent.main import create_app
7
- from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
8
+ from flowent.permissions import run_tool_with_path_permissions
8
9
  from flowent.sandbox import CommandResult, SandboxRunner
9
10
  from flowent.storage import StateStore
10
11
  from flowent.tools import ToolContext
@@ -63,11 +64,12 @@ def test_writable_paths_are_saved_as_normalized_absolute_paths(
63
64
 
64
65
 
65
66
  @pytest.mark.anyio
66
- async def test_allow_once_runs_tool_with_declared_write_path(
67
+ async def test_approved_declared_write_path_runs_command_with_extra_permission(
67
68
  tmp_path, monkeypatch
68
69
  ) -> None:
69
70
  cache_dir = tmp_path / "cache"
70
71
  calls: list[list[Path]] = []
72
+ reviews: list[ApprovalReviewRequest] = []
71
73
 
72
74
  async def fake_run_async(self, command, **kwargs):
73
75
  calls.append(self.writable_roots)
@@ -78,10 +80,11 @@ async def test_allow_once_runs_tool_with_declared_write_path(
78
80
  stdout="created",
79
81
  )
80
82
 
81
- async def approve(path: Path, reason: str) -> WritablePathDecision:
82
- assert path == cache_dir
83
- assert "shell command" in reason
84
- return WritablePathDecision(decision="allow_once", path=path)
83
+ async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
84
+ reviews.append(request)
85
+ return ApprovalReviewDecision(
86
+ decision="approved", reason="Needed for cache writes."
87
+ )
85
88
 
86
89
  monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
87
90
 
@@ -93,7 +96,7 @@ async def test_allow_once_runs_tool_with_declared_write_path(
93
96
  "sandbox_permissions": "with_additional_permissions",
94
97
  },
95
98
  ToolContext(cwd=tmp_path / "work"),
96
- request_writable_path=approve,
99
+ review_approval=approve,
97
100
  writable_paths=[],
98
101
  )
99
102
 
@@ -101,10 +104,21 @@ async def test_allow_once_runs_tool_with_declared_write_path(
101
104
  assert result.content == "created"
102
105
  assert len(calls) == 1
103
106
  assert cache_dir in calls[0]
107
+ assert reviews[0].tool_name == "shell_command"
108
+ assert reviews[0].action == "additional_permissions"
109
+ assert reviews[0].write_paths == [cache_dir]
110
+ assert result.data["approval"] == {
111
+ "action": "additional_permissions",
112
+ "decision": "approved",
113
+ "reason": "Needed for cache writes.",
114
+ "tool_name": "shell_command",
115
+ "tool_result": "",
116
+ "write_paths": [str(cache_dir)],
117
+ }
104
118
 
105
119
 
106
120
  @pytest.mark.anyio
107
- async def test_always_allow_runs_tool_and_persists_declared_path(
121
+ async def test_approved_declared_write_path_does_not_persist_runtime_permission(
108
122
  tmp_path, monkeypatch
109
123
  ) -> None:
110
124
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
@@ -120,9 +134,10 @@ async def test_always_allow_runs_tool_and_persists_declared_path(
120
134
  stdout="created",
121
135
  )
122
136
 
123
- async def approve(path: Path, reason: str) -> WritablePathDecision:
124
- saved = store.save_writable_path(path)
125
- return WritablePathDecision(decision="always_allow", path=Path(saved.path))
137
+ async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
138
+ return ApprovalReviewDecision(
139
+ decision="approved", reason="Needed for cache writes."
140
+ )
126
141
 
127
142
  monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
128
143
 
@@ -134,16 +149,16 @@ async def test_always_allow_runs_tool_and_persists_declared_path(
134
149
  "sandbox_permissions": "with_additional_permissions",
135
150
  },
136
151
  ToolContext(cwd=tmp_path / "work"),
137
- request_writable_path=approve,
152
+ review_approval=approve,
138
153
  writable_paths=[],
139
154
  )
140
155
 
141
156
  assert result.ok
142
- assert [path.path for path in store.read_writable_paths()] == [str(cache_dir)]
157
+ assert store.read_writable_paths() == []
143
158
 
144
159
 
145
160
  @pytest.mark.anyio
146
- async def test_deny_returns_failed_tool_result_before_running_command(
161
+ async def test_denied_declared_write_path_returns_failed_result_before_running_command(
147
162
  tmp_path, monkeypatch
148
163
  ) -> None:
149
164
  cache_dir = tmp_path / "cache"
@@ -159,8 +174,10 @@ async def test_deny_returns_failed_tool_result_before_running_command(
159
174
  stdout="created",
160
175
  )
161
176
 
162
- async def deny(path: Path, reason: str) -> WritablePathDecision:
163
- return WritablePathDecision(decision="deny", path=path)
177
+ async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
178
+ return ApprovalReviewDecision(
179
+ decision="denied", reason="Outside the task scope."
180
+ )
164
181
 
165
182
  monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
166
183
 
@@ -172,17 +189,21 @@ async def test_deny_returns_failed_tool_result_before_running_command(
172
189
  "sandbox_permissions": "with_additional_permissions",
173
190
  },
174
191
  ToolContext(cwd=tmp_path / "work"),
175
- request_writable_path=deny,
192
+ review_approval=deny,
176
193
  writable_paths=[],
177
194
  )
178
195
 
179
196
  assert not result.ok
180
- assert "Permission denied for" in result.content
197
+ assert "Automatic approval review denied this action" in result.content
198
+ assert "Outside the task scope." in result.content
199
+ assert "must not work around" in result.content
200
+ assert result.data["approval"]["decision"] == "denied"
201
+ assert result.data["approval"]["reason"] == "Outside the task scope."
181
202
  assert calls == 0
182
203
 
183
204
 
184
205
  @pytest.mark.anyio
185
- async def test_existing_writable_path_covers_declared_permission_request(
206
+ async def test_existing_writable_path_covers_declared_review(
186
207
  tmp_path, monkeypatch
187
208
  ) -> None:
188
209
  cache_dir = tmp_path / "cache"
@@ -197,10 +218,10 @@ async def test_existing_writable_path_covers_declared_permission_request(
197
218
  stdout="created",
198
219
  )
199
220
 
200
- async def approve(path: Path, reason: str) -> WritablePathDecision:
221
+ async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
201
222
  nonlocal requests
202
223
  requests += 1
203
- return WritablePathDecision(decision="allow_once", path=path)
224
+ return ApprovalReviewDecision(decision="approved", reason="Already allowed.")
204
225
 
205
226
  monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
206
227
 
@@ -212,7 +233,7 @@ async def test_existing_writable_path_covers_declared_permission_request(
212
233
  "sandbox_permissions": "with_additional_permissions",
213
234
  },
214
235
  ToolContext(cwd=tmp_path / "work"),
215
- request_writable_path=approve,
236
+ review_approval=approve,
216
237
  writable_paths=[cache_dir],
217
238
  )
218
239
 
@@ -226,7 +247,7 @@ async def test_multiple_declared_write_paths_request_each_missing_path(
226
247
  ) -> None:
227
248
  first = tmp_path / "cache"
228
249
  second = tmp_path / "downloads"
229
- requested: list[Path] = []
250
+ reviews: list[ApprovalReviewRequest] = []
230
251
 
231
252
  async def fake_run_async(self, command, **kwargs):
232
253
  assert first in self.writable_roots
@@ -238,9 +259,11 @@ async def test_multiple_declared_write_paths_request_each_missing_path(
238
259
  stdout="created",
239
260
  )
240
261
 
241
- async def approve(path: Path, reason: str) -> WritablePathDecision:
242
- requested.append(path)
243
- return WritablePathDecision(decision="allow_once", path=path)
262
+ async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
263
+ reviews.append(request)
264
+ return ApprovalReviewDecision(
265
+ decision="approved", reason="Needed for generated files."
266
+ )
244
267
 
245
268
  monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
246
269
 
@@ -254,20 +277,126 @@ async def test_multiple_declared_write_paths_request_each_missing_path(
254
277
  "sandbox_permissions": "with_additional_permissions",
255
278
  },
256
279
  ToolContext(cwd=tmp_path / "work"),
257
- request_writable_path=approve,
280
+ review_approval=approve,
281
+ writable_paths=[],
282
+ )
283
+
284
+ assert result.ok
285
+ assert len(reviews) == 1
286
+ assert reviews[0].write_paths == [first, second]
287
+
288
+
289
+ @pytest.mark.anyio
290
+ async def test_sandbox_denied_shell_command_is_reviewed_and_retried_without_sandbox(
291
+ tmp_path, monkeypatch
292
+ ) -> None:
293
+ calls: list[str] = []
294
+ reviews: list[ApprovalReviewRequest] = []
295
+
296
+ async def fake_run_async(self, command, **kwargs):
297
+ calls.append("sandbox")
298
+ return CommandResult(
299
+ command=" ".join(command),
300
+ exit_code=1,
301
+ stderr="failed to write file: Read-only file system\n",
302
+ stdout="",
303
+ )
304
+
305
+ async def fake_run_unsandboxed_async(self, command, **kwargs):
306
+ calls.append("unsandboxed")
307
+ return CommandResult(
308
+ command=" ".join(command),
309
+ exit_code=0,
310
+ stderr="",
311
+ stdout="created",
312
+ )
313
+
314
+ async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
315
+ reviews.append(request)
316
+ return ApprovalReviewDecision(
317
+ decision="approved", reason="Retry is consistent with the task."
318
+ )
319
+
320
+ monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
321
+ monkeypatch.setattr(
322
+ SandboxRunner,
323
+ "run_unsandboxed_async",
324
+ fake_run_unsandboxed_async,
325
+ raising=False,
326
+ )
327
+
328
+ result = await run_tool_with_path_permissions(
329
+ "shell_command",
330
+ {"command": "touch output.txt"},
331
+ ToolContext(cwd=tmp_path / "work"),
332
+ review_approval=approve,
258
333
  writable_paths=[],
259
334
  )
260
335
 
261
336
  assert result.ok
262
- assert requested == [first, second]
337
+ assert result.content == "created"
338
+ assert calls == ["sandbox", "unsandboxed"]
339
+ assert reviews[0].action == "sandbox_failure"
340
+ assert "Read-only file system" in reviews[0].tool_result
341
+ assert result.data["approval"]["action"] == "sandbox_failure"
342
+ assert result.data["approval"]["decision"] == "approved"
263
343
 
264
344
 
265
345
  @pytest.mark.anyio
266
- async def test_command_text_is_not_used_to_guess_permissions(
346
+ async def test_sandbox_denied_shell_command_is_not_retried_when_reviewer_denies(
347
+ tmp_path, monkeypatch
348
+ ) -> None:
349
+ calls: list[str] = []
350
+
351
+ async def fake_run_async(self, command, **kwargs):
352
+ calls.append("sandbox")
353
+ return CommandResult(
354
+ command=" ".join(command),
355
+ exit_code=1,
356
+ stderr="failed to write file: Read-only file system\n",
357
+ stdout="",
358
+ )
359
+
360
+ async def fake_run_unsandboxed_async(self, command, **kwargs):
361
+ calls.append("unsandboxed")
362
+ return CommandResult(
363
+ command=" ".join(command),
364
+ exit_code=0,
365
+ stderr="",
366
+ stdout="created",
367
+ )
368
+
369
+ async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
370
+ return ApprovalReviewDecision(decision="denied", reason="Too broad.")
371
+
372
+ monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
373
+ monkeypatch.setattr(
374
+ SandboxRunner,
375
+ "run_unsandboxed_async",
376
+ fake_run_unsandboxed_async,
377
+ raising=False,
378
+ )
379
+
380
+ result = await run_tool_with_path_permissions(
381
+ "shell_command",
382
+ {"command": "touch output.txt"},
383
+ ToolContext(cwd=tmp_path / "work"),
384
+ review_approval=deny,
385
+ writable_paths=[],
386
+ )
387
+
388
+ assert not result.ok
389
+ assert calls == ["sandbox"]
390
+ assert "Too broad." in result.content
391
+ assert result.data["approval"]["decision"] == "denied"
392
+
393
+
394
+ @pytest.mark.anyio
395
+ async def test_command_text_is_not_used_to_guess_write_paths(
267
396
  tmp_path, monkeypatch
268
397
  ) -> None:
269
398
  outside = tmp_path / "outside"
270
- requests = 0
399
+ reviews: list[ApprovalReviewRequest] = []
271
400
 
272
401
  async def fake_run_async(self, command, **kwargs):
273
402
  assert outside not in self.writable_roots
@@ -278,10 +407,11 @@ async def test_command_text_is_not_used_to_guess_permissions(
278
407
  stdout="",
279
408
  )
280
409
 
281
- async def approve(path: Path, reason: str) -> WritablePathDecision:
282
- nonlocal requests
283
- requests += 1
284
- return WritablePathDecision(decision="allow_once", path=path)
410
+ async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
411
+ reviews.append(request)
412
+ return ApprovalReviewDecision(
413
+ decision="denied", reason="No extra write paths were declared."
414
+ )
285
415
 
286
416
  monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
287
417
 
@@ -289,12 +419,13 @@ async def test_command_text_is_not_used_to_guess_permissions(
289
419
  "shell_command",
290
420
  {"command": f"rm -f {outside / 'file.txt'}"},
291
421
  ToolContext(cwd=tmp_path / "work"),
292
- request_writable_path=approve,
422
+ review_approval=deny,
293
423
  writable_paths=[],
294
424
  )
295
425
 
296
426
  assert not result.ok
297
- assert requests == 0
427
+ assert reviews[0].action == "sandbox_failure"
428
+ assert reviews[0].write_paths == []
298
429
 
299
430
 
300
431
  @pytest.mark.anyio
@@ -303,6 +434,7 @@ async def test_additional_permissions_require_matching_sandbox_permissions(
303
434
  ) -> None:
304
435
  cache_dir = tmp_path / "cache"
305
436
  calls = 0
437
+ reviews = 0
306
438
 
307
439
  async def fake_run_async(self, command, **kwargs):
308
440
  nonlocal calls
@@ -314,8 +446,10 @@ async def test_additional_permissions_require_matching_sandbox_permissions(
314
446
  stdout="created",
315
447
  )
316
448
 
317
- async def approve(path: Path, reason: str) -> WritablePathDecision:
318
- return WritablePathDecision(decision="allow_once", path=path)
449
+ async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
450
+ nonlocal reviews
451
+ reviews += 1
452
+ return ApprovalReviewDecision(decision="approved", reason="Allowed.")
319
453
 
320
454
  monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
321
455
 
@@ -326,17 +460,18 @@ async def test_additional_permissions_require_matching_sandbox_permissions(
326
460
  "command": f"touch {cache_dir / 'file.txt'}",
327
461
  },
328
462
  ToolContext(cwd=tmp_path / "work"),
329
- request_writable_path=approve,
463
+ review_approval=approve,
330
464
  writable_paths=[],
331
465
  )
332
466
 
333
467
  assert not result.ok
334
468
  assert "with_additional_permissions" in result.content
335
469
  assert calls == 0
470
+ assert reviews == 0
336
471
 
337
472
 
338
473
  @pytest.mark.anyio
339
- async def test_apply_patch_requests_permission_for_outside_workdir_file(
474
+ async def test_apply_patch_uses_reviewer_before_writing_outside_workdir_file(
340
475
  tmp_path, monkeypatch
341
476
  ) -> None:
342
477
  work_dir = tmp_path / "work"
@@ -345,11 +480,13 @@ async def test_apply_patch_requests_permission_for_outside_workdir_file(
345
480
  outside_dir.mkdir()
346
481
  target = outside_dir / "notes.txt"
347
482
  target.write_text("alpha\n")
348
- requested: list[Path] = []
483
+ reviews: list[ApprovalReviewRequest] = []
349
484
 
350
- async def approve(path: Path, reason: str) -> WritablePathDecision:
351
- requested.append(path)
352
- return WritablePathDecision(decision="allow_once", path=path)
485
+ async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
486
+ reviews.append(request)
487
+ return ApprovalReviewDecision(
488
+ decision="approved", reason="The edit matches the request."
489
+ )
353
490
 
354
491
  patch = f"""*** Begin Patch
355
492
  *** Update File: {target}
@@ -363,12 +500,16 @@ async def test_apply_patch_requests_permission_for_outside_workdir_file(
363
500
  "apply_patch",
364
501
  {"patch": patch},
365
502
  ToolContext(cwd=work_dir),
366
- request_writable_path=approve,
503
+ review_approval=approve,
367
504
  writable_paths=[],
368
505
  )
369
506
 
370
507
  assert result.ok
371
- assert requested == [outside_dir]
508
+ assert reviews[0].tool_name == "apply_patch"
509
+ assert reviews[0].action == "edit"
510
+ assert reviews[0].write_paths == [outside_dir]
511
+ assert result.data["approval"]["action"] == "edit"
512
+ assert result.data["approval"]["decision"] == "approved"
372
513
  assert target.read_text() == "beta\n"
373
514
 
374
515
 
@@ -384,10 +525,10 @@ async def test_apply_patch_uses_existing_writable_path_without_request(
384
525
  target.write_text("alpha\n")
385
526
  requests = 0
386
527
 
387
- async def approve(path: Path, reason: str) -> WritablePathDecision:
528
+ async def approve(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
388
529
  nonlocal requests
389
530
  requests += 1
390
- return WritablePathDecision(decision="allow_once", path=path)
531
+ return ApprovalReviewDecision(decision="approved", reason="Already allowed.")
391
532
 
392
533
  patch = f"""*** Begin Patch
393
534
  *** Update File: {target}
@@ -401,7 +542,7 @@ async def test_apply_patch_uses_existing_writable_path_without_request(
401
542
  "apply_patch",
402
543
  {"patch": patch},
403
544
  ToolContext(cwd=work_dir),
404
- request_writable_path=approve,
545
+ review_approval=approve,
405
546
  writable_paths=[outside_dir],
406
547
  )
407
548
 
@@ -419,8 +560,10 @@ async def test_denied_apply_patch_does_not_modify_file(tmp_path) -> None:
419
560
  target = outside_dir / "notes.txt"
420
561
  target.write_text("alpha\n")
421
562
 
422
- async def deny(path: Path, reason: str) -> WritablePathDecision:
423
- return WritablePathDecision(decision="deny", path=path)
563
+ async def deny(request: ApprovalReviewRequest) -> ApprovalReviewDecision:
564
+ return ApprovalReviewDecision(
565
+ decision="denied", reason="The target is outside the allowed scope."
566
+ )
424
567
 
425
568
  patch = f"""*** Begin Patch
426
569
  *** Update File: {target}
@@ -434,10 +577,12 @@ async def test_denied_apply_patch_does_not_modify_file(tmp_path) -> None:
434
577
  "apply_patch",
435
578
  {"patch": patch},
436
579
  ToolContext(cwd=work_dir),
437
- request_writable_path=deny,
580
+ review_approval=deny,
438
581
  writable_paths=[],
439
582
  )
440
583
 
441
584
  assert not result.ok
442
- assert "Permission denied for" in result.content
585
+ assert "outside the allowed scope" in result.content
586
+ assert result.data["approval"]["action"] == "edit"
587
+ assert result.data["approval"]["decision"] == "denied"
443
588
  assert target.read_text() == "alpha\n"
@@ -89,6 +89,7 @@ def test_app_state_persists_settings_and_workspace_messages(
89
89
  settings_response = client.put(
90
90
  "/api/settings",
91
91
  json={
92
+ "agent_prompt": "Respond with careful implementation plans.",
92
93
  "reasoning_effort": "xhigh",
93
94
  "selected_model": "claude-sonnet-4-5",
94
95
  "selected_provider_id": "provider-anthropic",
@@ -123,6 +124,7 @@ def test_app_state_persists_settings_and_workspace_messages(
123
124
  state = restarted_client.get("/api/state").json()
124
125
 
125
126
  assert state["settings"] == {
127
+ "agent_prompt": "Respond with careful implementation plans.",
126
128
  "reasoning_effort": "xhigh",
127
129
  "selected_model": "claude-sonnet-4-5",
128
130
  "selected_provider_id": "provider-anthropic",
@@ -148,6 +150,70 @@ def test_app_state_persists_settings_and_workspace_messages(
148
150
  ]
149
151
 
150
152
 
153
+ def test_app_state_persists_workspace_error_blocks_across_app_instances(
154
+ tmp_path, monkeypatch
155
+ ) -> None:
156
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
157
+ client = TestClient(create_app(serve_frontend=False))
158
+
159
+ messages_response = client.put(
160
+ "/api/workspace/messages",
161
+ json={
162
+ "messages": [
163
+ {
164
+ "author": "assistant",
165
+ "content": "",
166
+ "groups": [
167
+ {
168
+ "id": "message-1-errors",
169
+ "items": [
170
+ {
171
+ "detail": "HTML response returned.",
172
+ "id": "message-1-error-1",
173
+ "message": "Check the model connection settings and try again.",
174
+ "title": "Request failed",
175
+ "type": "error",
176
+ }
177
+ ],
178
+ }
179
+ ],
180
+ "id": "message-1",
181
+ "status": "failed",
182
+ }
183
+ ]
184
+ },
185
+ )
186
+
187
+ assert messages_response.status_code == 200
188
+
189
+ restarted_client = TestClient(create_app(serve_frontend=False))
190
+ state = restarted_client.get("/api/state").json()
191
+
192
+ assert state["messages"] == [
193
+ {
194
+ "author": "assistant",
195
+ "content": "",
196
+ "groups": [
197
+ {
198
+ "id": "message-1-errors",
199
+ "items": [
200
+ {
201
+ "detail": "HTML response returned.",
202
+ "id": "message-1-error-1",
203
+ "message": "Check the model connection settings and try again.",
204
+ "title": "Request failed",
205
+ "type": "error",
206
+ }
207
+ ],
208
+ }
209
+ ],
210
+ "id": "message-1",
211
+ "status": "failed",
212
+ "tools": [],
213
+ }
214
+ ]
215
+
216
+
151
217
  def test_data_directory_uses_flowent_data_dir(tmp_path, monkeypatch) -> None:
152
218
  data_dir = tmp_path / "custom-flowent"
153
219
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
@@ -169,3 +235,15 @@ def test_app_state_defaults_reasoning_effort_for_existing_settings(
169
235
 
170
236
  assert response.status_code == 200
171
237
  assert response.json()["settings"]["reasoning_effort"] == "default"
238
+
239
+
240
+ def test_app_state_defaults_agent_prompt_for_existing_settings(
241
+ tmp_path, monkeypatch
242
+ ) -> None:
243
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
244
+ client = TestClient(create_app(serve_frontend=False))
245
+
246
+ response = client.get("/api/state")
247
+
248
+ assert response.status_code == 200
249
+ assert response.json()["settings"].get("agent_prompt", "") == ""
@@ -6,6 +6,7 @@ import pytest
6
6
 
7
7
  from flowent.cli import main
8
8
  from flowent.main import create_app
9
+ from flowent.paths import WORKDIR_ENV_VAR
9
10
  from flowent.sandbox import SandboxError
10
11
 
11
12
 
@@ -80,6 +81,59 @@ def test_main_sets_workdir_for_server_start(tmp_path, monkeypatch) -> None:
80
81
  assert calls == [("flowent.main:app", {"host": "127.0.0.1", "port": 6899})]
81
82
 
82
83
 
84
+ def test_main_uses_default_host_when_environment_is_not_set(
85
+ tmp_path, monkeypatch
86
+ ) -> None:
87
+ workdir = tmp_path / "workspace"
88
+ workdir.mkdir()
89
+ calls: list[tuple[str, dict[str, object]]] = []
90
+
91
+ def fake_run(app: str, **kwargs: object) -> None:
92
+ calls.append((app, kwargs))
93
+
94
+ monkeypatch.delenv("FLOWENT_HOST", raising=False)
95
+ monkeypatch.setenv(WORKDIR_ENV_VAR, str(workdir))
96
+ monkeypatch.setitem(sys.modules, "uvicorn", SimpleNamespace(run=fake_run))
97
+
98
+ main(["--workdir", str(workdir), "--port", "6899"])
99
+
100
+ assert calls == [("flowent.main:app", {"host": "127.0.0.1", "port": 6899})]
101
+
102
+
103
+ def test_main_reads_host_from_environment(tmp_path, monkeypatch) -> None:
104
+ workdir = tmp_path / "workspace"
105
+ workdir.mkdir()
106
+ calls: list[tuple[str, dict[str, object]]] = []
107
+
108
+ def fake_run(app: str, **kwargs: object) -> None:
109
+ calls.append((app, kwargs))
110
+
111
+ monkeypatch.setenv("FLOWENT_HOST", "0.0.0.0")
112
+ monkeypatch.setenv(WORKDIR_ENV_VAR, str(workdir))
113
+ monkeypatch.setitem(sys.modules, "uvicorn", SimpleNamespace(run=fake_run))
114
+
115
+ main(["--workdir", str(workdir), "--port", "6899"])
116
+
117
+ assert calls == [("flowent.main:app", {"host": "0.0.0.0", "port": 6899})]
118
+
119
+
120
+ def test_main_prefers_host_argument_over_environment(tmp_path, monkeypatch) -> None:
121
+ workdir = tmp_path / "workspace"
122
+ workdir.mkdir()
123
+ calls: list[tuple[str, dict[str, object]]] = []
124
+
125
+ def fake_run(app: str, **kwargs: object) -> None:
126
+ calls.append((app, kwargs))
127
+
128
+ monkeypatch.setenv("FLOWENT_HOST", "0.0.0.0")
129
+ monkeypatch.setenv(WORKDIR_ENV_VAR, str(workdir))
130
+ monkeypatch.setitem(sys.modules, "uvicorn", SimpleNamespace(run=fake_run))
131
+
132
+ main(["--workdir", str(workdir), "--host", "127.0.0.1", "--port", "6899"])
133
+
134
+ assert calls == [("flowent.main:app", {"host": "127.0.0.1", "port": 6899})]
135
+
136
+
83
137
  def test_main_rejects_missing_workdir(tmp_path, capsys) -> None:
84
138
  missing = tmp_path / "missing"
85
139