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.
- package/README.md +14 -0
- package/backend/README.md +14 -0
- 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__/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__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/agent.py +15 -0
- package/backend/src/flowent/channels.py +296 -0
- package/backend/src/flowent/cli.py +11 -0
- package/backend/src/flowent/llm.py +49 -1
- package/backend/src/flowent/main.py +143 -2
- package/backend/src/flowent/sandbox.py +18 -3
- package/backend/src/flowent/static/assets/index-CEZrWoDG.css +2 -0
- package/backend/src/flowent/static/assets/index-S5a0Rkj1.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +217 -8
- package/backend/src/flowent/tools.py +28 -5
- 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_persistence.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/conftest.py +21 -0
- package/backend/tests/test_agent_tools.py +99 -0
- package/backend/tests/test_channels.py +360 -0
- package/backend/tests/test_llm_providers.py +58 -0
- package/backend/tests/test_persistence.py +46 -0
- package/backend/tests/test_startup_requirements.py +48 -0
- package/backend/tests/test_workspace_chat.py +28 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-CEZrWoDG.css +2 -0
- package/dist/frontend/assets/index-S5a0Rkj1.js +81 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-C76K95ty.js +0 -81
- package/backend/src/flowent/static/assets/index-iUMNKvlU.css +0 -2
- package/dist/frontend/assets/index-C76K95ty.js +0 -81
- 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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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 (
|
|
170
|
-
|
|
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
|
-
(
|
|
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 "
|
|
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
|
-
|
|
196
|
-
|
|
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=
|
|
268
|
-
title=
|
|
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()
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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)
|