flowent 0.1.3 → 0.1.4

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 (55) hide show
  1. package/backend/pyproject.toml +1 -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__/compact.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/cli.py +14 -2
  22. package/backend/src/flowent/compact.py +183 -0
  23. package/backend/src/flowent/main.py +125 -50
  24. package/backend/src/flowent/mcp.py +3 -1
  25. package/backend/src/flowent/paths.py +12 -0
  26. package/backend/src/flowent/sandbox.py +91 -12
  27. package/backend/src/flowent/static/assets/index-BREidonU.css +2 -0
  28. package/backend/src/flowent/static/assets/index-DSniOrhL.js +81 -0
  29. package/backend/src/flowent/static/index.html +2 -2
  30. package/backend/src/flowent/storage.py +154 -1
  31. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  42. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  43. package/backend/tests/test_agent_tools.py +235 -0
  44. package/backend/tests/test_mcp.py +76 -10
  45. package/backend/tests/test_startup_requirements.py +42 -0
  46. package/backend/tests/test_workspace_chat.py +316 -9
  47. package/backend/uv.lock +1 -1
  48. package/dist/frontend/assets/index-BREidonU.css +2 -0
  49. package/dist/frontend/assets/index-DSniOrhL.js +81 -0
  50. package/dist/frontend/index.html +2 -2
  51. package/package.json +2 -2
  52. package/backend/src/flowent/static/assets/index-DjF2KBwE.js +0 -81
  53. package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
  54. package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
  55. package/dist/frontend/assets/index-P-bBpJG8.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-DjF2KBwE.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-P-bBpJG8.css">
9
+ <script type="module" crossorigin src="/assets/index-DSniOrhL.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-BREidonU.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  from pydantic import BaseModel, ConfigDict, Field
6
6
 
7
- from flowent.llm import ProviderFormat, ReasoningEffort
7
+ from flowent.llm import ChatMessage, ProviderFormat, ReasoningEffort
8
8
  from flowent.paths import data_directory
9
9
 
10
10
 
@@ -75,6 +75,15 @@ class StoredWritablePath(BaseModel):
75
75
  path: str
76
76
 
77
77
 
78
+ class StoredPermissionRequest(BaseModel):
79
+ model_config = ConfigDict(extra="forbid")
80
+
81
+ id: str
82
+ path: str
83
+ reason: str
84
+ tool_call_id: str | None = None
85
+
86
+
78
87
  class StoredProvider(BaseModel):
79
88
  model_config = ConfigDict(extra="forbid")
80
89
 
@@ -119,6 +128,20 @@ class StoredMessage(BaseModel):
119
128
  tools: list[StoredToolItem] = Field(default_factory=list)
120
129
 
121
130
 
131
+ class StoredCompactionCheckpoint(BaseModel):
132
+ model_config = ConfigDict(extra="forbid")
133
+
134
+ created_at: int = 0
135
+ id: str
136
+ method: str
137
+ replacement_history: list[ChatMessage]
138
+ source_message_id: str | None = None
139
+ summary: str
140
+ token_after: int = 0
141
+ token_before: int = 0
142
+ trigger: str
143
+
144
+
122
145
  class StoredState(BaseModel):
123
146
  model_config = ConfigDict(extra="forbid")
124
147
 
@@ -127,6 +150,7 @@ class StoredState(BaseModel):
127
150
  mcp_servers: list[StoredMcpServer]
128
151
  messages: list[StoredMessage]
129
152
  providers: list[StoredProvider]
153
+ permission_requests: list[StoredPermissionRequest] = Field(default_factory=list)
130
154
  settings: StoredSettings
131
155
  skills: list[StoredSkill]
132
156
  telegram_bot: StoredTelegramBot
@@ -607,12 +631,120 @@ class StateStore:
607
631
  VALUES (1, ?)
608
632
  ON CONFLICT(id) DO UPDATE SET
609
633
  compacted_summary = excluded.compacted_summary,
634
+ active_compaction_id = NULL,
610
635
  updated_at = unixepoch()
611
636
  """,
612
637
  (summary,),
613
638
  )
614
639
  return summary
615
640
 
641
+ def read_active_compaction_checkpoint(
642
+ self,
643
+ ) -> StoredCompactionCheckpoint | None:
644
+ with self.connect() as connection:
645
+ row = connection.execute(
646
+ """
647
+ SELECT
648
+ checkpoint.id,
649
+ checkpoint.trigger,
650
+ checkpoint.method,
651
+ checkpoint.summary,
652
+ checkpoint.replacement_history,
653
+ checkpoint.source_message_id,
654
+ checkpoint.token_before,
655
+ checkpoint.token_after,
656
+ checkpoint.created_at
657
+ FROM workspace_context context
658
+ JOIN compaction_checkpoints checkpoint
659
+ ON checkpoint.id = context.active_compaction_id
660
+ WHERE context.id = 1
661
+ """
662
+ ).fetchone()
663
+ if row is None:
664
+ return None
665
+ return StoredCompactionCheckpoint(
666
+ created_at=row["created_at"],
667
+ id=row["id"],
668
+ method=row["method"],
669
+ replacement_history=[
670
+ ChatMessage.model_validate(message)
671
+ for message in json.loads(row["replacement_history"] or "[]")
672
+ ],
673
+ source_message_id=row["source_message_id"],
674
+ summary=row["summary"],
675
+ token_after=row["token_after"],
676
+ token_before=row["token_before"],
677
+ trigger=row["trigger"],
678
+ )
679
+
680
+ def save_compaction_checkpoint(
681
+ self, checkpoint: StoredCompactionCheckpoint
682
+ ) -> StoredCompactionCheckpoint:
683
+ with self.connect() as connection:
684
+ connection.execute(
685
+ """
686
+ INSERT INTO compaction_checkpoints (
687
+ id,
688
+ trigger,
689
+ method,
690
+ summary,
691
+ replacement_history,
692
+ source_message_id,
693
+ token_before,
694
+ token_after
695
+ )
696
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
697
+ ON CONFLICT(id) DO UPDATE SET
698
+ trigger = excluded.trigger,
699
+ method = excluded.method,
700
+ summary = excluded.summary,
701
+ replacement_history = excluded.replacement_history,
702
+ source_message_id = excluded.source_message_id,
703
+ token_before = excluded.token_before,
704
+ token_after = excluded.token_after
705
+ """,
706
+ (
707
+ checkpoint.id,
708
+ checkpoint.trigger,
709
+ checkpoint.method,
710
+ checkpoint.summary,
711
+ json.dumps(
712
+ [
713
+ message.model_dump()
714
+ for message in checkpoint.replacement_history
715
+ ],
716
+ ensure_ascii=False,
717
+ ),
718
+ checkpoint.source_message_id,
719
+ checkpoint.token_before,
720
+ checkpoint.token_after,
721
+ ),
722
+ )
723
+ connection.execute(
724
+ """
725
+ INSERT INTO workspace_context (
726
+ id,
727
+ compacted_summary,
728
+ active_compaction_id
729
+ )
730
+ VALUES (1, ?, ?)
731
+ ON CONFLICT(id) DO UPDATE SET
732
+ compacted_summary = excluded.compacted_summary,
733
+ active_compaction_id = excluded.active_compaction_id,
734
+ updated_at = unixepoch()
735
+ """,
736
+ (checkpoint.summary, checkpoint.id),
737
+ )
738
+ row = connection.execute(
739
+ """
740
+ SELECT created_at
741
+ FROM compaction_checkpoints
742
+ WHERE id = ?
743
+ """,
744
+ (checkpoint.id,),
745
+ ).fetchone()
746
+ return checkpoint.model_copy(update={"created_at": row["created_at"]})
747
+
616
748
  def _provider_models(
617
749
  self, connection: sqlite3.Connection, provider_id: str
618
750
  ) -> list[str]:
@@ -807,9 +939,22 @@ class StateStore:
807
939
  CREATE TABLE IF NOT EXISTS workspace_context (
808
940
  id INTEGER PRIMARY KEY CHECK (id = 1),
809
941
  compacted_summary TEXT NOT NULL DEFAULT '',
942
+ active_compaction_id TEXT,
810
943
  updated_at INTEGER NOT NULL DEFAULT (unixepoch())
811
944
  );
812
945
 
946
+ CREATE TABLE IF NOT EXISTS compaction_checkpoints (
947
+ id TEXT PRIMARY KEY,
948
+ trigger TEXT NOT NULL,
949
+ method TEXT NOT NULL,
950
+ summary TEXT NOT NULL,
951
+ replacement_history TEXT NOT NULL DEFAULT '[]',
952
+ source_message_id TEXT,
953
+ token_before INTEGER NOT NULL DEFAULT 0,
954
+ token_after INTEGER NOT NULL DEFAULT 0,
955
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
956
+ );
957
+
813
958
  CREATE TABLE IF NOT EXISTS skill_settings (
814
959
  id TEXT PRIMARY KEY,
815
960
  enabled INTEGER NOT NULL DEFAULT 1,
@@ -861,3 +1006,11 @@ class StateStore:
861
1006
  "ALTER TABLE settings "
862
1007
  "ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'default'"
863
1008
  )
1009
+ workspace_context_columns = {
1010
+ row["name"]
1011
+ for row in connection.execute("PRAGMA table_info(workspace_context)")
1012
+ }
1013
+ if "active_compaction_id" not in workspace_context_columns:
1014
+ connection.execute(
1015
+ "ALTER TABLE workspace_context ADD COLUMN active_compaction_id TEXT"
1016
+ )
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import json
3
+ import subprocess
3
4
  import time
4
5
  from pathlib import Path
5
6
 
@@ -226,6 +227,240 @@ def test_shell_command_has_network_by_default(tmp_path) -> None:
226
227
  assert "network-ready" in result.content
227
228
 
228
229
 
230
+ def test_sandbox_command_keeps_proc_mount_when_preflight_succeeds(
231
+ tmp_path, monkeypatch
232
+ ) -> None:
233
+ runner = SandboxRunner(cwd=tmp_path)
234
+ monkeypatch.setattr("flowent.sandbox.sandbox_supports_proc_mount", lambda: True)
235
+
236
+ command = runner.build_command(["/bin/true"])
237
+
238
+ assert command.args[command.args.index("--proc") + 1] == "/proc"
239
+
240
+
241
+ def test_sandbox_command_omits_proc_mount_when_preflight_reports_permission_error(
242
+ tmp_path, monkeypatch
243
+ ) -> None:
244
+ runner = SandboxRunner(cwd=tmp_path)
245
+ monkeypatch.setattr("flowent.sandbox.sandbox_supports_proc_mount", lambda: False)
246
+
247
+ command = runner.build_command(["/bin/true"])
248
+
249
+ assert "--proc" not in command.args
250
+
251
+
252
+ def test_sandbox_proc_preflight_does_not_hide_non_proc_errors(
253
+ tmp_path, monkeypatch
254
+ ) -> None:
255
+ bwrap = tmp_path / "bwrap"
256
+ bwrap.write_text("#!/bin/sh\necho 'bwrap: unrelated startup failure' >&2\nexit 1\n")
257
+ bwrap.chmod(0o700)
258
+ monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: str(bwrap))
259
+
260
+ assert SandboxRunner(cwd=tmp_path).build_command(["/bin/true"]).args[0:7] == [
261
+ str(bwrap),
262
+ "--ro-bind",
263
+ "/",
264
+ "/",
265
+ "--dev",
266
+ "/dev",
267
+ "--proc",
268
+ ]
269
+
270
+
271
+ def test_shell_command_runs_without_proc_mount_after_preflight_fallback(
272
+ tmp_path, monkeypatch
273
+ ) -> None:
274
+ bwrap = tmp_path / "bwrap"
275
+ bwrap.write_text(
276
+ "#!/bin/sh\n"
277
+ 'for arg in "$@"; do\n'
278
+ ' if [ "$arg" = --proc ]; then\n'
279
+ ' echo "bwrap: Can\'t mount proc on /newroot/proc: Operation not permitted" >&2\n'
280
+ " exit 1\n"
281
+ " fi\n"
282
+ "done\n"
283
+ 'while [ "$#" -gt 0 ]; do\n'
284
+ ' if [ "$1" = -- ]; then\n'
285
+ " shift\n"
286
+ ' exec "$@"\n'
287
+ " fi\n"
288
+ " shift\n"
289
+ "done\n"
290
+ )
291
+ bwrap.chmod(0o700)
292
+ monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: str(bwrap))
293
+
294
+ result = SandboxRunner(cwd=tmp_path).run(["/bin/sh", "-c", "printf ok"])
295
+
296
+ assert result.exit_code == 0
297
+ assert result.stdout == "ok"
298
+
299
+
300
+ def test_apply_patch_runs_without_proc_mount_after_preflight_fallback(
301
+ tmp_path, monkeypatch
302
+ ) -> None:
303
+ bwrap = tmp_path / "bwrap"
304
+ bwrap.write_text(
305
+ "#!/bin/sh\n"
306
+ 'for arg in "$@"; do\n'
307
+ ' if [ "$arg" = --proc ]; then\n'
308
+ ' echo "bwrap: Can\'t mount proc on /newroot/proc: Operation not permitted" >&2\n'
309
+ " exit 1\n"
310
+ " fi\n"
311
+ "done\n"
312
+ 'while [ "$#" -gt 0 ]; do\n'
313
+ ' if [ "$1" = -- ]; then\n'
314
+ " shift\n"
315
+ ' exec "$@"\n'
316
+ " fi\n"
317
+ " shift\n"
318
+ "done\n"
319
+ )
320
+ bwrap.chmod(0o700)
321
+ monkeypatch.setattr("flowent.sandbox.sandbox_binary", lambda: str(bwrap))
322
+ target = tmp_path / "notes.txt"
323
+ target.write_text("alpha\n")
324
+ patch = """*** Begin Patch
325
+ *** Update File: notes.txt
326
+ @@
327
+ -alpha
328
+ +beta
329
+ *** End Patch
330
+ """
331
+
332
+ result = run_tool("apply_patch", {"patch": patch}, ToolContext(cwd=tmp_path))
333
+
334
+ assert result.ok
335
+ assert target.read_text() == "beta\n"
336
+
337
+
338
+ def test_shell_command_environment_omits_development_variables(
339
+ tmp_path, monkeypatch
340
+ ) -> None:
341
+ monkeypatch.setenv("NODE_ENV", "production")
342
+ monkeypatch.setenv("VIRTUAL_ENV", "/tmp/flowent-venv")
343
+ monkeypatch.setenv("PYTHONPATH", "/tmp/flowent-pythonpath")
344
+ runner = SandboxRunner(cwd=tmp_path)
345
+ monkeypatch.setattr(
346
+ runner,
347
+ "build_command",
348
+ lambda command: SandboxCommand(command, seccomp_available=False),
349
+ )
350
+
351
+ result = runner.run(
352
+ [
353
+ "/bin/sh",
354
+ "-c",
355
+ 'printf \'%s|%s|%s\' "${NODE_ENV-unset}" "${VIRTUAL_ENV-unset}" "${PYTHONPATH-unset}"',
356
+ ]
357
+ )
358
+
359
+ assert result.exit_code == 0
360
+ assert result.stdout == "unset|unset|unset"
361
+
362
+
363
+ def test_shell_command_environment_omits_sensitive_variables(
364
+ tmp_path, monkeypatch
365
+ ) -> None:
366
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-local")
367
+ monkeypatch.setenv("SECRET_TOKEN", "secret")
368
+ monkeypatch.setenv("NPM_TOKEN", "npm")
369
+ runner = SandboxRunner(cwd=tmp_path)
370
+ monkeypatch.setattr(
371
+ runner,
372
+ "build_command",
373
+ lambda command: SandboxCommand(command, seccomp_available=False),
374
+ )
375
+
376
+ result = runner.run(
377
+ [
378
+ "/bin/sh",
379
+ "-c",
380
+ 'printf \'%s|%s|%s\' "${OPENAI_API_KEY-unset}" "${SECRET_TOKEN-unset}" "${NPM_TOKEN-unset}"',
381
+ ]
382
+ )
383
+
384
+ assert result.exit_code == 0
385
+ assert result.stdout == "unset|unset|unset"
386
+
387
+
388
+ def test_shell_command_environment_keeps_core_variables(tmp_path, monkeypatch) -> None:
389
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
390
+ monkeypatch.setenv("PATH", "/usr/local/bin:/usr/bin:/bin")
391
+ monkeypatch.setenv("SHELL", "/bin/sh")
392
+ monkeypatch.setenv("USER", "flowent")
393
+ runner = SandboxRunner(cwd=tmp_path)
394
+ monkeypatch.setattr(
395
+ runner,
396
+ "build_command",
397
+ lambda command: SandboxCommand(command, seccomp_available=False),
398
+ )
399
+
400
+ result = runner.run(
401
+ [
402
+ "/bin/sh",
403
+ "-c",
404
+ 'printf \'%s|%s|%s|%s\' "$HOME" "$PATH" "$SHELL" "$USER"',
405
+ ]
406
+ )
407
+
408
+ assert result.exit_code == 0
409
+ assert (
410
+ result.stdout
411
+ == f"{tmp_path / 'home'}|/usr/local/bin:/usr/bin:/bin|/bin/sh|flowent"
412
+ )
413
+
414
+
415
+ def test_shell_command_environment_uses_default_path_when_missing(
416
+ tmp_path, monkeypatch
417
+ ) -> None:
418
+ monkeypatch.delenv("PATH", raising=False)
419
+ runner = SandboxRunner(cwd=tmp_path)
420
+ captured_env: dict[str, str] = {}
421
+
422
+ def fake_run(*args, **kwargs):
423
+ captured_env.update(kwargs["env"])
424
+ return subprocess.CompletedProcess(
425
+ args=args[0], returncode=0, stdout="", stderr=""
426
+ )
427
+
428
+ monkeypatch.setattr(
429
+ runner,
430
+ "build_command",
431
+ lambda command: SandboxCommand(command, seccomp_available=False),
432
+ )
433
+ monkeypatch.setattr("subprocess.run", fake_run)
434
+
435
+ result = runner.run(["/bin/sh", "-c", "true"])
436
+
437
+ assert result.exit_code == 0
438
+ assert (
439
+ captured_env["PATH"]
440
+ == "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
441
+ )
442
+
443
+
444
+ def test_shell_command_environment_accepts_explicit_overrides(
445
+ tmp_path, monkeypatch
446
+ ) -> None:
447
+ monkeypatch.delenv("FLOWENT_TOOL_VAR", raising=False)
448
+ runner = SandboxRunner(cwd=tmp_path)
449
+ monkeypatch.setattr(
450
+ runner,
451
+ "build_command",
452
+ lambda command: SandboxCommand(command, seccomp_available=False),
453
+ )
454
+
455
+ result = runner.run(
456
+ ["/bin/sh", "-c", "printf '%s' \"$FLOWENT_TOOL_VAR\""],
457
+ env={"FLOWENT_TOOL_VAR": "explicit"},
458
+ )
459
+
460
+ assert result.exit_code == 0
461
+ assert result.stdout == "explicit"
462
+
463
+
229
464
  @pytest.mark.anyio
230
465
  async def test_async_shell_command_does_not_block_other_tasks(
231
466
  tmp_path, monkeypatch
@@ -411,7 +411,10 @@ def test_disabled_mcp_server_does_not_connect_or_expose_tools(
411
411
  assert response.json()["status"] == "disabled"
412
412
 
413
413
 
414
- def test_enabled_mcp_server_connects_and_lists_tools(tmp_path, monkeypatch) -> None:
414
+ @pytest.mark.anyio
415
+ async def test_enabled_mcp_server_save_returns_starting_and_connects_in_background(
416
+ tmp_path, monkeypatch
417
+ ) -> None:
415
418
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
416
419
  transport = FakeMcpTransport()
417
420
  transport.tools_by_server["mcp-files"] = [
@@ -426,11 +429,20 @@ def test_enabled_mcp_server_connects_and_lists_tools(tmp_path, monkeypatch) -> N
426
429
  response = client.put("/api/mcp/servers", json=command_server_payload())
427
430
 
428
431
  assert response.status_code == 200
429
- assert response.json()["status"] == "ready"
430
- assert response.json()["tools"][0]["name"] == "read_file"
432
+ assert response.json()["status"] == "starting"
433
+ assert response.json()["tools"] == []
434
+ manager = client.app.state.mcp_manager
435
+ connected = await wait_for_status(
436
+ manager,
437
+ StoredMcpServer.model_validate(response.json()),
438
+ "ready",
439
+ )
440
+ assert connected.status == "ready"
441
+ assert connected.tools[0].name == "read_file"
431
442
 
432
443
 
433
- def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> None:
444
+ @pytest.mark.anyio
445
+ async def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> None:
434
446
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
435
447
  transport = FakeMcpTransport()
436
448
  transport.errors["mcp-files"] = "Command failed"
@@ -439,11 +451,19 @@ def test_mcp_connection_error_is_reported_in_state(tmp_path, monkeypatch) -> Non
439
451
  response = client.put("/api/mcp/servers", json=command_server_payload())
440
452
 
441
453
  assert response.status_code == 200
442
- assert response.json()["status"] == "error"
443
- assert response.json()["error"] == "Command failed"
454
+ assert response.json()["status"] == "starting"
455
+ manager = client.app.state.mcp_manager
456
+ errored = await wait_for_status(
457
+ manager,
458
+ StoredMcpServer.model_validate(response.json()),
459
+ "error",
460
+ )
461
+ assert errored.status == "error"
462
+ assert errored.error == "Command failed"
444
463
 
445
464
 
446
- def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
465
+ @pytest.mark.anyio
466
+ async def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
447
467
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
448
468
  transport = FakeMcpTransport()
449
469
  transport.tools_by_server["mcp-files"] = [
@@ -451,6 +471,13 @@ def test_mcp_server_can_be_reconnected(tmp_path, monkeypatch) -> None:
451
471
  ]
452
472
  client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
453
473
  client.put("/api/mcp/servers", json=command_server_payload())
474
+
475
+ connected = await wait_for_status(
476
+ client.app.state.mcp_manager,
477
+ StoredMcpServer.model_validate(command_server_payload()),
478
+ "ready",
479
+ )
480
+ assert connected.status == "ready"
454
481
  transport.tools_by_server["mcp-files"] = [
455
482
  {"inputSchema": {"type": "object"}, "name": "read_file"},
456
483
  {"inputSchema": {"type": "object"}, "name": "write_file"},
@@ -552,7 +579,8 @@ def test_mcp_server_delete_removes_saved_server_when_disconnect_fails(
552
579
  assert transport.disconnect_calls == ["mcp-files"]
553
580
 
554
581
 
555
- def test_ready_mcp_tools_are_included_in_workspace_request(
582
+ @pytest.mark.anyio
583
+ async def test_ready_mcp_tools_are_included_in_workspace_request(
556
584
  tmp_path, monkeypatch
557
585
  ) -> None:
558
586
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
@@ -583,6 +611,12 @@ def test_ready_mcp_tools_are_included_in_workspace_request(
583
611
  )
584
612
  configure_provider(client)
585
613
  client.put("/api/mcp/servers", json=command_server_payload())
614
+ connected = await wait_for_status(
615
+ client.app.state.mcp_manager,
616
+ StoredMcpServer.model_validate(command_server_payload()),
617
+ "ready",
618
+ )
619
+ assert connected.status == "ready"
586
620
 
587
621
  response = client.post("/api/workspace/respond", json={"content": "Read file"})
588
622
 
@@ -595,7 +629,8 @@ def test_ready_mcp_tools_are_included_in_workspace_request(
595
629
  assert mcp_tool_name("mcp-files", "read_file") in tool_names
596
630
 
597
631
 
598
- def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
632
+ @pytest.mark.anyio
633
+ async def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
599
634
  tmp_path, monkeypatch
600
635
  ) -> None:
601
636
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
@@ -632,6 +667,12 @@ def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
632
667
  )
633
668
  configure_provider(client)
634
669
  client.put("/api/mcp/servers", json=command_server_payload())
670
+ connected = await wait_for_status(
671
+ client.app.state.mcp_manager,
672
+ StoredMcpServer.model_validate(command_server_payload()),
673
+ "ready",
674
+ )
675
+ assert connected.status == "ready"
635
676
 
636
677
  response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
637
678
 
@@ -651,7 +692,10 @@ def test_mcp_tool_call_is_forwarded_and_result_returns_to_agent(
651
692
  assert events[3]["data"]["data"]["tool"] == "read_file"
652
693
 
653
694
 
654
- def test_mcp_tool_call_failure_is_reported_in_workspace(tmp_path, monkeypatch) -> None:
695
+ @pytest.mark.anyio
696
+ async def test_mcp_tool_call_failure_is_reported_in_workspace(
697
+ tmp_path, monkeypatch
698
+ ) -> None:
655
699
  monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
656
700
  captured_requests: list[dict[str, object]] = []
657
701
  transport = FakeMcpTransport()
@@ -686,6 +730,12 @@ def test_mcp_tool_call_failure_is_reported_in_workspace(tmp_path, monkeypatch) -
686
730
  )
687
731
  configure_provider(client)
688
732
  client.put("/api/mcp/servers", json=command_server_payload())
733
+ connected = await wait_for_status(
734
+ client.app.state.mcp_manager,
735
+ StoredMcpServer.model_validate(command_server_payload()),
736
+ "ready",
737
+ )
738
+ assert connected.status == "ready"
689
739
 
690
740
  response = client.post("/api/workspace/respond", json={"content": "Use MCP"})
691
741
 
@@ -720,3 +770,19 @@ async def test_mcp_server_reload_reconnects_saved_enabled_servers(
720
770
  assert connected.status == "ready"
721
771
  assert connected.tools[0].name == "read_file"
722
772
  assert transport.connect_calls[0].id == "mcp-files"
773
+
774
+
775
+ @pytest.mark.anyio
776
+ async def test_enabled_mcp_server_save_does_not_block_response(
777
+ tmp_path, monkeypatch
778
+ ) -> None:
779
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
780
+ transport = FakeMcpTransport()
781
+ transport.sleep_on_connect.add("mcp-files")
782
+ client = TestClient(create_app(serve_frontend=False, mcp_transport=transport))
783
+
784
+ response = client.put("/api/mcp/servers", json=command_server_payload())
785
+
786
+ assert response.status_code == 200
787
+ assert response.json()["status"] == "starting"
788
+ assert response.json()["tools"] == []