Browse Source

event log: parse the daemon's string-encoded tool args/result; surface the command and real output

The daemon serialises a tool call's arguments and a tool result's envelope to a JSON STRING before storing them (live ledger: 375/375 tool_call rows have args as a string, 381/381 tool_result rows have result as a string). The renderer read p.args.command and p.stdout directly, so every result body rendered '(no output)' and the command the agent ran was never shown.

task_log.html now parses those members through a tolerant parseMaybeJson (objects pass through, a non-JSON string answers undefined and the raw text is rendered instead - data is never hidden) and renders from what it decoded:

- tool_call: 'run_command: <command>' as its own highlighted pre line; every other argument pretty-printed below it, so non-run_command tools (update_progress, submit_plan, ...) show their args instead of just the tool name.
- tool_result: summary computed from the envelope (exit code, bytes_out+bytes_err, truncated, duration_ms, with the string-length fallback for object-shaped payloads); body shows the envelope's stdout/stderr. A result string that is not JSON is the truncated raw output and renders verbatim, with its size in the summary.
- tool_result with empty streams: an error/note/timeout member renders, and the envelope's non-plumbing members (recorded/task_id/accepted/tasks) render as the tool's answer - previously these were all swallowed by '(no output)'. Only a genuinely silent envelope says '(no output)' (live ledger: exactly 3 rows, all zero-byte runs).
- command_output: a '$ <command>' pre line above the text when payload.command is present.
- search haystack appends the raw args/result strings so full-text search matches text inside the string encoding.

All rendering stays textContent/createElement (no innerHTML) and the script body stays pure ASCII.

app.css: .event-pre.event-command style for the command lines.

test_task_log_js.py: the render decisions are ported as pure python functions and asserted against real payload fixtures from the live ledger (run_command call, truncated result with byte counts, update_progress call, non-JSON result string, error envelope, command_output) plus the object-shaped payloads the test fake pushes; source-shape checks pin parseMaybeJson, the dollar prefix and the no-innerHTML rule. 52 tests.
saddle-Clanker-Saddle 1 tháng trước cách đây
mục cha
commit
127e9f3a88
3 tập tin đã thay đổi với 658 bổ sung và 27 xóa
  1. 4 0
      src/pages/app.css
  2. 224 27
      src/pages/task_log.html
  3. 430 0
      test/test_task_log_js.py

+ 4 - 0
src/pages/app.css

@@ -880,6 +880,10 @@ dialog#quote-comment-dialog blockquote {
 .event-pre.event-stderr {
     color: var(--danger, #cf222e);
 }
+.event-pre.event-command {
+    background: #ddf4ff;
+    font-weight: 600;
+}
 .event-result-body {
     margin-top: 0.3rem;
 }

+ 224 - 27
src/pages/task_log.html

@@ -39,9 +39,23 @@
     // after a reconnect, resent from the last seen id).
     //
     // Turn cards group a run's turn_text + tool_call +
-    // tool_result events by payload.turn (per session);
-    // results collapse behind a summary carrying head+tail
-    // truncation display and byte counts. Filters
+    // tool_result events by payload.turn (per session).
+    //
+    // PAYLOADS - the daemon serialises a tool call's arguments
+    // and a tool result's envelope to a JSON STRING before it
+    // stores them: args is a string like '{"command":"ls"}'
+    // (never an object) and result is a string holding the
+    // {"ok":..,"exit_code":..,"stdout":..,"stderr":..,
+    // "duration_ms":..,"bytes_out":..,"bytes_err":..,
+    // "truncated":..} envelope. Every render path parses these
+    // members through parseMaybeJson first and falls back to
+    // the raw string when it is not JSON - data is never
+    // hidden. The command the agent ran must always be
+    // visible: tool_call renders it as its own line,
+    // command_output prefixes '$ <command>'. Results collapse
+    // behind a summary carrying exit code, byte counts (from
+    // the envelope when present), the truncated flag and
+    // duration. Filters
     // (commands/results/comments/transitions/usage) and the
     // full-text search run client-side over the loaded window.
     // All rendering goes through textContent/createElement -
@@ -93,7 +107,9 @@
                 if (!state.filters[filterOf(evt.kind)]) return false;
                 if (state.search.length > 0) {
                     var haystack = evt.kind + ' ' + evt.actor + ' '
-                        + JSON.stringify(evt.payload || {});
+                        + JSON.stringify(evt.payload || {})
+                        + ' ' + strText((evt.payload || {}).args)
+                        + ' ' + strText((evt.payload || {}).result);
                     if (haystack.toLowerCase().indexOf(state.search.toLowerCase()) === -1) {
                         return false;
                     }
@@ -132,11 +148,42 @@
                 feed.scrollTop = feed.scrollHeight;
             }
 
-            function payloadBytes(evt, member) {
-                var value = evt.payload && evt.payload[member];
+            // The daemon JSON-encodes tool arguments and tool
+            // result envelopes into STRINGS before storing them;
+            // older/fake pushes send plain objects. Parse the
+            // string forms, pass objects through, and answer
+            // undefined when a string is not JSON (the caller
+            // then shows the raw text so data is never hidden).
+            function parseMaybeJson(value) {
+                if (typeof value !== 'string') return value;
+                try {
+                    return JSON.parse(value);
+                } catch (error) {
+                    return undefined;
+                }
+            }
+
+            // True for a parsed member we can treat as a bag of
+            // properties (the object-shaped push, or a string
+            // that decoded to an object).
+            function isPlainObject(value) {
+                return value !== null && typeof value === 'object'
+                    && !Array.isArray(value);
+            }
+
+            function strText(value) {
+                return typeof value === 'string' ? value : '';
+            }
+
+            function strBytes(value) {
                 return typeof value === 'string' ? value.length : 0;
             }
 
+            function fmtDuration(ms) {
+                if (ms < 1000) return ms + ' ms';
+                return (ms / 1000).toFixed(1) + ' s';
+            }
+
             function fmtBytes(n) {
                 if (n < 1024) return n + ' B';
                 if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KiB';
@@ -150,31 +197,106 @@
                 return pre;
             }
 
-            function resultSummary(evt) {
+            // The parsed result envelope: the daemon stores it
+            // as a JSON string ({"ok":..,"exit_code":..,
+            // "stdout":..,"stderr":..,"duration_ms":..,
+            // "bytes_out":..,"bytes_err":..,"truncated":..});
+            // the legacy object-shaped payload is the fallback.
+            function resultEnvelope(evt) {
+                var p = evt.payload || {};
+                var parsed = parseMaybeJson(p.result);
+                if (isPlainObject(parsed)) return parsed;
+                if (isPlainObject(p)) return p;
+                return {};
+            }
+
+            // Command plumbing: exit status, the streams and
+            // their sizes, plus the ledger's own framing keys.
+            // An envelope carrying ONLY these said nothing worth
+            // printing; anything else in it (a task_id,
+            // "recorded", a tasks list) IS the tool's answer
+            // and must be rendered, not swallowed by "(no
+            // output)".
+            var RESULT_PLUMBING = { ok: 1, exit_code: 1, stdout: 1,
+                stderr: 1, bytes_out: 1, bytes_err: 1, truncated: 1,
+                duration_ms: 1, turn: 1, tool: 1 };
+
+            function resultExtras(envelope) {
+                var extras = {};
+                var keys = Object.keys(envelope);
+                for (var i = 0; i < keys.length; i++) {
+                    if (RESULT_PLUMBING[keys[i]] === undefined
+                            && keys[i] !== 'error' && keys[i] !== 'note'
+                            && keys[i] !== 'timeout') {
+                        extras[keys[i]] = envelope[keys[i]];
+                    }
+                }
+                return extras;
+            }
+
+            function resultSummary(evt, envelope) {
                 var p = evt.payload || {};
                 var wrap = document.createElement('summary');
-                var label = document.createTextNode('result');
-                wrap.appendChild(label);
+                wrap.appendChild(document.createTextNode('result'));
                 var bits = [];
-                if (p.exit_code !== undefined && p.exit_code !== null) {
-                    bits.push('exit ' + p.exit_code);
+                if (envelope.exit_code !== undefined
+                        && envelope.exit_code !== null) {
+                    bits.push('exit ' + envelope.exit_code);
+                } else if (envelope.ok === false) {
+                    bits.push('failed');
+                }
+                // Prefer the envelope's own byte counters (they
+                // count the pre-truncation output); fall back to
+                // measuring the strings we hold.
+                var bytes;
+                if (typeof envelope.bytes_out === 'number'
+                        && typeof envelope.bytes_err === 'number') {
+                    bytes = envelope.bytes_out + envelope.bytes_err;
+                } else {
+                    bytes = strBytes(envelope.stdout) + strBytes(envelope.stderr);
                 }
-                var bytes = payloadBytes(evt, 'stdout') + payloadBytes(evt, 'stderr');
                 if (bytes > 0) bits.push(fmtBytes(bytes));
-                if (p.truncated) bits.push('truncated');
+                if (envelope.truncated) bits.push('truncated');
+                if (typeof envelope.duration_ms === 'number') {
+                    bits.push(fmtDuration(envelope.duration_ms));
+                }
                 if (p.turn !== undefined && p.turn !== null) bits.push('turn ' + p.turn);
+                // A result STRING that is not JSON carries no
+                // envelope; its own size is the only metric we
+                // have, and it is about to be rendered raw.
+                if (bits.length === 0 && typeof p.result === 'string'
+                        && !isPlainObject(parseMaybeJson(p.result))) {
+                    bits.push(fmtBytes(p.result.length));
+                }
                 if (bits.length > 0) {
                     wrap.appendChild(document.createTextNode(' (' + bits.join(', ') + ')'));
                 }
                 return wrap;
             }
 
-            function resultBody(evt) {
+            function resultBody(evt, envelope) {
                 var p = evt.payload || {};
                 var div = document.createElement('div');
                 div.className = 'event-result-body';
-                var out = p.stdout || '';
-                var err = p.stderr || '';
+                // A result string that is NOT JSON is the output
+                // text itself (the daemon sends truncated raw
+                // output this way): show it verbatim.
+                var out = '';
+                var err = '';
+                var parsed = parseMaybeJson(p.result);
+                if (isPlainObject(parsed)) {
+                    out = strText(envelope.stdout);
+                    err = strText(envelope.stderr);
+                } else if (typeof parsed === 'string') {
+                    out = parsed;  // a JSON string literal
+                } else if (typeof p.result === 'string') {
+                    out = p.result;
+                } else {
+                    // Legacy object-shaped payload: the members
+                    // sit on the payload itself.
+                    out = strText(envelope.stdout);
+                    err = strText(envelope.stderr);
+                }
                 if (out.length > 0) {
                     div.appendChild(preLine(out));
                 }
@@ -184,14 +306,80 @@
                     div.appendChild(errPre);
                 }
                 if (out.length === 0 && err.length === 0) {
-                    var empty = document.createElement('p');
-                    empty.className = 'muted';
-                    empty.textContent = '(no output)';
-                    div.appendChild(empty);
+                    // Empty stdout AND stderr. An error/note/
+                    // timeout member still said something, and a
+                    // non-run_command tool's answer IS the
+                    // envelope ({"recorded":true}, {"ok":true,
+                    // "task_id":4}, a tasks list) - render it
+                    // instead of claiming silence. Only an
+                    // envelope with nothing left to say is
+                    // "(no output)".
+                    var note = strText(envelope.error) || strText(envelope.note)
+                        || (envelope.timeout ? 'timed out' : '');
+                    if (note.length > 0) {
+                        var notePre = preLine(note);
+                        notePre.className = 'event-pre event-stderr';
+                        div.appendChild(notePre);
+                    } else {
+                        var extras = resultExtras(envelope);
+                        if (Object.keys(extras).length > 0) {
+                            var compact = JSON.stringify(extras);
+                            div.appendChild(preLine(compact.length <= 120
+                                ? compact : JSON.stringify(extras, null, 2)));
+                        } else {
+                            var empty = document.createElement('p');
+                            empty.className = 'muted';
+                            empty.textContent = '(no output)';
+                            div.appendChild(empty);
+                        }
+                    }
                 }
                 return div;
             }
 
+            // The tool call renders the tool name and then the
+            // command (or every parsed argument) as pre lines -
+            // what the agent asked for must be visible at a
+            // glance, not only inside a payload dump.
+            function toolCallBody(p) {
+                var frag = document.createDocumentFragment();
+                var tool = strText(p.tool).length > 0 ? strText(p.tool) : 'tool';
+                var parsed = parseMaybeJson(p.args);
+                var args = isPlainObject(parsed) ? parsed : null;
+                var line = tool;
+                if (args && strText(args.command).length > 0) {
+                    line += ': ' + args.command;
+                }
+                var head = preLine(line);
+                head.className = 'event-pre event-command';
+                frag.appendChild(head);
+                if (args) {
+                    var rest = argsWithout(args, 'command');
+                    if (rest.length > 0) frag.appendChild(preLine(rest));
+                } else if (strText(p.args).length > 0) {
+                    // Args that are not JSON: the daemon sent
+                    // text; never hide it behind a parse failure.
+                    frag.appendChild(preLine(p.args));
+                } else if (p.args !== undefined && p.args !== null
+                        && typeof p.args !== 'string') {
+                    frag.appendChild(preLine(JSON.stringify(p.args, null, 2)));
+                }
+                return frag;
+            }
+
+            // Every argument except `skip`, pretty-printed; ''
+            // when nothing else is left to show.
+            function argsWithout(args, skip) {
+                var rest = {};
+                var keys = Object.keys(args);
+                for (var i = 0; i < keys.length; i++) {
+                    if (keys[i] === skip) continue;
+                    rest[keys[i]] = args[keys[i]];
+                }
+                return Object.keys(rest).length > 0
+                    ? JSON.stringify(rest, null, 2) : '';
+            }
+
             function eventNode(evt) {
                 var row = document.createElement('div');
                 row.className = 'event-row';
@@ -213,18 +401,27 @@
                 if (evt.kind === 'turn_text' && typeof p.text === 'string') {
                     body.appendChild(preLine(p.text));
                 } else if (evt.kind === 'tool_call') {
-                    var args = p.args || {};
-                    var line = p.tool || 'tool';
-                    if (typeof args.command === 'string') line += ': ' + args.command;
-                    body.appendChild(preLine(line));
+                    body.appendChild(toolCallBody(p));
                 } else if (evt.kind === 'tool_result') {
+                    var envelope = resultEnvelope(evt);
                     var details = document.createElement('details');
                     details.className = 'event-result';
-                    details.appendChild(resultSummary(evt));
-                    details.appendChild(resultBody(evt));
+                    details.appendChild(resultSummary(evt, envelope));
+                    details.appendChild(resultBody(evt, envelope));
                     body.appendChild(details);
                 } else if (evt.kind === 'command_output') {
-                    body.appendChild(preLine(p.text || p.chunk || ''));
+                    // The command the agent ran rides the same
+                    // event: prefix it so the output reads as
+                    // '$ cmd ... output' instead of bare text.
+                    var text = p.text || p.chunk || '';
+                    if (strText(p.command).length > 0) {
+                        var cmdPre = preLine('$ ' + p.command);
+                        cmdPre.className = 'event-pre event-command';
+                        body.appendChild(cmdPre);
+                        body.appendChild(preLine(text));
+                    } else {
+                        body.appendChild(preLine(text));
+                    }
                 } else if (evt.kind === 'progress') {
                     var para = document.createElement('p');
                     para.textContent = p.text || '';

+ 430 - 0
test/test_task_log_js.py

@@ -23,6 +23,7 @@ src/pages/task_log.html two ways:
 Runnable standalone: python3 test/test_task_log_js.py
 """
 
+import json
 import os
 import re
 import unittest
@@ -420,5 +421,434 @@ class EndToEndSequenceTests(unittest.TestCase):
         self.assertEqual(feed.scroll_top, 100 + 3000)  # anchored
 
 
+# ---------------------------------------------------------------------------
+# Payload contract: the daemon JSON-encodes args/result into STRINGS
+# ---------------------------------------------------------------------------
+
+def parse_maybe_json(value):
+    """PORT of parseMaybeJson: strings decode, objects pass through,
+    a non-JSON string answers None so the caller can fall back to
+    rendering the raw text."""
+    if not isinstance(value, str):
+        return value
+    try:
+        return json.loads(value)
+    except ValueError:
+        return None
+
+
+def is_plain_object(value):
+    """PORT of isPlainObject."""
+    return isinstance(value, dict)
+
+
+def fmt_bytes(n):
+    """PORT of fmtBytes."""
+    if n < 1024:
+        return "%d B" % n
+    if n < 1024 * 1024:
+        return "%.1f KiB" % (n / 1024)
+    return "%.1f MiB" % (n / (1024 * 1024))
+
+
+def str_text(value):
+    """PORT of strText."""
+    return value if isinstance(value, str) else ""
+
+
+def fmt_duration(ms):
+    """PORT of fmtDuration."""
+    if ms < 1000:
+        return "%d ms" % ms
+    return "%.1f s" % (ms / 1000)
+
+
+def str_bytes(value):
+    """PORT of strBytes."""
+    return len(value) if isinstance(value, str) else 0
+
+
+def result_envelope(evt):
+    """PORT of resultEnvelope: the parsed result string when it
+    decodes to an object, else the object-shaped payload itself."""
+    payload = evt.get("payload") or {}
+    parsed = parse_maybe_json(payload.get("result"))
+    if is_plain_object(parsed):
+        return parsed
+    if is_plain_object(payload):
+        return payload
+    return {}
+
+
+RESULT_PLUMBING = ("ok", "exit_code", "stdout", "stderr", "bytes_out",
+                   "bytes_err", "truncated", "duration_ms", "turn", "tool")
+
+
+def result_extras(envelope):
+    """PORT of resultExtras: the envelope minus command plumbing
+    and minus the note members (rendered separately)."""
+    return dict((key, value) for key, value in envelope.items()
+                if key not in RESULT_PLUMBING
+                and key not in ("error", "note", "timeout"))
+
+
+def result_summary(evt):
+    """PORT of resultSummary -> the summary line's text."""
+    envelope = result_envelope(evt)
+    payload = evt.get("payload") or {}
+    bits = []
+    if envelope.get("exit_code") is not None:
+        bits.append("exit %s" % envelope["exit_code"])
+    elif envelope.get("ok") is False:
+        bits.append("failed")
+    if isinstance(envelope.get("bytes_out"), int) \
+            and isinstance(envelope.get("bytes_err"), int):
+        bytes_count = envelope["bytes_out"] + envelope["bytes_err"]
+    else:
+        bytes_count = str_bytes(envelope.get("stdout")) \
+            + str_bytes(envelope.get("stderr"))
+    if bytes_count > 0:
+        bits.append(fmt_bytes(bytes_count))
+    if envelope.get("truncated"):
+        bits.append("truncated")
+    if isinstance(envelope.get("duration_ms"), int):
+        bits.append(fmt_duration(envelope["duration_ms"]))
+    if payload.get("turn") is not None:
+        bits.append("turn %s" % payload["turn"])
+    # PORT: a non-JSON result string carries no envelope; its own
+    # size is the only metric we have.
+    if not bits and isinstance(payload.get("result"), str) \
+            and not is_plain_object(parse_maybe_json(payload["result"])):
+        bits.append(fmt_bytes(len(payload["result"])))
+    return "result" + (" (" + ", ".join(bits) + ")" if bits else "")
+
+
+def result_output(evt):
+    """PORT of resultBody's text decisions -> (stdout_text,
+    stderr_text, note_text, extras_text, no_output_flag)."""
+    envelope = result_envelope(evt)
+    payload = evt.get("payload") or {}
+    parsed = parse_maybe_json(payload.get("result"))
+    out = err = ""
+    if is_plain_object(parsed):
+        out = str_text(envelope.get("stdout"))
+        err = str_text(envelope.get("stderr"))
+    elif isinstance(parsed, str):
+        out = parsed  # a JSON string literal
+    elif isinstance(payload.get("result"), str):
+        out = payload["result"]
+    else:
+        out = str_text(envelope.get("stdout"))
+        err = str_text(envelope.get("stderr"))
+    note = ""
+    extras = ""
+    if not out and not err:
+        note = str_text(envelope.get("error")) or str_text(envelope.get("note")) \
+            or ("timed out" if envelope.get("timeout") else "")
+        if not note:
+            remainder = result_extras(envelope)
+            if remainder:
+                compact = json.dumps(remainder)
+                extras = compact if len(compact) <= 120 \
+                    else json.dumps(remainder, indent=2)
+    return out, err, note, extras, (not out and not err and not note and not extras)
+
+
+def args_without(args, skip):
+    """PORT of argsWithout: every member except `skip`, pretty-
+    printed; '' when nothing is left."""
+    rest = dict((key, value) for key, value in args.items() if key != skip)
+    return json.dumps(rest, indent=2) if rest else ""
+
+
+def tool_call_render(evt):
+    """PORT of toolCallBody -> the pre lines' texts, in order."""
+    payload = evt.get("payload") or {}
+    tool = str_text(payload.get("tool")) or "tool"
+    parsed = parse_maybe_json(payload.get("args"))
+    args = parsed if is_plain_object(parsed) else None
+    line = tool
+    if args and str_text(args.get("command")):
+        line += ": " + args["command"]
+    lines = [line]
+    if args is not None:
+        rest = args_without(args, "command")
+        if rest:
+            lines.append(rest)
+    elif str_text(payload.get("args")):
+        lines.append(payload["args"])
+    elif payload.get("args") is not None and not isinstance(payload.get("args"), str):
+        lines.append(json.dumps(payload["args"], indent=2))
+    return lines
+
+
+def command_output_render(evt):
+    """PORT of eventNode's command_output branch ->
+    (command_prefix, output_text)."""
+    payload = evt.get("payload") or {}
+    command = str_text(payload.get("command"))
+    text = payload.get("text") or payload.get("chunk") or ""
+    return ("$ " + command if command else ""), text
+
+
+# --- Fixtures: copied from the live ledger (web-db.sqlite, events
+# table) and the test fake's object-shaped pushes - the real wire
+# shapes, inner JSON spelled out as the daemon serialises it. ---
+
+# The daemon's inner JSON, built with json.dumps so the escaping
+# is exactly what the wire carries (a JSON string holding JSON).
+inner_call = json.dumps({"command": "cd /workspace && ls"})
+inner_progress = json.dumps(
+    {"text": "Root-caused the SSE listener; next: the payload parse"})
+inner_enqueue = json.dumps({"task_id": 3})
+inner_result = json.dumps(
+    {"ok": True, "exit_code": 0,
+     "stdout": "find /workspace/task-1 -maxdepth 2",
+     "stderr": "", "duration_ms": 11,
+     "bytes_out": 6042, "bytes_err": 0, "truncated": True})
+inner_error = json.dumps(
+    {"ok": False,
+     "error": "suspend refused: 3 draft task(s) were never enqueued"})
+inner_empty = json.dumps({"ok": True, "exit_code": 0,
+                          "stdout": "", "stderr": ""})
+
+FIXTURE_RUN_COMMAND_CALL = json.loads(
+    '{"turn": 52, "tool": "run_command", "args": ' + json.dumps(inner_call) + '}')
+
+FIXTURE_UPDATE_PROGRESS_CALL = json.loads(
+    '{"turn": 6, "tool": "update_progress", "args": '
+    + json.dumps(inner_progress) + '}')
+
+FIXTURE_ENQUEUE_CALL = json.loads(
+    '{"turn": 5, "tool": "enqueue_task", "args": '
+    + json.dumps(inner_enqueue) + '}')
+
+FIXTURE_RUN_COMMAND_RESULT = json.loads(
+    '{"tool": "run_command", "result": ' + json.dumps(inner_result)
+    + ', "turn": 52}')
+
+FIXTURE_ERROR_RESULT = json.loads(
+    '{"tool": "suspend", "result": ' + json.dumps(inner_error) + '}')
+
+FIXTURE_EMPTY_RESULT = json.loads(
+    '{"tool": "run_command", "result": ' + json.dumps(inner_empty) + '}')
+
+# Not valid JSON: the live daemon emitted result strings that are
+# truncated raw output (72 rows in the production ledger).
+FIXTURE_NOT_JSON_RESULT = {
+    "tool": "run_command",
+    "result": '{"ok":true,"exit_code":0,"stdout":"=== Spry ===\n'
+              'MANIFEST.usm  README.md  meson.build',
+}
+
+# command_output: the harness fake's shape; `command` is a member.
+FIXTURE_COMMAND_OUTPUT = {"command": "meson test -C builddir",
+                          "text": "nobody:/task-1$ done\r\n"}
+
+# The harness fake's legacy object-shaped payloads (plan 17's own
+# tests push these): args/result as real objects.
+FIXTURE_OBJECT_CALL = {"turn": 1, "tool": "run_command",
+                       "args": {"command": "echo hello"}}
+FIXTURE_OBJECT_RESULT = {"turn": 1, "tool": "run_command",
+                         "stdout": "hello\n", "exit_code": 0}
+
+
+class PayloadParserTests(unittest.TestCase):
+
+    def test_string_args_decode_to_an_object(self):
+        parsed = parse_maybe_json(FIXTURE_RUN_COMMAND_CALL["args"])
+        self.assertEqual(parsed, {"command": "cd /workspace && ls"})
+
+    def test_non_json_string_answers_none(self):
+        self.assertIsNone(parse_maybe_json(
+            FIXTURE_NOT_JSON_RESULT["result"]))
+
+    def test_objects_pass_through(self):
+        self.assertEqual(parse_maybe_json({"command": "ls"}), {"command": "ls"})
+        self.assertEqual(parse_maybe_json(None), None)
+
+
+class ToolCallRenderTests(unittest.TestCase):
+    """The command the agent ran must surface in EVERY render path."""
+
+    def evt(self, payload):
+        return {"kind": "tool_call", "payload": payload}
+
+    def test_run_command_call_surfaces_the_command(self):
+        lines = tool_call_render(self.evt(FIXTURE_RUN_COMMAND_CALL))
+        self.assertEqual(lines[0], "run_command: cd /workspace && ls")
+
+    def test_update_progress_call_renders_its_args(self):
+        """Non-run_command tools must show their args, not just the
+        tool name (the old renderer showed only the name)."""
+        lines = tool_call_render(self.evt(FIXTURE_UPDATE_PROGRESS_CALL))
+        self.assertEqual(lines[0], "update_progress")
+        self.assertEqual(len(lines), 2)
+        self.assertIn("Root-caused the SSE listener", lines[1])
+        self.assertIn('"text"', lines[1])
+
+    def test_enqueue_call_renders_the_id(self):
+        lines = tool_call_render(self.evt(FIXTURE_ENQUEUE_CALL))
+        self.assertEqual(lines[0], "enqueue_task")
+        self.assertIn('"task_id": 3', lines[1])
+
+    def test_object_shaped_call_still_works(self):
+        lines = tool_call_render(self.evt(FIXTURE_OBJECT_CALL))
+        self.assertEqual(lines[0], "run_command: echo hello")
+        self.assertEqual(len(lines), 1)
+
+    def test_non_json_args_render_raw(self):
+        lines = tool_call_render(self.evt(
+            {"tool": "run_command", "args": "not json at all"}))
+        self.assertEqual(lines, ["run_command", "not json at all"])
+
+    def test_no_args_at_all_renders_the_tool_name(self):
+        lines = tool_call_render(self.evt({"tool": "list_tasks"}))
+        self.assertEqual(lines, ["list_tasks"])
+
+
+class ToolResultRenderTests(unittest.TestCase):
+
+    def evt(self, payload):
+        return {"kind": "tool_result", "payload": payload}
+
+    def test_summary_comes_from_the_envelope(self):
+        line = result_summary(self.evt(FIXTURE_RUN_COMMAND_RESULT))
+        self.assertEqual(line,
+                         "result (exit 0, 5.9 KiB, truncated, 11 ms, turn 52)")
+
+    def test_summary_marks_failed_envelopes(self):
+        line = result_summary(self.evt(
+            {"tool": "run_command", "result": '{"ok":false,"error":"x"}'}))
+        self.assertEqual(line, "result (failed)")
+
+    def test_body_shows_the_envelopes_stdout(self):
+        out, err, note, extras, no_output = result_output(
+            self.evt(FIXTURE_RUN_COMMAND_RESULT))
+        self.assertEqual(out, "find /workspace/task-1 -maxdepth 2")
+        self.assertEqual(err, "")
+        self.assertFalse(no_output)
+
+    def test_non_json_result_renders_as_output_text(self):
+        out, err, note, extras, no_output = result_output(
+            self.evt(FIXTURE_NOT_JSON_RESULT))
+        self.assertIn("=== Spry ===", out)
+        self.assertFalse(no_output,
+                         "raw output must never be called (no output)")
+
+    def test_error_envelope_is_not_no_output(self):
+        out, err, note, extras, no_output = result_output(
+            self.evt(FIXTURE_ERROR_RESULT))
+        self.assertEqual((out, err), ("", ""))
+        self.assertIn("suspend refused", note)
+        self.assertFalse(no_output,
+                         "an error member means the result said something")
+
+    def test_genuinely_empty_result_says_no_output(self):
+        out, err, note, extras, no_output = result_output(self.evt(FIXTURE_EMPTY_RESULT))
+        self.assertEqual((out, err, note, extras), ("", "", "", ""))
+        self.assertTrue(no_output)
+
+    def test_object_shaped_result_still_works(self):
+        line = result_summary(self.evt(FIXTURE_OBJECT_RESULT))
+        self.assertEqual(line, "result (exit 0, 6 B, turn 1)")
+        out, err, note, extras, no_output = result_output(self.evt(FIXTURE_OBJECT_RESULT))
+        self.assertEqual(out, "hello\n")
+
+
+    def test_tool_answer_envelope_is_rendered_not_swallowed(self):
+        """The live ledger's update_progress/create_task/finish
+        results carry {"recorded":true} / {"ok":true,"task_id":4}
+        / {"accepted":true} with empty streams: that IS the tool's
+        answer and must render, not "(no output)"."""
+        for inner in ('{"recorded":true}',
+                      '{"ok":true,"task_id":4,"status":"draft"}',
+                      '{"accepted":true}',
+                      '{"suspended":true}',
+                      '{"ok":true,"status":"pending"}'):
+            out, err, note, extras, no_output = result_output(self.evt(
+                {"tool": "update_progress", "result": inner}))
+            self.assertFalse(no_output, inner)
+            self.assertEqual((out, err, note), ("", "", ""))
+            self.assertTrue(extras, inner)
+
+    def test_list_tasks_answer_renders_as_json(self):
+        inner = json.dumps({"ok": True, "tasks": [
+            {"id": 6, "title": "UX notes", "status": "queued"},
+            {"id": 5, "title": "Event log parse", "status": "working"}]})
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "list_tasks", "result": inner}))
+        self.assertFalse(no_output)
+        self.assertIn('"tasks"', extras)
+        self.assertIn("UX notes", extras)
+
+    def test_run_command_with_empty_streams_is_still_no_output(self):
+        """Only command plumbing present: the run genuinely said
+        nothing - keep the honest "(no output)"."""
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "run_command",
+             "result": '{"ok":true,"exit_code":0,"stdout":"",'
+                       '"stderr":"","duration_ms":5,"bytes_out":0,'
+                       '"bytes_err":0,"truncated":false}'}))
+        self.assertTrue(no_output)
+        self.assertEqual(extras, "")
+
+
+
+class CommandOutputRenderTests(unittest.TestCase):
+
+    def test_command_is_prefixed_as_a_shell_line(self):
+        command, text = command_output_render(
+            {"kind": "command_output", "payload": FIXTURE_COMMAND_OUTPUT})
+        self.assertEqual(command, "$ meson test -C builddir")
+        self.assertEqual(text, "nobody:/task-1$ done\r\n")
+
+    def test_text_only_chunk_keeps_the_old_render(self):
+        command, text = command_output_render(
+            {"kind": "command_output", "payload": {"text": "hello\r\n"}})
+        self.assertEqual(command, "")
+        self.assertEqual(text, "hello\r\n")
+
+
+class SearchHaystackTests(unittest.TestCase):
+    """A search for text inside the STRING-encoded args/result must
+    still find the event: the haystack appends the raw strings."""
+
+    def test_search_matches_through_the_string_encoding(self):
+        payload = FIXTURE_RUN_COMMAND_CALL
+        haystack = "tool_call daemon " + json.dumps(payload) + " " \
+            + str_text(payload.get("args")) + " " \
+            + str_text(payload.get("result"))
+        self.assertIn("cd /workspace && ls", haystack)
+
+
+class RendererSourceTests(unittest.TestCase):
+    """The shipped script itself (no JS engine in this container, so
+    the render decisions are pinned by the ports above plus these
+    shape checks on the served source)."""
+
+    def setUp(self):
+        self.body = script_body()
+
+    def test_parse_maybe_json_is_defined_and_used(self):
+        self.assertIn("function parseMaybeJson(value) {", self.body)
+        self.assertGreaterEqual(self.body.count("parseMaybeJson("), 4)
+
+    def test_no_innerhtml_anywhere(self):
+        self.assertNotIn(".innerHTML", self.body)
+
+    def test_command_output_prefixes_the_dollar(self):
+        self.assertIn("'$ ' + p.command", self.body)
+
+    def test_tool_call_line_uses_parsed_args(self):
+        self.assertIn("args.command", self.body)
+        self.assertIn("toolCallBody", self.body)
+
+    def test_result_reads_parsed_envelope(self):
+        self.assertIn("resultEnvelope", self.body)
+        self.assertIn("bytes_out", self.body)
+        self.assertIn("duration_ms", self.body)
+
+
 if __name__ == "__main__":
     unittest.main(verbosity=2)