Agents can drive the running grr TUI: a private unix control socket in --tui and six tui_* tools (screen, keys, type, click, goto, result) whose calls can be async jobs; documented in grrtui.md, 9 tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,15 @@ Mouse works everywhere: click rows, buttons, tabs, chips and tiles, wheel scroll
|
||||
In the terminal of a bot computer every other key goes to the shell. Tab still moves focus (the framework's rule for
|
||||
every terminal widget, see [tui.md](tui.md)), so shell tab completion is not available there.
|
||||
|
||||
## Agents driving the TUI
|
||||
|
||||
While `--tui` runs it listens on a unix socket, `<TAI_HOME>/tui.sock` (or `TAI_TUI_SOCK`), mode 0600, one JSON line in and
|
||||
one out. Any tai agent on the machine then has six tools (hidden while no TUI runs): `tui_screen` (route, size, focus and
|
||||
the screen text; `until` waits for text to appear), `tui_keys`, `tui_type`, `tui_click`, `tui_goto` and `tui_result`.
|
||||
Every action runs as a job on the UI loop. With `async` the call returns a job id at once and `tui_result` collects it,
|
||||
waiting up to `wait` seconds; without it the call waits for its own job. Keys are the framework's names (`down`, `enter`,
|
||||
`escape`, `ctrl+p`, a character); clicks are screen cells. A second TUI does not take over the socket of the first.
|
||||
|
||||
## Events
|
||||
|
||||
The server sends `bot.update`, `bot.delete`, `chat.update` and `chat.delete` (the lists are read again), `status`
|
||||
@@ -177,6 +186,11 @@ Every limit is a constant in `tai.py`.
|
||||
| `GRR_DRAFT_KEEP` | 64 | chats with a kept draft |
|
||||
| `GRR_LIST_MORE` | 12 | rows Show more adds |
|
||||
| `GRR_MENU_MAX_H` | 14 | rows of a menu before it scrolls |
|
||||
| `GRR_CONTROL_JOBS` | 64 | control jobs kept (finished ones drop oldest first) |
|
||||
| `GRR_CONTROL_LINE` | 65536 | bytes of one control request |
|
||||
| `GRR_CONTROL_WAIT` | 10 | seconds a control call waits for its job by default |
|
||||
| `GRR_CONTROL_WAIT_MAX` | 60 | longest wait a control call may ask for |
|
||||
| `GRR_CONTROL_KEYS_MAX` | 200 | keys in one `tui_keys` call |
|
||||
|
||||
## Measured
|
||||
|
||||
|
||||
@@ -2087,6 +2087,12 @@ class Glow:
|
||||
"plexer_read": ("Read", ident, ""),
|
||||
"plexer_type": ("Type", ident, ""),
|
||||
"plexer_status": ("Plexer", "", ""),
|
||||
"tui_screen": ("TUI", "screen", ""),
|
||||
"tui_keys": ("TUI", "keys", ""),
|
||||
"tui_type": ("TUI", "type", ""),
|
||||
"tui_click": ("TUI", "click", ""),
|
||||
"tui_goto": ("TUI", "goto", ""),
|
||||
"tui_result": ("TUI", "result", ""),
|
||||
"fork": ("Fork", task, ""),
|
||||
"poll": ("Poll", ident, ""),
|
||||
"ask_user": ("Ask", head("question", 60), ""),
|
||||
@@ -6161,6 +6167,12 @@ TOOL_SCHEMAS = [
|
||||
tool_schema("plexer_read", "Only available when a tai-managed terminal session is active. Reads back the last N lines the session's shell has produced (its actual rendered output, not a tool-call summary).", {"lines": {"type": "integer"}}, []),
|
||||
tool_schema("plexer_type", "Only available when a tai-managed terminal session is active. Types text into the session one character at a time with a delay between keystrokes, for a demo/animated-typing effect, then presses Enter unless enter is set to false.", {"text": {"type": "string"}, "delay": {"type": "number"}, "enter": {"type": "boolean"}}, ["text"]),
|
||||
tool_schema("plexer_status", "Only available when a tai-managed terminal session is active. Reports which backend is managing the current session (tai's own native pty wrapper, or tmux) and backend-specific details (pid/socket/log, or pane id).", {}, []),
|
||||
tool_schema("tui_screen", "Only available while a grr TUI (tai.py --tui) runs on this machine. Returns what its screen shows now (route, size, focus, the text). Set until to wait for that text to appear (timeout seconds), set async to get a job id at once and collect it with tui_result.", {"until": {"type": "string"}, "timeout": {"type": "integer"}, "async": {"type": "boolean"}}, []),
|
||||
tool_schema("tui_keys", "Only available while a grr TUI runs. Presses keys in it like a user: names such as down, up, enter, escape, space, tab, f1, ctrl+n, ctrl+p, or single characters. Async calls return a job id for tui_result.", {"keys": {"type": "array", "items": {"type": "string"}}, "async": {"type": "boolean"}}, ["keys"]),
|
||||
tool_schema("tui_type", "Only available while a grr TUI runs. Types text into the focused box (a newline presses Enter). Async calls return a job id for tui_result.", {"text": {"type": "string"}, "async": {"type": "boolean"}}, ["text"]),
|
||||
tool_schema("tui_click", "Only available while a grr TUI runs. Clicks the cell at column x, row y (0 based, as in tui_screen text). Async calls return a job id for tui_result.", {"x": {"type": "integer"}, "y": {"type": "integer"}, "async": {"type": "boolean"}}, ["x", "y"]),
|
||||
tool_schema("tui_goto", "Only available while a grr TUI runs. Opens a route such as /chat/new, /bots, /settings, /automations, /bot/<id>. Async calls return a job id for tui_result.", {"route": {"type": "string"}, "async": {"type": "boolean"}}, ["route"]),
|
||||
tool_schema("tui_result", "Only available while a grr TUI runs. Collects an async tui_* job by id, waiting up to wait seconds; the state is running, done or error.", {"id": {"type": "integer"}, "wait": {"type": "integer"}}, ["id"]),
|
||||
tool_schema("fork", "Spawn a background subagent with its own context that works while you continue. Returns an agent id immediately. Collect its summarized result with poll. Subagents get a smaller step budget and a time limit.", {"task": {"type": "string"}, "timeout": {"type": "integer"}, "profile": {"type": "string"}}, ["task"]),
|
||||
tool_schema("poll", "Collect a background subagent result by id. Waits up to wait seconds, then reports running or the result.", {"id": {"type": "integer"}, "wait": {"type": "integer"}}, ["id"]),
|
||||
tool_schema("ask_user", "Ask the user one question mid-turn and wait for their answer. Use this instead of ending your turn with questions in prose: an empty tool_calls list ends the turn at once and one-shot runs then exit, so prose questions are never answered. Pass the question plus optional options; the user picks an option (by number or text) or types free input, and their answer comes back as the tool result. Only for real blockers a human must decide; in autonomous mode and for background workers it is unavailable, so decide yourself and state the assumption.", {"question": {"type": "string"}, "options": {"type": "array"}}, ["question"]),
|
||||
@@ -6204,6 +6216,12 @@ TOOL_ENV_GATES = {
|
||||
"plexer_read": lambda: PlexerControllerFactory.detect() is not None,
|
||||
"plexer_type": lambda: PlexerControllerFactory.detect() is not None,
|
||||
"plexer_status": lambda: PlexerControllerFactory.detect() is not None,
|
||||
"tui_screen": lambda: os.path.exists(grr_control_path()),
|
||||
"tui_keys": lambda: os.path.exists(grr_control_path()),
|
||||
"tui_type": lambda: os.path.exists(grr_control_path()),
|
||||
"tui_click": lambda: os.path.exists(grr_control_path()),
|
||||
"tui_goto": lambda: os.path.exists(grr_control_path()),
|
||||
"tui_result": lambda: os.path.exists(grr_control_path()),
|
||||
}
|
||||
|
||||
|
||||
@@ -6232,6 +6250,12 @@ TOOL_TAGS = {
|
||||
"plexer_read": ("plexer", "terminal", "tmux", "pane", "read", "capture"),
|
||||
"plexer_type": ("plexer", "terminal", "tmux", "type", "animate", "keystroke", "demo"),
|
||||
"plexer_status": ("plexer", "terminal", "tmux", "status", "session"),
|
||||
"tui_screen": ("tui", "gui", "screen", "interface", "ui"),
|
||||
"tui_keys": ("tui", "gui", "keys", "press", "keyboard", "navigate"),
|
||||
"tui_type": ("tui", "gui", "type", "input", "text"),
|
||||
"tui_click": ("tui", "gui", "click", "mouse"),
|
||||
"tui_goto": ("tui", "gui", "open", "route", "navigate", "goto"),
|
||||
"tui_result": ("tui", "gui", "result", "async", "job"),
|
||||
"fork": ("fork", "subagent", "background", "parallel", "delegate", "spawn"),
|
||||
"poll": ("poll", "collect"),
|
||||
"ask_user": ("ask", "question", "clarify", "confirm", "choose", "answer", "input"),
|
||||
@@ -13376,6 +13400,12 @@ class Tools:
|
||||
"plexer_read": self.run_plexer_read,
|
||||
"plexer_type": self.run_plexer_type,
|
||||
"plexer_status": self.run_plexer_status,
|
||||
"tui_screen": lambda args: self.run_tui("screen", args),
|
||||
"tui_keys": lambda args: self.run_tui("key", args),
|
||||
"tui_type": lambda args: self.run_tui("type", args),
|
||||
"tui_click": lambda args: self.run_tui("click", args),
|
||||
"tui_goto": lambda args: self.run_tui("goto", args),
|
||||
"tui_result": lambda args: self.run_tui("result", args),
|
||||
"fork": self.run_fork,
|
||||
"poll": self.run_poll,
|
||||
"ask_user": self.run_ask_user,
|
||||
@@ -14110,6 +14140,14 @@ class Tools:
|
||||
return "error: " + str(exc)
|
||||
return "typed %d chars via %s backend at %.2fs/char" % (len(text), controller.kind, delay)
|
||||
|
||||
def run_tui(self, op, args):
|
||||
"""Drive the running grr TUI through its control socket (see GrrControl)."""
|
||||
payload = {"op": op}
|
||||
payload.update({k: v for k, v in args.items() if v is not None})
|
||||
if op == "screen" and args.get("until"):
|
||||
payload["wait"] = (args.get("timeout") or GRR_CONTROL_WAIT) + 2
|
||||
return json.dumps(grr_control_call(payload))
|
||||
|
||||
def run_plexer_status(self, args):
|
||||
controller = PlexerControllerFactory.detect()
|
||||
if controller is None:
|
||||
@@ -30074,6 +30112,240 @@ class GrrPaneDeploy(GrrPaneTab):
|
||||
+ [GrrButtonItem(t("deploy.button"), lambda: None, "secondary", inline=True, disabled=True), GrrNote(t("deploy.note"), center=True)])
|
||||
|
||||
|
||||
GRR_CONTROL_JOBS = 64
|
||||
GRR_CONTROL_LINE = 65536
|
||||
GRR_CONTROL_WAIT = 10
|
||||
GRR_CONTROL_WAIT_MAX = 60
|
||||
GRR_CONTROL_KEYS_MAX = 200
|
||||
|
||||
|
||||
def grr_control_path():
|
||||
"""The control socket of the running grr TUI: TAI_TUI_SOCK or <TAI_HOME>/tui.sock."""
|
||||
home = os.environ.get("TAI_HOME") or os.path.join(os.path.expanduser("~"), ".tai")
|
||||
return os.environ.get("TAI_TUI_SOCK") or os.path.join(home, "tui.sock")
|
||||
|
||||
|
||||
class GrrControl:
|
||||
"""Lets agents drive the running grr TUI over a local unix socket (mode
|
||||
0600), one JSON line in and one out. An operation is a job on the UI
|
||||
loop: `screen`, `key`, `type`, `click`, `goto` and `result`. Every one can
|
||||
be async: the call returns a job id at once and `result` collects it
|
||||
(with a wait). `screen` may wait for text to appear (`until`). At most
|
||||
GRR_CONTROL_JOBS finished jobs are kept."""
|
||||
|
||||
def __init__(self, shell, path=None):
|
||||
self.shell = shell
|
||||
self.path = path or grr_control_path()
|
||||
self.sock = None
|
||||
self.jobs = collections.OrderedDict()
|
||||
self.seq = 0
|
||||
self.lock = threading.Lock()
|
||||
self.thread = None
|
||||
self.stopped = False
|
||||
|
||||
def start(self):
|
||||
if os.path.exists(self.path):
|
||||
probe = socket.socket(socket.AF_UNIX)
|
||||
probe.settimeout(1)
|
||||
try:
|
||||
probe.connect(self.path)
|
||||
probe.close()
|
||||
return False
|
||||
except OSError:
|
||||
os.unlink(self.path)
|
||||
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
||||
self.sock = socket.socket(socket.AF_UNIX)
|
||||
self.sock.bind(self.path)
|
||||
os.chmod(self.path, 0o600)
|
||||
self.sock.listen(8)
|
||||
self.thread = threading.Thread(target=self.accept, name="grr-control", daemon=True)
|
||||
self.thread.start()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
if self.sock is not None:
|
||||
try:
|
||||
self.sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(self.path)
|
||||
except OSError:
|
||||
pass
|
||||
self.sock = None
|
||||
|
||||
def accept(self):
|
||||
while not self.stopped:
|
||||
try:
|
||||
conn, _addr = self.sock.accept()
|
||||
except OSError:
|
||||
return
|
||||
threading.Thread(target=self.serve, args=(conn,), daemon=True).start()
|
||||
|
||||
def serve(self, conn):
|
||||
try:
|
||||
conn.settimeout(GRR_CONTROL_WAIT_MAX + 5)
|
||||
data = b""
|
||||
while b"\n" not in data and len(data) < GRR_CONTROL_LINE:
|
||||
chunk = conn.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
try:
|
||||
request = json.loads(data.decode("utf-8"))
|
||||
reply = self.handle(request)
|
||||
except (ValueError, TypeError) as exc:
|
||||
reply = {"state": "error", "error": "bad request: %s" % exc}
|
||||
conn.sendall((json.dumps(reply) + "\n").encode("utf-8"))
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def handle(self, request):
|
||||
op = str(request.get("op") or "")
|
||||
wait = max(0.0, min(float(request.get("wait", GRR_CONTROL_WAIT)), GRR_CONTROL_WAIT_MAX))
|
||||
if op == "result":
|
||||
with self.lock:
|
||||
job = self.jobs.get(int(request.get("id", 0)))
|
||||
if job is None:
|
||||
return {"state": "error", "error": "no such job"}
|
||||
else:
|
||||
if not hasattr(self, "op_" + op):
|
||||
return {"state": "error", "error": "unknown op %r" % op}
|
||||
job = self.submit(op, request)
|
||||
if request.get("async") and op != "result":
|
||||
return {"id": job["id"], "state": job["state"]}
|
||||
job["event"].wait(wait)
|
||||
return self.view(job)
|
||||
|
||||
def view(self, job):
|
||||
out = {"id": job["id"], "state": job["state"]}
|
||||
if job["state"] == "done":
|
||||
out["result"] = job["result"]
|
||||
elif job["state"] == "error":
|
||||
out["error"] = job["error"]
|
||||
return out
|
||||
|
||||
def submit(self, op, request):
|
||||
with self.lock:
|
||||
self.seq += 1
|
||||
job = {"id": self.seq, "op": op, "state": "running", "event": threading.Event(), "result": None, "error": ""}
|
||||
self.jobs[job["id"]] = job
|
||||
while len(self.jobs) > GRR_CONTROL_JOBS:
|
||||
self.jobs.popitem(last=False)
|
||||
self.shell.app.run_on_loop(lambda: self.run(job, request))
|
||||
return job
|
||||
|
||||
def finish(self, job, result=None, error=""):
|
||||
job["result"], job["error"] = result, error
|
||||
job["state"] = "error" if error else "done"
|
||||
job["event"].set()
|
||||
|
||||
def run(self, job, request):
|
||||
try:
|
||||
outcome = getattr(self, "op_" + job["op"])(job, request)
|
||||
except Exception as exc:
|
||||
self.finish(job, error="%s: %s" % (type(exc).__name__, exc))
|
||||
return
|
||||
if outcome is not self.PENDING:
|
||||
self.finish(job, outcome)
|
||||
|
||||
PENDING = object()
|
||||
|
||||
def screen_info(self):
|
||||
app, shell = self.shell.app, self.shell
|
||||
app.layout()
|
||||
focus = app.focused
|
||||
return {"route": shell.router.current, "cols": app.cols, "rows": app.rows, "signed_in": shell.signed_in,
|
||||
"focus": type(focus).__name__ if focus is not None else "", "screen": app.render_text()}
|
||||
|
||||
def op_screen(self, job, request):
|
||||
until = request.get("until")
|
||||
if not until:
|
||||
return self.screen_info()
|
||||
deadline = time.time() + max(0.0, min(float(request.get("timeout", GRR_CONTROL_WAIT)), GRR_CONTROL_WAIT_MAX))
|
||||
|
||||
def look():
|
||||
info = self.screen_info()
|
||||
if until in info["screen"]:
|
||||
self.finish(job, dict(info, matched=True))
|
||||
elif time.time() >= deadline:
|
||||
self.finish(job, dict(info, matched=False))
|
||||
else:
|
||||
self.shell.app.after(0.2, look)
|
||||
look()
|
||||
return self.PENDING
|
||||
|
||||
@staticmethod
|
||||
def key_event(name):
|
||||
parts = name.split("+")
|
||||
base = parts[-1]
|
||||
mods = parts[:-1]
|
||||
char = {"space": " ", "enter": "\n", "tab": "\t"}.get(base, base if len(base) == 1 else "")
|
||||
if mods and len(base) == 1 and "ctrl" in mods:
|
||||
char = chr(ord(base.lower()) & 0x1f) if base.isalpha() else ""
|
||||
return TuiKey(key=name.lower() if len(base) > 1 or mods else base, char=char, modifiers=mods)
|
||||
|
||||
def op_key(self, job, request):
|
||||
keys = request.get("keys") or []
|
||||
if not isinstance(keys, list) or len(keys) > GRR_CONTROL_KEYS_MAX:
|
||||
raise ValueError("keys must be a list of at most %d names" % GRR_CONTROL_KEYS_MAX)
|
||||
for name in keys:
|
||||
self.shell.app.dispatch(self.key_event(str(name)))
|
||||
self.shell.app.wake()
|
||||
return {"sent": len(keys)}
|
||||
|
||||
def op_type(self, job, request):
|
||||
text = str(request.get("text") or "")[:GRR_CONTROL_KEYS_MAX * 20]
|
||||
for ch in text:
|
||||
name = "enter" if ch == "\n" else ("space" if ch == " " else ch)
|
||||
self.shell.app.dispatch(TuiKey(key=name, char="\n" if ch == "\n" else ch, modifiers=[]))
|
||||
self.shell.app.wake()
|
||||
return {"typed": len(text)}
|
||||
|
||||
def op_click(self, job, request):
|
||||
x, y = int(request["x"]), int(request["y"])
|
||||
app = self.shell.app
|
||||
app.layout()
|
||||
app.dispatch(TuiClick(x=x, y=y, button=0, modifiers=[], pressed=True))
|
||||
app.dispatch(TuiClick(x=x, y=y, button=0, modifiers=[], pressed=False))
|
||||
app.wake()
|
||||
return {"clicked": [x, y]}
|
||||
|
||||
def op_goto(self, job, request):
|
||||
route = str(request.get("route") or "/")
|
||||
if not route.startswith("/"):
|
||||
raise ValueError("a route starts with /")
|
||||
self.shell.router.go(route)
|
||||
return {"route": self.shell.router.current}
|
||||
|
||||
|
||||
def grr_control_call(payload, path=None, timeout=GRR_CONTROL_WAIT_MAX + 10):
|
||||
"""Client side: one request to the running TUI, its reply as a dict."""
|
||||
path = path or grr_control_path()
|
||||
sock = socket.socket(socket.AF_UNIX)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.connect(path)
|
||||
sock.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
data = b""
|
||||
while not data.endswith(b"\n"):
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
except OSError as exc:
|
||||
return {"state": "error", "error": "no running grr TUI answers on %s (%s)" % (path, exc)}
|
||||
finally:
|
||||
sock.close()
|
||||
try:
|
||||
return json.loads(data.decode("utf-8"))
|
||||
except ValueError:
|
||||
return {"state": "error", "error": "unreadable reply"}
|
||||
|
||||
|
||||
GRR_ROUTES = (
|
||||
("/bots", "GrrBotsView"), ("/bot/:id", "GrrProfileView"), ("/bot/:id/instructions", "GrrInstructionsView"), ("/bot/:id/memories", "GrrMemoriesView"),
|
||||
("/memory/:id", "GrrMemoryView"), ("/bot/:id/skills", "GrrSkillsView"), ("/skill/:id", "GrrSkillView"), ("/bot/:id/computer", "GrrComputerView"),
|
||||
@@ -30108,6 +30380,11 @@ def grr_tui():
|
||||
app = GrrTuiApp()
|
||||
app.rows, app.cols = app._winsize()
|
||||
shell = GrrShell(app)
|
||||
control = GrrControl(shell)
|
||||
try:
|
||||
control.start()
|
||||
except OSError as exc:
|
||||
print("tui control socket unavailable: %s" % exc, file=sys.stderr)
|
||||
try:
|
||||
app.mount(shell.root)
|
||||
shell.boot()
|
||||
@@ -30116,6 +30393,7 @@ def grr_tui():
|
||||
print("tui unavailable: %s" % exc, file=sys.stderr)
|
||||
return 2
|
||||
finally:
|
||||
control.stop()
|
||||
shell.stop_stream()
|
||||
|
||||
|
||||
|
||||
+107
@@ -11606,6 +11606,113 @@ class GrrShellTests(GrrThemeIsolation, unittest.TestCase):
|
||||
self.assertFalse(view.pane.is_open)
|
||||
|
||||
|
||||
class GrrControlTests(GrrThemeIsolation, unittest.TestCase):
|
||||
"""Agents drive the running TUI over its unix socket, headless here."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server = GrrFakeServer()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.close()
|
||||
|
||||
def setUp(self):
|
||||
GrrThemeIsolation.setUp(self)
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
session = tai.GrrSession(os.path.join(self.tmp, "g.json"))
|
||||
session.url = self.server.url
|
||||
session.accounts = [{"token": "tok", "username": "r", "email": ""}]
|
||||
session.active = "tok"
|
||||
self.app = tai.GrrTuiApp()
|
||||
self.app.rows, self.app.cols = 40, 120
|
||||
self.shell = tai.GrrShell(self.app, session)
|
||||
self.shell.client.base = self.server.url
|
||||
self.app.mount(self.shell.root)
|
||||
with mock.patch.object(tai.GrrStream, "start", lambda self: None):
|
||||
self.shell.boot()
|
||||
self.path = os.path.join(self.tmp, "tui.sock")
|
||||
self.control = tai.GrrControl(self.shell, self.path)
|
||||
self.assertTrue(self.control.start())
|
||||
self.addCleanup(self.control.stop)
|
||||
|
||||
def call(self, **payload):
|
||||
return tai.grr_control_call(payload, path=self.path, timeout=10)
|
||||
|
||||
def test_the_socket_is_private_and_a_second_tui_does_not_take_it(self):
|
||||
self.assertEqual(oct(os.stat(self.path).st_mode & 0o777), "0o600")
|
||||
self.assertFalse(tai.GrrControl(self.shell, self.path).start())
|
||||
|
||||
def test_screen_goto_type_and_keys_drive_the_real_screen(self):
|
||||
reply = self.call(op="screen")
|
||||
self.assertEqual(reply["state"], "done")
|
||||
self.assertIn("Automations", reply["result"]["screen"])
|
||||
self.assertEqual(self.call(op="goto", route="/chat/new")["result"]["route"], "/chat/new")
|
||||
self.assertEqual(self.call(op="type", text="hello box")["result"]["typed"], 9)
|
||||
shown = self.call(op="screen")["result"]
|
||||
self.assertIn("hello box", shown["screen"])
|
||||
self.assertEqual(shown["focus"], "GrrComposerBox")
|
||||
self.call(op="goto", route="/settings")
|
||||
self.call(op="key", keys=["down", "down", "down", "enter"])
|
||||
self.assertTrue(self.server.seen("PATCH", "/api/me/settings"))
|
||||
|
||||
def test_async_calls_return_an_id_and_result_collects_it(self):
|
||||
job = self.call(op="goto", route="/library", **{"async": True})
|
||||
self.assertIn("id", job)
|
||||
done = self.call(op="result", id=job["id"], wait=5)
|
||||
self.assertEqual((done["state"], done["result"]["route"]), ("done", "/library"))
|
||||
self.assertEqual(self.call(op="result", id=9999)["state"], "error")
|
||||
|
||||
def test_screen_waits_for_text_and_reports_when_it_never_comes(self):
|
||||
stop = threading.Event()
|
||||
|
||||
def pump():
|
||||
while not stop.is_set():
|
||||
self.app._fire_timers(time.time())
|
||||
time.sleep(0.02)
|
||||
thread = threading.Thread(target=pump, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(thread.join)
|
||||
self.addCleanup(stop.set)
|
||||
self.call(op="goto", route="/settings")
|
||||
self.assertTrue(self.call(op="screen", until="settings.auto_review", timeout=3)["result"]["matched"])
|
||||
self.assertFalse(self.call(op="screen", until="no such words", timeout=1)["result"]["matched"])
|
||||
|
||||
def test_clicks_land_on_cells(self):
|
||||
self.call(op="goto", route="/")
|
||||
rows = self.call(op="screen")["result"]["screen"].split("\n")
|
||||
y = next(i for i, line in enumerate(rows) if "Library" in line)
|
||||
self.call(op="click", x=rows[y].index("Library"), y=y)
|
||||
self.assertEqual(self.shell.router.current, "/library")
|
||||
|
||||
def test_bad_requests_are_errors_not_crashes(self):
|
||||
self.assertEqual(self.call(op="nope")["state"], "error")
|
||||
self.assertEqual(self.call(op="goto", route="nowhere")["state"], "error")
|
||||
self.assertEqual(self.call(op="key", keys=["a"] * (tai.GRR_CONTROL_KEYS_MAX + 1))["state"], "error")
|
||||
self.assertEqual(self.call(op="click", x="a", y=1)["state"], "error")
|
||||
|
||||
def test_finished_jobs_are_capped(self):
|
||||
for _ in range(tai.GRR_CONTROL_JOBS + 5):
|
||||
self.call(op="goto", route="/")
|
||||
self.assertEqual(len(self.control.jobs), tai.GRR_CONTROL_JOBS)
|
||||
|
||||
def test_no_tui_means_an_error_reply_and_no_tools(self):
|
||||
reply = tai.grr_control_call({"op": "screen"}, path=os.path.join(self.tmp, "none.sock"))
|
||||
self.assertEqual(reply["state"], "error")
|
||||
with mock.patch.dict(os.environ, {"TAI_TUI_SOCK": os.path.join(self.tmp, "none.sock")}):
|
||||
self.assertFalse(tai.tool_env_ok("tui_screen"))
|
||||
with mock.patch.dict(os.environ, {"TAI_TUI_SOCK": self.path}):
|
||||
self.assertTrue(tai.tool_env_ok("tui_screen"))
|
||||
|
||||
def test_the_agent_tool_talks_to_the_socket(self):
|
||||
with mock.patch.dict(os.environ, {"TAI_TUI_SOCK": self.path}):
|
||||
tools = tai.Tools.__new__(tai.Tools)
|
||||
out = json.loads(tai.Tools.run_tui(tools, "goto", {"route": "/projects"}))
|
||||
self.assertEqual(out["result"]["route"], "/projects")
|
||||
for name in ("tui_screen", "tui_keys", "tui_type", "tui_click", "tui_goto", "tui_result"):
|
||||
self.assertTrue(any(sc["function"]["name"] == name for sc in tai.TOOL_SCHEMAS), name)
|
||||
|
||||
|
||||
class GrrSpecTests(unittest.TestCase):
|
||||
"""grrtui.md is the specification; it must list exactly what the code has."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user