flowent 0.1.4 → 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 +4 -2
- 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 +639 -210
- package/backend/src/flowent/patch.py +55 -31
- package/backend/src/flowent/permissions.py +185 -42
- package/backend/src/flowent/sandbox.py +55 -1
- 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 +113 -18
- 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 +77 -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_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 +54 -0
- package/backend/tests/test_workspace_chat.py +855 -41
- 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 +1 -1
- package/backend/src/flowent/static/assets/index-BREidonU.css +0 -2
- package/backend/src/flowent/static/assets/index-DSniOrhL.js +0 -81
- package/dist/frontend/assets/index-BREidonU.css +0 -2
- package/dist/frontend/assets/index-DSniOrhL.js +0 -81
|
@@ -14,6 +14,7 @@ from flowent.sandbox import CommandResult, SandboxRunner
|
|
|
14
14
|
def configure_provider(
|
|
15
15
|
client,
|
|
16
16
|
*,
|
|
17
|
+
agent_prompt: str = "",
|
|
17
18
|
base_url: str = "",
|
|
18
19
|
model: str = "gpt-5.1",
|
|
19
20
|
name: str = "OpenAI",
|
|
@@ -35,6 +36,7 @@ def configure_provider(
|
|
|
35
36
|
client.put(
|
|
36
37
|
"/api/settings",
|
|
37
38
|
json={
|
|
39
|
+
"agent_prompt": agent_prompt,
|
|
38
40
|
"reasoning_effort": reasoning_effort,
|
|
39
41
|
"selected_model": model,
|
|
40
42
|
"selected_provider_id": provider_id,
|
|
@@ -45,6 +47,7 @@ def configure_provider(
|
|
|
45
47
|
async def configure_provider_async(
|
|
46
48
|
client: httpx.AsyncClient,
|
|
47
49
|
*,
|
|
50
|
+
agent_prompt: str = "",
|
|
48
51
|
base_url: str = "",
|
|
49
52
|
model: str = "gpt-5.1",
|
|
50
53
|
name: str = "OpenAI",
|
|
@@ -66,6 +69,7 @@ async def configure_provider_async(
|
|
|
66
69
|
await client.put(
|
|
67
70
|
"/api/settings",
|
|
68
71
|
json={
|
|
72
|
+
"agent_prompt": agent_prompt,
|
|
69
73
|
"reasoning_effort": reasoning_effort,
|
|
70
74
|
"selected_model": model,
|
|
71
75
|
"selected_provider_id": provider_id,
|
|
@@ -409,6 +413,288 @@ def test_workspace_response_uses_compacted_context_after_compact(
|
|
|
409
413
|
assert {"role": "user", "content": "Context compacted"} not in response_messages
|
|
410
414
|
|
|
411
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
|
+
|
|
412
698
|
def test_workspace_response_includes_project_and_environment_context(
|
|
413
699
|
tmp_path, monkeypatch
|
|
414
700
|
) -> None:
|
|
@@ -973,19 +1259,28 @@ def test_workspace_persists_failed_draft_when_stream_errors(
|
|
|
973
1259
|
assert assistant["author"] == "assistant"
|
|
974
1260
|
assert assistant["content"] == "Partial answer."
|
|
975
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
|
+
}
|
|
976
1274
|
|
|
977
1275
|
|
|
978
|
-
def
|
|
1276
|
+
def test_workspace_persists_error_block_when_model_fails_before_output(
|
|
979
1277
|
tmp_path, monkeypatch
|
|
980
1278
|
) -> None:
|
|
981
1279
|
monkeypatch.chdir(tmp_path)
|
|
982
1280
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
983
1281
|
|
|
984
1282
|
async def fake_completion(**request: object) -> object:
|
|
985
|
-
|
|
986
|
-
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
987
|
-
|
|
988
|
-
return chunks()
|
|
1283
|
+
raise RuntimeError("provider unavailable")
|
|
989
1284
|
|
|
990
1285
|
client = TestClient(
|
|
991
1286
|
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
@@ -995,39 +1290,287 @@ def test_workspace_marks_draft_complete_when_stream_finishes(
|
|
|
995
1290
|
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
996
1291
|
|
|
997
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
|
+
}
|
|
998
1306
|
state = client.get("/api/state").json()
|
|
999
1307
|
assistant = state["messages"][-1]
|
|
1000
1308
|
assert assistant["author"] == "assistant"
|
|
1001
|
-
assert assistant["content"] == "
|
|
1002
|
-
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
|
+
]
|
|
1003
1329
|
|
|
1004
1330
|
|
|
1005
|
-
|
|
1006
|
-
async def test_workspace_run_continues_without_stream_consumer(
|
|
1331
|
+
def test_workspace_treats_empty_model_result_as_failed_error_block(
|
|
1007
1332
|
tmp_path, monkeypatch
|
|
1008
1333
|
) -> None:
|
|
1009
1334
|
monkeypatch.chdir(tmp_path)
|
|
1010
1335
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
1011
|
-
first_chunk_sent = asyncio.Event()
|
|
1012
|
-
finish_response = asyncio.Event()
|
|
1013
1336
|
|
|
1014
1337
|
async def fake_completion(**request: object) -> object:
|
|
1015
1338
|
async def chunks() -> object:
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
await asyncio.wait_for(finish_response.wait(), timeout=2)
|
|
1019
|
-
yield {"choices": [{"delta": {"content": "answer."}}]}
|
|
1339
|
+
if False:
|
|
1340
|
+
yield {}
|
|
1020
1341
|
|
|
1021
1342
|
return chunks()
|
|
1022
1343
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
)
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1344
|
+
client = TestClient(
|
|
1345
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
1346
|
+
)
|
|
1347
|
+
configure_provider(client)
|
|
1348
|
+
|
|
1349
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
1350
|
+
|
|
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."},
|
|
1031
1574
|
)
|
|
1032
1575
|
assert response.status_code == 200
|
|
1033
1576
|
await asyncio.wait_for(first_chunk_sent.wait(), timeout=2)
|
|
@@ -1092,7 +1635,7 @@ async def test_workspace_state_exposes_active_run_for_reconnect(
|
|
|
1092
1635
|
|
|
1093
1636
|
|
|
1094
1637
|
@pytest.mark.anyio
|
|
1095
|
-
async def
|
|
1638
|
+
async def test_workspace_persists_automatic_review_result_during_stream(
|
|
1096
1639
|
tmp_path, monkeypatch
|
|
1097
1640
|
) -> None:
|
|
1098
1641
|
monkeypatch.chdir(tmp_path)
|
|
@@ -1110,7 +1653,34 @@ async def test_workspace_state_exposes_pending_permission_for_reconnect(
|
|
|
1110
1653
|
+beta
|
|
1111
1654
|
*** End Patch"""
|
|
1112
1655
|
|
|
1656
|
+
review_started = asyncio.Event()
|
|
1657
|
+
finish_review = asyncio.Event()
|
|
1658
|
+
review_payload: dict[str, object] = {}
|
|
1659
|
+
|
|
1113
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
|
+
|
|
1114
1684
|
async def chunks() -> object:
|
|
1115
1685
|
if request["messages"][-1]["role"] == "user":
|
|
1116
1686
|
yield tool_call_chunk(
|
|
@@ -1136,28 +1706,141 @@ async def test_workspace_state_exposes_pending_permission_for_reconnect(
|
|
|
1136
1706
|
json={"content": "Edit notes."},
|
|
1137
1707
|
)
|
|
1138
1708
|
run_id = response.json()["run_id"]
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
await asyncio.sleep(0.05)
|
|
1145
|
-
else:
|
|
1146
|
-
raise AssertionError("Permission request was not exposed.")
|
|
1147
|
-
|
|
1148
|
-
request = state["permission_requests"][0]
|
|
1149
|
-
await client.post(
|
|
1150
|
-
"/api/workspace/permissions/approve",
|
|
1151
|
-
json={"decision": "deny", "id": request["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']}"
|
|
1152
1714
|
)
|
|
1153
1715
|
|
|
1154
1716
|
assistant = state["messages"][-1]
|
|
1155
1717
|
assert state["active_run_id"] == run_id
|
|
1156
|
-
assert request["path"] == str(outside_dir)
|
|
1157
|
-
assert request["reason"] == "The edit needs to write this path."
|
|
1158
|
-
assert request["tool_call_id"] == assistant["tools"][0]["id"]
|
|
1159
1718
|
assert assistant["tools"][0]["name"] == "apply_patch"
|
|
1160
|
-
assert assistant["tools"][0]["status"] == "
|
|
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
|
+
)
|
|
1161
1844
|
|
|
1162
1845
|
|
|
1163
1846
|
@pytest.mark.anyio
|
|
@@ -1306,3 +1989,134 @@ def test_workspace_compact_is_unavailable_while_response_is_running(
|
|
|
1306
1989
|
assert response.status_code == 200
|
|
1307
1990
|
|
|
1308
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
|