webbee 0.3.4__tar.gz → 0.3.6__tar.gz
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.
- {webbee-0.3.4 → webbee-0.3.6}/CHANGELOG.md +17 -0
- {webbee-0.3.4 → webbee-0.3.6}/PKG-INFO +1 -1
- {webbee-0.3.4 → webbee-0.3.6}/pyproject.toml +1 -1
- webbee-0.3.6/src/webbee/__init__.py +1 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/frames.py +55 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/render.py +11 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/repl.py +21 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/session.py +26 -10
- webbee-0.3.6/src/webbee/thread.py +49 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_render.py +28 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_repl.py +96 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_session.py +193 -8
- webbee-0.3.6/tests/test_thread.py +153 -0
- webbee-0.3.4/src/webbee/__init__.py +0 -1
- {webbee-0.3.4 → webbee-0.3.6}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/.gitignore +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/LICENSE +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/README.md +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/install.sh +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/account.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/cli.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/commands.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/config.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/details.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/events.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/remote.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/repo.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/sessions.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/stream.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/tools.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/tui.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/src/webbee/update.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/__init__.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_account.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_cli.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_clipboard.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_commands.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_config.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_details.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_events.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_query.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_service.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_store.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_marathon.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_packaging.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_repo.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_sessions.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_stream.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_tools.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_tui.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_update.py +0 -0
- {webbee-0.3.4 → webbee-0.3.6}/tests/test_version.py +0 -0
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.6
|
|
4
|
+
|
|
5
|
+
- Boot replay shows only the conversation: flattened tool traffic
|
|
6
|
+
(`[tool_use …]` / `[tool_result] …` blocks the agent keeps as its own
|
|
7
|
+
working memory) no longer floods the boot screen — each replayed message
|
|
8
|
+
shows its human-readable part, pure tool messages are skipped.
|
|
9
|
+
|
|
10
|
+
## 0.3.5
|
|
11
|
+
|
|
12
|
+
- Surface-aware conversation: turns steered from Telegram/the panel render
|
|
13
|
+
live in the terminal tagged by origin (`[telegram]` / `[web-panel]`) —
|
|
14
|
+
the terminal stays the session's sole executor, foreign turns are
|
|
15
|
+
display-only and are never executed locally.
|
|
16
|
+
- Boot replay: opening the terminal replays the recent conversation
|
|
17
|
+
(including cross-surface turns) with origin tags, then a "— live —"
|
|
18
|
+
divider. Best-effort — a network failure never blocks boot.
|
|
19
|
+
|
|
3
20
|
## 0.3.4
|
|
4
21
|
|
|
5
22
|
- `/notify [tg|panel|both|off]` — mirror this coding session to Telegram
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.6"
|
|
@@ -123,6 +123,61 @@ def handle_action_frame(frame: dict, sink, started: set, finished: set, steps: l
|
|
|
123
123
|
steps.append({"step_id": sid, "label": lbl, "ok": ok})
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
# --- Cross-surface (foreign-turn) frames -------------------------------------
|
|
127
|
+
# Frames stamped with a DIFFERENT task_id belong to another turn on the shared
|
|
128
|
+
# persistent stream -- a turn steered from Telegram/the panel (kernel stamps
|
|
129
|
+
# `origin` with the source surface) or a stale prior turn (no `origin`). They
|
|
130
|
+
# are DISPLAY-ONLY for this client: one tagged line, NEVER executed, NEVER
|
|
131
|
+
# consented, NEVER terminal for the client's own turn (the C7 safety filter in
|
|
132
|
+
# session.run() owns that guarantee; these helpers only compose the line).
|
|
133
|
+
|
|
134
|
+
_FOREIGN_ACTIONABLE_TYPES = ("tool_request", "confirm_request", "final",
|
|
135
|
+
"marathon_complete", "panel_release_required")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _origin_tag(frame: dict) -> str:
|
|
139
|
+
"""Display prefix for an OWN-turn frame steered from another surface.
|
|
140
|
+
Live steer topology: a Telegram/panel-steered turn keeps the terminal's
|
|
141
|
+
OWN task_id (the terminal stays the sole executor) -- only the kernel-
|
|
142
|
+
stamped `origin` says where it came from. Empty for terminal/own frames,
|
|
143
|
+
so untagged rendering stays byte-identical to today."""
|
|
144
|
+
origin = str(frame.get("origin", "") or "")
|
|
145
|
+
return f"[{origin}] " if origin and origin != "terminal" else ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _foreign_note(frame: dict) -> str:
|
|
149
|
+
"""One-line display text for a cross-surface frame, or "" when there is
|
|
150
|
+
nothing meaningful to show (usage/step bookkeeping/unknown types) -- the
|
|
151
|
+
caller then skips it silently."""
|
|
152
|
+
ftype = frame.get("type", "")
|
|
153
|
+
tool = str(frame.get("tool", "") or "")
|
|
154
|
+
if ftype == "tool_request":
|
|
155
|
+
return f"running {tool}" if tool else "running a tool"
|
|
156
|
+
if ftype == "confirm_request":
|
|
157
|
+
return f"approval requested: {tool}" if tool else "approval requested"
|
|
158
|
+
if ftype in ("final", "marathon_complete", "progress", "thinking"):
|
|
159
|
+
return _progress_text(frame).strip()
|
|
160
|
+
if ftype == "panel_release_required":
|
|
161
|
+
summary = str(frame.get("summary", "") or "").strip()
|
|
162
|
+
return summary or "payment approval required in the panel"
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def render_foreign_frame(frame: dict, sink) -> None:
|
|
167
|
+
"""Render ONE tagged, display-only line for a foreign-turn frame. Guarded
|
|
168
|
+
like the marathon notes: a minimal sink without `foreign_turn` drops the
|
|
169
|
+
line, and a render error must never break the safety `continue` in
|
|
170
|
+
session.run() (rendering is the ONLY thing foreign frames ever get)."""
|
|
171
|
+
text = _foreign_note(frame)
|
|
172
|
+
render = getattr(sink, "foreign_turn", None)
|
|
173
|
+
if not text or render is None:
|
|
174
|
+
return
|
|
175
|
+
try:
|
|
176
|
+
render(str(frame.get("origin", "") or ""), "assistant", text)
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
|
|
126
181
|
# --- U4 marathon FACT frames -------------------------------------------------
|
|
127
182
|
# A marathon (long-horizon autonomous run) streams the SAME frame vocabulary as
|
|
128
183
|
# a coding turn PLUS three progress FACTS. The kernel emits facts; this renderer
|
|
@@ -222,6 +222,17 @@ class RichSink:
|
|
|
222
222
|
self.console.print(_pad(Text(" ❯ " + _clean(text) + " ", style="bold white on grey30")))
|
|
223
223
|
self._nudge()
|
|
224
224
|
|
|
225
|
+
def foreign_turn(self, surface: str, role: str, text: str) -> None:
|
|
226
|
+
"""One tagged line for a turn that lives on ANOTHER surface (a
|
|
227
|
+
Telegram/panel-steered turn on the shared stream, or a replayed prior
|
|
228
|
+
turn). DISPLAY-ONLY: never touches _tools/tokens/_busy, so the
|
|
229
|
+
terminal's own turn accounting stays uncontaminated."""
|
|
230
|
+
who = "you" if role == "user" else "🐝 webbee"
|
|
231
|
+
surface = _clean(surface)
|
|
232
|
+
tag = "" if surface in ("", "terminal") else f" [{surface}]"
|
|
233
|
+
self.console.print(_pad(Text(f"{who}{tag}: {_clean(text)}", style=_BEE)))
|
|
234
|
+
self._nudge()
|
|
235
|
+
|
|
225
236
|
def clear(self) -> None:
|
|
226
237
|
"""/clear: wipe the pane/screen + reset the session counters."""
|
|
227
238
|
self.console.clear()
|
|
@@ -291,6 +291,27 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
291
291
|
account = await account_fetcher(cfg, token_provider)
|
|
292
292
|
state["logged_in"] = account.signed_in
|
|
293
293
|
_sink.welcome(account, workspace, "terminal")
|
|
294
|
+
# Boot replay of the durable per-user thread (Task 9): best-effort,
|
|
295
|
+
# entirely swallowed on any failure -- history is a nice-to-have,
|
|
296
|
+
# never a boot blocker (network down, no such session yet, etc.).
|
|
297
|
+
try:
|
|
298
|
+
from imperal_mcp.client import ImperalClient
|
|
299
|
+
from webbee.thread import (conversational_text, fetch_recent_thread,
|
|
300
|
+
truncate_for_display)
|
|
301
|
+
_iid = await ImperalClient(cfg, token_provider).whoami()
|
|
302
|
+
_msgs = await fetch_recent_thread(cfg, token_provider, f"marathon-{_iid}-rboot")
|
|
303
|
+
_shown = 0
|
|
304
|
+
for _m in _msgs[-40:]:
|
|
305
|
+
_text = conversational_text(_m.get("content", ""))
|
|
306
|
+
if not _text:
|
|
307
|
+
continue # pure tool traffic -- mind-food, not conversation
|
|
308
|
+
_sink.foreign_turn(_m.get("surface", "terminal"), _m.get("role", ""),
|
|
309
|
+
truncate_for_display(_text))
|
|
310
|
+
_shown += 1
|
|
311
|
+
if _shown:
|
|
312
|
+
_sink.note("— live —")
|
|
313
|
+
except Exception:
|
|
314
|
+
pass # replay is best-effort; never block boot
|
|
294
315
|
if cfg.intel_enabled:
|
|
295
316
|
# Off-loop build (indexing does sync file I/O + subprocess). Any
|
|
296
317
|
# failure here (missing extra, bad repo, etc.) must degrade to
|
|
@@ -3,13 +3,16 @@ import os
|
|
|
3
3
|
import subprocess
|
|
4
4
|
|
|
5
5
|
from webbee.frames import (
|
|
6
|
+
_FOREIGN_ACTIONABLE_TYPES,
|
|
6
7
|
_MARATHON_FACT_TYPES,
|
|
7
8
|
_first_time,
|
|
9
|
+
_origin_tag,
|
|
8
10
|
_progress_text,
|
|
9
11
|
handle_action_frame,
|
|
10
12
|
handle_step_finished,
|
|
11
13
|
handle_step_started,
|
|
12
14
|
marathon_note,
|
|
15
|
+
render_foreign_frame,
|
|
13
16
|
)
|
|
14
17
|
|
|
15
18
|
|
|
@@ -218,13 +221,23 @@ class AgentSession:
|
|
|
218
221
|
ftype = frame.get("type")
|
|
219
222
|
|
|
220
223
|
_ftid = frame.get("task_id", "")
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
# A frame from a DIFFERENT turn on the shared persistent stream
|
|
225
|
+
# (task_id absent on legacy kernels -> treated as own). C7 safety:
|
|
226
|
+
# foreign actionable frames are NEVER executed/consented and NEVER
|
|
227
|
+
# end this turn -- but instead of vanishing they (and any origin-
|
|
228
|
+
# stamped cross-surface display frame, e.g. a Telegram-steered
|
|
229
|
+
# turn's progress) now render ONE tagged, display-only line.
|
|
230
|
+
if _ftid and self._task_id and _ftid != self._task_id and (
|
|
231
|
+
frame.get("origin") or ftype in _FOREIGN_ACTIONABLE_TYPES):
|
|
232
|
+
render_foreign_frame(frame, sink)
|
|
226
233
|
continue
|
|
227
234
|
|
|
235
|
+
# Live steer topology: a Telegram/panel-steered turn keeps THIS
|
|
236
|
+
# client's task_id (the terminal stays the sole executor) with
|
|
237
|
+
# `origin` stamped -- tag the text renders below; everything
|
|
238
|
+
# else (execution, dedup, consent, accounting) is unchanged.
|
|
239
|
+
_tag = _origin_tag(frame)
|
|
240
|
+
|
|
228
241
|
if ftype == "tool_request":
|
|
229
242
|
rid = frame.get("req_id")
|
|
230
243
|
sid = str(rid or "")
|
|
@@ -236,14 +249,14 @@ class AgentSession:
|
|
|
236
249
|
# dispatch and freezes the dock).
|
|
237
250
|
if _first_time(sid, started):
|
|
238
251
|
try:
|
|
239
|
-
sink.tool_start(frame.get("tool", ""), frame.get("args", {}))
|
|
252
|
+
sink.tool_start(_tag + frame.get("tool", ""), frame.get("args", {}))
|
|
240
253
|
except Exception:
|
|
241
254
|
pass
|
|
242
255
|
out = await asyncio.to_thread(handle_tool_request, frame, executor)
|
|
243
256
|
res = out["result"]
|
|
244
257
|
if _first_time(sid, finished):
|
|
245
258
|
try:
|
|
246
|
-
sink.tool_result(frame.get("tool", ""), bool(res.get("ok")), _summary(res))
|
|
259
|
+
sink.tool_result(_tag + frame.get("tool", ""), bool(res.get("ok")), _summary(res))
|
|
247
260
|
self.steps.append({"step_id": sid,
|
|
248
261
|
"label": frame.get("tool", ""),
|
|
249
262
|
"ok": bool(res.get("ok"))})
|
|
@@ -276,10 +289,12 @@ class AgentSession:
|
|
|
276
289
|
handle_step_finished(frame, sink, finished, step_labels, self.steps, local_ids)
|
|
277
290
|
|
|
278
291
|
elif ftype == "thinking": # system-driven reasoning -> the 💭 block
|
|
279
|
-
|
|
292
|
+
_text = _progress_text(frame)
|
|
293
|
+
(getattr(sink, "thinking", None) or sink.progress)(_tag + _text if _text else "")
|
|
280
294
|
|
|
281
295
|
elif ftype == "progress": # P2 — dual-reads llm_text (v2) / text (legacy)
|
|
282
|
-
|
|
296
|
+
_text = _progress_text(frame)
|
|
297
|
+
sink.progress(_tag + _text if _text else "")
|
|
283
298
|
|
|
284
299
|
elif ftype == "usage": # P2 — cumulative tokens + credits (Slice C; raw $ stays server-side)
|
|
285
300
|
sink.usage(
|
|
@@ -311,7 +326,8 @@ class AgentSession:
|
|
|
311
326
|
# non-marathon coding turn's `final` is terminal (unchanged).
|
|
312
327
|
if marathon and not frame.get("stopped"):
|
|
313
328
|
continue
|
|
314
|
-
|
|
329
|
+
_text = frame.get("text", "")
|
|
330
|
+
return _tag + _text if _text else ""
|
|
315
331
|
|
|
316
332
|
return ""
|
|
317
333
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Fetch the durable coding-thread transcript for boot replay. The gateway
|
|
2
|
+
keeps ONE durable per-user thread across turns/surfaces (session.py:142,
|
|
3
|
+
"server reloads the shared webbee-terminal thread, so context carries across
|
|
4
|
+
turns"); this reads its recent tail so `_boot` can replay it with origin tags
|
|
5
|
+
before the live loop starts. House pattern = sessions.py/remote.py: (cfg,
|
|
6
|
+
token_provider), lazy httpx, Bearer auth. This module does not swallow errors
|
|
7
|
+
itself -- `_boot` wraps the whole replay in one try/except so a network
|
|
8
|
+
failure never blocks/delays boot beyond the timeout, it just skips the
|
|
9
|
+
replay (same division of labor as remote.py + the /notify call site)."""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
_DISPLAY_LIMIT = 400
|
|
13
|
+
|
|
14
|
+
# The durable thread stores each tool exchange FLATTENED into the message
|
|
15
|
+
# text ("[tool_use bash] {...}" / "[tool_result] ..."), so the agent can
|
|
16
|
+
# reread its own past work. That is mind-food, not conversation -- replaying
|
|
17
|
+
# it verbatim floods the boot screen with raw JSON (Valentin, live
|
|
18
|
+
# 2026-07-15). Replay shows only the conversational part of each message.
|
|
19
|
+
_FLATTEN_MARKERS = ("[tool_use ", "[tool_result]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def conversational_text(content) -> str:
|
|
23
|
+
"""The human-conversation part of one stored thread message: everything
|
|
24
|
+
up to the first flattened tool block, stripped. "" means the message was
|
|
25
|
+
pure tool traffic and must be skipped by the replay."""
|
|
26
|
+
text = str(content or "")
|
|
27
|
+
cut = len(text)
|
|
28
|
+
for m in _FLATTEN_MARKERS:
|
|
29
|
+
i = text.find(m)
|
|
30
|
+
if i != -1:
|
|
31
|
+
cut = min(cut, i)
|
|
32
|
+
return text[:cut].strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def fetch_recent_thread(cfg, token_provider, session_id: str) -> list[dict]:
|
|
36
|
+
import httpx
|
|
37
|
+
token = await token_provider()
|
|
38
|
+
async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
|
|
39
|
+
r = await c.get(f"/v1/agent/sessions/{session_id}/thread",
|
|
40
|
+
headers={"Authorization": f"Bearer {token}"})
|
|
41
|
+
r.raise_for_status()
|
|
42
|
+
return (r.json() or {}).get("messages", [])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def truncate_for_display(text, limit: int = _DISPLAY_LIMIT) -> str:
|
|
46
|
+
"""Cap one replayed message's text so a single huge assistant reply can't
|
|
47
|
+
flood the boot screen -- foreign_turn renders one line per message."""
|
|
48
|
+
text = str(text or "")
|
|
49
|
+
return text if len(text) <= limit else text[:limit].rstrip() + "…"
|
|
@@ -246,6 +246,34 @@ def test_user_echo_is_unboxed_line():
|
|
|
246
246
|
assert "╭" not in out and "│" not in out and "╰" not in out # NOT the input box
|
|
247
247
|
|
|
248
248
|
|
|
249
|
+
def test_foreign_turn_tagged_line_no_turn_accounting():
|
|
250
|
+
# A cross-surface line is DISPLAY-ONLY: tagged by origin surface, empty
|
|
251
|
+
# tag for terminal/replay-own, and it must NOT touch the terminal's own
|
|
252
|
+
# turn accounting (_tools/tokens/_busy) -- recon §3's contamination risk.
|
|
253
|
+
s = _sink()
|
|
254
|
+
s.begin_turn()
|
|
255
|
+
tools, tokens, busy = s._tools, s.tokens, s._busy
|
|
256
|
+
s.foreign_turn("telegram", "assistant", "done on telegram")
|
|
257
|
+
s.foreign_turn("terminal", "user", "fix the tests")
|
|
258
|
+
s.foreign_turn("", "assistant", "stale line")
|
|
259
|
+
out = s.console.export_text()
|
|
260
|
+
assert "🐝 webbee [telegram]: done on telegram" in out
|
|
261
|
+
assert "you: fix the tests" in out # terminal -> no tag
|
|
262
|
+
assert "[terminal]" not in out
|
|
263
|
+
assert "🐝 webbee: stale line" in out # empty surface -> no tag
|
|
264
|
+
assert (s._tools, s.tokens, s._busy) == (tools, tokens, busy)
|
|
265
|
+
assert not NO_CYRILLIC.search(out)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_foreign_turn_strips_control_bytes():
|
|
269
|
+
# Kernel-relayed text is untrusted -- same _clean rule as note()/tool lines.
|
|
270
|
+
s = _sink()
|
|
271
|
+
s.foreign_turn("tele\x1b[2Jgram", "assistant", "hi\x1b]0;pwned\x07 there")
|
|
272
|
+
out = s.console.export_text()
|
|
273
|
+
assert "\x1b[2J" not in out and "pwned" not in out
|
|
274
|
+
assert "[telegram]" in out and "hi there" in out
|
|
275
|
+
|
|
276
|
+
|
|
249
277
|
def test_answer_marker_shows_webbee_name():
|
|
250
278
|
s = _sink()
|
|
251
279
|
s.begin_turn(); s.end_turn("here is the answer")
|
|
@@ -20,6 +20,8 @@ class FakeSink:
|
|
|
20
20
|
def welcome(self, *a, **kw): ...
|
|
21
21
|
def user_echo(self, text): self.echoed = getattr(self, "echoed", []) + [text]
|
|
22
22
|
def sessions_table(self, rows): self.session_tables = getattr(self, "session_tables", []) + [rows]
|
|
23
|
+
def foreign_turn(self, surface, role, text):
|
|
24
|
+
self.foreign = getattr(self, "foreign", []) + [(surface, role, text)]
|
|
23
25
|
# TurnSink no-ops
|
|
24
26
|
def tool_start(self, *a): ...
|
|
25
27
|
def tool_result(self, *a): ...
|
|
@@ -473,3 +475,97 @@ def test_watcher_started_at_intel_root_not_workspace(monkeypatch, tmp_path):
|
|
|
473
475
|
))
|
|
474
476
|
assert captured.get("root") == str(tmp_path)
|
|
475
477
|
assert captured.get("root") != os.getcwd()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ── boot replay of the durable coding thread (Task 9) ─────────────────────────
|
|
481
|
+
|
|
482
|
+
class _FakeImperalClient:
|
|
483
|
+
"""Stands in for imperal_mcp.client.ImperalClient -- house pattern already
|
|
484
|
+
used by test_session.py/test_marathon.py for the same external class."""
|
|
485
|
+
def __init__(self, cfg, token_provider):
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
async def whoami(self):
|
|
489
|
+
return "user-1"
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def test_boot_replays_recent_thread_before_live_loop(monkeypatch):
|
|
493
|
+
import imperal_mcp.client as ic
|
|
494
|
+
import webbee.thread as TH
|
|
495
|
+
|
|
496
|
+
monkeypatch.setattr(ic, "ImperalClient", _FakeImperalClient)
|
|
497
|
+
|
|
498
|
+
async def fake_fetch(cfg, token_provider, session_id):
|
|
499
|
+
assert session_id == "marathon-user-1-rboot" # _owns prefix contract
|
|
500
|
+
return [
|
|
501
|
+
{"role": "user", "content": "hi", "surface": "telegram"},
|
|
502
|
+
{"role": "assistant", "content": "done", "surface": "terminal"},
|
|
503
|
+
]
|
|
504
|
+
monkeypatch.setattr(TH, "fetch_recent_thread", fake_fetch)
|
|
505
|
+
|
|
506
|
+
sink, agent = _run(read_line=_lines("/exit"))
|
|
507
|
+
assert sink.foreign == [
|
|
508
|
+
("telegram", "user", "hi"), # renders as "you [telegram]: hi"
|
|
509
|
+
("terminal", "assistant", "done"),
|
|
510
|
+
]
|
|
511
|
+
assert any("live" in n for n in sink.notes)
|
|
512
|
+
# replay happened during boot, strictly before any turn ran
|
|
513
|
+
assert agent.tasks == []
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def test_boot_replay_survives_thread_fetch_failure(monkeypatch):
|
|
517
|
+
import imperal_mcp.client as ic
|
|
518
|
+
import webbee.thread as TH
|
|
519
|
+
|
|
520
|
+
monkeypatch.setattr(ic, "ImperalClient", _FakeImperalClient)
|
|
521
|
+
|
|
522
|
+
async def boom(cfg, token_provider, session_id):
|
|
523
|
+
raise RuntimeError("connection refused")
|
|
524
|
+
monkeypatch.setattr(TH, "fetch_recent_thread", boom)
|
|
525
|
+
|
|
526
|
+
sink, agent = _run(read_line=_lines("hello", "/exit"))
|
|
527
|
+
assert getattr(sink, "foreign", []) == []
|
|
528
|
+
assert agent.tasks == ["hello"] # boot completed cleanly, agent still works
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def test_boot_replay_survives_whoami_failure(monkeypatch):
|
|
532
|
+
# No imperal_mcp.client.ImperalClient patched here -- whoami() hits the
|
|
533
|
+
# (unreachable) fake api_url and raises. The whole replay block must be
|
|
534
|
+
# swallowed, never crashing boot or delaying it past the intel/agent setup.
|
|
535
|
+
sink, agent = _run(read_line=_lines("hello", "/exit"))
|
|
536
|
+
assert getattr(sink, "foreign", []) == []
|
|
537
|
+
assert agent.tasks == ["hello"]
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_boot_replay_truncates_long_messages(monkeypatch):
|
|
541
|
+
import imperal_mcp.client as ic
|
|
542
|
+
import webbee.thread as TH
|
|
543
|
+
|
|
544
|
+
monkeypatch.setattr(ic, "ImperalClient", _FakeImperalClient)
|
|
545
|
+
|
|
546
|
+
long_text = "x" * 1000
|
|
547
|
+
|
|
548
|
+
async def fake_fetch(cfg, token_provider, session_id):
|
|
549
|
+
return [{"role": "assistant", "content": long_text, "surface": "terminal"}]
|
|
550
|
+
monkeypatch.setattr(TH, "fetch_recent_thread", fake_fetch)
|
|
551
|
+
|
|
552
|
+
sink, agent = _run(read_line=_lines("/exit"))
|
|
553
|
+
assert len(sink.foreign) == 1
|
|
554
|
+
rendered = sink.foreign[0][2]
|
|
555
|
+
assert len(rendered) <= 401 # 400 chars + ellipsis
|
|
556
|
+
assert rendered.endswith("…")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def test_boot_replay_skips_note_when_thread_empty(monkeypatch):
|
|
560
|
+
import imperal_mcp.client as ic
|
|
561
|
+
import webbee.thread as TH
|
|
562
|
+
|
|
563
|
+
monkeypatch.setattr(ic, "ImperalClient", _FakeImperalClient)
|
|
564
|
+
|
|
565
|
+
async def fake_fetch(cfg, token_provider, session_id):
|
|
566
|
+
return []
|
|
567
|
+
monkeypatch.setattr(TH, "fetch_recent_thread", fake_fetch)
|
|
568
|
+
|
|
569
|
+
sink, agent = _run(read_line=_lines("/exit"))
|
|
570
|
+
assert getattr(sink, "foreign", []) == []
|
|
571
|
+
assert not any("live" in n for n in sink.notes)
|
|
@@ -137,10 +137,22 @@ def test_run_ignores_foreign_turn_actionable_frames_ends_on_own_final(monkeypatc
|
|
|
137
137
|
monkeypatch.setattr(T, "LocalToolExecutor", _RecExecutor)
|
|
138
138
|
|
|
139
139
|
async def _fake_stream(client, session_id, headers_provider, *, start_id="0-0"):
|
|
140
|
-
# A foreign turn's tool_request and final (must be
|
|
141
|
-
#
|
|
140
|
+
# A foreign turn's tool_request and final (must NEVER be executed /
|
|
141
|
+
# terminal for THIS turn -- only rendered as tagged lines), THEN this
|
|
142
|
+
# turn's own final (must be honored). Cross-surface frames carry the
|
|
143
|
+
# kernel-stamped `origin`; a stale prior-turn frame carries none.
|
|
142
144
|
yield {"type": "tool_request", "task_id": "OTHER", "req_id": "r1",
|
|
143
145
|
"tool": "read_file", "args": {}}
|
|
146
|
+
yield {"type": "tool_request", "task_id": "TG", "req_id": "r2",
|
|
147
|
+
"tool": "write_file", "args": {}, "origin": "telegram"}
|
|
148
|
+
yield {"type": "confirm_request", "task_id": "TG", "req_id": "r3",
|
|
149
|
+
"tool": "bash", "args": {}, "origin": "telegram"}
|
|
150
|
+
yield {"type": "progress", "task_id": "TG", "text": "thinking it over",
|
|
151
|
+
"origin": "telegram"}
|
|
152
|
+
yield {"type": "usage", "task_id": "TG", "tokens": 5, "credits": 1,
|
|
153
|
+
"origin": "telegram"} # nothing to show -> silently skipped
|
|
154
|
+
yield {"type": "final", "task_id": "TG", "text": "done on telegram",
|
|
155
|
+
"origin": "telegram"}
|
|
144
156
|
yield {"type": "final", "task_id": "OTHER", "text": "wrong turn"}
|
|
145
157
|
yield {"type": "final", "task_id": "OURS", "text": "done"}
|
|
146
158
|
|
|
@@ -168,22 +180,165 @@ def test_run_ignores_foreign_turn_actionable_frames_ends_on_own_final(monkeypatc
|
|
|
168
180
|
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
|
169
181
|
|
|
170
182
|
class RecSink:
|
|
183
|
+
def __init__(self):
|
|
184
|
+
self.foreign = []; self.progress_calls = []; self.usage_calls = []
|
|
171
185
|
def tool_start(self, *a): ...
|
|
172
186
|
def tool_result(self, *a): ...
|
|
173
187
|
def ask_consent(self, *a): return "y"
|
|
174
188
|
def panel_release(self, *a): ...
|
|
175
|
-
def progress(self,
|
|
176
|
-
def usage(self, *a):
|
|
189
|
+
def progress(self, text): self.progress_calls.append(text)
|
|
190
|
+
def usage(self, *a): self.usage_calls.append(a)
|
|
191
|
+
def foreign_turn(self, surface, role, text): self.foreign.append((surface, role, text))
|
|
177
192
|
|
|
178
193
|
async def token_provider():
|
|
179
194
|
return "tok"
|
|
180
195
|
|
|
181
196
|
sess = S.AgentSession(cfg=_FakeCfg(), token_provider=token_provider, workspace_root=".")
|
|
182
|
-
|
|
197
|
+
sink = RecSink()
|
|
198
|
+
result = asyncio.run(sess.run("do it", sink))
|
|
199
|
+
|
|
200
|
+
assert result == "done" # ended on OUR final, not a foreign one
|
|
201
|
+
assert executor_calls == [] # foreign tool_requests never dispatched
|
|
202
|
+
assert posts == ["/v1/agent/sessions"] # no /result POST for the foreign frames
|
|
203
|
+
# Cross-surface/stale frames render as DISPLAY-ONLY tagged lines (origin
|
|
204
|
+
# tag; empty for a stale/legacy frame) -- and nothing else happens.
|
|
205
|
+
assert sink.foreign == [
|
|
206
|
+
("", "assistant", "running read_file"),
|
|
207
|
+
("telegram", "assistant", "running write_file"),
|
|
208
|
+
("telegram", "assistant", "approval requested: bash"),
|
|
209
|
+
("telegram", "assistant", "thinking it over"),
|
|
210
|
+
("telegram", "assistant", "done on telegram"),
|
|
211
|
+
("", "assistant", "wrong turn"),
|
|
212
|
+
]
|
|
213
|
+
assert sink.progress_calls == [] # foreign progress never leaks into the own feed
|
|
214
|
+
assert sink.usage_calls == [] # foreign usage: nothing to show, nothing counted
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_own_turn_frames_with_cross_surface_origin_render_tagged_and_execute(monkeypatch):
|
|
218
|
+
# Live steer topology (final review C1): a Telegram-steered turn keeps the
|
|
219
|
+
# terminal's OWN task_id (the kernel drain adopts it -- the terminal stays
|
|
220
|
+
# the sole executor) and stamps `origin` with the source surface. The own
|
|
221
|
+
# path must behave EXACTLY as today (execute once, dedup, accounting,
|
|
222
|
+
# consent flow untouched) with the surface tag prefixed onto the text
|
|
223
|
+
# renders; own frames WITHOUT origin render byte-identical to today.
|
|
224
|
+
import httpx
|
|
225
|
+
import imperal_mcp.client as ic
|
|
226
|
+
import webbee.session as S
|
|
227
|
+
import webbee.stream as ST
|
|
228
|
+
import webbee.tools as T
|
|
229
|
+
|
|
230
|
+
monkeypatch.setattr(S, "build_coding_context", lambda root, intel=None: {
|
|
231
|
+
"cwd": root, "git": "", "tree": "", "repo_key": "x", "repo_root": root,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
class _FakeImperalClient:
|
|
235
|
+
def __init__(self, cfg, token_provider):
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
async def whoami(self):
|
|
239
|
+
return "user-1"
|
|
240
|
+
|
|
241
|
+
monkeypatch.setattr(ic, "ImperalClient", _FakeImperalClient)
|
|
242
|
+
|
|
243
|
+
executor_calls = []
|
|
244
|
+
|
|
245
|
+
class _RecExecutor:
|
|
246
|
+
def __init__(self, root, indexer=None, shadow=None):
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
def run(self, tool, args):
|
|
250
|
+
executor_calls.append((tool, args))
|
|
251
|
+
return {"ok": True, "content": "ran"}
|
|
252
|
+
|
|
253
|
+
monkeypatch.setattr(T, "LocalToolExecutor", _RecExecutor)
|
|
254
|
+
|
|
255
|
+
async def _fake_stream(client, session_id, headers_provider, *, start_id="0-0"):
|
|
256
|
+
yield {"type": "tool_request", "task_id": "OURS", "req_id": "r1",
|
|
257
|
+
"tool": "read_file", "args": {}, "origin": "telegram"}
|
|
258
|
+
yield {"type": "tool_request", "task_id": "OURS", "req_id": "r1",
|
|
259
|
+
"tool": "read_file", "args": {}, "origin": "telegram"} # dup req_id -> dedup
|
|
260
|
+
yield {"type": "tool_request", "task_id": "OURS", "req_id": "r2",
|
|
261
|
+
"tool": "write_file", "args": {}} # no origin -> untagged
|
|
262
|
+
yield {"type": "progress", "task_id": "OURS", "text": "editing files",
|
|
263
|
+
"origin": "telegram"}
|
|
264
|
+
yield {"type": "thinking", "task_id": "OURS", "llm_text": "pondering",
|
|
265
|
+
"origin": "telegram"}
|
|
266
|
+
yield {"type": "progress", "task_id": "OURS", "text": "plain progress"}
|
|
267
|
+
yield {"type": "progress", "task_id": "OURS", "text": "own terminal line",
|
|
268
|
+
"origin": "terminal"} # own surface -> no tag
|
|
269
|
+
yield {"type": "progress", "task_id": "OURS", "text": "",
|
|
270
|
+
"origin": "telegram"} # empty text -> stays empty, no lone tag line
|
|
271
|
+
yield {"type": "final", "task_id": "OURS", "text": "steered done",
|
|
272
|
+
"origin": "telegram"}
|
|
273
|
+
|
|
274
|
+
monkeypatch.setattr(ST, "stream_frames", _fake_stream)
|
|
275
|
+
|
|
276
|
+
class _SessResp:
|
|
277
|
+
def raise_for_status(self): pass
|
|
278
|
+
def json(self): return {"session_id": "sid1", "last_id": "0-0", "task_id": "OURS"}
|
|
279
|
+
|
|
280
|
+
class _ResultResp:
|
|
281
|
+
def raise_for_status(self): pass
|
|
282
|
+
def json(self): return {}
|
|
283
|
+
|
|
284
|
+
posts = []
|
|
285
|
+
|
|
286
|
+
class FakeAsyncClient:
|
|
287
|
+
def __init__(self, *a, **kw): pass
|
|
288
|
+
async def __aenter__(self): return self
|
|
289
|
+
async def __aexit__(self, *a): return False
|
|
290
|
+
|
|
291
|
+
async def post(self, path, headers=None, **kw):
|
|
292
|
+
posts.append(path)
|
|
293
|
+
return _SessResp() if path == "/v1/agent/sessions" else _ResultResp()
|
|
294
|
+
|
|
295
|
+
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
|
183
296
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
297
|
+
class RecSink:
|
|
298
|
+
def __init__(self):
|
|
299
|
+
self.starts = []; self.results = []; self.progress_calls = []; self.foreign = []
|
|
300
|
+
def tool_start(self, tool, args): self.starts.append((tool, dict(args)))
|
|
301
|
+
def tool_result(self, tool, ok, summary): self.results.append((tool, ok, summary))
|
|
302
|
+
def ask_consent(self, *a): return "y"
|
|
303
|
+
def panel_release(self, *a): ...
|
|
304
|
+
def progress(self, text): self.progress_calls.append(text)
|
|
305
|
+
def usage(self, *a): ...
|
|
306
|
+
def foreign_turn(self, surface, role, text): self.foreign.append((surface, role, text))
|
|
307
|
+
|
|
308
|
+
async def token_provider():
|
|
309
|
+
return "tok"
|
|
310
|
+
|
|
311
|
+
sess = S.AgentSession(cfg=_FakeCfg(), token_provider=token_provider, workspace_root=".")
|
|
312
|
+
sink = RecSink()
|
|
313
|
+
result = asyncio.run(sess.run("do it", sink))
|
|
314
|
+
|
|
315
|
+
assert result == "[telegram] steered done" # final text carries the tag
|
|
316
|
+
# Execution/dedup/accounting IDENTICAL to today: each req_id runs once,
|
|
317
|
+
# the dup re-posts the cached result (at-least-once), steps record cleanly.
|
|
318
|
+
assert executor_calls == [("read_file", {}), ("write_file", {})]
|
|
319
|
+
assert posts[0] == "/v1/agent/sessions"
|
|
320
|
+
assert posts.count("/v1/agent/sessions/sid1/result") == 3 # r1, r1-dup, r2
|
|
321
|
+
assert sess.steps == [
|
|
322
|
+
{"step_id": "r1", "label": "read_file", "ok": True},
|
|
323
|
+
{"step_id": "r2", "label": "write_file", "ok": True},
|
|
324
|
+
]
|
|
325
|
+
# Tool lines: tagged when steered, untagged otherwise.
|
|
326
|
+
assert sink.starts == [("[telegram] read_file", {}), ("write_file", {})]
|
|
327
|
+
assert sink.results == [("[telegram] read_file", True, "ran"),
|
|
328
|
+
("write_file", True, "ran")]
|
|
329
|
+
# Text lines: tagged when steered; no stray tag for no-origin/terminal.
|
|
330
|
+
assert sink.progress_calls == ["[telegram] editing files", "[telegram] pondering",
|
|
331
|
+
"plain progress", "own terminal line", ""]
|
|
332
|
+
assert sink.foreign == [] # own-path never routes via foreign_turn
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_origin_tag_prefix_only_for_other_surfaces():
|
|
336
|
+
from webbee.frames import _origin_tag
|
|
337
|
+
assert _origin_tag({"origin": "telegram"}) == "[telegram] "
|
|
338
|
+
assert _origin_tag({"origin": "web-panel"}) == "[web-panel] "
|
|
339
|
+
assert _origin_tag({"origin": "terminal"}) == "" # own surface
|
|
340
|
+
assert _origin_tag({"origin": ""}) == ""
|
|
341
|
+
assert _origin_tag({}) == ""
|
|
187
342
|
|
|
188
343
|
|
|
189
344
|
def test_action_frame_maps_to_feed():
|
|
@@ -260,6 +415,36 @@ def test_summary_from_facts_pluralizes_with_entity_kind_when_present():
|
|
|
260
415
|
assert _summary_from_facts({"count": 3, "entity_kind": "message"}) == "3 messages"
|
|
261
416
|
|
|
262
417
|
|
|
418
|
+
def test_render_foreign_frame_is_display_only_and_never_raises():
|
|
419
|
+
# Cross-surface frames (Telegram/panel steering) get ONE tagged line and
|
|
420
|
+
# NOTHING else. The render side effect must never break the safety
|
|
421
|
+
# `continue` in session.run(): a sink without foreign_turn drops the line;
|
|
422
|
+
# a sink whose foreign_turn raises is swallowed.
|
|
423
|
+
from webbee.frames import render_foreign_frame
|
|
424
|
+
|
|
425
|
+
class Rec:
|
|
426
|
+
def __init__(self): self.calls = []
|
|
427
|
+
def foreign_turn(self, surface, role, text): self.calls.append((surface, role, text))
|
|
428
|
+
|
|
429
|
+
r = Rec()
|
|
430
|
+
render_foreign_frame({"type": "tool_request", "tool": "bash", "origin": "web-panel"}, r)
|
|
431
|
+
assert r.calls == [("web-panel", "assistant", "running bash")]
|
|
432
|
+
render_foreign_frame({"type": "usage", "tokens": 1, "origin": "telegram"}, r)
|
|
433
|
+
assert len(r.calls) == 1 # nothing meaningful to show -> skipped
|
|
434
|
+
render_foreign_frame({"type": "final", "text": "", "origin": "telegram"}, r)
|
|
435
|
+
assert len(r.calls) == 1 # empty final text -> skipped
|
|
436
|
+
render_foreign_frame({"type": "progress", "llm_text": "planning", "origin": "telegram"}, r)
|
|
437
|
+
assert r.calls[-1] == ("telegram", "assistant", "planning")
|
|
438
|
+
|
|
439
|
+
class Bare: # minimal sink (no foreign_turn) -> no-op
|
|
440
|
+
pass
|
|
441
|
+
render_foreign_frame({"type": "final", "text": "x", "origin": "telegram"}, Bare())
|
|
442
|
+
|
|
443
|
+
class Boom:
|
|
444
|
+
def foreign_turn(self, *a): raise RuntimeError("ui bug")
|
|
445
|
+
render_foreign_frame({"type": "final", "text": "x", "origin": "telegram"}, Boom())
|
|
446
|
+
|
|
447
|
+
|
|
263
448
|
def test_handle_step_started_calls_tool_start_once():
|
|
264
449
|
from webbee.frames import handle_step_started
|
|
265
450
|
sink = RecSink()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from webbee.thread import fetch_recent_thread, truncate_for_display
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Cfg:
|
|
9
|
+
api_url = "http://x"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
async def _tp():
|
|
13
|
+
return "tok"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_fetch_recent_thread_gets_right_path_and_bearer(monkeypatch):
|
|
17
|
+
seen = {}
|
|
18
|
+
|
|
19
|
+
class _Resp:
|
|
20
|
+
def raise_for_status(self):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
def json(self):
|
|
24
|
+
return {"messages": [{"role": "user", "content": "hi", "surface": "telegram"}]}
|
|
25
|
+
|
|
26
|
+
class FakeAsyncClient:
|
|
27
|
+
def __init__(self, *a, **kw):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
async def __aenter__(self):
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
async def __aexit__(self, *a):
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
async def get(self, path, headers=None, **kw):
|
|
37
|
+
seen["path"] = path
|
|
38
|
+
seen["headers"] = headers
|
|
39
|
+
return _Resp()
|
|
40
|
+
|
|
41
|
+
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
|
42
|
+
out = asyncio.run(fetch_recent_thread(_Cfg(), _tp, "marathon-user-1-rboot"))
|
|
43
|
+
assert seen["path"] == "/v1/agent/sessions/marathon-user-1-rboot/thread"
|
|
44
|
+
assert seen["headers"] == {"Authorization": "Bearer tok"}
|
|
45
|
+
assert out == [{"role": "user", "content": "hi", "surface": "telegram"}]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_fetch_recent_thread_empty_response_returns_empty_list(monkeypatch):
|
|
49
|
+
class _Resp:
|
|
50
|
+
def raise_for_status(self):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def json(self):
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
class FakeAsyncClient:
|
|
57
|
+
def __init__(self, *a, **kw):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
async def __aenter__(self):
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
async def __aexit__(self, *a):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
async def get(self, path, headers=None, **kw):
|
|
67
|
+
return _Resp()
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
|
70
|
+
assert asyncio.run(fetch_recent_thread(_Cfg(), _tp, "marathon-user-1-rboot")) == []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_fetch_recent_thread_propagates_network_error():
|
|
74
|
+
# No transport mocked -- unreachable host raises. This module never
|
|
75
|
+
# swallows errors itself; the caller (`_boot`) owns the best-effort
|
|
76
|
+
# try/except (same division of labor as remote.py + get_step_detail's
|
|
77
|
+
# non-swallowing siblings in this package).
|
|
78
|
+
class _UnreachableCfg:
|
|
79
|
+
api_url = "http://127.0.0.1:1"
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
asyncio.run(fetch_recent_thread(_UnreachableCfg(), _tp, "marathon-user-1-rboot"))
|
|
83
|
+
assert False, "expected a network error to propagate"
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_fetch_recent_thread_raises_on_http_error(monkeypatch):
|
|
89
|
+
class _Resp:
|
|
90
|
+
def raise_for_status(self):
|
|
91
|
+
raise httpx.HTTPStatusError("boom", request=None, response=None)
|
|
92
|
+
|
|
93
|
+
class FakeAsyncClient:
|
|
94
|
+
def __init__(self, *a, **kw):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self):
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
async def __aexit__(self, *a):
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
async def get(self, path, headers=None, **kw):
|
|
104
|
+
return _Resp()
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
|
107
|
+
try:
|
|
108
|
+
asyncio.run(fetch_recent_thread(_Cfg(), _tp, "marathon-user-1-rboot"))
|
|
109
|
+
assert False, "expected raise_for_status's error to propagate"
|
|
110
|
+
except httpx.HTTPStatusError:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_truncate_for_display_short_text_unchanged():
|
|
115
|
+
assert truncate_for_display("hello") == "hello"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_truncate_for_display_none_becomes_empty_string():
|
|
119
|
+
assert truncate_for_display(None) == ""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_truncate_for_display_long_text_capped_with_ellipsis():
|
|
123
|
+
text = "a" * 1000
|
|
124
|
+
out = truncate_for_display(text)
|
|
125
|
+
assert len(out) == 401 # 400 chars + ellipsis
|
|
126
|
+
assert out.endswith("…")
|
|
127
|
+
assert out[:400] == "a" * 400
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_truncate_for_display_respects_custom_limit():
|
|
131
|
+
assert truncate_for_display("abcdef", limit=3) == "abc…"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_conversational_text_plain_message_untouched():
|
|
135
|
+
from webbee.thread import conversational_text
|
|
136
|
+
assert conversational_text("почини тесты") == "почини тесты"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_conversational_text_pure_tool_traffic_skipped():
|
|
140
|
+
from webbee.thread import conversational_text
|
|
141
|
+
assert conversational_text('[tool_result] {"content": "COMPILE_OK", "ok": true}') == ""
|
|
142
|
+
assert conversational_text('[tool_use bash] {"command": "pytest -q"}') == ""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_conversational_text_narration_kept_tool_tail_cut():
|
|
146
|
+
from webbee.thread import conversational_text
|
|
147
|
+
mixed = 'Всё компилируется чисто. Теперь коммичу.\n[tool_use bash] {"command": "git push"}'
|
|
148
|
+
assert conversational_text(mixed) == "Всё компилируется чисто. Теперь коммичу."
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_conversational_text_none_is_empty():
|
|
152
|
+
from webbee.thread import conversational_text
|
|
153
|
+
assert conversational_text(None) == ""
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|