flowent 0.2.0 → 0.2.1
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 +31 -5
- package/backend/src/flowent/agent.py +13 -4
- package/backend/src/flowent/compact.py +35 -14
- package/backend/src/flowent/llm.py +73 -7
- package/backend/src/flowent/main.py +260 -59
- package/backend/src/flowent/static/assets/index-CRSV2xu1.css +2 -0
- package/backend/src/flowent/static/assets/index-DUYj6rgD.js +82 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +135 -3
- package/backend/src/flowent/usage.py +315 -0
- package/backend/uv.lock +971 -3
- package/dist/frontend/assets/index-CRSV2xu1.css +2 -0
- package/dist/frontend/assets/index-DUYj6rgD.js +82 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +24 -3
- 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/static/assets/index-BlaCigkZ.js +0 -82
- package/backend/src/flowent/static/assets/index-CRvbsH4K.css +0 -2
- 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/conftest.py +0 -60
- package/backend/tests/test_agent_tools.py +0 -1124
- package/backend/tests/test_approval.py +0 -283
- package/backend/tests/test_channels.py +0 -360
- package/backend/tests/test_health.py +0 -12
- package/backend/tests/test_llm_providers.py +0 -548
- package/backend/tests/test_logging.py +0 -212
- package/backend/tests/test_mcp.py +0 -788
- package/backend/tests/test_patch.py +0 -112
- package/backend/tests/test_permissions.py +0 -588
- package/backend/tests/test_persistence.py +0 -249
- package/backend/tests/test_skills.py +0 -462
- package/backend/tests/test_startup_requirements.py +0 -144
- package/backend/tests/test_workspace_chat.py +0 -2174
- package/dist/frontend/assets/index-BlaCigkZ.js +0 -82
- package/dist/frontend/assets/index-CRvbsH4K.css +0 -2
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
|
|
3
|
-
import pytest
|
|
4
|
-
|
|
5
|
-
from flowent.approval import (
|
|
6
|
-
ApprovalReviewRequest,
|
|
7
|
-
ApprovalTranscriptEntry,
|
|
8
|
-
review_approval_request,
|
|
9
|
-
)
|
|
10
|
-
from flowent.llm import ProviderConnection, ProviderFormat
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def provider_connection() -> ProviderConnection:
|
|
14
|
-
return ProviderConnection(
|
|
15
|
-
model="model",
|
|
16
|
-
name="Provider",
|
|
17
|
-
provider=ProviderFormat.OPENAI,
|
|
18
|
-
secret_reference="secret",
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@pytest.mark.anyio
|
|
23
|
-
async def test_review_payload_includes_current_user_request_and_transcript(
|
|
24
|
-
tmp_path,
|
|
25
|
-
) -> None:
|
|
26
|
-
captured_messages: list[dict[str, object]] = []
|
|
27
|
-
|
|
28
|
-
async def fake_completion(**request: object) -> object:
|
|
29
|
-
captured_messages.extend(request["messages"])
|
|
30
|
-
return {
|
|
31
|
-
"choices": [
|
|
32
|
-
{
|
|
33
|
-
"message": {
|
|
34
|
-
"content": json.dumps(
|
|
35
|
-
{
|
|
36
|
-
"risk_level": "low",
|
|
37
|
-
"risk_score": 25,
|
|
38
|
-
"rationale": "User approved after concrete risk context.",
|
|
39
|
-
"evidence": [
|
|
40
|
-
{
|
|
41
|
-
"message": "Assistant explained Docker socket impact.",
|
|
42
|
-
"why": "Establishes informed consent.",
|
|
43
|
-
}
|
|
44
|
-
],
|
|
45
|
-
}
|
|
46
|
-
),
|
|
47
|
-
"role": "assistant",
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
],
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
decision = await review_approval_request(
|
|
54
|
-
provider_connection(),
|
|
55
|
-
ApprovalReviewRequest(
|
|
56
|
-
action="additional_permissions",
|
|
57
|
-
arguments={"command": "docker compose up -d --build"},
|
|
58
|
-
cwd=tmp_path,
|
|
59
|
-
tool_name="shell_command",
|
|
60
|
-
user_request="确认",
|
|
61
|
-
transcript=[
|
|
62
|
-
ApprovalTranscriptEntry(
|
|
63
|
-
role="assistant",
|
|
64
|
-
content=(
|
|
65
|
-
"This will recreate the dev container, write to the Docker "
|
|
66
|
-
"socket, and briefly interrupt the local service."
|
|
67
|
-
),
|
|
68
|
-
),
|
|
69
|
-
ApprovalTranscriptEntry(role="user", content="确认"),
|
|
70
|
-
],
|
|
71
|
-
write_paths=[tmp_path / "docker.sock"],
|
|
72
|
-
),
|
|
73
|
-
completion=fake_completion,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
assert decision.decision == "approved"
|
|
77
|
-
assert decision.risk_level == "low"
|
|
78
|
-
assert decision.risk_score == 25
|
|
79
|
-
assert "informed of the concrete risk" in str(captured_messages[0]["content"])
|
|
80
|
-
payload = json.loads(str(captured_messages[-1]["content"]))
|
|
81
|
-
assert payload["user_request"] == "确认"
|
|
82
|
-
assert payload["transcript"][-1] == {"role": "user", "content": "确认"}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@pytest.mark.anyio
|
|
86
|
-
async def test_concrete_docker_socket_confirmation_can_be_approved(tmp_path) -> None:
|
|
87
|
-
async def fake_completion(**request: object) -> object:
|
|
88
|
-
return {
|
|
89
|
-
"choices": [
|
|
90
|
-
{
|
|
91
|
-
"message": {
|
|
92
|
-
"content": json.dumps(
|
|
93
|
-
{
|
|
94
|
-
"risk_level": "medium",
|
|
95
|
-
"risk_score": 55,
|
|
96
|
-
"rationale": (
|
|
97
|
-
"The user approved after being told the command "
|
|
98
|
-
"will recreate the dev container through Docker."
|
|
99
|
-
),
|
|
100
|
-
"evidence": [],
|
|
101
|
-
}
|
|
102
|
-
),
|
|
103
|
-
"role": "assistant",
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
]
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
decision = await review_approval_request(
|
|
110
|
-
provider_connection(),
|
|
111
|
-
ApprovalReviewRequest(
|
|
112
|
-
action="additional_permissions",
|
|
113
|
-
arguments={
|
|
114
|
-
"command": "docker compose up -d --force-recreate flowent",
|
|
115
|
-
},
|
|
116
|
-
cwd=tmp_path,
|
|
117
|
-
tool_name="shell_command",
|
|
118
|
-
user_request="确认",
|
|
119
|
-
transcript=[
|
|
120
|
-
ApprovalTranscriptEntry(
|
|
121
|
-
role="assistant",
|
|
122
|
-
content=(
|
|
123
|
-
"This will recreate the Flowent dev container through "
|
|
124
|
-
"Docker and may briefly interrupt the running service."
|
|
125
|
-
),
|
|
126
|
-
),
|
|
127
|
-
ApprovalTranscriptEntry(role="user", content="确认"),
|
|
128
|
-
],
|
|
129
|
-
write_paths=[tmp_path / "docker.sock"],
|
|
130
|
-
),
|
|
131
|
-
completion=fake_completion,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
assert decision.decision == "approved"
|
|
135
|
-
assert decision.risk_level == "medium"
|
|
136
|
-
assert decision.risk_score == 55
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
@pytest.mark.anyio
|
|
140
|
-
async def test_vague_confirmation_without_concrete_risk_context_is_denied(
|
|
141
|
-
tmp_path,
|
|
142
|
-
) -> None:
|
|
143
|
-
captured_payload: dict[str, object] = {}
|
|
144
|
-
|
|
145
|
-
async def fake_completion(**request: object) -> object:
|
|
146
|
-
captured_payload.update(json.loads(str(request["messages"][-1]["content"])))
|
|
147
|
-
return {
|
|
148
|
-
"choices": [
|
|
149
|
-
{
|
|
150
|
-
"message": {
|
|
151
|
-
"content": json.dumps(
|
|
152
|
-
{
|
|
153
|
-
"risk_level": "high",
|
|
154
|
-
"risk_score": 82,
|
|
155
|
-
"rationale": (
|
|
156
|
-
"The transcript only contains a vague confirmation "
|
|
157
|
-
"and no concrete Docker risk explanation."
|
|
158
|
-
),
|
|
159
|
-
"evidence": [],
|
|
160
|
-
}
|
|
161
|
-
),
|
|
162
|
-
"role": "assistant",
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
]
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
decision = await review_approval_request(
|
|
169
|
-
provider_connection(),
|
|
170
|
-
ApprovalReviewRequest(
|
|
171
|
-
action="additional_permissions",
|
|
172
|
-
arguments={
|
|
173
|
-
"command": "docker compose up -d --force-recreate flowent",
|
|
174
|
-
},
|
|
175
|
-
cwd=tmp_path,
|
|
176
|
-
tool_name="shell_command",
|
|
177
|
-
user_request="确认",
|
|
178
|
-
transcript=[ApprovalTranscriptEntry(role="user", content="确认")],
|
|
179
|
-
write_paths=[tmp_path / "docker.sock"],
|
|
180
|
-
),
|
|
181
|
-
completion=fake_completion,
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
assert decision.decision == "denied"
|
|
185
|
-
assert decision.risk_level == "high"
|
|
186
|
-
assert decision.risk_score == 82
|
|
187
|
-
assert captured_payload["transcript"] == [{"role": "user", "content": "确认"}]
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
@pytest.mark.anyio
|
|
191
|
-
async def test_broad_destructive_action_with_vague_confirmation_is_denied(
|
|
192
|
-
tmp_path,
|
|
193
|
-
) -> None:
|
|
194
|
-
async def fake_completion(**request: object) -> object:
|
|
195
|
-
return {
|
|
196
|
-
"choices": [
|
|
197
|
-
{
|
|
198
|
-
"message": {
|
|
199
|
-
"content": json.dumps(
|
|
200
|
-
{
|
|
201
|
-
"risk_level": "high",
|
|
202
|
-
"risk_score": 96,
|
|
203
|
-
"rationale": (
|
|
204
|
-
"The action can delete broad data and the user "
|
|
205
|
-
"did not approve that concrete destructive risk."
|
|
206
|
-
),
|
|
207
|
-
"evidence": [
|
|
208
|
-
{
|
|
209
|
-
"message": "rm -rf /var/lib/postgresql",
|
|
210
|
-
"why": "Broad destructive write outside the task.",
|
|
211
|
-
}
|
|
212
|
-
],
|
|
213
|
-
}
|
|
214
|
-
),
|
|
215
|
-
"role": "assistant",
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
]
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
decision = await review_approval_request(
|
|
222
|
-
provider_connection(),
|
|
223
|
-
ApprovalReviewRequest(
|
|
224
|
-
action="sandbox_failure",
|
|
225
|
-
arguments={"command": "rm -rf /var/lib/postgresql"},
|
|
226
|
-
cwd=tmp_path,
|
|
227
|
-
tool_name="shell_command",
|
|
228
|
-
tool_result="Read-only file system",
|
|
229
|
-
user_request="确认",
|
|
230
|
-
transcript=[ApprovalTranscriptEntry(role="user", content="确认")],
|
|
231
|
-
),
|
|
232
|
-
completion=fake_completion,
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
assert decision.decision == "denied"
|
|
236
|
-
assert decision.risk_level == "high"
|
|
237
|
-
assert decision.risk_score == 96
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
@pytest.mark.anyio
|
|
241
|
-
async def test_invalid_reviewer_json_is_denied(tmp_path) -> None:
|
|
242
|
-
async def fake_completion(**request: object) -> object:
|
|
243
|
-
return {
|
|
244
|
-
"choices": [
|
|
245
|
-
{"message": {"content": "approved", "role": "assistant"}},
|
|
246
|
-
],
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
decision = await review_approval_request(
|
|
250
|
-
provider_connection(),
|
|
251
|
-
ApprovalReviewRequest(
|
|
252
|
-
action="sandbox_failure",
|
|
253
|
-
arguments={"command": "touch file.txt"},
|
|
254
|
-
cwd=tmp_path,
|
|
255
|
-
tool_name="shell_command",
|
|
256
|
-
tool_result="Read-only file system",
|
|
257
|
-
),
|
|
258
|
-
completion=fake_completion,
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
assert decision.decision == "denied"
|
|
262
|
-
assert "valid JSON" in decision.reason
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
@pytest.mark.anyio
|
|
266
|
-
async def test_reviewer_call_failure_is_denied(tmp_path) -> None:
|
|
267
|
-
async def fake_completion(**request: object) -> object:
|
|
268
|
-
raise RuntimeError("model unavailable")
|
|
269
|
-
|
|
270
|
-
decision = await review_approval_request(
|
|
271
|
-
provider_connection(),
|
|
272
|
-
ApprovalReviewRequest(
|
|
273
|
-
action="edit",
|
|
274
|
-
arguments={"patch": "*** Begin Patch\n*** End Patch"},
|
|
275
|
-
cwd=tmp_path,
|
|
276
|
-
tool_name="apply_patch",
|
|
277
|
-
write_paths=[tmp_path / "outside"],
|
|
278
|
-
),
|
|
279
|
-
completion=fake_completion,
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
assert decision.decision == "denied"
|
|
283
|
-
assert "model unavailable" in decision.reason
|
|
@@ -1,360 +0,0 @@
|
|
|
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("") == [""]
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
from fastapi.testclient import TestClient
|
|
2
|
-
|
|
3
|
-
from flowent.main import create_app
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_health_endpoint() -> None:
|
|
7
|
-
client = TestClient(create_app(serve_frontend=False))
|
|
8
|
-
|
|
9
|
-
response = client.get("/api/health")
|
|
10
|
-
|
|
11
|
-
assert response.status_code == 200
|
|
12
|
-
assert response.json() == {"status": "ok"}
|