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.
Files changed (78) hide show
  1. {webbee-0.3.4 → webbee-0.3.6}/CHANGELOG.md +17 -0
  2. {webbee-0.3.4 → webbee-0.3.6}/PKG-INFO +1 -1
  3. {webbee-0.3.4 → webbee-0.3.6}/pyproject.toml +1 -1
  4. webbee-0.3.6/src/webbee/__init__.py +1 -0
  5. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/frames.py +55 -0
  6. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/render.py +11 -0
  7. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/repl.py +21 -0
  8. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/session.py +26 -10
  9. webbee-0.3.6/src/webbee/thread.py +49 -0
  10. {webbee-0.3.4 → webbee-0.3.6}/tests/test_render.py +28 -0
  11. {webbee-0.3.4 → webbee-0.3.6}/tests/test_repl.py +96 -0
  12. {webbee-0.3.4 → webbee-0.3.6}/tests/test_session.py +193 -8
  13. webbee-0.3.6/tests/test_thread.py +153 -0
  14. webbee-0.3.4/src/webbee/__init__.py +0 -1
  15. {webbee-0.3.4 → webbee-0.3.6}/.github/workflows/publish.yml +0 -0
  16. {webbee-0.3.4 → webbee-0.3.6}/.gitignore +0 -0
  17. {webbee-0.3.4 → webbee-0.3.6}/LICENSE +0 -0
  18. {webbee-0.3.4 → webbee-0.3.6}/README.md +0 -0
  19. {webbee-0.3.4 → webbee-0.3.6}/install.sh +0 -0
  20. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/account.py +0 -0
  21. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/banner_art.py +0 -0
  22. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/checkpoints.py +0 -0
  23. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/cli.py +0 -0
  24. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/clipboard.py +0 -0
  25. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/commands.py +0 -0
  26. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/config.py +0 -0
  27. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/details.py +0 -0
  28. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/events.py +0 -0
  29. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/__init__.py +0 -0
  30. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/chunker.py +0 -0
  31. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/embed.py +0 -0
  32. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/graph.py +0 -0
  33. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/indexer.py +0 -0
  34. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/models.py +0 -0
  35. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/query.py +0 -0
  36. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/service.py +0 -0
  37. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/store.py +0 -0
  38. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/vectors.py +0 -0
  39. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/intel/watch.py +0 -0
  40. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/output_pane.py +0 -0
  41. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/remote.py +0 -0
  42. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/repo.py +0 -0
  43. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/sessions.py +0 -0
  44. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/stream.py +0 -0
  45. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/tools.py +0 -0
  46. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/tui.py +0 -0
  47. {webbee-0.3.4 → webbee-0.3.6}/src/webbee/update.py +0 -0
  48. {webbee-0.3.4 → webbee-0.3.6}/tests/__init__.py +0 -0
  49. {webbee-0.3.4 → webbee-0.3.6}/tests/test_account.py +0 -0
  50. {webbee-0.3.4 → webbee-0.3.6}/tests/test_checkpoints.py +0 -0
  51. {webbee-0.3.4 → webbee-0.3.6}/tests/test_cli.py +0 -0
  52. {webbee-0.3.4 → webbee-0.3.6}/tests/test_clipboard.py +0 -0
  53. {webbee-0.3.4 → webbee-0.3.6}/tests/test_commands.py +0 -0
  54. {webbee-0.3.4 → webbee-0.3.6}/tests/test_config.py +0 -0
  55. {webbee-0.3.4 → webbee-0.3.6}/tests/test_cpc_contract_stable.py +0 -0
  56. {webbee-0.3.4 → webbee-0.3.6}/tests/test_details.py +0 -0
  57. {webbee-0.3.4 → webbee-0.3.6}/tests/test_events.py +0 -0
  58. {webbee-0.3.4 → webbee-0.3.6}/tests/test_freeze_fix.py +0 -0
  59. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_chunker.py +0 -0
  60. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_embed.py +0 -0
  61. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_graph.py +0 -0
  62. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_indexer.py +0 -0
  63. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_no_numpy.py +0 -0
  64. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_query.py +0 -0
  65. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_service.py +0 -0
  66. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_store.py +0 -0
  67. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_vectors.py +0 -0
  68. {webbee-0.3.4 → webbee-0.3.6}/tests/test_intel_watch.py +0 -0
  69. {webbee-0.3.4 → webbee-0.3.6}/tests/test_marathon.py +0 -0
  70. {webbee-0.3.4 → webbee-0.3.6}/tests/test_packaging.py +0 -0
  71. {webbee-0.3.4 → webbee-0.3.6}/tests/test_repo.py +0 -0
  72. {webbee-0.3.4 → webbee-0.3.6}/tests/test_sessions.py +0 -0
  73. {webbee-0.3.4 → webbee-0.3.6}/tests/test_stream.py +0 -0
  74. {webbee-0.3.4 → webbee-0.3.6}/tests/test_tools.py +0 -0
  75. {webbee-0.3.4 → webbee-0.3.6}/tests/test_tui.py +0 -0
  76. {webbee-0.3.4 → webbee-0.3.6}/tests/test_tui_hardening.py +0 -0
  77. {webbee-0.3.4 → webbee-0.3.6}/tests/test_update.py +0 -0
  78. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webbee
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.4"
3
+ version = "0.3.6"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- # Ignore actionable frames from a DIFFERENT turn on the shared
222
- # persistent stream (task_id absent on legacy kernels -> not filtered).
223
- if _ftid and self._task_id and _ftid != self._task_id and ftype in (
224
- "tool_request", "confirm_request", "final",
225
- "marathon_complete", "panel_release_required"):
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
- (getattr(sink, "thinking", None) or sink.progress)(_progress_text(frame))
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
- sink.progress(_progress_text(frame))
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
- return frame.get("text", "")
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 ignored), THEN
141
- # this turn's own final (must be honored).
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, *a): ...
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
- result = asyncio.run(sess.run("do it", RecSink()))
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
- assert result == "done" # ended on OUR final, not the foreign one
185
- assert executor_calls == [] # foreign tool_request never dispatched
186
- assert posts == ["/v1/agent/sessions"] # no /result POST for the ignored frames
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