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.
Files changed (67) hide show
  1. package/backend/pyproject.toml +31 -5
  2. package/backend/src/flowent/agent.py +13 -4
  3. package/backend/src/flowent/compact.py +35 -14
  4. package/backend/src/flowent/llm.py +73 -7
  5. package/backend/src/flowent/main.py +260 -59
  6. package/backend/src/flowent/static/assets/index-CRSV2xu1.css +2 -0
  7. package/backend/src/flowent/static/assets/index-DUYj6rgD.js +82 -0
  8. package/backend/src/flowent/static/index.html +2 -2
  9. package/backend/src/flowent/storage.py +135 -3
  10. package/backend/src/flowent/usage.py +315 -0
  11. package/backend/uv.lock +971 -3
  12. package/dist/frontend/assets/index-CRSV2xu1.css +2 -0
  13. package/dist/frontend/assets/index-DUYj6rgD.js +82 -0
  14. package/dist/frontend/index.html +2 -2
  15. package/package.json +24 -3
  16. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  32. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  33. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  34. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  35. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  36. package/backend/src/flowent/static/assets/index-BlaCigkZ.js +0 -82
  37. package/backend/src/flowent/static/assets/index-CRvbsH4K.css +0 -2
  38. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  44. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  45. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  46. package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
  47. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  48. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  49. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  50. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  51. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  52. package/backend/tests/conftest.py +0 -60
  53. package/backend/tests/test_agent_tools.py +0 -1124
  54. package/backend/tests/test_approval.py +0 -283
  55. package/backend/tests/test_channels.py +0 -360
  56. package/backend/tests/test_health.py +0 -12
  57. package/backend/tests/test_llm_providers.py +0 -548
  58. package/backend/tests/test_logging.py +0 -212
  59. package/backend/tests/test_mcp.py +0 -788
  60. package/backend/tests/test_patch.py +0 -112
  61. package/backend/tests/test_permissions.py +0 -588
  62. package/backend/tests/test_persistence.py +0 -249
  63. package/backend/tests/test_skills.py +0 -462
  64. package/backend/tests/test_startup_requirements.py +0 -144
  65. package/backend/tests/test_workspace_chat.py +0 -2174
  66. package/dist/frontend/assets/index-BlaCigkZ.js +0 -82
  67. 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