flowent 0.0.12 → 0.1.0
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 +2 -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__/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__/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 +28 -7
- package/backend/src/flowent/channels.py +296 -0
- package/backend/src/flowent/main.py +226 -3
- package/backend/src/flowent/mcp.py +484 -0
- package/backend/src/flowent/mcp_import.py +202 -0
- package/backend/src/flowent/skills.py +157 -0
- package/backend/src/flowent/static/assets/index-DqTHSMBo.js +81 -0
- package/backend/src/flowent/static/assets/index-d3FBbOXX.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +419 -0
- package/backend/src/flowent/tools.py +34 -7
- 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_mcp.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 +54 -0
- package/backend/tests/test_channels.py +360 -0
- package/backend/tests/test_mcp.py +710 -0
- package/backend/tests/test_persistence.py +30 -0
- package/backend/tests/test_skills.py +462 -0
- package/backend/uv.lock +160 -1
- package/dist/frontend/assets/index-DqTHSMBo.js +81 -0
- package/dist/frontend/assets/index-d3FBbOXX.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-BwQOML_0.css +0 -2
- package/backend/src/flowent/static/assets/index-DXQ_smj0.js +0 -81
- package/dist/frontend/assets/index-BwQOML_0.css +0 -2
- package/dist/frontend/assets/index-DXQ_smj0.js +0 -81
|
@@ -162,7 +162,7 @@ def tool_call_title(name: str, arguments: dict[str, object]) -> str:
|
|
|
162
162
|
if name == "grep_files":
|
|
163
163
|
return f"Searching {arguments.get('pattern', 'files')}"
|
|
164
164
|
if name == "apply_patch":
|
|
165
|
-
return "
|
|
165
|
+
return "Editing files"
|
|
166
166
|
if name == "shell_command":
|
|
167
167
|
return f"Running {arguments.get('command', 'command')}"
|
|
168
168
|
if name == "update_plan":
|
|
@@ -192,9 +192,10 @@ def run_tool(
|
|
|
192
192
|
return web_search(arguments, context)
|
|
193
193
|
raise ValueError("Tool is not available.")
|
|
194
194
|
except Exception as error:
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
title = (
|
|
196
|
+
"Edit failed" if name == "apply_patch" else tool_call_title(name, arguments)
|
|
197
197
|
)
|
|
198
|
+
return ToolResult(content=str(error), ok=False, title=title)
|
|
198
199
|
|
|
199
200
|
|
|
200
201
|
def integer_argument(arguments: dict[str, object], name: str, default: int) -> int:
|
|
@@ -262,13 +263,35 @@ def apply_patch_tool(arguments: dict[str, object], context: ToolContext) -> Tool
|
|
|
262
263
|
)
|
|
263
264
|
if result.exit_code != 0:
|
|
264
265
|
raise SandboxError(tool_failure_content(result))
|
|
266
|
+
data = json.loads(result.stdout or "{}")
|
|
265
267
|
return ToolResult(
|
|
266
268
|
content=result.stdout,
|
|
267
|
-
data=
|
|
268
|
-
title=
|
|
269
|
+
data=data if isinstance(data, dict) else {},
|
|
270
|
+
title=patch_title_from_result(data),
|
|
269
271
|
)
|
|
270
272
|
|
|
271
273
|
|
|
274
|
+
def patch_title_from_result(data: object) -> str:
|
|
275
|
+
if not isinstance(data, dict):
|
|
276
|
+
return "Edited files"
|
|
277
|
+
files = data.get("files")
|
|
278
|
+
if not isinstance(files, list) or not files:
|
|
279
|
+
return "Edited files"
|
|
280
|
+
if len(files) > 1:
|
|
281
|
+
return f"Edited {len(files)} files"
|
|
282
|
+
file_info = files[0]
|
|
283
|
+
if not isinstance(file_info, dict):
|
|
284
|
+
return "Edited files"
|
|
285
|
+
raw_path = file_info.get("path")
|
|
286
|
+
name = Path(str(raw_path)).name if raw_path else "file"
|
|
287
|
+
status = file_info.get("status")
|
|
288
|
+
if status == "added":
|
|
289
|
+
return f"Added {name}"
|
|
290
|
+
if status == "deleted":
|
|
291
|
+
return f"Deleted {name}"
|
|
292
|
+
return f"Edited {name}"
|
|
293
|
+
|
|
294
|
+
|
|
272
295
|
def tool_failure_content(result: object) -> str:
|
|
273
296
|
stdout = str(getattr(result, "stdout", "") or "").strip()
|
|
274
297
|
stderr = str(getattr(result, "stderr", "") or "").strip()
|
|
@@ -366,11 +389,15 @@ def parse_tool_arguments(arguments: str) -> dict[str, object]:
|
|
|
366
389
|
return parsed
|
|
367
390
|
|
|
368
391
|
|
|
369
|
-
def new_tool_item(
|
|
392
|
+
def new_tool_item(
|
|
393
|
+
name: str,
|
|
394
|
+
arguments: dict[str, object],
|
|
395
|
+
title: str | None = None,
|
|
396
|
+
) -> dict[str, object]:
|
|
370
397
|
return {
|
|
371
398
|
"id": str(uuid4()),
|
|
372
399
|
"arguments": arguments,
|
|
373
400
|
"name": name,
|
|
374
401
|
"status": "running",
|
|
375
|
-
"title": tool_call_title(name, arguments),
|
|
402
|
+
"title": title or tool_call_title(name, arguments),
|
|
376
403
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -253,9 +253,60 @@ def test_apply_patch_modifies_workdir_file(tmp_path) -> None:
|
|
|
253
253
|
result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
|
|
254
254
|
|
|
255
255
|
assert result.ok
|
|
256
|
+
assert result.title == "Edited notes.txt"
|
|
256
257
|
assert target.read_text() == "alpha\nready\n"
|
|
257
258
|
|
|
258
259
|
|
|
260
|
+
def test_apply_patch_added_file_title(tmp_path) -> None:
|
|
261
|
+
patch = """*** Begin Patch
|
|
262
|
+
*** Add File: created.txt
|
|
263
|
+
+hello
|
|
264
|
+
*** End Patch
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
|
|
268
|
+
|
|
269
|
+
assert result.ok
|
|
270
|
+
assert result.title == "Added created.txt"
|
|
271
|
+
assert (tmp_path / "created.txt").read_text() == "hello\n"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_apply_patch_deleted_file_title(tmp_path) -> None:
|
|
275
|
+
target = tmp_path / "old.txt"
|
|
276
|
+
target.write_text("remove me\n")
|
|
277
|
+
patch = """*** Begin Patch
|
|
278
|
+
*** Delete File: old.txt
|
|
279
|
+
*** End Patch
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
|
|
283
|
+
|
|
284
|
+
assert result.ok
|
|
285
|
+
assert result.title == "Deleted old.txt"
|
|
286
|
+
assert not target.exists()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_apply_patch_multiple_files_title(tmp_path) -> None:
|
|
290
|
+
target = tmp_path / "notes.txt"
|
|
291
|
+
target.write_text("alpha\nbeta\n")
|
|
292
|
+
patch = """*** Begin Patch
|
|
293
|
+
*** Update File: notes.txt
|
|
294
|
+
@@
|
|
295
|
+
-beta
|
|
296
|
+
+ready
|
|
297
|
+
*** Add File: created.txt
|
|
298
|
+
+hello
|
|
299
|
+
*** End Patch
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
|
|
303
|
+
|
|
304
|
+
assert result.ok
|
|
305
|
+
assert result.title == "Edited 2 files"
|
|
306
|
+
assert target.read_text() == "alpha\nready\n"
|
|
307
|
+
assert (tmp_path / "created.txt").read_text() == "hello\n"
|
|
308
|
+
|
|
309
|
+
|
|
259
310
|
def test_apply_patch_rejects_outside_workdir_file(tmp_path) -> None:
|
|
260
311
|
outside = Path(__file__).resolve().parent / "outside-patch.txt"
|
|
261
312
|
outside.write_text("alpha\n")
|
|
@@ -271,6 +322,7 @@ def test_apply_patch_rejects_outside_workdir_file(tmp_path) -> None:
|
|
|
271
322
|
result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
|
|
272
323
|
|
|
273
324
|
assert not result.ok
|
|
325
|
+
assert result.title == "Edit failed"
|
|
274
326
|
assert outside.read_text() == "alpha\n"
|
|
275
327
|
finally:
|
|
276
328
|
outside.unlink(missing_ok=True)
|
|
@@ -297,6 +349,7 @@ def test_apply_patch_uses_internal_subcommand(tmp_path, monkeypatch) -> None:
|
|
|
297
349
|
result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
|
|
298
350
|
|
|
299
351
|
assert result.ok
|
|
352
|
+
assert result.title == "Edited files"
|
|
300
353
|
assert calls
|
|
301
354
|
assert calls[0][1:4] == ["-m", "flowent.cli", "apply-patch"]
|
|
302
355
|
|
|
@@ -326,6 +379,7 @@ def test_apply_patch_reports_patch_error_when_stderr_has_warning(
|
|
|
326
379
|
result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
|
|
327
380
|
|
|
328
381
|
assert not result.ok
|
|
382
|
+
assert result.title == "Edit failed"
|
|
329
383
|
assert result.content == "Patch context was not found."
|
|
330
384
|
|
|
331
385
|
|
|
@@ -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("") == [""]
|