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.
Files changed (53) hide show
  1. package/README.md +14 -0
  2. package/backend/README.md +14 -0
  3. package/backend/pyproject.toml +1 -1
  4. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/main.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__/storage.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/agent.py +15 -0
  19. package/backend/src/flowent/channels.py +296 -0
  20. package/backend/src/flowent/cli.py +11 -0
  21. package/backend/src/flowent/llm.py +49 -1
  22. package/backend/src/flowent/main.py +143 -2
  23. package/backend/src/flowent/sandbox.py +18 -3
  24. package/backend/src/flowent/static/assets/index-CEZrWoDG.css +2 -0
  25. package/backend/src/flowent/static/assets/index-S5a0Rkj1.js +81 -0
  26. package/backend/src/flowent/static/index.html +2 -2
  27. package/backend/src/flowent/storage.py +217 -8
  28. package/backend/src/flowent/tools.py +28 -5
  29. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/conftest.py +21 -0
  39. package/backend/tests/test_agent_tools.py +99 -0
  40. package/backend/tests/test_channels.py +360 -0
  41. package/backend/tests/test_llm_providers.py +58 -0
  42. package/backend/tests/test_persistence.py +46 -0
  43. package/backend/tests/test_startup_requirements.py +48 -0
  44. package/backend/tests/test_workspace_chat.py +28 -0
  45. package/backend/uv.lock +1 -1
  46. package/dist/frontend/assets/index-CEZrWoDG.css +2 -0
  47. package/dist/frontend/assets/index-S5a0Rkj1.js +81 -0
  48. package/dist/frontend/index.html +2 -2
  49. package/package.json +1 -1
  50. package/backend/src/flowent/static/assets/index-C76K95ty.js +0 -81
  51. package/backend/src/flowent/static/assets/index-iUMNKvlU.css +0 -2
  52. package/dist/frontend/assets/index-C76K95ty.js +0 -81
  53. package/dist/frontend/assets/index-iUMNKvlU.css +0 -2
@@ -6,8 +6,8 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Flowent</title>
8
8
  <meta name="description" content="Flowent application" />
9
- <script type="module" crossorigin src="/assets/index-C76K95ty.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-iUMNKvlU.css">
9
+ <script type="module" crossorigin src="/assets/index-S5a0Rkj1.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-CEZrWoDG.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -4,10 +4,32 @@ from pathlib import Path
4
4
 
5
5
  from pydantic import BaseModel, ConfigDict, Field
6
6
 
7
- from flowent.llm import ProviderFormat
7
+ from flowent.llm import ProviderFormat, ReasoningEffort
8
8
  from flowent.paths import data_directory
9
9
 
10
10
 
11
+ class StoredTelegramSession(BaseModel):
12
+ model_config = ConfigDict(extra="forbid")
13
+
14
+ chat_id: str
15
+ display_name: str = ""
16
+ recent_message: str = ""
17
+ status: str
18
+ updated_at: int = 0
19
+ user_id: str = ""
20
+ username: str = ""
21
+
22
+
23
+ class StoredTelegramBot(BaseModel):
24
+ model_config = ConfigDict(extra="forbid")
25
+
26
+ bot_token: str
27
+ enabled: bool
28
+ error: str = ""
29
+ sessions: list[StoredTelegramSession] = Field(default_factory=list)
30
+ status: str = "disabled"
31
+
32
+
11
33
  class StoredProvider(BaseModel):
12
34
  model_config = ConfigDict(extra="forbid")
13
35
 
@@ -22,6 +44,7 @@ class StoredProvider(BaseModel):
22
44
  class StoredSettings(BaseModel):
23
45
  model_config = ConfigDict(extra="forbid")
24
46
 
47
+ reasoning_effort: ReasoningEffort = ReasoningEffort.DEFAULT
25
48
  selected_model: str
26
49
  selected_provider_id: str
27
50
 
@@ -44,6 +67,7 @@ class StoredMessage(BaseModel):
44
67
  author: str
45
68
  content: str
46
69
  id: str
70
+ thinking: str = Field(default="", exclude_if=lambda value: value == "")
47
71
  tools: list[StoredToolItem] = Field(default_factory=list)
48
72
 
49
73
 
@@ -53,6 +77,7 @@ class StoredState(BaseModel):
53
77
  messages: list[StoredMessage]
54
78
  providers: list[StoredProvider]
55
79
  settings: StoredSettings
80
+ telegram_bot: StoredTelegramBot
56
81
 
57
82
 
58
83
  class StateStore:
@@ -72,6 +97,7 @@ class StateStore:
72
97
 
73
98
  def read_state(self) -> StoredState:
74
99
  with self.connect() as connection:
100
+ telegram_bot = self._read_telegram_bot(connection)
75
101
  providers = [
76
102
  StoredProvider(
77
103
  api_key=row["api_key"],
@@ -91,7 +117,7 @@ class StateStore:
91
117
  ]
92
118
  settings_row = connection.execute(
93
119
  """
94
- SELECT selected_provider_id, selected_model
120
+ SELECT selected_provider_id, selected_model, reasoning_effort
95
121
  FROM settings
96
122
  WHERE id = 1
97
123
  """
@@ -101,6 +127,7 @@ class StateStore:
101
127
  author=row["author"],
102
128
  content=row["content"],
103
129
  id=row["id"],
130
+ thinking=row["thinking"],
104
131
  tools=[
105
132
  StoredToolItem.model_validate(tool)
106
133
  for tool in json.loads(row["tools"] or "[]")
@@ -108,7 +135,7 @@ class StateStore:
108
135
  )
109
136
  for row in connection.execute(
110
137
  """
111
- SELECT id, author, content, tools
138
+ SELECT id, author, content, tools, thinking
112
139
  FROM messages
113
140
  ORDER BY position, id
114
141
  """
@@ -119,11 +146,113 @@ class StateStore:
119
146
  messages=messages,
120
147
  providers=providers,
121
148
  settings=StoredSettings(
149
+ reasoning_effort=settings_row["reasoning_effort"]
150
+ if settings_row
151
+ else ReasoningEffort.DEFAULT,
122
152
  selected_model=settings_row["selected_model"] if settings_row else "",
123
153
  selected_provider_id=settings_row["selected_provider_id"]
124
154
  if settings_row
125
155
  else "",
126
156
  ),
157
+ telegram_bot=telegram_bot,
158
+ )
159
+
160
+ def read_telegram_bot(self) -> StoredTelegramBot:
161
+ with self.connect() as connection:
162
+ return self._read_telegram_bot(connection)
163
+
164
+ def save_telegram_bot(self, telegram_bot: StoredTelegramBot) -> StoredTelegramBot:
165
+ with self.connect() as connection:
166
+ connection.execute(
167
+ """
168
+ INSERT INTO telegram_bot (
169
+ id,
170
+ enabled,
171
+ bot_token
172
+ )
173
+ VALUES (1, ?, ?)
174
+ ON CONFLICT(id) DO UPDATE SET
175
+ enabled = excluded.enabled,
176
+ bot_token = excluded.bot_token,
177
+ updated_at = unixepoch()
178
+ """,
179
+ (
180
+ int(telegram_bot.enabled),
181
+ telegram_bot.bot_token,
182
+ ),
183
+ )
184
+ return self._read_telegram_bot(connection)
185
+
186
+ def save_telegram_session(
187
+ self, session: StoredTelegramSession
188
+ ) -> StoredTelegramSession:
189
+ with self.connect() as connection:
190
+ connection.execute(
191
+ """
192
+ INSERT INTO telegram_sessions (
193
+ chat_id,
194
+ user_id,
195
+ username,
196
+ display_name,
197
+ recent_message,
198
+ status
199
+ )
200
+ VALUES (?, ?, ?, ?, ?, ?)
201
+ ON CONFLICT(chat_id) DO UPDATE SET
202
+ user_id = excluded.user_id,
203
+ username = excluded.username,
204
+ display_name = excluded.display_name,
205
+ recent_message = excluded.recent_message,
206
+ status = excluded.status,
207
+ updated_at = unixepoch()
208
+ """,
209
+ (
210
+ session.chat_id,
211
+ session.user_id,
212
+ session.username,
213
+ session.display_name,
214
+ session.recent_message,
215
+ session.status,
216
+ ),
217
+ )
218
+ return session
219
+
220
+ def approve_telegram_session(self, chat_id: str) -> StoredTelegramSession:
221
+ with self.connect() as connection:
222
+ connection.execute(
223
+ """
224
+ UPDATE telegram_sessions
225
+ SET status = 'approved',
226
+ updated_at = unixepoch()
227
+ WHERE chat_id = ?
228
+ """,
229
+ (chat_id,),
230
+ )
231
+ row = connection.execute(
232
+ """
233
+ SELECT
234
+ chat_id,
235
+ user_id,
236
+ username,
237
+ display_name,
238
+ recent_message,
239
+ status,
240
+ updated_at
241
+ FROM telegram_sessions
242
+ WHERE chat_id = ?
243
+ """,
244
+ (chat_id,),
245
+ ).fetchone()
246
+ if row is None:
247
+ raise KeyError(chat_id)
248
+ return StoredTelegramSession(
249
+ chat_id=row["chat_id"],
250
+ display_name=row["display_name"],
251
+ recent_message=row["recent_message"],
252
+ status=row["status"],
253
+ updated_at=row["updated_at"],
254
+ user_id=row["user_id"],
255
+ username=row["username"],
127
256
  )
128
257
 
129
258
  def save_provider(self, provider: StoredProvider) -> StoredProvider:
@@ -166,14 +295,24 @@ class StateStore:
166
295
  with self.connect() as connection:
167
296
  connection.execute(
168
297
  """
169
- INSERT INTO settings (id, selected_provider_id, selected_model)
170
- VALUES (1, ?, ?)
298
+ INSERT INTO settings (
299
+ id,
300
+ selected_provider_id,
301
+ selected_model,
302
+ reasoning_effort
303
+ )
304
+ VALUES (1, ?, ?, ?)
171
305
  ON CONFLICT(id) DO UPDATE SET
172
306
  selected_provider_id = excluded.selected_provider_id,
173
307
  selected_model = excluded.selected_model,
308
+ reasoning_effort = excluded.reasoning_effort,
174
309
  updated_at = unixepoch()
175
310
  """,
176
- (settings.selected_provider_id, settings.selected_model),
311
+ (
312
+ settings.selected_provider_id,
313
+ settings.selected_model,
314
+ settings.reasoning_effort.value,
315
+ ),
177
316
  )
178
317
  return settings
179
318
 
@@ -182,8 +321,8 @@ class StateStore:
182
321
  connection.execute("DELETE FROM messages")
183
322
  connection.executemany(
184
323
  """
185
- INSERT INTO messages (id, author, content, tools, position)
186
- VALUES (?, ?, ?, ?, ?)
324
+ INSERT INTO messages (id, author, content, tools, thinking, position)
325
+ VALUES (?, ?, ?, ?, ?, ?)
187
326
  """,
188
327
  [
189
328
  (
@@ -196,6 +335,7 @@ class StateStore:
196
335
  for tool in message.tools
197
336
  ]
198
337
  ),
338
+ message.thinking,
199
339
  position,
200
340
  )
201
341
  for position, message in enumerate(messages)
@@ -246,9 +386,65 @@ class StateStore:
246
386
  )
247
387
  ]
248
388
 
389
+ def _read_telegram_bot(self, connection: sqlite3.Connection) -> StoredTelegramBot:
390
+ bot_row = connection.execute(
391
+ """
392
+ SELECT enabled, bot_token
393
+ FROM telegram_bot
394
+ WHERE id = 1
395
+ """
396
+ ).fetchone()
397
+ sessions = [
398
+ StoredTelegramSession(
399
+ chat_id=row["chat_id"],
400
+ display_name=row["display_name"],
401
+ recent_message=row["recent_message"],
402
+ status=row["status"],
403
+ updated_at=row["updated_at"],
404
+ user_id=row["user_id"],
405
+ username=row["username"],
406
+ )
407
+ for row in connection.execute(
408
+ """
409
+ SELECT
410
+ chat_id,
411
+ user_id,
412
+ username,
413
+ display_name,
414
+ recent_message,
415
+ status,
416
+ updated_at
417
+ FROM telegram_sessions
418
+ ORDER BY status DESC, updated_at DESC, chat_id
419
+ """
420
+ )
421
+ ]
422
+ return StoredTelegramBot(
423
+ bot_token=bot_row["bot_token"] if bot_row else "",
424
+ enabled=bool(bot_row["enabled"]) if bot_row else False,
425
+ sessions=sessions,
426
+ )
427
+
249
428
  def _migrate(self, connection: sqlite3.Connection) -> None:
250
429
  connection.executescript(
251
430
  """
431
+ CREATE TABLE IF NOT EXISTS telegram_bot (
432
+ id INTEGER PRIMARY KEY CHECK (id = 1),
433
+ enabled INTEGER NOT NULL DEFAULT 0,
434
+ bot_token TEXT NOT NULL,
435
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
436
+ );
437
+
438
+ CREATE TABLE IF NOT EXISTS telegram_sessions (
439
+ chat_id TEXT PRIMARY KEY,
440
+ user_id TEXT NOT NULL DEFAULT '',
441
+ username TEXT NOT NULL DEFAULT '',
442
+ display_name TEXT NOT NULL DEFAULT '',
443
+ recent_message TEXT NOT NULL DEFAULT '',
444
+ status TEXT NOT NULL,
445
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
446
+ );
447
+
252
448
  CREATE TABLE IF NOT EXISTS providers (
253
449
  id TEXT PRIMARY KEY,
254
450
  name TEXT NOT NULL,
@@ -270,6 +466,7 @@ class StateStore:
270
466
  id INTEGER PRIMARY KEY CHECK (id = 1),
271
467
  selected_provider_id TEXT NOT NULL DEFAULT '',
272
468
  selected_model TEXT NOT NULL DEFAULT '',
469
+ reasoning_effort TEXT NOT NULL DEFAULT 'default',
273
470
  updated_at INTEGER NOT NULL DEFAULT (unixepoch())
274
471
  );
275
472
 
@@ -300,3 +497,15 @@ class StateStore:
300
497
  connection.execute(
301
498
  "ALTER TABLE messages ADD COLUMN tools TEXT NOT NULL DEFAULT '[]'"
302
499
  )
500
+ if "thinking" not in columns:
501
+ connection.execute(
502
+ "ALTER TABLE messages ADD COLUMN thinking TEXT NOT NULL DEFAULT ''"
503
+ )
504
+ settings_columns = {
505
+ row["name"] for row in connection.execute("PRAGMA table_info(settings)")
506
+ }
507
+ if "reasoning_effort" not in settings_columns:
508
+ connection.execute(
509
+ "ALTER TABLE settings "
510
+ "ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'default'"
511
+ )
@@ -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()
@@ -0,0 +1,21 @@
1
+ import os
2
+ import stat
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ _test_environment = Path(tempfile.mkdtemp(prefix="flowent-tests-"))
9
+ _test_bin = _test_environment / "bin"
10
+ _test_bin.mkdir(parents=True, exist_ok=True)
11
+ _test_bwrap = _test_bin / "bwrap"
12
+ _test_bwrap.write_text("#!/bin/sh\nexit 0\n")
13
+ _test_bwrap.chmod(_test_bwrap.stat().st_mode | stat.S_IXUSR)
14
+
15
+ os.environ.setdefault("FLOWENT_DATA_DIR", str(_test_environment / "data"))
16
+ os.environ["PATH"] = f"{_test_bin}{os.pathsep}{os.environ.get('PATH', '')}"
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def sandbox_available(monkeypatch):
21
+ monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: "/usr/bin/bwrap")
@@ -38,6 +38,7 @@ def configure_provider(client: TestClient) -> None:
38
38
  client.put(
39
39
  "/api/settings",
40
40
  json={
41
+ "reasoning_effort": "default",
41
42
  "selected_model": "gpt-5.1",
42
43
  "selected_provider_id": "provider-openai",
43
44
  },
@@ -72,6 +73,10 @@ def text_chunk(content: str) -> dict[str, object]:
72
73
  return {"choices": [{"delta": {"content": content}}]}
73
74
 
74
75
 
76
+ def thinking_chunk(content: str) -> dict[str, object]:
77
+ return {"choices": [{"delta": {"reasoning_content": content}}]}
78
+
79
+
75
80
  def test_workspace_response_streams_tool_process_and_final_text(
76
81
  tmp_path, monkeypatch
77
82
  ) -> None:
@@ -248,7 +253,58 @@ def test_apply_patch_modifies_workdir_file(tmp_path) -> None:
248
253
  result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
249
254
 
250
255
  assert result.ok
256
+ assert result.title == "Edited notes.txt"
257
+ assert target.read_text() == "alpha\nready\n"
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"
251
306
  assert target.read_text() == "alpha\nready\n"
307
+ assert (tmp_path / "created.txt").read_text() == "hello\n"
252
308
 
253
309
 
254
310
  def test_apply_patch_rejects_outside_workdir_file(tmp_path) -> None:
@@ -266,6 +322,7 @@ def test_apply_patch_rejects_outside_workdir_file(tmp_path) -> None:
266
322
  result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
267
323
 
268
324
  assert not result.ok
325
+ assert result.title == "Edit failed"
269
326
  assert outside.read_text() == "alpha\n"
270
327
  finally:
271
328
  outside.unlink(missing_ok=True)
@@ -292,6 +349,7 @@ def test_apply_patch_uses_internal_subcommand(tmp_path, monkeypatch) -> None:
292
349
  result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
293
350
 
294
351
  assert result.ok
352
+ assert result.title == "Edited files"
295
353
  assert calls
296
354
  assert calls[0][1:4] == ["-m", "flowent.cli", "apply-patch"]
297
355
 
@@ -321,6 +379,7 @@ def test_apply_patch_reports_patch_error_when_stderr_has_warning(
321
379
  result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
322
380
 
323
381
  assert not result.ok
382
+ assert result.title == "Edit failed"
324
383
  assert result.content == "Patch context was not found."
325
384
 
326
385
 
@@ -431,6 +490,46 @@ def test_agent_finishes_without_tools(tmp_path, monkeypatch) -> None:
431
490
  assert events[-1]["data"]["message"]["content"] == "Direct answer."
432
491
 
433
492
 
493
+ def test_agent_streams_and_persists_thinking(tmp_path, monkeypatch) -> None:
494
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
495
+ monkeypatch.chdir(tmp_path)
496
+
497
+ async def fake_completion(**request: object) -> object:
498
+ async def chunks() -> object:
499
+ yield thinking_chunk("Checking context.")
500
+ yield thinking_chunk(" Preparing answer.")
501
+ yield text_chunk("Direct answer.")
502
+
503
+ return chunks()
504
+
505
+ client = TestClient(
506
+ create_app(serve_frontend=False, chat_completion=fake_completion)
507
+ )
508
+ configure_provider(client)
509
+
510
+ response = client.post(
511
+ "/api/workspace/respond",
512
+ json={"content": "Answer directly."},
513
+ )
514
+
515
+ assert response.status_code == 200
516
+ events = stream_events(response.text)
517
+ assert [event["event"] for event in events] == [
518
+ "start",
519
+ "output_start",
520
+ "thinking_delta",
521
+ "thinking_delta",
522
+ "delta",
523
+ "done",
524
+ ]
525
+ assert events[2]["data"] == {"content": "Checking context."}
526
+ assert events[-1]["data"]["message"]["thinking"] == (
527
+ "Checking context. Preparing answer."
528
+ )
529
+ state = client.get("/api/state").json()
530
+ assert state["messages"][-1]["thinking"] == ("Checking context. Preparing answer.")
531
+
532
+
434
533
  def test_tool_failure_is_reported_and_agent_continues(tmp_path, monkeypatch) -> None:
435
534
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
436
535
  monkeypatch.chdir(tmp_path)