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
@@ -14,6 +14,7 @@ from flowent.sandbox import CommandResult, SandboxRunner
14
14
  def configure_provider(
15
15
  client,
16
16
  *,
17
+ agent_prompt: str = "",
17
18
  base_url: str = "",
18
19
  model: str = "gpt-5.1",
19
20
  name: str = "OpenAI",
@@ -35,6 +36,7 @@ def configure_provider(
35
36
  client.put(
36
37
  "/api/settings",
37
38
  json={
39
+ "agent_prompt": agent_prompt,
38
40
  "reasoning_effort": reasoning_effort,
39
41
  "selected_model": model,
40
42
  "selected_provider_id": provider_id,
@@ -45,6 +47,7 @@ def configure_provider(
45
47
  async def configure_provider_async(
46
48
  client: httpx.AsyncClient,
47
49
  *,
50
+ agent_prompt: str = "",
48
51
  base_url: str = "",
49
52
  model: str = "gpt-5.1",
50
53
  name: str = "OpenAI",
@@ -66,6 +69,7 @@ async def configure_provider_async(
66
69
  await client.put(
67
70
  "/api/settings",
68
71
  json={
72
+ "agent_prompt": agent_prompt,
69
73
  "reasoning_effort": reasoning_effort,
70
74
  "selected_model": model,
71
75
  "selected_provider_id": provider_id,
@@ -409,6 +413,288 @@ def test_workspace_response_uses_compacted_context_after_compact(
409
413
  assert {"role": "user", "content": "Context compacted"} not in response_messages
410
414
 
411
415
 
416
+ def test_workspace_response_auto_compacts_before_next_message(
417
+ tmp_path, monkeypatch
418
+ ) -> None:
419
+ monkeypatch.chdir(tmp_path)
420
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
421
+ monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
422
+ captured_requests: list[dict[str, object]] = []
423
+
424
+ async def fake_completion(**request: object) -> object:
425
+ captured_requests.append(request)
426
+ if not request.get("stream"):
427
+ return {
428
+ "choices": [
429
+ {
430
+ "message": {
431
+ "content": "Keep the launch plan summary.",
432
+ "role": "assistant",
433
+ }
434
+ }
435
+ ]
436
+ }
437
+
438
+ async def chunks() -> object:
439
+ yield {"choices": [{"delta": {"content": "Continuing."}}]}
440
+
441
+ return chunks()
442
+
443
+ client = TestClient(
444
+ create_app(serve_frontend=False, chat_completion=fake_completion)
445
+ )
446
+ configure_provider(client)
447
+ client.put(
448
+ "/api/workspace/messages",
449
+ json={
450
+ "messages": [
451
+ {
452
+ "author": "user",
453
+ "content": "Original request. " * 80,
454
+ "id": "message-1",
455
+ },
456
+ {
457
+ "author": "assistant",
458
+ "content": "Detailed work log. " * 80,
459
+ "id": "message-2",
460
+ },
461
+ ]
462
+ },
463
+ )
464
+
465
+ response = client.post(
466
+ "/api/workspace/respond",
467
+ json={"content": "Continue from there."},
468
+ )
469
+
470
+ assert response.status_code == 200
471
+ events = stream_events(response.text)
472
+ assert events[0]["event"] == "context_optimized"
473
+ assert json.loads(events[0]["data"])["message"]["content"] == ("Context optimized")
474
+ assert len(captured_requests) == 2
475
+ assert (
476
+ "CONTEXT CHECKPOINT COMPACTION"
477
+ in captured_requests[0]["messages"][-1]["content"]
478
+ )
479
+ response_messages = captured_requests[1]["messages"]
480
+ compacted_messages = [
481
+ message
482
+ for message in response_messages
483
+ if str(message["content"]).startswith(
484
+ "Another language model started working on this Flowent workspace session"
485
+ )
486
+ ]
487
+ assert len(compacted_messages) == 1
488
+ assert "Keep the launch plan summary." in compacted_messages[0]["content"]
489
+ assert {"role": "user", "content": "Context optimized"} not in response_messages
490
+ state = client.get("/api/state").json()
491
+ assert [message["content"] for message in state["messages"]][-3:] == [
492
+ "Context optimized",
493
+ "Continue from there.",
494
+ "Continuing.",
495
+ ]
496
+
497
+
498
+ def test_workspace_response_auto_compacts_after_tool_result(
499
+ tmp_path, monkeypatch
500
+ ) -> None:
501
+ monkeypatch.chdir(tmp_path)
502
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
503
+ monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "1200")
504
+ (tmp_path / "notes.txt").write_text("Launch notes. " * 600)
505
+ captured_requests: list[dict[str, object]] = []
506
+
507
+ async def fake_completion(**request: object) -> object:
508
+ captured_requests.append(request)
509
+ if not request.get("stream"):
510
+ return {
511
+ "choices": [
512
+ {
513
+ "message": {
514
+ "content": "Keep the file findings from notes.txt.",
515
+ "role": "assistant",
516
+ }
517
+ }
518
+ ]
519
+ }
520
+
521
+ async def chunks() -> object:
522
+ if len(captured_requests) == 1:
523
+ yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
524
+ return
525
+ yield {"choices": [{"delta": {"content": "Done."}}]}
526
+
527
+ return chunks()
528
+
529
+ client = TestClient(
530
+ create_app(serve_frontend=False, chat_completion=fake_completion)
531
+ )
532
+ configure_provider(client)
533
+
534
+ response = client.post(
535
+ "/api/workspace/respond",
536
+ json={"content": "Read the launch notes."},
537
+ )
538
+
539
+ assert response.status_code == 200
540
+ events = stream_events(response.text)
541
+ assert [event["event"] for event in events] == [
542
+ "start",
543
+ "output_start",
544
+ "tool_start",
545
+ "tool_done",
546
+ "context_optimized",
547
+ "output_start",
548
+ "delta",
549
+ "done",
550
+ ]
551
+ assert json.loads(events[4]["data"])["message"]["content"] == ("Context optimized")
552
+ assert len(captured_requests) == 3
553
+ assert "Launch notes." in captured_requests[1]["messages"][-1]["content"]
554
+ response_messages = captured_requests[2]["messages"]
555
+ compacted_messages = [
556
+ message
557
+ for message in response_messages
558
+ if str(message["content"]).startswith(
559
+ "Another language model started working on this Flowent workspace session"
560
+ )
561
+ ]
562
+ assert len(compacted_messages) == 1
563
+ assert "Keep the file findings from notes.txt." in compacted_messages[0]["content"]
564
+ state = client.get("/api/state").json()
565
+ assert [message["content"] for message in state["messages"]] == [
566
+ "Read the launch notes.",
567
+ "Context optimized",
568
+ "Done.",
569
+ ]
570
+
571
+
572
+ def test_workspace_auto_compact_failure_keeps_existing_checkpoint(
573
+ tmp_path, monkeypatch
574
+ ) -> None:
575
+ monkeypatch.chdir(tmp_path)
576
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
577
+ monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
578
+
579
+ async def fake_completion(**request: object) -> object:
580
+ if not request.get("stream"):
581
+ raise RuntimeError("summary failed")
582
+
583
+ async def chunks() -> object:
584
+ yield {"choices": [{"delta": {"content": "Should not run."}}]}
585
+
586
+ return chunks()
587
+
588
+ client = TestClient(
589
+ create_app(serve_frontend=False, chat_completion=fake_completion)
590
+ )
591
+ configure_provider(client)
592
+ client.put(
593
+ "/api/workspace/messages",
594
+ json={
595
+ "messages": [
596
+ {
597
+ "author": "user",
598
+ "content": "Original request. " * 80,
599
+ "id": "message-1",
600
+ }
601
+ ]
602
+ },
603
+ )
604
+
605
+ response = client.post(
606
+ "/api/workspace/respond",
607
+ json={"content": "Continue from there."},
608
+ )
609
+
610
+ assert response.status_code == 200
611
+ events = stream_events(response.text)
612
+ assert events[-1]["event"] == "error"
613
+ assert json.loads(events[-1]["data"])["message"] == (
614
+ "Context could not be optimized."
615
+ )
616
+ state = client.get("/api/state").json()
617
+ assert "Context optimized" not in [
618
+ message["content"] for message in state["messages"]
619
+ ]
620
+
621
+
622
+ def test_workspace_response_uses_auto_compaction_checkpoint_after_restart(
623
+ tmp_path, monkeypatch
624
+ ) -> None:
625
+ monkeypatch.chdir(tmp_path)
626
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
627
+ monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
628
+ captured_requests: list[dict[str, object]] = []
629
+
630
+ async def fake_completion(**request: object) -> object:
631
+ captured_requests.append(request)
632
+ if not request.get("stream"):
633
+ return {
634
+ "choices": [
635
+ {
636
+ "message": {
637
+ "content": "Auto checkpoint survives restarts.",
638
+ "role": "assistant",
639
+ }
640
+ }
641
+ ]
642
+ }
643
+
644
+ async def chunks() -> object:
645
+ yield {"choices": [{"delta": {"content": "Continuing."}}]}
646
+
647
+ return chunks()
648
+
649
+ client = TestClient(
650
+ create_app(serve_frontend=False, chat_completion=fake_completion)
651
+ )
652
+ configure_provider(client)
653
+ client.put(
654
+ "/api/workspace/messages",
655
+ json={
656
+ "messages": [
657
+ {
658
+ "author": "user",
659
+ "content": "Original request. " * 80,
660
+ "id": "message-1",
661
+ }
662
+ ]
663
+ },
664
+ )
665
+
666
+ first_response = client.post(
667
+ "/api/workspace/respond",
668
+ json={"content": "Continue from there."},
669
+ )
670
+ monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "100000")
671
+ restarted_client = TestClient(
672
+ create_app(serve_frontend=False, chat_completion=fake_completion)
673
+ )
674
+ second_response = restarted_client.post(
675
+ "/api/workspace/respond",
676
+ json={"content": "Continue after restart."},
677
+ )
678
+
679
+ assert first_response.status_code == 200
680
+ assert second_response.status_code == 200
681
+ response_messages = captured_requests[2]["messages"]
682
+ compacted_messages = [
683
+ message
684
+ for message in response_messages
685
+ if str(message["content"]).startswith(
686
+ "Another language model started working on this Flowent workspace session"
687
+ )
688
+ ]
689
+ assert len(compacted_messages) == 1
690
+ assert "Auto checkpoint survives restarts." in compacted_messages[0]["content"]
691
+ assert {"role": "user", "content": "Context optimized"} not in response_messages
692
+ assert response_messages[-1] == {
693
+ "role": "user",
694
+ "content": "Continue after restart.",
695
+ }
696
+
697
+
412
698
  def test_workspace_response_includes_project_and_environment_context(
413
699
  tmp_path, monkeypatch
414
700
  ) -> None:
@@ -973,19 +1259,28 @@ def test_workspace_persists_failed_draft_when_stream_errors(
973
1259
  assert assistant["author"] == "assistant"
974
1260
  assert assistant["content"] == "Partial answer."
975
1261
  assert assistant["status"] == "failed"
1262
+ assert assistant["groups"][-1] == {
1263
+ "id": f"{assistant['id']}-errors",
1264
+ "items": [
1265
+ {
1266
+ "detail": "provider stopped",
1267
+ "id": f"{assistant['id']}-error-1",
1268
+ "message": "Check the model connection settings and try again.",
1269
+ "title": "Request failed",
1270
+ "type": "error",
1271
+ }
1272
+ ],
1273
+ }
976
1274
 
977
1275
 
978
- def test_workspace_marks_draft_complete_when_stream_finishes(
1276
+ def test_workspace_persists_error_block_when_model_fails_before_output(
979
1277
  tmp_path, monkeypatch
980
1278
  ) -> None:
981
1279
  monkeypatch.chdir(tmp_path)
982
1280
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
983
1281
 
984
1282
  async def fake_completion(**request: object) -> object:
985
- async def chunks() -> object:
986
- yield {"choices": [{"delta": {"content": "Done."}}]}
987
-
988
- return chunks()
1283
+ raise RuntimeError("provider unavailable")
989
1284
 
990
1285
  client = TestClient(
991
1286
  create_app(serve_frontend=False, chat_completion=fake_completion)
@@ -995,39 +1290,287 @@ def test_workspace_marks_draft_complete_when_stream_finishes(
995
1290
  response = client.post("/api/workspace/respond", json={"content": "Hello."})
996
1291
 
997
1292
  assert response.status_code == 200
1293
+ events = stream_events(response.text)
1294
+ assert events[-1]["event"] == "error"
1295
+ assistant_id = json.loads(events[0]["data"])["id"]
1296
+ assert json.loads(events[-1]["data"]) == {
1297
+ "error": {
1298
+ "detail": "provider unavailable",
1299
+ "id": f"{assistant_id}-error-1",
1300
+ "message": "Check the model connection settings and try again.",
1301
+ "title": "Request failed",
1302
+ "type": "error",
1303
+ },
1304
+ "message": "Check the model connection settings and try again.",
1305
+ }
998
1306
  state = client.get("/api/state").json()
999
1307
  assistant = state["messages"][-1]
1000
1308
  assert assistant["author"] == "assistant"
1001
- assert assistant["content"] == "Done."
1002
- assert assistant.get("status", "completed") == "completed"
1309
+ assert assistant["content"] == ""
1310
+ assert assistant["status"] == "failed"
1311
+ assert assistant["groups"] == [
1312
+ {
1313
+ "id": f"{assistant['id']}-group-1",
1314
+ "items": [],
1315
+ },
1316
+ {
1317
+ "id": f"{assistant['id']}-errors",
1318
+ "items": [
1319
+ {
1320
+ "detail": "provider unavailable",
1321
+ "id": f"{assistant['id']}-error-1",
1322
+ "message": "Check the model connection settings and try again.",
1323
+ "title": "Request failed",
1324
+ "type": "error",
1325
+ }
1326
+ ],
1327
+ },
1328
+ ]
1003
1329
 
1004
1330
 
1005
- @pytest.mark.anyio
1006
- async def test_workspace_run_continues_without_stream_consumer(
1331
+ def test_workspace_treats_empty_model_result_as_failed_error_block(
1007
1332
  tmp_path, monkeypatch
1008
1333
  ) -> None:
1009
1334
  monkeypatch.chdir(tmp_path)
1010
1335
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1011
- first_chunk_sent = asyncio.Event()
1012
- finish_response = asyncio.Event()
1013
1336
 
1014
1337
  async def fake_completion(**request: object) -> object:
1015
1338
  async def chunks() -> object:
1016
- yield {"choices": [{"delta": {"content": "Partial "}}]}
1017
- first_chunk_sent.set()
1018
- await asyncio.wait_for(finish_response.wait(), timeout=2)
1019
- yield {"choices": [{"delta": {"content": "answer."}}]}
1339
+ if False:
1340
+ yield {}
1020
1341
 
1021
1342
  return chunks()
1022
1343
 
1023
- app = create_app(serve_frontend=False, chat_completion=fake_completion)
1024
- async with httpx.AsyncClient(
1025
- transport=httpx.ASGITransport(app=app), base_url="http://testserver"
1026
- ) as client:
1027
- await configure_provider_async(client)
1028
- response = await client.post(
1029
- "/api/workspace/runs",
1030
- json={"content": "Keep working."},
1344
+ client = TestClient(
1345
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1346
+ )
1347
+ configure_provider(client)
1348
+
1349
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
1350
+
1351
+ assert response.status_code == 200
1352
+ events = stream_events(response.text)
1353
+ assert events[-1]["event"] == "error"
1354
+ state = client.get("/api/state").json()
1355
+ assistant = state["messages"][-1]
1356
+ assert assistant["status"] == "failed"
1357
+ assert assistant["groups"][-1]["items"] == [
1358
+ {
1359
+ "detail": "The model did not return a response.",
1360
+ "id": f"{assistant['id']}-error-1",
1361
+ "message": "Check the model connection settings and try again.",
1362
+ "title": "Request failed",
1363
+ "type": "error",
1364
+ }
1365
+ ]
1366
+
1367
+
1368
+ def test_workspace_includes_previous_error_summary_in_next_request(
1369
+ tmp_path, monkeypatch
1370
+ ) -> None:
1371
+ monkeypatch.chdir(tmp_path)
1372
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1373
+ captured_requests: list[dict[str, object]] = []
1374
+
1375
+ async def fake_completion(**request: object) -> object:
1376
+ captured_requests.append(request)
1377
+
1378
+ async def chunks() -> object:
1379
+ yield {"choices": [{"delta": {"content": "Recovered."}}]}
1380
+
1381
+ return chunks()
1382
+
1383
+ client = TestClient(
1384
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1385
+ )
1386
+ configure_provider(client)
1387
+ client.put(
1388
+ "/api/workspace/messages",
1389
+ json={
1390
+ "messages": [
1391
+ {
1392
+ "author": "user",
1393
+ "content": "Try once.",
1394
+ "id": "message-user-1",
1395
+ },
1396
+ {
1397
+ "author": "assistant",
1398
+ "content": "",
1399
+ "groups": [
1400
+ {
1401
+ "id": "message-assistant-1-errors",
1402
+ "items": [
1403
+ {
1404
+ "detail": "HTML response returned.",
1405
+ "id": "message-assistant-1-error-1",
1406
+ "message": "Check the model connection settings and try again.",
1407
+ "title": "Request failed",
1408
+ "type": "error",
1409
+ }
1410
+ ],
1411
+ }
1412
+ ],
1413
+ "id": "message-assistant-1",
1414
+ "status": "failed",
1415
+ },
1416
+ ]
1417
+ },
1418
+ )
1419
+
1420
+ response = client.post("/api/workspace/respond", json={"content": "Try again."})
1421
+
1422
+ assert response.status_code == 200
1423
+ request_messages = captured_requests[0]["messages"]
1424
+ assert {
1425
+ "role": "assistant",
1426
+ "content": "Previous response failed: Request failed. Check the model connection settings and try again. Detail: HTML response returned.",
1427
+ } in request_messages
1428
+
1429
+
1430
+ def test_workspace_marks_running_tool_failed_when_stream_errors(
1431
+ tmp_path, monkeypatch
1432
+ ) -> None:
1433
+ monkeypatch.chdir(tmp_path)
1434
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1435
+
1436
+ async def fake_run_async(self, command, **kwargs):
1437
+ raise RuntimeError("sandbox failed")
1438
+
1439
+ monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
1440
+
1441
+ async def fake_completion(**request: object) -> object:
1442
+ async def chunks() -> object:
1443
+ yield tool_call_chunk("shell_command", '{"command": "boom"}')
1444
+
1445
+ return chunks()
1446
+
1447
+ client = TestClient(
1448
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1449
+ )
1450
+ configure_provider(client)
1451
+
1452
+ response = client.post("/api/workspace/respond", json={"content": "Run it."})
1453
+
1454
+ assert response.status_code == 200
1455
+ events = stream_events(response.text)
1456
+ assert events[-1]["event"] == "error"
1457
+ state = client.get("/api/state").json()
1458
+ assistant = state["messages"][-1]
1459
+ assert assistant["status"] == "failed"
1460
+ assert assistant["tools"][0]["name"] == "shell_command"
1461
+ assert assistant["tools"][0]["status"] == "failed"
1462
+ assert "sandbox failed" in assistant["tools"][0]["content"]
1463
+
1464
+
1465
+ def test_workspace_marks_draft_complete_when_stream_finishes(
1466
+ tmp_path, monkeypatch
1467
+ ) -> None:
1468
+ monkeypatch.chdir(tmp_path)
1469
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1470
+
1471
+ async def fake_completion(**request: object) -> object:
1472
+ async def chunks() -> object:
1473
+ yield {"choices": [{"delta": {"content": "Done."}}]}
1474
+
1475
+ return chunks()
1476
+
1477
+ client = TestClient(
1478
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1479
+ )
1480
+ configure_provider(client)
1481
+
1482
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
1483
+
1484
+ assert response.status_code == 200
1485
+ state = client.get("/api/state").json()
1486
+ assistant = state["messages"][-1]
1487
+ assert assistant["author"] == "assistant"
1488
+ assert assistant["content"] == "Done."
1489
+ assert assistant.get("status", "completed") == "completed"
1490
+
1491
+
1492
+ def test_workspace_persists_assistant_output_groups_after_tool_round(
1493
+ tmp_path, monkeypatch
1494
+ ) -> None:
1495
+ monkeypatch.chdir(tmp_path)
1496
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1497
+ (tmp_path / "notes.txt").write_text("Launch notes")
1498
+ completion_calls = 0
1499
+
1500
+ async def fake_completion(**request: object) -> object:
1501
+ nonlocal completion_calls
1502
+ completion_calls += 1
1503
+
1504
+ async def chunks() -> object:
1505
+ if completion_calls == 1:
1506
+ yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
1507
+ return
1508
+ yield {"choices": [{"delta": {"content": "The notes are ready."}}]}
1509
+
1510
+ return chunks()
1511
+
1512
+ client = TestClient(
1513
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1514
+ )
1515
+ configure_provider(client)
1516
+
1517
+ response = client.post("/api/workspace/respond", json={"content": "Read notes."})
1518
+ state = client.get("/api/state").json()
1519
+ assistant = state["messages"][-1]
1520
+ tool_id = assistant["tools"][0]["id"]
1521
+
1522
+ assert response.status_code == 200
1523
+ assert assistant["content"] == "The notes are ready."
1524
+ assert assistant["groups"] == [
1525
+ {
1526
+ "id": f"{assistant['id']}-group-1",
1527
+ "items": [
1528
+ {
1529
+ "id": f"tool-{tool_id}",
1530
+ "tool": assistant["tools"][0],
1531
+ "type": "tool",
1532
+ }
1533
+ ],
1534
+ },
1535
+ {
1536
+ "id": f"{assistant['id']}-group-2",
1537
+ "items": [
1538
+ {
1539
+ "content": "The notes are ready.",
1540
+ "id": f"{assistant['id']}-text-1",
1541
+ "type": "text",
1542
+ }
1543
+ ],
1544
+ },
1545
+ ]
1546
+
1547
+
1548
+ @pytest.mark.anyio
1549
+ async def test_workspace_run_continues_without_stream_consumer(
1550
+ tmp_path, monkeypatch
1551
+ ) -> None:
1552
+ monkeypatch.chdir(tmp_path)
1553
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1554
+ first_chunk_sent = asyncio.Event()
1555
+ finish_response = asyncio.Event()
1556
+
1557
+ async def fake_completion(**request: object) -> object:
1558
+ async def chunks() -> object:
1559
+ yield {"choices": [{"delta": {"content": "Partial "}}]}
1560
+ first_chunk_sent.set()
1561
+ await asyncio.wait_for(finish_response.wait(), timeout=2)
1562
+ yield {"choices": [{"delta": {"content": "answer."}}]}
1563
+
1564
+ return chunks()
1565
+
1566
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
1567
+ async with httpx.AsyncClient(
1568
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
1569
+ ) as client:
1570
+ await configure_provider_async(client)
1571
+ response = await client.post(
1572
+ "/api/workspace/runs",
1573
+ json={"content": "Keep working."},
1031
1574
  )
1032
1575
  assert response.status_code == 200
1033
1576
  await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
@@ -1092,7 +1635,7 @@ async def test_workspace_state_exposes_active_run_for_reconnect(
1092
1635
 
1093
1636
 
1094
1637
  @pytest.mark.anyio
1095
- async def test_workspace_state_exposes_pending_permission_for_reconnect(
1638
+ async def test_workspace_persists_automatic_review_result_during_stream(
1096
1639
  tmp_path, monkeypatch
1097
1640
  ) -> None:
1098
1641
  monkeypatch.chdir(tmp_path)
@@ -1110,7 +1653,34 @@ async def test_workspace_state_exposes_pending_permission_for_reconnect(
1110
1653
  +beta
1111
1654
  *** End Patch"""
1112
1655
 
1656
+ review_started = asyncio.Event()
1657
+ finish_review = asyncio.Event()
1658
+ review_payload: dict[str, object] = {}
1659
+
1113
1660
  async def fake_completion(**request: object) -> object:
1661
+ messages = request["messages"]
1662
+ if messages[0]["content"].startswith("You are Flowent Approval Reviewer"):
1663
+ review_payload.update(json.loads(messages[-1]["content"]))
1664
+ review_started.set()
1665
+ await asyncio.wait_for(finish_review.wait(), timeout=2)
1666
+ return {
1667
+ "choices": [
1668
+ {
1669
+ "message": {
1670
+ "content": json.dumps(
1671
+ {
1672
+ "risk_level": "high",
1673
+ "risk_score": 85,
1674
+ "rationale": "Outside the task scope.",
1675
+ "evidence": [],
1676
+ }
1677
+ ),
1678
+ "role": "assistant",
1679
+ }
1680
+ }
1681
+ ]
1682
+ }
1683
+
1114
1684
  async def chunks() -> object:
1115
1685
  if request["messages"][-1]["role"] == "user":
1116
1686
  yield tool_call_chunk(
@@ -1136,28 +1706,141 @@ async def test_workspace_state_exposes_pending_permission_for_reconnect(
1136
1706
  json={"content": "Edit notes."},
1137
1707
  )
1138
1708
  run_id = response.json()["run_id"]
1139
-
1140
- for _ in range(20):
1141
- state = (await client.get("/api/state")).json()
1142
- if state.get("permission_requests"):
1143
- break
1144
- await asyncio.sleep(0.05)
1145
- else:
1146
- raise AssertionError("Permission request was not exposed.")
1147
-
1148
- request = state["permission_requests"][0]
1149
- await client.post(
1150
- "/api/workspace/permissions/approve",
1151
- json={"decision": "deny", "id": request["id"]},
1709
+ await asyncio.wait_for(review_started.wait(), timeout=2)
1710
+ state = (await client.get("/api/state")).json()
1711
+ finish_review.set()
1712
+ stream_response = await client.get(
1713
+ f"/api/workspace/runs/{run_id}/stream?after={state['active_run_event_index']}"
1152
1714
  )
1153
1715
 
1154
1716
  assistant = state["messages"][-1]
1155
1717
  assert state["active_run_id"] == run_id
1156
- assert request["path"] == str(outside_dir)
1157
- assert request["reason"] == "The edit needs to write this path."
1158
- assert request["tool_call_id"] == assistant["tools"][0]["id"]
1159
1718
  assert assistant["tools"][0]["name"] == "apply_patch"
1160
- assert assistant["tools"][0]["status"] == "waiting"
1719
+ assert assistant["tools"][0]["status"] == "running"
1720
+ events = stream_events(stream_response.text)
1721
+ tool_error = next(event for event in events if event["event"] == "tool_error")
1722
+ tool_error_data = json.loads(str(tool_error["data"]))
1723
+ assert tool_error_data["data"]["approval"]["decision"] == "denied"
1724
+ assert tool_error_data["data"]["approval"]["reason"] == "Outside the task scope."
1725
+ assert review_payload["user_request"] == "Edit notes."
1726
+ assert target.read_text() == "alpha\n"
1727
+
1728
+
1729
+ @pytest.mark.anyio
1730
+ async def test_workspace_review_request_includes_recent_transcript(
1731
+ tmp_path, monkeypatch
1732
+ ) -> None:
1733
+ monkeypatch.chdir(tmp_path)
1734
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1735
+ work_dir = tmp_path / "work"
1736
+ work_dir.mkdir()
1737
+ review_payload: dict[str, object] = {}
1738
+
1739
+ async def fake_completion(**request: object) -> object:
1740
+ messages = request["messages"]
1741
+ if messages[0]["content"].startswith("You are Flowent Approval Reviewer"):
1742
+ review_payload.update(json.loads(messages[-1]["content"]))
1743
+ return {
1744
+ "choices": [
1745
+ {
1746
+ "message": {
1747
+ "content": json.dumps(
1748
+ {
1749
+ "risk_level": "high",
1750
+ "risk_score": 85,
1751
+ "rationale": "No run is needed for this test.",
1752
+ "evidence": [],
1753
+ }
1754
+ ),
1755
+ "role": "assistant",
1756
+ }
1757
+ }
1758
+ ]
1759
+ }
1760
+
1761
+ async def chunks() -> object:
1762
+ if request["messages"][-1]["role"] == "user":
1763
+ yield tool_call_chunk(
1764
+ "shell_command",
1765
+ json.dumps(
1766
+ {
1767
+ "additional_permissions": {
1768
+ "file_system": {"write": ["/var/run/docker.sock"]}
1769
+ },
1770
+ "command": (
1771
+ "docker compose up -d --force-recreate flowent"
1772
+ ),
1773
+ "sandbox_permissions": "with_additional_permissions",
1774
+ }
1775
+ ),
1776
+ )
1777
+ return
1778
+ yield {"choices": [{"delta": {"content": "Stopped."}}]}
1779
+
1780
+ return chunks()
1781
+
1782
+ app = create_app(
1783
+ workdir=work_dir,
1784
+ serve_frontend=False,
1785
+ chat_completion=fake_completion,
1786
+ )
1787
+ async with httpx.AsyncClient(
1788
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
1789
+ ) as client:
1790
+ await configure_provider_async(client)
1791
+ await client.put(
1792
+ "/api/workspace/messages",
1793
+ json={
1794
+ "messages": [
1795
+ {
1796
+ "author": "user",
1797
+ "content": "Can you recreate the dev container?",
1798
+ "id": "user-1",
1799
+ },
1800
+ {
1801
+ "author": "assistant",
1802
+ "content": (
1803
+ "This will recreate the Flowent dev container through "
1804
+ "Docker and may briefly interrupt the running service."
1805
+ ),
1806
+ "id": "assistant-1",
1807
+ "tools": [
1808
+ {
1809
+ "arguments": {"command": "docker compose ps"},
1810
+ "content": "flowent-dev-preview-flowent-1 running",
1811
+ "data": {},
1812
+ "id": "tool-1",
1813
+ "name": "shell_command",
1814
+ "status": "success",
1815
+ "title": "Ran docker compose ps",
1816
+ }
1817
+ ],
1818
+ },
1819
+ ]
1820
+ },
1821
+ )
1822
+ response = await client.post(
1823
+ "/api/workspace/runs",
1824
+ json={"content": "确认"},
1825
+ )
1826
+ run_id = response.json()["run_id"]
1827
+ stream_response = await client.get(f"/api/workspace/runs/{run_id}/stream")
1828
+
1829
+ events = stream_events(stream_response.text)
1830
+ assert "tool_error" in [event["event"] for event in events]
1831
+ assert review_payload["user_request"] == "确认"
1832
+ transcript = review_payload["transcript"]
1833
+ assert {"role": "user", "content": "确认"} in transcript
1834
+ assert any(
1835
+ entry["role"] == "assistant" and "briefly interrupt" in entry["content"]
1836
+ for entry in transcript
1837
+ )
1838
+ assert any(
1839
+ entry["role"] == "tool"
1840
+ and entry["name"] == "shell_command"
1841
+ and "flowent-dev-preview-flowent-1" in entry["content"]
1842
+ for entry in transcript
1843
+ )
1161
1844
 
1162
1845
 
1163
1846
  @pytest.mark.anyio
@@ -1306,3 +1989,134 @@ def test_workspace_compact_is_unavailable_while_response_is_running(
1306
1989
  assert response.status_code == 200
1307
1990
 
1308
1991
  asyncio.run(run_test())
1992
+
1993
+
1994
+ def configured_agent_prompt_message(
1995
+ request: dict[str, object],
1996
+ ) -> dict[str, object] | None:
1997
+ for message in request["messages"]:
1998
+ if str(message["content"]).startswith("# Flowent configured agent prompt"):
1999
+ return message
2000
+ return None
2001
+
2002
+
2003
+ def test_workspace_response_includes_configured_agent_prompt_before_agents_md(
2004
+ tmp_path, monkeypatch
2005
+ ) -> None:
2006
+ monkeypatch.chdir(tmp_path)
2007
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
2008
+ (tmp_path / ".git").mkdir()
2009
+ (tmp_path / "AGENTS.md").write_text("Use project instructions.")
2010
+ captured_request: dict[str, object] = {}
2011
+
2012
+ async def fake_completion(**request: object) -> object:
2013
+ captured_request.update(request)
2014
+
2015
+ async def chunks() -> object:
2016
+ yield {"choices": [{"delta": {"content": "Done."}}]}
2017
+
2018
+ return chunks()
2019
+
2020
+ client = TestClient(
2021
+ create_app(serve_frontend=False, chat_completion=fake_completion)
2022
+ )
2023
+ configure_provider(client, agent_prompt="Use UI configured instructions first.")
2024
+
2025
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
2026
+
2027
+ assert response.status_code == 200
2028
+ configured_message = configured_agent_prompt_message(captured_request)
2029
+ project_message = project_context_message(captured_request)
2030
+ assert configured_message is not None
2031
+ assert project_message is not None
2032
+ assert configured_message["role"] == "system"
2033
+ assert "Use UI configured instructions first." in configured_message["content"]
2034
+ assert "Use project instructions." in project_message["content"]
2035
+ assert captured_request["messages"].index(configured_message) < captured_request[
2036
+ "messages"
2037
+ ].index(project_message)
2038
+
2039
+
2040
+ def test_workspace_compacted_response_includes_latest_configured_agent_prompt(
2041
+ tmp_path, monkeypatch
2042
+ ) -> None:
2043
+ monkeypatch.chdir(tmp_path)
2044
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
2045
+ captured_requests: list[dict[str, object]] = []
2046
+
2047
+ async def fake_completion(**request: object) -> object:
2048
+ captured_requests.append(request)
2049
+ if len(captured_requests) == 1:
2050
+ return {
2051
+ "choices": [
2052
+ {
2053
+ "message": {
2054
+ "content": "Keep compacted state.",
2055
+ "role": "assistant",
2056
+ }
2057
+ }
2058
+ ]
2059
+ }
2060
+
2061
+ async def chunks() -> object:
2062
+ yield {"choices": [{"delta": {"content": "Done."}}]}
2063
+
2064
+ return chunks()
2065
+
2066
+ client = TestClient(
2067
+ create_app(serve_frontend=False, chat_completion=fake_completion)
2068
+ )
2069
+ configure_provider(client, agent_prompt="Prompt before compact.")
2070
+ client.put(
2071
+ "/api/workspace/messages",
2072
+ json={
2073
+ "messages": [
2074
+ {"author": "user", "content": "Original request.", "id": "message-1"}
2075
+ ]
2076
+ },
2077
+ )
2078
+
2079
+ compact_response = client.post("/api/workspace/compact")
2080
+ client.put(
2081
+ "/api/settings",
2082
+ json={
2083
+ "agent_prompt": "Prompt after compact.",
2084
+ "reasoning_effort": "default",
2085
+ "selected_model": "gpt-5.1",
2086
+ "selected_provider_id": "provider-openai",
2087
+ },
2088
+ )
2089
+ response = client.post("/api/workspace/respond", json={"content": "Continue."})
2090
+
2091
+ assert compact_response.status_code == 200
2092
+ assert response.status_code == 200
2093
+ configured_message = configured_agent_prompt_message(captured_requests[1])
2094
+ assert configured_message is not None
2095
+ assert "Prompt after compact." in configured_message["content"]
2096
+ assert "Prompt before compact." not in configured_message["content"]
2097
+
2098
+
2099
+ def test_workspace_response_trims_blank_configured_agent_prompt(
2100
+ tmp_path, monkeypatch
2101
+ ) -> None:
2102
+ monkeypatch.chdir(tmp_path)
2103
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
2104
+ captured_request: dict[str, object] = {}
2105
+
2106
+ async def fake_completion(**request: object) -> object:
2107
+ captured_request.update(request)
2108
+
2109
+ async def chunks() -> object:
2110
+ yield {"choices": [{"delta": {"content": "Done."}}]}
2111
+
2112
+ return chunks()
2113
+
2114
+ client = TestClient(
2115
+ create_app(serve_frontend=False, chat_completion=fake_completion)
2116
+ )
2117
+ configure_provider(client, agent_prompt="\n\n")
2118
+
2119
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
2120
+
2121
+ assert response.status_code == 200
2122
+ assert configured_agent_prompt_message(captured_request) is None