flowent 0.1.3 → 0.1.5
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 +1 -1
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/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/agent.py +23 -1
- package/backend/src/flowent/approval.py +148 -0
- package/backend/src/flowent/cli.py +16 -2
- package/backend/src/flowent/compact.py +183 -0
- package/backend/src/flowent/context.py +19 -1
- package/backend/src/flowent/llm.py +51 -11
- package/backend/src/flowent/logging.py +60 -0
- package/backend/src/flowent/main.py +696 -192
- package/backend/src/flowent/mcp.py +3 -1
- package/backend/src/flowent/patch.py +55 -31
- package/backend/src/flowent/paths.py +12 -0
- package/backend/src/flowent/permissions.py +185 -42
- package/backend/src/flowent/sandbox.py +146 -13
- package/backend/src/flowent/static/assets/index-Cl20cARb.css +2 -0
- package/backend/src/flowent/static/assets/index-dsDDsEym.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +257 -9
- 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/test_agent_tools.py +312 -1
- package/backend/tests/test_approval.py +283 -0
- package/backend/tests/test_llm_providers.py +216 -0
- package/backend/tests/test_logging.py +30 -0
- package/backend/tests/test_mcp.py +76 -10
- package/backend/tests/test_patch.py +112 -0
- package/backend/tests/test_permissions.py +198 -53
- package/backend/tests/test_persistence.py +78 -0
- package/backend/tests/test_startup_requirements.py +96 -0
- package/backend/tests/test_workspace_chat.py +1265 -144
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-Cl20cARb.css +2 -0
- package/dist/frontend/assets/index-dsDDsEym.js +81 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +2 -2
- package/backend/src/flowent/static/assets/index-DjF2KBwE.js +0 -81
- package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
- package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
- package/dist/frontend/assets/index-P-bBpJG8.css +0 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
1
3
|
import pytest
|
|
2
4
|
|
|
3
5
|
from flowent.llm import (
|
|
@@ -8,10 +10,17 @@ from flowent.llm import (
|
|
|
8
10
|
build_litellm_request,
|
|
9
11
|
chunk_delta_reasoning,
|
|
10
12
|
complete_chat,
|
|
13
|
+
normalize_system_messages,
|
|
11
14
|
stream_chat,
|
|
12
15
|
)
|
|
13
16
|
|
|
14
17
|
|
|
18
|
+
def read_single_llm_request_diagnostic(tmp_path):
|
|
19
|
+
files = sorted((tmp_path / "logs" / "llm-requests").glob("llm-request-*.json"))
|
|
20
|
+
assert len(files) == 1
|
|
21
|
+
return json.loads(files[0].read_text())
|
|
22
|
+
|
|
23
|
+
|
|
15
24
|
def test_supported_provider_formats_match_product_choices() -> None:
|
|
16
25
|
assert [provider.value for provider in ProviderFormat] == [
|
|
17
26
|
"openai",
|
|
@@ -137,6 +146,63 @@ async def test_complete_chat_uses_injected_litellm_completion() -> None:
|
|
|
137
146
|
assert answer == ChatMessage(role="assistant", content="Here is the checklist.")
|
|
138
147
|
|
|
139
148
|
|
|
149
|
+
@pytest.mark.anyio
|
|
150
|
+
async def test_development_mode_writes_completion_request_diagnostic_file(
|
|
151
|
+
tmp_path, monkeypatch
|
|
152
|
+
) -> None:
|
|
153
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
154
|
+
monkeypatch.setenv("DEBUG", "true")
|
|
155
|
+
|
|
156
|
+
async def fake_completion(**request: object) -> dict[str, object]:
|
|
157
|
+
return {
|
|
158
|
+
"choices": [
|
|
159
|
+
{
|
|
160
|
+
"message": {
|
|
161
|
+
"content": "Here is the checklist.",
|
|
162
|
+
"role": "assistant",
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
connection = ProviderConnection(
|
|
169
|
+
name="Responses",
|
|
170
|
+
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
171
|
+
model="gpt-5.1",
|
|
172
|
+
secret_reference="sk-request-secret",
|
|
173
|
+
)
|
|
174
|
+
messages = [ChatMessage(role="user", content="Create a checklist.")]
|
|
175
|
+
tools = [
|
|
176
|
+
{
|
|
177
|
+
"type": "function",
|
|
178
|
+
"function": {
|
|
179
|
+
"name": "create_checklist",
|
|
180
|
+
"description": "Create a checklist.",
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
await complete_chat(
|
|
186
|
+
connection,
|
|
187
|
+
messages,
|
|
188
|
+
completion=fake_completion,
|
|
189
|
+
tools=tools,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
diagnostic = read_single_llm_request_diagnostic(tmp_path)
|
|
193
|
+
|
|
194
|
+
assert diagnostic == {
|
|
195
|
+
"base_url": None,
|
|
196
|
+
"litellm_model": "openai/gpt-5.1",
|
|
197
|
+
"messages": [{"content": "Create a checklist.", "role": "user"}],
|
|
198
|
+
"model": "gpt-5.1",
|
|
199
|
+
"provider": "openai_responses",
|
|
200
|
+
"reasoning_effort": "default",
|
|
201
|
+
"stream": False,
|
|
202
|
+
"tools": tools,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
140
206
|
@pytest.mark.anyio
|
|
141
207
|
async def test_stream_chat_uses_litellm_streaming() -> None:
|
|
142
208
|
captured_request: dict[str, object] = {}
|
|
@@ -169,3 +235,153 @@ async def test_stream_chat_uses_litellm_streaming() -> None:
|
|
|
169
235
|
assert captured_request["stream"] is True
|
|
170
236
|
assert captured_request["model"] == "openai/gpt-5.1"
|
|
171
237
|
assert chunks == ["Here is ", "the checklist."]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@pytest.mark.anyio
|
|
241
|
+
async def test_development_mode_writes_one_streaming_request_diagnostic_file(
|
|
242
|
+
tmp_path, monkeypatch
|
|
243
|
+
) -> None:
|
|
244
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
245
|
+
monkeypatch.setenv("DEBUG", "true")
|
|
246
|
+
|
|
247
|
+
async def fake_completion(**request: object) -> object:
|
|
248
|
+
async def chunks() -> object:
|
|
249
|
+
yield {"choices": [{"delta": {"content": "Here is "}}]}
|
|
250
|
+
yield {"choices": [{"delta": {"content": "the checklist."}}]}
|
|
251
|
+
|
|
252
|
+
return chunks()
|
|
253
|
+
|
|
254
|
+
connection = ProviderConnection(
|
|
255
|
+
name="Responses",
|
|
256
|
+
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
257
|
+
model="gpt-5.1",
|
|
258
|
+
secret_reference="sk-request-secret",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
chunks = [
|
|
262
|
+
chunk
|
|
263
|
+
async for chunk in stream_chat(
|
|
264
|
+
connection,
|
|
265
|
+
[ChatMessage(role="user", content="Create a checklist.")],
|
|
266
|
+
completion=fake_completion,
|
|
267
|
+
)
|
|
268
|
+
]
|
|
269
|
+
diagnostic = read_single_llm_request_diagnostic(tmp_path)
|
|
270
|
+
|
|
271
|
+
assert chunks == ["Here is ", "the checklist."]
|
|
272
|
+
assert diagnostic["stream"] is True
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@pytest.mark.anyio
|
|
276
|
+
async def test_development_request_diagnostic_omits_api_key_and_secret_values(
|
|
277
|
+
tmp_path, monkeypatch
|
|
278
|
+
) -> None:
|
|
279
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
280
|
+
monkeypatch.setenv("DEBUG", "true")
|
|
281
|
+
|
|
282
|
+
async def fake_completion(**request: object) -> dict[str, object]:
|
|
283
|
+
return {
|
|
284
|
+
"choices": [
|
|
285
|
+
{
|
|
286
|
+
"message": {
|
|
287
|
+
"content": "Here is the checklist.",
|
|
288
|
+
"role": "assistant",
|
|
289
|
+
},
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
connection = ProviderConnection(
|
|
295
|
+
name="Responses",
|
|
296
|
+
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
297
|
+
model="gpt-5.1",
|
|
298
|
+
secret_reference="sk-provider-secret",
|
|
299
|
+
)
|
|
300
|
+
tools = [
|
|
301
|
+
{
|
|
302
|
+
"type": "function",
|
|
303
|
+
"function": {
|
|
304
|
+
"name": "create_checklist",
|
|
305
|
+
"description": "Uses api_key=sk-tool-secret when configured.",
|
|
306
|
+
},
|
|
307
|
+
}
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
await complete_chat(
|
|
311
|
+
connection,
|
|
312
|
+
[ChatMessage(role="user", content="authorization=Bearer sk-message-secret")],
|
|
313
|
+
completion=fake_completion,
|
|
314
|
+
tools=tools,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
rendered = next(
|
|
318
|
+
(tmp_path / "logs" / "llm-requests").glob("llm-request-*.json")
|
|
319
|
+
).read_text()
|
|
320
|
+
|
|
321
|
+
assert "api_key" not in rendered
|
|
322
|
+
assert "sk-provider-secret" not in rendered
|
|
323
|
+
assert "sk-tool-secret" not in rendered
|
|
324
|
+
assert "sk-message-secret" not in rendered
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@pytest.mark.anyio
|
|
328
|
+
async def test_non_development_mode_skips_request_diagnostic_file(
|
|
329
|
+
tmp_path, monkeypatch
|
|
330
|
+
) -> None:
|
|
331
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
332
|
+
monkeypatch.delenv("DEBUG", raising=False)
|
|
333
|
+
|
|
334
|
+
async def fake_completion(**request: object) -> dict[str, object]:
|
|
335
|
+
return {
|
|
336
|
+
"choices": [
|
|
337
|
+
{
|
|
338
|
+
"message": {
|
|
339
|
+
"content": "Here is the checklist.",
|
|
340
|
+
"role": "assistant",
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
]
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
connection = ProviderConnection(
|
|
347
|
+
name="Responses",
|
|
348
|
+
provider=ProviderFormat.OPENAI_RESPONSES,
|
|
349
|
+
model="gpt-5.1",
|
|
350
|
+
secret_reference="sk-request-secret",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
await complete_chat(
|
|
354
|
+
connection,
|
|
355
|
+
[ChatMessage(role="user", content="Create a checklist.")],
|
|
356
|
+
completion=fake_completion,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
assert not (tmp_path / "logs" / "llm-requests").exists()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_normalize_system_messages_keeps_multiple_system_messages_for_openai() -> None:
|
|
363
|
+
messages = [
|
|
364
|
+
{"role": "system", "content": "Base prompt."},
|
|
365
|
+
{"role": "system", "content": "Configured prompt."},
|
|
366
|
+
{"role": "user", "content": "Hello."},
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
assert normalize_system_messages(messages, ProviderFormat.OPENAI) == messages
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_normalize_system_messages_converts_additional_system_messages_for_anthropic() -> (
|
|
373
|
+
None
|
|
374
|
+
):
|
|
375
|
+
messages = [
|
|
376
|
+
{"role": "system", "content": "Base prompt."},
|
|
377
|
+
{"role": "system", "content": "Configured prompt."},
|
|
378
|
+
{"role": "system", "content": "Project prompt."},
|
|
379
|
+
{"role": "user", "content": "Hello."},
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
assert normalize_system_messages(messages, ProviderFormat.ANTHROPIC) == [
|
|
383
|
+
{"role": "system", "content": "Base prompt."},
|
|
384
|
+
{"role": "user", "content": "Configured prompt."},
|
|
385
|
+
{"role": "user", "content": "Project prompt."},
|
|
386
|
+
{"role": "user", "content": "Hello."},
|
|
387
|
+
]
|
|
@@ -8,6 +8,7 @@ from flowent.logging import (
|
|
|
8
8
|
configure_logging,
|
|
9
9
|
ensure_logging_configured,
|
|
10
10
|
redact_log_value,
|
|
11
|
+
sanitize_diagnostic_value,
|
|
11
12
|
)
|
|
12
13
|
|
|
13
14
|
|
|
@@ -103,6 +104,35 @@ def test_logging_redacts_full_api_key_but_keeps_context(tmp_path, monkeypatch) -
|
|
|
103
104
|
)
|
|
104
105
|
|
|
105
106
|
|
|
107
|
+
def test_diagnostic_sanitizer_removes_secret_fields_and_values() -> None:
|
|
108
|
+
sanitized = sanitize_diagnostic_value(
|
|
109
|
+
{
|
|
110
|
+
"api_key": "sk-root-secret",
|
|
111
|
+
"messages": [
|
|
112
|
+
{
|
|
113
|
+
"role": "user",
|
|
114
|
+
"content": "authorization=Bearer sk-message-secret",
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
"tools": [
|
|
118
|
+
{
|
|
119
|
+
"function": {
|
|
120
|
+
"name": "send_message",
|
|
121
|
+
"description": "Needs api_key=sk-tool-secret.",
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
],
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
rendered = str(sanitized)
|
|
129
|
+
|
|
130
|
+
assert "api_key" not in rendered
|
|
131
|
+
assert "sk-root-secret" not in rendered
|
|
132
|
+
assert "sk-message-secret" not in rendered
|
|
133
|
+
assert "sk-tool-secret" not in rendered
|
|
134
|
+
|
|
135
|
+
|
|
106
136
|
def test_direct_main_app_import_creates_data_log_file(tmp_path, monkeypatch) -> None:
|
|
107
137
|
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path))
|
|
108
138
|
sys.modules.pop("flowent.main", None)
|
|
@@ -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
|
-
|
|
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"] == "
|
|
430
|
-
assert response.json()["tools"]
|
|
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
|
-
|
|
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"] == "
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"] == []
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from flowent.patch import PatchError, affected_paths, apply_patch
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_apply_patch_applies_context_hunk_with_interleaved_changes(tmp_path) -> None:
|
|
7
|
+
target = tmp_path / "notes.txt"
|
|
8
|
+
target.write_text("start\nalpha\nmiddle\nbeta\nend\n")
|
|
9
|
+
patch = """*** Begin Patch
|
|
10
|
+
*** Update File: notes.txt
|
|
11
|
+
@@
|
|
12
|
+
start
|
|
13
|
+
-alpha
|
|
14
|
+
+one
|
|
15
|
+
middle
|
|
16
|
+
-beta
|
|
17
|
+
+two
|
|
18
|
+
end
|
|
19
|
+
*** End Patch
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
result = apply_patch(patch, tmp_path)
|
|
23
|
+
|
|
24
|
+
assert result == {"files": [{"path": str(target), "status": "modified"}]}
|
|
25
|
+
assert target.read_text() == "start\none\nmiddle\ntwo\nend\n"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_apply_patch_reports_context_mismatch(tmp_path) -> None:
|
|
29
|
+
target = tmp_path / "notes.txt"
|
|
30
|
+
target.write_text("start\nalpha\nend\n")
|
|
31
|
+
patch = """*** Begin Patch
|
|
32
|
+
*** Update File: notes.txt
|
|
33
|
+
@@
|
|
34
|
+
missing
|
|
35
|
+
-alpha
|
|
36
|
+
+beta
|
|
37
|
+
end
|
|
38
|
+
*** End Patch
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
with pytest.raises(PatchError, match=r"Patch context was not found\."):
|
|
42
|
+
apply_patch(patch, tmp_path)
|
|
43
|
+
|
|
44
|
+
assert target.read_text() == "start\nalpha\nend\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_apply_patch_applies_multiple_hunks_in_order(tmp_path) -> None:
|
|
48
|
+
target = tmp_path / "notes.txt"
|
|
49
|
+
target.write_text("first\nsame\nend first\nsecond\nsame\nend second\n")
|
|
50
|
+
patch = """*** Begin Patch
|
|
51
|
+
*** Update File: notes.txt
|
|
52
|
+
@@
|
|
53
|
+
first
|
|
54
|
+
-same
|
|
55
|
+
+one
|
|
56
|
+
end first
|
|
57
|
+
@@
|
|
58
|
+
second
|
|
59
|
+
-same
|
|
60
|
+
+two
|
|
61
|
+
end second
|
|
62
|
+
*** End Patch
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
apply_patch(patch, tmp_path)
|
|
66
|
+
|
|
67
|
+
assert target.read_text() == "first\none\nend first\nsecond\ntwo\nend second\n"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_apply_patch_keeps_simple_contiguous_replacement(tmp_path) -> None:
|
|
71
|
+
target = tmp_path / "notes.txt"
|
|
72
|
+
target.write_text("alpha\nbeta\n")
|
|
73
|
+
patch = """*** Begin Patch
|
|
74
|
+
*** Update File: notes.txt
|
|
75
|
+
@@
|
|
76
|
+
-alpha
|
|
77
|
+
-beta
|
|
78
|
+
+ready
|
|
79
|
+
*** End Patch
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
apply_patch(patch, tmp_path)
|
|
83
|
+
|
|
84
|
+
assert target.read_text() == "ready\n"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_affected_paths_reads_structured_patch_write_targets(tmp_path) -> None:
|
|
88
|
+
patch = """*** Begin Patch
|
|
89
|
+
*** Update File: notes.txt
|
|
90
|
+
@@
|
|
91
|
+
-alpha
|
|
92
|
+
+beta
|
|
93
|
+
*** Add File: created.txt
|
|
94
|
+
+hello
|
|
95
|
+
*** Delete File: old.txt
|
|
96
|
+
*** Update File: before.txt
|
|
97
|
+
*** Move to: after.txt
|
|
98
|
+
@@
|
|
99
|
+
-before
|
|
100
|
+
+after
|
|
101
|
+
*** End Patch
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
paths = affected_paths(patch, tmp_path)
|
|
105
|
+
|
|
106
|
+
assert paths == [
|
|
107
|
+
(tmp_path / "notes.txt").resolve(strict=False),
|
|
108
|
+
(tmp_path / "created.txt").resolve(strict=False),
|
|
109
|
+
(tmp_path / "old.txt").resolve(strict=False),
|
|
110
|
+
(tmp_path / "before.txt").resolve(strict=False),
|
|
111
|
+
(tmp_path / "after.txt").resolve(strict=False),
|
|
112
|
+
]
|