test_task_log_js.py 72 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762
  1. #!/usr/bin/env python3
  2. """Static + logic tests for the task event-log page's client JS.
  3. The app binary cannot be built in every container (statum-mkpstm /
  4. libstatum are build-time dependencies), so this suite verifies the
  5. page (src/pages/task_log.html) and its client JS resource
  6. (src/pages/task_log.js, loaded via pstm-res-src) two ways:
  7. 1. Statically - the script must be ASCII-only (the page compiler
  8. HTML-escapes inline script bodies, so any non-ASCII literal is
  9. re-served as literal entity text like "·" after the
  10. browser's one decode pass; the resource keeps the discipline),
  11. must wire the SSE stream through `onmessage` (the server sends
  12. id:/data: frames with no `event:` name, so named listeners can
  13. never fire), must not listen for an 'append' event name, and its
  14. delimiter structure must balance after strings and comments are
  15. stripped.
  16. 2. Behaviourally - the ingest / render-order / scroll-stick /
  17. load-more logic is ported verbatim below as pure python functions
  18. and unit-tested: oldest-first chronology, id dedupe, the 48px
  19. bottom-stick rule, prepend-mode scroll anchoring, and the
  20. newest-first-page reversal with the `more` -> exhausted flag.
  21. Runnable standalone: python3 test/test_task_log_js.py
  22. """
  23. import json
  24. import os
  25. import re
  26. import unittest
  27. PAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
  28. os.pardir, "src", "pages", "task_log.html")
  29. SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)),
  30. os.pardir, "src", "pages", "task_log.js")
  31. # Goal 41 T7: the interactive shell (toolbar + feed) lives in the
  32. # task-log-feed partial - shared by this page and the task page's
  33. # Log tab - so markup invariants are asserted against it.
  34. SHELL = os.path.join(os.path.dirname(os.path.abspath(__file__)),
  35. os.pardir, "src", "pages", "task_log_feed.html")
  36. def script_body():
  37. # The client JS lives in its own resource file; the page markup
  38. # carries no inline script.
  39. with open(SCRIPT, encoding="utf-8") as handle:
  40. return handle.read()
  41. # ---------------------------------------------------------------------------
  42. # Static checks
  43. # ---------------------------------------------------------------------------
  44. class ScriptSourceTests(unittest.TestCase):
  45. """What the served bytes must look like after compilation."""
  46. def setUp(self):
  47. self.body = script_body()
  48. def test_script_is_ascii_only(self):
  49. """Non-ASCII in a script body comes back as literal entity
  50. text ("·") because the compiler escapes script bodies
  51. and the browser decodes exactly once. ASCII (with \\uXXXX
  52. escapes for the characters we want) is immune."""
  53. offenders = [(index, hex(ord(char)))
  54. for index, char in enumerate(self.body) if ord(char) > 127]
  55. self.assertEqual(offenders, [])
  56. def test_page_loads_the_resource_and_carries_no_inline_script(self):
  57. with open(PAGE, encoding="utf-8") as handle:
  58. markup = handle.read()
  59. self.assertNotIn("<script>", markup,
  60. "the client JS lives in task_log.js, not inline")
  61. self.assertIn('pstm-res-src="task_log.js"', markup)
  62. def test_unicode_escapes_used_for_separators(self):
  63. """The separator characters must still render - as escapes,
  64. not as raw bytes and not as HTML entities."""
  65. for escape in ("\\u00b7", "\\u2026", "\\u2014"):
  66. self.assertIn(escape, self.body)
  67. self.assertNotIn("&middot;", self.body)
  68. self.assertNotIn("&mdash;", self.body)
  69. def test_stream_wired_through_onmessage(self):
  70. self.assertRegex(self.body, r"stream\.onmessage\s*=")
  71. self.assertNotIn("addEventListener('append'", self.body)
  72. self.assertNotIn('addEventListener("append"', self.body)
  73. def test_heartbeat_ping_is_skipped(self):
  74. self.assertRegex(self.body, r"message\.data\s*===\s*'ping'")
  75. def test_load_more_pages_before_oldest(self):
  76. self.assertRegex(self.body, r"/events\?before='\s*\+\s*\(state\.oldest")
  77. def test_page_is_reversed_before_ingest(self):
  78. self.assertIn("slice().reverse().forEach(ingest)", self.body)
  79. def test_exhausted_set_when_no_more(self):
  80. self.assertIn("state.exhausted = true", self.body)
  81. def test_more_button_rides_inside_the_feed(self):
  82. # The load-older control is the feed's FIRST row - it
  83. # scrolls away with the history instead of sitting above
  84. # the pane. Search the shell markup only (the script's
  85. # comments also mention [data-log-feed]).
  86. # Goal 41 T7: the shell moved into the task-log-feed
  87. # partial (task_log_feed.html) - the SAME markup the task
  88. # page's Log tab embeds - so the invariant is asserted
  89. # there.
  90. with open(SHELL, encoding="utf-8") as handle:
  91. markup = handle.read().split("</script>", 1)[-1]
  92. feed = markup.index("data-log-feed")
  93. more = markup.index("data-log-more", feed)
  94. close = markup.index("</div>", feed)
  95. self.assertLess(feed, more)
  96. self.assertLess(more, close,
  97. "the load-older control must live inside the feed container")
  98. self.assertLess(more, markup.index("<p", feed),
  99. "the load-older control must be the feed's first row")
  100. def test_delimiters_balance(self):
  101. """A cheap structural stand-in for a JS parser (none is
  102. installed here): after stripping comments and string literals
  103. every bracket must close."""
  104. body = self.body
  105. out = []
  106. i = 0
  107. mode = None # None | 'line' | 'block' | "'" | '"'
  108. while i < len(body):
  109. char = body[i]
  110. nxt = body[i + 1] if i + 1 < len(body) else ""
  111. if mode is None:
  112. if char == "/" and nxt == "/":
  113. mode = "line"
  114. i += 2
  115. continue
  116. if char == "/" and nxt == "*":
  117. mode = "block"
  118. i += 2
  119. continue
  120. if char in "'\"":
  121. mode = char
  122. i += 1
  123. continue
  124. out.append(char)
  125. elif mode == "line":
  126. if char == "\n":
  127. mode = None
  128. out.append("\n")
  129. elif mode == "block":
  130. if char == "*" and nxt == "/":
  131. mode = None
  132. i += 2
  133. continue
  134. else: # inside a string literal
  135. if char == "\\":
  136. i += 2
  137. continue
  138. if char == mode:
  139. mode = None
  140. i += 1
  141. self.assertEqual(mode, None, "unterminated comment or string literal")
  142. pairs = {")": "(", "]": "[", "}": "{"}
  143. stack = []
  144. for char in out:
  145. if char in "([{":
  146. stack.append(char)
  147. elif char in pairs:
  148. self.assertTrue(stack and stack[-1] == pairs[char],
  149. "unbalanced %r at depth %s" % (char, stack))
  150. stack.pop()
  151. self.assertEqual(stack, [], "unclosed delimiters %s" % stack)
  152. # ---------------------------------------------------------------------------
  153. # Port of the client logic (keep in step with task_log.html)
  154. # ---------------------------------------------------------------------------
  155. STICK_THRESHOLD = 48 # px that still counts as "reading the tail"
  156. PAGE_SIZE = 30
  157. class Feed(object):
  158. """Scroll-state stand-in for [data-log-feed]."""
  159. def __init__(self, scroll_height=0, client_height=600, scroll_top=0):
  160. self.scroll_height = scroll_height
  161. self.client_height = client_height
  162. self.scroll_top = scroll_top
  163. def at_bottom(feed):
  164. """PORT: a feed shorter than its viewport counts as at-bottom."""
  165. return feed.scroll_height - feed.scroll_top - feed.client_height \
  166. <= STICK_THRESHOLD
  167. class State(object):
  168. """PORT: the module-level `state` object."""
  169. def __init__(self):
  170. self.events = []
  171. self.oldest = 0
  172. self.exhausted = False
  173. self.loading = False
  174. self.seeded = False
  175. def ingest(state, evt):
  176. """PORT: keep state.events oldest-first (ascending id); skip ids
  177. already held (SSE replay after a reconnect resends a frame)."""
  178. i = 0
  179. while i < len(state.events) and state.events[i]["id"] < evt["id"]:
  180. i += 1
  181. if i < len(state.events) and state.events[i]["id"] == evt["id"]:
  182. return
  183. state.events.insert(i, evt)
  184. if evt["id"] < state.oldest or state.oldest == 0:
  185. state.oldest = evt["id"]
  186. def render(state, feed, mode=None, height_after=None):
  187. """PORT of render(mode): decide the post-render scroll position.
  188. height_after stands in for the DOM rebuild's new scrollHeight."""
  189. was_at_bottom = at_bottom(feed)
  190. height_before = feed.scroll_height
  191. scroll_top_before = feed.scroll_top
  192. if height_after is not None:
  193. feed.scroll_height = height_after
  194. if not state.seeded and len(state.events) > 0:
  195. state.seeded = True
  196. feed.scroll_top = feed.scroll_height # scrollToBottom()
  197. elif mode == "prepend":
  198. if was_at_bottom:
  199. feed.scroll_top = feed.scroll_height
  200. else:
  201. feed.scroll_top = scroll_top_before + (feed.scroll_height
  202. - height_before)
  203. elif was_at_bottom:
  204. feed.scroll_top = feed.scroll_height
  205. else:
  206. feed.scroll_top = scroll_top_before
  207. return feed.scroll_top
  208. def server_page(newest, oldest, more):
  209. """The endpoint's wire shape: newest-first within the page."""
  210. return {"events": [{"id": i} for i in range(newest, oldest - 1, -1)],
  211. "more": more}
  212. def load_more(state, feed, page, height_after):
  213. """PORT of loadMore's success path: reverse the newest-first page
  214. into chronological order, ingest it, set exhausted, render."""
  215. rows = page["events"]
  216. for evt in reversed(rows):
  217. ingest(state, evt)
  218. if not page["more"] or len(rows) == 0:
  219. state.exhausted = True
  220. return render(state, feed, mode="prepend", height_after=height_after)
  221. def on_stream_message(state, feed, data, height_after):
  222. """PORT of onStreamMessage: skip heartbeats, ingest one row."""
  223. if data == "ping":
  224. return feed.scroll_top
  225. ingest(state, {"id": int(data)})
  226. return render(state, feed, mode="append", height_after=height_after)
  227. # ---------------------------------------------------------------------------
  228. # Behaviour tests
  229. # ---------------------------------------------------------------------------
  230. def ids(state):
  231. return [evt["id"] for evt in state.events]
  232. class ChronologyTests(unittest.TestCase):
  233. def test_initial_window_renders_oldest_first(self):
  234. """The SSE replay / first JSON page arrives newest-first
  235. ([30..1]); the feed must render [1..30] - oldest at the top."""
  236. state = State()
  237. for evt in server_page(30, 1, True)["events"]:
  238. ingest(state, evt)
  239. self.assertEqual(ids(state), list(range(1, 31)))
  240. self.assertEqual(state.oldest, 1)
  241. def test_live_appends_land_in_order(self):
  242. state = State()
  243. for i in range(30, 0, -1):
  244. ingest(state, {"id": i})
  245. ingest(state, {"id": 31})
  246. ingest(state, {"id": 33})
  247. ingest(state, {"id": 32}) # out of order arrival
  248. self.assertEqual(ids(state)[-3:], [31, 32, 33])
  249. def test_duplicate_ids_are_dropped(self):
  250. state = State()
  251. ingest(state, {"id": 5})
  252. ingest(state, {"id": 5}) # EventSource reconnect replay
  253. self.assertEqual(ids(state), [5])
  254. class ScrollStickTests(unittest.TestCase):
  255. def test_first_render_with_rows_seeds_at_the_bottom(self):
  256. state = State()
  257. feed = Feed(scroll_height=0, client_height=600)
  258. for evt in server_page(30, 1, True)["events"]:
  259. ingest(state, evt)
  260. render(state, feed, height_after=3000)
  261. self.assertTrue(state.seeded)
  262. self.assertEqual(feed.scroll_top, 3000)
  263. def test_append_sticks_when_reading_the_tail(self):
  264. state = State()
  265. feed = Feed(scroll_height=3000, client_height=600)
  266. state.seeded = True
  267. feed.scroll_top = 2400 # exactly at the bottom
  268. self.assertTrue(at_bottom(feed))
  269. ingest(state, {"id": 31})
  270. render(state, feed, mode="append", height_after=3100)
  271. self.assertEqual(feed.scroll_top, 3100)
  272. def test_append_within_threshold_still_sticks(self):
  273. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2360)
  274. self.assertTrue(at_bottom(feed)) # 40px shy: close enough
  275. def test_just_past_threshold_is_not_the_tail(self):
  276. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2351)
  277. self.assertFalse(at_bottom(feed)) # 49px shy: reading history
  278. def test_scrolled_up_reader_is_never_yanked(self):
  279. state = State()
  280. feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
  281. state.seeded = True
  282. self.assertFalse(at_bottom(feed))
  283. ingest(state, {"id": 31})
  284. render(state, feed, mode="append", height_after=3100)
  285. self.assertEqual(feed.scroll_top, 500)
  286. def test_short_feed_counts_as_at_bottom(self):
  287. """A feed shorter than its viewport cannot be scrolled away
  288. from the newest row."""
  289. feed = Feed(scroll_height=100, client_height=600, scroll_top=0)
  290. self.assertTrue(at_bottom(feed))
  291. def test_heartbeat_does_not_touch_anything(self):
  292. state = State()
  293. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  294. state.seeded = True
  295. before = (list(ids(state)), feed.scroll_top)
  296. on_stream_message(state, feed, "ping", height_after=3100)
  297. self.assertEqual((list(ids(state)), feed.scroll_top), before)
  298. class LoadMoreTests(unittest.TestCase):
  299. def test_older_page_is_reversed_and_prepended(self):
  300. """before=<oldest>=1 returns [60..31] newest-first on the
  301. wire; the display must gain [31..60] above [1..30]."""
  302. state = State()
  303. feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
  304. for evt in server_page(30, 1, True)["events"]:
  305. ingest(state, evt)
  306. state.seeded = True
  307. render(state, feed, height_after=3000)
  308. feed.scroll_top = 500 # the reader scrolled up into history
  309. load_more(state, feed, server_page(60, 31, True), height_after=6000)
  310. self.assertEqual(ids(state), list(range(1, 61)))
  311. self.assertEqual(state.oldest, 1)
  312. self.assertFalse(state.exhausted)
  313. def test_prepend_keeps_the_viewport_anchored(self):
  314. """Content added above must not move the rows under the
  315. reader's cursor: scrollTop grows by the added height."""
  316. state = State()
  317. feed = Feed(scroll_height=3000, client_height=600, scroll_top=500)
  318. state.seeded = True
  319. self.assertFalse(at_bottom(feed))
  320. delta = 3000
  321. load_more(state, feed, server_page(60, 31, True),
  322. height_after=3000 + delta)
  323. self.assertEqual(feed.scroll_top, 500 + delta)
  324. def test_prepend_while_at_bottom_stays_at_bottom(self):
  325. state = State()
  326. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  327. state.seeded = True
  328. self.assertTrue(at_bottom(feed))
  329. load_more(state, feed, server_page(60, 31, True), height_after=6000)
  330. self.assertEqual(feed.scroll_top, 6000)
  331. def test_exhausted_when_more_is_false(self):
  332. state = State()
  333. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  334. state.seeded = True
  335. load_more(state, feed, server_page(60, 31, False), height_after=6000)
  336. self.assertTrue(state.exhausted)
  337. def test_empty_page_exhausts_too(self):
  338. """A defensive guard: a page with no rows means no older
  339. history, whatever the flag says."""
  340. state = State()
  341. feed = Feed(scroll_height=3000, client_height=600, scroll_top=2400)
  342. state.seeded = True
  343. ingest(state, {"id": 1})
  344. load_more(state, feed, {"events": [], "more": True}, height_after=3000)
  345. self.assertTrue(state.exhausted)
  346. class EndToEndSequenceTests(unittest.TestCase):
  347. """The whole first-minute story: replay, live tail, history."""
  348. def test_replay_then_append_then_history(self):
  349. state = State()
  350. feed = Feed(scroll_height=0, client_height=600)
  351. # 1. SSE replay: most-recent-30 oldest-first on the wire.
  352. for evt in server_page(30, 1, True)["events"]:
  353. ingest(state, evt)
  354. render(state, feed, height_after=3000)
  355. self.assertEqual(ids(state), list(range(1, 31)))
  356. self.assertEqual(feed.scroll_top, 3000) # seeded at the tail
  357. # 2. Two live rows arrive; the reader is at the bottom.
  358. on_stream_message(state, feed, "31", height_after=3100)
  359. on_stream_message(state, feed, "32", height_after=3200)
  360. self.assertEqual(ids(state)[-2:], [31, 32])
  361. self.assertEqual(feed.scroll_top, 3200)
  362. # 3. A heartbeat is ignored, then the reader scrolls up.
  363. on_stream_message(state, feed, "ping", height_after=3200)
  364. feed.scroll_top = 100
  365. on_stream_message(state, feed, "33", height_after=3300)
  366. self.assertEqual(feed.scroll_top, 100) # never yanked
  367. # 4. Load older: before=1 -> [60..31] newest-first wire page.
  368. self.assertEqual(state.oldest, 1)
  369. load_more(state, feed, server_page(60, 31, False), height_after=6300)
  370. self.assertEqual(ids(state), list(range(1, 61)))
  371. self.assertTrue(state.exhausted)
  372. self.assertEqual(feed.scroll_top, 100 + 3000) # anchored
  373. # ---------------------------------------------------------------------------
  374. # Payload contract: the daemon JSON-encodes args/result into STRINGS
  375. # ---------------------------------------------------------------------------
  376. def parse_maybe_json(value):
  377. """PORT of parseMaybeJson: strings decode, objects pass through,
  378. a non-JSON string answers None so the caller can fall back to
  379. rendering the raw text."""
  380. if not isinstance(value, str):
  381. return value
  382. try:
  383. return json.loads(value)
  384. except ValueError:
  385. return None
  386. def is_plain_object(value):
  387. """PORT of isPlainObject."""
  388. return isinstance(value, dict)
  389. def fmt_bytes(n):
  390. """PORT of fmtBytes."""
  391. if n < 1024:
  392. return "%d B" % n
  393. if n < 1024 * 1024:
  394. return "%.1f KiB" % (n / 1024)
  395. return "%.1f MiB" % (n / (1024 * 1024))
  396. def str_text(value):
  397. """PORT of strText."""
  398. return value if isinstance(value, str) else ""
  399. def filter_of(kind):
  400. """PORT of filterOf: which filter bucket owns a kind. The yeehaw
  401. celebration lands in the comments bucket (goal 9 task 4b)."""
  402. if kind == "tool_call":
  403. return "commands"
  404. if kind in ("tool_result", "command_output"):
  405. return "results"
  406. if kind in ("comment", "answer", "verdict", "yeehaw"):
  407. return "comments"
  408. if kind == "usage":
  409. return "usage"
  410. return "transitions"
  411. def yeehaw_render(evt):
  412. """PORT of eventNode's yeehaw branch -> (span_class, message)."""
  413. payload = evt.get("payload") or {}
  414. return "yeehaw-message", str_text(payload.get("message"))
  415. def fmt_duration(ms):
  416. """PORT of fmtDuration."""
  417. if ms < 1000:
  418. return "%d ms" % ms
  419. return "%.1f s" % (ms / 1000)
  420. def str_bytes(value):
  421. """PORT of strBytes."""
  422. return len(value) if isinstance(value, str) else 0
  423. def result_envelope(evt):
  424. """PORT of resultEnvelope: the parsed result string when it
  425. decodes to an object, else the object-shaped payload itself."""
  426. payload = evt.get("payload") or {}
  427. parsed = parse_maybe_json(payload.get("result"))
  428. if is_plain_object(parsed):
  429. return parsed
  430. if is_plain_object(payload):
  431. return payload
  432. return {}
  433. RESULT_PLUMBING = ("ok", "exit_code", "stdout", "stderr", "bytes_out",
  434. "bytes_err", "truncated", "duration_ms", "turn", "tool")
  435. def result_extras(envelope):
  436. """PORT of resultExtras: the envelope minus command plumbing
  437. and minus the note members (rendered separately)."""
  438. return dict((key, value) for key, value in envelope.items()
  439. if key not in RESULT_PLUMBING
  440. and key not in ("error", "note", "timeout"))
  441. def result_bits(evt):
  442. """PORT of resultBits -> the metric bits shared by the expander
  443. summary and the inline line."""
  444. envelope = result_envelope(evt)
  445. payload = evt.get("payload") or {}
  446. bits = []
  447. if envelope.get("exit_code") is not None:
  448. bits.append("exit %s" % envelope["exit_code"])
  449. elif envelope.get("ok") is False:
  450. bits.append("failed")
  451. if isinstance(envelope.get("bytes_out"), int) \
  452. and isinstance(envelope.get("bytes_err"), int):
  453. bytes_count = envelope["bytes_out"] + envelope["bytes_err"]
  454. else:
  455. bytes_count = str_bytes(envelope.get("stdout")) \
  456. + str_bytes(envelope.get("stderr"))
  457. if bytes_count > 0:
  458. bits.append(fmt_bytes(bytes_count))
  459. if envelope.get("truncated"):
  460. bits.append("truncated")
  461. if isinstance(envelope.get("duration_ms"), int):
  462. bits.append(fmt_duration(envelope["duration_ms"]))
  463. if payload.get("turn") is not None:
  464. bits.append("turn %s" % payload["turn"])
  465. if not bits and isinstance(payload.get("result"), str) \
  466. and not is_plain_object(parse_maybe_json(payload["result"])):
  467. bits.append(fmt_bytes(len(payload["result"])))
  468. return bits
  469. INLINE_RESULT_LIMIT = 220
  470. def result_view(evt, attached=False):
  471. """PORT of resultView -> (inline, bits_text, body_text): a short
  472. body renders inline under its metric bits; a long one collapses
  473. behind the expander summary - UNLESS it rides attached to its
  474. call, where the output is always visible (CSS clamps oversized
  475. bodies)."""
  476. out, err, note, extras, no_output = result_output(evt)
  477. parts = [part for part in (out, err, note, extras) if part]
  478. if no_output:
  479. parts = ["(no output)"]
  480. body_text = "\n".join(parts).strip()
  481. inline = attached or len(body_text) <= INLINE_RESULT_LIMIT
  482. return inline, " \u00b7 ".join(result_bits(evt)), body_text
  483. def result_summary(evt):
  484. """PORT of resultSummary -> the summary line's text."""
  485. bits = result_bits(evt)
  486. return "result" + (" (" + ", ".join(bits) + ")" if bits else "")
  487. def result_output(evt):
  488. """PORT of resultBody's text decisions -> (stdout_text,
  489. stderr_text, note_text, extras_text, no_output_flag). The extras
  490. go through the tailored result body first (a read window, a
  491. replacement count, a task list, an ok ack), then raw JSON."""
  492. envelope = result_envelope(evt)
  493. payload = evt.get("payload") or {}
  494. parsed = parse_maybe_json(payload.get("result"))
  495. out = err = ""
  496. if is_plain_object(parsed):
  497. out = str_text(envelope.get("stdout"))
  498. err = str_text(envelope.get("stderr"))
  499. elif isinstance(parsed, str):
  500. out = parsed # a JSON string literal
  501. elif isinstance(payload.get("result"), str):
  502. out = payload["result"]
  503. else:
  504. out = str_text(envelope.get("stdout"))
  505. err = str_text(envelope.get("stderr"))
  506. note = ""
  507. extras = ""
  508. if not out and not err:
  509. note = str_text(envelope.get("error")) or str_text(envelope.get("note")) \
  510. or ("timed out" if envelope.get("timeout") else "")
  511. if not note:
  512. remainder = result_extras(envelope)
  513. if remainder:
  514. tailored = tailored_result_text(
  515. str_text(payload.get("tool")), remainder)
  516. if tailored is not None:
  517. extras = tailored
  518. else:
  519. compact = json.dumps(remainder)
  520. extras = compact if len(compact) <= 120 \
  521. else json.dumps(remainder, indent=2)
  522. return out, err, note, extras, (not out and not err and not note and not extras)
  523. def task_list_text(items):
  524. """PORT of taskList -> one '#id title status' line per item."""
  525. lines = []
  526. for item in items:
  527. if not is_plain_object(item):
  528. continue
  529. line = "#%s %s" % (item.get("id", "?"), str_text(item.get("title")))
  530. if str_text(item.get("status")):
  531. line += " " + str_text(item.get("status"))
  532. lines.append(line)
  533. return "\n".join(lines)
  534. def tailored_result_text(tool, extras):
  535. """PORT of tailoredResultBody -> the rendered text, or None for
  536. the generic JSON view."""
  537. if str_text(extras.get("content")) or isinstance(extras.get("total_lines"), int):
  538. meta = []
  539. if str_text(extras.get("path")):
  540. meta.append(str_text(extras.get("path")))
  541. if isinstance(extras.get("total_lines"), int):
  542. start = extras.get("line_start") \
  543. if isinstance(extras.get("line_start"), int) else 1
  544. end = extras.get("line_end") \
  545. if isinstance(extras.get("line_end"), int) else extras["total_lines"]
  546. meta.append("lines %d-%d of %d"
  547. % (start, end, extras["total_lines"]))
  548. if isinstance(extras.get("bytes"), int):
  549. meta.append(fmt_bytes(extras["bytes"]))
  550. else:
  551. if str_text(extras.get("representation")):
  552. meta.append(str_text(extras.get("representation")))
  553. if isinstance(extras.get("bytes_read"), int):
  554. window = fmt_bytes(extras["bytes_read"])
  555. if isinstance(extras.get("total_bytes"), int):
  556. window += " of " + fmt_bytes(extras["total_bytes"])
  557. meta.append(window)
  558. if isinstance(extras.get("offset"), int) and extras["offset"] > 0:
  559. meta.append("at " + fmt_bytes(extras["offset"]))
  560. if extras.get("truncated"):
  561. meta.append("truncated")
  562. parts = [" \u00b7 ".join(meta)]
  563. if str_text(extras.get("content")):
  564. # The content rides behind an expander: a read is
  565. # reference material, not the action's output.
  566. parts.append("[fold] content \u00b7 "
  567. + fmt_bytes(len(str_text(extras.get("content")))))
  568. return "\n".join(parts)
  569. if tool == "replace_in_file" and isinstance(extras.get("replacements"), int):
  570. count = extras["replacements"]
  571. written = extras.get("bytes_written") \
  572. if isinstance(extras.get("bytes_written"), int) else 0
  573. return "%d replacement%s in %s \u00b7 %s" % (
  574. count, "" if count == 1 else "s",
  575. str_text(extras.get("path")), fmt_bytes(written))
  576. if tool == "write_file" and isinstance(extras.get("bytes"), int):
  577. return "wrote %s \u00b7 %s" % (str_text(extras.get("path")),
  578. fmt_bytes(extras["bytes"]))
  579. if tool == "overwrite_file" and isinstance(extras.get("bytes_written"), int):
  580. text = "rewrote %s \u00b7 %s" % (str_text(extras.get("path")),
  581. fmt_bytes(extras["bytes_written"]))
  582. if isinstance(extras.get("previous_bytes"), int):
  583. text += " (was %s)" % fmt_bytes(extras["previous_bytes"])
  584. return text
  585. if tool == "create_task" and isinstance(extras.get("task_id"), int):
  586. text = "created task #%d" % extras["task_id"]
  587. if str_text(extras.get("status")):
  588. text += " \u00b7 " + str_text(extras.get("status"))
  589. return text
  590. if tool == "get_task" and "id" in extras:
  591. return task_list_text([extras])
  592. for key in ("tasks", "goals"):
  593. if isinstance(extras.get(key), list):
  594. return task_list_text(extras[key])
  595. keys = list(extras)
  596. all_true = len(keys) > 0 and all(extras[key] is True for key in keys)
  597. all_scalars = 0 < len(keys) <= 4 and all(
  598. extras[key] is None or not isinstance(extras[key], (dict, list))
  599. for key in keys)
  600. if all_true:
  601. return "ok"
  602. if all_scalars:
  603. return " \u00b7 ".join("%s: %s" % (key, extras[key]) for key in keys)
  604. return None
  605. def args_without(args, skip):
  606. """PORT of argsWithout: every member except `skip`, pretty-
  607. printed; '' when nothing is left."""
  608. rest = dict((key, value) for key, value in args.items() if key != skip)
  609. return json.dumps(rest, indent=2) if rest else ""
  610. def tool_call_render(evt):
  611. """PORT of toolCallBody -> the rendered blocks as (kind, text)
  612. pairs: ('head', line) for a command-styled head, ('pre', text)
  613. for plain pre lines, ('para', text) for prose, ('fold', label)
  614. for collapsed payloads, ('diff', '- old') / ('diff', '+ new')
  615. for the replace_in_file diff. Bespoke bodies come from
  616. tailored_blocks; None falls back to the generic view."""
  617. payload = evt.get("payload") or {}
  618. tool = str_text(payload.get("tool")) or "tool"
  619. parsed = parse_maybe_json(payload.get("args"))
  620. args = parsed if is_plain_object(parsed) else None
  621. if args is not None:
  622. blocks = tailored_blocks(tool, args)
  623. if blocks is not None:
  624. return blocks
  625. blocks = [("head", tool)]
  626. if args is not None:
  627. rest = args_without(args, "command")
  628. if rest:
  629. blocks.append(("pre", rest))
  630. elif str_text(payload.get("args")):
  631. blocks.append(("pre", payload["args"]))
  632. elif payload.get("args") is not None and not isinstance(payload.get("args"), str):
  633. blocks.append(("pre", json.dumps(payload["args"], indent=2)))
  634. return blocks
  635. def fmt_int(n):
  636. """PORT of fmtInt: thousands separators without locale."""
  637. digits = str(abs(int(round(n))))
  638. out = ""
  639. for i, char in enumerate(digits):
  640. if i > 0 and (len(digits) - i) % 3 == 0:
  641. out += ","
  642. out += char
  643. return ("-" if n < 0 else "") + out
  644. def usage_line(payload):
  645. """PORT of eventNode's usage branch -> the one-line text. The
  646. daemon may report either token count alone."""
  647. bits = []
  648. if str_text(payload.get("model")):
  649. bits.append(payload["model"])
  650. in_tok = "%s in" % fmt_int(payload["prompt_tokens"]) \
  651. if isinstance(payload.get("prompt_tokens"), int) else ""
  652. out_tok = "%s out" % fmt_int(payload["completion_tokens"]) \
  653. if isinstance(payload.get("completion_tokens"), int) else ""
  654. if in_tok or out_tok:
  655. bits.append(in_tok + (" / " if in_tok and out_tok else "") + out_tok + " tok")
  656. if isinstance(payload.get("cached_tokens"), int):
  657. bits.append("%s cached" % fmt_int(payload["cached_tokens"]))
  658. if isinstance(payload.get("elapsed_ms"), int):
  659. bits.append(fmt_duration(payload["elapsed_ms"]))
  660. return " \u00b7 ".join(bits) if bits else "unreported"
  661. def state_change_line(payload):
  662. """PORT of eventNode's state_change branch."""
  663. from_status = str_text(payload.get("from"))
  664. to_status = str_text(payload.get("to"))
  665. if from_status and to_status:
  666. return from_status + " \u2192 " + to_status
  667. return str_text(payload.get("text")) or json.dumps(payload)
  668. # --- Quiet-call folding + result attachment (the live-tail polish):
  669. # a successful side-effect tool whose content already renders as its
  670. # own event folds its call/ack pair away; every other call carries
  671. # its result as an attached block instead of a separate row. ---
  672. FOLDABLE_TOOLS = {"update_progress": "progress", "add_comment": "comment",
  673. "yeehaw": "yeehaw"}
  674. def quiet_result(evt):
  675. """PORT of quietResult: an ack-shaped success with nothing to
  676. say - ok, no error/note/timeout, no output, extras only true/
  677. false/empty-string/row pointers (_id integers whose content
  678. renders elsewhere)."""
  679. envelope = result_envelope(evt)
  680. if envelope.get("ok") is False:
  681. return False
  682. if isinstance(envelope.get("exit_code"), int) and envelope["exit_code"] != 0:
  683. return False
  684. if str_text(envelope.get("error")) or str_text(envelope.get("note")):
  685. return False
  686. if envelope.get("timeout"):
  687. return False
  688. if str_text(envelope.get("stdout")) or str_text(envelope.get("stderr")):
  689. return False
  690. for key, value in result_extras(envelope).items():
  691. if value is True or value is False:
  692. continue
  693. if isinstance(value, str) and value == "":
  694. continue
  695. if isinstance(value, int) and key.endswith("_id"):
  696. continue
  697. return False
  698. return True
  699. def same_session(a, b):
  700. """PORT of sameSession: equal, or either side unlabelled."""
  701. return a == b or a is None or b is None
  702. def match_result_index(rows, index):
  703. """PORT of matchResultIndex: the first tool_result after the
  704. call is its own (one call per turn). The live wire's result
  705. payloads carry no turn of their own - matching rides on order,
  706. tool name and session; a turn mismatch (both present) is the
  707. only rejection."""
  708. evt = rows[index]
  709. payload = evt.get("payload") or {}
  710. if not isinstance(payload.get("turn"), int):
  711. return -1
  712. for j in range(index + 1, len(rows)):
  713. other = rows[j]
  714. if other.get("kind") == "tool_result":
  715. other_payload = other.get("payload") or {}
  716. other_turn = other_payload.get("turn")
  717. if (other_turn is None or other_turn == payload.get("turn")) \
  718. and same_session(other.get("session_id"), evt.get("session_id")) \
  719. and str_text(other_payload.get("tool")) == str_text(payload.get("tool")):
  720. return j
  721. return -1
  722. if other.get("kind") == "tool_call":
  723. return -1
  724. return -1
  725. def folded_quiet_calls(rows):
  726. """PORT of foldedQuietCalls -> the folded row indices."""
  727. folded = set()
  728. for index, evt in enumerate(rows):
  729. if evt.get("kind") != "tool_call":
  730. continue
  731. payload = evt.get("payload") or {}
  732. if not isinstance(payload.get("turn"), int):
  733. continue
  734. companion = FOLDABLE_TOOLS.get(str_text(payload.get("tool")))
  735. if not companion:
  736. continue
  737. result_index = match_result_index(rows, index)
  738. if result_index < 0:
  739. continue
  740. has_companion = any(
  741. rows[k].get("kind") == companion
  742. and same_session(rows[k].get("session_id"), evt.get("session_id"))
  743. for k in range(index + 1, result_index))
  744. if has_companion and quiet_result(rows[result_index]):
  745. folded.add(index)
  746. folded.add(result_index)
  747. return folded
  748. def attach_pairs(rows, folded):
  749. """PORT of render's attachment walk -> (calls_by_id,
  750. rides_by_id): every non-folded call carries its matching
  751. result; the result then never renders as its own row."""
  752. calls_by_id = {}
  753. rides_by_id = {}
  754. for index, evt in enumerate(rows):
  755. if index in folded or evt.get("kind") != "tool_call":
  756. continue
  757. result_index = match_result_index(rows, index)
  758. if result_index >= 0 and result_index not in folded:
  759. calls_by_id[evt["id"]] = rows[result_index]["id"]
  760. rides_by_id[rows[result_index]["id"]] = True
  761. return calls_by_id, rides_by_id
  762. # --- The state badge: only a working (or assigned) task streams
  763. # live; every other status names itself on the pill, still. ---
  764. LIVE_STATUSES = {"working", "assigned"}
  765. BADGE_LABELS = {"suspended": "parked", "review_requested": "review requested"}
  766. def badge_line(status, stream_state="live"):
  767. """PORT of updateBadge -> (class, label)."""
  768. if status in LIVE_STATUSES:
  769. if stream_state == "retry":
  770. return "log-live retry", "reconnecting..."
  771. return "log-live", "live"
  772. label = BADGE_LABELS.get(status, status) or "idle"
  773. return "log-live still badge badge-" + status, label
  774. DIFF_CONTEXT = 2
  775. DIFF_MAX_LINES = 400
  776. def _push_side(rows, lines, begin, end, marker):
  777. """PORT of pushSide."""
  778. count = end - begin
  779. half = DIFF_MAX_LINES // 2
  780. if count <= DIFF_MAX_LINES:
  781. for i in range(begin, end):
  782. rows.append([marker, lines[i]])
  783. return
  784. for j in range(begin, begin + half):
  785. rows.append([marker, lines[j]])
  786. rows.append([marker, "... %d more lines ..." % (count - half * 2)])
  787. for k in range(end - half, end):
  788. rows.append([marker, lines[k]])
  789. def diff_rows(old_text, new_text):
  790. """PORT of diffRows: common head/tail become clamped context,
  791. the differing middle renders as -old/+new."""
  792. old_lines = old_text.split("\n")
  793. new_lines = new_text.split("\n")
  794. start = 0
  795. while start < len(old_lines) and start < len(new_lines) \
  796. and old_lines[start] == new_lines[start]:
  797. start += 1
  798. end_old, end_new = len(old_lines), len(new_lines)
  799. while end_old > start and end_new > start \
  800. and old_lines[end_old - 1] == new_lines[end_new - 1]:
  801. end_old -= 1
  802. end_new -= 1
  803. rows = []
  804. has_middle = end_old > start or end_new > start
  805. if has_middle:
  806. for i in range(max(0, start - DIFF_CONTEXT), start):
  807. rows.append([" ", old_lines[i]])
  808. _push_side(rows, old_lines, start, end_old, "-")
  809. _push_side(rows, new_lines, start, end_new, "+")
  810. for i in range(end_old, min(len(old_lines), end_old + DIFF_CONTEXT)):
  811. rows.append([" ", old_lines[i]])
  812. return rows
  813. FILE_LANGUAGES = {".vala": "Vala", ".vapi": "Vapi", ".c": "C", ".h": "C",
  814. ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
  815. ".html": "HTML", ".css": "CSS", ".json": "JSON",
  816. ".md": "Markdown", ".sh": "Shell", ".bash": "Shell",
  817. ".xml": "XML", ".yml": "YAML", ".yaml": "YAML",
  818. ".sql": "SQL", ".toml": "TOML", ".ini": "INI",
  819. ".cfg": "Config", ".diff": "Diff", ".patch": "Diff",
  820. ".rst": "reST", ".txt": "Text"}
  821. def language_of(path):
  822. """PORT of languageOf: the extension chip, with meson.build and
  823. makefile as whole-name matches; '' when nothing matches."""
  824. lower = path.lower()
  825. name = lower[lower.rfind("/") + 1:]
  826. if name == "meson.build":
  827. return "Meson"
  828. if name == "makefile":
  829. return "Make"
  830. dot = name.rfind(".")
  831. if dot < 1 or dot == len(name) - 1:
  832. return ""
  833. return FILE_LANGUAGES.get(name[dot:], "")
  834. def content_bits(content):
  835. """PORT of contentBits: line count and byte size."""
  836. lines = content.count("\n") + 1
  837. return ["%d line%s" % (lines, "" if lines == 1 else "s"),
  838. fmt_bytes(len(content))]
  839. def tailored_blocks(tool, args):
  840. """PORT of tailoredCallBody -> the bespoke blocks, or None to
  841. fall back to the generic JSON view (fields missing - e.g. a
  842. truncated args string that clamped mid-JSON)."""
  843. path = str_text(args.get("path"))
  844. command = str_text(args.get("command"))
  845. question = str_text(args.get("question"))
  846. bits = []
  847. def head(text, more=None):
  848. block = ("head", text)
  849. if more:
  850. block = ("head", text + " \u00b7 " + " \u00b7 ".join(more))
  851. return block
  852. if tool == "run_command":
  853. if not command:
  854. return None
  855. if isinstance(args.get("timeout_ms"), int):
  856. bits.append("timeout %ds" % round(args["timeout_ms"] / 1000))
  857. return [head("$ " + command, bits)]
  858. if tool == "read_file":
  859. if not path:
  860. return None
  861. if isinstance(args.get("offset"), int) and args["offset"] > 1:
  862. bits.append("from line %d" % args["offset"])
  863. if isinstance(args.get("limit"), int):
  864. bits.append("%d lines" % args["limit"])
  865. return [head("read_file " + path, bits)]
  866. if tool in ("write_file", "overwrite_file", "temp_doc"):
  867. content = str_text(args.get("content"))
  868. if not content:
  869. return None
  870. if tool == "temp_doc":
  871. return [head("temp_doc", content_bits(content)), ("pre", content)]
  872. if not path:
  873. return None
  874. more = content_bits(content)
  875. language = language_of(path)
  876. if language:
  877. more.insert(0, language)
  878. return [head(tool + " " + path, more), ("pre", content)]
  879. if tool == "replace_in_file":
  880. old = str_text(args.get("old"))
  881. if not path or not old:
  882. return None
  883. if isinstance(args.get("expected_count"), int):
  884. bits.append("expect %d" % args["expected_count"])
  885. blocks = [head("replace_in_file " + path, bits)]
  886. rows = diff_rows(old, str_text(args.get("new")))
  887. if rows:
  888. blocks.extend(("diff", marker + " " + text) for marker, text in rows)
  889. return blocks
  890. if tool in ("enqueue_task", "delete_task", "get_task"):
  891. if not isinstance(args.get("task_id"), int):
  892. return None
  893. return [head(tool + " #" + str(args["task_id"]))]
  894. if tool in ("update_progress", "add_comment", "save_note"):
  895. text = str_text(args.get("text")) or str_text(args.get("body"))
  896. if not text:
  897. return None
  898. if tool == "save_note" and str_text(args.get("project")):
  899. bits.append(str_text(args.get("project")))
  900. return [head(tool, bits), ("para", text)]
  901. if tool == "ask_question":
  902. if not question:
  903. return None
  904. blocks = [head("ask_question"), ("para", "Q: " + question)]
  905. if isinstance(args.get("options"), list) and args["options"]:
  906. blocks.append(("options", [str(o) for o in args["options"]]))
  907. return blocks
  908. return None
  909. def command_output_render(evt):
  910. """PORT of eventNode's command_output branch ->
  911. (command_prefix, output_text)."""
  912. payload = evt.get("payload") or {}
  913. command = str_text(payload.get("command"))
  914. text = payload.get("text") or payload.get("chunk") or ""
  915. return ("$ " + command if command else ""), text
  916. # --- Fixtures: copied from the live ledger (web-db.sqlite, events
  917. # table) and the test fake's object-shaped pushes - the real wire
  918. # shapes, inner JSON spelled out as the daemon serialises it. ---
  919. # The daemon's inner JSON, built with json.dumps so the escaping
  920. # is exactly what the wire carries (a JSON string holding JSON).
  921. inner_call = json.dumps({"command": "cd /workspace && ls"})
  922. inner_progress = json.dumps(
  923. {"text": "Root-caused the SSE listener; next: the payload parse"})
  924. inner_enqueue = json.dumps({"task_id": 3})
  925. inner_result = json.dumps(
  926. {"ok": True, "exit_code": 0,
  927. "stdout": "find /workspace/task-1 -maxdepth 2",
  928. "stderr": "", "duration_ms": 11,
  929. "bytes_out": 6042, "bytes_err": 0, "truncated": True})
  930. inner_error = json.dumps(
  931. {"ok": False,
  932. "error": "suspend refused: 3 draft task(s) were never enqueued"})
  933. inner_empty = json.dumps({"ok": True, "exit_code": 0,
  934. "stdout": "", "stderr": ""})
  935. FIXTURE_RUN_COMMAND_CALL = json.loads(
  936. '{"turn": 52, "tool": "run_command", "args": ' + json.dumps(inner_call) + '}')
  937. FIXTURE_UPDATE_PROGRESS_CALL = json.loads(
  938. '{"turn": 6, "tool": "update_progress", "args": '
  939. + json.dumps(inner_progress) + '}')
  940. FIXTURE_ENQUEUE_CALL = json.loads(
  941. '{"turn": 5, "tool": "enqueue_task", "args": '
  942. + json.dumps(inner_enqueue) + '}')
  943. FIXTURE_RUN_COMMAND_RESULT = json.loads(
  944. '{"tool": "run_command", "result": ' + json.dumps(inner_result)
  945. + ', "turn": 52}')
  946. FIXTURE_ERROR_RESULT = json.loads(
  947. '{"tool": "suspend", "result": ' + json.dumps(inner_error) + '}')
  948. FIXTURE_EMPTY_RESULT = json.loads(
  949. '{"tool": "run_command", "result": ' + json.dumps(inner_empty) + '}')
  950. # Not valid JSON: the live daemon emitted result strings that are
  951. # truncated raw output (72 rows in the production ledger).
  952. FIXTURE_NOT_JSON_RESULT = {
  953. "tool": "run_command",
  954. "result": '{"ok":true,"exit_code":0,"stdout":"=== Spry ===\n'
  955. 'MANIFEST.usm README.md meson.build',
  956. }
  957. # command_output: the harness fake's shape; `command` is a member.
  958. FIXTURE_COMMAND_OUTPUT = {"command": "meson test -C builddir",
  959. "text": "nobody:/task-1$ done\r\n"}
  960. # The harness fake's legacy object-shaped payloads (plan 17's own
  961. # tests push these): args/result as real objects.
  962. FIXTURE_OBJECT_CALL = {"turn": 1, "tool": "run_command",
  963. "args": {"command": "echo hello"}}
  964. FIXTURE_OBJECT_RESULT = {"turn": 1, "tool": "run_command",
  965. "stdout": "hello\n", "exit_code": 0}
  966. # Tailored-rendering fixtures (plan 18): the file tools' arg shapes
  967. # straight off the daemon's tool schemas.
  968. FIXTURE_REPLACE_CALL = {"turn": 40, "tool": "replace_in_file", "args": {
  969. "path": "src/pages/app.css",
  970. "old": ".event-turn {\n border-left: 3px solid #ddf4ff;\n}",
  971. "new": ".event-turn {\n border-left: 3px solid #ddf4ff;\n color: var(--ink-muted);\n}",
  972. "expected_count": 1}}
  973. FIXTURE_WRITE_CALL = {"turn": 41, "tool": "write_file", "args": {
  974. "path": "notes.md", "content": "# Notes\n- first\n- second"}}
  975. # Args clamped mid-JSON by the daemon's display clamp: parsing
  976. # fails, so the bespoke renderer must step aside and the generic
  977. # view shows the raw text.
  978. FIXTURE_TRUNCATED_ARGS = {"turn": 42, "tool": "write_file", "args":
  979. '{"path":"big.bin","content":"AAA'}
  980. class PayloadParserTests(unittest.TestCase):
  981. def test_string_args_decode_to_an_object(self):
  982. parsed = parse_maybe_json(FIXTURE_RUN_COMMAND_CALL["args"])
  983. self.assertEqual(parsed, {"command": "cd /workspace && ls"})
  984. def test_non_json_string_answers_none(self):
  985. self.assertIsNone(parse_maybe_json(
  986. FIXTURE_NOT_JSON_RESULT["result"]))
  987. def test_objects_pass_through(self):
  988. self.assertEqual(parse_maybe_json({"command": "ls"}), {"command": "ls"})
  989. self.assertEqual(parse_maybe_json(None), None)
  990. class ToolCallRenderTests(unittest.TestCase):
  991. """The command the agent ran must surface in EVERY render path,
  992. and each known tool renders through its tailored body."""
  993. def evt(self, payload):
  994. return {"kind": "tool_call", "payload": payload}
  995. def test_run_command_call_heads_with_the_dollar(self):
  996. blocks = tool_call_render(self.evt(FIXTURE_RUN_COMMAND_CALL))
  997. self.assertEqual(blocks[0], ("head", "$ cd /workspace && ls"))
  998. def test_run_command_timeout_rides_the_head(self):
  999. blocks = tool_call_render(self.evt({"tool": "run_command", "args":
  1000. {"command": "make", "timeout_ms": 900000}}))
  1001. self.assertEqual(blocks[0],
  1002. ("head", "$ make \u00b7 timeout 900s"))
  1003. def test_update_progress_call_renders_prose_not_json(self):
  1004. """The old renderer dumped {"text": ...} as JSON; the
  1005. tailored body renders the text itself."""
  1006. blocks = tool_call_render(self.evt(FIXTURE_UPDATE_PROGRESS_CALL))
  1007. self.assertEqual(blocks[0], ("head", "update_progress"))
  1008. self.assertEqual(blocks[1],
  1009. ("para", "Root-caused the SSE listener; next: the payload parse"))
  1010. self.assertNotIn('"text"', json.dumps(blocks))
  1011. def test_enqueue_call_renders_the_id(self):
  1012. blocks = tool_call_render(self.evt(FIXTURE_ENQUEUE_CALL))
  1013. self.assertEqual(blocks, [("head", "enqueue_task #3")])
  1014. def test_replace_in_file_renders_a_diff(self):
  1015. blocks = tool_call_render(self.evt(FIXTURE_REPLACE_CALL))
  1016. self.assertEqual(blocks[0],
  1017. ("head", "replace_in_file src/pages/app.css \u00b7 expect 1"))
  1018. diff_texts = [text for kind, text in blocks if kind == "diff"]
  1019. joined = " ".join(diff_texts)
  1020. self.assertIn(" .event-turn {", joined)
  1021. # The untouched border line is context; only the added color
  1022. # line is an addition - no phantom removals.
  1023. self.assertIn(" border-left: 3px solid #ddf4ff;", joined)
  1024. self.assertIn("+ color: var(--ink-muted);", joined)
  1025. self.assertIn(" }", joined)
  1026. self.assertFalse([t for t in diff_texts if t.startswith("- ")],
  1027. "an unchanged line must never render as a removal")
  1028. def test_write_file_renders_language_lines_and_content(self):
  1029. blocks = tool_call_render(self.evt(FIXTURE_WRITE_CALL))
  1030. self.assertEqual(blocks[0],
  1031. ("head", "write_file notes.md \u00b7 Markdown \u00b7 3 lines \u00b7 24 B"))
  1032. self.assertEqual(blocks[1], ("pre", "# Notes\n- first\n- second"))
  1033. def test_meson_build_gets_its_language(self):
  1034. self.assertEqual(language_of("sub/meson.build"), "Meson")
  1035. self.assertEqual(language_of("src/app.vala"), "Vala")
  1036. self.assertEqual(language_of("Makefile".lower()), "Make")
  1037. self.assertEqual(language_of("data/bin"), "")
  1038. def test_truncated_args_fall_back_to_the_raw_view(self):
  1039. blocks = tool_call_render(self.evt(FIXTURE_TRUNCATED_ARGS))
  1040. self.assertEqual(blocks[0], ("head", "write_file"))
  1041. self.assertEqual(blocks[1][0], "pre")
  1042. self.assertIn('"path":"big.bin"', blocks[1][1])
  1043. def test_unknown_tool_falls_back_to_json_args(self):
  1044. blocks = tool_call_render(self.evt(
  1045. {"tool": "brand_new_tool", "args": {"deep": {"a": 1}}}))
  1046. self.assertEqual(blocks[0], ("head", "brand_new_tool"))
  1047. self.assertIn('"deep"', blocks[1][1])
  1048. def test_object_shaped_call_still_works(self):
  1049. blocks = tool_call_render(self.evt(FIXTURE_OBJECT_CALL))
  1050. self.assertEqual(blocks, [("head", "$ echo hello")])
  1051. def test_non_json_args_render_raw(self):
  1052. blocks = tool_call_render(self.evt(
  1053. {"tool": "run_command", "args": "not json at all"}))
  1054. self.assertEqual(blocks,
  1055. [("head", "run_command"), ("pre", "not json at all")])
  1056. def test_no_args_at_all_renders_the_tool_name(self):
  1057. blocks = tool_call_render(self.evt({"tool": "list_tasks"}))
  1058. self.assertEqual(blocks, [("head", "list_tasks")])
  1059. def test_bespoke_missing_fields_step_aside(self):
  1060. """A replace_in_file without its old string (clamped args
  1061. that still parse) falls back rather than lying."""
  1062. blocks = tool_call_render(self.evt(
  1063. {"tool": "replace_in_file", "args": {"path": "x.c"}}))
  1064. self.assertEqual(blocks[0], ("head", "replace_in_file"))
  1065. self.assertEqual(blocks[1][0], "pre")
  1066. class DiffRowTests(unittest.TestCase):
  1067. """The replace_in_file diff: context clamping and honest
  1068. replacement semantics."""
  1069. def test_single_line_swap(self):
  1070. rows = diff_rows("alpha\nbeta\ngamma", "alpha\nBETA\ngamma")
  1071. self.assertEqual(rows, [[" ", "alpha"], ["-", "beta"],
  1072. ["+", "BETA"], [" ", "gamma"]])
  1073. def test_context_is_clamped_to_two_lines_per_side(self):
  1074. old = "\n".join(["ctx"] * 10 + ["old-line"] + ["ctx"] * 10)
  1075. new = "\n".join(["ctx"] * 10 + ["new-line"] + ["ctx"] * 10)
  1076. rows = diff_rows(old, new)
  1077. context = [text for marker, text in rows if marker == " "]
  1078. self.assertEqual(context, ["ctx"] * 4)
  1079. def test_whole_file_rewrite_shows_no_context(self):
  1080. rows = diff_rows("a\nb\nc", "x\ny\nz")
  1081. self.assertEqual([m for m, _ in rows], ["-", "-", "-", "+", "+", "+"])
  1082. def test_deletion_only_keeps_its_context(self):
  1083. rows = diff_rows("keep\ndrop\nkeep", "keep\nkeep")
  1084. self.assertEqual(rows, [[" ", "keep"], ["-", "drop"], [" ", "keep"]])
  1085. def test_empty_new_text(self):
  1086. rows = diff_rows("line", "")
  1087. self.assertEqual(rows, [["-", "line"], ["+", ""]])
  1088. def test_oversized_side_keeps_head_and_tail(self):
  1089. old = "\n".join("line-%04d" % i for i in range(1000))
  1090. rows = diff_rows(old, "changed")
  1091. dels = [text for marker, text in rows if marker == "-"]
  1092. self.assertEqual(len(dels), 401)
  1093. self.assertIn("more lines", dels[200])
  1094. self.assertEqual(dels[0], "line-0000")
  1095. self.assertEqual(dels[-1], "line-0999")
  1096. def test_identical_text_renders_nothing(self):
  1097. self.assertEqual(diff_rows("same\nsame", "same\nsame"), [])
  1098. class UsageLineTests(unittest.TestCase):
  1099. def test_full_payload_is_one_line(self):
  1100. line = usage_line({"model": "glm-4.7", "prompt_tokens": 45200000,
  1101. "completion_tokens": 1100000, "cached_tokens": 4500,
  1102. "elapsed_ms": 3200})
  1103. self.assertEqual(line,
  1104. "glm-4.7 \u00b7 45,200,000 in / 1,100,000 out tok "
  1105. "\u00b7 4,500 cached \u00b7 3.2 s")
  1106. def test_unreported_when_no_numbers(self):
  1107. self.assertEqual(usage_line({}), "unreported")
  1108. def test_partial_payload_skips_missing_members(self):
  1109. line = usage_line({"completion_tokens": 5, "elapsed_ms": 40})
  1110. self.assertEqual(line, "5 out tok \u00b7 40 ms")
  1111. class StateChangeLineTests(unittest.TestCase):
  1112. def test_from_to_arrow(self):
  1113. self.assertEqual(
  1114. state_change_line({"from": "working", "to": "blocked",
  1115. "text": "Status \u2192 blocked"}),
  1116. "working \u2192 blocked")
  1117. def test_text_fallback(self):
  1118. self.assertEqual(
  1119. state_change_line({"text": "Goal created (branch x)"}),
  1120. "Goal created (branch x)")
  1121. def test_json_fallback(self):
  1122. self.assertEqual(state_change_line({}), "{}")
  1123. QUIET_ACK = json.dumps({"ok": True, "recorded": True})
  1124. FAILED_ACK = json.dumps({"ok": False, "error": "web callback unreachable"})
  1125. def progress_trio(result_inner=QUIET_ACK, with_companion=True,
  1126. result_turn=None):
  1127. """The live-tail's repetitive trio: an update_progress call,
  1128. the progress event carrying the same text, then the ack
  1129. result. The live wire's result payloads carry NO turn of their
  1130. own (the payload is {tool, result}); pass result_turn to model
  1131. a turn-stamped result."""
  1132. rows = [{"id": 1, "kind": "tool_call", "session_id": 201, "payload": {
  1133. "turn": 1551, "tool": "update_progress",
  1134. "args": json.dumps({"text": "Segfault located"})}}]
  1135. if with_companion:
  1136. rows.append({"id": 2, "kind": "progress", "session_id": 201,
  1137. "payload": {"text": "Segfault located"}})
  1138. result_payload = {"tool": "update_progress", "result": result_inner}
  1139. if result_turn is not None:
  1140. result_payload["turn"] = result_turn
  1141. rows.append({"id": 3, "kind": "tool_result", "session_id": 201,
  1142. "payload": result_payload})
  1143. return rows
  1144. class QuietFoldingTests(unittest.TestCase):
  1145. """A successful update_progress renders as its progress event
  1146. ALONE; the duplicate call and the bare ack fold away. A failure
  1147. unfolds both."""
  1148. def test_quiet_trio_folds_call_and_result(self):
  1149. """The live wire's shape: the result carries no turn."""
  1150. folded = folded_quiet_calls(progress_trio())
  1151. self.assertEqual(folded, {0, 2})
  1152. def test_turn_stamped_result_also_folds(self):
  1153. folded = folded_quiet_calls(progress_trio(result_turn=1551))
  1154. self.assertEqual(folded, {0, 2})
  1155. def test_turn_mismatch_never_folds(self):
  1156. """A result stamped with another turn is not this call's
  1157. answer."""
  1158. self.assertEqual(
  1159. folded_quiet_calls(progress_trio(result_turn=9999)), set())
  1160. def test_add_comment_folds_behind_its_comment_event(self):
  1161. """The add_comment result carries a comment_id row pointer -
  1162. quiet - and the comment event between carries the body."""
  1163. rows = [
  1164. {"id": 1, "kind": "tool_call", "session_id": 201, "payload": {
  1165. "turn": 60, "tool": "add_comment",
  1166. "args": json.dumps({"body": "Merged and pushed."})}},
  1167. {"id": 2, "kind": "comment", "session_id": 201, "actor": "model",
  1168. "payload": {"body": "Merged and pushed."}},
  1169. {"id": 3, "kind": "tool_result", "session_id": 201, "payload": {
  1170. "tool": "add_comment",
  1171. "result": json.dumps({"ok": True, "comment_id": 234})}},
  1172. ]
  1173. self.assertEqual(folded_quiet_calls(rows), {0, 2})
  1174. def test_failed_ack_unfolds_the_pair(self):
  1175. self.assertEqual(folded_quiet_calls(progress_trio(FAILED_ACK)), set())
  1176. def test_missing_companion_unfolds(self):
  1177. """Without the progress event between them there would be
  1178. nothing to show instead - keep the pair."""
  1179. self.assertEqual(folded_quiet_calls(progress_trio(with_companion=False)),
  1180. set())
  1181. def test_missing_result_unfolds(self):
  1182. rows = progress_trio()[:2]
  1183. self.assertEqual(folded_quiet_calls(rows), set())
  1184. def test_non_foldable_tool_never_folds(self):
  1185. rows = [
  1186. {"id": 1, "kind": "tool_call", "session_id": 201, "payload": {
  1187. "turn": 5, "tool": "run_command",
  1188. "args": json.dumps({"command": "ls"})}},
  1189. {"id": 2, "kind": "tool_result", "session_id": 201, "payload": {
  1190. "tool": "run_command", "result": QUIET_ACK}},
  1191. ]
  1192. self.assertEqual(folded_quiet_calls(rows), set())
  1193. class ResultAttachmentTests(unittest.TestCase):
  1194. """A non-folded call carries its result as an attached block
  1195. INSIDE its row; the result never renders as a separate row."""
  1196. def test_call_and_result_pair_up(self):
  1197. rows = [
  1198. {"id": 10, "kind": "tool_call", "session_id": 7, "payload": {
  1199. "turn": 5, "tool": "run_command",
  1200. "args": json.dumps({"command": "make"})}},
  1201. {"id": 11, "kind": "tool_result", "session_id": 7, "payload": {
  1202. "tool": "run_command",
  1203. "result": json.dumps({"ok": True, "exit_code": 0,
  1204. "stdout": "built\n", "stderr": "",
  1205. "duration_ms": 900})}},
  1206. ]
  1207. calls_by_id, rides_by_id = attach_pairs(rows, set())
  1208. self.assertEqual(calls_by_id, {10: 11})
  1209. self.assertEqual(rides_by_id, {11: True})
  1210. def test_turn_less_live_result_pairs_up(self):
  1211. """The live wire's shape: the result carries no turn, so
  1212. matching rides on order, tool and session."""
  1213. rows = [
  1214. {"id": 20, "kind": "tool_call", "session_id": 201, "payload": {
  1215. "turn": 1731, "tool": "run_command",
  1216. "args": json.dumps({"command": "make test"})}},
  1217. {"id": 21, "kind": "tool_result", "session_id": 201, "payload": {
  1218. "tool": "run_command",
  1219. "result": json.dumps({"ok": True, "exit_code": 0,
  1220. "stdout": "ok\n", "stderr": "",
  1221. "duration_ms": 25000})}},
  1222. ]
  1223. calls_by_id, rides_by_id = attach_pairs(rows, set())
  1224. self.assertEqual(calls_by_id, {20: 21})
  1225. self.assertEqual(rides_by_id, {21: True})
  1226. def test_long_command_output_is_visible_when_attached(self):
  1227. """A command's output must be VISIBLE riding with its call -
  1228. never behind a click - even past the inline limit; CSS
  1229. clamps the overflow."""
  1230. big_output = "line %d\n" * 60 % tuple(range(60))
  1231. evt = {"kind": "tool_result", "payload": {
  1232. "tool": "run_command",
  1233. "result": json.dumps({"ok": True, "exit_code": 0,
  1234. "stdout": big_output, "stderr": "",
  1235. "duration_ms": 5000})}}
  1236. standalone, _, body = result_view(evt)
  1237. attached, _, attached_body = result_view(evt, attached=True)
  1238. self.assertFalse(standalone, "long standalone results collapse")
  1239. self.assertTrue(attached, "attached results are always inline")
  1240. self.assertEqual(attached_body, body)
  1241. def test_folded_pair_attaches_nothing(self):
  1242. calls_by_id, rides_by_id = attach_pairs(
  1243. progress_trio(), folded_quiet_calls(progress_trio()))
  1244. self.assertEqual((calls_by_id, rides_by_id), ({}, {}))
  1245. def test_result_without_visible_call_stands_alone(self):
  1246. rows = progress_trio()[2:] # only the result is visible
  1247. calls_by_id, rides_by_id = attach_pairs(rows, set())
  1248. self.assertEqual((calls_by_id, rides_by_id), ({}, {}))
  1249. class BadgeLineTests(unittest.TestCase):
  1250. """The badge reflects the TASK first: live only while a working
  1251. (or assigned) task streams; every other status names itself."""
  1252. def test_working_is_live(self):
  1253. self.assertEqual(badge_line("working"), ("log-live", "live"))
  1254. def test_assigned_is_live(self):
  1255. self.assertEqual(badge_line("assigned"), ("log-live", "live"))
  1256. def test_retry_only_rides_a_live_task(self):
  1257. self.assertEqual(badge_line("working", "retry"),
  1258. ("log-live retry", "reconnecting..."))
  1259. self.assertEqual(badge_line("failed", "retry")[1], "failed")
  1260. def test_suspended_shows_as_parked(self):
  1261. cls, label = badge_line("suspended")
  1262. self.assertEqual(label, "parked")
  1263. self.assertIn("still badge badge-suspended", cls)
  1264. def test_review_requested_uses_the_task_page_word(self):
  1265. self.assertEqual(badge_line("review_requested")[1],
  1266. "review requested")
  1267. def test_terminal_states_name_themselves(self):
  1268. self.assertEqual(badge_line("done")[1], "done")
  1269. self.assertEqual(badge_line("failed")[1], "failed")
  1270. def test_unknown_status_falls_back_to_idle(self):
  1271. self.assertEqual(badge_line("")[1], "idle")
  1272. class ToolResultRenderTests(unittest.TestCase):
  1273. def evt(self, payload):
  1274. return {"kind": "tool_result", "payload": payload}
  1275. def test_summary_comes_from_the_envelope(self):
  1276. line = result_summary(self.evt(FIXTURE_RUN_COMMAND_RESULT))
  1277. self.assertEqual(line,
  1278. "result (exit 0, 5.9 KiB, truncated, 11 ms, turn 52)")
  1279. def test_summary_marks_failed_envelopes(self):
  1280. line = result_summary(self.evt(
  1281. {"tool": "run_command", "result": '{"ok":false,"error":"x"}'}))
  1282. self.assertEqual(line, "result (failed)")
  1283. def test_body_shows_the_envelopes_stdout(self):
  1284. out, err, note, extras, no_output = result_output(
  1285. self.evt(FIXTURE_RUN_COMMAND_RESULT))
  1286. self.assertEqual(out, "find /workspace/task-1 -maxdepth 2")
  1287. self.assertEqual(err, "")
  1288. self.assertFalse(no_output)
  1289. def test_non_json_result_renders_as_output_text(self):
  1290. out, err, note, extras, no_output = result_output(
  1291. self.evt(FIXTURE_NOT_JSON_RESULT))
  1292. self.assertIn("=== Spry ===", out)
  1293. self.assertFalse(no_output,
  1294. "raw output must never be called (no output)")
  1295. def test_error_envelope_is_not_no_output(self):
  1296. out, err, note, extras, no_output = result_output(
  1297. self.evt(FIXTURE_ERROR_RESULT))
  1298. self.assertEqual((out, err), ("", ""))
  1299. self.assertIn("suspend refused", note)
  1300. self.assertFalse(no_output,
  1301. "an error member means the result said something")
  1302. def test_genuinely_empty_result_says_no_output(self):
  1303. out, err, note, extras, no_output = result_output(self.evt(FIXTURE_EMPTY_RESULT))
  1304. self.assertEqual((out, err, note, extras), ("", "", "", ""))
  1305. self.assertTrue(no_output)
  1306. def test_object_shaped_result_still_works(self):
  1307. line = result_summary(self.evt(FIXTURE_OBJECT_RESULT))
  1308. self.assertEqual(line, "result (exit 0, 6 B, turn 1)")
  1309. out, err, note, extras, no_output = result_output(self.evt(FIXTURE_OBJECT_RESULT))
  1310. self.assertEqual(out, "hello\n")
  1311. def test_tool_answer_envelope_is_rendered_not_swallowed(self):
  1312. """The live ledger's update_progress/create_task/finish
  1313. results carry {"recorded":true} / {"ok":true,"task_id":4}
  1314. / {"accepted":true} with empty streams: that IS the tool's
  1315. answer and must render, not "(no output)"."""
  1316. for inner in ('{"recorded":true}',
  1317. '{"ok":true,"task_id":4,"status":"draft"}',
  1318. '{"accepted":true}',
  1319. '{"suspended":true}',
  1320. '{"ok":true,"status":"pending"}'):
  1321. out, err, note, extras, no_output = result_output(self.evt(
  1322. {"tool": "update_progress", "result": inner}))
  1323. self.assertFalse(no_output, inner)
  1324. self.assertEqual((out, err, note), ("", "", ""))
  1325. self.assertTrue(extras, inner)
  1326. def test_list_tasks_answer_renders_as_a_list(self):
  1327. inner = json.dumps({"ok": True, "tasks": [
  1328. {"id": 6, "title": "UX notes", "status": "queued"},
  1329. {"id": 5, "title": "Event log parse", "status": "working"}]})
  1330. out, err, note, extras, no_output = result_output(self.evt(
  1331. {"tool": "list_tasks", "result": inner}))
  1332. self.assertFalse(no_output)
  1333. self.assertIn("UX notes", extras)
  1334. self.assertIn("Event log parse", extras)
  1335. self.assertIn("#6", extras)
  1336. self.assertNotIn('"tasks"', extras,
  1337. "a task list renders as rows, not a JSON dump")
  1338. def test_replace_in_file_result_counts_the_replacements(self):
  1339. inner = json.dumps({"ok": True, "path": "src/app.css",
  1340. "replacements": 2, "bytes_written": 2048})
  1341. out, err, note, extras, no_output = result_output(self.evt(
  1342. {"tool": "replace_in_file", "result": inner}))
  1343. self.assertEqual(extras, "2 replacements in src/app.css \u00b7 2.0 KiB")
  1344. def test_read_file_result_renders_the_window(self):
  1345. inner = json.dumps({"ok": True, "path": "src/main.vala",
  1346. "total_lines": 540, "line_start": 100,
  1347. "line_end": 160, "bytes": 4200,
  1348. "truncated": False, "content": "int main() {"})
  1349. out, err, note, extras, no_output = result_output(self.evt(
  1350. {"tool": "read_file", "result": inner}))
  1351. self.assertIn("src/main.vala \u00b7 lines 100-160 of 540 \u00b7 4.1 KiB", extras)
  1352. self.assertIn("[fold] content \u00b7 12 B", extras)
  1353. def test_read_binary_result_renders_the_window(self):
  1354. inner = json.dumps({"ok": True, "path": "model.bin",
  1355. "representation": "hex", "total_bytes": 1024,
  1356. "offset": 256, "bytes_read": 512,
  1357. "truncated": False,
  1358. "content": "00000100 7f 45 4c 46"})
  1359. out, err, note, extras, no_output = result_output(self.evt(
  1360. {"tool": "read_binary", "result": inner}))
  1361. self.assertIn("model.bin \u00b7 hex \u00b7 512 B of 1.0 KiB \u00b7 at 256 B", extras)
  1362. self.assertIn("[fold] content", extras)
  1363. def test_write_file_result_states_the_write(self):
  1364. out, err, note, extras, no_output = result_output(self.evt(
  1365. {"tool": "write_file",
  1366. "result": '{"ok":true,"path":"notes.md","bytes":24}'}))
  1367. self.assertEqual(extras, "wrote notes.md \u00b7 24 B")
  1368. def test_create_task_result_names_the_task(self):
  1369. out, err, note, extras, no_output = result_output(self.evt(
  1370. {"tool": "create_task",
  1371. "result": '{"ok":true,"task_id":4,"status":"draft"}'}))
  1372. self.assertEqual(extras, "created task #4 \u00b7 draft")
  1373. def test_pure_acknowledgement_is_an_ok_line(self):
  1374. out, err, note, extras, no_output = result_output(self.evt(
  1375. {"tool": "update_progress", "result": '{"ok":true,"recorded":true}'}))
  1376. self.assertEqual(extras, "ok")
  1377. def test_run_command_with_empty_streams_is_still_no_output(self):
  1378. """Only command plumbing present: the run genuinely said
  1379. nothing - keep the honest "(no output)"."""
  1380. out, err, note, extras, no_output = result_output(self.evt(
  1381. {"tool": "run_command",
  1382. "result": '{"ok":true,"exit_code":0,"stdout":"",'
  1383. '"stderr":"","duration_ms":5,"bytes_out":0,'
  1384. '"bytes_err":0,"truncated":false}'}))
  1385. self.assertTrue(no_output)
  1386. self.assertEqual(extras, "")
  1387. class CommandOutputRenderTests(unittest.TestCase):
  1388. def test_command_is_prefixed_as_a_shell_line(self):
  1389. command, text = command_output_render(
  1390. {"kind": "command_output", "payload": FIXTURE_COMMAND_OUTPUT})
  1391. self.assertEqual(command, "$ meson test -C builddir")
  1392. self.assertEqual(text, "nobody:/task-1$ done\r\n")
  1393. def test_text_only_chunk_keeps_the_old_render(self):
  1394. command, text = command_output_render(
  1395. {"kind": "command_output", "payload": {"text": "hello\r\n"}})
  1396. self.assertEqual(command, "")
  1397. self.assertEqual(text, "hello\r\n")
  1398. class SearchHaystackTests(unittest.TestCase):
  1399. """A search for text inside the STRING-encoded args/result must
  1400. still find the event: the haystack appends the raw strings."""
  1401. def test_search_matches_through_the_string_encoding(self):
  1402. payload = FIXTURE_RUN_COMMAND_CALL
  1403. haystack = "tool_call daemon " + json.dumps(payload) + " " \
  1404. + str_text(payload.get("args")) + " " \
  1405. + str_text(payload.get("result"))
  1406. self.assertIn("cd /workspace && ls", haystack)
  1407. class YeehawFilterTests(unittest.TestCase):
  1408. """The celebration must ride the comments filter (goal 9 task 4b):
  1409. a human scanning the conversation sees the yeehaw, and the
  1410. comments toggle hides it like any other chatter."""
  1411. def test_yeehaw_maps_to_comments(self):
  1412. self.assertEqual(filter_of("yeehaw"), "comments")
  1413. def test_comment_kinds_unchanged(self):
  1414. for kind in ("comment", "answer", "verdict"):
  1415. self.assertEqual(filter_of(kind), "comments")
  1416. def test_other_kinds_unchanged(self):
  1417. self.assertEqual(filter_of("tool_call"), "commands")
  1418. self.assertEqual(filter_of("tool_result"), "results")
  1419. self.assertEqual(filter_of("command_output"), "results")
  1420. self.assertEqual(filter_of("usage"), "usage")
  1421. self.assertEqual(filter_of("lifecycle"), "transitions")
  1422. class YeehawRenderTests(unittest.TestCase):
  1423. """The yeehaw branch: the message in the bold .yeehaw-message span
  1424. (the festive palette), never a JSON payload dump."""
  1425. def test_message_renders_in_the_span(self):
  1426. span, message = yeehaw_render(
  1427. {"kind": "yeehaw", "payload": {"message": "Loved every line"}})
  1428. self.assertEqual(span, "yeehaw-message")
  1429. self.assertEqual(message, "Loved every line")
  1430. def test_missing_message_is_blank_not_garbage(self):
  1431. span, message = yeehaw_render({"kind": "yeehaw", "payload": {}})
  1432. self.assertEqual(message, "")
  1433. def test_non_string_message_is_blank(self):
  1434. span, message = yeehaw_render(
  1435. {"kind": "yeehaw", "payload": {"message": 42}})
  1436. self.assertEqual(message, "")
  1437. class RendererSourceTests(unittest.TestCase):
  1438. """The shipped script itself (no JS engine in this container, so
  1439. the render decisions are pinned by the ports above plus these
  1440. shape checks on the served source)."""
  1441. def setUp(self):
  1442. self.body = script_body()
  1443. def test_parse_maybe_json_is_defined_and_used(self):
  1444. self.assertIn("function parseMaybeJson(value) {", self.body)
  1445. self.assertGreaterEqual(self.body.count("parseMaybeJson("), 4)
  1446. def test_no_innerhtml_anywhere(self):
  1447. self.assertNotIn(".innerHTML", self.body)
  1448. def test_command_output_prefixes_the_dollar(self):
  1449. self.assertIn("'$ ' + p.command", self.body)
  1450. def test_tool_call_line_uses_parsed_args(self):
  1451. self.assertIn("args.command", self.body)
  1452. self.assertIn("toolCallBody", self.body)
  1453. def test_result_reads_parsed_envelope(self):
  1454. self.assertIn("resultEnvelope", self.body)
  1455. self.assertIn("bytes_out", self.body)
  1456. self.assertIn("duration_ms", self.body)
  1457. def test_yeehaw_lands_in_the_comments_bucket(self):
  1458. self.assertRegex(
  1459. self.body,
  1460. r"kind === 'comment' \|\| kind === 'answer' \|\| kind === 'verdict'[\s\S]*?"
  1461. r"kind === 'yeehaw'\) return 'comments'")
  1462. def test_yeehaw_renders_the_message_span(self):
  1463. self.assertIn("evt.kind === 'yeehaw'", self.body)
  1464. self.assertIn("'yeehaw-message'", self.body)
  1465. self.assertIn("strText(p.message)", self.body)
  1466. def test_tailored_renderers_present(self):
  1467. self.assertIn("function tailoredCallBody(tool, args) {", self.body)
  1468. self.assertIn("function diffRows(oldText, newText) {", self.body)
  1469. self.assertIn("'event-diff'", self.body)
  1470. self.assertIn("tailoredCallBody(tool, args)", self.body)
  1471. if __name__ == "__main__":
  1472. unittest.main(verbosity=2)