flowent 0.1.1 → 0.1.3

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 (53) 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__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/agent.py +19 -9
  21. package/backend/src/flowent/main.py +356 -62
  22. package/backend/src/flowent/permissions.py +259 -0
  23. package/backend/src/flowent/sandbox.py +83 -6
  24. package/backend/src/flowent/static/assets/index-DjF2KBwE.js +81 -0
  25. package/backend/src/flowent/static/assets/index-P-bBpJG8.css +2 -0
  26. package/backend/src/flowent/static/index.html +2 -2
  27. package/backend/src/flowent/storage.py +115 -3
  28. package/backend/src/flowent/tools.py +96 -2
  29. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/test_agent_tools.py +103 -2
  42. package/backend/tests/test_permissions.py +443 -0
  43. package/backend/tests/test_workspace_chat.py +396 -1
  44. package/backend/uv.lock +1 -1
  45. package/bin/flowent.mjs +1 -1
  46. package/dist/frontend/assets/index-DjF2KBwE.js +81 -0
  47. package/dist/frontend/assets/index-P-bBpJG8.css +2 -0
  48. package/dist/frontend/index.html +2 -2
  49. package/package.json +1 -1
  50. package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
  51. package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
  52. package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
  53. package/dist/frontend/assets/index-C89n9qe2.css +0 -2
@@ -1,11 +1,17 @@
1
+ import asyncio
2
+ import time
3
+
4
+ import httpx
5
+ import pytest
1
6
  from fastapi.testclient import TestClient
2
7
 
3
8
  from flowent.agent import FLOWENT_AGENT_SYSTEM_PROMPT
4
9
  from flowent.main import create_app
10
+ from flowent.sandbox import CommandResult, SandboxRunner
5
11
 
6
12
 
7
13
  def configure_provider(
8
- client: TestClient,
14
+ client,
9
15
  *,
10
16
  base_url: str = "",
11
17
  model: str = "gpt-5.1",
@@ -35,6 +41,37 @@ def configure_provider(
35
41
  )
36
42
 
37
43
 
44
+ async def configure_provider_async(
45
+ client: httpx.AsyncClient,
46
+ *,
47
+ base_url: str = "",
48
+ model: str = "gpt-5.1",
49
+ name: str = "OpenAI",
50
+ provider_id: str = "provider-openai",
51
+ provider_type: str = "openai",
52
+ reasoning_effort: str = "default",
53
+ ) -> None:
54
+ await client.post(
55
+ "/api/providers",
56
+ json={
57
+ "api_key": "sk-local",
58
+ "base_url": base_url,
59
+ "id": provider_id,
60
+ "models": [model],
61
+ "name": name,
62
+ "type": provider_type,
63
+ },
64
+ )
65
+ await client.put(
66
+ "/api/settings",
67
+ json={
68
+ "reasoning_effort": reasoning_effort,
69
+ "selected_model": model,
70
+ "selected_provider_id": provider_id,
71
+ },
72
+ )
73
+
74
+
38
75
  def project_context_message(request: dict[str, object]) -> dict[str, object] | None:
39
76
  for message in request["messages"]:
40
77
  if str(message["content"]).startswith("# AGENTS.md instructions for "):
@@ -63,6 +100,88 @@ def stream_events(content: str) -> list[dict[str, object]]:
63
100
  return events
64
101
 
65
102
 
103
+ def tool_call_chunk(
104
+ name: str,
105
+ arguments: str,
106
+ *,
107
+ call_id: str = "call-1",
108
+ ) -> dict[str, object]:
109
+ return {
110
+ "choices": [
111
+ {
112
+ "delta": {
113
+ "tool_calls": [
114
+ {
115
+ "index": 0,
116
+ "id": call_id,
117
+ "type": "function",
118
+ "function": {
119
+ "arguments": arguments,
120
+ "name": name,
121
+ },
122
+ }
123
+ ]
124
+ }
125
+ }
126
+ ]
127
+ }
128
+
129
+
130
+ @pytest.mark.anyio
131
+ async def test_workspace_long_shell_command_does_not_block_health(
132
+ tmp_path, monkeypatch
133
+ ) -> None:
134
+ monkeypatch.chdir(tmp_path)
135
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
136
+ command_started = asyncio.Event()
137
+ command_can_finish = asyncio.Event()
138
+
139
+ async def fake_run_async(self, command, **kwargs):
140
+ command_started.set()
141
+ await asyncio.wait_for(command_can_finish.wait(), timeout=2)
142
+ return CommandResult(
143
+ command=" ".join(command),
144
+ exit_code=0,
145
+ stderr="",
146
+ stdout="slow command finished",
147
+ )
148
+
149
+ monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
150
+
151
+ captured_requests: list[dict[str, object]] = []
152
+
153
+ async def fake_completion(**request: object) -> object:
154
+ captured_requests.append(request)
155
+
156
+ async def chunks() -> object:
157
+ if len(captured_requests) == 1:
158
+ yield tool_call_chunk("shell_command", '{"command": "slow"}')
159
+ else:
160
+ yield {"choices": [{"delta": {"content": "Done."}}]}
161
+
162
+ return chunks()
163
+
164
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
165
+ async with httpx.AsyncClient(
166
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
167
+ ) as client:
168
+ await configure_provider_async(client)
169
+ response_task = asyncio.create_task(
170
+ client.post("/api/workspace/respond", json={"content": "Run slow."})
171
+ )
172
+ await asyncio.wait_for(command_started.wait(), timeout=2)
173
+ start = time.perf_counter()
174
+ health_response = await client.get("/api/health")
175
+ elapsed = time.perf_counter() - start
176
+ command_can_finish.set()
177
+ response = await response_task
178
+
179
+ assert health_response.status_code == 200
180
+ assert health_response.json() == {"status": "ok"}
181
+ assert elapsed < 0.2
182
+ assert response.status_code == 200
183
+
184
+
66
185
  def test_workspace_response_streams_selected_provider_model_and_history(
67
186
  tmp_path, monkeypatch
68
187
  ) -> None:
@@ -604,3 +723,279 @@ def test_project_instructions_are_truncated_to_size_limit(
604
723
  assert project_message is not None
605
724
  assert "1234567890ab" in project_message["content"]
606
725
  assert "cdef" not in project_message["content"]
726
+
727
+
728
+ @pytest.mark.anyio
729
+ async def test_workspace_persists_tool_start_during_stream(
730
+ tmp_path, monkeypatch
731
+ ) -> None:
732
+ monkeypatch.chdir(tmp_path)
733
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
734
+ command_started = asyncio.Event()
735
+ command_can_finish = asyncio.Event()
736
+
737
+ async def fake_run_async(self, command, **kwargs):
738
+ command_started.set()
739
+ await asyncio.wait_for(command_can_finish.wait(), timeout=2)
740
+ return CommandResult(
741
+ command=" ".join(command),
742
+ exit_code=0,
743
+ stderr="",
744
+ stdout="Launch notes",
745
+ )
746
+
747
+ monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
748
+
749
+ async def fake_completion(**request: object) -> object:
750
+ async def chunks() -> object:
751
+ if request["messages"][-1]["role"] == "user":
752
+ yield tool_call_chunk("shell_command", '{"command": "slow"}')
753
+ else:
754
+ yield {"choices": [{"delta": {"content": "Done."}}]}
755
+
756
+ return chunks()
757
+
758
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
759
+ async with httpx.AsyncClient(
760
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
761
+ ) as client:
762
+ await configure_provider_async(client)
763
+ response_task = asyncio.create_task(
764
+ client.post("/api/workspace/respond", json={"content": "Read notes."})
765
+ )
766
+ await asyncio.wait_for(command_started.wait(), timeout=2)
767
+ state = (await client.get("/api/state")).json()
768
+ command_can_finish.set()
769
+ response = await response_task
770
+
771
+ assistant = state["messages"][-1]
772
+ assert response.status_code == 200
773
+ assert assistant["author"] == "assistant"
774
+ assert assistant["status"] == "running"
775
+ assert assistant["tools"][0]["name"] == "shell_command"
776
+ assert assistant["tools"][0]["status"] == "running"
777
+
778
+
779
+ @pytest.mark.anyio
780
+ async def test_workspace_persists_tool_result_during_stream(
781
+ tmp_path, monkeypatch
782
+ ) -> None:
783
+ monkeypatch.chdir(tmp_path)
784
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
785
+ (tmp_path / "notes.txt").write_text("Launch notes")
786
+ second_round_started = asyncio.Event()
787
+ continue_stream = asyncio.Event()
788
+
789
+ async def fake_completion(**request: object) -> object:
790
+ async def chunks() -> object:
791
+ if request["messages"][-1]["role"] == "user":
792
+ yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
793
+ return
794
+ second_round_started.set()
795
+ await asyncio.wait_for(continue_stream.wait(), timeout=2)
796
+ yield {"choices": [{"delta": {"content": "Done."}}]}
797
+
798
+ return chunks()
799
+
800
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
801
+ async with httpx.AsyncClient(
802
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
803
+ ) as client:
804
+ await configure_provider_async(client)
805
+ response_task = asyncio.create_task(
806
+ client.post("/api/workspace/respond", json={"content": "Read notes."})
807
+ )
808
+ await asyncio.wait_for(second_round_started.wait(), timeout=2)
809
+ state = (await client.get("/api/state")).json()
810
+ continue_stream.set()
811
+ response = await response_task
812
+
813
+ assistant = state["messages"][-1]
814
+ assert response.status_code == 200
815
+ assert assistant["status"] == "running"
816
+ assert assistant["tools"][0]["status"] == "success"
817
+ assert assistant["tools"][0]["content"] == "Launch notes"
818
+
819
+
820
+ def test_workspace_persists_failed_draft_when_stream_errors(
821
+ tmp_path, monkeypatch
822
+ ) -> None:
823
+ monkeypatch.chdir(tmp_path)
824
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
825
+
826
+ async def fake_completion(**request: object) -> object:
827
+ async def chunks() -> object:
828
+ yield {"choices": [{"delta": {"content": "Partial answer."}}]}
829
+ raise RuntimeError("provider stopped")
830
+
831
+ return chunks()
832
+
833
+ client = TestClient(
834
+ create_app(serve_frontend=False, chat_completion=fake_completion)
835
+ )
836
+ configure_provider(client)
837
+
838
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
839
+
840
+ assert response.status_code == 200
841
+ events = stream_events(response.text)
842
+ assert events[-1]["event"] == "error"
843
+ state = client.get("/api/state").json()
844
+ assistant = state["messages"][-1]
845
+ assert assistant["author"] == "assistant"
846
+ assert assistant["content"] == "Partial answer."
847
+ assert assistant["status"] == "failed"
848
+
849
+
850
+ def test_workspace_marks_draft_complete_when_stream_finishes(
851
+ tmp_path, monkeypatch
852
+ ) -> None:
853
+ monkeypatch.chdir(tmp_path)
854
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
855
+
856
+ async def fake_completion(**request: object) -> object:
857
+ async def chunks() -> object:
858
+ yield {"choices": [{"delta": {"content": "Done."}}]}
859
+
860
+ return chunks()
861
+
862
+ client = TestClient(
863
+ create_app(serve_frontend=False, chat_completion=fake_completion)
864
+ )
865
+ configure_provider(client)
866
+
867
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
868
+
869
+ assert response.status_code == 200
870
+ state = client.get("/api/state").json()
871
+ assistant = state["messages"][-1]
872
+ assert assistant["author"] == "assistant"
873
+ assert assistant["content"] == "Done."
874
+ assert assistant.get("status", "completed") == "completed"
875
+
876
+
877
+ @pytest.mark.anyio
878
+ async def test_workspace_run_continues_without_stream_consumer(
879
+ tmp_path, monkeypatch
880
+ ) -> None:
881
+ monkeypatch.chdir(tmp_path)
882
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
883
+ first_chunk_sent = asyncio.Event()
884
+ finish_response = asyncio.Event()
885
+
886
+ async def fake_completion(**request: object) -> object:
887
+ 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."}}]}
892
+
893
+ return chunks()
894
+
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."
921
+
922
+
923
+ @pytest.mark.anyio
924
+ async def test_workspace_state_exposes_active_run_for_reconnect(
925
+ tmp_path, monkeypatch
926
+ ) -> None:
927
+ monkeypatch.chdir(tmp_path)
928
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
929
+ first_chunk_sent = asyncio.Event()
930
+ finish_response = asyncio.Event()
931
+
932
+ async def fake_completion(**request: object) -> object:
933
+ async def chunks() -> object:
934
+ yield {"choices": [{"delta": {"content": "First "}}]}
935
+ first_chunk_sent.set()
936
+ await asyncio.wait_for(finish_response.wait(), timeout=2)
937
+ yield {"choices": [{"delta": {"content": "second."}}]}
938
+
939
+ return chunks()
940
+
941
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
942
+ async with httpx.AsyncClient(
943
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
944
+ ) as client:
945
+ await configure_provider_async(client)
946
+ response = await client.post(
947
+ "/api/workspace/runs",
948
+ json={"content": "Continue if I reconnect."},
949
+ )
950
+ run_id = response.json()["run_id"]
951
+ await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
952
+ state = (await client.get("/api/state")).json()
953
+ event_index = state["active_run_event_index"]
954
+ finish_response.set()
955
+ stream_response = await client.get(
956
+ f"/api/workspace/runs/{run_id}/stream?after={event_index}"
957
+ )
958
+
959
+ assert state["active_run_id"] == run_id
960
+ assert event_index > 0
961
+ events = stream_events(stream_response.text)
962
+ assert {"event": "delta", "data": '{"content": "First "}'} not in events
963
+ assert {"event": "delta", "data": '{"content": "second."}'} in events
964
+
965
+
966
+ @pytest.mark.anyio
967
+ async def test_workspace_clear_removes_running_run_draft(tmp_path, monkeypatch) -> None:
968
+ monkeypatch.chdir(tmp_path)
969
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
970
+ first_chunk_sent = asyncio.Event()
971
+ finish_response = asyncio.Event()
972
+
973
+ async def fake_completion(**request: object) -> object:
974
+ async def chunks() -> object:
975
+ yield {"choices": [{"delta": {"content": "Partial"}}]}
976
+ first_chunk_sent.set()
977
+ await finish_response.wait()
978
+
979
+ return chunks()
980
+
981
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
982
+ async with httpx.AsyncClient(
983
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
984
+ ) as client:
985
+ await configure_provider_async(client)
986
+ response = await client.post(
987
+ "/api/workspace/runs",
988
+ json={"content": "Keep working."},
989
+ )
990
+ assert response.status_code == 200
991
+ await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
992
+ clear_response = await client.put(
993
+ "/api/workspace/messages",
994
+ json={"messages": []},
995
+ )
996
+ await asyncio.sleep(0)
997
+ state = (await client.get("/api/state")).json()
998
+
999
+ assert clear_response.status_code == 200
1000
+ assert state["messages"] == []
1001
+ assert state["active_run_id"] is None
package/backend/uv.lock CHANGED
@@ -462,7 +462,7 @@ wheels = [
462
462
 
463
463
  [[package]]
464
464
  name = "flowent"
465
- version = "0.1.1"
465
+ version = "0.1.3"
466
466
  source = { editable = "." }
467
467
  dependencies = [
468
468
  { name = "fastapi", extra = ["standard"] },
package/bin/flowent.mjs CHANGED
@@ -70,7 +70,7 @@ const child = spawn(
70
70
  uvCommand,
71
71
  ["run", "--project", backendProject, "flowent", ...passthroughArgs],
72
72
  {
73
- cwd: packageRoot,
73
+ cwd: process.cwd(),
74
74
  stdio: "inherit",
75
75
  env: {
76
76
  ...process.env,