ompchamber 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/README.md +9 -6
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5291f58223.css +2 -0
  4. package/dist/client/static/js/async/{1k.544adbf77d.js → 1k.5974df3b87.js} +2 -2
  5. package/dist/client/static/js/async/3j.357c80ff38.js +2 -0
  6. package/dist/client/static/js/async/{3y.22075aa079.js → 3y.ededeebde8.js} +5 -5
  7. package/dist/client/static/js/async/51.95bcae98af.js +2 -0
  8. package/dist/client/static/js/async/6.f034c9b528.js +1 -0
  9. package/dist/client/static/js/async/a.e05da304c8.js +10 -0
  10. package/dist/client/static/js/async/ar.1e14da5588.js +1 -0
  11. package/dist/client/static/js/async/art.62139ed479.js +2 -0
  12. package/dist/client/static/js/async/ct.b74928f7a3.js +1 -0
  13. package/dist/client/static/js/async/fw.6a51998a8e.js +6 -0
  14. package/dist/client/static/js/async/g.7a4bbd4798.js +2 -0
  15. package/dist/client/static/js/async/g.7a4bbd4798.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/{k.e878dd350f.js → k.7466330d1a.js} +2 -2
  17. package/dist/client/static/js/async/katex-vendor.de6a87c308.js +1 -0
  18. package/dist/client/static/js/async/kc.bbdb35b771.js +1 -0
  19. package/dist/client/static/js/async/l6.c56640c205.js +1 -0
  20. package/dist/client/static/js/async/{m.3fc017a055.js → m.4b29d34f93.js} +3 -3
  21. package/dist/client/static/js/async/{og.ae7acf03ac.js → og.634fba99ac.js} +2 -2
  22. package/dist/client/static/js/async/q.7fb4d2f374.js +21 -0
  23. package/dist/client/static/js/async/rd.cb6bf495bb.js +2 -0
  24. package/dist/client/static/js/async/rf.d626afc367.js +2 -0
  25. package/dist/client/static/js/async/s.413c2a4683.js +2 -0
  26. package/dist/client/static/js/async/s.413c2a4683.js.LICENSE.txt +6 -0
  27. package/dist/client/static/js/async/sy.d16be843a7.js +94 -0
  28. package/dist/client/static/js/async/t.cb3fcf8615.js +1 -0
  29. package/dist/client/static/js/async/ud.3065bfee31.js +2 -0
  30. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  31. package/dist/client/static/js/async/uf.46ad6209fa.js.LICENSE.txt +6 -0
  32. package/dist/client/static/js/async/v.7d28d3c241.js +34 -0
  33. package/dist/client/static/js/async/v0.394ed37c6b.js +2 -0
  34. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +6 -0
  35. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  36. package/dist/client/static/js/async/vy.bbe114c4e0.js +2 -0
  37. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +6 -0
  38. package/dist/client/static/js/async/xw.ae67ba06fd.js +31 -0
  39. package/dist/client/static/js/async/xw.ae67ba06fd.js.LICENSE.txt +4 -0
  40. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  41. package/dist/client/static/js/async/yv.2fa22f009a.js +1 -0
  42. package/dist/client/static/js/index.bf1b1fe988.js +33 -0
  43. package/dist/client/static/js/index.bf1b1fe988.js.LICENSE.txt +4 -0
  44. package/dist/client/static/js/{markdown-vendor.9da142c67b.js → markdown-vendor.71e43278b3.js} +1 -1
  45. package/dist/client/static/js/uy.5f791144ce.js +139 -0
  46. package/package.json +2 -1
  47. package/src/client/App.tsx +22 -5
  48. package/src/client/components/common/MarkdownRenderer.tsx +36 -0
  49. package/src/client/components/layout/desktop-layout/index.tsx +1 -1
  50. package/src/client/components/layout/session-sidebar/Modals.tsx +1 -1
  51. package/src/client/components/mobile/LayoutWrapper.tsx +1 -1
  52. package/src/client/components/settings/LazyModal.tsx +46 -0
  53. package/src/client/components/settings/Modal.tsx +1 -1
  54. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +43 -29
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +15 -69
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +47 -66
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +60 -0
  58. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +37 -21
  59. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +22 -4
  60. package/src/client/components/workspace/chat-timeline/index.tsx +3 -6
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +15 -19
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader.tsx +46 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput.tsx +18 -68
  64. package/src/client/components/workspace/file-explorer/TreeItem.tsx +8 -2
  65. package/src/client/hooks/chat/omp/index.ts +9 -10
  66. package/src/client/hooks/chat/omp/revalidation-throttle.ts +6 -5
  67. package/src/client/hooks/chat/timeline/actions.ts +1 -2
  68. package/src/client/hooks/chat/timeline/index.ts +5 -0
  69. package/src/client/hooks/chat/timeline/send.ts +1 -2
  70. package/src/client/hooks/chat/timeline/session-load.ts +3 -4
  71. package/src/client/hooks/ui/syntax-ready.ts +16 -3
  72. package/src/client/main.tsx +2 -5
  73. package/src/client/stubs/elkjs.ts +37 -0
  74. package/src/client/tailwind.css +10 -0
  75. package/src/server/lib/fs/git-ignore.ts +49 -0
  76. package/src/server/lib/omp/rpc/constants.ts +10 -0
  77. package/src/server/lib/omp/rpc/lifecycle.test.ts +138 -0
  78. package/src/server/lib/omp/rpc/manager.ts +32 -4
  79. package/src/server/lib/omp/rpc/session-commands.ts +32 -9
  80. package/src/server/lib/omp/rpc/session-registry.ts +3 -2
  81. package/src/server/lib/omp/rpc/subagent-liveness.test.ts +62 -0
  82. package/src/server/lib/omp/rpc/subagent-liveness.ts +100 -0
  83. package/src/server/lib/omp/session/messages.test.ts +112 -0
  84. package/src/server/lib/omp/session/messages.ts +6 -5
  85. package/src/server/routes/agent/command.ts +34 -16
  86. package/src/server/routes/fs/read.ts +15 -7
  87. package/src/shared/lib/chat/notice-row.test.ts +89 -0
  88. package/src/shared/lib/chat/notice-row.ts +78 -0
  89. package/src/shared/lib/chat/omp/abort.ts +41 -0
  90. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +154 -0
  91. package/src/shared/lib/chat/timeline/omp-callbacks.ts +36 -7
  92. package/src/shared/lib/chat/timeline/run-footer.test.ts +84 -0
  93. package/src/shared/lib/chat/timeline/run-footer.ts +96 -0
  94. package/src/shared/lib/chat/tool-output.test.ts +91 -0
  95. package/src/shared/lib/chat/tool-output.ts +54 -0
  96. package/src/shared/lib/chat/xml-envelope.ts +158 -0
  97. package/src/shared/lib/code/highlighter-lazy.ts +106 -0
  98. package/src/shared/lib/code/highlighter.ts +31 -52
  99. package/src/shared/lib/code/syntax-highlight.test.ts +36 -1
  100. package/src/shared/lib/code/syntax-highlight.ts +17 -3
  101. package/src/shared/lib/format/time.ts +19 -0
  102. package/src/shared/lib/markdown/fira-code-fonts.css +51 -0
  103. package/src/shared/lib/markdown/katex-fonts.css +20 -0
  104. package/src/shared/lib/markdown/katex.ts +101 -0
  105. package/src/shared/lib/markdown/marked.ts +23 -14
  106. package/src/shared/lib/markdown/math.test.ts +109 -0
  107. package/src/shared/lib/markdown/mermaid.ts +6 -0
  108. package/src/shared/lib/markdown/sanitize.ts +48 -2
  109. package/src/shared/lib/omp/session/mapper.ts +11 -3
  110. package/src/shared/lib/omp/session/messages-map.ts +12 -12
  111. package/src/shared/types/chat.ts +3 -1
  112. package/src/shared/types/fs.ts +3 -0
  113. package/dist/client/static/css/g.f36aa429fa.css +0 -1
  114. package/dist/client/static/css/index.a0f07455bd.css +0 -2
  115. package/dist/client/static/css/katex-vendor.ca3a0600e8.css +0 -1
  116. package/dist/client/static/font/KaTeX_AMS-Regular.1608a09b4a.woff +0 -0
  117. package/dist/client/static/font/KaTeX_AMS-Regular.4aafdb685c.ttf +0 -0
  118. package/dist/client/static/font/KaTeX_Caligraphic-Bold.b67709187b.woff +0 -0
  119. package/dist/client/static/font/KaTeX_Caligraphic-Bold.cce5b8ecea.ttf +0 -0
  120. package/dist/client/static/font/KaTeX_Caligraphic-Regular.07ef19e7b0.ttf +0 -0
  121. package/dist/client/static/font/KaTeX_Caligraphic-Regular.dad44a7fc6.woff +0 -0
  122. package/dist/client/static/font/KaTeX_Fraktur-Bold.9f256b8593.woff +0 -0
  123. package/dist/client/static/font/KaTeX_Fraktur-Bold.b18f59e1d1.ttf +0 -0
  124. package/dist/client/static/font/KaTeX_Fraktur-Regular.7c1871215e.woff +0 -0
  125. package/dist/client/static/font/KaTeX_Fraktur-Regular.ed38e79f57.ttf +0 -0
  126. package/dist/client/static/font/KaTeX_Main-Bold.b74a1a8b2c.ttf +0 -0
  127. package/dist/client/static/font/KaTeX_Main-Bold.d181c4650d.woff +0 -0
  128. package/dist/client/static/font/KaTeX_Main-BoldItalic.70d8b0a530.ttf +0 -0
  129. package/dist/client/static/font/KaTeX_Main-BoldItalic.e3f82f9d27.woff +0 -0
  130. package/dist/client/static/font/KaTeX_Main-Italic.47373d1e51.ttf +0 -0
  131. package/dist/client/static/font/KaTeX_Main-Italic.9024d815ba.woff +0 -0
  132. package/dist/client/static/font/KaTeX_Main-Regular.7f51fe0340.woff +0 -0
  133. package/dist/client/static/font/KaTeX_Main-Regular.b7f8fe9b5f.ttf +0 -0
  134. package/dist/client/static/font/KaTeX_Math-BoldItalic.a879cf8383.ttf +0 -0
  135. package/dist/client/static/font/KaTeX_Math-BoldItalic.f1035d8d5d.woff +0 -0
  136. package/dist/client/static/font/KaTeX_Math-Italic.5295ba483a.woff +0 -0
  137. package/dist/client/static/font/KaTeX_Math-Italic.939bc64440.ttf +0 -0
  138. package/dist/client/static/font/KaTeX_SansSerif-Bold.94e1e8dc5f.ttf +0 -0
  139. package/dist/client/static/font/KaTeX_SansSerif-Bold.bf59d231c3.woff +0 -0
  140. package/dist/client/static/font/KaTeX_SansSerif-Italic.7c9bc82b17.woff +0 -0
  141. package/dist/client/static/font/KaTeX_SansSerif-Italic.b4c20c84d8.ttf +0 -0
  142. package/dist/client/static/font/KaTeX_SansSerif-Regular.740484788f.woff +0 -0
  143. package/dist/client/static/font/KaTeX_SansSerif-Regular.d4d7ba4804.ttf +0 -0
  144. package/dist/client/static/font/KaTeX_Script-Regular.0750571032.woff +0 -0
  145. package/dist/client/static/font/KaTeX_Script-Regular.fe9cbbe1a0.ttf +0 -0
  146. package/dist/client/static/font/KaTeX_Size1-Regular.e1e279cbdd.woff +0 -0
  147. package/dist/client/static/font/KaTeX_Size1-Regular.fabc004aab.ttf +0 -0
  148. package/dist/client/static/font/KaTeX_Size2-Regular.577270225e.woff +0 -0
  149. package/dist/client/static/font/KaTeX_Size2-Regular.d6b476ecd3.ttf +0 -0
  150. package/dist/client/static/font/KaTeX_Size3-Regular.9acaf01c64.woff +0 -0
  151. package/dist/client/static/font/KaTeX_Size3-Regular.a144ef5840.ttf +0 -0
  152. package/dist/client/static/font/KaTeX_Size4-Regular.7a996c9da2.woff +0 -0
  153. package/dist/client/static/font/KaTeX_Size4-Regular.fbccdabe0a.ttf +0 -0
  154. package/dist/client/static/font/KaTeX_Typewriter-Regular.6258592bdc.woff +0 -0
  155. package/dist/client/static/font/KaTeX_Typewriter-Regular.d97aaf4a1e.ttf +0 -0
  156. package/dist/client/static/font/fira-code-cyrillic-400-normal.5971438a4a.woff2 +0 -0
  157. package/dist/client/static/font/fira-code-cyrillic-400-normal.9bb7bfea9c.woff +0 -0
  158. package/dist/client/static/font/fira-code-cyrillic-500-normal.5486265b18.woff2 +0 -0
  159. package/dist/client/static/font/fira-code-cyrillic-500-normal.89f92bfb31.woff +0 -0
  160. package/dist/client/static/font/fira-code-cyrillic-600-normal.2805b497e5.woff +0 -0
  161. package/dist/client/static/font/fira-code-cyrillic-600-normal.86e0df22a3.woff2 +0 -0
  162. package/dist/client/static/font/fira-code-cyrillic-700-normal.505a88f22a.woff +0 -0
  163. package/dist/client/static/font/fira-code-cyrillic-700-normal.76f5e8d2e2.woff2 +0 -0
  164. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.3c4b15e57d.woff2 +0 -0
  165. package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.61323debaa.woff +0 -0
  166. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.0f34210f2a.woff2 +0 -0
  167. package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.2497cfa1aa.woff +0 -0
  168. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.40fce94af9.woff2 +0 -0
  169. package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.6c8774b829.woff +0 -0
  170. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.4b742eb865.woff +0 -0
  171. package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.e75eb4fccc.woff2 +0 -0
  172. package/dist/client/static/font/fira-code-greek-400-normal.bb900468b6.woff2 +0 -0
  173. package/dist/client/static/font/fira-code-greek-400-normal.be76e699ba.woff +0 -0
  174. package/dist/client/static/font/fira-code-greek-500-normal.176c9fb001.woff2 +0 -0
  175. package/dist/client/static/font/fira-code-greek-500-normal.d2c596136b.woff +0 -0
  176. package/dist/client/static/font/fira-code-greek-600-normal.490c3deed7.woff +0 -0
  177. package/dist/client/static/font/fira-code-greek-600-normal.7df14376d9.woff2 +0 -0
  178. package/dist/client/static/font/fira-code-greek-700-normal.c82b6072fc.woff2 +0 -0
  179. package/dist/client/static/font/fira-code-greek-700-normal.d5f2b2917b.woff +0 -0
  180. package/dist/client/static/font/fira-code-greek-ext-400-normal.4849a79781.woff +0 -0
  181. package/dist/client/static/font/fira-code-greek-ext-400-normal.7f5bfc4538.woff2 +0 -0
  182. package/dist/client/static/font/fira-code-greek-ext-500-normal.aaccca4075.woff2 +0 -0
  183. package/dist/client/static/font/fira-code-greek-ext-500-normal.e383181256.woff +0 -0
  184. package/dist/client/static/font/fira-code-greek-ext-600-normal.78cb4bff8e.woff +0 -0
  185. package/dist/client/static/font/fira-code-greek-ext-600-normal.d6c93d54f9.woff2 +0 -0
  186. package/dist/client/static/font/fira-code-greek-ext-700-normal.57ec7c92d9.woff +0 -0
  187. package/dist/client/static/font/fira-code-greek-ext-700-normal.8fafdae5e8.woff2 +0 -0
  188. package/dist/client/static/font/fira-code-latin-400-normal.973295532b.woff +0 -0
  189. package/dist/client/static/font/fira-code-latin-500-normal.7ae2be8fb8.woff +0 -0
  190. package/dist/client/static/font/fira-code-latin-600-normal.1dbbe5bc27.woff +0 -0
  191. package/dist/client/static/font/fira-code-latin-700-normal.497a70148b.woff +0 -0
  192. package/dist/client/static/font/fira-code-latin-ext-400-normal.9dfae29aaf.woff +0 -0
  193. package/dist/client/static/font/fira-code-latin-ext-400-normal.e534ae46e2.woff2 +0 -0
  194. package/dist/client/static/font/fira-code-latin-ext-500-normal.24c7b781b0.woff +0 -0
  195. package/dist/client/static/font/fira-code-latin-ext-500-normal.aad44ab072.woff2 +0 -0
  196. package/dist/client/static/font/fira-code-latin-ext-600-normal.7bed0f2f7e.woff +0 -0
  197. package/dist/client/static/font/fira-code-latin-ext-600-normal.c509a7c9f1.woff2 +0 -0
  198. package/dist/client/static/font/fira-code-latin-ext-700-normal.6c66c8b0a9.woff2 +0 -0
  199. package/dist/client/static/font/fira-code-latin-ext-700-normal.88f64e2822.woff +0 -0
  200. package/dist/client/static/font/fira-code-symbols2-400-normal.97d3f4b17c.woff +0 -0
  201. package/dist/client/static/font/fira-code-symbols2-500-normal.b06f1a06e4.woff +0 -0
  202. package/dist/client/static/font/fira-code-symbols2-600-normal.304b653b93.woff +0 -0
  203. package/dist/client/static/font/fira-code-symbols2-700-normal.d8ce0ad586.woff +0 -0
  204. package/dist/client/static/js/async/3j.a2fcea99be.js +0 -2
  205. package/dist/client/static/js/async/51.1938cec320.js +0 -2
  206. package/dist/client/static/js/async/6.7929428505.js +0 -1
  207. package/dist/client/static/js/async/69.a18a10882f.js +0 -1
  208. package/dist/client/static/js/async/a.bce4542392.js +0 -1
  209. package/dist/client/static/js/async/ar.9dc8b1992a.js +0 -2
  210. package/dist/client/static/js/async/bi.cbfe8eaa8e.js +0 -1
  211. package/dist/client/static/js/async/ct.38d9da8a5a.js +0 -1
  212. package/dist/client/static/js/async/fw.fb1a08b92d.js +0 -6
  213. package/dist/client/static/js/async/l6.45f3b62e15.js +0 -1
  214. package/dist/client/static/js/async/q.0b6fb9bf8d.js +0 -21
  215. package/dist/client/static/js/async/rd.b77011ab7f.js +0 -2
  216. package/dist/client/static/js/async/rf.cfbde7f744.js +0 -2
  217. package/dist/client/static/js/async/s.c7d521a8ae.js +0 -2
  218. package/dist/client/static/js/async/sy.7345abcd67.js +0 -94
  219. package/dist/client/static/js/async/t.0dcb01dfde.js +0 -1
  220. package/dist/client/static/js/async/v.5c7a4b087b.js +0 -1
  221. package/dist/client/static/js/async/vu.311f1d202c.js +0 -34
  222. package/dist/client/static/js/async/yg.9bd4e09ecb.js +0 -149
  223. package/dist/client/static/js/async/yv.593a3f7d54.js +0 -1
  224. package/dist/client/static/js/g.124fba0826.js +0 -139
  225. package/dist/client/static/js/index.907fd4670e.js +0 -68
  226. package/dist/client/static/js/katex-vendor.95e48cf65a.js +0 -1
  227. package/src/shared/lib/chat/order.ts +0 -40
  228. /package/dist/client/static/js/async/{1k.544adbf77d.js.LICENSE.txt → 1k.5974df3b87.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{3j.a2fcea99be.js.LICENSE.txt → 3j.357c80ff38.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{3y.22075aa079.js.LICENSE.txt → 3y.ededeebde8.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{51.1938cec320.js.LICENSE.txt → 51.95bcae98af.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{ar.9dc8b1992a.js.LICENSE.txt → a.e05da304c8.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/async/{fw.fb1a08b92d.js.LICENSE.txt → art.62139ed479.js.LICENSE.txt} +0 -0
  234. /package/dist/client/static/js/async/{m.3fc017a055.js.LICENSE.txt → fw.6a51998a8e.js.LICENSE.txt} +0 -0
  235. /package/dist/client/static/js/async/{q.0b6fb9bf8d.js.LICENSE.txt → m.4b29d34f93.js.LICENSE.txt} +0 -0
  236. /package/dist/client/static/js/async/{rf.cfbde7f744.js.LICENSE.txt → q.7fb4d2f374.js.LICENSE.txt} +0 -0
  237. /package/dist/client/static/js/async/{rd.b77011ab7f.js.LICENSE.txt → rd.cb6bf495bb.js.LICENSE.txt} +0 -0
  238. /package/dist/client/static/js/async/{s.c7d521a8ae.js.LICENSE.txt → rf.d626afc367.js.LICENSE.txt} +0 -0
  239. /package/dist/client/static/js/{index.907fd4670e.js.LICENSE.txt → async/ud.3065bfee31.js.LICENSE.txt} +0 -0
  240. /package/dist/client/static/js/{g.124fba0826.js.LICENSE.txt → uy.5f791144ce.js.LICENSE.txt} +0 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The sidebar is signalled on the run's FIRST completed assistant turn (not on
8
+ * every assistant segment, not on user turns, and not on notice rows), and the
9
+ * guard re-arms at every run start / stream reattach. `omp:session-updated`
10
+ * rides a leading+trailing throttle, so an extra dispatch per assistant segment
11
+ * would keep resetting that window and delay the refresh it exists to trigger.
12
+ *
13
+ * One case is driven through `foldAgentEvent` with a real omp frame: the
14
+ * callbacks see the MAPPED chamber role (`assistant` → `ai`), which a
15
+ * hand-built message would hide.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+
20
+ import {
21
+ createOmpAgentCallbacks,
22
+ type OmpAgentCallbacksDeps,
23
+ } from '@/shared/lib/chat/timeline/omp-callbacks';
24
+ import { foldAgentEvent, type OmpAgentFoldDeps } from '@/shared/lib/chat/omp/agent-events';
25
+ import type { ChatMessageData, OmpAgentCallbacks, OmpAgentState } from '@/shared/types';
26
+
27
+ /** omp's own shape for a completed assistant turn, before the mapper runs. */
28
+ const OMP_ASSISTANT_TURN = {
29
+ role: 'assistant',
30
+ content: [{ type: 'text', text: 'hi' }],
31
+ timestamp: 1757943900000,
32
+ };
33
+
34
+ const assistantTurn = (id: string): ChatMessageData => ({ id, role: 'ai', content: 'hi' });
35
+ const userTurn = (id: string): ChatMessageData => ({ id, role: 'user', content: 'yo' });
36
+ const noticeRow = (id: string): ChatMessageData => ({ id, role: 'ai', content: '', notice: 'job done' });
37
+
38
+ describe('createOmpAgentCallbacks first-assistant sidebar signal', () => {
39
+ const signals: Array<{ name: string; sessionId?: string }> = [];
40
+
41
+ // `bun test` runs without a DOM: the dispatch site is window-guarded, so the
42
+ // suite installs a recording stub and removes it again.
43
+ const installWindowStub = () => {
44
+ Object.assign(globalThis, {
45
+ window: {
46
+ dispatchEvent: (event: Event) => {
47
+ const custom = event as CustomEvent<{ sessionId?: string }>;
48
+ signals.push({ name: custom.type, sessionId: custom.detail?.sessionId });
49
+ return true;
50
+ },
51
+ },
52
+ });
53
+ };
54
+
55
+ beforeEach(() => {
56
+ signals.length = 0;
57
+ installWindowStub();
58
+ });
59
+
60
+ afterEach(() => {
61
+ Reflect.deleteProperty(globalThis, 'window');
62
+ });
63
+
64
+ function makeCallbacks(): OmpAgentCallbacks {
65
+ const deps: OmpAgentCallbacksDeps = {
66
+ removeDeliveredFromQueue: () => {},
67
+ setGenerating: () => {},
68
+ setGeneratingVerb: () => {},
69
+ scrollToBottom: () => {},
70
+ adoptedSessionIdRef: { current: null },
71
+ sessionIdRef: { current: 'sess-1' },
72
+ metaRefreshedRef: { current: null },
73
+ firstAssistantRef: { current: false },
74
+ refreshSessionMeta: () => {},
75
+ setLocalMessages: () => {},
76
+ aiPlaceholderIdRef: { current: null },
77
+ optimisticUserIdRef: { current: null },
78
+ pendingUserDisplaysRef: { current: [] },
79
+ persistMessages: () => {},
80
+ abortControllerRef: { current: null },
81
+ appSettings: {},
82
+ enqueueExtensionDialog: () => {},
83
+ withdrawExtensionDialog: () => {},
84
+ };
85
+ return createOmpAgentCallbacks(deps);
86
+ }
87
+
88
+ function makeFoldDeps(callbacks: OmpAgentCallbacks): OmpAgentFoldDeps {
89
+ let state: OmpAgentState = { isGenerating: false, connected: true, error: null };
90
+ return {
91
+ sessionId: 'sess-1',
92
+ setState: (update) => {
93
+ state = typeof update === 'function' ? update(state) : update;
94
+ },
95
+ callbacksRef: { current: callbacks },
96
+ toolResultsRef: { current: new Map<string, { output: string }>() },
97
+ lastToolMessageRef: { current: null },
98
+ interruptPendingRef: { current: false },
99
+ activityRef: { current: '' },
100
+ currentThinkingLevelRef: { current: undefined },
101
+ fileMutatingCallsRef: { current: new Set<string>() },
102
+ };
103
+ }
104
+
105
+ test('signals on the first completed assistant turn of each run only', () => {
106
+ const callbacks = makeCallbacks();
107
+
108
+ callbacks.onAgentStart?.();
109
+ expect(signals.map(s => s.name)).toEqual(['omp:session-updated']);
110
+
111
+ callbacks.onMessageEnd?.(assistantTurn('a1'));
112
+ expect(signals).toHaveLength(2);
113
+ expect(signals[1]?.sessionId).toBe('sess-1');
114
+
115
+ // Later segments of the same run, and the steering user echo, stay silent.
116
+ callbacks.onMessageEnd?.(assistantTurn('a2'));
117
+ callbacks.onMessageEnd?.(userTurn('u2'));
118
+ expect(signals).toHaveLength(2);
119
+
120
+ // The next run re-arms the guard.
121
+ callbacks.onAgentStart?.();
122
+ callbacks.onMessageEnd?.(assistantTurn('a3'));
123
+ expect(signals).toHaveLength(4);
124
+ });
125
+
126
+ test('re-arms on a mid-run stream reattach', () => {
127
+ const callbacks = makeCallbacks();
128
+
129
+ callbacks.onResumeStream?.();
130
+ callbacks.onMessageEnd?.(assistantTurn('a1'));
131
+ callbacks.onMessageEnd?.(assistantTurn('a2'));
132
+
133
+ expect(signals).toHaveLength(2);
134
+ });
135
+
136
+ test('never signals from user turns or notice rows alone', () => {
137
+ const callbacks = makeCallbacks();
138
+
139
+ callbacks.onMessageEnd?.(userTurn('u1'));
140
+ callbacks.onMessageEnd?.(noticeRow('n1'));
141
+
142
+ expect(signals).toHaveLength(0);
143
+ });
144
+
145
+ test('a real omp assistant message_end frame reaches the signal once', () => {
146
+ const deps = makeFoldDeps(makeCallbacks());
147
+
148
+ foldAgentEvent({ type: 'agent_start' }, deps);
149
+ foldAgentEvent({ type: 'message_end', message: OMP_ASSISTANT_TURN }, deps);
150
+ foldAgentEvent({ type: 'message_end', message: { ...OMP_ASSISTANT_TURN, timestamp: 1757943901000 } }, deps);
151
+
152
+ expect(signals).toHaveLength(2);
153
+ });
154
+ });
@@ -14,8 +14,8 @@
14
14
 
15
15
  import type { Dispatch, SetStateAction } from 'preact/compat';
16
16
  import type { ChatMessageData, ExtensionUiDialogRequest, IncomingExtensionUiRequest, OmpAgentCallbacks } from '@/shared/types';
17
- import { normalizeNoticePositions } from '@/shared/lib/chat/order';
18
17
  import { triggerChatCompletionSound } from '@/client/hooks/ui/notification-sound';
18
+ import { isNoticeRow } from '@/shared/lib/chat/notice-row';
19
19
  import { createRafBatch } from '@/shared/lib/chat/timeline/stream-raf';
20
20
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
21
21
 
@@ -54,6 +54,9 @@ export interface OmpAgentCallbacksDeps {
54
54
  adoptedSessionIdRef: { current: string | null };
55
55
  sessionIdRef: { current: string | null };
56
56
  metaRefreshedRef: { current: string | null };
57
+ /** Per-run guard: the sidebar is signalled once, on the run's first completed
58
+ * assistant turn. Reset at every run start (and on stream reattach). */
59
+ firstAssistantRef: { current: boolean };
57
60
  refreshSessionMeta: (sid: string) => void;
58
61
  setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
59
62
  aiPlaceholderIdRef: { current: string | null };
@@ -77,6 +80,7 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
77
80
  adoptedSessionIdRef,
78
81
  sessionIdRef,
79
82
  metaRefreshedRef,
83
+ firstAssistantRef,
80
84
  refreshSessionMeta,
81
85
  setLocalMessages,
82
86
  aiPlaceholderIdRef,
@@ -103,6 +107,8 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
103
107
  setGenerating(true);
104
108
  setGeneratingVerb(PHASE_VERBS.thinking);
105
109
  setTimeout(() => scrollToBottom('smooth'), 50);
110
+ // A fresh run re-arms the first-assistant signal.
111
+ firstAssistantRef.current = false;
106
112
  const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
107
113
  if (sid) {
108
114
  // Sidebar signal on EVERY run start — the metaRefreshedRef guard below
@@ -123,6 +129,9 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
123
129
  setGenerating(true);
124
130
  setGeneratingVerb(PHASE_VERBS.thinking);
125
131
  setTimeout(() => scrollToBottom('smooth'), 50);
132
+ // Reattach re-arms the signal too: the reattached run's first assistant
133
+ // turn has not been signalled by THIS mount.
134
+ firstAssistantRef.current = false;
126
135
  const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
127
136
  // Reattach mid-run: the sidebar needs the `stream` status row.
128
137
  if (sid) window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
@@ -139,21 +148,22 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
139
148
  onMessageUpdate: (msg) => {
140
149
  // React may invoke state updaters more than once (eager-state bailout),
141
150
  // so this ref mutation must stay OUTSIDE the updater to keep it pure.
142
- if (msg.role !== 'user' && !msg.notice) optimisticUserIdRef.current = null;
151
+ if (msg.role !== 'user' && !isNoticeRow(msg)) optimisticUserIdRef.current = null;
143
152
  // Queue for the next frame, collapsing same-message bursts to the newest
144
153
  // full-content frame; scroll runs once per rendered frame.
145
154
  messageBatch.queue(prev => {
146
155
  const placeholderId = aiPlaceholderIdRef.current;
147
- // Notice rows (e.g. background job done, system alerts) belong chronologically
148
- // right before the next AI response, NEVER backwards before the initiating user message.
149
- if (msg.notice) {
156
+ // Notice rows (e.g. background job done, system alerts) are appended in
157
+ // arrival order — the timeline renders state as-is, so omp's own write
158
+ // order is what the user sees.
159
+ if (isNoticeRow(msg)) {
150
160
  if (prev.some(m => m.id === msg.id)) return prev;
151
161
  // If an active AI placeholder is generating at the tail, insert notice immediately before it
152
162
  if (placeholderId && prev.some(m => m.id === placeholderId)) {
153
163
  const pIdx = prev.findIndex(m => m.id === placeholderId);
154
- return normalizeNoticePositions([...prev.slice(0, pIdx), msg, ...prev.slice(pIdx)]);
164
+ return [...prev.slice(0, pIdx), msg, ...prev.slice(pIdx)];
155
165
  }
156
- return normalizeNoticePositions([...prev, msg]);
166
+ return [...prev, msg];
157
167
  }
158
168
  // User turns from the stream (steering abort_and_prompt, queue
159
169
  // follow-up deliveries) carry no optimistic bubble — append them
@@ -205,6 +215,25 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
205
215
  }, msg.id);
206
216
  },
207
217
  onMessageEnd: (msg) => {
218
+ // The run's first COMPLETED assistant turn. omp has durably written the
219
+ // turn by now — and, for a session it just titled, the auto title slot —
220
+ // so signal the sidebar here instead of waiting for agent_end (the whole
221
+ // run, tools included) or the 8s stream poll. A token-level
222
+ // (`onMessageUpdate`) signal would fire before the JSONL write and
223
+ // re-fetch the same scan; the completed turn is the first point where the
224
+ // refresh can actually return the new title/updated_at.
225
+ //
226
+ // Role is 'ai' here: toChatMessage collapses every non-user omp role to
227
+ // 'ai'. Notice rows (developer/system/custom) also carry 'ai', so they are
228
+ // excluded — a system-reminder row is not an answer.
229
+ if (msg.role === 'ai' && !isNoticeRow(msg) && !firstAssistantRef.current) {
230
+ firstAssistantRef.current = true;
231
+ const sid = adoptedSessionIdRef.current ?? sessionIdRef.current;
232
+ // Guarded: the fold also runs headless under `bun test` (no window).
233
+ if (sid && typeof window !== 'undefined') {
234
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
235
+ }
236
+ }
208
237
  // Apply any coalesced update before the terminal frame so the last chunk
209
238
  // is never dropped and the end always runs after it.
210
239
  messageBatch.flush();
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A run footer closes the AI response run, so it renders at the run's own
8
+ * boundary: after every row the run owns — including notice rows omp wrote at
9
+ * its tail — which keeps it immediately before the next user message. A run
10
+ * that is still generating, or a notice-only stretch, gets none.
11
+ */
12
+
13
+ import { describe, expect, test } from 'bun:test';
14
+
15
+ import { previousNonNoticeIndex, resolveRunFooters, streamingRowIndex } from '@/shared/lib/chat/timeline/run-footer';
16
+ import type { ChatMessageData } from '@/shared/types';
17
+
18
+ const user = (id: string, startedAt?: number): ChatMessageData => ({ id, role: 'user', content: 'yo', startedAt });
19
+ const ai = (id: string, completedAt?: number): ChatMessageData => ({ id, role: 'ai', content: 'hi', completedAt });
20
+ const notice = (id: string): ChatMessageData => ({ id, role: 'ai', content: '', notice: 'job done' });
21
+
22
+ /** Slot message ids, one per timeline index — `null` where no footer renders. */
23
+ const owners = (messages: ChatMessageData[], isGenerating = false) =>
24
+ resolveRunFooters(messages, isGenerating).map(slot => slot?.msg.id ?? null);
25
+
26
+ describe('resolveRunFooters', () => {
27
+ test('renders the run footer after the run, before the next user message', () => {
28
+ expect(owners([user('u1'), ai('a1'), user('u2')])).toEqual([null, 'a1', null]);
29
+ });
30
+
31
+ test('renders after a trailing notice row, not before it', () => {
32
+ const messages = [user('u1'), ai('a1'), notice('n1'), user('u2')];
33
+ expect(owners(messages)).toEqual([null, null, 'a1', null]);
34
+ });
35
+
36
+ test('renders once per run at the run boundary', () => {
37
+ const messages = [user('u1'), ai('a1'), ai('a2'), notice('n1'), user('u2'), ai('a3')];
38
+ expect(owners(messages)).toEqual([null, null, null, 'a2', null, 'a3']);
39
+ });
40
+
41
+ test('measures the run span, falling back to the turn duration', () => {
42
+ const measured = resolveRunFooters([user('u1', 1_000), ai('a1', 61_000)], false);
43
+ expect(measured[1]?.durationMs).toBe(60_000);
44
+
45
+ const fallback = resolveRunFooters([user('u1'), { ...ai('a1'), durationMs: 2_500 }], false);
46
+ expect(fallback[1]?.durationMs).toBe(2_500);
47
+ });
48
+
49
+ test('holds the footer back while the run is still generating', () => {
50
+ expect(owners([user('u1'), ai('a1')], true)).toEqual([null, null]);
51
+ // A notice row at the tail ends the run but must not settle the footer.
52
+ expect(owners([user('u1'), ai('a1'), notice('n1')], true)).toEqual([null, null, null]);
53
+ });
54
+
55
+ test('settles the footer once the run completes', () => {
56
+ expect(owners([user('u1'), ai('a1'), notice('n1')], false)).toEqual([null, null, 'a1']);
57
+ });
58
+
59
+ test('gives a notice-only stretch no footer', () => {
60
+ expect(owners([user('u1'), notice('n1'), user('u2')])).toEqual([null, null, null]);
61
+ });
62
+
63
+ test('a notice carrying the answer is an ordinary AI row and owns the run', () => {
64
+ const diverted = { ...notice('n1'), model: 'deepseek-v4', usage: { totalTokens: 150_640 } };
65
+ const messages = [user('u1'), ai('a1'), diverted, user('u2')];
66
+
67
+ expect(owners(messages)).toEqual([null, null, 'n1', null]);
68
+ // While the run is still generating, the diverted answer streams — not the row before it.
69
+ expect(streamingRowIndex([user('u1'), ai('a1'), diverted])).toBe(2);
70
+ });
71
+ });
72
+
73
+ describe('streamingRowIndex', () => {
74
+ test('skips trailing notice rows so the answer is the streaming row', () => {
75
+ expect(streamingRowIndex([user('u1'), ai('a1'), notice('n1')])).toBe(1);
76
+ expect(streamingRowIndex([user('u1'), notice('n1')])).toBe(0);
77
+ });
78
+ });
79
+
80
+ describe('previousNonNoticeIndex', () => {
81
+ test('skips notice rows when pointing at the previous row', () => {
82
+ expect(previousNonNoticeIndex([user('u1'), ai('a1'), notice('n1'), ai('a2')])).toEqual([-1, 0, 1, 1]);
83
+ });
84
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Run-footer placement for the chat timeline. The model/duration/token footer
8
+ * closes an AI response run, so it is emitted at the run's boundary — after
9
+ * every row the run owns, including notice rows omp wrote at its tail, and
10
+ * therefore immediately before the next user message. Nothing here reorders
11
+ * rows: the timeline renders file order and the footer is an extra row placed
12
+ * after the run's last one.
13
+ *
14
+ * "Notice row" means a real notice card: a row whose `notice` carries the
15
+ * answer instead is an ordinary AI row and owns its run (chat/notice-row.ts).
16
+ */
17
+
18
+ import { responseRunDurationMs } from '@/shared/lib/chat/duration';
19
+ import { isNoticeRow } from '@/shared/lib/chat/notice-row';
20
+ import type { ChatMessageData } from '@/shared/types';
21
+
22
+ export interface RunFooterSlot {
23
+ /** The run's last real AI message — the row carrying model/usage/duration. */
24
+ msg: ChatMessageData;
25
+ /** Index of that message, i.e. the row that streams during generation. */
26
+ ownerIndex: number;
27
+ /** Measured run span, falling back to the turn's own recorded duration. */
28
+ durationMs: number | null;
29
+ }
30
+
31
+ /**
32
+ * The row that streams while generating: the last non-notice row. Notice rows
33
+ * sit after the turn, so a plain "last item" check would mark a notice as the
34
+ * streaming row and settle the footer early.
35
+ */
36
+ export function streamingRowIndex(messages: ChatMessageData[]): number {
37
+ let index = messages.length - 1;
38
+ while (index >= 0 && isNoticeRow(messages[index])) index--;
39
+ return index;
40
+ }
41
+
42
+ /**
43
+ * `previousNonNoticeIndex[idx]`: nearest non-notice row index strictly BEFORE
44
+ * idx, or -1 when none. Rows use it to tell an assistant continuation from a
45
+ * fresh turn without re-scanning the timeline per row.
46
+ */
47
+ export function previousNonNoticeIndex(messages: ChatMessageData[]): number[] {
48
+ const indexes: number[] = new Array(messages.length);
49
+ let prevIdx = -1;
50
+ for (let i = 0; i < messages.length; i++) {
51
+ indexes[i] = prevIdx;
52
+ if (!isNoticeRow(messages[i])) prevIdx = i;
53
+ }
54
+ return indexes;
55
+ }
56
+
57
+ /**
58
+ * One entry per timeline index: non-null when a run footer renders AFTER that
59
+ * row. A run is a maximal stretch of non-user rows; its footer lands on the
60
+ * run's last row, so trailing notice rows no longer separate the footer from
61
+ * the following user message. A notice-only run gets none (no AI message to
62
+ * describe), and a run that is still generating is skipped until it settles.
63
+ */
64
+ export function resolveRunFooters(
65
+ messages: ChatMessageData[],
66
+ isGenerating: boolean,
67
+ ): (RunFooterSlot | null)[] {
68
+ const count = messages.length;
69
+ const footers: (RunFooterSlot | null)[] = new Array(count).fill(null);
70
+ const streamingIdx = streamingRowIndex(messages);
71
+ let ownerIndex = -1;
72
+
73
+ for (let i = 0; i < count; i++) {
74
+ const msg = messages[i];
75
+ if (msg.role === 'user') {
76
+ ownerIndex = -1;
77
+ continue;
78
+ }
79
+ if (!isNoticeRow(msg)) ownerIndex = i;
80
+
81
+ const endsRun = i === count - 1 || messages[i + 1]?.role === 'user';
82
+ if (!endsRun || ownerIndex < 0) continue;
83
+
84
+ const owner = messages[ownerIndex];
85
+ // The answer is still streaming — no footer until the run settles.
86
+ if (isGenerating && ownerIndex === streamingIdx && owner.role === 'ai') continue;
87
+
88
+ footers[i] = {
89
+ msg: owner,
90
+ ownerIndex,
91
+ durationMs: responseRunDurationMs(messages, ownerIndex) ?? owner.durationMs ?? null,
92
+ };
93
+ }
94
+
95
+ return footers;
96
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Tool output is rendered as markdown, with one override: an XML wrapper that
8
+ * opens the output is transport, not content, so it is peeled before parsing.
9
+ * What makes the parser hard is telling that wrapper apart from markup that IS
10
+ * the content — a tag in the middle of prose, a mismatched pair, a second
11
+ * element after the first — and from content markdown would reflow (a log, a
12
+ * JSON body) once the wrapper is gone.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+
17
+ import { outputMarkdown, readToolOutput } from '@/shared/lib/chat/tool-output';
18
+
19
+ describe('readToolOutput XML envelope', () => {
20
+ test('peels a wrapper that opens the output and keeps the trailing line', () => {
21
+ const output = readToolOutput(
22
+ '<system-reminder reason="rule_violation" rule="ts-no-inline-cast-access">\n' +
23
+ 'MUST comply with the following instruction.\n' +
24
+ '</system-reminder>\n' +
25
+ 'Successfully wrote 3604 bytes to src/a.ts',
26
+ );
27
+
28
+ expect(output.envelope?.tag).toBe('system-reminder');
29
+ expect(output.envelope?.attributes).toEqual({
30
+ reason: 'rule_violation',
31
+ rule: 'ts-no-inline-cast-access',
32
+ });
33
+ expect(output.content).toBe(
34
+ 'MUST comply with the following instruction.\n\nSuccessfully wrote 3604 bytes to src/a.ts',
35
+ );
36
+ });
37
+
38
+ test('reads single-quoted and valueless attributes', () => {
39
+ expect(readToolOutput("<a rule='ts-x' flag>\nbody\n</a>").envelope?.attributes).toEqual({
40
+ rule: 'ts-x',
41
+ flag: '',
42
+ });
43
+ });
44
+
45
+ test('dedents the wrapper body so markdown reads it as prose, not a code block', () => {
46
+ const output = readToolOutput('<result>\n # Judul\n teks biasa\n</result>');
47
+
48
+ expect(output.content).toBe('# Judul\nteks biasa');
49
+ expect(output.format).toBe('markdown');
50
+ expect(outputMarkdown(output.content, output.format)).toBe('# Judul\nteks biasa');
51
+ });
52
+
53
+ test('handles a `>` inside an attribute value and nested elements', () => {
54
+ expect(readToolOutput('<a title="x > y">\n<b>1</b>\n</a>').content).toBe('<b>1</b>');
55
+ });
56
+
57
+ test('leaves markup that does not open the output alone', () => {
58
+ const raw = 'Set <name> to the file, then run <cmd />';
59
+ const output = readToolOutput(raw);
60
+
61
+ expect(output.envelope?.tag).toBeUndefined();
62
+ expect(output.content).toBe(raw);
63
+ });
64
+
65
+ test('leaves mismatched and self-closing roots alone', () => {
66
+ expect(readToolOutput('<a>\n<b>x</a>').envelope?.tag).toBeUndefined();
67
+ expect(readToolOutput('<hr />').envelope?.tag).toBeUndefined();
68
+ expect(readToolOutput('<!-- note -->\nbody').envelope?.tag).toBeUndefined();
69
+ });
70
+
71
+ test('does not peel when a second element follows the wrapper', () => {
72
+ expect(readToolOutput('<a>1</a><b>2</b>').envelope?.tag).toBeUndefined();
73
+ });
74
+ });
75
+
76
+ describe('outputMarkdown', () => {
77
+ test('fences JSON so the parser cannot reflow it', () => {
78
+ const output = readToolOutput('{\n "a": 1\n}');
79
+
80
+ expect(output.format).toBe('json');
81
+ expect(outputMarkdown(output.content, output.format)).toBe('```json\n{\n "a": 1\n}\n```');
82
+ });
83
+
84
+ test('fences a plain log without a language', () => {
85
+ expect(outputMarkdown('error: boom\n at foo', 'text')).toBe('```\nerror: boom\n at foo\n```');
86
+ });
87
+
88
+ test('outgrows a fence already present in the content', () => {
89
+ expect(outputMarkdown('a ``` b', 'json')).toBe('````json\na ``` b\n````');
90
+ });
91
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The one rule every tool-output surface follows: **output is rendered as
8
+ * markdown**.
9
+ *
10
+ * Two things happen before the parser sees it:
11
+ *
12
+ * 1. An XML envelope wrapping the whole output is peeled
13
+ * (`<system-reminder …>…</system-reminder>`, `<result>…</result>`). Those
14
+ * tags are transport — left in place, markdown escapes them and the user
15
+ * reads markup instead of the message it carries.
16
+ * 2. Content markdown would reflow gets fenced. A log, a JSON body, an HTML
17
+ * dump have no blank lines and no markers: parsed as prose they collapse
18
+ * into a single paragraph and their `#`/`-` lines turn into headings and
19
+ * bullets. A fence is still markdown, and it is the only shape that keeps
20
+ * such a blob verbatim.
21
+ */
22
+
23
+ import { detectOutputFormat, type OutputFormat } from '@/shared/lib/chat/detect-format';
24
+ import { unwrapXmlEnvelope, type XmlEnvelope } from '@/shared/lib/chat/xml-envelope';
25
+
26
+ export interface ToolOutputText {
27
+ /** What the user reads: the XML envelope peeled off, if there was one. */
28
+ content: string;
29
+ /** Format of `content`, deciding how `outputMarkdown` renders it. */
30
+ format: OutputFormat;
31
+ /** Wrapper that was peeled — rendered as the output's header. */
32
+ envelope?: XmlEnvelope;
33
+ }
34
+
35
+ /** Read raw tool output into the text the timeline renders. */
36
+ export function readToolOutput(text: string): ToolOutputText {
37
+ const envelope = unwrapXmlEnvelope(text);
38
+ const content = envelope ? [envelope.inner, envelope.rest].filter(Boolean).join('\n\n') : text;
39
+ return {
40
+ content,
41
+ format: detectOutputFormat(content),
42
+ ...(envelope ? { envelope } : {}),
43
+ };
44
+ }
45
+
46
+ /** Markdown for output content — verbatim formats are fenced so the parser
47
+ * cannot reflow them; markdown itself passes through untouched. */
48
+ export function outputMarkdown(content: string, format: OutputFormat): string {
49
+ if (!content || format === 'markdown') return content;
50
+ let longest = 0;
51
+ for (const run of content.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
52
+ const fence = '`'.repeat(Math.max(3, longest + 1));
53
+ return `${fence}${format === 'text' ? '' : format}\n${content}\n${fence}`;
54
+ }