|
|
@@ -582,24 +582,181 @@ def args_without(args, skip):
|
|
|
|
|
|
|
|
|
def tool_call_render(evt):
|
|
|
- """PORT of toolCallBody -> the pre lines' texts, in order."""
|
|
|
+ """PORT of toolCallBody -> the rendered blocks as (kind, text)
|
|
|
+ pairs: ('head', line) for a command-styled head, ('pre', text)
|
|
|
+ for plain pre lines, ('para', text) for prose, ('fold', label)
|
|
|
+ for collapsed payloads, ('diff', '- old') / ('diff', '+ new')
|
|
|
+ for the replace_in_file diff. Bespoke bodies come from
|
|
|
+ tailored_blocks; None falls back to the generic view."""
|
|
|
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:
|
|
|
+ blocks = tailored_blocks(tool, args)
|
|
|
+ if blocks is not None:
|
|
|
+ return blocks
|
|
|
+ blocks = [("head", tool)]
|
|
|
if args is not None:
|
|
|
rest = args_without(args, "command")
|
|
|
if rest:
|
|
|
- lines.append(rest)
|
|
|
+ blocks.append(("pre", rest))
|
|
|
elif str_text(payload.get("args")):
|
|
|
- lines.append(payload["args"])
|
|
|
+ blocks.append(("pre", 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
|
|
|
+ blocks.append(("pre", json.dumps(payload["args"], indent=2)))
|
|
|
+ return blocks
|
|
|
+
|
|
|
+
|
|
|
+def fmt_int(n):
|
|
|
+ """PORT of fmtInt: thousands separators without locale."""
|
|
|
+ digits = str(abs(int(round(n))))
|
|
|
+ out = ""
|
|
|
+ for i, char in enumerate(digits):
|
|
|
+ if i > 0 and (len(digits) - i) % 3 == 0:
|
|
|
+ out += ","
|
|
|
+ out += char
|
|
|
+ return ("-" if n < 0 else "") + out
|
|
|
+
|
|
|
+
|
|
|
+def usage_line(payload):
|
|
|
+ """PORT of eventNode's usage branch -> the one-line text. The
|
|
|
+ daemon may report either token count alone."""
|
|
|
+ bits = []
|
|
|
+ if str_text(payload.get("model")):
|
|
|
+ bits.append(payload["model"])
|
|
|
+ in_tok = "%s in" % fmt_int(payload["prompt_tokens"]) \
|
|
|
+ if isinstance(payload.get("prompt_tokens"), int) else ""
|
|
|
+ out_tok = "%s out" % fmt_int(payload["completion_tokens"]) \
|
|
|
+ if isinstance(payload.get("completion_tokens"), int) else ""
|
|
|
+ if in_tok or out_tok:
|
|
|
+ bits.append(in_tok + (" / " if in_tok and out_tok else "") + out_tok + " tok")
|
|
|
+ if isinstance(payload.get("cached_tokens"), int):
|
|
|
+ bits.append("%s cached" % fmt_int(payload["cached_tokens"]))
|
|
|
+ if isinstance(payload.get("elapsed_ms"), int):
|
|
|
+ bits.append(fmt_duration(payload["elapsed_ms"]))
|
|
|
+ return " \u00b7 ".join(bits) if bits else "unreported"
|
|
|
+
|
|
|
+
|
|
|
+def state_change_line(payload):
|
|
|
+ """PORT of eventNode's state_change branch."""
|
|
|
+ from_status = str_text(payload.get("from"))
|
|
|
+ to_status = str_text(payload.get("to"))
|
|
|
+ if from_status and to_status:
|
|
|
+ return from_status + " \u2192 " + to_status
|
|
|
+ return str_text(payload.get("text")) or json.dumps(payload)
|
|
|
+
|
|
|
+
|
|
|
+DIFF_CONTEXT = 2
|
|
|
+DIFF_MAX_LINES = 400
|
|
|
+
|
|
|
+
|
|
|
+def _push_side(rows, lines, begin, end, marker):
|
|
|
+ """PORT of pushSide."""
|
|
|
+ count = end - begin
|
|
|
+ half = DIFF_MAX_LINES // 2
|
|
|
+ if count <= DIFF_MAX_LINES:
|
|
|
+ for i in range(begin, end):
|
|
|
+ rows.append([marker, lines[i]])
|
|
|
+ return
|
|
|
+ for j in range(begin, begin + half):
|
|
|
+ rows.append([marker, lines[j]])
|
|
|
+ rows.append([marker, "... %d more lines ..." % (count - half * 2)])
|
|
|
+ for k in range(end - half, end):
|
|
|
+ rows.append([marker, lines[k]])
|
|
|
+
|
|
|
+
|
|
|
+def diff_rows(old_text, new_text):
|
|
|
+ """PORT of diffRows: common head/tail become clamped context,
|
|
|
+ the differing middle renders as -old/+new."""
|
|
|
+ old_lines = old_text.split("\n")
|
|
|
+ new_lines = new_text.split("\n")
|
|
|
+ start = 0
|
|
|
+ while start < len(old_lines) and start < len(new_lines) \
|
|
|
+ and old_lines[start] == new_lines[start]:
|
|
|
+ start += 1
|
|
|
+ end_old, end_new = len(old_lines), len(new_lines)
|
|
|
+ while end_old > start and end_new > start \
|
|
|
+ and old_lines[end_old - 1] == new_lines[end_new - 1]:
|
|
|
+ end_old -= 1
|
|
|
+ end_new -= 1
|
|
|
+ rows = []
|
|
|
+ has_middle = end_old > start or end_new > start
|
|
|
+ if has_middle:
|
|
|
+ for i in range(max(0, start - DIFF_CONTEXT), start):
|
|
|
+ rows.append([" ", old_lines[i]])
|
|
|
+ _push_side(rows, old_lines, start, end_old, "-")
|
|
|
+ _push_side(rows, new_lines, start, end_new, "+")
|
|
|
+ for i in range(end_old, min(len(old_lines), end_old + DIFF_CONTEXT)):
|
|
|
+ rows.append([" ", old_lines[i]])
|
|
|
+ return rows
|
|
|
+
|
|
|
+
|
|
|
+def tailored_blocks(tool, args):
|
|
|
+ """PORT of tailoredCallBody -> the bespoke blocks, or None to
|
|
|
+ fall back to the generic JSON view (fields missing - e.g. a
|
|
|
+ truncated args string that clamped mid-JSON)."""
|
|
|
+ path = str_text(args.get("path"))
|
|
|
+ command = str_text(args.get("command"))
|
|
|
+ question = str_text(args.get("question"))
|
|
|
+ bits = []
|
|
|
+
|
|
|
+ def head(text, more=None):
|
|
|
+ block = ("head", text)
|
|
|
+ if more:
|
|
|
+ block = ("head", text + " \u00b7 " + " \u00b7 ".join(more))
|
|
|
+ return block
|
|
|
+
|
|
|
+ if tool == "run_command":
|
|
|
+ if not command:
|
|
|
+ return None
|
|
|
+ if isinstance(args.get("timeout_ms"), int):
|
|
|
+ bits.append("timeout %ds" % round(args["timeout_ms"] / 1000))
|
|
|
+ return [head("$ " + command, bits)]
|
|
|
+ if tool == "read_file":
|
|
|
+ if not path:
|
|
|
+ return None
|
|
|
+ if isinstance(args.get("offset"), int) and args["offset"] > 1:
|
|
|
+ bits.append("from line %d" % args["offset"])
|
|
|
+ if isinstance(args.get("limit"), int):
|
|
|
+ bits.append("%d lines" % args["limit"])
|
|
|
+ return [head("read_file " + path, bits)]
|
|
|
+ if tool in ("write_file", "overwrite_file"):
|
|
|
+ content = str_text(args.get("content"))
|
|
|
+ if not path or not content:
|
|
|
+ return None
|
|
|
+ return [head(tool + " " + path, [fmt_bytes(len(content))]),
|
|
|
+ ("fold", "content \u00b7 " + fmt_bytes(len(content)))]
|
|
|
+ if tool == "replace_in_file":
|
|
|
+ old = str_text(args.get("old"))
|
|
|
+ if not path or not old:
|
|
|
+ return None
|
|
|
+ if isinstance(args.get("expected_count"), int):
|
|
|
+ bits.append("expect %d" % args["expected_count"])
|
|
|
+ blocks = [head("replace_in_file " + path, bits)]
|
|
|
+ rows = diff_rows(old, str_text(args.get("new")))
|
|
|
+ if rows:
|
|
|
+ blocks.extend(("diff", marker + " " + text) for marker, text in rows)
|
|
|
+ return blocks
|
|
|
+ if tool in ("enqueue_task", "delete_task", "get_task"):
|
|
|
+ if not isinstance(args.get("task_id"), int):
|
|
|
+ return None
|
|
|
+ return [head(tool + " #" + str(args["task_id"]))]
|
|
|
+ if tool in ("update_progress", "add_comment", "save_note"):
|
|
|
+ text = str_text(args.get("text")) or str_text(args.get("body"))
|
|
|
+ if not text:
|
|
|
+ return None
|
|
|
+ if tool == "save_note" and str_text(args.get("project")):
|
|
|
+ bits.append(str_text(args.get("project")))
|
|
|
+ return [head(tool, bits), ("para", text)]
|
|
|
+ if tool == "ask_question":
|
|
|
+ if not question:
|
|
|
+ return None
|
|
|
+ blocks = [head("ask_question"), ("para", "Q: " + question)]
|
|
|
+ if isinstance(args.get("options"), list) and args["options"]:
|
|
|
+ blocks.append(("options", [str(o) for o in args["options"]]))
|
|
|
+ return blocks
|
|
|
+ return None
|
|
|
|
|
|
|
|
|
def command_output_render(evt):
|
|
|
@@ -672,6 +829,21 @@ FIXTURE_OBJECT_CALL = {"turn": 1, "tool": "run_command",
|
|
|
FIXTURE_OBJECT_RESULT = {"turn": 1, "tool": "run_command",
|
|
|
"stdout": "hello\n", "exit_code": 0}
|
|
|
|
|
|
+# Tailored-rendering fixtures (plan 18): the file tools' arg shapes
|
|
|
+# straight off the daemon's tool schemas.
|
|
|
+FIXTURE_REPLACE_CALL = {"turn": 40, "tool": "replace_in_file", "args": {
|
|
|
+ "path": "src/pages/app.css",
|
|
|
+ "old": ".event-turn {\n border-left: 3px solid #ddf4ff;\n}",
|
|
|
+ "new": ".event-turn {\n border-left: 3px solid #ddf4ff;\n color: var(--ink-muted);\n}",
|
|
|
+ "expected_count": 1}}
|
|
|
+FIXTURE_WRITE_CALL = {"turn": 41, "tool": "write_file", "args": {
|
|
|
+ "path": "notes.md", "content": "# Notes\n- first\n- second"}}
|
|
|
+# Args clamped mid-JSON by the daemon's display clamp: parsing
|
|
|
+# fails, so the bespoke renderer must step aside and the generic
|
|
|
+# view shows the raw text.
|
|
|
+FIXTURE_TRUNCATED_ARGS = {"turn": 42, "tool": "write_file", "args":
|
|
|
+ '{"path":"big.bin","content":"AAA'}
|
|
|
+
|
|
|
|
|
|
class PayloadParserTests(unittest.TestCase):
|
|
|
|
|
|
@@ -689,42 +861,164 @@ class PayloadParserTests(unittest.TestCase):
|
|
|
|
|
|
|
|
|
class ToolCallRenderTests(unittest.TestCase):
|
|
|
- """The command the agent ran must surface in EVERY render path."""
|
|
|
+ """The command the agent ran must surface in EVERY render path,
|
|
|
+ and each known tool renders through its tailored body."""
|
|
|
|
|
|
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_run_command_call_heads_with_the_dollar(self):
|
|
|
+ blocks = tool_call_render(self.evt(FIXTURE_RUN_COMMAND_CALL))
|
|
|
+ self.assertEqual(blocks[0], ("head", "$ cd /workspace && ls"))
|
|
|
+
|
|
|
+ def test_run_command_timeout_rides_the_head(self):
|
|
|
+ blocks = tool_call_render(self.evt({"tool": "run_command", "args":
|
|
|
+ {"command": "make", "timeout_ms": 900000}}))
|
|
|
+ self.assertEqual(blocks[0],
|
|
|
+ ("head", "$ make \u00b7 timeout 900s"))
|
|
|
+
|
|
|
+ def test_update_progress_call_renders_prose_not_json(self):
|
|
|
+ """The old renderer dumped {"text": ...} as JSON; the
|
|
|
+ tailored body renders the text itself."""
|
|
|
+ blocks = tool_call_render(self.evt(FIXTURE_UPDATE_PROGRESS_CALL))
|
|
|
+ self.assertEqual(blocks[0], ("head", "update_progress"))
|
|
|
+ self.assertEqual(blocks[1],
|
|
|
+ ("para", "Root-caused the SSE listener; next: the payload parse"))
|
|
|
+ self.assertNotIn('"text"', json.dumps(blocks))
|
|
|
|
|
|
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])
|
|
|
+ blocks = tool_call_render(self.evt(FIXTURE_ENQUEUE_CALL))
|
|
|
+ self.assertEqual(blocks, [("head", "enqueue_task #3")])
|
|
|
+
|
|
|
+ def test_replace_in_file_renders_a_diff(self):
|
|
|
+ blocks = tool_call_render(self.evt(FIXTURE_REPLACE_CALL))
|
|
|
+ self.assertEqual(blocks[0],
|
|
|
+ ("head", "replace_in_file src/pages/app.css \u00b7 expect 1"))
|
|
|
+ diff_texts = [text for kind, text in blocks if kind == "diff"]
|
|
|
+ joined = " ".join(diff_texts)
|
|
|
+ self.assertIn(" .event-turn {", joined)
|
|
|
+ # The untouched border line is context; only the added color
|
|
|
+ # line is an addition - no phantom removals.
|
|
|
+ self.assertIn(" border-left: 3px solid #ddf4ff;", joined)
|
|
|
+ self.assertIn("+ color: var(--ink-muted);", joined)
|
|
|
+ self.assertIn(" }", joined)
|
|
|
+ self.assertFalse([t for t in diff_texts if t.startswith("- ")],
|
|
|
+ "an unchanged line must never render as a removal")
|
|
|
+
|
|
|
+ def test_write_file_folds_its_content(self):
|
|
|
+ blocks = tool_call_render(self.evt(FIXTURE_WRITE_CALL))
|
|
|
+ self.assertEqual(blocks[0], ("head", "write_file notes.md \u00b7 24 B"))
|
|
|
+ self.assertEqual(blocks[1], ("fold", "content \u00b7 24 B"))
|
|
|
+
|
|
|
+ def test_truncated_args_fall_back_to_the_raw_view(self):
|
|
|
+ blocks = tool_call_render(self.evt(FIXTURE_TRUNCATED_ARGS))
|
|
|
+ self.assertEqual(blocks[0], ("head", "write_file"))
|
|
|
+ self.assertEqual(blocks[1][0], "pre")
|
|
|
+ self.assertIn('"path":"big.bin"', blocks[1][1])
|
|
|
+
|
|
|
+ def test_unknown_tool_falls_back_to_json_args(self):
|
|
|
+ blocks = tool_call_render(self.evt(
|
|
|
+ {"tool": "brand_new_tool", "args": {"deep": {"a": 1}}}))
|
|
|
+ self.assertEqual(blocks[0], ("head", "brand_new_tool"))
|
|
|
+ self.assertIn('"deep"', blocks[1][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)
|
|
|
+ blocks = tool_call_render(self.evt(FIXTURE_OBJECT_CALL))
|
|
|
+ self.assertEqual(blocks, [("head", "$ echo hello")])
|
|
|
|
|
|
def test_non_json_args_render_raw(self):
|
|
|
- lines = tool_call_render(self.evt(
|
|
|
+ blocks = tool_call_render(self.evt(
|
|
|
{"tool": "run_command", "args": "not json at all"}))
|
|
|
- self.assertEqual(lines, ["run_command", "not json at all"])
|
|
|
+ self.assertEqual(blocks,
|
|
|
+ [("head", "run_command"), ("pre", "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"])
|
|
|
+ blocks = tool_call_render(self.evt({"tool": "list_tasks"}))
|
|
|
+ self.assertEqual(blocks, [("head", "list_tasks")])
|
|
|
+
|
|
|
+ def test_bespoke_missing_fields_step_aside(self):
|
|
|
+ """A replace_in_file without its old string (clamped args
|
|
|
+ that still parse) falls back rather than lying."""
|
|
|
+ blocks = tool_call_render(self.evt(
|
|
|
+ {"tool": "replace_in_file", "args": {"path": "x.c"}}))
|
|
|
+ self.assertEqual(blocks[0], ("head", "replace_in_file"))
|
|
|
+ self.assertEqual(blocks[1][0], "pre")
|
|
|
+
|
|
|
+
|
|
|
+class DiffRowTests(unittest.TestCase):
|
|
|
+ """The replace_in_file diff: context clamping and honest
|
|
|
+ replacement semantics."""
|
|
|
+
|
|
|
+ def test_single_line_swap(self):
|
|
|
+ rows = diff_rows("alpha\nbeta\ngamma", "alpha\nBETA\ngamma")
|
|
|
+ self.assertEqual(rows, [[" ", "alpha"], ["-", "beta"],
|
|
|
+ ["+", "BETA"], [" ", "gamma"]])
|
|
|
+
|
|
|
+ def test_context_is_clamped_to_two_lines_per_side(self):
|
|
|
+ old = "\n".join(["ctx"] * 10 + ["old-line"] + ["ctx"] * 10)
|
|
|
+ new = "\n".join(["ctx"] * 10 + ["new-line"] + ["ctx"] * 10)
|
|
|
+ rows = diff_rows(old, new)
|
|
|
+ context = [text for marker, text in rows if marker == " "]
|
|
|
+ self.assertEqual(context, ["ctx"] * 4)
|
|
|
+
|
|
|
+ def test_whole_file_rewrite_shows_no_context(self):
|
|
|
+ rows = diff_rows("a\nb\nc", "x\ny\nz")
|
|
|
+ self.assertEqual([m for m, _ in rows], ["-", "-", "-", "+", "+", "+"])
|
|
|
+
|
|
|
+ def test_deletion_only_keeps_its_context(self):
|
|
|
+ rows = diff_rows("keep\ndrop\nkeep", "keep\nkeep")
|
|
|
+ self.assertEqual(rows, [[" ", "keep"], ["-", "drop"], [" ", "keep"]])
|
|
|
+
|
|
|
+ def test_empty_new_text(self):
|
|
|
+ rows = diff_rows("line", "")
|
|
|
+ self.assertEqual(rows, [["-", "line"], ["+", ""]])
|
|
|
+
|
|
|
+ def test_oversized_side_keeps_head_and_tail(self):
|
|
|
+ old = "\n".join("line-%04d" % i for i in range(1000))
|
|
|
+ rows = diff_rows(old, "changed")
|
|
|
+ dels = [text for marker, text in rows if marker == "-"]
|
|
|
+ self.assertEqual(len(dels), 401)
|
|
|
+ self.assertIn("more lines", dels[200])
|
|
|
+ self.assertEqual(dels[0], "line-0000")
|
|
|
+ self.assertEqual(dels[-1], "line-0999")
|
|
|
+
|
|
|
+ def test_identical_text_renders_nothing(self):
|
|
|
+ self.assertEqual(diff_rows("same\nsame", "same\nsame"), [])
|
|
|
+
|
|
|
+
|
|
|
+class UsageLineTests(unittest.TestCase):
|
|
|
+
|
|
|
+ def test_full_payload_is_one_line(self):
|
|
|
+ line = usage_line({"model": "glm-4.7", "prompt_tokens": 45200000,
|
|
|
+ "completion_tokens": 1100000, "cached_tokens": 4500,
|
|
|
+ "elapsed_ms": 3200})
|
|
|
+ self.assertEqual(line,
|
|
|
+ "glm-4.7 \u00b7 45,200,000 in / 1,100,000 out tok "
|
|
|
+ "\u00b7 4,500 cached \u00b7 3.2 s")
|
|
|
+
|
|
|
+ def test_unreported_when_no_numbers(self):
|
|
|
+ self.assertEqual(usage_line({}), "unreported")
|
|
|
+
|
|
|
+ def test_partial_payload_skips_missing_members(self):
|
|
|
+ line = usage_line({"completion_tokens": 5, "elapsed_ms": 40})
|
|
|
+ self.assertEqual(line, "5 out tok \u00b7 40 ms")
|
|
|
+
|
|
|
+
|
|
|
+class StateChangeLineTests(unittest.TestCase):
|
|
|
+
|
|
|
+ def test_from_to_arrow(self):
|
|
|
+ self.assertEqual(
|
|
|
+ state_change_line({"from": "working", "to": "blocked",
|
|
|
+ "text": "Status \u2192 blocked"}),
|
|
|
+ "working \u2192 blocked")
|
|
|
+
|
|
|
+ def test_text_fallback(self):
|
|
|
+ self.assertEqual(
|
|
|
+ state_change_line({"text": "Goal created (branch x)"}),
|
|
|
+ "Goal created (branch x)")
|
|
|
+
|
|
|
+ def test_json_fallback(self):
|
|
|
+ self.assertEqual(state_change_line({}), "{}")
|
|
|
|
|
|
|
|
|
class ToolResultRenderTests(unittest.TestCase):
|
|
|
@@ -920,6 +1214,12 @@ class RendererSourceTests(unittest.TestCase):
|
|
|
self.assertIn("'yeehaw-message'", self.body)
|
|
|
self.assertIn("strText(p.message)", self.body)
|
|
|
|
|
|
+ def test_tailored_renderers_present(self):
|
|
|
+ self.assertIn("function tailoredCallBody(tool, args) {", self.body)
|
|
|
+ self.assertIn("function diffRows(oldText, newText) {", self.body)
|
|
|
+ self.assertIn("'event-diff'", self.body)
|
|
|
+ self.assertIn("tailoredCallBody(tool, args)", self.body)
|
|
|
+
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
unittest.main(verbosity=2)
|