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.
Files changed (54) hide show
  1. package/backend/pyproject.toml +2 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/agent.py +28 -7
  20. package/backend/src/flowent/channels.py +296 -0
  21. package/backend/src/flowent/main.py +226 -3
  22. package/backend/src/flowent/mcp.py +484 -0
  23. package/backend/src/flowent/mcp_import.py +202 -0
  24. package/backend/src/flowent/skills.py +157 -0
  25. package/backend/src/flowent/static/assets/index-DqTHSMBo.js +81 -0
  26. package/backend/src/flowent/static/assets/index-d3FBbOXX.css +2 -0
  27. package/backend/src/flowent/static/index.html +2 -2
  28. package/backend/src/flowent/storage.py +419 -0
  29. package/backend/src/flowent/tools.py +34 -7
  30. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/test_agent_tools.py +54 -0
  42. package/backend/tests/test_channels.py +360 -0
  43. package/backend/tests/test_mcp.py +710 -0
  44. package/backend/tests/test_persistence.py +30 -0
  45. package/backend/tests/test_skills.py +462 -0
  46. package/backend/uv.lock +160 -1
  47. package/dist/frontend/assets/index-DqTHSMBo.js +81 -0
  48. package/dist/frontend/assets/index-d3FBbOXX.css +2 -0
  49. package/dist/frontend/index.html +2 -2
  50. package/package.json +1 -1
  51. package/backend/src/flowent/static/assets/index-BwQOML_0.css +0 -2
  52. package/backend/src/flowent/static/assets/index-DXQ_smj0.js +0 -81
  53. package/dist/frontend/assets/index-BwQOML_0.css +0 -2
  54. 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 "Applying patch"
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
- return ToolResult(
196
- content=str(error), ok=False, title=tool_call_title(name, arguments)
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={"files": [str(path) for path in paths]},
268
- title="Applied patch",
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(name: str, arguments: dict[str, object]) -> dict[str, object]:
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
  }
@@ -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("") == [""]