flowent 0.0.10 → 0.0.12
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/README.md +14 -0
- package/backend/README.md +14 -0
- package/backend/pyproject.toml +1 -1
- package/backend/src/flowent/__init__.py +6 -2
- 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__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.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 +15 -0
- package/backend/src/flowent/cli.py +11 -0
- package/backend/src/flowent/llm.py +49 -1
- package/backend/src/flowent/main.py +15 -0
- package/backend/src/flowent/sandbox.py +18 -3
- package/backend/src/flowent/static/assets/index-BwQOML_0.css +2 -0
- package/backend/src/flowent/static/assets/index-DXQ_smj0.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +38 -8
- package/backend/src/flowent/tools.py +15 -3
- 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_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_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/conftest.py +21 -0
- package/backend/tests/test_agent_tools.py +73 -0
- package/backend/tests/test_llm_providers.py +58 -0
- package/backend/tests/test_persistence.py +16 -0
- package/backend/tests/test_startup_requirements.py +48 -0
- package/backend/tests/test_workspace_chat.py +28 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BwQOML_0.css +2 -0
- package/dist/frontend/assets/index-DXQ_smj0.js +81 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-C76K95ty.js +0 -81
- package/backend/src/flowent/static/assets/index-iUMNKvlU.css +0 -2
- package/dist/frontend/assets/index-C76K95ty.js +0 -81
- package/dist/frontend/assets/index-iUMNKvlU.css +0 -2
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>Flowent</title>
|
|
8
8
|
<meta name="description" content="Flowent application" />
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-DXQ_smj0.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BwQOML_0.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel, ConfigDict, Field
|
|
6
6
|
|
|
7
|
-
from flowent.llm import ProviderFormat
|
|
7
|
+
from flowent.llm import ProviderFormat, ReasoningEffort
|
|
8
8
|
from flowent.paths import data_directory
|
|
9
9
|
|
|
10
10
|
|
|
@@ -22,6 +22,7 @@ class StoredProvider(BaseModel):
|
|
|
22
22
|
class StoredSettings(BaseModel):
|
|
23
23
|
model_config = ConfigDict(extra="forbid")
|
|
24
24
|
|
|
25
|
+
reasoning_effort: ReasoningEffort = ReasoningEffort.DEFAULT
|
|
25
26
|
selected_model: str
|
|
26
27
|
selected_provider_id: str
|
|
27
28
|
|
|
@@ -44,6 +45,7 @@ class StoredMessage(BaseModel):
|
|
|
44
45
|
author: str
|
|
45
46
|
content: str
|
|
46
47
|
id: str
|
|
48
|
+
thinking: str = Field(default="", exclude_if=lambda value: value == "")
|
|
47
49
|
tools: list[StoredToolItem] = Field(default_factory=list)
|
|
48
50
|
|
|
49
51
|
|
|
@@ -91,7 +93,7 @@ class StateStore:
|
|
|
91
93
|
]
|
|
92
94
|
settings_row = connection.execute(
|
|
93
95
|
"""
|
|
94
|
-
SELECT selected_provider_id, selected_model
|
|
96
|
+
SELECT selected_provider_id, selected_model, reasoning_effort
|
|
95
97
|
FROM settings
|
|
96
98
|
WHERE id = 1
|
|
97
99
|
"""
|
|
@@ -101,6 +103,7 @@ class StateStore:
|
|
|
101
103
|
author=row["author"],
|
|
102
104
|
content=row["content"],
|
|
103
105
|
id=row["id"],
|
|
106
|
+
thinking=row["thinking"],
|
|
104
107
|
tools=[
|
|
105
108
|
StoredToolItem.model_validate(tool)
|
|
106
109
|
for tool in json.loads(row["tools"] or "[]")
|
|
@@ -108,7 +111,7 @@ class StateStore:
|
|
|
108
111
|
)
|
|
109
112
|
for row in connection.execute(
|
|
110
113
|
"""
|
|
111
|
-
SELECT id, author, content, tools
|
|
114
|
+
SELECT id, author, content, tools, thinking
|
|
112
115
|
FROM messages
|
|
113
116
|
ORDER BY position, id
|
|
114
117
|
"""
|
|
@@ -119,6 +122,9 @@ class StateStore:
|
|
|
119
122
|
messages=messages,
|
|
120
123
|
providers=providers,
|
|
121
124
|
settings=StoredSettings(
|
|
125
|
+
reasoning_effort=settings_row["reasoning_effort"]
|
|
126
|
+
if settings_row
|
|
127
|
+
else ReasoningEffort.DEFAULT,
|
|
122
128
|
selected_model=settings_row["selected_model"] if settings_row else "",
|
|
123
129
|
selected_provider_id=settings_row["selected_provider_id"]
|
|
124
130
|
if settings_row
|
|
@@ -166,14 +172,24 @@ class StateStore:
|
|
|
166
172
|
with self.connect() as connection:
|
|
167
173
|
connection.execute(
|
|
168
174
|
"""
|
|
169
|
-
INSERT INTO settings (
|
|
170
|
-
|
|
175
|
+
INSERT INTO settings (
|
|
176
|
+
id,
|
|
177
|
+
selected_provider_id,
|
|
178
|
+
selected_model,
|
|
179
|
+
reasoning_effort
|
|
180
|
+
)
|
|
181
|
+
VALUES (1, ?, ?, ?)
|
|
171
182
|
ON CONFLICT(id) DO UPDATE SET
|
|
172
183
|
selected_provider_id = excluded.selected_provider_id,
|
|
173
184
|
selected_model = excluded.selected_model,
|
|
185
|
+
reasoning_effort = excluded.reasoning_effort,
|
|
174
186
|
updated_at = unixepoch()
|
|
175
187
|
""",
|
|
176
|
-
(
|
|
188
|
+
(
|
|
189
|
+
settings.selected_provider_id,
|
|
190
|
+
settings.selected_model,
|
|
191
|
+
settings.reasoning_effort.value,
|
|
192
|
+
),
|
|
177
193
|
)
|
|
178
194
|
return settings
|
|
179
195
|
|
|
@@ -182,8 +198,8 @@ class StateStore:
|
|
|
182
198
|
connection.execute("DELETE FROM messages")
|
|
183
199
|
connection.executemany(
|
|
184
200
|
"""
|
|
185
|
-
INSERT INTO messages (id, author, content, tools, position)
|
|
186
|
-
VALUES (?, ?, ?, ?, ?)
|
|
201
|
+
INSERT INTO messages (id, author, content, tools, thinking, position)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
187
203
|
""",
|
|
188
204
|
[
|
|
189
205
|
(
|
|
@@ -196,6 +212,7 @@ class StateStore:
|
|
|
196
212
|
for tool in message.tools
|
|
197
213
|
]
|
|
198
214
|
),
|
|
215
|
+
message.thinking,
|
|
199
216
|
position,
|
|
200
217
|
)
|
|
201
218
|
for position, message in enumerate(messages)
|
|
@@ -270,6 +287,7 @@ class StateStore:
|
|
|
270
287
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
271
288
|
selected_provider_id TEXT NOT NULL DEFAULT '',
|
|
272
289
|
selected_model TEXT NOT NULL DEFAULT '',
|
|
290
|
+
reasoning_effort TEXT NOT NULL DEFAULT 'default',
|
|
273
291
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
274
292
|
);
|
|
275
293
|
|
|
@@ -300,3 +318,15 @@ class StateStore:
|
|
|
300
318
|
connection.execute(
|
|
301
319
|
"ALTER TABLE messages ADD COLUMN tools TEXT NOT NULL DEFAULT '[]'"
|
|
302
320
|
)
|
|
321
|
+
if "thinking" not in columns:
|
|
322
|
+
connection.execute(
|
|
323
|
+
"ALTER TABLE messages ADD COLUMN thinking TEXT NOT NULL DEFAULT ''"
|
|
324
|
+
)
|
|
325
|
+
settings_columns = {
|
|
326
|
+
row["name"] for row in connection.execute("PRAGMA table_info(settings)")
|
|
327
|
+
}
|
|
328
|
+
if "reasoning_effort" not in settings_columns:
|
|
329
|
+
connection.execute(
|
|
330
|
+
"ALTER TABLE settings "
|
|
331
|
+
"ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'default'"
|
|
332
|
+
)
|
|
@@ -261,9 +261,7 @@ def apply_patch_tool(arguments: dict[str, object], context: ToolContext) -> Tool
|
|
|
261
261
|
input_text=patch,
|
|
262
262
|
)
|
|
263
263
|
if result.exit_code != 0:
|
|
264
|
-
raise SandboxError(
|
|
265
|
-
result.stderr or result.stdout or "Patch could not be applied."
|
|
266
|
-
)
|
|
264
|
+
raise SandboxError(tool_failure_content(result))
|
|
267
265
|
return ToolResult(
|
|
268
266
|
content=result.stdout,
|
|
269
267
|
data={"files": [str(path) for path in paths]},
|
|
@@ -271,6 +269,20 @@ def apply_patch_tool(arguments: dict[str, object], context: ToolContext) -> Tool
|
|
|
271
269
|
)
|
|
272
270
|
|
|
273
271
|
|
|
272
|
+
def tool_failure_content(result: object) -> str:
|
|
273
|
+
stdout = str(getattr(result, "stdout", "") or "").strip()
|
|
274
|
+
stderr = str(getattr(result, "stderr", "") or "").strip()
|
|
275
|
+
if stdout:
|
|
276
|
+
try:
|
|
277
|
+
payload = json.loads(stdout)
|
|
278
|
+
except json.JSONDecodeError:
|
|
279
|
+
payload = None
|
|
280
|
+
if isinstance(payload, dict) and isinstance(payload.get("error"), str):
|
|
281
|
+
return payload["error"]
|
|
282
|
+
parts = [part for part in [stderr, stdout] if part]
|
|
283
|
+
return "\n".join(parts) or "Tool failed."
|
|
284
|
+
|
|
285
|
+
|
|
274
286
|
def shell_command(arguments: dict[str, object], context: ToolContext) -> ToolResult:
|
|
275
287
|
command = str(arguments["command"])
|
|
276
288
|
timeout_seconds = integer_argument(arguments, "timeout_seconds", 30)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
_test_environment = Path(tempfile.mkdtemp(prefix="flowent-tests-"))
|
|
9
|
+
_test_bin = _test_environment / "bin"
|
|
10
|
+
_test_bin.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
_test_bwrap = _test_bin / "bwrap"
|
|
12
|
+
_test_bwrap.write_text("#!/bin/sh\nexit 0\n")
|
|
13
|
+
_test_bwrap.chmod(_test_bwrap.stat().st_mode | stat.S_IXUSR)
|
|
14
|
+
|
|
15
|
+
os.environ.setdefault("FLOWENT_DATA_DIR", str(_test_environment / "data"))
|
|
16
|
+
os.environ["PATH"] = f"{_test_bin}{os.pathsep}{os.environ.get('PATH', '')}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture(autouse=True)
|
|
20
|
+
def sandbox_available(monkeypatch):
|
|
21
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: "/usr/bin/bwrap")
|
|
@@ -38,6 +38,7 @@ def configure_provider(client: TestClient) -> None:
|
|
|
38
38
|
client.put(
|
|
39
39
|
"/api/settings",
|
|
40
40
|
json={
|
|
41
|
+
"reasoning_effort": "default",
|
|
41
42
|
"selected_model": "gpt-5.1",
|
|
42
43
|
"selected_provider_id": "provider-openai",
|
|
43
44
|
},
|
|
@@ -72,6 +73,10 @@ def text_chunk(content: str) -> dict[str, object]:
|
|
|
72
73
|
return {"choices": [{"delta": {"content": content}}]}
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
def thinking_chunk(content: str) -> dict[str, object]:
|
|
77
|
+
return {"choices": [{"delta": {"reasoning_content": content}}]}
|
|
78
|
+
|
|
79
|
+
|
|
75
80
|
def test_workspace_response_streams_tool_process_and_final_text(
|
|
76
81
|
tmp_path, monkeypatch
|
|
77
82
|
) -> None:
|
|
@@ -296,6 +301,34 @@ def test_apply_patch_uses_internal_subcommand(tmp_path, monkeypatch) -> None:
|
|
|
296
301
|
assert calls[0][1:4] == ["-m", "flowent.cli", "apply-patch"]
|
|
297
302
|
|
|
298
303
|
|
|
304
|
+
def test_apply_patch_reports_patch_error_when_stderr_has_warning(
|
|
305
|
+
tmp_path, monkeypatch
|
|
306
|
+
) -> None:
|
|
307
|
+
def fake_run(self, command, **kwargs):
|
|
308
|
+
from flowent.sandbox import CommandResult
|
|
309
|
+
|
|
310
|
+
return CommandResult(
|
|
311
|
+
command=" ".join(command),
|
|
312
|
+
exit_code=1,
|
|
313
|
+
stderr="RuntimeWarning: flowent.cli was already imported\n",
|
|
314
|
+
stdout='{"error": "Patch context was not found."}\n',
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
monkeypatch.setattr(SandboxRunner, "run", fake_run)
|
|
318
|
+
patch = """*** Begin Patch
|
|
319
|
+
*** Update File: notes.txt
|
|
320
|
+
@@
|
|
321
|
+
-missing
|
|
322
|
+
+ready
|
|
323
|
+
*** End Patch
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
|
|
327
|
+
|
|
328
|
+
assert not result.ok
|
|
329
|
+
assert result.content == "Patch context was not found."
|
|
330
|
+
|
|
331
|
+
|
|
299
332
|
def test_web_search_result_enters_tool_output(tmp_path) -> None:
|
|
300
333
|
def fake_search(query: str):
|
|
301
334
|
return [{"title": "Result", "url": "https://example.test", "snippet": query}]
|
|
@@ -403,6 +436,46 @@ def test_agent_finishes_without_tools(tmp_path, monkeypatch) -> None:
|
|
|
403
436
|
assert events[-1]["data"]["message"]["content"] == "Direct answer."
|
|
404
437
|
|
|
405
438
|
|
|
439
|
+
def test_agent_streams_and_persists_thinking(tmp_path, monkeypatch) -> None:
|
|
440
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
441
|
+
monkeypatch.chdir(tmp_path)
|
|
442
|
+
|
|
443
|
+
async def fake_completion(**request: object) -> object:
|
|
444
|
+
async def chunks() -> object:
|
|
445
|
+
yield thinking_chunk("Checking context.")
|
|
446
|
+
yield thinking_chunk(" Preparing answer.")
|
|
447
|
+
yield text_chunk("Direct answer.")
|
|
448
|
+
|
|
449
|
+
return chunks()
|
|
450
|
+
|
|
451
|
+
client = TestClient(
|
|
452
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
453
|
+
)
|
|
454
|
+
configure_provider(client)
|
|
455
|
+
|
|
456
|
+
response = client.post(
|
|
457
|
+
"/api/workspace/respond",
|
|
458
|
+
json={"content": "Answer directly."},
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
assert response.status_code == 200
|
|
462
|
+
events = stream_events(response.text)
|
|
463
|
+
assert [event["event"] for event in events] == [
|
|
464
|
+
"start",
|
|
465
|
+
"output_start",
|
|
466
|
+
"thinking_delta",
|
|
467
|
+
"thinking_delta",
|
|
468
|
+
"delta",
|
|
469
|
+
"done",
|
|
470
|
+
]
|
|
471
|
+
assert events[2]["data"] == {"content": "Checking context."}
|
|
472
|
+
assert events[-1]["data"]["message"]["thinking"] == (
|
|
473
|
+
"Checking context. Preparing answer."
|
|
474
|
+
)
|
|
475
|
+
state = client.get("/api/state").json()
|
|
476
|
+
assert state["messages"][-1]["thinking"] == ("Checking context. Preparing answer.")
|
|
477
|
+
|
|
478
|
+
|
|
406
479
|
def test_tool_failure_is_reported_and_agent_continues(tmp_path, monkeypatch) -> None:
|
|
407
480
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
408
481
|
monkeypatch.chdir(tmp_path)
|
|
@@ -4,7 +4,9 @@ from flowent.llm import (
|
|
|
4
4
|
ChatMessage,
|
|
5
5
|
ProviderConnection,
|
|
6
6
|
ProviderFormat,
|
|
7
|
+
ReasoningEffort,
|
|
7
8
|
build_litellm_request,
|
|
9
|
+
chunk_delta_reasoning,
|
|
8
10
|
complete_chat,
|
|
9
11
|
stream_chat,
|
|
10
12
|
)
|
|
@@ -45,6 +47,62 @@ def test_build_litellm_request_maps_provider_connection_to_completion_args() ->
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
|
|
50
|
+
def test_build_litellm_request_omits_default_reasoning_effort() -> None:
|
|
51
|
+
connection = ProviderConnection(
|
|
52
|
+
name="Primary",
|
|
53
|
+
provider=ProviderFormat.OPENAI,
|
|
54
|
+
model="gpt-5.1",
|
|
55
|
+
secret_reference="connection-primary",
|
|
56
|
+
reasoning_effort=ReasoningEffort.DEFAULT,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
request = build_litellm_request(
|
|
60
|
+
connection, [ChatMessage(role="user", content="Draft a checklist.")]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
assert "reasoning_effort" not in request
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_build_litellm_request_includes_selected_reasoning_effort() -> None:
|
|
67
|
+
connection = ProviderConnection(
|
|
68
|
+
name="Primary",
|
|
69
|
+
provider=ProviderFormat.OPENAI,
|
|
70
|
+
model="gpt-5.1",
|
|
71
|
+
secret_reference="connection-primary",
|
|
72
|
+
reasoning_effort=ReasoningEffort.XHIGH,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
request = build_litellm_request(
|
|
76
|
+
connection, [ChatMessage(role="user", content="Draft a checklist.")]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
assert request["reasoning_effort"] == "xhigh"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_chunk_delta_reasoning_reads_litellm_reasoning_fields() -> None:
|
|
83
|
+
assert (
|
|
84
|
+
chunk_delta_reasoning(
|
|
85
|
+
{"choices": [{"delta": {"reasoning_content": "Checking files."}}]}
|
|
86
|
+
)
|
|
87
|
+
== "Checking files."
|
|
88
|
+
)
|
|
89
|
+
assert (
|
|
90
|
+
chunk_delta_reasoning(
|
|
91
|
+
{
|
|
92
|
+
"choices": [
|
|
93
|
+
{
|
|
94
|
+
"delta": {
|
|
95
|
+
"thinking_blocks": [{"thinking": "Read files."}],
|
|
96
|
+
"reasoning_items": [{"summary": "Summarize."}],
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
== "Read files.Summarize."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
48
106
|
@pytest.mark.anyio
|
|
49
107
|
async def test_complete_chat_uses_injected_litellm_completion() -> None:
|
|
50
108
|
captured_request: dict[str, object] = {}
|
|
@@ -59,6 +59,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
59
59
|
settings_response = client.put(
|
|
60
60
|
"/api/settings",
|
|
61
61
|
json={
|
|
62
|
+
"reasoning_effort": "xhigh",
|
|
62
63
|
"selected_model": "claude-sonnet-4-5",
|
|
63
64
|
"selected_provider_id": "provider-anthropic",
|
|
64
65
|
},
|
|
@@ -71,6 +72,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
71
72
|
"author": "assistant",
|
|
72
73
|
"content": "Draft a launch checklist",
|
|
73
74
|
"id": "message-1",
|
|
75
|
+
"thinking": "Read the request.",
|
|
74
76
|
"tools": [
|
|
75
77
|
{
|
|
76
78
|
"id": "tool-1",
|
|
@@ -91,6 +93,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
91
93
|
state = restarted_client.get("/api/state").json()
|
|
92
94
|
|
|
93
95
|
assert state["settings"] == {
|
|
96
|
+
"reasoning_effort": "xhigh",
|
|
94
97
|
"selected_model": "claude-sonnet-4-5",
|
|
95
98
|
"selected_provider_id": "provider-anthropic",
|
|
96
99
|
}
|
|
@@ -99,6 +102,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
99
102
|
"author": "assistant",
|
|
100
103
|
"content": "Draft a launch checklist",
|
|
101
104
|
"id": "message-1",
|
|
105
|
+
"thinking": "Read the request.",
|
|
102
106
|
"tools": [
|
|
103
107
|
{
|
|
104
108
|
"arguments": None,
|
|
@@ -123,3 +127,15 @@ def test_data_directory_uses_flowent_data_dir(tmp_path, monkeypatch) -> None:
|
|
|
123
127
|
|
|
124
128
|
assert response.status_code == 200
|
|
125
129
|
assert (data_dir / "flowent.db").is_file()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_app_state_defaults_reasoning_effort_for_existing_settings(
|
|
133
|
+
tmp_path, monkeypatch
|
|
134
|
+
) -> None:
|
|
135
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
136
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
137
|
+
|
|
138
|
+
response = client.get("/api/state")
|
|
139
|
+
|
|
140
|
+
assert response.status_code == 200
|
|
141
|
+
assert response.json()["settings"]["reasoning_effort"] == "default"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from flowent.cli import main
|
|
4
|
+
from flowent.main import create_app
|
|
5
|
+
from flowent.sandbox import SandboxError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_create_app_fails_when_sandbox_is_missing(monkeypatch) -> None:
|
|
9
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: None)
|
|
10
|
+
|
|
11
|
+
with pytest.raises(SandboxError, match="Install bubblewrap"):
|
|
12
|
+
create_app(serve_frontend=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_create_app_starts_when_bwrap_is_available(monkeypatch) -> None:
|
|
16
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: "/usr/bin/bwrap")
|
|
17
|
+
|
|
18
|
+
app = create_app(serve_frontend=False)
|
|
19
|
+
|
|
20
|
+
assert app.title == "Flowent"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_create_app_starts_when_bubblewrap_fallback_is_available(monkeypatch) -> None:
|
|
24
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: "/usr/bin/bubblewrap")
|
|
25
|
+
|
|
26
|
+
app = create_app(serve_frontend=False)
|
|
27
|
+
|
|
28
|
+
assert app.title == "Flowent"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_doctor_reports_missing_sandbox(monkeypatch, capsys) -> None:
|
|
32
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: None)
|
|
33
|
+
|
|
34
|
+
with pytest.raises(SystemExit) as error:
|
|
35
|
+
main(["doctor"])
|
|
36
|
+
|
|
37
|
+
assert error.value.code == 1
|
|
38
|
+
assert "Sandbox: missing." in capsys.readouterr().err
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_doctor_reports_available_sandbox(monkeypatch, capsys) -> None:
|
|
42
|
+
monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: "/usr/bin/bwrap")
|
|
43
|
+
|
|
44
|
+
with pytest.raises(SystemExit) as error:
|
|
45
|
+
main(["doctor"])
|
|
46
|
+
|
|
47
|
+
assert error.value.code == 0
|
|
48
|
+
assert "Sandbox: /usr/bin/bwrap" in capsys.readouterr().out
|
|
@@ -12,6 +12,7 @@ def configure_provider(
|
|
|
12
12
|
name: str = "OpenAI",
|
|
13
13
|
provider_id: str = "provider-openai",
|
|
14
14
|
provider_type: str = "openai",
|
|
15
|
+
reasoning_effort: str = "default",
|
|
15
16
|
) -> None:
|
|
16
17
|
client.post(
|
|
17
18
|
"/api/providers",
|
|
@@ -27,6 +28,7 @@ def configure_provider(
|
|
|
27
28
|
client.put(
|
|
28
29
|
"/api/settings",
|
|
29
30
|
json={
|
|
31
|
+
"reasoning_effort": reasoning_effort,
|
|
30
32
|
"selected_model": model,
|
|
31
33
|
"selected_provider_id": provider_id,
|
|
32
34
|
},
|
|
@@ -324,6 +326,32 @@ def test_workspace_response_includes_project_and_environment_context(
|
|
|
324
326
|
}
|
|
325
327
|
|
|
326
328
|
|
|
329
|
+
def test_workspace_response_uses_selected_reasoning_effort(
|
|
330
|
+
tmp_path, monkeypatch
|
|
331
|
+
) -> None:
|
|
332
|
+
monkeypatch.chdir(tmp_path)
|
|
333
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
334
|
+
captured_request: dict[str, object] = {}
|
|
335
|
+
|
|
336
|
+
async def fake_completion(**request: object) -> object:
|
|
337
|
+
captured_request.update(request)
|
|
338
|
+
|
|
339
|
+
async def chunks() -> object:
|
|
340
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
341
|
+
|
|
342
|
+
return chunks()
|
|
343
|
+
|
|
344
|
+
client = TestClient(
|
|
345
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
346
|
+
)
|
|
347
|
+
configure_provider(client, reasoning_effort="xhigh")
|
|
348
|
+
|
|
349
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
350
|
+
|
|
351
|
+
assert response.status_code == 200
|
|
352
|
+
assert captured_request["reasoning_effort"] == "xhigh"
|
|
353
|
+
|
|
354
|
+
|
|
327
355
|
def test_workspace_response_prefers_agents_override(tmp_path, monkeypatch) -> None:
|
|
328
356
|
monkeypatch.chdir(tmp_path)
|
|
329
357
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|