flowent 0.2.0 → 0.2.1
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 +31 -5
- package/backend/src/flowent/agent.py +13 -4
- package/backend/src/flowent/compact.py +35 -14
- package/backend/src/flowent/llm.py +73 -7
- package/backend/src/flowent/main.py +260 -59
- package/backend/src/flowent/static/assets/index-CRSV2xu1.css +2 -0
- package/backend/src/flowent/static/assets/index-DUYj6rgD.js +82 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +135 -3
- package/backend/src/flowent/usage.py +315 -0
- package/backend/uv.lock +971 -3
- package/dist/frontend/assets/index-CRSV2xu1.css +2 -0
- package/dist/frontend/assets/index-DUYj6rgD.js +82 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +24 -3
- 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__/approval.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__/compact.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/static/assets/index-BlaCigkZ.js +0 -82
- package/backend/src/flowent/static/assets/index-CRvbsH4K.css +0 -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_approval.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_patch.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/conftest.py +0 -60
- package/backend/tests/test_agent_tools.py +0 -1124
- package/backend/tests/test_approval.py +0 -283
- package/backend/tests/test_channels.py +0 -360
- package/backend/tests/test_health.py +0 -12
- package/backend/tests/test_llm_providers.py +0 -548
- package/backend/tests/test_logging.py +0 -212
- package/backend/tests/test_mcp.py +0 -788
- package/backend/tests/test_patch.py +0 -112
- package/backend/tests/test_permissions.py +0 -588
- package/backend/tests/test_persistence.py +0 -249
- package/backend/tests/test_skills.py +0 -462
- package/backend/tests/test_startup_requirements.py +0 -144
- package/backend/tests/test_workspace_chat.py +0 -2174
- package/dist/frontend/assets/index-BlaCigkZ.js +0 -82
- package/dist/frontend/assets/index-CRvbsH4K.css +0 -2
|
@@ -1,2174 +0,0 @@
|
|
|
1
|
-
import asyncio
|
|
2
|
-
import json
|
|
3
|
-
import time
|
|
4
|
-
|
|
5
|
-
import httpx
|
|
6
|
-
import pytest
|
|
7
|
-
from fastapi.testclient import TestClient
|
|
8
|
-
|
|
9
|
-
from flowent.agent import FLOWENT_AGENT_SYSTEM_PROMPT
|
|
10
|
-
from flowent.main import create_app
|
|
11
|
-
from flowent.sandbox import CommandResult, SandboxRunner
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def configure_provider(
|
|
15
|
-
client,
|
|
16
|
-
*,
|
|
17
|
-
agent_prompt: str = "",
|
|
18
|
-
base_url: str = "",
|
|
19
|
-
model: str = "gpt-5.1",
|
|
20
|
-
name: str = "OpenAI",
|
|
21
|
-
provider_id: str = "provider-openai",
|
|
22
|
-
provider_type: str = "openai",
|
|
23
|
-
reasoning_effort: str = "default",
|
|
24
|
-
) -> None:
|
|
25
|
-
client.post(
|
|
26
|
-
"/api/providers",
|
|
27
|
-
json={
|
|
28
|
-
"api_key": "sk-local",
|
|
29
|
-
"base_url": base_url,
|
|
30
|
-
"id": provider_id,
|
|
31
|
-
"models": [model],
|
|
32
|
-
"name": name,
|
|
33
|
-
"type": provider_type,
|
|
34
|
-
},
|
|
35
|
-
)
|
|
36
|
-
client.put(
|
|
37
|
-
"/api/settings",
|
|
38
|
-
json={
|
|
39
|
-
"agent_prompt": agent_prompt,
|
|
40
|
-
"reasoning_effort": reasoning_effort,
|
|
41
|
-
"selected_model": model,
|
|
42
|
-
"selected_provider_id": provider_id,
|
|
43
|
-
},
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
async def configure_provider_async(
|
|
48
|
-
client: httpx.AsyncClient,
|
|
49
|
-
*,
|
|
50
|
-
agent_prompt: str = "",
|
|
51
|
-
base_url: str = "",
|
|
52
|
-
model: str = "gpt-5.1",
|
|
53
|
-
name: str = "OpenAI",
|
|
54
|
-
provider_id: str = "provider-openai",
|
|
55
|
-
provider_type: str = "openai",
|
|
56
|
-
reasoning_effort: str = "default",
|
|
57
|
-
) -> None:
|
|
58
|
-
await client.post(
|
|
59
|
-
"/api/providers",
|
|
60
|
-
json={
|
|
61
|
-
"api_key": "sk-local",
|
|
62
|
-
"base_url": base_url,
|
|
63
|
-
"id": provider_id,
|
|
64
|
-
"models": [model],
|
|
65
|
-
"name": name,
|
|
66
|
-
"type": provider_type,
|
|
67
|
-
},
|
|
68
|
-
)
|
|
69
|
-
await client.put(
|
|
70
|
-
"/api/settings",
|
|
71
|
-
json={
|
|
72
|
-
"agent_prompt": agent_prompt,
|
|
73
|
-
"reasoning_effort": reasoning_effort,
|
|
74
|
-
"selected_model": model,
|
|
75
|
-
"selected_provider_id": provider_id,
|
|
76
|
-
},
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def project_context_message(request: dict[str, object]) -> dict[str, object] | None:
|
|
81
|
-
for message in request["messages"]:
|
|
82
|
-
if str(message["content"]).startswith("# AGENTS.md instructions for "):
|
|
83
|
-
return message
|
|
84
|
-
return None
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def environment_context_message(request: dict[str, object]) -> dict[str, object]:
|
|
88
|
-
for message in request["messages"]:
|
|
89
|
-
if str(message["content"]).startswith("<environment_context>"):
|
|
90
|
-
return message
|
|
91
|
-
raise AssertionError("Environment context was not sent.")
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def stream_events(content: str) -> list[dict[str, object]]:
|
|
95
|
-
events: list[dict[str, object]] = []
|
|
96
|
-
for raw_event in content.strip().split("\n\n"):
|
|
97
|
-
event_type = ""
|
|
98
|
-
data = ""
|
|
99
|
-
for line in raw_event.splitlines():
|
|
100
|
-
if line.startswith("event: "):
|
|
101
|
-
event_type = line.removeprefix("event: ")
|
|
102
|
-
if line.startswith("data: "):
|
|
103
|
-
data = line.removeprefix("data: ")
|
|
104
|
-
events.append({"event": event_type, "data": data})
|
|
105
|
-
return events
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def tool_call_chunk(
|
|
109
|
-
name: str,
|
|
110
|
-
arguments: str,
|
|
111
|
-
*,
|
|
112
|
-
call_id: str = "call-1",
|
|
113
|
-
) -> dict[str, object]:
|
|
114
|
-
return {
|
|
115
|
-
"choices": [
|
|
116
|
-
{
|
|
117
|
-
"delta": {
|
|
118
|
-
"tool_calls": [
|
|
119
|
-
{
|
|
120
|
-
"index": 0,
|
|
121
|
-
"id": call_id,
|
|
122
|
-
"type": "function",
|
|
123
|
-
"function": {
|
|
124
|
-
"arguments": arguments,
|
|
125
|
-
"name": name,
|
|
126
|
-
},
|
|
127
|
-
}
|
|
128
|
-
]
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
]
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
@pytest.mark.anyio
|
|
136
|
-
async def test_workspace_long_shell_command_does_not_block_health(
|
|
137
|
-
tmp_path, monkeypatch
|
|
138
|
-
) -> None:
|
|
139
|
-
monkeypatch.chdir(tmp_path)
|
|
140
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
141
|
-
command_started = asyncio.Event()
|
|
142
|
-
command_can_finish = asyncio.Event()
|
|
143
|
-
|
|
144
|
-
async def fake_run_async(self, command, **kwargs):
|
|
145
|
-
command_started.set()
|
|
146
|
-
await asyncio.wait_for(command_can_finish.wait(), timeout=2)
|
|
147
|
-
return CommandResult(
|
|
148
|
-
command=" ".join(command),
|
|
149
|
-
exit_code=0,
|
|
150
|
-
stderr="",
|
|
151
|
-
stdout="slow command finished",
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
155
|
-
|
|
156
|
-
captured_requests: list[dict[str, object]] = []
|
|
157
|
-
|
|
158
|
-
async def fake_completion(**request: object) -> object:
|
|
159
|
-
captured_requests.append(request)
|
|
160
|
-
|
|
161
|
-
async def chunks() -> object:
|
|
162
|
-
if len(captured_requests) == 1:
|
|
163
|
-
yield tool_call_chunk("shell_command", '{"command": "slow"}')
|
|
164
|
-
else:
|
|
165
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
166
|
-
|
|
167
|
-
return chunks()
|
|
168
|
-
|
|
169
|
-
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
170
|
-
async with httpx.AsyncClient(
|
|
171
|
-
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
172
|
-
) as client:
|
|
173
|
-
await configure_provider_async(client)
|
|
174
|
-
response_task = asyncio.create_task(
|
|
175
|
-
client.post("/api/workspace/respond", json={"content": "Run slow."})
|
|
176
|
-
)
|
|
177
|
-
await asyncio.wait_for(command_started.wait(), timeout=2)
|
|
178
|
-
start = time.perf_counter()
|
|
179
|
-
health_response = await client.get("/api/health")
|
|
180
|
-
elapsed = time.perf_counter() - start
|
|
181
|
-
command_can_finish.set()
|
|
182
|
-
response = await response_task
|
|
183
|
-
|
|
184
|
-
assert health_response.status_code == 200
|
|
185
|
-
assert health_response.json() == {"status": "ok"}
|
|
186
|
-
assert elapsed < 0.2
|
|
187
|
-
assert response.status_code == 200
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def test_workspace_response_streams_selected_provider_model_and_history(
|
|
191
|
-
tmp_path, monkeypatch
|
|
192
|
-
) -> None:
|
|
193
|
-
monkeypatch.chdir(tmp_path)
|
|
194
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
195
|
-
captured_request: dict[str, object] = {}
|
|
196
|
-
|
|
197
|
-
async def fake_completion(**request: object) -> object:
|
|
198
|
-
captured_request.update(request)
|
|
199
|
-
|
|
200
|
-
async def chunks() -> object:
|
|
201
|
-
yield {"choices": [{"delta": {"content": "Here is "}}]}
|
|
202
|
-
yield {"choices": [{"delta": {"content": "the launch checklist."}}]}
|
|
203
|
-
|
|
204
|
-
return chunks()
|
|
205
|
-
|
|
206
|
-
client = TestClient(
|
|
207
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
208
|
-
)
|
|
209
|
-
configure_provider(
|
|
210
|
-
client,
|
|
211
|
-
base_url="https://api.example.test/v1",
|
|
212
|
-
model="claude-sonnet-4-5",
|
|
213
|
-
name="Anthropic",
|
|
214
|
-
provider_id="provider-anthropic",
|
|
215
|
-
provider_type="anthropic",
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
response = client.post(
|
|
219
|
-
"/api/workspace/respond",
|
|
220
|
-
json={"content": "Draft a launch checklist."},
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
assert response.status_code == 200
|
|
224
|
-
assert response.headers["content-type"].startswith("text/event-stream")
|
|
225
|
-
events = stream_events(response.text)
|
|
226
|
-
assert events[0]["event"] == "start"
|
|
227
|
-
assert events[1] == {"event": "output_start", "data": '{"index": 1}'}
|
|
228
|
-
assert events[2] == {"event": "delta", "data": '{"content": "Here is "}'}
|
|
229
|
-
assert events[3] == {
|
|
230
|
-
"event": "delta",
|
|
231
|
-
"data": '{"content": "the launch checklist."}',
|
|
232
|
-
}
|
|
233
|
-
assert '"author": "assistant"' in str(events[4]["data"])
|
|
234
|
-
assert '"content": "Here is the launch checklist."' in str(events[4]["data"])
|
|
235
|
-
assert captured_request["api_base"] == "https://api.example.test/v1"
|
|
236
|
-
assert captured_request["api_key"] == "sk-local"
|
|
237
|
-
assert captured_request["messages"][0] == {
|
|
238
|
-
"role": "system",
|
|
239
|
-
"content": FLOWENT_AGENT_SYSTEM_PROMPT,
|
|
240
|
-
}
|
|
241
|
-
assert project_context_message(captured_request) is None
|
|
242
|
-
assert environment_context_message(captured_request)["role"] == "user"
|
|
243
|
-
assert captured_request["messages"][-1] == {
|
|
244
|
-
"role": "user",
|
|
245
|
-
"content": "Draft a launch checklist.",
|
|
246
|
-
}
|
|
247
|
-
assert captured_request["model"] == "anthropic/claude-sonnet-4-5"
|
|
248
|
-
assert captured_request["stream"] is True
|
|
249
|
-
assert isinstance(captured_request["tools"], list)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def test_workspace_response_requires_selected_provider_and_model(
|
|
253
|
-
tmp_path, monkeypatch
|
|
254
|
-
) -> None:
|
|
255
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
256
|
-
client = TestClient(create_app(serve_frontend=False))
|
|
257
|
-
|
|
258
|
-
response = client.post(
|
|
259
|
-
"/api/workspace/respond",
|
|
260
|
-
json={"content": "Draft a launch checklist."},
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
assert response.status_code == 400
|
|
264
|
-
assert response.json()["detail"] == "Choose a provider and model before sending."
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def test_workspace_compact_persists_compacted_context(tmp_path, monkeypatch) -> None:
|
|
268
|
-
monkeypatch.chdir(tmp_path)
|
|
269
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
270
|
-
captured_request: dict[str, object] = {}
|
|
271
|
-
|
|
272
|
-
async def fake_completion(**request: object) -> dict[str, object]:
|
|
273
|
-
captured_request.update(request)
|
|
274
|
-
return {
|
|
275
|
-
"choices": [
|
|
276
|
-
{
|
|
277
|
-
"message": {
|
|
278
|
-
"content": "Keep the launch checklist and provider setup decisions.",
|
|
279
|
-
"role": "assistant",
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
]
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
client = TestClient(
|
|
286
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
287
|
-
)
|
|
288
|
-
configure_provider(client)
|
|
289
|
-
client.put(
|
|
290
|
-
"/api/workspace/messages",
|
|
291
|
-
json={
|
|
292
|
-
"messages": [
|
|
293
|
-
{
|
|
294
|
-
"author": "user",
|
|
295
|
-
"content": "Draft a launch checklist.",
|
|
296
|
-
"id": "message-1",
|
|
297
|
-
},
|
|
298
|
-
{
|
|
299
|
-
"author": "assistant",
|
|
300
|
-
"content": "Use provider setup first.",
|
|
301
|
-
"id": "message-2",
|
|
302
|
-
},
|
|
303
|
-
]
|
|
304
|
-
},
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
response = client.post("/api/workspace/compact")
|
|
308
|
-
|
|
309
|
-
assert response.status_code == 200
|
|
310
|
-
body = response.json()
|
|
311
|
-
assert body == {
|
|
312
|
-
"message": {
|
|
313
|
-
"author": "system",
|
|
314
|
-
"content": "Context compacted",
|
|
315
|
-
"id": body["message"]["id"],
|
|
316
|
-
"tools": [],
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
assert captured_request["model"] == "openai/gpt-5.1"
|
|
320
|
-
assert captured_request["messages"][0] == {
|
|
321
|
-
"role": "system",
|
|
322
|
-
"content": "You are performing a context checkpoint compaction for Flowent.",
|
|
323
|
-
}
|
|
324
|
-
assert "AGENTS.md instructions" not in captured_request["messages"][-1]["content"]
|
|
325
|
-
assert "<environment_context>" in captured_request["messages"][-1]["content"]
|
|
326
|
-
assert captured_request["messages"][-1]["role"] == "user"
|
|
327
|
-
assert (
|
|
328
|
-
"CONTEXT CHECKPOINT COMPACTION" in captured_request["messages"][-1]["content"]
|
|
329
|
-
)
|
|
330
|
-
assert "Draft a launch checklist." in captured_request["messages"][-1]["content"]
|
|
331
|
-
assert "Use provider setup first." in captured_request["messages"][-1]["content"]
|
|
332
|
-
|
|
333
|
-
state = client.get("/api/state").json()
|
|
334
|
-
assert state["messages"][-1] == body["message"]
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def test_workspace_response_uses_compacted_context_after_compact(
|
|
338
|
-
tmp_path, monkeypatch
|
|
339
|
-
) -> None:
|
|
340
|
-
monkeypatch.chdir(tmp_path)
|
|
341
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
342
|
-
captured_requests: list[dict[str, object]] = []
|
|
343
|
-
|
|
344
|
-
async def fake_completion(**request: object) -> object:
|
|
345
|
-
captured_requests.append(request)
|
|
346
|
-
if len(captured_requests) == 1:
|
|
347
|
-
return {
|
|
348
|
-
"choices": [
|
|
349
|
-
{
|
|
350
|
-
"message": {
|
|
351
|
-
"content": "Keep the provider setup decision.",
|
|
352
|
-
"role": "assistant",
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
]
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async def chunks() -> object:
|
|
359
|
-
yield {"choices": [{"delta": {"content": "Continuing."}}]}
|
|
360
|
-
|
|
361
|
-
return chunks()
|
|
362
|
-
|
|
363
|
-
client = TestClient(
|
|
364
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
365
|
-
)
|
|
366
|
-
configure_provider(client)
|
|
367
|
-
client.put(
|
|
368
|
-
"/api/workspace/messages",
|
|
369
|
-
json={
|
|
370
|
-
"messages": [
|
|
371
|
-
{
|
|
372
|
-
"author": "user",
|
|
373
|
-
"content": "Original detailed request.",
|
|
374
|
-
"id": "message-1",
|
|
375
|
-
},
|
|
376
|
-
{
|
|
377
|
-
"author": "assistant",
|
|
378
|
-
"content": "Original detailed reply.",
|
|
379
|
-
"id": "message-2",
|
|
380
|
-
},
|
|
381
|
-
]
|
|
382
|
-
},
|
|
383
|
-
)
|
|
384
|
-
|
|
385
|
-
compact_response = client.post("/api/workspace/compact")
|
|
386
|
-
response = client.post(
|
|
387
|
-
"/api/workspace/respond",
|
|
388
|
-
json={"content": "Continue from there."},
|
|
389
|
-
)
|
|
390
|
-
|
|
391
|
-
assert compact_response.status_code == 200
|
|
392
|
-
assert response.status_code == 200
|
|
393
|
-
response_messages = captured_requests[1]["messages"]
|
|
394
|
-
assert response_messages[0] == {
|
|
395
|
-
"role": "system",
|
|
396
|
-
"content": FLOWENT_AGENT_SYSTEM_PROMPT,
|
|
397
|
-
}
|
|
398
|
-
assert project_context_message(captured_requests[1]) is None
|
|
399
|
-
assert environment_context_message(captured_requests[1])["role"] == "user"
|
|
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
|
-
)
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
def test_workspace_response_auto_compacts_before_next_message(
|
|
417
|
-
tmp_path, monkeypatch
|
|
418
|
-
) -> None:
|
|
419
|
-
monkeypatch.chdir(tmp_path)
|
|
420
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
421
|
-
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
|
|
422
|
-
captured_requests: list[dict[str, object]] = []
|
|
423
|
-
|
|
424
|
-
async def fake_completion(**request: object) -> object:
|
|
425
|
-
captured_requests.append(request)
|
|
426
|
-
if not request.get("stream"):
|
|
427
|
-
return {
|
|
428
|
-
"choices": [
|
|
429
|
-
{
|
|
430
|
-
"message": {
|
|
431
|
-
"content": "Keep the launch plan summary.",
|
|
432
|
-
"role": "assistant",
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
]
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
async def chunks() -> object:
|
|
439
|
-
yield {"choices": [{"delta": {"content": "Continuing."}}]}
|
|
440
|
-
|
|
441
|
-
return chunks()
|
|
442
|
-
|
|
443
|
-
client = TestClient(
|
|
444
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
445
|
-
)
|
|
446
|
-
configure_provider(client)
|
|
447
|
-
client.put(
|
|
448
|
-
"/api/workspace/messages",
|
|
449
|
-
json={
|
|
450
|
-
"messages": [
|
|
451
|
-
{
|
|
452
|
-
"author": "user",
|
|
453
|
-
"content": "Original request. " * 80,
|
|
454
|
-
"id": "message-1",
|
|
455
|
-
},
|
|
456
|
-
{
|
|
457
|
-
"author": "assistant",
|
|
458
|
-
"content": "Detailed work log. " * 80,
|
|
459
|
-
"id": "message-2",
|
|
460
|
-
},
|
|
461
|
-
]
|
|
462
|
-
},
|
|
463
|
-
)
|
|
464
|
-
|
|
465
|
-
response = client.post(
|
|
466
|
-
"/api/workspace/respond",
|
|
467
|
-
json={"content": "Continue from there."},
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
assert response.status_code == 200
|
|
471
|
-
events = stream_events(response.text)
|
|
472
|
-
assert events[0]["event"] == "context_optimized"
|
|
473
|
-
assert json.loads(events[0]["data"])["message"]["content"] == ("Context optimized")
|
|
474
|
-
assert len(captured_requests) == 2
|
|
475
|
-
assert (
|
|
476
|
-
"CONTEXT CHECKPOINT COMPACTION"
|
|
477
|
-
in captured_requests[0]["messages"][-1]["content"]
|
|
478
|
-
)
|
|
479
|
-
response_messages = captured_requests[1]["messages"]
|
|
480
|
-
compacted_messages = [
|
|
481
|
-
message
|
|
482
|
-
for message in response_messages
|
|
483
|
-
if str(message["content"]).startswith(
|
|
484
|
-
"Another language model started working on this Flowent workspace session"
|
|
485
|
-
)
|
|
486
|
-
]
|
|
487
|
-
assert len(compacted_messages) == 1
|
|
488
|
-
assert "Keep the launch plan summary." in compacted_messages[0]["content"]
|
|
489
|
-
assert {"role": "user", "content": "Context optimized"} not in response_messages
|
|
490
|
-
state = client.get("/api/state").json()
|
|
491
|
-
assert [message["content"] for message in state["messages"]][-3:] == [
|
|
492
|
-
"Context optimized",
|
|
493
|
-
"Continue from there.",
|
|
494
|
-
"Continuing.",
|
|
495
|
-
]
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
def test_workspace_response_auto_compacts_after_tool_result(
|
|
499
|
-
tmp_path, monkeypatch
|
|
500
|
-
) -> None:
|
|
501
|
-
monkeypatch.chdir(tmp_path)
|
|
502
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
503
|
-
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "1200")
|
|
504
|
-
(tmp_path / "notes.txt").write_text("Launch notes. " * 600)
|
|
505
|
-
captured_requests: list[dict[str, object]] = []
|
|
506
|
-
|
|
507
|
-
async def fake_completion(**request: object) -> object:
|
|
508
|
-
captured_requests.append(request)
|
|
509
|
-
if not request.get("stream"):
|
|
510
|
-
return {
|
|
511
|
-
"choices": [
|
|
512
|
-
{
|
|
513
|
-
"message": {
|
|
514
|
-
"content": "Keep the file findings from notes.txt.",
|
|
515
|
-
"role": "assistant",
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
]
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
async def chunks() -> object:
|
|
522
|
-
if len(captured_requests) == 1:
|
|
523
|
-
yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
|
|
524
|
-
return
|
|
525
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
526
|
-
|
|
527
|
-
return chunks()
|
|
528
|
-
|
|
529
|
-
client = TestClient(
|
|
530
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
531
|
-
)
|
|
532
|
-
configure_provider(client)
|
|
533
|
-
|
|
534
|
-
response = client.post(
|
|
535
|
-
"/api/workspace/respond",
|
|
536
|
-
json={"content": "Read the launch notes."},
|
|
537
|
-
)
|
|
538
|
-
|
|
539
|
-
assert response.status_code == 200
|
|
540
|
-
events = stream_events(response.text)
|
|
541
|
-
assert [event["event"] for event in events] == [
|
|
542
|
-
"start",
|
|
543
|
-
"output_start",
|
|
544
|
-
"tool_start",
|
|
545
|
-
"tool_done",
|
|
546
|
-
"context_optimized",
|
|
547
|
-
"output_start",
|
|
548
|
-
"delta",
|
|
549
|
-
"done",
|
|
550
|
-
]
|
|
551
|
-
assert json.loads(events[4]["data"])["message"]["content"] == ("Context optimized")
|
|
552
|
-
assert len(captured_requests) == 3
|
|
553
|
-
assert "Launch notes." in captured_requests[1]["messages"][-1]["content"]
|
|
554
|
-
response_messages = captured_requests[2]["messages"]
|
|
555
|
-
compacted_messages = [
|
|
556
|
-
message
|
|
557
|
-
for message in response_messages
|
|
558
|
-
if str(message["content"]).startswith(
|
|
559
|
-
"Another language model started working on this Flowent workspace session"
|
|
560
|
-
)
|
|
561
|
-
]
|
|
562
|
-
assert len(compacted_messages) == 1
|
|
563
|
-
assert "Keep the file findings from notes.txt." in compacted_messages[0]["content"]
|
|
564
|
-
state = client.get("/api/state").json()
|
|
565
|
-
assert [message["content"] for message in state["messages"]] == [
|
|
566
|
-
"Read the launch notes.",
|
|
567
|
-
"Context optimized",
|
|
568
|
-
"Done.",
|
|
569
|
-
]
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
def test_workspace_auto_compact_failure_keeps_existing_checkpoint(
|
|
573
|
-
tmp_path, monkeypatch
|
|
574
|
-
) -> None:
|
|
575
|
-
monkeypatch.chdir(tmp_path)
|
|
576
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
577
|
-
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
|
|
578
|
-
|
|
579
|
-
async def fake_completion(**request: object) -> object:
|
|
580
|
-
if not request.get("stream"):
|
|
581
|
-
raise RuntimeError("summary failed")
|
|
582
|
-
|
|
583
|
-
async def chunks() -> object:
|
|
584
|
-
yield {"choices": [{"delta": {"content": "Should not run."}}]}
|
|
585
|
-
|
|
586
|
-
return chunks()
|
|
587
|
-
|
|
588
|
-
client = TestClient(
|
|
589
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
590
|
-
)
|
|
591
|
-
configure_provider(client)
|
|
592
|
-
client.put(
|
|
593
|
-
"/api/workspace/messages",
|
|
594
|
-
json={
|
|
595
|
-
"messages": [
|
|
596
|
-
{
|
|
597
|
-
"author": "user",
|
|
598
|
-
"content": "Original request. " * 80,
|
|
599
|
-
"id": "message-1",
|
|
600
|
-
}
|
|
601
|
-
]
|
|
602
|
-
},
|
|
603
|
-
)
|
|
604
|
-
|
|
605
|
-
response = client.post(
|
|
606
|
-
"/api/workspace/respond",
|
|
607
|
-
json={"content": "Continue from there."},
|
|
608
|
-
)
|
|
609
|
-
|
|
610
|
-
assert response.status_code == 200
|
|
611
|
-
events = stream_events(response.text)
|
|
612
|
-
assert events[-1]["event"] == "error"
|
|
613
|
-
assert json.loads(events[-1]["data"])["message"] == (
|
|
614
|
-
"Context could not be optimized."
|
|
615
|
-
)
|
|
616
|
-
state = client.get("/api/state").json()
|
|
617
|
-
assert "Context optimized" not in [
|
|
618
|
-
message["content"] for message in state["messages"]
|
|
619
|
-
]
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
def test_workspace_response_uses_auto_compaction_checkpoint_after_restart(
|
|
623
|
-
tmp_path, monkeypatch
|
|
624
|
-
) -> None:
|
|
625
|
-
monkeypatch.chdir(tmp_path)
|
|
626
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
627
|
-
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
|
|
628
|
-
captured_requests: list[dict[str, object]] = []
|
|
629
|
-
|
|
630
|
-
async def fake_completion(**request: object) -> object:
|
|
631
|
-
captured_requests.append(request)
|
|
632
|
-
if not request.get("stream"):
|
|
633
|
-
return {
|
|
634
|
-
"choices": [
|
|
635
|
-
{
|
|
636
|
-
"message": {
|
|
637
|
-
"content": "Auto checkpoint survives restarts.",
|
|
638
|
-
"role": "assistant",
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
]
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
async def chunks() -> object:
|
|
645
|
-
yield {"choices": [{"delta": {"content": "Continuing."}}]}
|
|
646
|
-
|
|
647
|
-
return chunks()
|
|
648
|
-
|
|
649
|
-
client = TestClient(
|
|
650
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
651
|
-
)
|
|
652
|
-
configure_provider(client)
|
|
653
|
-
client.put(
|
|
654
|
-
"/api/workspace/messages",
|
|
655
|
-
json={
|
|
656
|
-
"messages": [
|
|
657
|
-
{
|
|
658
|
-
"author": "user",
|
|
659
|
-
"content": "Original request. " * 80,
|
|
660
|
-
"id": "message-1",
|
|
661
|
-
}
|
|
662
|
-
]
|
|
663
|
-
},
|
|
664
|
-
)
|
|
665
|
-
|
|
666
|
-
first_response = client.post(
|
|
667
|
-
"/api/workspace/respond",
|
|
668
|
-
json={"content": "Continue from there."},
|
|
669
|
-
)
|
|
670
|
-
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "100000")
|
|
671
|
-
restarted_client = TestClient(
|
|
672
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
673
|
-
)
|
|
674
|
-
second_response = restarted_client.post(
|
|
675
|
-
"/api/workspace/respond",
|
|
676
|
-
json={"content": "Continue after restart."},
|
|
677
|
-
)
|
|
678
|
-
|
|
679
|
-
assert first_response.status_code == 200
|
|
680
|
-
assert second_response.status_code == 200
|
|
681
|
-
response_messages = captured_requests[2]["messages"]
|
|
682
|
-
compacted_messages = [
|
|
683
|
-
message
|
|
684
|
-
for message in response_messages
|
|
685
|
-
if str(message["content"]).startswith(
|
|
686
|
-
"Another language model started working on this Flowent workspace session"
|
|
687
|
-
)
|
|
688
|
-
]
|
|
689
|
-
assert len(compacted_messages) == 1
|
|
690
|
-
assert "Auto checkpoint survives restarts." in compacted_messages[0]["content"]
|
|
691
|
-
assert {"role": "user", "content": "Context optimized"} not in response_messages
|
|
692
|
-
assert response_messages[-1] == {
|
|
693
|
-
"role": "user",
|
|
694
|
-
"content": "Continue after restart.",
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
def test_workspace_response_includes_project_and_environment_context(
|
|
699
|
-
tmp_path, monkeypatch
|
|
700
|
-
) -> None:
|
|
701
|
-
monkeypatch.chdir(tmp_path)
|
|
702
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
703
|
-
(tmp_path / ".git").mkdir()
|
|
704
|
-
(tmp_path / "AGENTS.md").write_text("Use concise replies.")
|
|
705
|
-
captured_request: dict[str, object] = {}
|
|
706
|
-
|
|
707
|
-
async def fake_completion(**request: object) -> object:
|
|
708
|
-
captured_request.update(request)
|
|
709
|
-
|
|
710
|
-
async def chunks() -> object:
|
|
711
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
712
|
-
|
|
713
|
-
return chunks()
|
|
714
|
-
|
|
715
|
-
client = TestClient(
|
|
716
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
717
|
-
)
|
|
718
|
-
configure_provider(client)
|
|
719
|
-
|
|
720
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
721
|
-
|
|
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
|
-
}
|
|
745
|
-
|
|
746
|
-
|
|
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] = {}
|
|
759
|
-
|
|
760
|
-
async def fake_completion(**request: object) -> object:
|
|
761
|
-
captured_request.update(request)
|
|
762
|
-
|
|
763
|
-
async def chunks() -> object:
|
|
764
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
765
|
-
|
|
766
|
-
return chunks()
|
|
767
|
-
|
|
768
|
-
client = TestClient(
|
|
769
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
770
|
-
)
|
|
771
|
-
configure_provider(client)
|
|
772
|
-
|
|
773
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
774
|
-
|
|
775
|
-
assert response.status_code == 200
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
def test_workspace_clear_keeps_runtime_context_available(tmp_path, monkeypatch) -> None:
|
|
1014
|
-
monkeypatch.chdir(tmp_path)
|
|
1015
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1016
|
-
(tmp_path / ".git").mkdir()
|
|
1017
|
-
(tmp_path / "AGENTS.md").write_text("Instructions after clear.")
|
|
1018
|
-
captured_requests: list[dict[str, object]] = []
|
|
1019
|
-
|
|
1020
|
-
async def fake_completion(**request: object) -> object:
|
|
1021
|
-
captured_requests.append(request)
|
|
1022
|
-
|
|
1023
|
-
async def chunks() -> object:
|
|
1024
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
1025
|
-
|
|
1026
|
-
return chunks()
|
|
1027
|
-
|
|
1028
|
-
client = TestClient(
|
|
1029
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1030
|
-
)
|
|
1031
|
-
configure_provider(client)
|
|
1032
|
-
|
|
1033
|
-
first_response = client.post("/api/workspace/respond", json={"content": "First."})
|
|
1034
|
-
clear_response = client.put("/api/workspace/messages", json={"messages": []})
|
|
1035
|
-
second_response = client.post("/api/workspace/respond", json={"content": "Second."})
|
|
1036
|
-
|
|
1037
|
-
assert first_response.status_code == 200
|
|
1038
|
-
assert clear_response.status_code == 200
|
|
1039
|
-
assert second_response.status_code == 200
|
|
1040
|
-
project_message = project_context_message(captured_requests[1])
|
|
1041
|
-
assert project_message is not None
|
|
1042
|
-
assert "Instructions after clear." in project_message["content"]
|
|
1043
|
-
assert environment_context_message(captured_requests[1])["role"] == "user"
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
def test_workspace_compacted_response_includes_latest_runtime_context(
|
|
1047
|
-
tmp_path, monkeypatch
|
|
1048
|
-
) -> None:
|
|
1049
|
-
monkeypatch.chdir(tmp_path)
|
|
1050
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1051
|
-
(tmp_path / ".git").mkdir()
|
|
1052
|
-
agents_file = tmp_path / "AGENTS.md"
|
|
1053
|
-
agents_file.write_text("Instructions before compact.")
|
|
1054
|
-
captured_requests: list[dict[str, object]] = []
|
|
1055
|
-
|
|
1056
|
-
async def fake_completion(**request: object) -> object:
|
|
1057
|
-
captured_requests.append(request)
|
|
1058
|
-
if len(captured_requests) == 1:
|
|
1059
|
-
return {
|
|
1060
|
-
"choices": [
|
|
1061
|
-
{
|
|
1062
|
-
"message": {
|
|
1063
|
-
"content": "Keep compacted state.",
|
|
1064
|
-
"role": "assistant",
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
]
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
async def chunks() -> object:
|
|
1071
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
1072
|
-
|
|
1073
|
-
return chunks()
|
|
1074
|
-
|
|
1075
|
-
client = TestClient(
|
|
1076
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1077
|
-
)
|
|
1078
|
-
configure_provider(client)
|
|
1079
|
-
client.put(
|
|
1080
|
-
"/api/workspace/messages",
|
|
1081
|
-
json={
|
|
1082
|
-
"messages": [
|
|
1083
|
-
{"author": "user", "content": "Original request.", "id": "message-1"}
|
|
1084
|
-
]
|
|
1085
|
-
},
|
|
1086
|
-
)
|
|
1087
|
-
|
|
1088
|
-
compact_response = client.post("/api/workspace/compact")
|
|
1089
|
-
agents_file.write_text("Instructions after compact.")
|
|
1090
|
-
response = client.post("/api/workspace/respond", json={"content": "Continue."})
|
|
1091
|
-
|
|
1092
|
-
assert compact_response.status_code == 200
|
|
1093
|
-
assert response.status_code == 200
|
|
1094
|
-
response_messages = captured_requests[1]["messages"]
|
|
1095
|
-
project_message = project_context_message(captured_requests[1])
|
|
1096
|
-
assert project_message is not None
|
|
1097
|
-
assert "Instructions after compact." in project_message["content"]
|
|
1098
|
-
assert environment_context_message(captured_requests[1])["role"] == "user"
|
|
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"]
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
def test_project_instructions_are_truncated_to_size_limit(
|
|
1111
|
-
tmp_path, monkeypatch
|
|
1112
|
-
) -> None:
|
|
1113
|
-
monkeypatch.chdir(tmp_path)
|
|
1114
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1115
|
-
monkeypatch.setenv("FLOWENT_PROJECT_INSTRUCTIONS_MAX_BYTES", "12")
|
|
1116
|
-
(tmp_path / ".git").mkdir()
|
|
1117
|
-
(tmp_path / "AGENTS.md").write_text("1234567890abcdef")
|
|
1118
|
-
captured_request: dict[str, object] = {}
|
|
1119
|
-
|
|
1120
|
-
async def fake_completion(**request: object) -> object:
|
|
1121
|
-
captured_request.update(request)
|
|
1122
|
-
|
|
1123
|
-
async def chunks() -> object:
|
|
1124
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
1125
|
-
|
|
1126
|
-
return chunks()
|
|
1127
|
-
|
|
1128
|
-
client = TestClient(
|
|
1129
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1130
|
-
)
|
|
1131
|
-
configure_provider(client)
|
|
1132
|
-
|
|
1133
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
1134
|
-
|
|
1135
|
-
assert response.status_code == 200
|
|
1136
|
-
project_message = project_context_message(captured_request)
|
|
1137
|
-
assert project_message is not None
|
|
1138
|
-
assert "1234567890ab" in project_message["content"]
|
|
1139
|
-
assert "cdef" not in project_message["content"]
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
@pytest.mark.anyio
|
|
1143
|
-
async def test_workspace_persists_tool_start_during_stream(
|
|
1144
|
-
tmp_path, monkeypatch
|
|
1145
|
-
) -> None:
|
|
1146
|
-
monkeypatch.chdir(tmp_path)
|
|
1147
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1148
|
-
command_started = asyncio.Event()
|
|
1149
|
-
command_can_finish = asyncio.Event()
|
|
1150
|
-
|
|
1151
|
-
async def fake_run_async(self, command, **kwargs):
|
|
1152
|
-
command_started.set()
|
|
1153
|
-
await asyncio.wait_for(command_can_finish.wait(), timeout=2)
|
|
1154
|
-
return CommandResult(
|
|
1155
|
-
command=" ".join(command),
|
|
1156
|
-
exit_code=0,
|
|
1157
|
-
stderr="",
|
|
1158
|
-
stdout="Launch notes",
|
|
1159
|
-
)
|
|
1160
|
-
|
|
1161
|
-
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
1162
|
-
|
|
1163
|
-
async def fake_completion(**request: object) -> object:
|
|
1164
|
-
async def chunks() -> object:
|
|
1165
|
-
if request["messages"][-1]["role"] == "user":
|
|
1166
|
-
yield tool_call_chunk("shell_command", '{"command": "slow"}')
|
|
1167
|
-
else:
|
|
1168
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
1169
|
-
|
|
1170
|
-
return chunks()
|
|
1171
|
-
|
|
1172
|
-
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1173
|
-
async with httpx.AsyncClient(
|
|
1174
|
-
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1175
|
-
) as client:
|
|
1176
|
-
await configure_provider_async(client)
|
|
1177
|
-
response_task = asyncio.create_task(
|
|
1178
|
-
client.post("/api/workspace/respond", json={"content": "Read notes."})
|
|
1179
|
-
)
|
|
1180
|
-
await asyncio.wait_for(command_started.wait(), timeout=2)
|
|
1181
|
-
state = (await client.get("/api/state")).json()
|
|
1182
|
-
command_can_finish.set()
|
|
1183
|
-
response = await response_task
|
|
1184
|
-
|
|
1185
|
-
assistant = state["messages"][-1]
|
|
1186
|
-
assert response.status_code == 200
|
|
1187
|
-
assert assistant["author"] == "assistant"
|
|
1188
|
-
assert assistant["status"] == "running"
|
|
1189
|
-
assert assistant["tools"][0]["name"] == "shell_command"
|
|
1190
|
-
assert assistant["tools"][0]["status"] == "running"
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
@pytest.mark.anyio
|
|
1194
|
-
async def test_workspace_persists_tool_result_during_stream(
|
|
1195
|
-
tmp_path, monkeypatch
|
|
1196
|
-
) -> None:
|
|
1197
|
-
monkeypatch.chdir(tmp_path)
|
|
1198
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1199
|
-
(tmp_path / "notes.txt").write_text("Launch notes")
|
|
1200
|
-
second_round_started = asyncio.Event()
|
|
1201
|
-
continue_stream = asyncio.Event()
|
|
1202
|
-
|
|
1203
|
-
async def fake_completion(**request: object) -> object:
|
|
1204
|
-
async def chunks() -> object:
|
|
1205
|
-
if request["messages"][-1]["role"] == "user":
|
|
1206
|
-
yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
|
|
1207
|
-
return
|
|
1208
|
-
second_round_started.set()
|
|
1209
|
-
await asyncio.wait_for(continue_stream.wait(), timeout=2)
|
|
1210
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
1211
|
-
|
|
1212
|
-
return chunks()
|
|
1213
|
-
|
|
1214
|
-
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1215
|
-
async with httpx.AsyncClient(
|
|
1216
|
-
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1217
|
-
) as client:
|
|
1218
|
-
await configure_provider_async(client)
|
|
1219
|
-
response_task = asyncio.create_task(
|
|
1220
|
-
client.post("/api/workspace/respond", json={"content": "Read notes."})
|
|
1221
|
-
)
|
|
1222
|
-
await asyncio.wait_for(second_round_started.wait(), timeout=2)
|
|
1223
|
-
state = (await client.get("/api/state")).json()
|
|
1224
|
-
continue_stream.set()
|
|
1225
|
-
response = await response_task
|
|
1226
|
-
|
|
1227
|
-
assistant = state["messages"][-1]
|
|
1228
|
-
assert response.status_code == 200
|
|
1229
|
-
assert assistant["status"] == "running"
|
|
1230
|
-
assert assistant["tools"][0]["status"] == "success"
|
|
1231
|
-
assert assistant["tools"][0]["content"] == "Launch notes"
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
def test_workspace_persists_failed_draft_when_stream_errors(
|
|
1235
|
-
tmp_path, monkeypatch
|
|
1236
|
-
) -> None:
|
|
1237
|
-
monkeypatch.chdir(tmp_path)
|
|
1238
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1239
|
-
|
|
1240
|
-
async def fake_completion(**request: object) -> object:
|
|
1241
|
-
async def chunks() -> object:
|
|
1242
|
-
yield {"choices": [{"delta": {"content": "Partial answer."}}]}
|
|
1243
|
-
raise RuntimeError("provider stopped")
|
|
1244
|
-
|
|
1245
|
-
return chunks()
|
|
1246
|
-
|
|
1247
|
-
client = TestClient(
|
|
1248
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1249
|
-
)
|
|
1250
|
-
configure_provider(client)
|
|
1251
|
-
|
|
1252
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
1253
|
-
|
|
1254
|
-
assert response.status_code == 200
|
|
1255
|
-
events = stream_events(response.text)
|
|
1256
|
-
assert events[-1]["event"] == "error"
|
|
1257
|
-
state = client.get("/api/state").json()
|
|
1258
|
-
assistant = state["messages"][-1]
|
|
1259
|
-
assert assistant["author"] == "assistant"
|
|
1260
|
-
assert assistant["content"] == "Partial answer."
|
|
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
|
-
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
def test_workspace_persists_error_block_when_responses_stream_fails(
|
|
1277
|
-
tmp_path, monkeypatch, fake_litellm_responses_transformer
|
|
1278
|
-
) -> None:
|
|
1279
|
-
monkeypatch.chdir(tmp_path)
|
|
1280
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1281
|
-
|
|
1282
|
-
async def fake_completion(**request: object) -> object:
|
|
1283
|
-
async def chunks() -> object:
|
|
1284
|
-
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
|
1285
|
-
OpenAiResponsesToChatCompletionStreamIterator,
|
|
1286
|
-
)
|
|
1287
|
-
|
|
1288
|
-
yield {"choices": [{"delta": {"content": "Partial answer."}}]}
|
|
1289
|
-
yield OpenAiResponsesToChatCompletionStreamIterator.translate_responses_chunk_to_openai_stream(
|
|
1290
|
-
{
|
|
1291
|
-
"response": {
|
|
1292
|
-
"error": {
|
|
1293
|
-
"code": "upstream_error",
|
|
1294
|
-
"message": "Upstream request failed",
|
|
1295
|
-
},
|
|
1296
|
-
"status": "failed",
|
|
1297
|
-
},
|
|
1298
|
-
"type": "response.failed",
|
|
1299
|
-
}
|
|
1300
|
-
)
|
|
1301
|
-
|
|
1302
|
-
return chunks()
|
|
1303
|
-
|
|
1304
|
-
client = TestClient(
|
|
1305
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1306
|
-
)
|
|
1307
|
-
configure_provider(client)
|
|
1308
|
-
|
|
1309
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
1310
|
-
|
|
1311
|
-
assert response.status_code == 200
|
|
1312
|
-
events = stream_events(response.text)
|
|
1313
|
-
assert events[-1]["event"] == "error"
|
|
1314
|
-
state = client.get("/api/state").json()
|
|
1315
|
-
assistant = state["messages"][-1]
|
|
1316
|
-
assert assistant["author"] == "assistant"
|
|
1317
|
-
assert assistant["content"] == "Partial answer."
|
|
1318
|
-
assert assistant["status"] == "failed"
|
|
1319
|
-
assert json.loads(events[-1]["data"])["error"] == {
|
|
1320
|
-
"detail": "Upstream request failed",
|
|
1321
|
-
"id": f"{assistant['id']}-error-1",
|
|
1322
|
-
"message": "Check the model connection settings and try again.",
|
|
1323
|
-
"title": "Request failed",
|
|
1324
|
-
"type": "error",
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
def test_workspace_persists_error_block_when_model_fails_before_output(
|
|
1329
|
-
tmp_path, monkeypatch
|
|
1330
|
-
) -> None:
|
|
1331
|
-
monkeypatch.chdir(tmp_path)
|
|
1332
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1333
|
-
|
|
1334
|
-
async def fake_completion(**request: object) -> object:
|
|
1335
|
-
raise RuntimeError("provider unavailable")
|
|
1336
|
-
|
|
1337
|
-
client = TestClient(
|
|
1338
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1339
|
-
)
|
|
1340
|
-
configure_provider(client)
|
|
1341
|
-
|
|
1342
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
1343
|
-
|
|
1344
|
-
assert response.status_code == 200
|
|
1345
|
-
events = stream_events(response.text)
|
|
1346
|
-
assert events[-1]["event"] == "error"
|
|
1347
|
-
assistant_id = json.loads(events[0]["data"])["id"]
|
|
1348
|
-
assert json.loads(events[-1]["data"]) == {
|
|
1349
|
-
"error": {
|
|
1350
|
-
"detail": "provider unavailable",
|
|
1351
|
-
"id": f"{assistant_id}-error-1",
|
|
1352
|
-
"message": "Check the model connection settings and try again.",
|
|
1353
|
-
"title": "Request failed",
|
|
1354
|
-
"type": "error",
|
|
1355
|
-
},
|
|
1356
|
-
"message": "Check the model connection settings and try again.",
|
|
1357
|
-
}
|
|
1358
|
-
state = client.get("/api/state").json()
|
|
1359
|
-
assistant = state["messages"][-1]
|
|
1360
|
-
assert assistant["author"] == "assistant"
|
|
1361
|
-
assert assistant["content"] == ""
|
|
1362
|
-
assert assistant["status"] == "failed"
|
|
1363
|
-
assert assistant["groups"] == [
|
|
1364
|
-
{
|
|
1365
|
-
"id": f"{assistant['id']}-group-1",
|
|
1366
|
-
"items": [],
|
|
1367
|
-
},
|
|
1368
|
-
{
|
|
1369
|
-
"id": f"{assistant['id']}-errors",
|
|
1370
|
-
"items": [
|
|
1371
|
-
{
|
|
1372
|
-
"detail": "provider unavailable",
|
|
1373
|
-
"id": f"{assistant['id']}-error-1",
|
|
1374
|
-
"message": "Check the model connection settings and try again.",
|
|
1375
|
-
"title": "Request failed",
|
|
1376
|
-
"type": "error",
|
|
1377
|
-
}
|
|
1378
|
-
],
|
|
1379
|
-
},
|
|
1380
|
-
]
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
def test_workspace_treats_empty_model_result_as_failed_error_block(
|
|
1384
|
-
tmp_path, monkeypatch
|
|
1385
|
-
) -> None:
|
|
1386
|
-
monkeypatch.chdir(tmp_path)
|
|
1387
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1388
|
-
|
|
1389
|
-
async def fake_completion(**request: object) -> object:
|
|
1390
|
-
async def chunks() -> object:
|
|
1391
|
-
if False:
|
|
1392
|
-
yield {}
|
|
1393
|
-
|
|
1394
|
-
return chunks()
|
|
1395
|
-
|
|
1396
|
-
client = TestClient(
|
|
1397
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1398
|
-
)
|
|
1399
|
-
configure_provider(client)
|
|
1400
|
-
|
|
1401
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
1402
|
-
|
|
1403
|
-
assert response.status_code == 200
|
|
1404
|
-
events = stream_events(response.text)
|
|
1405
|
-
assert events[-1]["event"] == "error"
|
|
1406
|
-
state = client.get("/api/state").json()
|
|
1407
|
-
assistant = state["messages"][-1]
|
|
1408
|
-
assert assistant["status"] == "failed"
|
|
1409
|
-
assert assistant["groups"][-1]["items"] == [
|
|
1410
|
-
{
|
|
1411
|
-
"detail": "The model did not return a response.",
|
|
1412
|
-
"id": f"{assistant['id']}-error-1",
|
|
1413
|
-
"message": "Check the model connection settings and try again.",
|
|
1414
|
-
"title": "Request failed",
|
|
1415
|
-
"type": "error",
|
|
1416
|
-
}
|
|
1417
|
-
]
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
def test_workspace_includes_previous_error_summary_in_next_request(
|
|
1421
|
-
tmp_path, monkeypatch
|
|
1422
|
-
) -> None:
|
|
1423
|
-
monkeypatch.chdir(tmp_path)
|
|
1424
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1425
|
-
captured_requests: list[dict[str, object]] = []
|
|
1426
|
-
|
|
1427
|
-
async def fake_completion(**request: object) -> object:
|
|
1428
|
-
captured_requests.append(request)
|
|
1429
|
-
|
|
1430
|
-
async def chunks() -> object:
|
|
1431
|
-
yield {"choices": [{"delta": {"content": "Recovered."}}]}
|
|
1432
|
-
|
|
1433
|
-
return chunks()
|
|
1434
|
-
|
|
1435
|
-
client = TestClient(
|
|
1436
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1437
|
-
)
|
|
1438
|
-
configure_provider(client)
|
|
1439
|
-
client.put(
|
|
1440
|
-
"/api/workspace/messages",
|
|
1441
|
-
json={
|
|
1442
|
-
"messages": [
|
|
1443
|
-
{
|
|
1444
|
-
"author": "user",
|
|
1445
|
-
"content": "Try once.",
|
|
1446
|
-
"id": "message-user-1",
|
|
1447
|
-
},
|
|
1448
|
-
{
|
|
1449
|
-
"author": "assistant",
|
|
1450
|
-
"content": "",
|
|
1451
|
-
"groups": [
|
|
1452
|
-
{
|
|
1453
|
-
"id": "message-assistant-1-errors",
|
|
1454
|
-
"items": [
|
|
1455
|
-
{
|
|
1456
|
-
"detail": "HTML response returned.",
|
|
1457
|
-
"id": "message-assistant-1-error-1",
|
|
1458
|
-
"message": "Check the model connection settings and try again.",
|
|
1459
|
-
"title": "Request failed",
|
|
1460
|
-
"type": "error",
|
|
1461
|
-
}
|
|
1462
|
-
],
|
|
1463
|
-
}
|
|
1464
|
-
],
|
|
1465
|
-
"id": "message-assistant-1",
|
|
1466
|
-
"status": "failed",
|
|
1467
|
-
},
|
|
1468
|
-
]
|
|
1469
|
-
},
|
|
1470
|
-
)
|
|
1471
|
-
|
|
1472
|
-
response = client.post("/api/workspace/respond", json={"content": "Try again."})
|
|
1473
|
-
|
|
1474
|
-
assert response.status_code == 200
|
|
1475
|
-
request_messages = captured_requests[0]["messages"]
|
|
1476
|
-
assert {
|
|
1477
|
-
"role": "assistant",
|
|
1478
|
-
"content": "Previous response failed: Request failed. Check the model connection settings and try again. Detail: HTML response returned.",
|
|
1479
|
-
} in request_messages
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
def test_workspace_marks_running_tool_failed_when_stream_errors(
|
|
1483
|
-
tmp_path, monkeypatch
|
|
1484
|
-
) -> None:
|
|
1485
|
-
monkeypatch.chdir(tmp_path)
|
|
1486
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1487
|
-
|
|
1488
|
-
async def fake_run_async(self, command, **kwargs):
|
|
1489
|
-
raise RuntimeError("sandbox failed")
|
|
1490
|
-
|
|
1491
|
-
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
1492
|
-
|
|
1493
|
-
async def fake_completion(**request: object) -> object:
|
|
1494
|
-
async def chunks() -> object:
|
|
1495
|
-
yield tool_call_chunk("shell_command", '{"command": "boom"}')
|
|
1496
|
-
|
|
1497
|
-
return chunks()
|
|
1498
|
-
|
|
1499
|
-
client = TestClient(
|
|
1500
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1501
|
-
)
|
|
1502
|
-
configure_provider(client)
|
|
1503
|
-
|
|
1504
|
-
response = client.post("/api/workspace/respond", json={"content": "Run it."})
|
|
1505
|
-
|
|
1506
|
-
assert response.status_code == 200
|
|
1507
|
-
events = stream_events(response.text)
|
|
1508
|
-
assert events[-1]["event"] == "error"
|
|
1509
|
-
state = client.get("/api/state").json()
|
|
1510
|
-
assistant = state["messages"][-1]
|
|
1511
|
-
assert assistant["status"] == "failed"
|
|
1512
|
-
assert assistant["tools"][0]["name"] == "shell_command"
|
|
1513
|
-
assert assistant["tools"][0]["status"] == "failed"
|
|
1514
|
-
assert "sandbox failed" in assistant["tools"][0]["content"]
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
def test_workspace_marks_draft_complete_when_stream_finishes(
|
|
1518
|
-
tmp_path, monkeypatch
|
|
1519
|
-
) -> None:
|
|
1520
|
-
monkeypatch.chdir(tmp_path)
|
|
1521
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1522
|
-
|
|
1523
|
-
async def fake_completion(**request: object) -> object:
|
|
1524
|
-
async def chunks() -> object:
|
|
1525
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
1526
|
-
|
|
1527
|
-
return chunks()
|
|
1528
|
-
|
|
1529
|
-
client = TestClient(
|
|
1530
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1531
|
-
)
|
|
1532
|
-
configure_provider(client)
|
|
1533
|
-
|
|
1534
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
1535
|
-
|
|
1536
|
-
assert response.status_code == 200
|
|
1537
|
-
state = client.get("/api/state").json()
|
|
1538
|
-
assistant = state["messages"][-1]
|
|
1539
|
-
assert assistant["author"] == "assistant"
|
|
1540
|
-
assert assistant["content"] == "Done."
|
|
1541
|
-
assert assistant.get("status", "completed") == "completed"
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
def test_workspace_persists_assistant_output_groups_after_tool_round(
|
|
1545
|
-
tmp_path, monkeypatch
|
|
1546
|
-
) -> None:
|
|
1547
|
-
monkeypatch.chdir(tmp_path)
|
|
1548
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1549
|
-
(tmp_path / "notes.txt").write_text("Launch notes")
|
|
1550
|
-
completion_calls = 0
|
|
1551
|
-
|
|
1552
|
-
async def fake_completion(**request: object) -> object:
|
|
1553
|
-
nonlocal completion_calls
|
|
1554
|
-
completion_calls += 1
|
|
1555
|
-
|
|
1556
|
-
async def chunks() -> object:
|
|
1557
|
-
if completion_calls == 1:
|
|
1558
|
-
yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
|
|
1559
|
-
return
|
|
1560
|
-
yield {"choices": [{"delta": {"content": "The notes are ready."}}]}
|
|
1561
|
-
|
|
1562
|
-
return chunks()
|
|
1563
|
-
|
|
1564
|
-
client = TestClient(
|
|
1565
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1566
|
-
)
|
|
1567
|
-
configure_provider(client)
|
|
1568
|
-
|
|
1569
|
-
response = client.post("/api/workspace/respond", json={"content": "Read notes."})
|
|
1570
|
-
state = client.get("/api/state").json()
|
|
1571
|
-
assistant = state["messages"][-1]
|
|
1572
|
-
tool_id = assistant["tools"][0]["id"]
|
|
1573
|
-
|
|
1574
|
-
assert response.status_code == 200
|
|
1575
|
-
assert assistant["content"] == "The notes are ready."
|
|
1576
|
-
assert assistant["groups"] == [
|
|
1577
|
-
{
|
|
1578
|
-
"id": f"{assistant['id']}-group-1",
|
|
1579
|
-
"items": [
|
|
1580
|
-
{
|
|
1581
|
-
"id": f"tool-{tool_id}",
|
|
1582
|
-
"tool": assistant["tools"][0],
|
|
1583
|
-
"type": "tool",
|
|
1584
|
-
}
|
|
1585
|
-
],
|
|
1586
|
-
},
|
|
1587
|
-
{
|
|
1588
|
-
"id": f"{assistant['id']}-group-2",
|
|
1589
|
-
"items": [
|
|
1590
|
-
{
|
|
1591
|
-
"content": "The notes are ready.",
|
|
1592
|
-
"id": f"{assistant['id']}-text-1",
|
|
1593
|
-
"type": "text",
|
|
1594
|
-
}
|
|
1595
|
-
],
|
|
1596
|
-
},
|
|
1597
|
-
]
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
@pytest.mark.anyio
|
|
1601
|
-
async def test_workspace_run_continues_without_stream_consumer(
|
|
1602
|
-
tmp_path, monkeypatch
|
|
1603
|
-
) -> None:
|
|
1604
|
-
monkeypatch.chdir(tmp_path)
|
|
1605
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1606
|
-
first_chunk_sent = asyncio.Event()
|
|
1607
|
-
finish_response = asyncio.Event()
|
|
1608
|
-
|
|
1609
|
-
async def fake_completion(**request: object) -> object:
|
|
1610
|
-
async def chunks() -> object:
|
|
1611
|
-
yield {"choices": [{"delta": {"content": "Partial "}}]}
|
|
1612
|
-
first_chunk_sent.set()
|
|
1613
|
-
await asyncio.wait_for(finish_response.wait(), timeout=2)
|
|
1614
|
-
yield {"choices": [{"delta": {"content": "answer."}}]}
|
|
1615
|
-
|
|
1616
|
-
return chunks()
|
|
1617
|
-
|
|
1618
|
-
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1619
|
-
async with httpx.AsyncClient(
|
|
1620
|
-
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1621
|
-
) as client:
|
|
1622
|
-
await configure_provider_async(client)
|
|
1623
|
-
response = await client.post(
|
|
1624
|
-
"/api/workspace/runs",
|
|
1625
|
-
json={"content": "Keep working."},
|
|
1626
|
-
)
|
|
1627
|
-
assert response.status_code == 200
|
|
1628
|
-
await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
|
|
1629
|
-
finish_response.set()
|
|
1630
|
-
|
|
1631
|
-
for _ in range(20):
|
|
1632
|
-
state = (await client.get("/api/state")).json()
|
|
1633
|
-
assistant = state["messages"][-1]
|
|
1634
|
-
if (
|
|
1635
|
-
assistant["author"] == "assistant"
|
|
1636
|
-
and assistant.get("status", "completed") == "completed"
|
|
1637
|
-
):
|
|
1638
|
-
break
|
|
1639
|
-
await asyncio.sleep(0.05)
|
|
1640
|
-
else:
|
|
1641
|
-
raise AssertionError("Workspace run did not complete.")
|
|
1642
|
-
|
|
1643
|
-
assert assistant["content"] == "Partial answer."
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
@pytest.mark.anyio
|
|
1647
|
-
async def test_workspace_state_exposes_active_run_for_reconnect(
|
|
1648
|
-
tmp_path, monkeypatch
|
|
1649
|
-
) -> None:
|
|
1650
|
-
monkeypatch.chdir(tmp_path)
|
|
1651
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1652
|
-
first_chunk_sent = asyncio.Event()
|
|
1653
|
-
finish_response = asyncio.Event()
|
|
1654
|
-
|
|
1655
|
-
async def fake_completion(**request: object) -> object:
|
|
1656
|
-
async def chunks() -> object:
|
|
1657
|
-
yield {"choices": [{"delta": {"content": "First "}}]}
|
|
1658
|
-
first_chunk_sent.set()
|
|
1659
|
-
await asyncio.wait_for(finish_response.wait(), timeout=2)
|
|
1660
|
-
yield {"choices": [{"delta": {"content": "second."}}]}
|
|
1661
|
-
|
|
1662
|
-
return chunks()
|
|
1663
|
-
|
|
1664
|
-
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1665
|
-
async with httpx.AsyncClient(
|
|
1666
|
-
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1667
|
-
) as client:
|
|
1668
|
-
await configure_provider_async(client)
|
|
1669
|
-
response = await client.post(
|
|
1670
|
-
"/api/workspace/runs",
|
|
1671
|
-
json={"content": "Continue if I reconnect."},
|
|
1672
|
-
)
|
|
1673
|
-
run_id = response.json()["run_id"]
|
|
1674
|
-
await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
|
|
1675
|
-
state = (await client.get("/api/state")).json()
|
|
1676
|
-
event_index = state["active_run_event_index"]
|
|
1677
|
-
finish_response.set()
|
|
1678
|
-
stream_response = await client.get(
|
|
1679
|
-
f"/api/workspace/runs/{run_id}/stream?after={event_index}"
|
|
1680
|
-
)
|
|
1681
|
-
|
|
1682
|
-
assert state["active_run_id"] == run_id
|
|
1683
|
-
assert event_index > 0
|
|
1684
|
-
events = stream_events(stream_response.text)
|
|
1685
|
-
assert {"event": "delta", "data": '{"content": "First "}'} not in events
|
|
1686
|
-
assert {"event": "delta", "data": '{"content": "second."}'} in events
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
@pytest.mark.anyio
|
|
1690
|
-
async def test_workspace_persists_automatic_review_result_during_stream(
|
|
1691
|
-
tmp_path, monkeypatch
|
|
1692
|
-
) -> None:
|
|
1693
|
-
monkeypatch.chdir(tmp_path)
|
|
1694
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1695
|
-
work_dir = tmp_path / "work"
|
|
1696
|
-
outside_dir = tmp_path / "outside"
|
|
1697
|
-
work_dir.mkdir()
|
|
1698
|
-
outside_dir.mkdir()
|
|
1699
|
-
target = outside_dir / "notes.txt"
|
|
1700
|
-
target.write_text("alpha\n")
|
|
1701
|
-
patch = f"""*** Begin Patch
|
|
1702
|
-
*** Update File: {target}
|
|
1703
|
-
@@
|
|
1704
|
-
-alpha
|
|
1705
|
-
+beta
|
|
1706
|
-
*** End Patch"""
|
|
1707
|
-
|
|
1708
|
-
review_started = asyncio.Event()
|
|
1709
|
-
finish_review = asyncio.Event()
|
|
1710
|
-
review_payload: dict[str, object] = {}
|
|
1711
|
-
|
|
1712
|
-
async def fake_completion(**request: object) -> object:
|
|
1713
|
-
messages = request["messages"]
|
|
1714
|
-
if messages[0]["content"].startswith("You are Flowent Approval Reviewer"):
|
|
1715
|
-
review_payload.update(json.loads(messages[-1]["content"]))
|
|
1716
|
-
review_started.set()
|
|
1717
|
-
await asyncio.wait_for(finish_review.wait(), timeout=2)
|
|
1718
|
-
return {
|
|
1719
|
-
"choices": [
|
|
1720
|
-
{
|
|
1721
|
-
"message": {
|
|
1722
|
-
"content": json.dumps(
|
|
1723
|
-
{
|
|
1724
|
-
"risk_level": "high",
|
|
1725
|
-
"risk_score": 85,
|
|
1726
|
-
"rationale": "Outside the task scope.",
|
|
1727
|
-
"evidence": [],
|
|
1728
|
-
}
|
|
1729
|
-
),
|
|
1730
|
-
"role": "assistant",
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
]
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
async def chunks() -> object:
|
|
1737
|
-
if request["messages"][-1]["role"] == "user":
|
|
1738
|
-
yield tool_call_chunk(
|
|
1739
|
-
"apply_patch",
|
|
1740
|
-
json.dumps({"patch": patch}),
|
|
1741
|
-
)
|
|
1742
|
-
return
|
|
1743
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
1744
|
-
|
|
1745
|
-
return chunks()
|
|
1746
|
-
|
|
1747
|
-
app = create_app(
|
|
1748
|
-
workdir=work_dir,
|
|
1749
|
-
serve_frontend=False,
|
|
1750
|
-
chat_completion=fake_completion,
|
|
1751
|
-
)
|
|
1752
|
-
async with httpx.AsyncClient(
|
|
1753
|
-
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1754
|
-
) as client:
|
|
1755
|
-
await configure_provider_async(client)
|
|
1756
|
-
response = await client.post(
|
|
1757
|
-
"/api/workspace/runs",
|
|
1758
|
-
json={"content": "Edit notes."},
|
|
1759
|
-
)
|
|
1760
|
-
run_id = response.json()["run_id"]
|
|
1761
|
-
await asyncio.wait_for(review_started.wait(), timeout=2)
|
|
1762
|
-
state = (await client.get("/api/state")).json()
|
|
1763
|
-
finish_review.set()
|
|
1764
|
-
stream_response = await client.get(
|
|
1765
|
-
f"/api/workspace/runs/{run_id}/stream?after={state['active_run_event_index']}"
|
|
1766
|
-
)
|
|
1767
|
-
|
|
1768
|
-
assistant = state["messages"][-1]
|
|
1769
|
-
assert state["active_run_id"] == run_id
|
|
1770
|
-
assert assistant["tools"][0]["name"] == "apply_patch"
|
|
1771
|
-
assert assistant["tools"][0]["status"] == "running"
|
|
1772
|
-
events = stream_events(stream_response.text)
|
|
1773
|
-
tool_error = next(event for event in events if event["event"] == "tool_error")
|
|
1774
|
-
tool_error_data = json.loads(str(tool_error["data"]))
|
|
1775
|
-
assert tool_error_data["data"]["approval"]["decision"] == "denied"
|
|
1776
|
-
assert tool_error_data["data"]["approval"]["reason"] == "Outside the task scope."
|
|
1777
|
-
assert review_payload["user_request"] == "Edit notes."
|
|
1778
|
-
assert target.read_text() == "alpha\n"
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
@pytest.mark.anyio
|
|
1782
|
-
async def test_workspace_review_request_includes_recent_transcript(
|
|
1783
|
-
tmp_path, monkeypatch
|
|
1784
|
-
) -> None:
|
|
1785
|
-
monkeypatch.chdir(tmp_path)
|
|
1786
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1787
|
-
work_dir = tmp_path / "work"
|
|
1788
|
-
work_dir.mkdir()
|
|
1789
|
-
review_payload: dict[str, object] = {}
|
|
1790
|
-
|
|
1791
|
-
async def fake_completion(**request: object) -> object:
|
|
1792
|
-
messages = request["messages"]
|
|
1793
|
-
if messages[0]["content"].startswith("You are Flowent Approval Reviewer"):
|
|
1794
|
-
review_payload.update(json.loads(messages[-1]["content"]))
|
|
1795
|
-
return {
|
|
1796
|
-
"choices": [
|
|
1797
|
-
{
|
|
1798
|
-
"message": {
|
|
1799
|
-
"content": json.dumps(
|
|
1800
|
-
{
|
|
1801
|
-
"risk_level": "high",
|
|
1802
|
-
"risk_score": 85,
|
|
1803
|
-
"rationale": "No run is needed for this test.",
|
|
1804
|
-
"evidence": [],
|
|
1805
|
-
}
|
|
1806
|
-
),
|
|
1807
|
-
"role": "assistant",
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
1810
|
-
]
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
async def chunks() -> object:
|
|
1814
|
-
if request["messages"][-1]["role"] == "user":
|
|
1815
|
-
yield tool_call_chunk(
|
|
1816
|
-
"shell_command",
|
|
1817
|
-
json.dumps(
|
|
1818
|
-
{
|
|
1819
|
-
"additional_permissions": {
|
|
1820
|
-
"file_system": {"write": ["/var/run/docker.sock"]}
|
|
1821
|
-
},
|
|
1822
|
-
"command": (
|
|
1823
|
-
"docker compose up -d --force-recreate flowent"
|
|
1824
|
-
),
|
|
1825
|
-
"sandbox_permissions": "with_additional_permissions",
|
|
1826
|
-
}
|
|
1827
|
-
),
|
|
1828
|
-
)
|
|
1829
|
-
return
|
|
1830
|
-
yield {"choices": [{"delta": {"content": "Stopped."}}]}
|
|
1831
|
-
|
|
1832
|
-
return chunks()
|
|
1833
|
-
|
|
1834
|
-
app = create_app(
|
|
1835
|
-
workdir=work_dir,
|
|
1836
|
-
serve_frontend=False,
|
|
1837
|
-
chat_completion=fake_completion,
|
|
1838
|
-
)
|
|
1839
|
-
async with httpx.AsyncClient(
|
|
1840
|
-
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1841
|
-
) as client:
|
|
1842
|
-
await configure_provider_async(client)
|
|
1843
|
-
await client.put(
|
|
1844
|
-
"/api/workspace/messages",
|
|
1845
|
-
json={
|
|
1846
|
-
"messages": [
|
|
1847
|
-
{
|
|
1848
|
-
"author": "user",
|
|
1849
|
-
"content": "Can you recreate the dev container?",
|
|
1850
|
-
"id": "user-1",
|
|
1851
|
-
},
|
|
1852
|
-
{
|
|
1853
|
-
"author": "assistant",
|
|
1854
|
-
"content": (
|
|
1855
|
-
"This will recreate the Flowent dev container through "
|
|
1856
|
-
"Docker and may briefly interrupt the running service."
|
|
1857
|
-
),
|
|
1858
|
-
"id": "assistant-1",
|
|
1859
|
-
"tools": [
|
|
1860
|
-
{
|
|
1861
|
-
"arguments": {"command": "docker compose ps"},
|
|
1862
|
-
"content": "flowent-dev-preview-flowent-1 running",
|
|
1863
|
-
"data": {},
|
|
1864
|
-
"id": "tool-1",
|
|
1865
|
-
"name": "shell_command",
|
|
1866
|
-
"status": "success",
|
|
1867
|
-
"title": "Ran docker compose ps",
|
|
1868
|
-
}
|
|
1869
|
-
],
|
|
1870
|
-
},
|
|
1871
|
-
]
|
|
1872
|
-
},
|
|
1873
|
-
)
|
|
1874
|
-
response = await client.post(
|
|
1875
|
-
"/api/workspace/runs",
|
|
1876
|
-
json={"content": "确认"},
|
|
1877
|
-
)
|
|
1878
|
-
run_id = response.json()["run_id"]
|
|
1879
|
-
stream_response = await client.get(f"/api/workspace/runs/{run_id}/stream")
|
|
1880
|
-
|
|
1881
|
-
events = stream_events(stream_response.text)
|
|
1882
|
-
assert "tool_error" in [event["event"] for event in events]
|
|
1883
|
-
assert review_payload["user_request"] == "确认"
|
|
1884
|
-
transcript = review_payload["transcript"]
|
|
1885
|
-
assert {"role": "user", "content": "确认"} in transcript
|
|
1886
|
-
assert any(
|
|
1887
|
-
entry["role"] == "assistant" and "briefly interrupt" in entry["content"]
|
|
1888
|
-
for entry in transcript
|
|
1889
|
-
)
|
|
1890
|
-
assert any(
|
|
1891
|
-
entry["role"] == "tool"
|
|
1892
|
-
and entry["name"] == "shell_command"
|
|
1893
|
-
and "flowent-dev-preview-flowent-1" in entry["content"]
|
|
1894
|
-
for entry in transcript
|
|
1895
|
-
)
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
@pytest.mark.anyio
|
|
1899
|
-
async def test_workspace_clear_removes_running_run_draft(tmp_path, monkeypatch) -> None:
|
|
1900
|
-
monkeypatch.chdir(tmp_path)
|
|
1901
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1902
|
-
first_chunk_sent = asyncio.Event()
|
|
1903
|
-
finish_response = asyncio.Event()
|
|
1904
|
-
|
|
1905
|
-
async def fake_completion(**request: object) -> object:
|
|
1906
|
-
async def chunks() -> object:
|
|
1907
|
-
yield {"choices": [{"delta": {"content": "Partial"}}]}
|
|
1908
|
-
first_chunk_sent.set()
|
|
1909
|
-
await finish_response.wait()
|
|
1910
|
-
|
|
1911
|
-
return chunks()
|
|
1912
|
-
|
|
1913
|
-
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1914
|
-
async with httpx.AsyncClient(
|
|
1915
|
-
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1916
|
-
) as client:
|
|
1917
|
-
await configure_provider_async(client)
|
|
1918
|
-
response = await client.post(
|
|
1919
|
-
"/api/workspace/runs",
|
|
1920
|
-
json={"content": "Keep working."},
|
|
1921
|
-
)
|
|
1922
|
-
assert response.status_code == 200
|
|
1923
|
-
await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
|
|
1924
|
-
clear_response = await client.put(
|
|
1925
|
-
"/api/workspace/messages",
|
|
1926
|
-
json={"messages": []},
|
|
1927
|
-
)
|
|
1928
|
-
await asyncio.sleep(0)
|
|
1929
|
-
state = (await client.get("/api/state")).json()
|
|
1930
|
-
|
|
1931
|
-
assert clear_response.status_code == 200
|
|
1932
|
-
assert state["messages"] == []
|
|
1933
|
-
assert state["active_run_id"] is None
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
def test_workspace_response_uses_compaction_checkpoint_after_restart(
|
|
1937
|
-
tmp_path, monkeypatch
|
|
1938
|
-
) -> None:
|
|
1939
|
-
monkeypatch.chdir(tmp_path)
|
|
1940
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1941
|
-
captured_requests: list[dict[str, object]] = []
|
|
1942
|
-
|
|
1943
|
-
async def fake_completion(**request: object) -> object:
|
|
1944
|
-
captured_requests.append(request)
|
|
1945
|
-
if len(captured_requests) == 1:
|
|
1946
|
-
return {
|
|
1947
|
-
"choices": [
|
|
1948
|
-
{
|
|
1949
|
-
"message": {
|
|
1950
|
-
"content": "Checkpoint summary survives restarts.",
|
|
1951
|
-
"role": "assistant",
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
]
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
async def chunks() -> object:
|
|
1958
|
-
yield {"choices": [{"delta": {"content": "Continuing."}}]}
|
|
1959
|
-
|
|
1960
|
-
return chunks()
|
|
1961
|
-
|
|
1962
|
-
client = TestClient(
|
|
1963
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1964
|
-
)
|
|
1965
|
-
configure_provider(client)
|
|
1966
|
-
client.put(
|
|
1967
|
-
"/api/workspace/messages",
|
|
1968
|
-
json={
|
|
1969
|
-
"messages": [
|
|
1970
|
-
{"author": "user", "content": "Original request.", "id": "message-1"},
|
|
1971
|
-
{
|
|
1972
|
-
"author": "assistant",
|
|
1973
|
-
"content": "Original reply.",
|
|
1974
|
-
"id": "message-2",
|
|
1975
|
-
},
|
|
1976
|
-
]
|
|
1977
|
-
},
|
|
1978
|
-
)
|
|
1979
|
-
|
|
1980
|
-
compact_response = client.post("/api/workspace/compact")
|
|
1981
|
-
restarted_client = TestClient(
|
|
1982
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1983
|
-
)
|
|
1984
|
-
response = restarted_client.post(
|
|
1985
|
-
"/api/workspace/respond",
|
|
1986
|
-
json={"content": "Continue after restart."},
|
|
1987
|
-
)
|
|
1988
|
-
|
|
1989
|
-
assert compact_response.status_code == 200
|
|
1990
|
-
assert response.status_code == 200
|
|
1991
|
-
response_messages = captured_requests[1]["messages"]
|
|
1992
|
-
compacted_messages = [
|
|
1993
|
-
message
|
|
1994
|
-
for message in response_messages
|
|
1995
|
-
if str(message["content"]).startswith(
|
|
1996
|
-
"Another language model started working on this Flowent workspace session"
|
|
1997
|
-
)
|
|
1998
|
-
]
|
|
1999
|
-
assert len(compacted_messages) == 1
|
|
2000
|
-
assert "Checkpoint summary survives restarts." in compacted_messages[0]["content"]
|
|
2001
|
-
assert {"role": "user", "content": "Context compacted"} not in response_messages
|
|
2002
|
-
assert response_messages[-1] == {
|
|
2003
|
-
"role": "user",
|
|
2004
|
-
"content": "Continue after restart.",
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
def test_workspace_compact_is_unavailable_while_response_is_running(
|
|
2009
|
-
tmp_path, monkeypatch
|
|
2010
|
-
) -> None:
|
|
2011
|
-
monkeypatch.chdir(tmp_path)
|
|
2012
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
2013
|
-
continue_stream = asyncio.Event()
|
|
2014
|
-
|
|
2015
|
-
async def fake_completion(**request: object) -> object:
|
|
2016
|
-
async def chunks() -> object:
|
|
2017
|
-
yield {"choices": [{"delta": {"content": "Partial."}}]}
|
|
2018
|
-
await asyncio.wait_for(continue_stream.wait(), timeout=2)
|
|
2019
|
-
yield {"choices": [{"delta": {"content": " Done."}}]}
|
|
2020
|
-
|
|
2021
|
-
return chunks()
|
|
2022
|
-
|
|
2023
|
-
async def run_test() -> None:
|
|
2024
|
-
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
2025
|
-
async with httpx.AsyncClient(
|
|
2026
|
-
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
2027
|
-
) as client:
|
|
2028
|
-
await configure_provider_async(client)
|
|
2029
|
-
response_task = asyncio.create_task(
|
|
2030
|
-
client.post("/api/workspace/respond", json={"content": "Start."})
|
|
2031
|
-
)
|
|
2032
|
-
await asyncio.sleep(0)
|
|
2033
|
-
compact_response = await client.post("/api/workspace/compact")
|
|
2034
|
-
continue_stream.set()
|
|
2035
|
-
response = await response_task
|
|
2036
|
-
|
|
2037
|
-
assert compact_response.status_code == 409
|
|
2038
|
-
assert compact_response.json()["detail"] == (
|
|
2039
|
-
"Compact is unavailable while Flowent is responding."
|
|
2040
|
-
)
|
|
2041
|
-
assert response.status_code == 200
|
|
2042
|
-
|
|
2043
|
-
asyncio.run(run_test())
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
def configured_agent_prompt_message(
|
|
2047
|
-
request: dict[str, object],
|
|
2048
|
-
) -> dict[str, object] | None:
|
|
2049
|
-
for message in request["messages"]:
|
|
2050
|
-
if str(message["content"]).startswith("# Flowent configured agent prompt"):
|
|
2051
|
-
return message
|
|
2052
|
-
return None
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
def test_workspace_response_includes_configured_agent_prompt_before_agents_md(
|
|
2056
|
-
tmp_path, monkeypatch
|
|
2057
|
-
) -> None:
|
|
2058
|
-
monkeypatch.chdir(tmp_path)
|
|
2059
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
2060
|
-
(tmp_path / ".git").mkdir()
|
|
2061
|
-
(tmp_path / "AGENTS.md").write_text("Use project instructions.")
|
|
2062
|
-
captured_request: dict[str, object] = {}
|
|
2063
|
-
|
|
2064
|
-
async def fake_completion(**request: object) -> object:
|
|
2065
|
-
captured_request.update(request)
|
|
2066
|
-
|
|
2067
|
-
async def chunks() -> object:
|
|
2068
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
2069
|
-
|
|
2070
|
-
return chunks()
|
|
2071
|
-
|
|
2072
|
-
client = TestClient(
|
|
2073
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
2074
|
-
)
|
|
2075
|
-
configure_provider(client, agent_prompt="Use UI configured instructions first.")
|
|
2076
|
-
|
|
2077
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
2078
|
-
|
|
2079
|
-
assert response.status_code == 200
|
|
2080
|
-
configured_message = configured_agent_prompt_message(captured_request)
|
|
2081
|
-
project_message = project_context_message(captured_request)
|
|
2082
|
-
assert configured_message is not None
|
|
2083
|
-
assert project_message is not None
|
|
2084
|
-
assert configured_message["role"] == "system"
|
|
2085
|
-
assert "Use UI configured instructions first." in configured_message["content"]
|
|
2086
|
-
assert "Use project instructions." in project_message["content"]
|
|
2087
|
-
assert captured_request["messages"].index(configured_message) < captured_request[
|
|
2088
|
-
"messages"
|
|
2089
|
-
].index(project_message)
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
def test_workspace_compacted_response_includes_latest_configured_agent_prompt(
|
|
2093
|
-
tmp_path, monkeypatch
|
|
2094
|
-
) -> None:
|
|
2095
|
-
monkeypatch.chdir(tmp_path)
|
|
2096
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
2097
|
-
captured_requests: list[dict[str, object]] = []
|
|
2098
|
-
|
|
2099
|
-
async def fake_completion(**request: object) -> object:
|
|
2100
|
-
captured_requests.append(request)
|
|
2101
|
-
if len(captured_requests) == 1:
|
|
2102
|
-
return {
|
|
2103
|
-
"choices": [
|
|
2104
|
-
{
|
|
2105
|
-
"message": {
|
|
2106
|
-
"content": "Keep compacted state.",
|
|
2107
|
-
"role": "assistant",
|
|
2108
|
-
}
|
|
2109
|
-
}
|
|
2110
|
-
]
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
async def chunks() -> object:
|
|
2114
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
2115
|
-
|
|
2116
|
-
return chunks()
|
|
2117
|
-
|
|
2118
|
-
client = TestClient(
|
|
2119
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
2120
|
-
)
|
|
2121
|
-
configure_provider(client, agent_prompt="Prompt before compact.")
|
|
2122
|
-
client.put(
|
|
2123
|
-
"/api/workspace/messages",
|
|
2124
|
-
json={
|
|
2125
|
-
"messages": [
|
|
2126
|
-
{"author": "user", "content": "Original request.", "id": "message-1"}
|
|
2127
|
-
]
|
|
2128
|
-
},
|
|
2129
|
-
)
|
|
2130
|
-
|
|
2131
|
-
compact_response = client.post("/api/workspace/compact")
|
|
2132
|
-
client.put(
|
|
2133
|
-
"/api/settings",
|
|
2134
|
-
json={
|
|
2135
|
-
"agent_prompt": "Prompt after compact.",
|
|
2136
|
-
"reasoning_effort": "default",
|
|
2137
|
-
"selected_model": "gpt-5.1",
|
|
2138
|
-
"selected_provider_id": "provider-openai",
|
|
2139
|
-
},
|
|
2140
|
-
)
|
|
2141
|
-
response = client.post("/api/workspace/respond", json={"content": "Continue."})
|
|
2142
|
-
|
|
2143
|
-
assert compact_response.status_code == 200
|
|
2144
|
-
assert response.status_code == 200
|
|
2145
|
-
configured_message = configured_agent_prompt_message(captured_requests[1])
|
|
2146
|
-
assert configured_message is not None
|
|
2147
|
-
assert "Prompt after compact." in configured_message["content"]
|
|
2148
|
-
assert "Prompt before compact." not in configured_message["content"]
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
def test_workspace_response_trims_blank_configured_agent_prompt(
|
|
2152
|
-
tmp_path, monkeypatch
|
|
2153
|
-
) -> None:
|
|
2154
|
-
monkeypatch.chdir(tmp_path)
|
|
2155
|
-
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
2156
|
-
captured_request: dict[str, object] = {}
|
|
2157
|
-
|
|
2158
|
-
async def fake_completion(**request: object) -> object:
|
|
2159
|
-
captured_request.update(request)
|
|
2160
|
-
|
|
2161
|
-
async def chunks() -> object:
|
|
2162
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
2163
|
-
|
|
2164
|
-
return chunks()
|
|
2165
|
-
|
|
2166
|
-
client = TestClient(
|
|
2167
|
-
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
2168
|
-
)
|
|
2169
|
-
configure_provider(client, agent_prompt="\n\n")
|
|
2170
|
-
|
|
2171
|
-
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
2172
|
-
|
|
2173
|
-
assert response.status_code == 200
|
|
2174
|
-
assert configured_agent_prompt_message(captured_request) is None
|