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.
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/agent.py +19 -9
- package/backend/src/flowent/main.py +356 -62
- package/backend/src/flowent/permissions.py +259 -0
- package/backend/src/flowent/sandbox.py +83 -6
- package/backend/src/flowent/static/assets/index-DjF2KBwE.js +81 -0
- package/backend/src/flowent/static/assets/index-P-bBpJG8.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +115 -3
- package/backend/src/flowent/tools.py +96 -2
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/test_agent_tools.py +103 -2
- package/backend/tests/test_permissions.py +443 -0
- package/backend/tests/test_workspace_chat.py +396 -1
- package/backend/uv.lock +1 -1
- package/bin/flowent.mjs +1 -1
- package/dist/frontend/assets/index-DjF2KBwE.js +81 -0
- package/dist/frontend/assets/index-P-bBpJG8.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
- package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
- package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
- 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
|
|
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