flowent 0.1.1 → 0.1.2
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__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.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__/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 +18 -9
- package/backend/src/flowent/main.py +61 -9
- package/backend/src/flowent/sandbox.py +69 -2
- package/backend/src/flowent/storage.py +51 -3
- package/backend/src/flowent/tools.py +72 -1
- 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_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_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 +103 -2
- package/backend/tests/test_workspace_chat.py +269 -1
- package/backend/uv.lock +1 -1
- package/bin/flowent.mjs +1 -1
- package/package.json +1 -1
package/backend/pyproject.toml
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -23,7 +23,7 @@ from flowent.tools import (
|
|
|
23
23
|
ToolResult,
|
|
24
24
|
new_tool_item,
|
|
25
25
|
parse_tool_arguments,
|
|
26
|
-
|
|
26
|
+
run_tool_async,
|
|
27
27
|
tool_specs,
|
|
28
28
|
)
|
|
29
29
|
|
|
@@ -109,6 +109,8 @@ async def run_agent_stream(
|
|
|
109
109
|
| None = None,
|
|
110
110
|
extra_tool_specs: Sequence[Mapping[str, object]] | None = None,
|
|
111
111
|
extra_tool_title: Callable[[str], str | None] | None = None,
|
|
112
|
+
tool_runner: Callable[[str, dict[str, object], ToolContext], Awaitable[ToolResult]]
|
|
113
|
+
| None = None,
|
|
112
114
|
web_searcher: Callable[[str], Sequence[dict[str, str]]] | None = None,
|
|
113
115
|
) -> AsyncIterator[AgentStreamEvent]:
|
|
114
116
|
conversation: list[Mapping[str, object]] = [
|
|
@@ -244,15 +246,22 @@ async def run_agent_stream(
|
|
|
244
246
|
if extra_tool_runner is not None
|
|
245
247
|
else None
|
|
246
248
|
)
|
|
247
|
-
result = (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
249
|
+
result = extra_result if isinstance(extra_result, ToolResult) else None
|
|
250
|
+
if result is None:
|
|
251
|
+
context = ToolContext(cwd=cwd, web_searcher=web_searcher)
|
|
252
|
+
result = await (
|
|
253
|
+
tool_runner(
|
|
254
|
+
tool_call.name,
|
|
255
|
+
arguments,
|
|
256
|
+
context,
|
|
257
|
+
)
|
|
258
|
+
if tool_runner is not None
|
|
259
|
+
else run_tool_async(
|
|
260
|
+
tool_call.name,
|
|
261
|
+
arguments,
|
|
262
|
+
context,
|
|
263
|
+
)
|
|
254
264
|
)
|
|
255
|
-
)
|
|
256
265
|
result_content = result.content
|
|
257
266
|
logger.debug(
|
|
258
267
|
"Tool call finished name=%s id=%s ok=%s",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import json
|
|
2
3
|
import logging
|
|
3
4
|
import os
|
|
@@ -119,6 +120,15 @@ def stream_event(event: str, data: dict[str, object]) -> str:
|
|
|
119
120
|
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
120
121
|
|
|
121
122
|
|
|
123
|
+
def append_or_replace_message(
|
|
124
|
+
messages: list[StoredMessage], message: StoredMessage
|
|
125
|
+
) -> list[StoredMessage]:
|
|
126
|
+
return [
|
|
127
|
+
*(current for current in messages if current.id != message.id),
|
|
128
|
+
message,
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
|
|
122
132
|
def frontend_static_directory() -> Path:
|
|
123
133
|
configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
|
|
124
134
|
if configured_directory:
|
|
@@ -312,6 +322,7 @@ def create_app(
|
|
|
312
322
|
author="assistant",
|
|
313
323
|
content=assistant_content,
|
|
314
324
|
id=assistant_id,
|
|
325
|
+
status="completed",
|
|
315
326
|
thinking=assistant_thinking,
|
|
316
327
|
tools=list(assistant_tools.values()),
|
|
317
328
|
)
|
|
@@ -541,6 +552,30 @@ def create_app(
|
|
|
541
552
|
|
|
542
553
|
async def response_stream() -> AsyncIterator[str]:
|
|
543
554
|
assistant_tools: dict[str, StoredToolItem] = {}
|
|
555
|
+
assistant_message = StoredMessage(
|
|
556
|
+
author="assistant",
|
|
557
|
+
content="",
|
|
558
|
+
id=str(uuid4()),
|
|
559
|
+
status="running",
|
|
560
|
+
)
|
|
561
|
+
assistant_content = ""
|
|
562
|
+
assistant_thinking = ""
|
|
563
|
+
|
|
564
|
+
def persist_assistant(status: str = "running") -> None:
|
|
565
|
+
nonlocal next_messages, assistant_message
|
|
566
|
+
assistant_message = StoredMessage(
|
|
567
|
+
author="assistant",
|
|
568
|
+
content=assistant_content,
|
|
569
|
+
id=assistant_message.id,
|
|
570
|
+
status=status,
|
|
571
|
+
thinking=assistant_thinking,
|
|
572
|
+
tools=list(assistant_tools.values()),
|
|
573
|
+
)
|
|
574
|
+
next_messages = append_or_replace_message(
|
|
575
|
+
next_messages, assistant_message
|
|
576
|
+
)
|
|
577
|
+
store.upsert_message(assistant_message)
|
|
578
|
+
|
|
544
579
|
try:
|
|
545
580
|
async for event in run_agent_stream(
|
|
546
581
|
completion=chat_completion,
|
|
@@ -551,12 +586,20 @@ def create_app(
|
|
|
551
586
|
extra_tool_title=mcp_manager.tool_title,
|
|
552
587
|
messages=request_messages,
|
|
553
588
|
):
|
|
589
|
+
if event.event == "start":
|
|
590
|
+
event_id = event.data.get("id")
|
|
591
|
+
if isinstance(event_id, str):
|
|
592
|
+
assistant_message = assistant_message.model_copy(
|
|
593
|
+
update={"id": event_id}
|
|
594
|
+
)
|
|
595
|
+
persist_assistant()
|
|
554
596
|
if event.event == "tool_start":
|
|
555
597
|
tool = event.data.get("tool")
|
|
556
598
|
if isinstance(tool, dict) and isinstance(tool.get("id"), str):
|
|
557
599
|
assistant_tools[tool["id"]] = StoredToolItem.model_validate(
|
|
558
600
|
tool
|
|
559
601
|
)
|
|
602
|
+
persist_assistant()
|
|
560
603
|
if event.event in {"tool_done", "tool_error"}:
|
|
561
604
|
tool_id = event.data.get("id")
|
|
562
605
|
if isinstance(tool_id, str) and tool_id in assistant_tools:
|
|
@@ -568,6 +611,13 @@ def create_app(
|
|
|
568
611
|
**event.data,
|
|
569
612
|
}
|
|
570
613
|
)
|
|
614
|
+
persist_assistant()
|
|
615
|
+
if event.event == "delta":
|
|
616
|
+
assistant_content += str(event.data.get("content") or "")
|
|
617
|
+
persist_assistant()
|
|
618
|
+
if event.event == "thinking_delta":
|
|
619
|
+
assistant_thinking += str(event.data.get("content") or "")
|
|
620
|
+
persist_assistant()
|
|
571
621
|
logger.log(
|
|
572
622
|
TRACE_LEVEL,
|
|
573
623
|
"Workspace stream event=%s data=%r",
|
|
@@ -577,19 +627,21 @@ def create_app(
|
|
|
577
627
|
if event.event == "done":
|
|
578
628
|
message = event.data.get("message")
|
|
579
629
|
if isinstance(message, dict):
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
thinking=str(message.get("thinking") or ""),
|
|
586
|
-
tools=list(assistant_tools.values()),
|
|
587
|
-
)
|
|
630
|
+
assistant_content = str(
|
|
631
|
+
message.get("content") or assistant_content
|
|
632
|
+
)
|
|
633
|
+
assistant_thinking = str(
|
|
634
|
+
message.get("thinking") or assistant_thinking
|
|
588
635
|
)
|
|
589
|
-
|
|
636
|
+
persist_assistant("completed")
|
|
590
637
|
yield stream_event(event.event, event.data)
|
|
638
|
+
except asyncio.CancelledError:
|
|
639
|
+
logger.info("Workspace response interrupted")
|
|
640
|
+
persist_assistant("interrupted")
|
|
641
|
+
raise
|
|
591
642
|
except Exception as error:
|
|
592
643
|
logger.exception("Workspace response failed")
|
|
644
|
+
persist_assistant("failed")
|
|
593
645
|
yield stream_event(
|
|
594
646
|
"error",
|
|
595
647
|
{"message": str(error) or "Message could not be sent."},
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import ctypes
|
|
4
5
|
import errno
|
|
5
6
|
import os
|
|
6
7
|
import shutil
|
|
8
|
+
import signal
|
|
7
9
|
import subprocess
|
|
8
10
|
import tempfile
|
|
11
|
+
from contextlib import suppress
|
|
9
12
|
from dataclasses import dataclass
|
|
10
13
|
from pathlib import Path
|
|
11
14
|
from typing import BinaryIO
|
|
@@ -127,7 +130,7 @@ class SandboxRunner:
|
|
|
127
130
|
self,
|
|
128
131
|
*,
|
|
129
132
|
cwd: Path | None = None,
|
|
130
|
-
timeout_seconds:
|
|
133
|
+
timeout_seconds: float = 30,
|
|
131
134
|
output_limit: int = 20000,
|
|
132
135
|
) -> None:
|
|
133
136
|
self.cwd = (cwd or Path.cwd()).resolve(strict=False)
|
|
@@ -193,7 +196,7 @@ class SandboxRunner:
|
|
|
193
196
|
*,
|
|
194
197
|
env: dict[str, str] | None = None,
|
|
195
198
|
input_text: str | None = None,
|
|
196
|
-
timeout_seconds:
|
|
199
|
+
timeout_seconds: float | None = None,
|
|
197
200
|
) -> CommandResult:
|
|
198
201
|
sandbox_command = self.build_command(command)
|
|
199
202
|
pass_fds: tuple[int, ...] = ()
|
|
@@ -232,3 +235,67 @@ class SandboxRunner:
|
|
|
232
235
|
stderr=completed.stderr[: self.output_limit],
|
|
233
236
|
stdout=completed.stdout[: self.output_limit],
|
|
234
237
|
)
|
|
238
|
+
|
|
239
|
+
async def run_async(
|
|
240
|
+
self,
|
|
241
|
+
command: list[str],
|
|
242
|
+
*,
|
|
243
|
+
env: dict[str, str] | None = None,
|
|
244
|
+
input_text: str | None = None,
|
|
245
|
+
timeout_seconds: float | None = None,
|
|
246
|
+
) -> CommandResult:
|
|
247
|
+
sandbox_command = self.build_command(command)
|
|
248
|
+
pass_fds: tuple[int, ...] = ()
|
|
249
|
+
if sandbox_command.seccomp_file is not None:
|
|
250
|
+
pass_fds = (sandbox_command.seccomp_file.fileno(),)
|
|
251
|
+
|
|
252
|
+
process_env = os.environ.copy()
|
|
253
|
+
if env is not None:
|
|
254
|
+
process_env.update(env)
|
|
255
|
+
process = await asyncio.create_subprocess_exec(
|
|
256
|
+
*sandbox_command.args,
|
|
257
|
+
cwd=self.cwd,
|
|
258
|
+
env=process_env,
|
|
259
|
+
pass_fds=pass_fds,
|
|
260
|
+
start_new_session=True,
|
|
261
|
+
stdin=asyncio.subprocess.PIPE if input_text is not None else None,
|
|
262
|
+
stdout=asyncio.subprocess.PIPE,
|
|
263
|
+
stderr=asyncio.subprocess.PIPE,
|
|
264
|
+
)
|
|
265
|
+
try:
|
|
266
|
+
stdout, stderr = await asyncio.wait_for(
|
|
267
|
+
process.communicate(
|
|
268
|
+
input_text.encode() if input_text is not None else None
|
|
269
|
+
),
|
|
270
|
+
timeout=timeout_seconds or self.timeout_seconds,
|
|
271
|
+
)
|
|
272
|
+
except TimeoutError as error:
|
|
273
|
+
with suppress(ProcessLookupError):
|
|
274
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
275
|
+
stdout, stderr = await process.communicate()
|
|
276
|
+
return CommandResult(
|
|
277
|
+
command=" ".join(command),
|
|
278
|
+
exit_code=124,
|
|
279
|
+
stderr=str(error) or "Command timed out.",
|
|
280
|
+
stdout=self._text_output(stdout)[: self.output_limit],
|
|
281
|
+
)
|
|
282
|
+
except asyncio.CancelledError:
|
|
283
|
+
with suppress(ProcessLookupError):
|
|
284
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
285
|
+
try:
|
|
286
|
+
await asyncio.wait_for(process.wait(), timeout=1)
|
|
287
|
+
except TimeoutError:
|
|
288
|
+
with suppress(ProcessLookupError):
|
|
289
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
290
|
+
await process.wait()
|
|
291
|
+
raise
|
|
292
|
+
finally:
|
|
293
|
+
if sandbox_command.seccomp_file is not None:
|
|
294
|
+
sandbox_command.seccomp_file.close()
|
|
295
|
+
|
|
296
|
+
return CommandResult(
|
|
297
|
+
command=" ".join(command),
|
|
298
|
+
exit_code=process.returncode or 0,
|
|
299
|
+
stderr=self._text_output(stderr)[: self.output_limit],
|
|
300
|
+
stdout=self._text_output(stdout)[: self.output_limit],
|
|
301
|
+
)
|
|
@@ -105,6 +105,9 @@ class StoredMessage(BaseModel):
|
|
|
105
105
|
author: str
|
|
106
106
|
content: str
|
|
107
107
|
id: str
|
|
108
|
+
status: str = Field(
|
|
109
|
+
default="completed", exclude_if=lambda value: value == "completed"
|
|
110
|
+
)
|
|
108
111
|
thinking: str = Field(default="", exclude_if=lambda value: value == "")
|
|
109
112
|
tools: list[StoredToolItem] = Field(default_factory=list)
|
|
110
113
|
|
|
@@ -168,6 +171,7 @@ class StateStore:
|
|
|
168
171
|
author=row["author"],
|
|
169
172
|
content=row["content"],
|
|
170
173
|
id=row["id"],
|
|
174
|
+
status=row["status"],
|
|
171
175
|
thinking=row["thinking"],
|
|
172
176
|
tools=[
|
|
173
177
|
StoredToolItem.model_validate(tool)
|
|
@@ -176,7 +180,7 @@ class StateStore:
|
|
|
176
180
|
)
|
|
177
181
|
for row in connection.execute(
|
|
178
182
|
"""
|
|
179
|
-
SELECT id, author, content, tools, thinking
|
|
183
|
+
SELECT id, author, content, tools, thinking, status
|
|
180
184
|
FROM messages
|
|
181
185
|
ORDER BY position, id
|
|
182
186
|
"""
|
|
@@ -476,8 +480,8 @@ class StateStore:
|
|
|
476
480
|
connection.execute("DELETE FROM messages")
|
|
477
481
|
connection.executemany(
|
|
478
482
|
"""
|
|
479
|
-
INSERT INTO messages (id, author, content, tools, thinking, position)
|
|
480
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
483
|
+
INSERT INTO messages (id, author, content, tools, thinking, status, position)
|
|
484
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
481
485
|
""",
|
|
482
486
|
[
|
|
483
487
|
(
|
|
@@ -491,6 +495,7 @@ class StateStore:
|
|
|
491
495
|
]
|
|
492
496
|
),
|
|
493
497
|
message.thinking,
|
|
498
|
+
message.status,
|
|
494
499
|
position,
|
|
495
500
|
)
|
|
496
501
|
for position, message in enumerate(messages)
|
|
@@ -500,6 +505,44 @@ class StateStore:
|
|
|
500
505
|
connection.execute("DELETE FROM workspace_context WHERE id = 1")
|
|
501
506
|
return messages
|
|
502
507
|
|
|
508
|
+
def upsert_message(self, message: StoredMessage) -> StoredMessage:
|
|
509
|
+
with self.connect() as connection:
|
|
510
|
+
row = connection.execute(
|
|
511
|
+
"SELECT position FROM messages WHERE id = ?", (message.id,)
|
|
512
|
+
).fetchone()
|
|
513
|
+
if row:
|
|
514
|
+
position = row["position"]
|
|
515
|
+
else:
|
|
516
|
+
position_row = connection.execute(
|
|
517
|
+
"SELECT COALESCE(MAX(position) + 1, 0) AS position FROM messages"
|
|
518
|
+
).fetchone()
|
|
519
|
+
position = position_row["position"]
|
|
520
|
+
connection.execute(
|
|
521
|
+
"""
|
|
522
|
+
INSERT INTO messages (id, author, content, tools, thinking, status, position)
|
|
523
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
524
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
525
|
+
author = excluded.author,
|
|
526
|
+
content = excluded.content,
|
|
527
|
+
tools = excluded.tools,
|
|
528
|
+
thinking = excluded.thinking,
|
|
529
|
+
status = excluded.status,
|
|
530
|
+
position = excluded.position
|
|
531
|
+
""",
|
|
532
|
+
(
|
|
533
|
+
message.id,
|
|
534
|
+
message.author,
|
|
535
|
+
message.content,
|
|
536
|
+
json.dumps(
|
|
537
|
+
[tool.model_dump(exclude_none=True) for tool in message.tools]
|
|
538
|
+
),
|
|
539
|
+
message.thinking,
|
|
540
|
+
message.status,
|
|
541
|
+
position,
|
|
542
|
+
),
|
|
543
|
+
)
|
|
544
|
+
return message
|
|
545
|
+
|
|
503
546
|
def read_compacted_context(self) -> str:
|
|
504
547
|
with self.connect() as connection:
|
|
505
548
|
row = connection.execute(
|
|
@@ -698,6 +741,7 @@ class StateStore:
|
|
|
698
741
|
id TEXT PRIMARY KEY,
|
|
699
742
|
author TEXT NOT NULL,
|
|
700
743
|
content TEXT NOT NULL,
|
|
744
|
+
status TEXT NOT NULL DEFAULT 'completed',
|
|
701
745
|
position INTEGER NOT NULL
|
|
702
746
|
);
|
|
703
747
|
|
|
@@ -741,6 +785,10 @@ class StateStore:
|
|
|
741
785
|
connection.execute(
|
|
742
786
|
"ALTER TABLE messages ADD COLUMN thinking TEXT NOT NULL DEFAULT ''"
|
|
743
787
|
)
|
|
788
|
+
if "status" not in columns:
|
|
789
|
+
connection.execute(
|
|
790
|
+
"ALTER TABLE messages ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
|
|
791
|
+
)
|
|
744
792
|
settings_columns = {
|
|
745
793
|
row["name"] for row in connection.execute("PRAGMA table_info(settings)")
|
|
746
794
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import json
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
@@ -198,6 +199,22 @@ def run_tool(
|
|
|
198
199
|
return ToolResult(content=str(error), ok=False, title=title)
|
|
199
200
|
|
|
200
201
|
|
|
202
|
+
async def run_tool_async(
|
|
203
|
+
name: str, arguments: dict[str, object], context: ToolContext
|
|
204
|
+
) -> ToolResult:
|
|
205
|
+
try:
|
|
206
|
+
if name == "shell_command":
|
|
207
|
+
return await shell_command_async(arguments, context)
|
|
208
|
+
if name == "apply_patch":
|
|
209
|
+
return await apply_patch_tool_async(arguments, context)
|
|
210
|
+
return await asyncio.to_thread(run_tool, name, arguments, context)
|
|
211
|
+
except Exception as error:
|
|
212
|
+
title = (
|
|
213
|
+
"Edit failed" if name == "apply_patch" else tool_call_title(name, arguments)
|
|
214
|
+
)
|
|
215
|
+
return ToolResult(content=str(error), ok=False, title=title)
|
|
216
|
+
|
|
217
|
+
|
|
201
218
|
def integer_argument(arguments: dict[str, object], name: str, default: int) -> int:
|
|
202
219
|
value = arguments.get(name, default)
|
|
203
220
|
if isinstance(value, int):
|
|
@@ -207,6 +224,15 @@ def integer_argument(arguments: dict[str, object], name: str, default: int) -> i
|
|
|
207
224
|
return default
|
|
208
225
|
|
|
209
226
|
|
|
227
|
+
def number_argument(arguments: dict[str, object], name: str, default: float) -> float:
|
|
228
|
+
value = arguments.get(name, default)
|
|
229
|
+
if isinstance(value, int | float):
|
|
230
|
+
return float(value)
|
|
231
|
+
if isinstance(value, str):
|
|
232
|
+
return float(value)
|
|
233
|
+
return default
|
|
234
|
+
|
|
235
|
+
|
|
210
236
|
def read_file(arguments: dict[str, object], context: ToolContext) -> ToolResult:
|
|
211
237
|
path = resolve_tool_path(str(arguments["path"]), context.cwd)
|
|
212
238
|
offset = integer_argument(arguments, "offset", 0)
|
|
@@ -271,6 +297,28 @@ def apply_patch_tool(arguments: dict[str, object], context: ToolContext) -> Tool
|
|
|
271
297
|
)
|
|
272
298
|
|
|
273
299
|
|
|
300
|
+
async def apply_patch_tool_async(
|
|
301
|
+
arguments: dict[str, object], context: ToolContext
|
|
302
|
+
) -> ToolResult:
|
|
303
|
+
patch = str(arguments["patch"])
|
|
304
|
+
paths = affected_paths(patch, context.cwd)
|
|
305
|
+
runner = SandboxRunner(cwd=context.cwd)
|
|
306
|
+
for path in paths:
|
|
307
|
+
runner.ensure_writable_path(path)
|
|
308
|
+
result = await runner.run_async(
|
|
309
|
+
[sys.executable, "-m", "flowent.cli", "apply-patch", "--cwd", str(context.cwd)],
|
|
310
|
+
input_text=patch,
|
|
311
|
+
)
|
|
312
|
+
if result.exit_code != 0:
|
|
313
|
+
raise SandboxError(tool_failure_content(result))
|
|
314
|
+
data = json.loads(result.stdout or "{}")
|
|
315
|
+
return ToolResult(
|
|
316
|
+
content=result.stdout,
|
|
317
|
+
data=data if isinstance(data, dict) else {},
|
|
318
|
+
title=patch_title_from_result(data),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
274
322
|
def patch_title_from_result(data: object) -> str:
|
|
275
323
|
if not isinstance(data, dict):
|
|
276
324
|
return "Edited files"
|
|
@@ -308,7 +356,7 @@ def tool_failure_content(result: object) -> str:
|
|
|
308
356
|
|
|
309
357
|
def shell_command(arguments: dict[str, object], context: ToolContext) -> ToolResult:
|
|
310
358
|
command = str(arguments["command"])
|
|
311
|
-
timeout_seconds =
|
|
359
|
+
timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
|
|
312
360
|
result = SandboxRunner(cwd=context.cwd).run(
|
|
313
361
|
["/bin/sh", "-c", command], timeout_seconds=timeout_seconds
|
|
314
362
|
)
|
|
@@ -327,6 +375,29 @@ def shell_command(arguments: dict[str, object], context: ToolContext) -> ToolRes
|
|
|
327
375
|
)
|
|
328
376
|
|
|
329
377
|
|
|
378
|
+
async def shell_command_async(
|
|
379
|
+
arguments: dict[str, object], context: ToolContext
|
|
380
|
+
) -> ToolResult:
|
|
381
|
+
command = str(arguments["command"])
|
|
382
|
+
timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
|
|
383
|
+
result = await SandboxRunner(cwd=context.cwd).run_async(
|
|
384
|
+
["/bin/sh", "-c", command], timeout_seconds=timeout_seconds
|
|
385
|
+
)
|
|
386
|
+
ok = result.exit_code == 0
|
|
387
|
+
content = result.stdout or result.stderr
|
|
388
|
+
return ToolResult(
|
|
389
|
+
content=content,
|
|
390
|
+
data={
|
|
391
|
+
"command": command,
|
|
392
|
+
"exit_code": result.exit_code,
|
|
393
|
+
"stderr": result.stderr,
|
|
394
|
+
"stdout": result.stdout,
|
|
395
|
+
},
|
|
396
|
+
ok=ok,
|
|
397
|
+
title=f"Ran {command}",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
330
401
|
def update_plan(arguments: dict[str, object]) -> ToolResult:
|
|
331
402
|
items = arguments.get("items", [])
|
|
332
403
|
content = json.dumps(items, ensure_ascii=False)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import json
|
|
3
|
+
import time
|
|
2
4
|
from pathlib import Path
|
|
3
5
|
|
|
6
|
+
import pytest
|
|
4
7
|
from fastapi.testclient import TestClient
|
|
5
8
|
|
|
6
|
-
from flowent.agent import FLOWENT_AGENT_SYSTEM_PROMPT
|
|
9
|
+
from flowent.agent import FLOWENT_AGENT_SYSTEM_PROMPT, run_agent_stream
|
|
10
|
+
from flowent.llm import ProviderConnection, ProviderFormat
|
|
7
11
|
from flowent.main import create_app
|
|
8
|
-
from flowent.sandbox import SandboxRunner
|
|
12
|
+
from flowent.sandbox import SandboxCommand, SandboxRunner
|
|
9
13
|
from flowent.tools import ToolContext, run_tool
|
|
10
14
|
|
|
11
15
|
|
|
@@ -222,6 +226,103 @@ def test_shell_command_has_network_by_default(tmp_path) -> None:
|
|
|
222
226
|
assert "network-ready" in result.content
|
|
223
227
|
|
|
224
228
|
|
|
229
|
+
@pytest.mark.anyio
|
|
230
|
+
async def test_async_shell_command_does_not_block_other_tasks(
|
|
231
|
+
tmp_path, monkeypatch
|
|
232
|
+
) -> None:
|
|
233
|
+
runner = SandboxRunner(cwd=tmp_path)
|
|
234
|
+
command = [
|
|
235
|
+
"/bin/sh",
|
|
236
|
+
"-c",
|
|
237
|
+
"python - <<'PY'\nimport time\ntime.sleep(0.2)\nprint('done')\nPY",
|
|
238
|
+
]
|
|
239
|
+
monkeypatch.setattr(
|
|
240
|
+
runner,
|
|
241
|
+
"build_command",
|
|
242
|
+
lambda command: SandboxCommand(command, seccomp_available=False),
|
|
243
|
+
)
|
|
244
|
+
command_task = asyncio.create_task(runner.run_async(command, timeout_seconds=1))
|
|
245
|
+
start = time.perf_counter()
|
|
246
|
+
await asyncio.sleep(0.01)
|
|
247
|
+
elapsed = time.perf_counter() - start
|
|
248
|
+
result = await command_task
|
|
249
|
+
|
|
250
|
+
assert elapsed < 0.1
|
|
251
|
+
assert result.exit_code == 0
|
|
252
|
+
assert "done" in result.stdout
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@pytest.mark.anyio
|
|
256
|
+
async def test_async_shell_command_timeout_returns_failed_result(
|
|
257
|
+
tmp_path, monkeypatch
|
|
258
|
+
) -> None:
|
|
259
|
+
runner = SandboxRunner(cwd=tmp_path)
|
|
260
|
+
command = [
|
|
261
|
+
"/bin/sh",
|
|
262
|
+
"-c",
|
|
263
|
+
"python - <<'PY'\nimport time\ntime.sleep(1)\nprint('late')\nPY",
|
|
264
|
+
]
|
|
265
|
+
monkeypatch.setattr(
|
|
266
|
+
runner,
|
|
267
|
+
"build_command",
|
|
268
|
+
lambda command: SandboxCommand(command, seccomp_available=False),
|
|
269
|
+
)
|
|
270
|
+
result = await runner.run_async(
|
|
271
|
+
command,
|
|
272
|
+
timeout_seconds=0.05,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
assert result.exit_code == 124
|
|
276
|
+
assert "late" not in result.stdout
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@pytest.mark.anyio
|
|
280
|
+
async def test_agent_stream_stops_after_cancelled_tool(tmp_path) -> None:
|
|
281
|
+
cancelled = False
|
|
282
|
+
|
|
283
|
+
async def fake_completion(**request: object) -> object:
|
|
284
|
+
async def chunks() -> object:
|
|
285
|
+
yield tool_call_chunk("shell_command", {"command": "slow"})
|
|
286
|
+
|
|
287
|
+
return chunks()
|
|
288
|
+
|
|
289
|
+
async def fake_runner(
|
|
290
|
+
name: str, arguments: dict[str, object], context: ToolContext
|
|
291
|
+
):
|
|
292
|
+
nonlocal cancelled
|
|
293
|
+
try:
|
|
294
|
+
await asyncio.sleep(10)
|
|
295
|
+
except asyncio.CancelledError:
|
|
296
|
+
cancelled = True
|
|
297
|
+
raise
|
|
298
|
+
|
|
299
|
+
stream = run_agent_stream(
|
|
300
|
+
completion=fake_completion,
|
|
301
|
+
connection=ProviderConnection(
|
|
302
|
+
base_url=None,
|
|
303
|
+
model="gpt-5.1",
|
|
304
|
+
name="OpenAI",
|
|
305
|
+
provider=ProviderFormat.OPENAI,
|
|
306
|
+
secret_reference="sk-local",
|
|
307
|
+
),
|
|
308
|
+
cwd=tmp_path,
|
|
309
|
+
messages=[{"role": "user", "content": "Run it."}],
|
|
310
|
+
tool_runner=fake_runner,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
await stream.__anext__()
|
|
314
|
+
await stream.__anext__()
|
|
315
|
+
await stream.__anext__()
|
|
316
|
+
next_event = asyncio.create_task(stream.__anext__())
|
|
317
|
+
await asyncio.sleep(0)
|
|
318
|
+
next_event.cancel()
|
|
319
|
+
with pytest.raises(asyncio.CancelledError):
|
|
320
|
+
await next_event
|
|
321
|
+
await stream.aclose()
|
|
322
|
+
|
|
323
|
+
assert cancelled
|
|
324
|
+
|
|
325
|
+
|
|
225
326
|
def test_shell_command_denies_ptrace_when_seccomp_is_available(tmp_path) -> None:
|
|
226
327
|
command = SandboxRunner(cwd=tmp_path).build_command(["/bin/true"])
|
|
227
328
|
if not command.seccomp_available:
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
import pytest
|
|
1
6
|
from fastapi.testclient import TestClient
|
|
2
7
|
|
|
3
8
|
from flowent.agent import FLOWENT_AGENT_SYSTEM_PROMPT
|
|
4
9
|
from flowent.main import create_app
|
|
10
|
+
from flowent.sandbox import CommandResult, SandboxRunner
|
|
5
11
|
|
|
6
12
|
|
|
7
13
|
def configure_provider(
|
|
8
|
-
client
|
|
14
|
+
client,
|
|
9
15
|
*,
|
|
10
16
|
base_url: str = "",
|
|
11
17
|
model: str = "gpt-5.1",
|
|
@@ -35,6 +41,37 @@ def configure_provider(
|
|
|
35
41
|
)
|
|
36
42
|
|
|
37
43
|
|
|
44
|
+
async def configure_provider_async(
|
|
45
|
+
client: httpx.AsyncClient,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str = "",
|
|
48
|
+
model: str = "gpt-5.1",
|
|
49
|
+
name: str = "OpenAI",
|
|
50
|
+
provider_id: str = "provider-openai",
|
|
51
|
+
provider_type: str = "openai",
|
|
52
|
+
reasoning_effort: str = "default",
|
|
53
|
+
) -> None:
|
|
54
|
+
await client.post(
|
|
55
|
+
"/api/providers",
|
|
56
|
+
json={
|
|
57
|
+
"api_key": "sk-local",
|
|
58
|
+
"base_url": base_url,
|
|
59
|
+
"id": provider_id,
|
|
60
|
+
"models": [model],
|
|
61
|
+
"name": name,
|
|
62
|
+
"type": provider_type,
|
|
63
|
+
},
|
|
64
|
+
)
|
|
65
|
+
await client.put(
|
|
66
|
+
"/api/settings",
|
|
67
|
+
json={
|
|
68
|
+
"reasoning_effort": reasoning_effort,
|
|
69
|
+
"selected_model": model,
|
|
70
|
+
"selected_provider_id": provider_id,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
38
75
|
def project_context_message(request: dict[str, object]) -> dict[str, object] | None:
|
|
39
76
|
for message in request["messages"]:
|
|
40
77
|
if str(message["content"]).startswith("# AGENTS.md instructions for "):
|
|
@@ -63,6 +100,88 @@ def stream_events(content: str) -> list[dict[str, object]]:
|
|
|
63
100
|
return events
|
|
64
101
|
|
|
65
102
|
|
|
103
|
+
def tool_call_chunk(
|
|
104
|
+
name: str,
|
|
105
|
+
arguments: str,
|
|
106
|
+
*,
|
|
107
|
+
call_id: str = "call-1",
|
|
108
|
+
) -> dict[str, object]:
|
|
109
|
+
return {
|
|
110
|
+
"choices": [
|
|
111
|
+
{
|
|
112
|
+
"delta": {
|
|
113
|
+
"tool_calls": [
|
|
114
|
+
{
|
|
115
|
+
"index": 0,
|
|
116
|
+
"id": call_id,
|
|
117
|
+
"type": "function",
|
|
118
|
+
"function": {
|
|
119
|
+
"arguments": arguments,
|
|
120
|
+
"name": name,
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.anyio
|
|
131
|
+
async def test_workspace_long_shell_command_does_not_block_health(
|
|
132
|
+
tmp_path, monkeypatch
|
|
133
|
+
) -> None:
|
|
134
|
+
monkeypatch.chdir(tmp_path)
|
|
135
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
136
|
+
command_started = asyncio.Event()
|
|
137
|
+
command_can_finish = asyncio.Event()
|
|
138
|
+
|
|
139
|
+
async def fake_run_async(self, command, **kwargs):
|
|
140
|
+
command_started.set()
|
|
141
|
+
await asyncio.wait_for(command_can_finish.wait(), timeout=2)
|
|
142
|
+
return CommandResult(
|
|
143
|
+
command=" ".join(command),
|
|
144
|
+
exit_code=0,
|
|
145
|
+
stderr="",
|
|
146
|
+
stdout="slow command finished",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
150
|
+
|
|
151
|
+
captured_requests: list[dict[str, object]] = []
|
|
152
|
+
|
|
153
|
+
async def fake_completion(**request: object) -> object:
|
|
154
|
+
captured_requests.append(request)
|
|
155
|
+
|
|
156
|
+
async def chunks() -> object:
|
|
157
|
+
if len(captured_requests) == 1:
|
|
158
|
+
yield tool_call_chunk("shell_command", '{"command": "slow"}')
|
|
159
|
+
else:
|
|
160
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
161
|
+
|
|
162
|
+
return chunks()
|
|
163
|
+
|
|
164
|
+
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
165
|
+
async with httpx.AsyncClient(
|
|
166
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
167
|
+
) as client:
|
|
168
|
+
await configure_provider_async(client)
|
|
169
|
+
response_task = asyncio.create_task(
|
|
170
|
+
client.post("/api/workspace/respond", json={"content": "Run slow."})
|
|
171
|
+
)
|
|
172
|
+
await asyncio.wait_for(command_started.wait(), timeout=2)
|
|
173
|
+
start = time.perf_counter()
|
|
174
|
+
health_response = await client.get("/api/health")
|
|
175
|
+
elapsed = time.perf_counter() - start
|
|
176
|
+
command_can_finish.set()
|
|
177
|
+
response = await response_task
|
|
178
|
+
|
|
179
|
+
assert health_response.status_code == 200
|
|
180
|
+
assert health_response.json() == {"status": "ok"}
|
|
181
|
+
assert elapsed < 0.2
|
|
182
|
+
assert response.status_code == 200
|
|
183
|
+
|
|
184
|
+
|
|
66
185
|
def test_workspace_response_streams_selected_provider_model_and_history(
|
|
67
186
|
tmp_path, monkeypatch
|
|
68
187
|
) -> None:
|
|
@@ -604,3 +723,152 @@ def test_project_instructions_are_truncated_to_size_limit(
|
|
|
604
723
|
assert project_message is not None
|
|
605
724
|
assert "1234567890ab" in project_message["content"]
|
|
606
725
|
assert "cdef" not in project_message["content"]
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@pytest.mark.anyio
|
|
729
|
+
async def test_workspace_persists_tool_start_during_stream(
|
|
730
|
+
tmp_path, monkeypatch
|
|
731
|
+
) -> None:
|
|
732
|
+
monkeypatch.chdir(tmp_path)
|
|
733
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
734
|
+
command_started = asyncio.Event()
|
|
735
|
+
command_can_finish = asyncio.Event()
|
|
736
|
+
|
|
737
|
+
async def fake_run_async(self, command, **kwargs):
|
|
738
|
+
command_started.set()
|
|
739
|
+
await asyncio.wait_for(command_can_finish.wait(), timeout=2)
|
|
740
|
+
return CommandResult(
|
|
741
|
+
command=" ".join(command),
|
|
742
|
+
exit_code=0,
|
|
743
|
+
stderr="",
|
|
744
|
+
stdout="Launch notes",
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
|
|
748
|
+
|
|
749
|
+
async def fake_completion(**request: object) -> object:
|
|
750
|
+
async def chunks() -> object:
|
|
751
|
+
if request["messages"][-1]["role"] == "user":
|
|
752
|
+
yield tool_call_chunk("shell_command", '{"command": "slow"}')
|
|
753
|
+
else:
|
|
754
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
755
|
+
|
|
756
|
+
return chunks()
|
|
757
|
+
|
|
758
|
+
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
759
|
+
async with httpx.AsyncClient(
|
|
760
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
761
|
+
) as client:
|
|
762
|
+
await configure_provider_async(client)
|
|
763
|
+
response_task = asyncio.create_task(
|
|
764
|
+
client.post("/api/workspace/respond", json={"content": "Read notes."})
|
|
765
|
+
)
|
|
766
|
+
await asyncio.wait_for(command_started.wait(), timeout=2)
|
|
767
|
+
state = (await client.get("/api/state")).json()
|
|
768
|
+
command_can_finish.set()
|
|
769
|
+
response = await response_task
|
|
770
|
+
|
|
771
|
+
assistant = state["messages"][-1]
|
|
772
|
+
assert response.status_code == 200
|
|
773
|
+
assert assistant["author"] == "assistant"
|
|
774
|
+
assert assistant["status"] == "running"
|
|
775
|
+
assert assistant["tools"][0]["name"] == "shell_command"
|
|
776
|
+
assert assistant["tools"][0]["status"] == "running"
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@pytest.mark.anyio
|
|
780
|
+
async def test_workspace_persists_tool_result_during_stream(
|
|
781
|
+
tmp_path, monkeypatch
|
|
782
|
+
) -> None:
|
|
783
|
+
monkeypatch.chdir(tmp_path)
|
|
784
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
785
|
+
(tmp_path / "notes.txt").write_text("Launch notes")
|
|
786
|
+
second_round_started = asyncio.Event()
|
|
787
|
+
continue_stream = asyncio.Event()
|
|
788
|
+
|
|
789
|
+
async def fake_completion(**request: object) -> object:
|
|
790
|
+
async def chunks() -> object:
|
|
791
|
+
if request["messages"][-1]["role"] == "user":
|
|
792
|
+
yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
|
|
793
|
+
return
|
|
794
|
+
second_round_started.set()
|
|
795
|
+
await asyncio.wait_for(continue_stream.wait(), timeout=2)
|
|
796
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
797
|
+
|
|
798
|
+
return chunks()
|
|
799
|
+
|
|
800
|
+
app = create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
801
|
+
async with httpx.AsyncClient(
|
|
802
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
803
|
+
) as client:
|
|
804
|
+
await configure_provider_async(client)
|
|
805
|
+
response_task = asyncio.create_task(
|
|
806
|
+
client.post("/api/workspace/respond", json={"content": "Read notes."})
|
|
807
|
+
)
|
|
808
|
+
await asyncio.wait_for(second_round_started.wait(), timeout=2)
|
|
809
|
+
state = (await client.get("/api/state")).json()
|
|
810
|
+
continue_stream.set()
|
|
811
|
+
response = await response_task
|
|
812
|
+
|
|
813
|
+
assistant = state["messages"][-1]
|
|
814
|
+
assert response.status_code == 200
|
|
815
|
+
assert assistant["status"] == "running"
|
|
816
|
+
assert assistant["tools"][0]["status"] == "success"
|
|
817
|
+
assert assistant["tools"][0]["content"] == "Launch notes"
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def test_workspace_persists_failed_draft_when_stream_errors(
|
|
821
|
+
tmp_path, monkeypatch
|
|
822
|
+
) -> None:
|
|
823
|
+
monkeypatch.chdir(tmp_path)
|
|
824
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
825
|
+
|
|
826
|
+
async def fake_completion(**request: object) -> object:
|
|
827
|
+
async def chunks() -> object:
|
|
828
|
+
yield {"choices": [{"delta": {"content": "Partial answer."}}]}
|
|
829
|
+
raise RuntimeError("provider stopped")
|
|
830
|
+
|
|
831
|
+
return chunks()
|
|
832
|
+
|
|
833
|
+
client = TestClient(
|
|
834
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
835
|
+
)
|
|
836
|
+
configure_provider(client)
|
|
837
|
+
|
|
838
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
839
|
+
|
|
840
|
+
assert response.status_code == 200
|
|
841
|
+
events = stream_events(response.text)
|
|
842
|
+
assert events[-1]["event"] == "error"
|
|
843
|
+
state = client.get("/api/state").json()
|
|
844
|
+
assistant = state["messages"][-1]
|
|
845
|
+
assert assistant["author"] == "assistant"
|
|
846
|
+
assert assistant["content"] == "Partial answer."
|
|
847
|
+
assert assistant["status"] == "failed"
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def test_workspace_marks_draft_complete_when_stream_finishes(
|
|
851
|
+
tmp_path, monkeypatch
|
|
852
|
+
) -> None:
|
|
853
|
+
monkeypatch.chdir(tmp_path)
|
|
854
|
+
monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
|
|
855
|
+
|
|
856
|
+
async def fake_completion(**request: object) -> object:
|
|
857
|
+
async def chunks() -> object:
|
|
858
|
+
yield {"choices": [{"delta": {"content": "Done."}}]}
|
|
859
|
+
|
|
860
|
+
return chunks()
|
|
861
|
+
|
|
862
|
+
client = TestClient(
|
|
863
|
+
create_app(serve_frontend=False, chat_completion=fake_completion)
|
|
864
|
+
)
|
|
865
|
+
configure_provider(client)
|
|
866
|
+
|
|
867
|
+
response = client.post("/api/workspace/respond", json={"content": "Hello."})
|
|
868
|
+
|
|
869
|
+
assert response.status_code == 200
|
|
870
|
+
state = client.get("/api/state").json()
|
|
871
|
+
assistant = state["messages"][-1]
|
|
872
|
+
assert assistant["author"] == "assistant"
|
|
873
|
+
assert assistant["content"] == "Done."
|
|
874
|
+
assert assistant.get("status", "completed") == "completed"
|
package/backend/uv.lock
CHANGED
package/bin/flowent.mjs
CHANGED