feat(operator): Phase 6 — relay-mode read (screen) + stop-condition engine (watch)
Completes the remote-drive triad for a broker-owned sandbox the operator can't exec into directly: keys (in) → watch (wait) → screen (out). - _sbx:data PTY-relay frames are absorbed into a capped rolling terminal buffer (not surfaced as chat); `screen` returns it ansi-stripped (or raw). sandbox. strip_ansi handles CSI/OSC/CR noise so output greps cleanly. - `watch` is a formal stop-condition engine: blocks until a regex matches in the screen buffer or chat events, or an idle-quiescence window, or a hard timeout, then reports which fired — the autonomy loop's principled wait. - Skill doctrine updated with the type→wait→read relay loop. 28 offline tests green. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -363,3 +363,67 @@ def test_spawn_refuses_without_objective_or_room_or_budget():
|
||||
"room": {"host": "h", "port": 7}})
|
||||
assert r["ok"] is False and "budget exhausted" in r["error"]
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
# ── Phase 6: relay read (screen) + stop-condition engine (watch) ──────────
|
||||
def test_strip_ansi():
|
||||
assert sbx.strip_ansi("\x1b[31mred\x1b[0m text") == "red text"
|
||||
assert sbx.strip_ansi("a\rb") == "a\nb" # bare CR → newline
|
||||
assert sbx.strip_ansi("\x1b]0;title\x07ok") == "ok" # OSC title strip
|
||||
|
||||
|
||||
def _sbx_data(b, raw: bytes):
|
||||
"""A broker PTY-relay frame carrying raw terminal bytes."""
|
||||
import base64 as _b64
|
||||
payload = json.dumps({"_sbx": "data", "b64": _b64.b64encode(raw).decode()})
|
||||
return _msg_frame(b, "broker", payload)
|
||||
|
||||
|
||||
def test_sbx_data_absorbed_into_screen():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
await b._handle_frame(None, _sbx_data(b, b"\x1b[32mroot@box\x1b[0m:~# ls\r\n"))
|
||||
# PTY relay must NOT surface as a chat message
|
||||
assert [e for e in b.events if e["kind"] == "message"] == []
|
||||
r = await b._op_screen({})
|
||||
assert "root@box:~# ls" in r["text"] # ansi-stripped
|
||||
raw = await b._op_screen({"ansi": True})
|
||||
assert "\x1b[32m" in raw["text"] # raw keeps codes
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_watch_matches_screen():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
|
||||
async def driver():
|
||||
await asyncio.sleep(0.05)
|
||||
await b._handle_frame(None, _sbx_data(b, b"...building...\n"))
|
||||
await asyncio.sleep(0.05)
|
||||
await b._handle_frame(None, _sbx_data(b, b"BUILD SUCCESS\n"))
|
||||
|
||||
watcher = b._op_watch({"for": "BUILD (SUCCESS|FAIL)", "in": "screen",
|
||||
"timeout": 5})
|
||||
res, _ = await asyncio.gather(watcher, driver())
|
||||
assert res["reason"] == "match" and res["match"] == "BUILD SUCCESS"
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_watch_matches_event_and_idle_and_timeout():
|
||||
async def go():
|
||||
b = _bridge("oracle")
|
||||
|
||||
async def speak():
|
||||
await asyncio.sleep(0.05)
|
||||
await b._emit("message", **{"from": "alice"}, text="please stop now",
|
||||
addressed=True)
|
||||
res, _ = await asyncio.gather(
|
||||
b._op_watch({"for": r"\bstop\b", "in": "events", "timeout": 5}), speak())
|
||||
assert res["reason"] == "match" and res["event"]["from"] == "alice"
|
||||
# idle: nothing happens → quiescence fires fast
|
||||
res = await b._op_watch({"idle": 0.2, "timeout": 5})
|
||||
assert res["reason"] == "idle"
|
||||
# timeout: a pattern that never appears
|
||||
res = await b._op_watch({"for": "never-ever", "timeout": 0.3})
|
||||
assert res["reason"] == "timeout" and res["matched"] is False
|
||||
asyncio.run(go())
|
||||
|
||||
Reference in New Issue
Block a user