From ac84fe7ea11bffe0587d8b798d13748af7c7ca6d Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 19:27:49 +0530 Subject: [PATCH] tui_gateway: surface tool failure as payload.error (result convention) --- tests/test_tui_gateway_server.py | 40 ++++++++++++++++++++++++++++++++ tui_gateway/server.py | 29 +++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index f0fa3f5bf94..dd93fda30e4 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -6118,3 +6118,43 @@ def test_sniff_image_ext_magic_and_filename(): assert server._sniff_image_ext(b"unknown") == ".png" # fallback # filename hint wins over magic bytes assert server._sniff_image_ext(b"\x89PNG", "photo.jpeg") == ".jpeg" + + +def test_tool_complete_derives_error_from_result_convention(monkeypatch): + # The repo convention (agent.display._result_succeeded): a JSON-object + # result with a truthy string "error" — or success:false — IS a failure. + # The gateway surfaces it as payload["error"] so clients (OpenTUI ✗ state, + # Ink trail ✗) don't sniff the convention themselves. + events: list[tuple[str, str, dict]] = [] + monkeypatch.setattr( + server, "_emit", lambda event_type, sid, payload: events.append((event_type, sid, payload)) + ) + monkeypatch.setitem( + server._sessions, + "err-test", + {"tool_progress_mode": "verbose", "tool_started_at": {}, "edit_snapshots": {}}, + ) + + # 1) error-string result → flattened, capped error on the payload + server._on_tool_complete( + "err-test", "t1", "read_file", {"path": "/nope"}, + json.dumps({"error": "File not found:\n /nope"}), + ) + assert events[-1][2]["error"] == "File not found: /nope" + + # 2) success:false without error string → generic failure marker + server._on_tool_complete( + "err-test", "t2", "patch", {"path": "x"}, json.dumps({"success": False}) + ) + assert events[-1][2]["error"] == "tool reported failure" + + # 3) plain-text result → NEVER a failure + server._on_tool_complete("err-test", "t3", "terminal", {"command": "ls"}, "file-a\nfile-b") + assert "error" not in events[-1][2] + + # 4) JSON result with error: null / no error key → success + server._on_tool_complete( + "err-test", "t4", "web_search", {"q": "x"}, + json.dumps({"results": [1, 2], "error": None}), + ) + assert "error" not in events[-1][2] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 3f2b806fb17..19fe603b721 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2100,8 +2100,37 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): _emit("tool.start", sid, payload) +def _tool_error_from_result(result: str) -> str | None: + """Derive a tool-failure message from the repo's result convention. + + Canon (same as ``agent.display._result_succeeded``): a JSON-object result + with a truthy string ``error`` key — or an explicit ``success: false`` — + means the tool failed. Returned flattened + capped for a one-line header. + Conservative on purpose: non-JSON / non-dict results are NEVER failures + (plain-text output is normal success output). + """ + try: + data = json.loads(result) + except Exception: + return None + if not isinstance(data, dict): + return None + err = data.get("error") + if isinstance(err, str) and err.strip(): + return " ".join(err.split())[:300] + if data.get("success") is False: + return "tool reported failure" + return None + + def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str): payload = {"tool_id": tool_call_id, "name": name, "args": args} + # Failure surfaced explicitly so clients don't have to sniff the result + # convention themselves (the TUI's ✗ state and Ink's trail ✗ both key off + # payload["error"], which was previously never set on this path). + _tool_err = _tool_error_from_result(result) + if _tool_err: + payload["error"] = _tool_err session = _sessions.get(sid) snapshot = None started_at = None