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
@@ -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,17 +1259,45 @@ 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(
979
- tmp_path, monkeypatch
1276
+ def test_workspace_persists_error_block_when_responses_stream_fails(
1277
+ tmp_path, monkeypatch, fake_litellm_responses_transformer
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
1283
  async def chunks() -> object:
986
- yield {"choices": [{"delta": {"content": "Done."}}]}
1284
+ from litellm.completion_extras.litellm_responses_transformation.transformation import (
1285
+ OpenAiResponsesToChatCompletionStreamIterator,
1286
+ )
1287
+
1288
+ yield {"choices": [{"delta": {"content": "Partial answer."}}]}
1289
+ yield OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
1290
+ {
1291
+ "response": {
1292
+ "error": {
1293
+ "code": "upstream_error",
1294
+ "message": "Upstream request failed",
1295
+ },
1296
+ "status": "failed",
1297
+ },
1298
+ "type": "response.failed",
1299
+ }
1300
+ )
987
1301
 
988
1302
  return chunks()
989
1303
 
@@ -995,34 +1309,315 @@ def test_workspace_marks_draft_complete_when_stream_finishes(
995
1309
  response = client.post("/api/workspace/respond", json={"content": "Hello."})
996
1310
 
997
1311
  assert response.status_code == 200
1312
+ events = stream_events(response.text)
1313
+ assert events[-1]["event"] == "error"
998
1314
  state = client.get("/api/state").json()
999
1315
  assistant = state["messages"][-1]
1000
1316
  assert assistant["author"] == "assistant"
1001
- assert assistant["content"] == "Done."
1002
- assert assistant.get("status", "completed") == "completed"
1317
+ assert assistant["content"] == "Partial answer."
1318
+ assert assistant["status"] == "failed"
1319
+ assert json.loads(events[-1]["data"])["error"] == {
1320
+ "detail": "Upstream request failed",
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
+ }
1003
1326
 
1004
1327
 
1005
- @pytest.mark.anyio
1006
- async def test_workspace_run_continues_without_stream_consumer(
1328
+ def test_workspace_persists_error_block_when_model_fails_before_output(
1007
1329
  tmp_path, monkeypatch
1008
1330
  ) -> None:
1009
1331
  monkeypatch.chdir(tmp_path)
1010
1332
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1011
- first_chunk_sent = asyncio.Event()
1012
- finish_response = asyncio.Event()
1013
1333
 
1014
1334
  async def fake_completion(**request: object) -> object:
1015
- 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."}}]}
1335
+ raise RuntimeError("provider unavailable")
1020
1336
 
1021
- return chunks()
1337
+ client = TestClient(
1338
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1339
+ )
1340
+ configure_provider(client)
1022
1341
 
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"
1342
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
1343
+
1344
+ assert response.status_code == 200
1345
+ events = stream_events(response.text)
1346
+ assert events[-1]["event"] == "error"
1347
+ assistant_id = json.loads(events[0]["data"])["id"]
1348
+ assert json.loads(events[-1]["data"]) == {
1349
+ "error": {
1350
+ "detail": "provider unavailable",
1351
+ "id": f"{assistant_id}-error-1",
1352
+ "message": "Check the model connection settings and try again.",
1353
+ "title": "Request failed",
1354
+ "type": "error",
1355
+ },
1356
+ "message": "Check the model connection settings and try again.",
1357
+ }
1358
+ state = client.get("/api/state").json()
1359
+ assistant = state["messages"][-1]
1360
+ assert assistant["author"] == "assistant"
1361
+ assert assistant["content"] == ""
1362
+ assert assistant["status"] == "failed"
1363
+ assert assistant["groups"] == [
1364
+ {
1365
+ "id": f"{assistant['id']}-group-1",
1366
+ "items": [],
1367
+ },
1368
+ {
1369
+ "id": f"{assistant['id']}-errors",
1370
+ "items": [
1371
+ {
1372
+ "detail": "provider unavailable",
1373
+ "id": f"{assistant['id']}-error-1",
1374
+ "message": "Check the model connection settings and try again.",
1375
+ "title": "Request failed",
1376
+ "type": "error",
1377
+ }
1378
+ ],
1379
+ },
1380
+ ]
1381
+
1382
+
1383
+ def test_workspace_treats_empty_model_result_as_failed_error_block(
1384
+ tmp_path, monkeypatch
1385
+ ) -> None:
1386
+ monkeypatch.chdir(tmp_path)
1387
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1388
+
1389
+ async def fake_completion(**request: object) -> object:
1390
+ async def chunks() -> object:
1391
+ if False:
1392
+ yield {}
1393
+
1394
+ return chunks()
1395
+
1396
+ client = TestClient(
1397
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1398
+ )
1399
+ configure_provider(client)
1400
+
1401
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
1402
+
1403
+ assert response.status_code == 200
1404
+ events = stream_events(response.text)
1405
+ assert events[-1]["event"] == "error"
1406
+ state = client.get("/api/state").json()
1407
+ assistant = state["messages"][-1]
1408
+ assert assistant["status"] == "failed"
1409
+ assert assistant["groups"][-1]["items"] == [
1410
+ {
1411
+ "detail": "The model did not return a response.",
1412
+ "id": f"{assistant['id']}-error-1",
1413
+ "message": "Check the model connection settings and try again.",
1414
+ "title": "Request failed",
1415
+ "type": "error",
1416
+ }
1417
+ ]
1418
+
1419
+
1420
+ def test_workspace_includes_previous_error_summary_in_next_request(
1421
+ tmp_path, monkeypatch
1422
+ ) -> None:
1423
+ monkeypatch.chdir(tmp_path)
1424
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1425
+ captured_requests: list[dict[str, object]] = []
1426
+
1427
+ async def fake_completion(**request: object) -> object:
1428
+ captured_requests.append(request)
1429
+
1430
+ async def chunks() -> object:
1431
+ yield {"choices": [{"delta": {"content": "Recovered."}}]}
1432
+
1433
+ return chunks()
1434
+
1435
+ client = TestClient(
1436
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1437
+ )
1438
+ configure_provider(client)
1439
+ client.put(
1440
+ "/api/workspace/messages",
1441
+ json={
1442
+ "messages": [
1443
+ {
1444
+ "author": "user",
1445
+ "content": "Try once.",
1446
+ "id": "message-user-1",
1447
+ },
1448
+ {
1449
+ "author": "assistant",
1450
+ "content": "",
1451
+ "groups": [
1452
+ {
1453
+ "id": "message-assistant-1-errors",
1454
+ "items": [
1455
+ {
1456
+ "detail": "HTML response returned.",
1457
+ "id": "message-assistant-1-error-1",
1458
+ "message": "Check the model connection settings and try again.",
1459
+ "title": "Request failed",
1460
+ "type": "error",
1461
+ }
1462
+ ],
1463
+ }
1464
+ ],
1465
+ "id": "message-assistant-1",
1466
+ "status": "failed",
1467
+ },
1468
+ ]
1469
+ },
1470
+ )
1471
+
1472
+ response = client.post("/api/workspace/respond", json={"content": "Try again."})
1473
+
1474
+ assert response.status_code == 200
1475
+ request_messages = captured_requests[0]["messages"]
1476
+ assert {
1477
+ "role": "assistant",
1478
+ "content": "Previous response failed: Request failed. Check the model connection settings and try again. Detail: HTML response returned.",
1479
+ } in request_messages
1480
+
1481
+
1482
+ def test_workspace_marks_running_tool_failed_when_stream_errors(
1483
+ tmp_path, monkeypatch
1484
+ ) -> None:
1485
+ monkeypatch.chdir(tmp_path)
1486
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1487
+
1488
+ async def fake_run_async(self, command, **kwargs):
1489
+ raise RuntimeError("sandbox failed")
1490
+
1491
+ monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
1492
+
1493
+ async def fake_completion(**request: object) -> object:
1494
+ async def chunks() -> object:
1495
+ yield tool_call_chunk("shell_command", '{"command": "boom"}')
1496
+
1497
+ return chunks()
1498
+
1499
+ client = TestClient(
1500
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1501
+ )
1502
+ configure_provider(client)
1503
+
1504
+ response = client.post("/api/workspace/respond", json={"content": "Run it."})
1505
+
1506
+ assert response.status_code == 200
1507
+ events = stream_events(response.text)
1508
+ assert events[-1]["event"] == "error"
1509
+ state = client.get("/api/state").json()
1510
+ assistant = state["messages"][-1]
1511
+ assert assistant["status"] == "failed"
1512
+ assert assistant["tools"][0]["name"] == "shell_command"
1513
+ assert assistant["tools"][0]["status"] == "failed"
1514
+ assert "sandbox failed" in assistant["tools"][0]["content"]
1515
+
1516
+
1517
+ def test_workspace_marks_draft_complete_when_stream_finishes(
1518
+ tmp_path, monkeypatch
1519
+ ) -> None:
1520
+ monkeypatch.chdir(tmp_path)
1521
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1522
+
1523
+ async def fake_completion(**request: object) -> object:
1524
+ async def chunks() -> object:
1525
+ yield {"choices": [{"delta": {"content": "Done."}}]}
1526
+
1527
+ return chunks()
1528
+
1529
+ client = TestClient(
1530
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1531
+ )
1532
+ configure_provider(client)
1533
+
1534
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
1535
+
1536
+ assert response.status_code == 200
1537
+ state = client.get("/api/state").json()
1538
+ assistant = state["messages"][-1]
1539
+ assert assistant["author"] == "assistant"
1540
+ assert assistant["content"] == "Done."
1541
+ assert assistant.get("status", "completed") == "completed"
1542
+
1543
+
1544
+ def test_workspace_persists_assistant_output_groups_after_tool_round(
1545
+ tmp_path, monkeypatch
1546
+ ) -> None:
1547
+ monkeypatch.chdir(tmp_path)
1548
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1549
+ (tmp_path / "notes.txt").write_text("Launch notes")
1550
+ completion_calls = 0
1551
+
1552
+ async def fake_completion(**request: object) -> object:
1553
+ nonlocal completion_calls
1554
+ completion_calls += 1
1555
+
1556
+ async def chunks() -> object:
1557
+ if completion_calls == 1:
1558
+ yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
1559
+ return
1560
+ yield {"choices": [{"delta": {"content": "The notes are ready."}}]}
1561
+
1562
+ return chunks()
1563
+
1564
+ client = TestClient(
1565
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1566
+ )
1567
+ configure_provider(client)
1568
+
1569
+ response = client.post("/api/workspace/respond", json={"content": "Read notes."})
1570
+ state = client.get("/api/state").json()
1571
+ assistant = state["messages"][-1]
1572
+ tool_id = assistant["tools"][0]["id"]
1573
+
1574
+ assert response.status_code == 200
1575
+ assert assistant["content"] == "The notes are ready."
1576
+ assert assistant["groups"] == [
1577
+ {
1578
+ "id": f"{assistant['id']}-group-1",
1579
+ "items": [
1580
+ {
1581
+ "id": f"tool-{tool_id}",
1582
+ "tool": assistant["tools"][0],
1583
+ "type": "tool",
1584
+ }
1585
+ ],
1586
+ },
1587
+ {
1588
+ "id": f"{assistant['id']}-group-2",
1589
+ "items": [
1590
+ {
1591
+ "content": "The notes are ready.",
1592
+ "id": f"{assistant['id']}-text-1",
1593
+ "type": "text",
1594
+ }
1595
+ ],
1596
+ },
1597
+ ]
1598
+
1599
+
1600
+ @pytest.mark.anyio
1601
+ async def test_workspace_run_continues_without_stream_consumer(
1602
+ tmp_path, monkeypatch
1603
+ ) -> None:
1604
+ monkeypatch.chdir(tmp_path)
1605
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1606
+ first_chunk_sent = asyncio.Event()
1607
+ finish_response = asyncio.Event()
1608
+
1609
+ async def fake_completion(**request: object) -> object:
1610
+ async def chunks() -> object:
1611
+ yield {"choices": [{"delta": {"content": "Partial "}}]}
1612
+ first_chunk_sent.set()
1613
+ await asyncio.wait_for(finish_response.wait(), timeout=2)
1614
+ yield {"choices": [{"delta": {"content": "answer."}}]}
1615
+
1616
+ return chunks()
1617
+
1618
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
1619
+ async with httpx.AsyncClient(
1620
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
1026
1621
  ) as client:
1027
1622
  await configure_provider_async(client)
1028
1623
  response = await client.post(
@@ -1092,7 +1687,7 @@ async def test_workspace_state_exposes_active_run_for_reconnect(
1092
1687
 
1093
1688
 
1094
1689
  @pytest.mark.anyio
1095
- async def test_workspace_state_exposes_pending_permission_for_reconnect(
1690
+ async def test_workspace_persists_automatic_review_result_during_stream(
1096
1691
  tmp_path, monkeypatch
1097
1692
  ) -> None:
1098
1693
  monkeypatch.chdir(tmp_path)
@@ -1110,7 +1705,34 @@ async def test_workspace_state_exposes_pending_permission_for_reconnect(
1110
1705
  +beta
1111
1706
  *** End Patch"""
1112
1707
 
1708
+ review_started = asyncio.Event()
1709
+ finish_review = asyncio.Event()
1710
+ review_payload: dict[str, object] = {}
1711
+
1113
1712
  async def fake_completion(**request: object) -> object:
1713
+ messages = request["messages"]
1714
+ if messages[0]["content"].startswith("You are Flowent Approval Reviewer"):
1715
+ review_payload.update(json.loads(messages[-1]["content"]))
1716
+ review_started.set()
1717
+ await asyncio.wait_for(finish_review.wait(), timeout=2)
1718
+ return {
1719
+ "choices": [
1720
+ {
1721
+ "message": {
1722
+ "content": json.dumps(
1723
+ {
1724
+ "risk_level": "high",
1725
+ "risk_score": 85,
1726
+ "rationale": "Outside the task scope.",
1727
+ "evidence": [],
1728
+ }
1729
+ ),
1730
+ "role": "assistant",
1731
+ }
1732
+ }
1733
+ ]
1734
+ }
1735
+
1114
1736
  async def chunks() -> object:
1115
1737
  if request["messages"][-1]["role"] == "user":
1116
1738
  yield tool_call_chunk(
@@ -1136,28 +1758,141 @@ async def test_workspace_state_exposes_pending_permission_for_reconnect(
1136
1758
  json={"content": "Edit notes."},
1137
1759
  )
1138
1760
  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"]},
1761
+ await asyncio.wait_for(review_started.wait(), timeout=2)
1762
+ state = (await client.get("/api/state")).json()
1763
+ finish_review.set()
1764
+ stream_response = await client.get(
1765
+ f"/api/workspace/runs/{run_id}/stream?after={state['active_run_event_index']}"
1152
1766
  )
1153
1767
 
1154
1768
  assistant = state["messages"][-1]
1155
1769
  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
1770
  assert assistant["tools"][0]["name"] == "apply_patch"
1160
- assert assistant["tools"][0]["status"] == "waiting"
1771
+ assert assistant["tools"][0]["status"] == "running"
1772
+ events = stream_events(stream_response.text)
1773
+ tool_error = next(event for event in events if event["event"] == "tool_error")
1774
+ tool_error_data = json.loads(str(tool_error["data"]))
1775
+ assert tool_error_data["data"]["approval"]["decision"] == "denied"
1776
+ assert tool_error_data["data"]["approval"]["reason"] == "Outside the task scope."
1777
+ assert review_payload["user_request"] == "Edit notes."
1778
+ assert target.read_text() == "alpha\n"
1779
+
1780
+
1781
+ @pytest.mark.anyio
1782
+ async def test_workspace_review_request_includes_recent_transcript(
1783
+ tmp_path, monkeypatch
1784
+ ) -> None:
1785
+ monkeypatch.chdir(tmp_path)
1786
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1787
+ work_dir = tmp_path / "work"
1788
+ work_dir.mkdir()
1789
+ review_payload: dict[str, object] = {}
1790
+
1791
+ async def fake_completion(**request: object) -> object:
1792
+ messages = request["messages"]
1793
+ if messages[0]["content"].startswith("You are Flowent Approval Reviewer"):
1794
+ review_payload.update(json.loads(messages[-1]["content"]))
1795
+ return {
1796
+ "choices": [
1797
+ {
1798
+ "message": {
1799
+ "content": json.dumps(
1800
+ {
1801
+ "risk_level": "high",
1802
+ "risk_score": 85,
1803
+ "rationale": "No run is needed for this test.",
1804
+ "evidence": [],
1805
+ }
1806
+ ),
1807
+ "role": "assistant",
1808
+ }
1809
+ }
1810
+ ]
1811
+ }
1812
+
1813
+ async def chunks() -> object:
1814
+ if request["messages"][-1]["role"] == "user":
1815
+ yield tool_call_chunk(
1816
+ "shell_command",
1817
+ json.dumps(
1818
+ {
1819
+ "additional_permissions": {
1820
+ "file_system": {"write": ["/var/run/docker.sock"]}
1821
+ },
1822
+ "command": (
1823
+ "docker compose up -d --force-recreate flowent"
1824
+ ),
1825
+ "sandbox_permissions": "with_additional_permissions",
1826
+ }
1827
+ ),
1828
+ )
1829
+ return
1830
+ yield {"choices": [{"delta": {"content": "Stopped."}}]}
1831
+
1832
+ return chunks()
1833
+
1834
+ app = create_app(
1835
+ workdir=work_dir,
1836
+ serve_frontend=False,
1837
+ chat_completion=fake_completion,
1838
+ )
1839
+ async with httpx.AsyncClient(
1840
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
1841
+ ) as client:
1842
+ await configure_provider_async(client)
1843
+ await client.put(
1844
+ "/api/workspace/messages",
1845
+ json={
1846
+ "messages": [
1847
+ {
1848
+ "author": "user",
1849
+ "content": "Can you recreate the dev container?",
1850
+ "id": "user-1",
1851
+ },
1852
+ {
1853
+ "author": "assistant",
1854
+ "content": (
1855
+ "This will recreate the Flowent dev container through "
1856
+ "Docker and may briefly interrupt the running service."
1857
+ ),
1858
+ "id": "assistant-1",
1859
+ "tools": [
1860
+ {
1861
+ "arguments": {"command": "docker compose ps"},
1862
+ "content": "flowent-dev-preview-flowent-1 running",
1863
+ "data": {},
1864
+ "id": "tool-1",
1865
+ "name": "shell_command",
1866
+ "status": "success",
1867
+ "title": "Ran docker compose ps",
1868
+ }
1869
+ ],
1870
+ },
1871
+ ]
1872
+ },
1873
+ )
1874
+ response = await client.post(
1875
+ "/api/workspace/runs",
1876
+ json={"content": "确认"},
1877
+ )
1878
+ run_id = response.json()["run_id"]
1879
+ stream_response = await client.get(f"/api/workspace/runs/{run_id}/stream")
1880
+
1881
+ events = stream_events(stream_response.text)
1882
+ assert "tool_error" in [event["event"] for event in events]
1883
+ assert review_payload["user_request"] == "确认"
1884
+ transcript = review_payload["transcript"]
1885
+ assert {"role": "user", "content": "确认"} in transcript
1886
+ assert any(
1887
+ entry["role"] == "assistant" and "briefly interrupt" in entry["content"]
1888
+ for entry in transcript
1889
+ )
1890
+ assert any(
1891
+ entry["role"] == "tool"
1892
+ and entry["name"] == "shell_command"
1893
+ and "flowent-dev-preview-flowent-1" in entry["content"]
1894
+ for entry in transcript
1895
+ )
1161
1896
 
1162
1897
 
1163
1898
  @pytest.mark.anyio
@@ -1306,3 +2041,134 @@ def test_workspace_compact_is_unavailable_while_response_is_running(
1306
2041
  assert response.status_code == 200
1307
2042
 
1308
2043
  asyncio.run(run_test())
2044
+
2045
+
2046
+ def configured_agent_prompt_message(
2047
+ request: dict[str, object],
2048
+ ) -> dict[str, object] | None:
2049
+ for message in request["messages"]:
2050
+ if str(message["content"]).startswith("# Flowent configured agent prompt"):
2051
+ return message
2052
+ return None
2053
+
2054
+
2055
+ def test_workspace_response_includes_configured_agent_prompt_before_agents_md(
2056
+ tmp_path, monkeypatch
2057
+ ) -> None:
2058
+ monkeypatch.chdir(tmp_path)
2059
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
2060
+ (tmp_path / ".git").mkdir()
2061
+ (tmp_path / "AGENTS.md").write_text("Use project instructions.")
2062
+ captured_request: dict[str, object] = {}
2063
+
2064
+ async def fake_completion(**request: object) -> object:
2065
+ captured_request.update(request)
2066
+
2067
+ async def chunks() -> object:
2068
+ yield {"choices": [{"delta": {"content": "Done."}}]}
2069
+
2070
+ return chunks()
2071
+
2072
+ client = TestClient(
2073
+ create_app(serve_frontend=False, chat_completion=fake_completion)
2074
+ )
2075
+ configure_provider(client, agent_prompt="Use UI configured instructions first.")
2076
+
2077
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
2078
+
2079
+ assert response.status_code == 200
2080
+ configured_message = configured_agent_prompt_message(captured_request)
2081
+ project_message = project_context_message(captured_request)
2082
+ assert configured_message is not None
2083
+ assert project_message is not None
2084
+ assert configured_message["role"] == "system"
2085
+ assert "Use UI configured instructions first." in configured_message["content"]
2086
+ assert "Use project instructions." in project_message["content"]
2087
+ assert captured_request["messages"].index(configured_message) < captured_request[
2088
+ "messages"
2089
+ ].index(project_message)
2090
+
2091
+
2092
+ def test_workspace_compacted_response_includes_latest_configured_agent_prompt(
2093
+ tmp_path, monkeypatch
2094
+ ) -> None:
2095
+ monkeypatch.chdir(tmp_path)
2096
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
2097
+ captured_requests: list[dict[str, object]] = []
2098
+
2099
+ async def fake_completion(**request: object) -> object:
2100
+ captured_requests.append(request)
2101
+ if len(captured_requests) == 1:
2102
+ return {
2103
+ "choices": [
2104
+ {
2105
+ "message": {
2106
+ "content": "Keep compacted state.",
2107
+ "role": "assistant",
2108
+ }
2109
+ }
2110
+ ]
2111
+ }
2112
+
2113
+ async def chunks() -> object:
2114
+ yield {"choices": [{"delta": {"content": "Done."}}]}
2115
+
2116
+ return chunks()
2117
+
2118
+ client = TestClient(
2119
+ create_app(serve_frontend=False, chat_completion=fake_completion)
2120
+ )
2121
+ configure_provider(client, agent_prompt="Prompt before compact.")
2122
+ client.put(
2123
+ "/api/workspace/messages",
2124
+ json={
2125
+ "messages": [
2126
+ {"author": "user", "content": "Original request.", "id": "message-1"}
2127
+ ]
2128
+ },
2129
+ )
2130
+
2131
+ compact_response = client.post("/api/workspace/compact")
2132
+ client.put(
2133
+ "/api/settings",
2134
+ json={
2135
+ "agent_prompt": "Prompt after compact.",
2136
+ "reasoning_effort": "default",
2137
+ "selected_model": "gpt-5.1",
2138
+ "selected_provider_id": "provider-openai",
2139
+ },
2140
+ )
2141
+ response = client.post("/api/workspace/respond", json={"content": "Continue."})
2142
+
2143
+ assert compact_response.status_code == 200
2144
+ assert response.status_code == 200
2145
+ configured_message = configured_agent_prompt_message(captured_requests[1])
2146
+ assert configured_message is not None
2147
+ assert "Prompt after compact." in configured_message["content"]
2148
+ assert "Prompt before compact." not in configured_message["content"]
2149
+
2150
+
2151
+ def test_workspace_response_trims_blank_configured_agent_prompt(
2152
+ tmp_path, monkeypatch
2153
+ ) -> None:
2154
+ monkeypatch.chdir(tmp_path)
2155
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
2156
+ captured_request: dict[str, object] = {}
2157
+
2158
+ async def fake_completion(**request: object) -> object:
2159
+ captured_request.update(request)
2160
+
2161
+ async def chunks() -> object:
2162
+ yield {"choices": [{"delta": {"content": "Done."}}]}
2163
+
2164
+ return chunks()
2165
+
2166
+ client = TestClient(
2167
+ create_app(serve_frontend=False, chat_completion=fake_completion)
2168
+ )
2169
+ configure_provider(client, agent_prompt="\n\n")
2170
+
2171
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
2172
+
2173
+ assert response.status_code == 200
2174
+ assert configured_agent_prompt_message(captured_request) is None