flowent 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/agent.py +23 -1
  23. package/backend/src/flowent/approval.py +148 -0
  24. package/backend/src/flowent/cli.py +16 -2
  25. package/backend/src/flowent/compact.py +183 -0
  26. package/backend/src/flowent/context.py +19 -1
  27. package/backend/src/flowent/llm.py +51 -11
  28. package/backend/src/flowent/logging.py +60 -0
  29. package/backend/src/flowent/main.py +696 -192
  30. package/backend/src/flowent/mcp.py +3 -1
  31. package/backend/src/flowent/patch.py +55 -31
  32. package/backend/src/flowent/paths.py +12 -0
  33. package/backend/src/flowent/permissions.py +185 -42
  34. package/backend/src/flowent/sandbox.py +146 -13
  35. package/backend/src/flowent/static/assets/index-Cl20cARb.css +2 -0
  36. package/backend/src/flowent/static/assets/index-dsDDsEym.js +81 -0
  37. package/backend/src/flowent/static/index.html +2 -2
  38. package/backend/src/flowent/storage.py +257 -9
  39. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  51. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  52. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  53. package/backend/tests/test_agent_tools.py +312 -1
  54. package/backend/tests/test_approval.py +283 -0
  55. package/backend/tests/test_llm_providers.py +216 -0
  56. package/backend/tests/test_logging.py +30 -0
  57. package/backend/tests/test_mcp.py +76 -10
  58. package/backend/tests/test_patch.py +112 -0
  59. package/backend/tests/test_permissions.py +198 -53
  60. package/backend/tests/test_persistence.py +78 -0
  61. package/backend/tests/test_startup_requirements.py +96 -0
  62. package/backend/tests/test_workspace_chat.py +1265 -144
  63. package/backend/uv.lock +1 -1
  64. package/dist/frontend/assets/index-Cl20cARb.css +2 -0
  65. package/dist/frontend/assets/index-dsDDsEym.js +81 -0
  66. package/dist/frontend/index.html +2 -2
  67. package/package.json +2 -2
  68. package/backend/src/flowent/static/assets/index-DjF2KBwE.js +0 -81
  69. package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
  70. package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
  71. package/dist/frontend/assets/index-P-bBpJG8.css +0 -2
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import json
2
3
  import time
3
4
 
4
5
  import httpx
@@ -13,6 +14,7 @@ from flowent.sandbox import CommandResult, SandboxRunner
13
14
  def configure_provider(
14
15
  client,
15
16
  *,
17
+ agent_prompt: str = "",
16
18
  base_url: str = "",
17
19
  model: str = "gpt-5.1",
18
20
  name: str = "OpenAI",
@@ -34,6 +36,7 @@ def configure_provider(
34
36
  client.put(
35
37
  "/api/settings",
36
38
  json={
39
+ "agent_prompt": agent_prompt,
37
40
  "reasoning_effort": reasoning_effort,
38
41
  "selected_model": model,
39
42
  "selected_provider_id": provider_id,
@@ -44,6 +47,7 @@ def configure_provider(
44
47
  async def configure_provider_async(
45
48
  client: httpx.AsyncClient,
46
49
  *,
50
+ agent_prompt: str = "",
47
51
  base_url: str = "",
48
52
  model: str = "gpt-5.1",
49
53
  name: str = "OpenAI",
@@ -65,6 +69,7 @@ async def configure_provider_async(
65
69
  await client.put(
66
70
  "/api/settings",
67
71
  json={
72
+ "agent_prompt": agent_prompt,
68
73
  "reasoning_effort": reasoning_effort,
69
74
  "selected_model": model,
70
75
  "selected_provider_id": provider_id,
@@ -314,11 +319,14 @@ def test_workspace_compact_persists_compacted_context(tmp_path, monkeypatch) ->
314
319
  assert captured_request["model"] == "openai/gpt-5.1"
315
320
  assert captured_request["messages"][0] == {
316
321
  "role": "system",
317
- "content": "You are compacting Flowent workspace context.",
322
+ "content": "You are performing a context checkpoint compaction for Flowent.",
318
323
  }
319
324
  assert "AGENTS.md instructions" not in captured_request["messages"][-1]["content"]
320
325
  assert "<environment_context>" in captured_request["messages"][-1]["content"]
321
326
  assert captured_request["messages"][-1]["role"] == "user"
327
+ assert (
328
+ "CONTEXT CHECKPOINT COMPACTION" in captured_request["messages"][-1]["content"]
329
+ )
322
330
  assert "Draft a launch checklist." in captured_request["messages"][-1]["content"]
323
331
  assert "Use provider setup first." in captured_request["messages"][-1]["content"]
324
332
 
@@ -389,27 +397,46 @@ def test_workspace_response_uses_compacted_context_after_compact(
389
397
  }
390
398
  assert project_context_message(captured_requests[1]) is None
391
399
  assert environment_context_message(captured_requests[1])["role"] == "user"
392
- assert response_messages[-3:] == [
393
- {"role": "user", "content": "Context compacted"},
394
- {"role": "assistant", "content": "Keep the provider setup decision."},
395
- {"role": "user", "content": "Continue from there."},
400
+ compacted_messages = [
401
+ message
402
+ for message in response_messages
403
+ if str(message["content"]).startswith(
404
+ "Another language model started working on this Flowent workspace session"
405
+ )
396
406
  ]
407
+ assert len(compacted_messages) == 1
408
+ assert "Keep the provider setup decision." in compacted_messages[0]["content"]
409
+ assert response_messages[-1] == {
410
+ "role": "user",
411
+ "content": "Continue from there.",
412
+ }
413
+ assert {"role": "user", "content": "Context compacted"} not in response_messages
397
414
 
398
415
 
399
- def test_workspace_response_includes_project_and_environment_context(
416
+ def test_workspace_response_auto_compacts_before_next_message(
400
417
  tmp_path, monkeypatch
401
418
  ) -> None:
402
419
  monkeypatch.chdir(tmp_path)
403
420
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
404
- (tmp_path / ".git").mkdir()
405
- (tmp_path / "AGENTS.md").write_text("Use concise replies.")
406
- captured_request: dict[str, object] = {}
421
+ monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
422
+ captured_requests: list[dict[str, object]] = []
407
423
 
408
424
  async def fake_completion(**request: object) -> object:
409
- captured_request.update(request)
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
+ }
410
437
 
411
438
  async def chunks() -> object:
412
- yield {"choices": [{"delta": {"content": "Done."}}]}
439
+ yield {"choices": [{"delta": {"content": "Continuing."}}]}
413
440
 
414
441
  return chunks()
415
442
 
@@ -417,45 +444,84 @@ def test_workspace_response_includes_project_and_environment_context(
417
444
  create_app(serve_frontend=False, chat_completion=fake_completion)
418
445
  )
419
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
+ )
420
464
 
421
- response = client.post("/api/workspace/respond", json={"content": "Hello."})
465
+ response = client.post(
466
+ "/api/workspace/respond",
467
+ json={"content": "Continue from there."},
468
+ )
422
469
 
423
470
  assert response.status_code == 200
424
- assert captured_request["messages"][0] == {
425
- "role": "system",
426
- "content": FLOWENT_AGENT_SYSTEM_PROMPT,
427
- }
428
- project_message = project_context_message(captured_request)
429
- assert project_message == {
430
- "role": "user",
431
- "content": (
432
- f"# AGENTS.md instructions for {tmp_path}\n\n"
433
- "<INSTRUCTIONS>\nUse concise replies.\n</INSTRUCTIONS>"
434
- ),
435
- }
436
- environment_message = environment_context_message(captured_request)
437
- assert environment_message["role"] == "user"
438
- assert f"<cwd>{tmp_path}</cwd>" in environment_message["content"]
439
- assert "<filesystem>workspace-write</filesystem>" in environment_message["content"]
440
- assert "<network>enabled</network>" in environment_message["content"]
441
- assert "<tool>read_file</tool>" in environment_message["content"]
442
- assert captured_request["messages"][-1] == {
443
- "role": "user",
444
- "content": "Hello.",
445
- }
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
+ ]
446
496
 
447
497
 
448
- def test_workspace_response_uses_selected_reasoning_effort(
498
+ def test_workspace_response_auto_compacts_after_tool_result(
449
499
  tmp_path, monkeypatch
450
500
  ) -> None:
451
501
  monkeypatch.chdir(tmp_path)
452
502
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
453
- captured_request: dict[str, object] = {}
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]] = []
454
506
 
455
507
  async def fake_completion(**request: object) -> object:
456
- captured_request.update(request)
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
+ }
457
520
 
458
521
  async def chunks() -> object:
522
+ if len(captured_requests) == 1:
523
+ yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
524
+ return
459
525
  yield {"choices": [{"delta": {"content": "Done."}}]}
460
526
 
461
527
  return chunks()
@@ -463,27 +529,59 @@ def test_workspace_response_uses_selected_reasoning_effort(
463
529
  client = TestClient(
464
530
  create_app(serve_frontend=False, chat_completion=fake_completion)
465
531
  )
466
- configure_provider(client, reasoning_effort="xhigh")
532
+ configure_provider(client)
467
533
 
468
- response = client.post("/api/workspace/respond", json={"content": "Hello."})
534
+ response = client.post(
535
+ "/api/workspace/respond",
536
+ json={"content": "Read the launch notes."},
537
+ )
469
538
 
470
539
  assert response.status_code == 200
471
- assert captured_request["reasoning_effort"] == "xhigh"
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
+ ]
472
570
 
473
571
 
474
- def test_workspace_response_prefers_agents_override(tmp_path, monkeypatch) -> None:
572
+ def test_workspace_auto_compact_failure_keeps_existing_checkpoint(
573
+ tmp_path, monkeypatch
574
+ ) -> None:
475
575
  monkeypatch.chdir(tmp_path)
476
576
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
477
- (tmp_path / ".git").mkdir()
478
- (tmp_path / "AGENTS.md").write_text("Versioned instructions.")
479
- (tmp_path / "AGENTS.override.md").write_text("Local override instructions.")
480
- captured_request: dict[str, object] = {}
577
+ monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
481
578
 
482
579
  async def fake_completion(**request: object) -> object:
483
- captured_request.update(request)
580
+ if not request.get("stream"):
581
+ raise RuntimeError("summary failed")
484
582
 
485
583
  async def chunks() -> object:
486
- yield {"choices": [{"delta": {"content": "Done."}}]}
584
+ yield {"choices": [{"delta": {"content": "Should not run."}}]}
487
585
 
488
586
  return chunks()
489
587
 
@@ -491,34 +589,60 @@ def test_workspace_response_prefers_agents_override(tmp_path, monkeypatch) -> No
491
589
  create_app(serve_frontend=False, chat_completion=fake_completion)
492
590
  )
493
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
+ )
494
604
 
495
- response = client.post("/api/workspace/respond", json={"content": "Hello."})
605
+ response = client.post(
606
+ "/api/workspace/respond",
607
+ json={"content": "Continue from there."},
608
+ )
496
609
 
497
610
  assert response.status_code == 200
498
- project_message = project_context_message(captured_request)
499
- assert project_message is not None
500
- assert "Local override instructions." in project_message["content"]
501
- assert "Versioned instructions." not in project_message["content"]
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
+ ]
502
620
 
503
621
 
504
- def test_workspace_response_merges_project_instructions_from_root_to_cwd(
622
+ def test_workspace_response_uses_auto_compaction_checkpoint_after_restart(
505
623
  tmp_path, monkeypatch
506
624
  ) -> None:
507
- repo = tmp_path / "repo"
508
- nested = repo / "packages" / "agent"
509
- nested.mkdir(parents=True)
510
- (repo / ".git").mkdir()
511
- (repo / "AGENTS.md").write_text("Root instructions.")
512
- (nested / "AGENTS.md").write_text("Nested instructions.")
513
- monkeypatch.chdir(nested)
625
+ monkeypatch.chdir(tmp_path)
514
626
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
515
- captured_request: dict[str, object] = {}
627
+ monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
628
+ captured_requests: list[dict[str, object]] = []
516
629
 
517
630
  async def fake_completion(**request: object) -> object:
518
- captured_request.update(request)
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
+ }
519
643
 
520
644
  async def chunks() -> object:
521
- yield {"choices": [{"delta": {"content": "Done."}}]}
645
+ yield {"choices": [{"delta": {"content": "Continuing."}}]}
522
646
 
523
647
  return chunks()
524
648
 
@@ -526,29 +650,62 @@ def test_workspace_response_merges_project_instructions_from_root_to_cwd(
526
650
  create_app(serve_frontend=False, chat_completion=fake_completion)
527
651
  )
528
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
+ )
529
665
 
530
- response = client.post("/api/workspace/respond", json={"content": "Hello."})
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
+ )
531
678
 
532
- assert response.status_code == 200
533
- project_message = project_context_message(captured_request)
534
- assert project_message is not None
535
- assert project_message["content"].index("Root instructions.") < project_message[
536
- "content"
537
- ].index("Nested instructions.")
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
+ }
538
696
 
539
697
 
540
- def test_workspace_response_uses_updated_project_instructions(
698
+ def test_workspace_response_includes_project_and_environment_context(
541
699
  tmp_path, monkeypatch
542
700
  ) -> None:
543
701
  monkeypatch.chdir(tmp_path)
544
702
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
545
703
  (tmp_path / ".git").mkdir()
546
- agents_file = tmp_path / "AGENTS.md"
547
- agents_file.write_text("Old instructions.")
548
- captured_requests: list[dict[str, object]] = []
704
+ (tmp_path / "AGENTS.md").write_text("Use concise replies.")
705
+ captured_request: dict[str, object] = {}
549
706
 
550
707
  async def fake_completion(**request: object) -> object:
551
- captured_requests.append(request)
708
+ captured_request.update(request)
552
709
 
553
710
  async def chunks() -> object:
554
711
  yield {"choices": [{"delta": {"content": "Done."}}]}
@@ -560,28 +717,49 @@ def test_workspace_response_uses_updated_project_instructions(
560
717
  )
561
718
  configure_provider(client)
562
719
 
563
- first_response = client.post("/api/workspace/respond", json={"content": "First."})
564
- agents_file.write_text("Updated instructions.")
565
- second_response = client.post("/api/workspace/respond", json={"content": "Second."})
720
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
566
721
 
567
- assert first_response.status_code == 200
568
- assert second_response.status_code == 200
569
- first_project_message = project_context_message(captured_requests[0])
570
- second_project_message = project_context_message(captured_requests[1])
571
- assert first_project_message is not None
572
- assert second_project_message is not None
573
- assert "Old instructions." in first_project_message["content"]
574
- assert "Updated instructions." in second_project_message["content"]
575
- assert "Old instructions." not in second_project_message["content"]
722
+ assert response.status_code == 200
723
+ assert captured_request["messages"][0] == {
724
+ "role": "system",
725
+ "content": FLOWENT_AGENT_SYSTEM_PROMPT,
726
+ }
727
+ project_message = project_context_message(captured_request)
728
+ assert project_message == {
729
+ "role": "user",
730
+ "content": (
731
+ f"# AGENTS.md instructions for {tmp_path}\n\n"
732
+ "<INSTRUCTIONS>\nUse concise replies.\n</INSTRUCTIONS>"
733
+ ),
734
+ }
735
+ environment_message = environment_context_message(captured_request)
736
+ assert environment_message["role"] == "user"
737
+ assert f"<cwd>{tmp_path}</cwd>" in environment_message["content"]
738
+ assert "<filesystem>workspace-write</filesystem>" in environment_message["content"]
739
+ assert "<network>enabled</network>" in environment_message["content"]
740
+ assert "<tool>read_file</tool>" in environment_message["content"]
741
+ assert captured_request["messages"][-1] == {
742
+ "role": "user",
743
+ "content": "Hello.",
744
+ }
576
745
 
577
746
 
578
- def test_workspace_context_is_not_persisted_in_state(tmp_path, monkeypatch) -> None:
579
- monkeypatch.chdir(tmp_path)
580
- monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
581
- (tmp_path / ".git").mkdir()
582
- (tmp_path / "AGENTS.md").write_text("Hidden instructions.")
747
+ def test_workspace_response_uses_flowent_workdir(tmp_path, monkeypatch) -> None:
748
+ launch_dir = tmp_path / "launch"
749
+ workdir = tmp_path / "workspace"
750
+ data_dir = tmp_path / "data"
751
+ launch_dir.mkdir()
752
+ workdir.mkdir()
753
+ monkeypatch.chdir(launch_dir)
754
+ monkeypatch.setenv("FLOWENT_WORKDIR", str(workdir))
755
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
756
+ (workdir / ".git").mkdir()
757
+ (workdir / "AGENTS.md").write_text("Use workspace instructions.")
758
+ captured_request: dict[str, object] = {}
583
759
 
584
760
  async def fake_completion(**request: object) -> object:
761
+ captured_request.update(request)
762
+
585
763
  async def chunks() -> object:
586
764
  yield {"choices": [{"delta": {"content": "Done."}}]}
587
765
 
@@ -593,13 +771,244 @@ def test_workspace_context_is_not_persisted_in_state(tmp_path, monkeypatch) -> N
593
771
  configure_provider(client)
594
772
 
595
773
  response = client.post("/api/workspace/respond", json={"content": "Hello."})
596
- state = client.get("/api/state").json()
597
774
 
598
775
  assert response.status_code == 200
599
- persisted_content = "\n".join(message["content"] for message in state["messages"])
600
- assert "Hidden instructions." not in persisted_content
601
- assert "<environment_context>" not in persisted_content
602
-
776
+ project_message = project_context_message(captured_request)
777
+ assert project_message == {
778
+ "role": "user",
779
+ "content": (
780
+ f"# AGENTS.md instructions for {workdir}\n\n"
781
+ "<INSTRUCTIONS>\nUse workspace instructions.\n</INSTRUCTIONS>"
782
+ ),
783
+ }
784
+ environment_message = environment_context_message(captured_request)
785
+ assert f"<cwd>{workdir}</cwd>" in environment_message["content"]
786
+
787
+
788
+ def test_create_app_workdir_overrides_flowent_workdir(tmp_path, monkeypatch) -> None:
789
+ env_workdir = tmp_path / "env-workspace"
790
+ app_workdir = tmp_path / "app-workspace"
791
+ data_dir = tmp_path / "data"
792
+ env_workdir.mkdir()
793
+ app_workdir.mkdir()
794
+ monkeypatch.setenv("FLOWENT_WORKDIR", str(env_workdir))
795
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
796
+ (env_workdir / ".git").mkdir()
797
+ (app_workdir / ".git").mkdir()
798
+ (env_workdir / "AGENTS.md").write_text("Use env instructions.")
799
+ (app_workdir / "AGENTS.md").write_text("Use app instructions.")
800
+ captured_request: dict[str, object] = {}
801
+
802
+ async def fake_completion(**request: object) -> object:
803
+ captured_request.update(request)
804
+
805
+ async def chunks() -> object:
806
+ yield {"choices": [{"delta": {"content": "Done."}}]}
807
+
808
+ return chunks()
809
+
810
+ client = TestClient(
811
+ create_app(
812
+ serve_frontend=False,
813
+ chat_completion=fake_completion,
814
+ workdir=app_workdir,
815
+ )
816
+ )
817
+ configure_provider(client)
818
+
819
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
820
+
821
+ assert response.status_code == 200
822
+ project_message = project_context_message(captured_request)
823
+ assert project_message is not None
824
+ assert "Use app instructions." in project_message["content"]
825
+ assert "Use env instructions." not in project_message["content"]
826
+ environment_message = environment_context_message(captured_request)
827
+ assert f"<cwd>{app_workdir}</cwd>" in environment_message["content"]
828
+
829
+
830
+ def test_workspace_workdir_does_not_change_data_directory(
831
+ tmp_path, monkeypatch
832
+ ) -> None:
833
+ workdir = tmp_path / "workspace"
834
+ data_dir = tmp_path / "data"
835
+ workdir.mkdir()
836
+ monkeypatch.setenv("FLOWENT_WORKDIR", str(workdir))
837
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
838
+
839
+ client = TestClient(create_app(serve_frontend=False))
840
+ response = client.post(
841
+ "/api/providers",
842
+ json={
843
+ "api_key": "sk-local",
844
+ "base_url": "",
845
+ "id": "provider-openai",
846
+ "models": ["gpt-5.1"],
847
+ "name": "OpenAI",
848
+ "type": "openai",
849
+ },
850
+ )
851
+
852
+ assert response.status_code == 200
853
+ assert (data_dir / "flowent.db").is_file()
854
+ assert not (workdir / "flowent.db").exists()
855
+
856
+
857
+ def test_workspace_response_uses_selected_reasoning_effort(
858
+ tmp_path, monkeypatch
859
+ ) -> None:
860
+ monkeypatch.chdir(tmp_path)
861
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
862
+ captured_request: dict[str, object] = {}
863
+
864
+ async def fake_completion(**request: object) -> object:
865
+ captured_request.update(request)
866
+
867
+ async def chunks() -> object:
868
+ yield {"choices": [{"delta": {"content": "Done."}}]}
869
+
870
+ return chunks()
871
+
872
+ client = TestClient(
873
+ create_app(serve_frontend=False, chat_completion=fake_completion)
874
+ )
875
+ configure_provider(client, reasoning_effort="xhigh")
876
+
877
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
878
+
879
+ assert response.status_code == 200
880
+ assert captured_request["reasoning_effort"] == "xhigh"
881
+
882
+
883
+ def test_workspace_response_prefers_agents_override(tmp_path, monkeypatch) -> None:
884
+ monkeypatch.chdir(tmp_path)
885
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
886
+ (tmp_path / ".git").mkdir()
887
+ (tmp_path / "AGENTS.md").write_text("Versioned instructions.")
888
+ (tmp_path / "AGENTS.override.md").write_text("Local override instructions.")
889
+ captured_request: dict[str, object] = {}
890
+
891
+ async def fake_completion(**request: object) -> object:
892
+ captured_request.update(request)
893
+
894
+ async def chunks() -> object:
895
+ yield {"choices": [{"delta": {"content": "Done."}}]}
896
+
897
+ return chunks()
898
+
899
+ client = TestClient(
900
+ create_app(serve_frontend=False, chat_completion=fake_completion)
901
+ )
902
+ configure_provider(client)
903
+
904
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
905
+
906
+ assert response.status_code == 200
907
+ project_message = project_context_message(captured_request)
908
+ assert project_message is not None
909
+ assert "Local override instructions." in project_message["content"]
910
+ assert "Versioned instructions." not in project_message["content"]
911
+
912
+
913
+ def test_workspace_response_merges_project_instructions_from_root_to_cwd(
914
+ tmp_path, monkeypatch
915
+ ) -> None:
916
+ repo = tmp_path / "repo"
917
+ nested = repo / "packages" / "agent"
918
+ nested.mkdir(parents=True)
919
+ (repo / ".git").mkdir()
920
+ (repo / "AGENTS.md").write_text("Root instructions.")
921
+ (nested / "AGENTS.md").write_text("Nested instructions.")
922
+ monkeypatch.chdir(nested)
923
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
924
+ captured_request: dict[str, object] = {}
925
+
926
+ async def fake_completion(**request: object) -> object:
927
+ captured_request.update(request)
928
+
929
+ async def chunks() -> object:
930
+ yield {"choices": [{"delta": {"content": "Done."}}]}
931
+
932
+ return chunks()
933
+
934
+ client = TestClient(
935
+ create_app(serve_frontend=False, chat_completion=fake_completion)
936
+ )
937
+ configure_provider(client)
938
+
939
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
940
+
941
+ assert response.status_code == 200
942
+ project_message = project_context_message(captured_request)
943
+ assert project_message is not None
944
+ assert project_message["content"].index("Root instructions.") < project_message[
945
+ "content"
946
+ ].index("Nested instructions.")
947
+
948
+
949
+ def test_workspace_response_uses_updated_project_instructions(
950
+ tmp_path, monkeypatch
951
+ ) -> None:
952
+ monkeypatch.chdir(tmp_path)
953
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
954
+ (tmp_path / ".git").mkdir()
955
+ agents_file = tmp_path / "AGENTS.md"
956
+ agents_file.write_text("Old instructions.")
957
+ captured_requests: list[dict[str, object]] = []
958
+
959
+ async def fake_completion(**request: object) -> object:
960
+ captured_requests.append(request)
961
+
962
+ async def chunks() -> object:
963
+ yield {"choices": [{"delta": {"content": "Done."}}]}
964
+
965
+ return chunks()
966
+
967
+ client = TestClient(
968
+ create_app(serve_frontend=False, chat_completion=fake_completion)
969
+ )
970
+ configure_provider(client)
971
+
972
+ first_response = client.post("/api/workspace/respond", json={"content": "First."})
973
+ agents_file.write_text("Updated instructions.")
974
+ second_response = client.post("/api/workspace/respond", json={"content": "Second."})
975
+
976
+ assert first_response.status_code == 200
977
+ assert second_response.status_code == 200
978
+ first_project_message = project_context_message(captured_requests[0])
979
+ second_project_message = project_context_message(captured_requests[1])
980
+ assert first_project_message is not None
981
+ assert second_project_message is not None
982
+ assert "Old instructions." in first_project_message["content"]
983
+ assert "Updated instructions." in second_project_message["content"]
984
+ assert "Old instructions." not in second_project_message["content"]
985
+
986
+
987
+ def test_workspace_context_is_not_persisted_in_state(tmp_path, monkeypatch) -> None:
988
+ monkeypatch.chdir(tmp_path)
989
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
990
+ (tmp_path / ".git").mkdir()
991
+ (tmp_path / "AGENTS.md").write_text("Hidden instructions.")
992
+
993
+ async def fake_completion(**request: object) -> object:
994
+ async def chunks() -> object:
995
+ yield {"choices": [{"delta": {"content": "Done."}}]}
996
+
997
+ return chunks()
998
+
999
+ client = TestClient(
1000
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1001
+ )
1002
+ configure_provider(client)
1003
+
1004
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
1005
+ state = client.get("/api/state").json()
1006
+
1007
+ assert response.status_code == 200
1008
+ persisted_content = "\n".join(message["content"] for message in state["messages"])
1009
+ assert "Hidden instructions." not in persisted_content
1010
+ assert "<environment_context>" not in persisted_content
1011
+
603
1012
 
604
1013
  def test_workspace_clear_keeps_runtime_context_available(tmp_path, monkeypatch) -> None:
605
1014
  monkeypatch.chdir(tmp_path)
@@ -687,10 +1096,15 @@ def test_workspace_compacted_response_includes_latest_runtime_context(
687
1096
  assert project_message is not None
688
1097
  assert "Instructions after compact." in project_message["content"]
689
1098
  assert environment_context_message(captured_requests[1])["role"] == "user"
690
- assert {
691
- "role": "assistant",
692
- "content": "Keep compacted state.",
693
- } in response_messages
1099
+ compacted_messages = [
1100
+ message
1101
+ for message in response_messages
1102
+ if str(message["content"]).startswith(
1103
+ "Another language model started working on this Flowent workspace session"
1104
+ )
1105
+ ]
1106
+ assert len(compacted_messages) == 1
1107
+ assert "Keep compacted state." in compacted_messages[0]["content"]
694
1108
 
695
1109
 
696
1110
  def test_project_instructions_are_truncated_to_size_limit(
@@ -845,19 +1259,28 @@ def test_workspace_persists_failed_draft_when_stream_errors(
845
1259
  assert assistant["author"] == "assistant"
846
1260
  assert assistant["content"] == "Partial answer."
847
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
+ }
848
1274
 
849
1275
 
850
- def test_workspace_marks_draft_complete_when_stream_finishes(
1276
+ def test_workspace_persists_error_block_when_model_fails_before_output(
851
1277
  tmp_path, monkeypatch
852
1278
  ) -> None:
853
1279
  monkeypatch.chdir(tmp_path)
854
1280
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
855
1281
 
856
1282
  async def fake_completion(**request: object) -> object:
857
- async def chunks() -> object:
858
- yield {"choices": [{"delta": {"content": "Done."}}]}
859
-
860
- return chunks()
1283
+ raise RuntimeError("provider unavailable")
861
1284
 
862
1285
  client = TestClient(
863
1286
  create_app(serve_frontend=False, chat_completion=fake_completion)
@@ -867,61 +1290,309 @@ def test_workspace_marks_draft_complete_when_stream_finishes(
867
1290
  response = client.post("/api/workspace/respond", json={"content": "Hello."})
868
1291
 
869
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
+ }
870
1306
  state = client.get("/api/state").json()
871
1307
  assistant = state["messages"][-1]
872
1308
  assert assistant["author"] == "assistant"
873
- assert assistant["content"] == "Done."
874
- 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
+ ]
875
1329
 
876
1330
 
877
- @pytest.mark.anyio
878
- async def test_workspace_run_continues_without_stream_consumer(
1331
+ def test_workspace_treats_empty_model_result_as_failed_error_block(
879
1332
  tmp_path, monkeypatch
880
1333
  ) -> None:
881
1334
  monkeypatch.chdir(tmp_path)
882
1335
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
883
- first_chunk_sent = asyncio.Event()
884
- finish_response = asyncio.Event()
885
1336
 
886
1337
  async def fake_completion(**request: object) -> object:
887
1338
  async def chunks() -> object:
888
- yield {"choices": [{"delta": {"content": "Partial "}}]}
889
- first_chunk_sent.set()
890
- await asyncio.wait_for(finish_response.wait(), timeout=2)
891
- yield {"choices": [{"delta": {"content": "answer."}}]}
1339
+ if False:
1340
+ yield {}
892
1341
 
893
1342
  return chunks()
894
1343
 
895
- app = create_app(serve_frontend=False, chat_completion=fake_completion)
896
- async with httpx.AsyncClient(
897
- transport=httpx.ASGITransport(app=app), base_url="http://testserver"
898
- ) as client:
899
- await configure_provider_async(client)
900
- response = await client.post(
901
- "/api/workspace/runs",
902
- json={"content": "Keep working."},
903
- )
904
- assert response.status_code == 200
905
- await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
906
- finish_response.set()
907
-
908
- for _ in range(20):
909
- state = (await client.get("/api/state")).json()
910
- assistant = state["messages"][-1]
911
- if (
912
- assistant["author"] == "assistant"
913
- and assistant.get("status", "completed") == "completed"
914
- ):
915
- break
916
- await asyncio.sleep(0.05)
917
- else:
918
- raise AssertionError("Workspace run did not complete.")
919
-
920
- assert assistant["content"] == "Partial answer."
1344
+ client = TestClient(
1345
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1346
+ )
1347
+ configure_provider(client)
921
1348
 
1349
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
922
1350
 
923
- @pytest.mark.anyio
924
- async def test_workspace_state_exposes_active_run_for_reconnect(
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."},
1574
+ )
1575
+ assert response.status_code == 200
1576
+ await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
1577
+ finish_response.set()
1578
+
1579
+ for _ in range(20):
1580
+ state = (await client.get("/api/state")).json()
1581
+ assistant = state["messages"][-1]
1582
+ if (
1583
+ assistant["author"] == "assistant"
1584
+ and assistant.get("status", "completed") == "completed"
1585
+ ):
1586
+ break
1587
+ await asyncio.sleep(0.05)
1588
+ else:
1589
+ raise AssertionError("Workspace run did not complete.")
1590
+
1591
+ assert assistant["content"] == "Partial answer."
1592
+
1593
+
1594
+ @pytest.mark.anyio
1595
+ async def test_workspace_state_exposes_active_run_for_reconnect(
925
1596
  tmp_path, monkeypatch
926
1597
  ) -> None:
927
1598
  monkeypatch.chdir(tmp_path)
@@ -963,6 +1634,215 @@ async def test_workspace_state_exposes_active_run_for_reconnect(
963
1634
  assert {"event": "delta", "data": '{"content": "second."}'} in events
964
1635
 
965
1636
 
1637
+ @pytest.mark.anyio
1638
+ async def test_workspace_persists_automatic_review_result_during_stream(
1639
+ tmp_path, monkeypatch
1640
+ ) -> None:
1641
+ monkeypatch.chdir(tmp_path)
1642
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1643
+ work_dir = tmp_path / "work"
1644
+ outside_dir = tmp_path / "outside"
1645
+ work_dir.mkdir()
1646
+ outside_dir.mkdir()
1647
+ target = outside_dir / "notes.txt"
1648
+ target.write_text("alpha\n")
1649
+ patch = f"""*** Begin Patch
1650
+ *** Update File: {target}
1651
+ @@
1652
+ -alpha
1653
+ +beta
1654
+ *** End Patch"""
1655
+
1656
+ review_started = asyncio.Event()
1657
+ finish_review = asyncio.Event()
1658
+ review_payload: dict[str, object] = {}
1659
+
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
+
1684
+ async def chunks() -> object:
1685
+ if request["messages"][-1]["role"] == "user":
1686
+ yield tool_call_chunk(
1687
+ "apply_patch",
1688
+ json.dumps({"patch": patch}),
1689
+ )
1690
+ return
1691
+ yield {"choices": [{"delta": {"content": "Done."}}]}
1692
+
1693
+ return chunks()
1694
+
1695
+ app = create_app(
1696
+ workdir=work_dir,
1697
+ serve_frontend=False,
1698
+ chat_completion=fake_completion,
1699
+ )
1700
+ async with httpx.AsyncClient(
1701
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
1702
+ ) as client:
1703
+ await configure_provider_async(client)
1704
+ response = await client.post(
1705
+ "/api/workspace/runs",
1706
+ json={"content": "Edit notes."},
1707
+ )
1708
+ run_id = response.json()["run_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']}"
1714
+ )
1715
+
1716
+ assistant = state["messages"][-1]
1717
+ assert state["active_run_id"] == run_id
1718
+ assert assistant["tools"][0]["name"] == "apply_patch"
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
+ )
1844
+
1845
+
966
1846
  @pytest.mark.anyio
967
1847
  async def test_workspace_clear_removes_running_run_draft(tmp_path, monkeypatch) -> None:
968
1848
  monkeypatch.chdir(tmp_path)
@@ -999,3 +1879,244 @@ async def test_workspace_clear_removes_running_run_draft(tmp_path, monkeypatch)
999
1879
  assert clear_response.status_code == 200
1000
1880
  assert state["messages"] == []
1001
1881
  assert state["active_run_id"] is None
1882
+
1883
+
1884
+ def test_workspace_response_uses_compaction_checkpoint_after_restart(
1885
+ tmp_path, monkeypatch
1886
+ ) -> None:
1887
+ monkeypatch.chdir(tmp_path)
1888
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1889
+ captured_requests: list[dict[str, object]] = []
1890
+
1891
+ async def fake_completion(**request: object) -> object:
1892
+ captured_requests.append(request)
1893
+ if len(captured_requests) == 1:
1894
+ return {
1895
+ "choices": [
1896
+ {
1897
+ "message": {
1898
+ "content": "Checkpoint summary survives restarts.",
1899
+ "role": "assistant",
1900
+ }
1901
+ }
1902
+ ]
1903
+ }
1904
+
1905
+ async def chunks() -> object:
1906
+ yield {"choices": [{"delta": {"content": "Continuing."}}]}
1907
+
1908
+ return chunks()
1909
+
1910
+ client = TestClient(
1911
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1912
+ )
1913
+ configure_provider(client)
1914
+ client.put(
1915
+ "/api/workspace/messages",
1916
+ json={
1917
+ "messages": [
1918
+ {"author": "user", "content": "Original request.", "id": "message-1"},
1919
+ {
1920
+ "author": "assistant",
1921
+ "content": "Original reply.",
1922
+ "id": "message-2",
1923
+ },
1924
+ ]
1925
+ },
1926
+ )
1927
+
1928
+ compact_response = client.post("/api/workspace/compact")
1929
+ restarted_client = TestClient(
1930
+ create_app(serve_frontend=False, chat_completion=fake_completion)
1931
+ )
1932
+ response = restarted_client.post(
1933
+ "/api/workspace/respond",
1934
+ json={"content": "Continue after restart."},
1935
+ )
1936
+
1937
+ assert compact_response.status_code == 200
1938
+ assert response.status_code == 200
1939
+ response_messages = captured_requests[1]["messages"]
1940
+ compacted_messages = [
1941
+ message
1942
+ for message in response_messages
1943
+ if str(message["content"]).startswith(
1944
+ "Another language model started working on this Flowent workspace session"
1945
+ )
1946
+ ]
1947
+ assert len(compacted_messages) == 1
1948
+ assert "Checkpoint summary survives restarts." in compacted_messages[0]["content"]
1949
+ assert {"role": "user", "content": "Context compacted"} not in response_messages
1950
+ assert response_messages[-1] == {
1951
+ "role": "user",
1952
+ "content": "Continue after restart.",
1953
+ }
1954
+
1955
+
1956
+ def test_workspace_compact_is_unavailable_while_response_is_running(
1957
+ tmp_path, monkeypatch
1958
+ ) -> None:
1959
+ monkeypatch.chdir(tmp_path)
1960
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
1961
+ continue_stream = asyncio.Event()
1962
+
1963
+ async def fake_completion(**request: object) -> object:
1964
+ async def chunks() -> object:
1965
+ yield {"choices": [{"delta": {"content": "Partial."}}]}
1966
+ await asyncio.wait_for(continue_stream.wait(), timeout=2)
1967
+ yield {"choices": [{"delta": {"content": " Done."}}]}
1968
+
1969
+ return chunks()
1970
+
1971
+ async def run_test() -> None:
1972
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
1973
+ async with httpx.AsyncClient(
1974
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
1975
+ ) as client:
1976
+ await configure_provider_async(client)
1977
+ response_task = asyncio.create_task(
1978
+ client.post("/api/workspace/respond", json={"content": "Start."})
1979
+ )
1980
+ await asyncio.sleep(0)
1981
+ compact_response = await client.post("/api/workspace/compact")
1982
+ continue_stream.set()
1983
+ response = await response_task
1984
+
1985
+ assert compact_response.status_code == 409
1986
+ assert compact_response.json()["detail"] == (
1987
+ "Compact is unavailable while Flowent is responding."
1988
+ )
1989
+ assert response.status_code == 200
1990
+
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