flowent 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/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/agent.py +23 -1
- package/backend/src/flowent/approval.py +148 -0
- package/backend/src/flowent/cli.py +16 -2
- package/backend/src/flowent/compact.py +183 -0
- package/backend/src/flowent/context.py +19 -1
- package/backend/src/flowent/llm.py +51 -11
- package/backend/src/flowent/logging.py +60 -0
- package/backend/src/flowent/main.py +696 -192
- package/backend/src/flowent/mcp.py +3 -1
- package/backend/src/flowent/patch.py +55 -31
- package/backend/src/flowent/paths.py +12 -0
- package/backend/src/flowent/permissions.py +185 -42
- package/backend/src/flowent/sandbox.py +146 -13
- package/backend/src/flowent/static/assets/index-Cl20cARb.css +2 -0
- package/backend/src/flowent/static/assets/index-dsDDsEym.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +257 -9
- 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/test_agent_tools.py +312 -1
- package/backend/tests/test_approval.py +283 -0
- package/backend/tests/test_llm_providers.py +216 -0
- package/backend/tests/test_logging.py +30 -0
- package/backend/tests/test_mcp.py +76 -10
- package/backend/tests/test_patch.py +112 -0
- package/backend/tests/test_permissions.py +198 -53
- package/backend/tests/test_persistence.py +78 -0
- package/backend/tests/test_startup_requirements.py +96 -0
- package/backend/tests/test_workspace_chat.py +1265 -144
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-Cl20cARb.css +2 -0
- package/dist/frontend/assets/index-dsDDsEym.js +81 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +2 -2
- package/backend/src/flowent/static/assets/index-DjF2KBwE.js +0 -81
- package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
- package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
- package/dist/frontend/assets/index-P-bBpJG8.css +0 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import json
|
|
2
3
|
import time
|
|
3
4
|
|
|
4
5
|
import httpx
|
|
@@ -13,6 +14,7 @@ from flowent.sandbox import CommandResult, SandboxRunner
|
|
|
13
14
|
def configure_provider(
|
|
14
15
|
client,
|
|
15
16
|
*,
|
|
17
|
+
agent_prompt: str = "",
|
|
16
18
|
base_url: str = "",
|
|
17
19
|
model: str = "gpt-5.1",
|
|
18
20
|
name: str = "OpenAI",
|
|
@@ -34,6 +36,7 @@ def configure_provider(
|
|
|
34
36
|
client.put(
|
|
35
37
|
"/api/settings",
|
|
36
38
|
json={
|
|
39
|
+
"agent_prompt": agent_prompt,
|
|
37
40
|
"reasoning_effort": reasoning_effort,
|
|
38
41
|
"selected_model": model,
|
|
39
42
|
"selected_provider_id": provider_id,
|
|
@@ -44,6 +47,7 @@ def configure_provider(
|
|
|
44
47
|
async def configure_provider_async(
|
|
45
48
|
client: httpx.AsyncClient,
|
|
46
49
|
*,
|
|
50
|
+
agent_prompt: str = "",
|
|
47
51
|
base_url: str = "",
|
|
48
52
|
model: str = "gpt-5.1",
|
|
49
53
|
name: str = "OpenAI",
|
|
@@ -65,6 +69,7 @@ async def configure_provider_async(
|
|
|
65
69
|
await client.put(
|
|
66
70
|
"/api/settings",
|
|
67
71
|
json={
|
|
72
|
+
"agent_prompt": agent_prompt,
|
|
68
73
|
"reasoning_effort": reasoning_effort,
|
|
69
74
|
"selected_model": model,
|
|
70
75
|
"selected_provider_id": provider_id,
|
|
@@ -314,11 +319,14 @@ def test_workspace_compact_persists_compacted_context(tmp_path, monkeypatch) ->
|
|
|
314
319
|
assert captured_request["model"] == "openai/gpt-5.1"
|
|
315
320
|
assert captured_request["messages"][0] == {
|
|
316
321
|
"role": "system",
|
|
317
|
-
"content": "You are
|
|
322
|
+
"content": "You are performing a context checkpoint compaction for Flowent.",
|
|
318
323
|
}
|
|
319
324
|
assert "AGENTS.md instructions" not in captured_request["messages"][-1]["content"]
|
|
320
325
|
assert "<environment_context>" in captured_request["messages"][-1]["content"]
|
|
321
326
|
assert captured_request["messages"][-1]["role"] == "user"
|
|
327
|
+
assert (
|
|
328
|
+
"CONTEXT CHECKPOINT COMPACTION" in captured_request["messages"][-1]["content"]
|
|
329
|
+
)
|
|
322
330
|
assert "Draft a launch checklist." in captured_request["messages"][-1]["content"]
|
|
323
331
|
assert "Use provider setup first." in captured_request["messages"][-1]["content"]
|
|
324
332
|
|
|
@@ -389,27 +397,46 @@ def test_workspace_response_uses_compacted_context_after_compact(
|
|
|
389
397
|
}
|
|
390
398
|
assert project_context_message(captured_requests[1]) is None
|
|
391
399
|
assert environment_context_message(captured_requests[1])["role"] == "user"
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
400
|
+
compacted_messages = [
|
|
401
|
+
message
|
|
402
|
+
for message in response_messages
|
|
403
|
+
if str(message["content"]).startswith(
|
|
404
|
+
"Another language model started working on this Flowent workspace session"
|
|
405
|
+
)
|
|
396
406
|
]
|
|
407
|
+
assert len(compacted_messages) == 1
|
|
408
|
+
assert "Keep the provider setup decision." in compacted_messages[0]["content"]
|
|
409
|
+
assert response_messages[-1] == {
|
|
410
|
+
"role": "user",
|
|
411
|
+
"content": "Continue from there.",
|
|
412
|
+
}
|
|
413
|
+
assert {"role": "user", "content": "Context compacted"} not in response_messages
|
|
397
414
|
|
|
398
415
|
|
|
399
|
-
def
|
|
416
|
+
def test_workspace_response_auto_compacts_before_next_message(
|
|
400
417
|
tmp_path, monkeypatch
|
|
401
418
|
) -> None:
|
|
402
419
|
monkeypatch.chdir(tmp_path)
|
|
403
420
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
404
|
-
(
|
|
405
|
-
|
|
406
|
-
captured_request: dict[str, object] = {}
|
|
421
|
+
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
|
|
422
|
+
captured_requests: list[dict[str, object]] = []
|
|
407
423
|
|
|
408
424
|
async def fake_completion(**request: object) -> object:
|
|
409
|
-
|
|
425
|
+
captured_requests.append(request)
|
|
426
|
+
if not request.get("stream"):
|
|
427
|
+
return {
|
|
428
|
+
"choices": [
|
|
429
|
+
{
|
|
430
|
+
"message": {
|
|
431
|
+
"content": "Keep the launch plan summary.",
|
|
432
|
+
"role": "assistant",
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
]
|
|
436
|
+
}
|
|
410
437
|
|
|
411
438
|
async def chunks() -> object:
|
|
412
|
-
yield {"choices": [{"delta": {"content": "
|
|
439
|
+
yield {"choices": [{"delta": {"content": "Continuing."}}]}
|
|
413
440
|
|
|
414
441
|
return chunks()
|
|
415
442
|
|
|
@@ -417,45 +444,84 @@ def test_workspace_response_includes_project_and_environment_context(
|
|
|
417
444
|
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
418
445
|
)
|
|
419
446
|
configure_provider(client)
|
|
447
|
+
client.put(
|
|
448
|
+
"/api/workspace/messages",
|
|
449
|
+
json={
|
|
450
|
+
"messages": [
|
|
451
|
+
{
|
|
452
|
+
"author": "user",
|
|
453
|
+
"content": "Original request. " * 80,
|
|
454
|
+
"id": "message-1",
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
"author": "assistant",
|
|
458
|
+
"content": "Detailed work log. " * 80,
|
|
459
|
+
"id": "message-2",
|
|
460
|
+
},
|
|
461
|
+
]
|
|
462
|
+
},
|
|
463
|
+
)
|
|
420
464
|
|
|
421
|
-
response = client.post(
|
|
465
|
+
response = client.post(
|
|
466
|
+
"/api/workspace/respond",
|
|
467
|
+
json={"content": "Continue from there."},
|
|
468
|
+
)
|
|
422
469
|
|
|
423
470
|
assert response.status_code == 200
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
"
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
assert
|
|
441
|
-
assert "
|
|
442
|
-
assert
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
471
|
+
events = stream_events(response.text)
|
|
472
|
+
assert events[0]["event"] == "context_optimized"
|
|
473
|
+
assert json.loads(events[0]["data"])["message"]["content"] == ("Context optimized")
|
|
474
|
+
assert len(captured_requests) == 2
|
|
475
|
+
assert (
|
|
476
|
+
"CONTEXT CHECKPOINT COMPACTION"
|
|
477
|
+
in captured_requests[0]["messages"][-1]["content"]
|
|
478
|
+
)
|
|
479
|
+
response_messages = captured_requests[1]["messages"]
|
|
480
|
+
compacted_messages = [
|
|
481
|
+
message
|
|
482
|
+
for message in response_messages
|
|
483
|
+
if str(message["content"]).startswith(
|
|
484
|
+
"Another language model started working on this Flowent workspace session"
|
|
485
|
+
)
|
|
486
|
+
]
|
|
487
|
+
assert len(compacted_messages) == 1
|
|
488
|
+
assert "Keep the launch plan summary." in compacted_messages[0]["content"]
|
|
489
|
+
assert {"role": "user", "content": "Context optimized"} not in response_messages
|
|
490
|
+
state = client.get("/api/state").json()
|
|
491
|
+
assert [message["content"] for message in state["messages"]][-3:] == [
|
|
492
|
+
"Context optimized",
|
|
493
|
+
"Continue from there.",
|
|
494
|
+
"Continuing.",
|
|
495
|
+
]
|
|
446
496
|
|
|
447
497
|
|
|
448
|
-
def
|
|
498
|
+
def test_workspace_response_auto_compacts_after_tool_result(
|
|
449
499
|
tmp_path, monkeypatch
|
|
450
500
|
) -> None:
|
|
451
501
|
monkeypatch.chdir(tmp_path)
|
|
452
502
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
453
|
-
|
|
503
|
+
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "1200")
|
|
504
|
+
(tmp_path / "notes.txt").write_text("Launch notes. " * 600)
|
|
505
|
+
captured_requests: list[dict[str, object]] = []
|
|
454
506
|
|
|
455
507
|
async def fake_completion(**request: object) -> object:
|
|
456
|
-
|
|
508
|
+
captured_requests.append(request)
|
|
509
|
+
if not request.get("stream"):
|
|
510
|
+
return {
|
|
511
|
+
"choices": [
|
|
512
|
+
{
|
|
513
|
+
"message": {
|
|
514
|
+
"content": "Keep the file findings from notes.txt.",
|
|
515
|
+
"role": "assistant",
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
]
|
|
519
|
+
}
|
|
457
520
|
|
|
458
521
|
async def chunks() -> object:
|
|
522
|
+
if len(captured_requests) == 1:
|
|
523
|
+
yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
|
|
524
|
+
return
|
|
459
525
|
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
460
526
|
|
|
461
527
|
return chunks()
|
|
@@ -463,27 +529,59 @@ def test_workspace_response_uses_selected_reasoning_effort(
|
|
|
463
529
|
client = TestClient(
|
|
464
530
|
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
465
531
|
)
|
|
466
|
-
configure_provider(client
|
|
532
|
+
configure_provider(client)
|
|
467
533
|
|
|
468
|
-
response = client.post(
|
|
534
|
+
response = client.post(
|
|
535
|
+
"/api/workspace/respond",
|
|
536
|
+
json={"content": "Read the launch notes."},
|
|
537
|
+
)
|
|
469
538
|
|
|
470
539
|
assert response.status_code == 200
|
|
471
|
-
|
|
540
|
+
events = stream_events(response.text)
|
|
541
|
+
assert [event["event"] for event in events] == [
|
|
542
|
+
"start",
|
|
543
|
+
"output_start",
|
|
544
|
+
"tool_start",
|
|
545
|
+
"tool_done",
|
|
546
|
+
"context_optimized",
|
|
547
|
+
"output_start",
|
|
548
|
+
"delta",
|
|
549
|
+
"done",
|
|
550
|
+
]
|
|
551
|
+
assert json.loads(events[4]["data"])["message"]["content"] == ("Context optimized")
|
|
552
|
+
assert len(captured_requests) == 3
|
|
553
|
+
assert "Launch notes." in captured_requests[1]["messages"][-1]["content"]
|
|
554
|
+
response_messages = captured_requests[2]["messages"]
|
|
555
|
+
compacted_messages = [
|
|
556
|
+
message
|
|
557
|
+
for message in response_messages
|
|
558
|
+
if str(message["content"]).startswith(
|
|
559
|
+
"Another language model started working on this Flowent workspace session"
|
|
560
|
+
)
|
|
561
|
+
]
|
|
562
|
+
assert len(compacted_messages) == 1
|
|
563
|
+
assert "Keep the file findings from notes.txt." in compacted_messages[0]["content"]
|
|
564
|
+
state = client.get("/api/state").json()
|
|
565
|
+
assert [message["content"] for message in state["messages"]] == [
|
|
566
|
+
"Read the launch notes.",
|
|
567
|
+
"Context optimized",
|
|
568
|
+
"Done.",
|
|
569
|
+
]
|
|
472
570
|
|
|
473
571
|
|
|
474
|
-
def
|
|
572
|
+
def test_workspace_auto_compact_failure_keeps_existing_checkpoint(
|
|
573
|
+
tmp_path, monkeypatch
|
|
574
|
+
) -> None:
|
|
475
575
|
monkeypatch.chdir(tmp_path)
|
|
476
576
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
477
|
-
(
|
|
478
|
-
(tmp_path / "AGENTS.md").write_text("Versioned instructions.")
|
|
479
|
-
(tmp_path / "AGENTS.override.md").write_text("Local override instructions.")
|
|
480
|
-
captured_request: dict[str, object] = {}
|
|
577
|
+
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
|
|
481
578
|
|
|
482
579
|
async def fake_completion(**request: object) -> object:
|
|
483
|
-
|
|
580
|
+
if not request.get("stream"):
|
|
581
|
+
raise RuntimeError("summary failed")
|
|
484
582
|
|
|
485
583
|
async def chunks() -> object:
|
|
486
|
-
yield {"choices": [{"delta": {"content": "
|
|
584
|
+
yield {"choices": [{"delta": {"content": "Should not run."}}]}
|
|
487
585
|
|
|
488
586
|
return chunks()
|
|
489
587
|
|
|
@@ -491,34 +589,60 @@ def test_workspace_response_prefers_agents_override(tmp_path, monkeypatch) -> No
|
|
|
491
589
|
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
492
590
|
)
|
|
493
591
|
configure_provider(client)
|
|
592
|
+
client.put(
|
|
593
|
+
"/api/workspace/messages",
|
|
594
|
+
json={
|
|
595
|
+
"messages": [
|
|
596
|
+
{
|
|
597
|
+
"author": "user",
|
|
598
|
+
"content": "Original request. " * 80,
|
|
599
|
+
"id": "message-1",
|
|
600
|
+
}
|
|
601
|
+
]
|
|
602
|
+
},
|
|
603
|
+
)
|
|
494
604
|
|
|
495
|
-
response = client.post(
|
|
605
|
+
response = client.post(
|
|
606
|
+
"/api/workspace/respond",
|
|
607
|
+
json={"content": "Continue from there."},
|
|
608
|
+
)
|
|
496
609
|
|
|
497
610
|
assert response.status_code == 200
|
|
498
|
-
|
|
499
|
-
assert
|
|
500
|
-
assert
|
|
501
|
-
|
|
611
|
+
events = stream_events(response.text)
|
|
612
|
+
assert events[-1]["event"] == "error"
|
|
613
|
+
assert json.loads(events[-1]["data"])["message"] == (
|
|
614
|
+
"Context could not be optimized."
|
|
615
|
+
)
|
|
616
|
+
state = client.get("/api/state").json()
|
|
617
|
+
assert "Context optimized" not in [
|
|
618
|
+
message["content"] for message in state["messages"]
|
|
619
|
+
]
|
|
502
620
|
|
|
503
621
|
|
|
504
|
-
def
|
|
622
|
+
def test_workspace_response_uses_auto_compaction_checkpoint_after_restart(
|
|
505
623
|
tmp_path, monkeypatch
|
|
506
624
|
) -> None:
|
|
507
|
-
|
|
508
|
-
nested = repo / "packages" / "agent"
|
|
509
|
-
nested.mkdir(parents=True)
|
|
510
|
-
(repo / ".git").mkdir()
|
|
511
|
-
(repo / "AGENTS.md").write_text("Root instructions.")
|
|
512
|
-
(nested / "AGENTS.md").write_text("Nested instructions.")
|
|
513
|
-
monkeypatch.chdir(nested)
|
|
625
|
+
monkeypatch.chdir(tmp_path)
|
|
514
626
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
515
|
-
|
|
627
|
+
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "120")
|
|
628
|
+
captured_requests: list[dict[str, object]] = []
|
|
516
629
|
|
|
517
630
|
async def fake_completion(**request: object) -> object:
|
|
518
|
-
|
|
631
|
+
captured_requests.append(request)
|
|
632
|
+
if not request.get("stream"):
|
|
633
|
+
return {
|
|
634
|
+
"choices": [
|
|
635
|
+
{
|
|
636
|
+
"message": {
|
|
637
|
+
"content": "Auto checkpoint survives restarts.",
|
|
638
|
+
"role": "assistant",
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
]
|
|
642
|
+
}
|
|
519
643
|
|
|
520
644
|
async def chunks() -> object:
|
|
521
|
-
yield {"choices": [{"delta": {"content": "
|
|
645
|
+
yield {"choices": [{"delta": {"content": "Continuing."}}]}
|
|
522
646
|
|
|
523
647
|
return chunks()
|
|
524
648
|
|
|
@@ -526,29 +650,62 @@ def test_workspace_response_merges_project_instructions_from_root_to_cwd(
|
|
|
526
650
|
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
527
651
|
)
|
|
528
652
|
configure_provider(client)
|
|
653
|
+
client.put(
|
|
654
|
+
"/api/workspace/messages",
|
|
655
|
+
json={
|
|
656
|
+
"messages": [
|
|
657
|
+
{
|
|
658
|
+
"author": "user",
|
|
659
|
+
"content": "Original request. " * 80,
|
|
660
|
+
"id": "message-1",
|
|
661
|
+
}
|
|
662
|
+
]
|
|
663
|
+
},
|
|
664
|
+
)
|
|
529
665
|
|
|
530
|
-
|
|
666
|
+
first_response = client.post(
|
|
667
|
+
"/api/workspace/respond",
|
|
668
|
+
json={"content": "Continue from there."},
|
|
669
|
+
)
|
|
670
|
+
monkeypatch.setenv("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "100000")
|
|
671
|
+
restarted_client = TestClient(
|
|
672
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
673
|
+
)
|
|
674
|
+
second_response = restarted_client.post(
|
|
675
|
+
"/api/workspace/respond",
|
|
676
|
+
json={"content": "Continue after restart."},
|
|
677
|
+
)
|
|
531
678
|
|
|
532
|
-
assert
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
679
|
+
assert first_response.status_code == 200
|
|
680
|
+
assert second_response.status_code == 200
|
|
681
|
+
response_messages = captured_requests[2]["messages"]
|
|
682
|
+
compacted_messages = [
|
|
683
|
+
message
|
|
684
|
+
for message in response_messages
|
|
685
|
+
if str(message["content"]).startswith(
|
|
686
|
+
"Another language model started working on this Flowent workspace session"
|
|
687
|
+
)
|
|
688
|
+
]
|
|
689
|
+
assert len(compacted_messages) == 1
|
|
690
|
+
assert "Auto checkpoint survives restarts." in compacted_messages[0]["content"]
|
|
691
|
+
assert {"role": "user", "content": "Context optimized"} not in response_messages
|
|
692
|
+
assert response_messages[-1] == {
|
|
693
|
+
"role": "user",
|
|
694
|
+
"content": "Continue after restart.",
|
|
695
|
+
}
|
|
538
696
|
|
|
539
697
|
|
|
540
|
-
def
|
|
698
|
+
def test_workspace_response_includes_project_and_environment_context(
|
|
541
699
|
tmp_path, monkeypatch
|
|
542
700
|
) -> None:
|
|
543
701
|
monkeypatch.chdir(tmp_path)
|
|
544
702
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
545
703
|
(tmp_path / ".git").mkdir()
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
captured_requests: list[dict[str, object]] = []
|
|
704
|
+
(tmp_path / "AGENTS.md").write_text("Use concise replies.")
|
|
705
|
+
captured_request: dict[str, object] = {}
|
|
549
706
|
|
|
550
707
|
async def fake_completion(**request: object) -> object:
|
|
551
|
-
|
|
708
|
+
captured_request.update(request)
|
|
552
709
|
|
|
553
710
|
async def chunks() -> object:
|
|
554
711
|
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
@@ -560,28 +717,49 @@ def test_workspace_response_uses_updated_project_instructions(
|
|
|
560
717
|
)
|
|
561
718
|
configure_provider(client)
|
|
562
719
|
|
|
563
|
-
|
|
564
|
-
agents_file.write_text("Updated instructions.")
|
|
565
|
-
second_response = client.post("/api/workspace/respond", json={"content": "Second."})
|
|
720
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
566
721
|
|
|
567
|
-
assert
|
|
568
|
-
assert
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
assert
|
|
574
|
-
|
|
575
|
-
|
|
722
|
+
assert response.status_code == 200
|
|
723
|
+
assert captured_request["messages"][0] == {
|
|
724
|
+
"role": "system",
|
|
725
|
+
"content": FLOWENT_AGENT_SYSTEM_PROMPT,
|
|
726
|
+
}
|
|
727
|
+
project_message = project_context_message(captured_request)
|
|
728
|
+
assert project_message == {
|
|
729
|
+
"role": "user",
|
|
730
|
+
"content": (
|
|
731
|
+
f"# AGENTS.md instructions for {tmp_path}\n\n"
|
|
732
|
+
"<INSTRUCTIONS>\nUse concise replies.\n</INSTRUCTIONS>"
|
|
733
|
+
),
|
|
734
|
+
}
|
|
735
|
+
environment_message = environment_context_message(captured_request)
|
|
736
|
+
assert environment_message["role"] == "user"
|
|
737
|
+
assert f"<cwd>{tmp_path}</cwd>" in environment_message["content"]
|
|
738
|
+
assert "<filesystem>workspace-write</filesystem>" in environment_message["content"]
|
|
739
|
+
assert "<network>enabled</network>" in environment_message["content"]
|
|
740
|
+
assert "<tool>read_file</tool>" in environment_message["content"]
|
|
741
|
+
assert captured_request["messages"][-1] == {
|
|
742
|
+
"role": "user",
|
|
743
|
+
"content": "Hello.",
|
|
744
|
+
}
|
|
576
745
|
|
|
577
746
|
|
|
578
|
-
def
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
747
|
+
def test_workspace_response_uses_flowent_workdir(tmp_path, monkeypatch) -> None:
|
|
748
|
+
launch_dir = tmp_path / "launch"
|
|
749
|
+
workdir = tmp_path / "workspace"
|
|
750
|
+
data_dir = tmp_path / "data"
|
|
751
|
+
launch_dir.mkdir()
|
|
752
|
+
workdir.mkdir()
|
|
753
|
+
monkeypatch.chdir(launch_dir)
|
|
754
|
+
monkeypatch.setenv("FLOWENT_WORKDIR", str(workdir))
|
|
755
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
|
|
756
|
+
(workdir / ".git").mkdir()
|
|
757
|
+
(workdir / "AGENTS.md").write_text("Use workspace instructions.")
|
|
758
|
+
captured_request: dict[str, object] = {}
|
|
583
759
|
|
|
584
760
|
async def fake_completion(**request: object) -> object:
|
|
761
|
+
captured_request.update(request)
|
|
762
|
+
|
|
585
763
|
async def chunks() -> object:
|
|
586
764
|
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
587
765
|
|
|
@@ -593,13 +771,244 @@ def test_workspace_context_is_not_persisted_in_state(tmp_path, monkeypatch) -> N
|
|
|
593
771
|
configure_provider(client)
|
|
594
772
|
|
|
595
773
|
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
596
|
-
state = client.get("/api/state").json()
|
|
597
774
|
|
|
598
775
|
assert response.status_code == 200
|
|
599
|
-
|
|
600
|
-
assert
|
|
601
|
-
|
|
602
|
-
|
|
776
|
+
project_message = project_context_message(captured_request)
|
|
777
|
+
assert project_message == {
|
|
778
|
+
"role": "user",
|
|
779
|
+
"content": (
|
|
780
|
+
f"# AGENTS.md instructions for {workdir}\n\n"
|
|
781
|
+
"<INSTRUCTIONS>\nUse workspace instructions.\n</INSTRUCTIONS>"
|
|
782
|
+
),
|
|
783
|
+
}
|
|
784
|
+
environment_message = environment_context_message(captured_request)
|
|
785
|
+
assert f"<cwd>{workdir}</cwd>" in environment_message["content"]
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def test_create_app_workdir_overrides_flowent_workdir(tmp_path, monkeypatch) -> None:
|
|
789
|
+
env_workdir = tmp_path / "env-workspace"
|
|
790
|
+
app_workdir = tmp_path / "app-workspace"
|
|
791
|
+
data_dir = tmp_path / "data"
|
|
792
|
+
env_workdir.mkdir()
|
|
793
|
+
app_workdir.mkdir()
|
|
794
|
+
monkeypatch.setenv("FLOWENT_WORKDIR", str(env_workdir))
|
|
795
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
|
|
796
|
+
(env_workdir / ".git").mkdir()
|
|
797
|
+
(app_workdir / ".git").mkdir()
|
|
798
|
+
(env_workdir / "AGENTS.md").write_text("Use env instructions.")
|
|
799
|
+
(app_workdir / "AGENTS.md").write_text("Use app instructions.")
|
|
800
|
+
captured_request: dict[str, object] = {}
|
|
801
|
+
|
|
802
|
+
async def fake_completion(**request: object) -> object:
|
|
803
|
+
captured_request.update(request)
|
|
804
|
+
|
|
805
|
+
async def chunks() -> object:
|
|
806
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
807
|
+
|
|
808
|
+
return chunks()
|
|
809
|
+
|
|
810
|
+
client = TestClient(
|
|
811
|
+
create_app(
|
|
812
|
+
serve_frontend=False,
|
|
813
|
+
chat_completion=fake_completion,
|
|
814
|
+
workdir=app_workdir,
|
|
815
|
+
)
|
|
816
|
+
)
|
|
817
|
+
configure_provider(client)
|
|
818
|
+
|
|
819
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
820
|
+
|
|
821
|
+
assert response.status_code == 200
|
|
822
|
+
project_message = project_context_message(captured_request)
|
|
823
|
+
assert project_message is not None
|
|
824
|
+
assert "Use app instructions." in project_message["content"]
|
|
825
|
+
assert "Use env instructions." not in project_message["content"]
|
|
826
|
+
environment_message = environment_context_message(captured_request)
|
|
827
|
+
assert f"<cwd>{app_workdir}</cwd>" in environment_message["content"]
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def test_workspace_workdir_does_not_change_data_directory(
|
|
831
|
+
tmp_path, monkeypatch
|
|
832
|
+
) -> None:
|
|
833
|
+
workdir = tmp_path / "workspace"
|
|
834
|
+
data_dir = tmp_path / "data"
|
|
835
|
+
workdir.mkdir()
|
|
836
|
+
monkeypatch.setenv("FLOWENT_WORKDIR", str(workdir))
|
|
837
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(data_dir))
|
|
838
|
+
|
|
839
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
840
|
+
response = client.post(
|
|
841
|
+
"/api/providers",
|
|
842
|
+
json={
|
|
843
|
+
"api_key": "sk-local",
|
|
844
|
+
"base_url": "",
|
|
845
|
+
"id": "provider-openai",
|
|
846
|
+
"models": ["gpt-5.1"],
|
|
847
|
+
"name": "OpenAI",
|
|
848
|
+
"type": "openai",
|
|
849
|
+
},
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
assert response.status_code == 200
|
|
853
|
+
assert (data_dir / "flowent.db").is_file()
|
|
854
|
+
assert not (workdir / "flowent.db").exists()
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def test_workspace_response_uses_selected_reasoning_effort(
|
|
858
|
+
tmp_path, monkeypatch
|
|
859
|
+
) -> None:
|
|
860
|
+
monkeypatch.chdir(tmp_path)
|
|
861
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
862
|
+
captured_request: dict[str, object] = {}
|
|
863
|
+
|
|
864
|
+
async def fake_completion(**request: object) -> object:
|
|
865
|
+
captured_request.update(request)
|
|
866
|
+
|
|
867
|
+
async def chunks() -> object:
|
|
868
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
869
|
+
|
|
870
|
+
return chunks()
|
|
871
|
+
|
|
872
|
+
client = TestClient(
|
|
873
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
874
|
+
)
|
|
875
|
+
configure_provider(client, reasoning_effort="xhigh")
|
|
876
|
+
|
|
877
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
878
|
+
|
|
879
|
+
assert response.status_code == 200
|
|
880
|
+
assert captured_request["reasoning_effort"] == "xhigh"
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def test_workspace_response_prefers_agents_override(tmp_path, monkeypatch) -> None:
|
|
884
|
+
monkeypatch.chdir(tmp_path)
|
|
885
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
886
|
+
(tmp_path / ".git").mkdir()
|
|
887
|
+
(tmp_path / "AGENTS.md").write_text("Versioned instructions.")
|
|
888
|
+
(tmp_path / "AGENTS.override.md").write_text("Local override instructions.")
|
|
889
|
+
captured_request: dict[str, object] = {}
|
|
890
|
+
|
|
891
|
+
async def fake_completion(**request: object) -> object:
|
|
892
|
+
captured_request.update(request)
|
|
893
|
+
|
|
894
|
+
async def chunks() -> object:
|
|
895
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
896
|
+
|
|
897
|
+
return chunks()
|
|
898
|
+
|
|
899
|
+
client = TestClient(
|
|
900
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
901
|
+
)
|
|
902
|
+
configure_provider(client)
|
|
903
|
+
|
|
904
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
905
|
+
|
|
906
|
+
assert response.status_code == 200
|
|
907
|
+
project_message = project_context_message(captured_request)
|
|
908
|
+
assert project_message is not None
|
|
909
|
+
assert "Local override instructions." in project_message["content"]
|
|
910
|
+
assert "Versioned instructions." not in project_message["content"]
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def test_workspace_response_merges_project_instructions_from_root_to_cwd(
|
|
914
|
+
tmp_path, monkeypatch
|
|
915
|
+
) -> None:
|
|
916
|
+
repo = tmp_path / "repo"
|
|
917
|
+
nested = repo / "packages" / "agent"
|
|
918
|
+
nested.mkdir(parents=True)
|
|
919
|
+
(repo / ".git").mkdir()
|
|
920
|
+
(repo / "AGENTS.md").write_text("Root instructions.")
|
|
921
|
+
(nested / "AGENTS.md").write_text("Nested instructions.")
|
|
922
|
+
monkeypatch.chdir(nested)
|
|
923
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
924
|
+
captured_request: dict[str, object] = {}
|
|
925
|
+
|
|
926
|
+
async def fake_completion(**request: object) -> object:
|
|
927
|
+
captured_request.update(request)
|
|
928
|
+
|
|
929
|
+
async def chunks() -> object:
|
|
930
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
931
|
+
|
|
932
|
+
return chunks()
|
|
933
|
+
|
|
934
|
+
client = TestClient(
|
|
935
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
936
|
+
)
|
|
937
|
+
configure_provider(client)
|
|
938
|
+
|
|
939
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
940
|
+
|
|
941
|
+
assert response.status_code == 200
|
|
942
|
+
project_message = project_context_message(captured_request)
|
|
943
|
+
assert project_message is not None
|
|
944
|
+
assert project_message["content"].index("Root instructions.") < project_message[
|
|
945
|
+
"content"
|
|
946
|
+
].index("Nested instructions.")
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def test_workspace_response_uses_updated_project_instructions(
|
|
950
|
+
tmp_path, monkeypatch
|
|
951
|
+
) -> None:
|
|
952
|
+
monkeypatch.chdir(tmp_path)
|
|
953
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
954
|
+
(tmp_path / ".git").mkdir()
|
|
955
|
+
agents_file = tmp_path / "AGENTS.md"
|
|
956
|
+
agents_file.write_text("Old instructions.")
|
|
957
|
+
captured_requests: list[dict[str, object]] = []
|
|
958
|
+
|
|
959
|
+
async def fake_completion(**request: object) -> object:
|
|
960
|
+
captured_requests.append(request)
|
|
961
|
+
|
|
962
|
+
async def chunks() -> object:
|
|
963
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
964
|
+
|
|
965
|
+
return chunks()
|
|
966
|
+
|
|
967
|
+
client = TestClient(
|
|
968
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
969
|
+
)
|
|
970
|
+
configure_provider(client)
|
|
971
|
+
|
|
972
|
+
first_response = client.post("/api/workspace/respond", json={"content": "First."})
|
|
973
|
+
agents_file.write_text("Updated instructions.")
|
|
974
|
+
second_response = client.post("/api/workspace/respond", json={"content": "Second."})
|
|
975
|
+
|
|
976
|
+
assert first_response.status_code == 200
|
|
977
|
+
assert second_response.status_code == 200
|
|
978
|
+
first_project_message = project_context_message(captured_requests[0])
|
|
979
|
+
second_project_message = project_context_message(captured_requests[1])
|
|
980
|
+
assert first_project_message is not None
|
|
981
|
+
assert second_project_message is not None
|
|
982
|
+
assert "Old instructions." in first_project_message["content"]
|
|
983
|
+
assert "Updated instructions." in second_project_message["content"]
|
|
984
|
+
assert "Old instructions." not in second_project_message["content"]
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def test_workspace_context_is_not_persisted_in_state(tmp_path, monkeypatch) -> None:
|
|
988
|
+
monkeypatch.chdir(tmp_path)
|
|
989
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
990
|
+
(tmp_path / ".git").mkdir()
|
|
991
|
+
(tmp_path / "AGENTS.md").write_text("Hidden instructions.")
|
|
992
|
+
|
|
993
|
+
async def fake_completion(**request: object) -> object:
|
|
994
|
+
async def chunks() -> object:
|
|
995
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
996
|
+
|
|
997
|
+
return chunks()
|
|
998
|
+
|
|
999
|
+
client = TestClient(
|
|
1000
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1001
|
+
)
|
|
1002
|
+
configure_provider(client)
|
|
1003
|
+
|
|
1004
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
1005
|
+
state = client.get("/api/state").json()
|
|
1006
|
+
|
|
1007
|
+
assert response.status_code == 200
|
|
1008
|
+
persisted_content = "\n".join(message["content"] for message in state["messages"])
|
|
1009
|
+
assert "Hidden instructions." not in persisted_content
|
|
1010
|
+
assert "<environment_context>" not in persisted_content
|
|
1011
|
+
|
|
603
1012
|
|
|
604
1013
|
def test_workspace_clear_keeps_runtime_context_available(tmp_path, monkeypatch) -> None:
|
|
605
1014
|
monkeypatch.chdir(tmp_path)
|
|
@@ -687,10 +1096,15 @@ def test_workspace_compacted_response_includes_latest_runtime_context(
|
|
|
687
1096
|
assert project_message is not None
|
|
688
1097
|
assert "Instructions after compact." in project_message["content"]
|
|
689
1098
|
assert environment_context_message(captured_requests[1])["role"] == "user"
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1099
|
+
compacted_messages = [
|
|
1100
|
+
message
|
|
1101
|
+
for message in response_messages
|
|
1102
|
+
if str(message["content"]).startswith(
|
|
1103
|
+
"Another language model started working on this Flowent workspace session"
|
|
1104
|
+
)
|
|
1105
|
+
]
|
|
1106
|
+
assert len(compacted_messages) == 1
|
|
1107
|
+
assert "Keep compacted state." in compacted_messages[0]["content"]
|
|
694
1108
|
|
|
695
1109
|
|
|
696
1110
|
def test_project_instructions_are_truncated_to_size_limit(
|
|
@@ -845,19 +1259,28 @@ def test_workspace_persists_failed_draft_when_stream_errors(
|
|
|
845
1259
|
assert assistant["author"] == "assistant"
|
|
846
1260
|
assert assistant["content"] == "Partial answer."
|
|
847
1261
|
assert assistant["status"] == "failed"
|
|
1262
|
+
assert assistant["groups"][-1] == {
|
|
1263
|
+
"id": f"{assistant['id']}-errors",
|
|
1264
|
+
"items": [
|
|
1265
|
+
{
|
|
1266
|
+
"detail": "provider stopped",
|
|
1267
|
+
"id": f"{assistant['id']}-error-1",
|
|
1268
|
+
"message": "Check the model connection settings and try again.",
|
|
1269
|
+
"title": "Request failed",
|
|
1270
|
+
"type": "error",
|
|
1271
|
+
}
|
|
1272
|
+
],
|
|
1273
|
+
}
|
|
848
1274
|
|
|
849
1275
|
|
|
850
|
-
def
|
|
1276
|
+
def test_workspace_persists_error_block_when_model_fails_before_output(
|
|
851
1277
|
tmp_path, monkeypatch
|
|
852
1278
|
) -> None:
|
|
853
1279
|
monkeypatch.chdir(tmp_path)
|
|
854
1280
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
855
1281
|
|
|
856
1282
|
async def fake_completion(**request: object) -> object:
|
|
857
|
-
|
|
858
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
859
|
-
|
|
860
|
-
return chunks()
|
|
1283
|
+
raise RuntimeError("provider unavailable")
|
|
861
1284
|
|
|
862
1285
|
client = TestClient(
|
|
863
1286
|
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
@@ -867,61 +1290,309 @@ def test_workspace_marks_draft_complete_when_stream_finishes(
|
|
|
867
1290
|
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
868
1291
|
|
|
869
1292
|
assert response.status_code == 200
|
|
1293
|
+
events = stream_events(response.text)
|
|
1294
|
+
assert events[-1]["event"] == "error"
|
|
1295
|
+
assistant_id = json.loads(events[0]["data"])["id"]
|
|
1296
|
+
assert json.loads(events[-1]["data"]) == {
|
|
1297
|
+
"error": {
|
|
1298
|
+
"detail": "provider unavailable",
|
|
1299
|
+
"id": f"{assistant_id}-error-1",
|
|
1300
|
+
"message": "Check the model connection settings and try again.",
|
|
1301
|
+
"title": "Request failed",
|
|
1302
|
+
"type": "error",
|
|
1303
|
+
},
|
|
1304
|
+
"message": "Check the model connection settings and try again.",
|
|
1305
|
+
}
|
|
870
1306
|
state = client.get("/api/state").json()
|
|
871
1307
|
assistant = state["messages"][-1]
|
|
872
1308
|
assert assistant["author"] == "assistant"
|
|
873
|
-
assert assistant["content"] == "
|
|
874
|
-
assert assistant
|
|
1309
|
+
assert assistant["content"] == ""
|
|
1310
|
+
assert assistant["status"] == "failed"
|
|
1311
|
+
assert assistant["groups"] == [
|
|
1312
|
+
{
|
|
1313
|
+
"id": f"{assistant['id']}-group-1",
|
|
1314
|
+
"items": [],
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
"id": f"{assistant['id']}-errors",
|
|
1318
|
+
"items": [
|
|
1319
|
+
{
|
|
1320
|
+
"detail": "provider unavailable",
|
|
1321
|
+
"id": f"{assistant['id']}-error-1",
|
|
1322
|
+
"message": "Check the model connection settings and try again.",
|
|
1323
|
+
"title": "Request failed",
|
|
1324
|
+
"type": "error",
|
|
1325
|
+
}
|
|
1326
|
+
],
|
|
1327
|
+
},
|
|
1328
|
+
]
|
|
875
1329
|
|
|
876
1330
|
|
|
877
|
-
|
|
878
|
-
async def test_workspace_run_continues_without_stream_consumer(
|
|
1331
|
+
def test_workspace_treats_empty_model_result_as_failed_error_block(
|
|
879
1332
|
tmp_path, monkeypatch
|
|
880
1333
|
) -> None:
|
|
881
1334
|
monkeypatch.chdir(tmp_path)
|
|
882
1335
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
883
|
-
first_chunk_sent = asyncio.Event()
|
|
884
|
-
finish_response = asyncio.Event()
|
|
885
1336
|
|
|
886
1337
|
async def fake_completion(**request: object) -> object:
|
|
887
1338
|
async def chunks() -> object:
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
await asyncio.wait_for(finish_response.wait(), timeout=2)
|
|
891
|
-
yield {"choices": [{"delta": {"content": "answer."}}]}
|
|
1339
|
+
if False:
|
|
1340
|
+
yield {}
|
|
892
1341
|
|
|
893
1342
|
return chunks()
|
|
894
1343
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
)
|
|
899
|
-
await configure_provider_async(client)
|
|
900
|
-
response = await client.post(
|
|
901
|
-
"/api/workspace/runs",
|
|
902
|
-
json={"content": "Keep working."},
|
|
903
|
-
)
|
|
904
|
-
assert response.status_code == 200
|
|
905
|
-
await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
|
|
906
|
-
finish_response.set()
|
|
907
|
-
|
|
908
|
-
for _ in range(20):
|
|
909
|
-
state = (await client.get("/api/state")).json()
|
|
910
|
-
assistant = state["messages"][-1]
|
|
911
|
-
if (
|
|
912
|
-
assistant["author"] == "assistant"
|
|
913
|
-
and assistant.get("status", "completed") == "completed"
|
|
914
|
-
):
|
|
915
|
-
break
|
|
916
|
-
await asyncio.sleep(0.05)
|
|
917
|
-
else:
|
|
918
|
-
raise AssertionError("Workspace run did not complete.")
|
|
919
|
-
|
|
920
|
-
assert assistant["content"] == "Partial answer."
|
|
1344
|
+
client = TestClient(
|
|
1345
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1346
|
+
)
|
|
1347
|
+
configure_provider(client)
|
|
921
1348
|
|
|
1349
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
922
1350
|
|
|
923
|
-
|
|
924
|
-
|
|
1351
|
+
assert response.status_code == 200
|
|
1352
|
+
events = stream_events(response.text)
|
|
1353
|
+
assert events[-1]["event"] == "error"
|
|
1354
|
+
state = client.get("/api/state").json()
|
|
1355
|
+
assistant = state["messages"][-1]
|
|
1356
|
+
assert assistant["status"] == "failed"
|
|
1357
|
+
assert assistant["groups"][-1]["items"] == [
|
|
1358
|
+
{
|
|
1359
|
+
"detail": "The model did not return a response.",
|
|
1360
|
+
"id": f"{assistant['id']}-error-1",
|
|
1361
|
+
"message": "Check the model connection settings and try again.",
|
|
1362
|
+
"title": "Request failed",
|
|
1363
|
+
"type": "error",
|
|
1364
|
+
}
|
|
1365
|
+
]
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def test_workspace_includes_previous_error_summary_in_next_request(
|
|
1369
|
+
tmp_path, monkeypatch
|
|
1370
|
+
) -> None:
|
|
1371
|
+
monkeypatch.chdir(tmp_path)
|
|
1372
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1373
|
+
captured_requests: list[dict[str, object]] = []
|
|
1374
|
+
|
|
1375
|
+
async def fake_completion(**request: object) -> object:
|
|
1376
|
+
captured_requests.append(request)
|
|
1377
|
+
|
|
1378
|
+
async def chunks() -> object:
|
|
1379
|
+
yield {"choices": [{"delta": {"content": "Recovered."}}]}
|
|
1380
|
+
|
|
1381
|
+
return chunks()
|
|
1382
|
+
|
|
1383
|
+
client = TestClient(
|
|
1384
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1385
|
+
)
|
|
1386
|
+
configure_provider(client)
|
|
1387
|
+
client.put(
|
|
1388
|
+
"/api/workspace/messages",
|
|
1389
|
+
json={
|
|
1390
|
+
"messages": [
|
|
1391
|
+
{
|
|
1392
|
+
"author": "user",
|
|
1393
|
+
"content": "Try once.",
|
|
1394
|
+
"id": "message-user-1",
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
"author": "assistant",
|
|
1398
|
+
"content": "",
|
|
1399
|
+
"groups": [
|
|
1400
|
+
{
|
|
1401
|
+
"id": "message-assistant-1-errors",
|
|
1402
|
+
"items": [
|
|
1403
|
+
{
|
|
1404
|
+
"detail": "HTML response returned.",
|
|
1405
|
+
"id": "message-assistant-1-error-1",
|
|
1406
|
+
"message": "Check the model connection settings and try again.",
|
|
1407
|
+
"title": "Request failed",
|
|
1408
|
+
"type": "error",
|
|
1409
|
+
}
|
|
1410
|
+
],
|
|
1411
|
+
}
|
|
1412
|
+
],
|
|
1413
|
+
"id": "message-assistant-1",
|
|
1414
|
+
"status": "failed",
|
|
1415
|
+
},
|
|
1416
|
+
]
|
|
1417
|
+
},
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
response = client.post("/api/workspace/respond", json={"content": "Try again."})
|
|
1421
|
+
|
|
1422
|
+
assert response.status_code == 200
|
|
1423
|
+
request_messages = captured_requests[0]["messages"]
|
|
1424
|
+
assert {
|
|
1425
|
+
"role": "assistant",
|
|
1426
|
+
"content": "Previous response failed: Request failed. Check the model connection settings and try again. Detail: HTML response returned.",
|
|
1427
|
+
} in request_messages
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def test_workspace_marks_running_tool_failed_when_stream_errors(
|
|
1431
|
+
tmp_path, monkeypatch
|
|
1432
|
+
) -> None:
|
|
1433
|
+
monkeypatch.chdir(tmp_path)
|
|
1434
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1435
|
+
|
|
1436
|
+
async def fake_run_async(self, command, **kwargs):
|
|
1437
|
+
raise RuntimeError("sandbox failed")
|
|
1438
|
+
|
|
1439
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
1440
|
+
|
|
1441
|
+
async def fake_completion(**request: object) -> object:
|
|
1442
|
+
async def chunks() -> object:
|
|
1443
|
+
yield tool_call_chunk("shell_command", '{"command": "boom"}')
|
|
1444
|
+
|
|
1445
|
+
return chunks()
|
|
1446
|
+
|
|
1447
|
+
client = TestClient(
|
|
1448
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1449
|
+
)
|
|
1450
|
+
configure_provider(client)
|
|
1451
|
+
|
|
1452
|
+
response = client.post("/api/workspace/respond", json={"content": "Run it."})
|
|
1453
|
+
|
|
1454
|
+
assert response.status_code == 200
|
|
1455
|
+
events = stream_events(response.text)
|
|
1456
|
+
assert events[-1]["event"] == "error"
|
|
1457
|
+
state = client.get("/api/state").json()
|
|
1458
|
+
assistant = state["messages"][-1]
|
|
1459
|
+
assert assistant["status"] == "failed"
|
|
1460
|
+
assert assistant["tools"][0]["name"] == "shell_command"
|
|
1461
|
+
assert assistant["tools"][0]["status"] == "failed"
|
|
1462
|
+
assert "sandbox failed" in assistant["tools"][0]["content"]
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def test_workspace_marks_draft_complete_when_stream_finishes(
|
|
1466
|
+
tmp_path, monkeypatch
|
|
1467
|
+
) -> None:
|
|
1468
|
+
monkeypatch.chdir(tmp_path)
|
|
1469
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1470
|
+
|
|
1471
|
+
async def fake_completion(**request: object) -> object:
|
|
1472
|
+
async def chunks() -> object:
|
|
1473
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
1474
|
+
|
|
1475
|
+
return chunks()
|
|
1476
|
+
|
|
1477
|
+
client = TestClient(
|
|
1478
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1479
|
+
)
|
|
1480
|
+
configure_provider(client)
|
|
1481
|
+
|
|
1482
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
1483
|
+
|
|
1484
|
+
assert response.status_code == 200
|
|
1485
|
+
state = client.get("/api/state").json()
|
|
1486
|
+
assistant = state["messages"][-1]
|
|
1487
|
+
assert assistant["author"] == "assistant"
|
|
1488
|
+
assert assistant["content"] == "Done."
|
|
1489
|
+
assert assistant.get("status", "completed") == "completed"
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def test_workspace_persists_assistant_output_groups_after_tool_round(
|
|
1493
|
+
tmp_path, monkeypatch
|
|
1494
|
+
) -> None:
|
|
1495
|
+
monkeypatch.chdir(tmp_path)
|
|
1496
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1497
|
+
(tmp_path / "notes.txt").write_text("Launch notes")
|
|
1498
|
+
completion_calls = 0
|
|
1499
|
+
|
|
1500
|
+
async def fake_completion(**request: object) -> object:
|
|
1501
|
+
nonlocal completion_calls
|
|
1502
|
+
completion_calls += 1
|
|
1503
|
+
|
|
1504
|
+
async def chunks() -> object:
|
|
1505
|
+
if completion_calls == 1:
|
|
1506
|
+
yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
|
|
1507
|
+
return
|
|
1508
|
+
yield {"choices": [{"delta": {"content": "The notes are ready."}}]}
|
|
1509
|
+
|
|
1510
|
+
return chunks()
|
|
1511
|
+
|
|
1512
|
+
client = TestClient(
|
|
1513
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1514
|
+
)
|
|
1515
|
+
configure_provider(client)
|
|
1516
|
+
|
|
1517
|
+
response = client.post("/api/workspace/respond", json={"content": "Read notes."})
|
|
1518
|
+
state = client.get("/api/state").json()
|
|
1519
|
+
assistant = state["messages"][-1]
|
|
1520
|
+
tool_id = assistant["tools"][0]["id"]
|
|
1521
|
+
|
|
1522
|
+
assert response.status_code == 200
|
|
1523
|
+
assert assistant["content"] == "The notes are ready."
|
|
1524
|
+
assert assistant["groups"] == [
|
|
1525
|
+
{
|
|
1526
|
+
"id": f"{assistant['id']}-group-1",
|
|
1527
|
+
"items": [
|
|
1528
|
+
{
|
|
1529
|
+
"id": f"tool-{tool_id}",
|
|
1530
|
+
"tool": assistant["tools"][0],
|
|
1531
|
+
"type": "tool",
|
|
1532
|
+
}
|
|
1533
|
+
],
|
|
1534
|
+
},
|
|
1535
|
+
{
|
|
1536
|
+
"id": f"{assistant['id']}-group-2",
|
|
1537
|
+
"items": [
|
|
1538
|
+
{
|
|
1539
|
+
"content": "The notes are ready.",
|
|
1540
|
+
"id": f"{assistant['id']}-text-1",
|
|
1541
|
+
"type": "text",
|
|
1542
|
+
}
|
|
1543
|
+
],
|
|
1544
|
+
},
|
|
1545
|
+
]
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
@pytest.mark.anyio
|
|
1549
|
+
async def test_workspace_run_continues_without_stream_consumer(
|
|
1550
|
+
tmp_path, monkeypatch
|
|
1551
|
+
) -> None:
|
|
1552
|
+
monkeypatch.chdir(tmp_path)
|
|
1553
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1554
|
+
first_chunk_sent = asyncio.Event()
|
|
1555
|
+
finish_response = asyncio.Event()
|
|
1556
|
+
|
|
1557
|
+
async def fake_completion(**request: object) -> object:
|
|
1558
|
+
async def chunks() -> object:
|
|
1559
|
+
yield {"choices": [{"delta": {"content": "Partial "}}]}
|
|
1560
|
+
first_chunk_sent.set()
|
|
1561
|
+
await asyncio.wait_for(finish_response.wait(), timeout=2)
|
|
1562
|
+
yield {"choices": [{"delta": {"content": "answer."}}]}
|
|
1563
|
+
|
|
1564
|
+
return chunks()
|
|
1565
|
+
|
|
1566
|
+
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1567
|
+
async with httpx.AsyncClient(
|
|
1568
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1569
|
+
) as client:
|
|
1570
|
+
await configure_provider_async(client)
|
|
1571
|
+
response = await client.post(
|
|
1572
|
+
"/api/workspace/runs",
|
|
1573
|
+
json={"content": "Keep working."},
|
|
1574
|
+
)
|
|
1575
|
+
assert response.status_code == 200
|
|
1576
|
+
await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
|
|
1577
|
+
finish_response.set()
|
|
1578
|
+
|
|
1579
|
+
for _ in range(20):
|
|
1580
|
+
state = (await client.get("/api/state")).json()
|
|
1581
|
+
assistant = state["messages"][-1]
|
|
1582
|
+
if (
|
|
1583
|
+
assistant["author"] == "assistant"
|
|
1584
|
+
and assistant.get("status", "completed") == "completed"
|
|
1585
|
+
):
|
|
1586
|
+
break
|
|
1587
|
+
await asyncio.sleep(0.05)
|
|
1588
|
+
else:
|
|
1589
|
+
raise AssertionError("Workspace run did not complete.")
|
|
1590
|
+
|
|
1591
|
+
assert assistant["content"] == "Partial answer."
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
@pytest.mark.anyio
|
|
1595
|
+
async def test_workspace_state_exposes_active_run_for_reconnect(
|
|
925
1596
|
tmp_path, monkeypatch
|
|
926
1597
|
) -> None:
|
|
927
1598
|
monkeypatch.chdir(tmp_path)
|
|
@@ -963,6 +1634,215 @@ async def test_workspace_state_exposes_active_run_for_reconnect(
|
|
|
963
1634
|
assert {"event": "delta", "data": '{"content": "second."}'} in events
|
|
964
1635
|
|
|
965
1636
|
|
|
1637
|
+
@pytest.mark.anyio
|
|
1638
|
+
async def test_workspace_persists_automatic_review_result_during_stream(
|
|
1639
|
+
tmp_path, monkeypatch
|
|
1640
|
+
) -> None:
|
|
1641
|
+
monkeypatch.chdir(tmp_path)
|
|
1642
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1643
|
+
work_dir = tmp_path / "work"
|
|
1644
|
+
outside_dir = tmp_path / "outside"
|
|
1645
|
+
work_dir.mkdir()
|
|
1646
|
+
outside_dir.mkdir()
|
|
1647
|
+
target = outside_dir / "notes.txt"
|
|
1648
|
+
target.write_text("alpha\n")
|
|
1649
|
+
patch = f"""*** Begin Patch
|
|
1650
|
+
*** Update File: {target}
|
|
1651
|
+
@@
|
|
1652
|
+
-alpha
|
|
1653
|
+
+beta
|
|
1654
|
+
*** End Patch"""
|
|
1655
|
+
|
|
1656
|
+
review_started = asyncio.Event()
|
|
1657
|
+
finish_review = asyncio.Event()
|
|
1658
|
+
review_payload: dict[str, object] = {}
|
|
1659
|
+
|
|
1660
|
+
async def fake_completion(**request: object) -> object:
|
|
1661
|
+
messages = request["messages"]
|
|
1662
|
+
if messages[0]["content"].startswith("You are Flowent Approval Reviewer"):
|
|
1663
|
+
review_payload.update(json.loads(messages[-1]["content"]))
|
|
1664
|
+
review_started.set()
|
|
1665
|
+
await asyncio.wait_for(finish_review.wait(), timeout=2)
|
|
1666
|
+
return {
|
|
1667
|
+
"choices": [
|
|
1668
|
+
{
|
|
1669
|
+
"message": {
|
|
1670
|
+
"content": json.dumps(
|
|
1671
|
+
{
|
|
1672
|
+
"risk_level": "high",
|
|
1673
|
+
"risk_score": 85,
|
|
1674
|
+
"rationale": "Outside the task scope.",
|
|
1675
|
+
"evidence": [],
|
|
1676
|
+
}
|
|
1677
|
+
),
|
|
1678
|
+
"role": "assistant",
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
]
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
async def chunks() -> object:
|
|
1685
|
+
if request["messages"][-1]["role"] == "user":
|
|
1686
|
+
yield tool_call_chunk(
|
|
1687
|
+
"apply_patch",
|
|
1688
|
+
json.dumps({"patch": patch}),
|
|
1689
|
+
)
|
|
1690
|
+
return
|
|
1691
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
1692
|
+
|
|
1693
|
+
return chunks()
|
|
1694
|
+
|
|
1695
|
+
app = create_app(
|
|
1696
|
+
workdir=work_dir,
|
|
1697
|
+
serve_frontend=False,
|
|
1698
|
+
chat_completion=fake_completion,
|
|
1699
|
+
)
|
|
1700
|
+
async with httpx.AsyncClient(
|
|
1701
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1702
|
+
) as client:
|
|
1703
|
+
await configure_provider_async(client)
|
|
1704
|
+
response = await client.post(
|
|
1705
|
+
"/api/workspace/runs",
|
|
1706
|
+
json={"content": "Edit notes."},
|
|
1707
|
+
)
|
|
1708
|
+
run_id = response.json()["run_id"]
|
|
1709
|
+
await asyncio.wait_for(review_started.wait(), timeout=2)
|
|
1710
|
+
state = (await client.get("/api/state")).json()
|
|
1711
|
+
finish_review.set()
|
|
1712
|
+
stream_response = await client.get(
|
|
1713
|
+
f"/api/workspace/runs/{run_id}/stream?after={state['active_run_event_index']}"
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
assistant = state["messages"][-1]
|
|
1717
|
+
assert state["active_run_id"] == run_id
|
|
1718
|
+
assert assistant["tools"][0]["name"] == "apply_patch"
|
|
1719
|
+
assert assistant["tools"][0]["status"] == "running"
|
|
1720
|
+
events = stream_events(stream_response.text)
|
|
1721
|
+
tool_error = next(event for event in events if event["event"] == "tool_error")
|
|
1722
|
+
tool_error_data = json.loads(str(tool_error["data"]))
|
|
1723
|
+
assert tool_error_data["data"]["approval"]["decision"] == "denied"
|
|
1724
|
+
assert tool_error_data["data"]["approval"]["reason"] == "Outside the task scope."
|
|
1725
|
+
assert review_payload["user_request"] == "Edit notes."
|
|
1726
|
+
assert target.read_text() == "alpha\n"
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
@pytest.mark.anyio
|
|
1730
|
+
async def test_workspace_review_request_includes_recent_transcript(
|
|
1731
|
+
tmp_path, monkeypatch
|
|
1732
|
+
) -> None:
|
|
1733
|
+
monkeypatch.chdir(tmp_path)
|
|
1734
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1735
|
+
work_dir = tmp_path / "work"
|
|
1736
|
+
work_dir.mkdir()
|
|
1737
|
+
review_payload: dict[str, object] = {}
|
|
1738
|
+
|
|
1739
|
+
async def fake_completion(**request: object) -> object:
|
|
1740
|
+
messages = request["messages"]
|
|
1741
|
+
if messages[0]["content"].startswith("You are Flowent Approval Reviewer"):
|
|
1742
|
+
review_payload.update(json.loads(messages[-1]["content"]))
|
|
1743
|
+
return {
|
|
1744
|
+
"choices": [
|
|
1745
|
+
{
|
|
1746
|
+
"message": {
|
|
1747
|
+
"content": json.dumps(
|
|
1748
|
+
{
|
|
1749
|
+
"risk_level": "high",
|
|
1750
|
+
"risk_score": 85,
|
|
1751
|
+
"rationale": "No run is needed for this test.",
|
|
1752
|
+
"evidence": [],
|
|
1753
|
+
}
|
|
1754
|
+
),
|
|
1755
|
+
"role": "assistant",
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
]
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async def chunks() -> object:
|
|
1762
|
+
if request["messages"][-1]["role"] == "user":
|
|
1763
|
+
yield tool_call_chunk(
|
|
1764
|
+
"shell_command",
|
|
1765
|
+
json.dumps(
|
|
1766
|
+
{
|
|
1767
|
+
"additional_permissions": {
|
|
1768
|
+
"file_system": {"write": ["/var/run/docker.sock"]}
|
|
1769
|
+
},
|
|
1770
|
+
"command": (
|
|
1771
|
+
"docker compose up -d --force-recreate flowent"
|
|
1772
|
+
),
|
|
1773
|
+
"sandbox_permissions": "with_additional_permissions",
|
|
1774
|
+
}
|
|
1775
|
+
),
|
|
1776
|
+
)
|
|
1777
|
+
return
|
|
1778
|
+
yield {"choices": [{"delta": {"content": "Stopped."}}]}
|
|
1779
|
+
|
|
1780
|
+
return chunks()
|
|
1781
|
+
|
|
1782
|
+
app = create_app(
|
|
1783
|
+
workdir=work_dir,
|
|
1784
|
+
serve_frontend=False,
|
|
1785
|
+
chat_completion=fake_completion,
|
|
1786
|
+
)
|
|
1787
|
+
async with httpx.AsyncClient(
|
|
1788
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1789
|
+
) as client:
|
|
1790
|
+
await configure_provider_async(client)
|
|
1791
|
+
await client.put(
|
|
1792
|
+
"/api/workspace/messages",
|
|
1793
|
+
json={
|
|
1794
|
+
"messages": [
|
|
1795
|
+
{
|
|
1796
|
+
"author": "user",
|
|
1797
|
+
"content": "Can you recreate the dev container?",
|
|
1798
|
+
"id": "user-1",
|
|
1799
|
+
},
|
|
1800
|
+
{
|
|
1801
|
+
"author": "assistant",
|
|
1802
|
+
"content": (
|
|
1803
|
+
"This will recreate the Flowent dev container through "
|
|
1804
|
+
"Docker and may briefly interrupt the running service."
|
|
1805
|
+
),
|
|
1806
|
+
"id": "assistant-1",
|
|
1807
|
+
"tools": [
|
|
1808
|
+
{
|
|
1809
|
+
"arguments": {"command": "docker compose ps"},
|
|
1810
|
+
"content": "flowent-dev-preview-flowent-1 running",
|
|
1811
|
+
"data": {},
|
|
1812
|
+
"id": "tool-1",
|
|
1813
|
+
"name": "shell_command",
|
|
1814
|
+
"status": "success",
|
|
1815
|
+
"title": "Ran docker compose ps",
|
|
1816
|
+
}
|
|
1817
|
+
],
|
|
1818
|
+
},
|
|
1819
|
+
]
|
|
1820
|
+
},
|
|
1821
|
+
)
|
|
1822
|
+
response = await client.post(
|
|
1823
|
+
"/api/workspace/runs",
|
|
1824
|
+
json={"content": "确认"},
|
|
1825
|
+
)
|
|
1826
|
+
run_id = response.json()["run_id"]
|
|
1827
|
+
stream_response = await client.get(f"/api/workspace/runs/{run_id}/stream")
|
|
1828
|
+
|
|
1829
|
+
events = stream_events(stream_response.text)
|
|
1830
|
+
assert "tool_error" in [event["event"] for event in events]
|
|
1831
|
+
assert review_payload["user_request"] == "确认"
|
|
1832
|
+
transcript = review_payload["transcript"]
|
|
1833
|
+
assert {"role": "user", "content": "确认"} in transcript
|
|
1834
|
+
assert any(
|
|
1835
|
+
entry["role"] == "assistant" and "briefly interrupt" in entry["content"]
|
|
1836
|
+
for entry in transcript
|
|
1837
|
+
)
|
|
1838
|
+
assert any(
|
|
1839
|
+
entry["role"] == "tool"
|
|
1840
|
+
and entry["name"] == "shell_command"
|
|
1841
|
+
and "flowent-dev-preview-flowent-1" in entry["content"]
|
|
1842
|
+
for entry in transcript
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
|
|
966
1846
|
@pytest.mark.anyio
|
|
967
1847
|
async def test_workspace_clear_removes_running_run_draft(tmp_path, monkeypatch) -> None:
|
|
968
1848
|
monkeypatch.chdir(tmp_path)
|
|
@@ -999,3 +1879,244 @@ async def test_workspace_clear_removes_running_run_draft(tmp_path, monkeypatch)
|
|
|
999
1879
|
assert clear_response.status_code == 200
|
|
1000
1880
|
assert state["messages"] == []
|
|
1001
1881
|
assert state["active_run_id"] is None
|
|
1882
|
+
|
|
1883
|
+
|
|
1884
|
+
def test_workspace_response_uses_compaction_checkpoint_after_restart(
|
|
1885
|
+
tmp_path, monkeypatch
|
|
1886
|
+
) -> None:
|
|
1887
|
+
monkeypatch.chdir(tmp_path)
|
|
1888
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1889
|
+
captured_requests: list[dict[str, object]] = []
|
|
1890
|
+
|
|
1891
|
+
async def fake_completion(**request: object) -> object:
|
|
1892
|
+
captured_requests.append(request)
|
|
1893
|
+
if len(captured_requests) == 1:
|
|
1894
|
+
return {
|
|
1895
|
+
"choices": [
|
|
1896
|
+
{
|
|
1897
|
+
"message": {
|
|
1898
|
+
"content": "Checkpoint summary survives restarts.",
|
|
1899
|
+
"role": "assistant",
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
]
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
async def chunks() -> object:
|
|
1906
|
+
yield {"choices": [{"delta": {"content": "Continuing."}}]}
|
|
1907
|
+
|
|
1908
|
+
return chunks()
|
|
1909
|
+
|
|
1910
|
+
client = TestClient(
|
|
1911
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1912
|
+
)
|
|
1913
|
+
configure_provider(client)
|
|
1914
|
+
client.put(
|
|
1915
|
+
"/api/workspace/messages",
|
|
1916
|
+
json={
|
|
1917
|
+
"messages": [
|
|
1918
|
+
{"author": "user", "content": "Original request.", "id": "message-1"},
|
|
1919
|
+
{
|
|
1920
|
+
"author": "assistant",
|
|
1921
|
+
"content": "Original reply.",
|
|
1922
|
+
"id": "message-2",
|
|
1923
|
+
},
|
|
1924
|
+
]
|
|
1925
|
+
},
|
|
1926
|
+
)
|
|
1927
|
+
|
|
1928
|
+
compact_response = client.post("/api/workspace/compact")
|
|
1929
|
+
restarted_client = TestClient(
|
|
1930
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1931
|
+
)
|
|
1932
|
+
response = restarted_client.post(
|
|
1933
|
+
"/api/workspace/respond",
|
|
1934
|
+
json={"content": "Continue after restart."},
|
|
1935
|
+
)
|
|
1936
|
+
|
|
1937
|
+
assert compact_response.status_code == 200
|
|
1938
|
+
assert response.status_code == 200
|
|
1939
|
+
response_messages = captured_requests[1]["messages"]
|
|
1940
|
+
compacted_messages = [
|
|
1941
|
+
message
|
|
1942
|
+
for message in response_messages
|
|
1943
|
+
if str(message["content"]).startswith(
|
|
1944
|
+
"Another language model started working on this Flowent workspace session"
|
|
1945
|
+
)
|
|
1946
|
+
]
|
|
1947
|
+
assert len(compacted_messages) == 1
|
|
1948
|
+
assert "Checkpoint summary survives restarts." in compacted_messages[0]["content"]
|
|
1949
|
+
assert {"role": "user", "content": "Context compacted"} not in response_messages
|
|
1950
|
+
assert response_messages[-1] == {
|
|
1951
|
+
"role": "user",
|
|
1952
|
+
"content": "Continue after restart.",
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def test_workspace_compact_is_unavailable_while_response_is_running(
|
|
1957
|
+
tmp_path, monkeypatch
|
|
1958
|
+
) -> None:
|
|
1959
|
+
monkeypatch.chdir(tmp_path)
|
|
1960
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1961
|
+
continue_stream = asyncio.Event()
|
|
1962
|
+
|
|
1963
|
+
async def fake_completion(**request: object) -> object:
|
|
1964
|
+
async def chunks() -> object:
|
|
1965
|
+
yield {"choices": [{"delta": {"content": "Partial."}}]}
|
|
1966
|
+
await asyncio.wait_for(continue_stream.wait(), timeout=2)
|
|
1967
|
+
yield {"choices": [{"delta": {"content": " Done."}}]}
|
|
1968
|
+
|
|
1969
|
+
return chunks()
|
|
1970
|
+
|
|
1971
|
+
async def run_test() -> None:
|
|
1972
|
+
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1973
|
+
async with httpx.AsyncClient(
|
|
1974
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
1975
|
+
) as client:
|
|
1976
|
+
await configure_provider_async(client)
|
|
1977
|
+
response_task = asyncio.create_task(
|
|
1978
|
+
client.post("/api/workspace/respond", json={"content": "Start."})
|
|
1979
|
+
)
|
|
1980
|
+
await asyncio.sleep(0)
|
|
1981
|
+
compact_response = await client.post("/api/workspace/compact")
|
|
1982
|
+
continue_stream.set()
|
|
1983
|
+
response = await response_task
|
|
1984
|
+
|
|
1985
|
+
assert compact_response.status_code == 409
|
|
1986
|
+
assert compact_response.json()["detail"] == (
|
|
1987
|
+
"Compact is unavailable while Flowent is responding."
|
|
1988
|
+
)
|
|
1989
|
+
assert response.status_code == 200
|
|
1990
|
+
|
|
1991
|
+
asyncio.run(run_test())
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
def configured_agent_prompt_message(
|
|
1995
|
+
request: dict[str, object],
|
|
1996
|
+
) -> dict[str, object] | None:
|
|
1997
|
+
for message in request["messages"]:
|
|
1998
|
+
if str(message["content"]).startswith("# Flowent configured agent prompt"):
|
|
1999
|
+
return message
|
|
2000
|
+
return None
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def test_workspace_response_includes_configured_agent_prompt_before_agents_md(
|
|
2004
|
+
tmp_path, monkeypatch
|
|
2005
|
+
) -> None:
|
|
2006
|
+
monkeypatch.chdir(tmp_path)
|
|
2007
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
2008
|
+
(tmp_path / ".git").mkdir()
|
|
2009
|
+
(tmp_path / "AGENTS.md").write_text("Use project instructions.")
|
|
2010
|
+
captured_request: dict[str, object] = {}
|
|
2011
|
+
|
|
2012
|
+
async def fake_completion(**request: object) -> object:
|
|
2013
|
+
captured_request.update(request)
|
|
2014
|
+
|
|
2015
|
+
async def chunks() -> object:
|
|
2016
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
2017
|
+
|
|
2018
|
+
return chunks()
|
|
2019
|
+
|
|
2020
|
+
client = TestClient(
|
|
2021
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
2022
|
+
)
|
|
2023
|
+
configure_provider(client, agent_prompt="Use UI configured instructions first.")
|
|
2024
|
+
|
|
2025
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
2026
|
+
|
|
2027
|
+
assert response.status_code == 200
|
|
2028
|
+
configured_message = configured_agent_prompt_message(captured_request)
|
|
2029
|
+
project_message = project_context_message(captured_request)
|
|
2030
|
+
assert configured_message is not None
|
|
2031
|
+
assert project_message is not None
|
|
2032
|
+
assert configured_message["role"] == "system"
|
|
2033
|
+
assert "Use UI configured instructions first." in configured_message["content"]
|
|
2034
|
+
assert "Use project instructions." in project_message["content"]
|
|
2035
|
+
assert captured_request["messages"].index(configured_message) < captured_request[
|
|
2036
|
+
"messages"
|
|
2037
|
+
].index(project_message)
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
def test_workspace_compacted_response_includes_latest_configured_agent_prompt(
|
|
2041
|
+
tmp_path, monkeypatch
|
|
2042
|
+
) -> None:
|
|
2043
|
+
monkeypatch.chdir(tmp_path)
|
|
2044
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
2045
|
+
captured_requests: list[dict[str, object]] = []
|
|
2046
|
+
|
|
2047
|
+
async def fake_completion(**request: object) -> object:
|
|
2048
|
+
captured_requests.append(request)
|
|
2049
|
+
if len(captured_requests) == 1:
|
|
2050
|
+
return {
|
|
2051
|
+
"choices": [
|
|
2052
|
+
{
|
|
2053
|
+
"message": {
|
|
2054
|
+
"content": "Keep compacted state.",
|
|
2055
|
+
"role": "assistant",
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
]
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
async def chunks() -> object:
|
|
2062
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
2063
|
+
|
|
2064
|
+
return chunks()
|
|
2065
|
+
|
|
2066
|
+
client = TestClient(
|
|
2067
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
2068
|
+
)
|
|
2069
|
+
configure_provider(client, agent_prompt="Prompt before compact.")
|
|
2070
|
+
client.put(
|
|
2071
|
+
"/api/workspace/messages",
|
|
2072
|
+
json={
|
|
2073
|
+
"messages": [
|
|
2074
|
+
{"author": "user", "content": "Original request.", "id": "message-1"}
|
|
2075
|
+
]
|
|
2076
|
+
},
|
|
2077
|
+
)
|
|
2078
|
+
|
|
2079
|
+
compact_response = client.post("/api/workspace/compact")
|
|
2080
|
+
client.put(
|
|
2081
|
+
"/api/settings",
|
|
2082
|
+
json={
|
|
2083
|
+
"agent_prompt": "Prompt after compact.",
|
|
2084
|
+
"reasoning_effort": "default",
|
|
2085
|
+
"selected_model": "gpt-5.1",
|
|
2086
|
+
"selected_provider_id": "provider-openai",
|
|
2087
|
+
},
|
|
2088
|
+
)
|
|
2089
|
+
response = client.post("/api/workspace/respond", json={"content": "Continue."})
|
|
2090
|
+
|
|
2091
|
+
assert compact_response.status_code == 200
|
|
2092
|
+
assert response.status_code == 200
|
|
2093
|
+
configured_message = configured_agent_prompt_message(captured_requests[1])
|
|
2094
|
+
assert configured_message is not None
|
|
2095
|
+
assert "Prompt after compact." in configured_message["content"]
|
|
2096
|
+
assert "Prompt before compact." not in configured_message["content"]
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def test_workspace_response_trims_blank_configured_agent_prompt(
|
|
2100
|
+
tmp_path, monkeypatch
|
|
2101
|
+
) -> None:
|
|
2102
|
+
monkeypatch.chdir(tmp_path)
|
|
2103
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
2104
|
+
captured_request: dict[str, object] = {}
|
|
2105
|
+
|
|
2106
|
+
async def fake_completion(**request: object) -> object:
|
|
2107
|
+
captured_request.update(request)
|
|
2108
|
+
|
|
2109
|
+
async def chunks() -> object:
|
|
2110
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
2111
|
+
|
|
2112
|
+
return chunks()
|
|
2113
|
+
|
|
2114
|
+
client = TestClient(
|
|
2115
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
2116
|
+
)
|
|
2117
|
+
configure_provider(client, agent_prompt="\n\n")
|
|
2118
|
+
|
|
2119
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
2120
|
+
|
|
2121
|
+
assert response.status_code == 200
|
|
2122
|
+
assert configured_agent_prompt_message(captured_request) is None
|