webbee 0.3.3__tar.gz → 0.3.5__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.3 → webbee-0.3.5}/CHANGELOG.md +18 -0
- {webbee-0.3.3 → webbee-0.3.5}/PKG-INFO +1 -1
- {webbee-0.3.3 → webbee-0.3.5}/pyproject.toml +1 -1
- webbee-0.3.5/src/webbee/__init__.py +1 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/commands.py +3 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/frames.py +55 -0
- webbee-0.3.5/src/webbee/remote.py +41 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/render.py +11 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/repl.py +33 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/session.py +26 -10
- webbee-0.3.5/src/webbee/thread.py +29 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_commands.py +12 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_render.py +28 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_repl.py +129 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_session.py +193 -8
- webbee-0.3.5/tests/test_thread.py +131 -0
- webbee-0.3.3/src/webbee/__init__.py +0 -1
- {webbee-0.3.3 → webbee-0.3.5}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/.gitignore +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/LICENSE +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/README.md +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/install.sh +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/account.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/cli.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/config.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/details.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/events.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/indexer.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/models.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/query.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/service.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/store.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/repo.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/sessions.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/stream.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/tools.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/tui.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/src/webbee/update.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/__init__.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_account.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_cli.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_clipboard.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_config.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_details.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_events.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_query.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_service.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_store.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_marathon.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_packaging.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_repo.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_sessions.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_stream.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_tools.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_tui.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_update.py +0 -0
- {webbee-0.3.3 → webbee-0.3.5}/tests/test_version.py +0 -0
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.5
|
|
4
|
+
|
|
5
|
+
- Surface-aware conversation: turns steered from Telegram/the panel render
|
|
6
|
+
live in the terminal tagged by origin (`[telegram]` / `[web-panel]`) —
|
|
7
|
+
the terminal stays the session's sole executor, foreign turns are
|
|
8
|
+
display-only and are never executed locally.
|
|
9
|
+
- Boot replay: opening the terminal replays the recent conversation
|
|
10
|
+
(including cross-surface turns) with origin tags, then a "— live —"
|
|
11
|
+
divider. Best-effort — a network failure never blocks boot.
|
|
12
|
+
|
|
13
|
+
## 0.3.4
|
|
14
|
+
|
|
15
|
+
- `/notify [tg|panel|both|off]` — mirror this coding session to Telegram
|
|
16
|
+
and/or the panel, and let either surface steer it back (approve/deny
|
|
17
|
+
prompts, follow-up turns). No argument shows the current routing. Talks to
|
|
18
|
+
the gateway's per-session remote-control state; a network hiccup notes
|
|
19
|
+
cleanly instead of crashing the dock.
|
|
20
|
+
|
|
3
21
|
## 0.3.3
|
|
4
22
|
|
|
5
23
|
- Mouse-garbage / dead-keyboard fix (Linux, occasionally macOS): the dock now
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.5"
|
|
@@ -16,6 +16,7 @@ _HELP = """Commands:
|
|
|
16
16
|
/sessions list active sessions (this + other devices)
|
|
17
17
|
/sessions revoke <#> revoke a session by its number
|
|
18
18
|
/logout-others sign out every session except this one
|
|
19
|
+
/notify [tg|panel|both|off] mirror this session to Telegram/panel & steer it back
|
|
19
20
|
/exit (=/quit) quit"""
|
|
20
21
|
|
|
21
22
|
|
|
@@ -85,6 +86,8 @@ def dispatch(line: str, ctx: CommandContext) -> SlashResult:
|
|
|
85
86
|
return SlashResult(handled=True, action="checkpoints")
|
|
86
87
|
if cmd == "/rollback":
|
|
87
88
|
return SlashResult(handled=True, action="rollback", arg=" ".join(args))
|
|
89
|
+
if cmd == "/notify":
|
|
90
|
+
return SlashResult(handled=True, action="notify", arg=(args[0].lower() if args else ""))
|
|
88
91
|
if cmd == "/mode":
|
|
89
92
|
if not args:
|
|
90
93
|
return SlashResult(handled=True, action="mode", new_mode=None,
|
|
@@ -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
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Remote-control client — read/set this coding session's mirror+steer routing
|
|
2
|
+
(Telegram / panel) via the gateway. Best-effort: network errors never raise."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_ARG_TO_STATE = {
|
|
6
|
+
"tg": {"enabled": True, "mirror": ["telegram"], "steer": ["telegram"]},
|
|
7
|
+
"panel": {"enabled": True, "mirror": ["panel"], "steer": []},
|
|
8
|
+
"both": {"enabled": True, "mirror": ["telegram", "panel"], "steer": ["telegram"]},
|
|
9
|
+
"off": {"enabled": False, "mirror": [], "steer": []},
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def get_remote(cfg, token_provider, session_id: str) -> dict:
|
|
14
|
+
import httpx
|
|
15
|
+
token = await token_provider()
|
|
16
|
+
async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
|
|
17
|
+
r = await c.get(f"/v1/agent/sessions/{session_id}/remote",
|
|
18
|
+
headers={"Authorization": f"Bearer {token}"})
|
|
19
|
+
r.raise_for_status()
|
|
20
|
+
return (r.json() or {}).get("state", {})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def set_remote(cfg, token_provider, session_id: str, arg: str) -> dict:
|
|
24
|
+
import httpx
|
|
25
|
+
body = _ARG_TO_STATE.get(arg)
|
|
26
|
+
if body is None:
|
|
27
|
+
raise ValueError(arg)
|
|
28
|
+
token = await token_provider()
|
|
29
|
+
async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
|
|
30
|
+
r = await c.put(f"/v1/agent/sessions/{session_id}/remote", json=body,
|
|
31
|
+
headers={"Authorization": f"Bearer {token}"})
|
|
32
|
+
r.raise_for_status()
|
|
33
|
+
return (r.json() or {}).get("state", {})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def describe(state: dict) -> str:
|
|
37
|
+
if not state.get("enabled"):
|
|
38
|
+
return "Remote control: OFF."
|
|
39
|
+
m = ", ".join(state.get("mirror", [])) or "—"
|
|
40
|
+
s = ", ".join(state.get("steer", [])) or "—"
|
|
41
|
+
return f"Remote control: ON · mirror → {m} · steer ← {s}"
|
|
@@ -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()
|
|
@@ -238,6 +238,24 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
238
238
|
_r = await asyncio.to_thread(shadow.rollback, res.arg)
|
|
239
239
|
_sink.note(str(_r.get("content", "")))
|
|
240
240
|
return "continue"
|
|
241
|
+
if res.action == "notify":
|
|
242
|
+
from webbee import remote as _remote
|
|
243
|
+
sid = getattr(agent, "session_id", "")
|
|
244
|
+
if not sid:
|
|
245
|
+
_sink.note("Start a coding turn first, then /notify to route it.")
|
|
246
|
+
return "continue"
|
|
247
|
+
try:
|
|
248
|
+
if res.arg in ("tg", "panel", "both", "off"):
|
|
249
|
+
st = await _remote.set_remote(cfg, token_provider, sid, res.arg)
|
|
250
|
+
elif res.arg:
|
|
251
|
+
_sink.note("Usage: /notify [tg|panel|both|off]")
|
|
252
|
+
return "continue"
|
|
253
|
+
else:
|
|
254
|
+
st = await _remote.get_remote(cfg, token_provider, sid)
|
|
255
|
+
_sink.note(_remote.describe(st))
|
|
256
|
+
except Exception as e:
|
|
257
|
+
_sink.note(f"Remote control unavailable: {type(e).__name__}")
|
|
258
|
+
return "continue"
|
|
241
259
|
if res.action == "clear":
|
|
242
260
|
_sink.clear()
|
|
243
261
|
_sink.note(res.message)
|
|
@@ -273,6 +291,21 @@ async def run_repl(cfg, mode: str = "default", *, once: bool = False, sink=None,
|
|
|
273
291
|
account = await account_fetcher(cfg, token_provider)
|
|
274
292
|
state["logged_in"] = account.signed_in
|
|
275
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 fetch_recent_thread, truncate_for_display
|
|
300
|
+
_iid = await ImperalClient(cfg, token_provider).whoami()
|
|
301
|
+
_msgs = await fetch_recent_thread(cfg, token_provider, f"marathon-{_iid}-rboot")
|
|
302
|
+
for _m in _msgs[-40:]:
|
|
303
|
+
_sink.foreign_turn(_m.get("surface", "terminal"), _m.get("role", ""),
|
|
304
|
+
truncate_for_display(_m.get("content", "")))
|
|
305
|
+
if _msgs:
|
|
306
|
+
_sink.note("— live —")
|
|
307
|
+
except Exception:
|
|
308
|
+
pass # replay is best-effort; never block boot
|
|
276
309
|
if cfg.intel_enabled:
|
|
277
310
|
# Off-loop build (indexing does sync file I/O + subprocess). Any
|
|
278
311
|
# 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,29 @@
|
|
|
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
|
+
|
|
15
|
+
async def fetch_recent_thread(cfg, token_provider, session_id: str) -> list[dict]:
|
|
16
|
+
import httpx
|
|
17
|
+
token = await token_provider()
|
|
18
|
+
async with httpx.AsyncClient(base_url=cfg.api_url, timeout=10) as c:
|
|
19
|
+
r = await c.get(f"/v1/agent/sessions/{session_id}/thread",
|
|
20
|
+
headers={"Authorization": f"Bearer {token}"})
|
|
21
|
+
r.raise_for_status()
|
|
22
|
+
return (r.json() or {}).get("messages", [])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def truncate_for_display(text, limit: int = _DISPLAY_LIMIT) -> str:
|
|
26
|
+
"""Cap one replayed message's text so a single huge assistant reply can't
|
|
27
|
+
flood the boot screen -- foreign_turn renders one line per message."""
|
|
28
|
+
text = str(text or "")
|
|
29
|
+
return text if len(text) <= limit else text[:limit].rstrip() + "…"
|
|
@@ -140,3 +140,15 @@ def test_help_mentions_the_time_machine():
|
|
|
140
140
|
from webbee.commands import dispatch
|
|
141
141
|
res = dispatch("/help", _anyctx())
|
|
142
142
|
assert "/checkpoints" in res.message and "/rollback" in res.message
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_notify_command_dispatches():
|
|
146
|
+
from webbee.commands import dispatch
|
|
147
|
+
assert dispatch("/notify", _anyctx()).action == "notify"
|
|
148
|
+
r = dispatch("/notify tg", _anyctx())
|
|
149
|
+
assert r.action == "notify" and r.arg == "tg"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_help_mentions_notify():
|
|
153
|
+
from webbee.commands import dispatch
|
|
154
|
+
assert "/notify" in dispatch("/help", _anyctx()).message
|
|
@@ -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): ...
|
|
@@ -404,6 +406,39 @@ def test_boot_skips_intel_when_disabled_in_config(monkeypatch):
|
|
|
404
406
|
assert _SpyAgent.last_intel is None
|
|
405
407
|
|
|
406
408
|
|
|
409
|
+
# ── /notify remote control (Task 8) ───────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
def test_notify_sets_remote_and_shows_state(monkeypatch):
|
|
412
|
+
agent = StepAgent()
|
|
413
|
+
|
|
414
|
+
async def fake_set(cfg, tp, sid, arg):
|
|
415
|
+
assert sid == "sess-1" and arg == "tg"
|
|
416
|
+
return {"enabled": True, "mirror": ["telegram"], "steer": ["telegram"]}
|
|
417
|
+
monkeypatch.setattr("webbee.remote.set_remote", fake_set)
|
|
418
|
+
|
|
419
|
+
sink, agent = _run(read_line=_lines("/notify tg", "/exit"), agent=agent)
|
|
420
|
+
assert any("ON" in n for n in sink.notes)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_notify_without_session_prompts_to_start_a_turn():
|
|
424
|
+
sink, agent = _run(read_line=_lines("/notify tg", "/exit"), agent=StepAgent(session_id=""))
|
|
425
|
+
assert any("Start a coding turn first" in n for n in sink.notes)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def test_notify_bad_arg_shows_usage():
|
|
429
|
+
sink, agent = _run(read_line=_lines("/notify bogus", "/exit"), agent=StepAgent())
|
|
430
|
+
assert any("Usage: /notify" in n for n in sink.notes)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def test_notify_network_error_notes_cleanly(monkeypatch):
|
|
434
|
+
async def fake_set(cfg, tp, sid, arg):
|
|
435
|
+
raise RuntimeError("connection refused")
|
|
436
|
+
monkeypatch.setattr("webbee.remote.set_remote", fake_set)
|
|
437
|
+
|
|
438
|
+
sink, agent = _run(read_line=_lines("/notify tg", "/exit"), agent=StepAgent())
|
|
439
|
+
assert any("Remote control unavailable" in n for n in sink.notes)
|
|
440
|
+
|
|
441
|
+
|
|
407
442
|
def test_watcher_started_at_intel_root_not_workspace(monkeypatch, tmp_path):
|
|
408
443
|
"""F1: IntelService.root is the discovered repo root, which can differ
|
|
409
444
|
from the raw cwd (e.g. the REPL launched from a subdir). The watcher must
|
|
@@ -440,3 +475,97 @@ def test_watcher_started_at_intel_root_not_workspace(monkeypatch, tmp_path):
|
|
|
440
475
|
))
|
|
441
476
|
assert captured.get("root") == str(tmp_path)
|
|
442
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,131 @@
|
|
|
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…"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.3"
|
|
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
|