flowent 0.0.11 → 0.0.13
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/__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__/channels.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/channels.py +296 -0
- package/backend/src/flowent/cli.py +11 -0
- package/backend/src/flowent/llm.py +49 -1
- package/backend/src/flowent/main.py +143 -2
- package/backend/src/flowent/sandbox.py +18 -3
- package/backend/src/flowent/static/assets/index-CEZrWoDG.css +2 -0
- package/backend/src/flowent/static/assets/index-S5a0Rkj1.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +217 -8
- package/backend/src/flowent/tools.py +28 -5
- 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_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_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 +99 -0
- package/backend/tests/test_channels.py +360 -0
- package/backend/tests/test_llm_providers.py +58 -0
- package/backend/tests/test_persistence.py +46 -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-CEZrWoDG.css +2 -0
- package/dist/frontend/assets/index-S5a0Rkj1.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
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from fastapi.testclient import TestClient
|
|
8
|
+
|
|
9
|
+
from flowent.channels import TelegramBotManager, split_telegram_message
|
|
10
|
+
from flowent.main import create_app
|
|
11
|
+
from flowent.storage import StateStore, StoredTelegramBot, StoredTelegramSession
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeTelegramTransport:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.sent_messages: list[dict[str, str]] = []
|
|
17
|
+
self.updates: list[dict[str, Any]] = []
|
|
18
|
+
self.error: Exception | None = None
|
|
19
|
+
self.get_updates_calls = 0
|
|
20
|
+
|
|
21
|
+
async def get_updates(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
offset: int | None,
|
|
25
|
+
timeout: int,
|
|
26
|
+
token: str,
|
|
27
|
+
) -> list[dict[str, Any]]:
|
|
28
|
+
self.get_updates_calls += 1
|
|
29
|
+
if self.error is not None:
|
|
30
|
+
raise self.error
|
|
31
|
+
return self.updates
|
|
32
|
+
|
|
33
|
+
async def send_message(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
chat_id: str,
|
|
37
|
+
text: str,
|
|
38
|
+
token: str,
|
|
39
|
+
) -> None:
|
|
40
|
+
self.sent_messages.append(
|
|
41
|
+
{
|
|
42
|
+
"chat_id": chat_id,
|
|
43
|
+
"text": text,
|
|
44
|
+
"token": token,
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def stored_bot(
|
|
50
|
+
*,
|
|
51
|
+
bot_token: str = "telegram-secret",
|
|
52
|
+
enabled: bool = True,
|
|
53
|
+
sessions: list[StoredTelegramSession] | None = None,
|
|
54
|
+
) -> StoredTelegramBot:
|
|
55
|
+
return StoredTelegramBot(
|
|
56
|
+
bot_token=bot_token,
|
|
57
|
+
enabled=enabled,
|
|
58
|
+
sessions=sessions or [],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def stored_session(
|
|
63
|
+
*,
|
|
64
|
+
chat_id: str = "2001",
|
|
65
|
+
display_name: str = "Alice Example",
|
|
66
|
+
recent_message: str = "Hello Flowent",
|
|
67
|
+
status: str = "approved",
|
|
68
|
+
user_id: str = "1001",
|
|
69
|
+
username: str = "alice",
|
|
70
|
+
) -> StoredTelegramSession:
|
|
71
|
+
return StoredTelegramSession(
|
|
72
|
+
chat_id=chat_id,
|
|
73
|
+
display_name=display_name,
|
|
74
|
+
recent_message=recent_message,
|
|
75
|
+
status=status,
|
|
76
|
+
user_id=user_id,
|
|
77
|
+
username=username,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def telegram_update(
|
|
82
|
+
*,
|
|
83
|
+
chat_id: int = 2001,
|
|
84
|
+
text: str = "Hello Flowent",
|
|
85
|
+
update_id: int = 1,
|
|
86
|
+
user_id: int = 1001,
|
|
87
|
+
username: str = "alice",
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"message": {
|
|
91
|
+
"chat": {"id": chat_id},
|
|
92
|
+
"from": {
|
|
93
|
+
"first_name": "Alice",
|
|
94
|
+
"id": user_id,
|
|
95
|
+
"last_name": "Example",
|
|
96
|
+
"username": username,
|
|
97
|
+
},
|
|
98
|
+
"text": text,
|
|
99
|
+
},
|
|
100
|
+
"update_id": update_id,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def static_reply(_: str) -> str:
|
|
105
|
+
return "Reply"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.anyio
|
|
109
|
+
async def test_disabled_telegram_bot_does_not_start_polling(
|
|
110
|
+
tmp_path, monkeypatch
|
|
111
|
+
) -> None:
|
|
112
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
113
|
+
transport = FakeTelegramTransport()
|
|
114
|
+
manager = TelegramBotManager(
|
|
115
|
+
message_handler=static_reply,
|
|
116
|
+
store=StateStore(tmp_path),
|
|
117
|
+
telegram_transport=transport,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
await manager.poll_once(stored_bot(enabled=False))
|
|
121
|
+
|
|
122
|
+
assert transport.get_updates_calls == 0
|
|
123
|
+
assert manager.bot_with_status(stored_bot(enabled=False)).status == "disabled"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.anyio
|
|
127
|
+
async def test_enabled_telegram_bot_polls_and_reports_running_status(
|
|
128
|
+
tmp_path, monkeypatch
|
|
129
|
+
) -> None:
|
|
130
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
131
|
+
transport = FakeTelegramTransport()
|
|
132
|
+
manager = TelegramBotManager(
|
|
133
|
+
message_handler=static_reply,
|
|
134
|
+
store=StateStore(tmp_path),
|
|
135
|
+
telegram_transport=transport,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
await manager.poll_once(stored_bot())
|
|
139
|
+
|
|
140
|
+
assert transport.get_updates_calls == 1
|
|
141
|
+
assert manager.bot_with_status(stored_bot()).status == "running"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.mark.anyio
|
|
145
|
+
async def test_unapproved_telegram_message_creates_pending_request(
|
|
146
|
+
tmp_path, monkeypatch
|
|
147
|
+
) -> None:
|
|
148
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
149
|
+
handled_messages: list[str] = []
|
|
150
|
+
transport = FakeTelegramTransport()
|
|
151
|
+
transport.updates = [telegram_update(text="Pair this chat")]
|
|
152
|
+
store = StateStore(tmp_path)
|
|
153
|
+
|
|
154
|
+
async def handle_message(content: str) -> str:
|
|
155
|
+
handled_messages.append(content)
|
|
156
|
+
return "Reply"
|
|
157
|
+
|
|
158
|
+
manager = TelegramBotManager(
|
|
159
|
+
message_handler=handle_message,
|
|
160
|
+
store=store,
|
|
161
|
+
telegram_transport=transport,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
await manager.poll_once(stored_bot())
|
|
165
|
+
|
|
166
|
+
assert handled_messages == []
|
|
167
|
+
assert store.read_telegram_bot().sessions == [
|
|
168
|
+
StoredTelegramSession(
|
|
169
|
+
chat_id="2001",
|
|
170
|
+
display_name="Alice Example",
|
|
171
|
+
recent_message="Pair this chat",
|
|
172
|
+
status="pending",
|
|
173
|
+
updated_at=store.read_telegram_bot().sessions[0].updated_at,
|
|
174
|
+
user_id="1001",
|
|
175
|
+
username="alice",
|
|
176
|
+
)
|
|
177
|
+
]
|
|
178
|
+
assert transport.sent_messages == [
|
|
179
|
+
{
|
|
180
|
+
"chat_id": "2001",
|
|
181
|
+
"text": "Request received. Approve this conversation in Flowent.",
|
|
182
|
+
"token": "telegram-secret",
|
|
183
|
+
}
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.anyio
|
|
188
|
+
async def test_approved_telegram_message_enters_workspace_and_replies(
|
|
189
|
+
tmp_path, monkeypatch
|
|
190
|
+
) -> None:
|
|
191
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
192
|
+
handled_messages: list[str] = []
|
|
193
|
+
transport = FakeTelegramTransport()
|
|
194
|
+
transport.updates = [telegram_update()]
|
|
195
|
+
store = StateStore(tmp_path)
|
|
196
|
+
store.save_telegram_session(stored_session())
|
|
197
|
+
|
|
198
|
+
async def handle_message(content: str) -> str:
|
|
199
|
+
handled_messages.append(content)
|
|
200
|
+
return "Reply"
|
|
201
|
+
|
|
202
|
+
manager = TelegramBotManager(
|
|
203
|
+
message_handler=handle_message,
|
|
204
|
+
store=store,
|
|
205
|
+
telegram_transport=transport,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
await manager.poll_once(stored_bot())
|
|
209
|
+
|
|
210
|
+
assert handled_messages == ["Hello Flowent"]
|
|
211
|
+
assert transport.sent_messages == [
|
|
212
|
+
{
|
|
213
|
+
"chat_id": "2001",
|
|
214
|
+
"text": "Reply",
|
|
215
|
+
"token": "telegram-secret",
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_approved_telegram_message_is_persisted_in_workspace(
|
|
221
|
+
tmp_path, monkeypatch
|
|
222
|
+
) -> None:
|
|
223
|
+
monkeypatch.chdir(tmp_path)
|
|
224
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
225
|
+
transport = FakeTelegramTransport()
|
|
226
|
+
transport.updates = [telegram_update(text="Draft from Telegram")]
|
|
227
|
+
|
|
228
|
+
async def fake_completion(**request: object) -> object:
|
|
229
|
+
async def chunks() -> object:
|
|
230
|
+
yield {"choices": [{"delta": {"content": "Telegram reply"}}]}
|
|
231
|
+
|
|
232
|
+
return chunks()
|
|
233
|
+
|
|
234
|
+
app = create_app(
|
|
235
|
+
serve_frontend=False,
|
|
236
|
+
chat_completion=fake_completion,
|
|
237
|
+
telegram_transport=transport,
|
|
238
|
+
)
|
|
239
|
+
client = TestClient(app)
|
|
240
|
+
with client:
|
|
241
|
+
client.post(
|
|
242
|
+
"/api/providers",
|
|
243
|
+
json={
|
|
244
|
+
"api_key": "sk-local",
|
|
245
|
+
"base_url": "",
|
|
246
|
+
"id": "provider-openai",
|
|
247
|
+
"models": ["gpt-5.1"],
|
|
248
|
+
"name": "OpenAI",
|
|
249
|
+
"type": "openai",
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
client.put(
|
|
253
|
+
"/api/settings",
|
|
254
|
+
json={
|
|
255
|
+
"reasoning_effort": "default",
|
|
256
|
+
"selected_model": "gpt-5.1",
|
|
257
|
+
"selected_provider_id": "provider-openai",
|
|
258
|
+
},
|
|
259
|
+
)
|
|
260
|
+
StateStore(tmp_path / "data").save_telegram_session(stored_session())
|
|
261
|
+
asyncio.run(app.state.telegram_bot_manager.poll_once(stored_bot()))
|
|
262
|
+
|
|
263
|
+
state = client.get("/api/state").json()
|
|
264
|
+
|
|
265
|
+
assert [message["content"] for message in state["messages"]] == [
|
|
266
|
+
"Draft from Telegram",
|
|
267
|
+
"Telegram reply",
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_telegram_bot_config_is_saved_and_reported_in_state(
|
|
272
|
+
tmp_path, monkeypatch
|
|
273
|
+
) -> None:
|
|
274
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
275
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
276
|
+
|
|
277
|
+
response = client.put(
|
|
278
|
+
"/api/telegram-bot",
|
|
279
|
+
json={
|
|
280
|
+
"bot_token": "telegram-secret",
|
|
281
|
+
"enabled": False,
|
|
282
|
+
"sessions": [],
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
state = client.get("/api/state").json()
|
|
286
|
+
|
|
287
|
+
assert response.status_code == 200
|
|
288
|
+
assert state["telegram_bot"] == {
|
|
289
|
+
"bot_token": "telegram-secret",
|
|
290
|
+
"enabled": False,
|
|
291
|
+
"error": "",
|
|
292
|
+
"sessions": [],
|
|
293
|
+
"status": "disabled",
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def test_pending_telegram_request_can_be_approved(tmp_path, monkeypatch) -> None:
|
|
298
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
299
|
+
store = StateStore(tmp_path)
|
|
300
|
+
store.save_telegram_session(stored_session(status="pending"))
|
|
301
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
302
|
+
|
|
303
|
+
response = client.post(
|
|
304
|
+
"/api/telegram-bot/approve",
|
|
305
|
+
json={"chat_id": "2001"},
|
|
306
|
+
)
|
|
307
|
+
state = client.get("/api/state").json()
|
|
308
|
+
|
|
309
|
+
assert response.status_code == 200
|
|
310
|
+
assert response.json()["status"] == "approved"
|
|
311
|
+
assert state["telegram_bot"]["sessions"][0]["status"] == "approved"
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@pytest.mark.anyio
|
|
315
|
+
async def test_telegram_reply_is_split_when_it_is_too_long(
|
|
316
|
+
tmp_path, monkeypatch
|
|
317
|
+
) -> None:
|
|
318
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
319
|
+
transport = FakeTelegramTransport()
|
|
320
|
+
transport.updates = [telegram_update()]
|
|
321
|
+
long_reply = "x" * 4100
|
|
322
|
+
store = StateStore(tmp_path)
|
|
323
|
+
store.save_telegram_session(stored_session())
|
|
324
|
+
|
|
325
|
+
async def handle_message(_: str) -> str:
|
|
326
|
+
return long_reply
|
|
327
|
+
|
|
328
|
+
manager = TelegramBotManager(
|
|
329
|
+
message_handler=handle_message,
|
|
330
|
+
store=store,
|
|
331
|
+
telegram_transport=transport,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
await manager.poll_once(stored_bot())
|
|
335
|
+
|
|
336
|
+
assert [len(message["text"]) for message in transport.sent_messages] == [4096, 4]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@pytest.mark.anyio
|
|
340
|
+
async def test_telegram_connection_failure_reports_error_status(
|
|
341
|
+
tmp_path, monkeypatch
|
|
342
|
+
) -> None:
|
|
343
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
344
|
+
transport = FakeTelegramTransport()
|
|
345
|
+
transport.error = RuntimeError("Secret is invalid")
|
|
346
|
+
manager = TelegramBotManager(
|
|
347
|
+
message_handler=static_reply,
|
|
348
|
+
store=StateStore(tmp_path),
|
|
349
|
+
telegram_transport=transport,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
await manager.poll_once(stored_bot())
|
|
353
|
+
bot = manager.bot_with_status(stored_bot())
|
|
354
|
+
|
|
355
|
+
assert bot.status == "error"
|
|
356
|
+
assert bot.error == "Secret is invalid"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def test_split_telegram_message_keeps_empty_reply_sendable() -> None:
|
|
360
|
+
assert split_telegram_message("") == [""]
|
|
@@ -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] = {}
|
|
@@ -39,6 +39,36 @@ def test_app_state_persists_providers_across_app_instances(
|
|
|
39
39
|
]
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def test_app_state_persists_telegram_bot_across_app_instances(
|
|
43
|
+
tmp_path, monkeypatch
|
|
44
|
+
) -> None:
|
|
45
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
46
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
47
|
+
|
|
48
|
+
response = client.put(
|
|
49
|
+
"/api/telegram-bot",
|
|
50
|
+
json={
|
|
51
|
+
"bot_token": "telegram-secret",
|
|
52
|
+
"enabled": False,
|
|
53
|
+
"sessions": [],
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
assert response.status_code == 200
|
|
58
|
+
|
|
59
|
+
restarted_client = TestClient(create_app(serve_frontend=False))
|
|
60
|
+
state_response = restarted_client.get("/api/state")
|
|
61
|
+
|
|
62
|
+
assert state_response.status_code == 200
|
|
63
|
+
assert state_response.json()["telegram_bot"] == {
|
|
64
|
+
"bot_token": "telegram-secret",
|
|
65
|
+
"enabled": False,
|
|
66
|
+
"error": "",
|
|
67
|
+
"sessions": [],
|
|
68
|
+
"status": "disabled",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
42
72
|
def test_app_state_persists_settings_and_workspace_messages(
|
|
43
73
|
tmp_path, monkeypatch
|
|
44
74
|
) -> None:
|
|
@@ -59,6 +89,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
59
89
|
settings_response = client.put(
|
|
60
90
|
"/api/settings",
|
|
61
91
|
json={
|
|
92
|
+
"reasoning_effort": "xhigh",
|
|
62
93
|
"selected_model": "claude-sonnet-4-5",
|
|
63
94
|
"selected_provider_id": "provider-anthropic",
|
|
64
95
|
},
|
|
@@ -71,6 +102,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
71
102
|
"author": "assistant",
|
|
72
103
|
"content": "Draft a launch checklist",
|
|
73
104
|
"id": "message-1",
|
|
105
|
+
"thinking": "Read the request.",
|
|
74
106
|
"tools": [
|
|
75
107
|
{
|
|
76
108
|
"id": "tool-1",
|
|
@@ -91,6 +123,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
91
123
|
state = restarted_client.get("/api/state").json()
|
|
92
124
|
|
|
93
125
|
assert state["settings"] == {
|
|
126
|
+
"reasoning_effort": "xhigh",
|
|
94
127
|
"selected_model": "claude-sonnet-4-5",
|
|
95
128
|
"selected_provider_id": "provider-anthropic",
|
|
96
129
|
}
|
|
@@ -99,6 +132,7 @@ def test_app_state_persists_settings_and_workspace_messages(
|
|
|
99
132
|
"author": "assistant",
|
|
100
133
|
"content": "Draft a launch checklist",
|
|
101
134
|
"id": "message-1",
|
|
135
|
+
"thinking": "Read the request.",
|
|
102
136
|
"tools": [
|
|
103
137
|
{
|
|
104
138
|
"arguments": None,
|
|
@@ -123,3 +157,15 @@ def test_data_directory_uses_flowent_data_dir(tmp_path, monkeypatch) -> None:
|
|
|
123
157
|
|
|
124
158
|
assert response.status_code == 200
|
|
125
159
|
assert (data_dir / "flowent.db").is_file()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_app_state_defaults_reasoning_effort_for_existing_settings(
|
|
163
|
+
tmp_path, monkeypatch
|
|
164
|
+
) -> None:
|
|
165
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
166
|
+
client = TestClient(create_app(serve_frontend=False))
|
|
167
|
+
|
|
168
|
+
response = client.get("/api/state")
|
|
169
|
+
|
|
170
|
+
assert response.status_code == 200
|
|
171
|
+
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"))
|