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.
- package/README.md +9 -6
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.5291f58223.css +2 -0
- package/dist/client/static/js/async/{1k.544adbf77d.js → 1k.5974df3b87.js} +2 -2
- package/dist/client/static/js/async/3j.357c80ff38.js +2 -0
- package/dist/client/static/js/async/{3y.22075aa079.js → 3y.ededeebde8.js} +5 -5
- package/dist/client/static/js/async/51.95bcae98af.js +2 -0
- package/dist/client/static/js/async/6.f034c9b528.js +1 -0
- package/dist/client/static/js/async/a.e05da304c8.js +10 -0
- package/dist/client/static/js/async/ar.1e14da5588.js +1 -0
- package/dist/client/static/js/async/art.62139ed479.js +2 -0
- package/dist/client/static/js/async/ct.b74928f7a3.js +1 -0
- package/dist/client/static/js/async/fw.6a51998a8e.js +6 -0
- package/dist/client/static/js/async/g.7a4bbd4798.js +2 -0
- package/dist/client/static/js/async/g.7a4bbd4798.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{k.e878dd350f.js → k.7466330d1a.js} +2 -2
- package/dist/client/static/js/async/katex-vendor.de6a87c308.js +1 -0
- package/dist/client/static/js/async/kc.bbdb35b771.js +1 -0
- package/dist/client/static/js/async/l6.c56640c205.js +1 -0
- package/dist/client/static/js/async/{m.3fc017a055.js → m.4b29d34f93.js} +3 -3
- package/dist/client/static/js/async/{og.ae7acf03ac.js → og.634fba99ac.js} +2 -2
- package/dist/client/static/js/async/q.7fb4d2f374.js +21 -0
- package/dist/client/static/js/async/rd.cb6bf495bb.js +2 -0
- package/dist/client/static/js/async/rf.d626afc367.js +2 -0
- package/dist/client/static/js/async/s.413c2a4683.js +2 -0
- package/dist/client/static/js/async/s.413c2a4683.js.LICENSE.txt +6 -0
- package/dist/client/static/js/async/sy.d16be843a7.js +94 -0
- package/dist/client/static/js/async/t.cb3fcf8615.js +1 -0
- package/dist/client/static/js/async/ud.3065bfee31.js +2 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js.LICENSE.txt +6 -0
- package/dist/client/static/js/async/v.7d28d3c241.js +34 -0
- package/dist/client/static/js/async/v0.394ed37c6b.js +2 -0
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +6 -0
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/vy.bbe114c4e0.js +2 -0
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +6 -0
- package/dist/client/static/js/async/xw.ae67ba06fd.js +31 -0
- package/dist/client/static/js/async/xw.ae67ba06fd.js.LICENSE.txt +4 -0
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.2fa22f009a.js +1 -0
- package/dist/client/static/js/index.bf1b1fe988.js +33 -0
- package/dist/client/static/js/index.bf1b1fe988.js.LICENSE.txt +4 -0
- package/dist/client/static/js/{markdown-vendor.9da142c67b.js → markdown-vendor.71e43278b3.js} +1 -1
- package/dist/client/static/js/uy.5f791144ce.js +139 -0
- package/package.json +2 -1
- package/src/client/App.tsx +22 -5
- package/src/client/components/common/MarkdownRenderer.tsx +36 -0
- package/src/client/components/layout/desktop-layout/index.tsx +1 -1
- package/src/client/components/layout/session-sidebar/Modals.tsx +1 -1
- package/src/client/components/mobile/LayoutWrapper.tsx +1 -1
- package/src/client/components/settings/LazyModal.tsx +46 -0
- package/src/client/components/settings/Modal.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +43 -29
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +15 -69
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +47 -66
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +60 -0
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +37 -21
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +22 -4
- package/src/client/components/workspace/chat-timeline/index.tsx +3 -6
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +15 -19
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/EnvelopeHeader.tsx +46 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/FallbackOutput.tsx +18 -68
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +8 -2
- package/src/client/hooks/chat/omp/index.ts +9 -10
- package/src/client/hooks/chat/omp/revalidation-throttle.ts +6 -5
- package/src/client/hooks/chat/timeline/actions.ts +1 -2
- package/src/client/hooks/chat/timeline/index.ts +5 -0
- package/src/client/hooks/chat/timeline/send.ts +1 -2
- package/src/client/hooks/chat/timeline/session-load.ts +3 -4
- package/src/client/hooks/ui/syntax-ready.ts +16 -3
- package/src/client/main.tsx +2 -5
- package/src/client/stubs/elkjs.ts +37 -0
- package/src/client/tailwind.css +10 -0
- package/src/server/lib/fs/git-ignore.ts +49 -0
- package/src/server/lib/omp/rpc/constants.ts +10 -0
- package/src/server/lib/omp/rpc/lifecycle.test.ts +138 -0
- package/src/server/lib/omp/rpc/manager.ts +32 -4
- package/src/server/lib/omp/rpc/session-commands.ts +32 -9
- package/src/server/lib/omp/rpc/session-registry.ts +3 -2
- package/src/server/lib/omp/rpc/subagent-liveness.test.ts +62 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +100 -0
- package/src/server/lib/omp/session/messages.test.ts +112 -0
- package/src/server/lib/omp/session/messages.ts +6 -5
- package/src/server/routes/agent/command.ts +34 -16
- package/src/server/routes/fs/read.ts +15 -7
- package/src/shared/lib/chat/notice-row.test.ts +89 -0
- package/src/shared/lib/chat/notice-row.ts +78 -0
- package/src/shared/lib/chat/omp/abort.ts +41 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +154 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +36 -7
- package/src/shared/lib/chat/timeline/run-footer.test.ts +84 -0
- package/src/shared/lib/chat/timeline/run-footer.ts +96 -0
- package/src/shared/lib/chat/tool-output.test.ts +91 -0
- package/src/shared/lib/chat/tool-output.ts +54 -0
- package/src/shared/lib/chat/xml-envelope.ts +158 -0
- package/src/shared/lib/code/highlighter-lazy.ts +106 -0
- package/src/shared/lib/code/highlighter.ts +31 -52
- package/src/shared/lib/code/syntax-highlight.test.ts +36 -1
- package/src/shared/lib/code/syntax-highlight.ts +17 -3
- package/src/shared/lib/format/time.ts +19 -0
- package/src/shared/lib/markdown/fira-code-fonts.css +51 -0
- package/src/shared/lib/markdown/katex-fonts.css +20 -0
- package/src/shared/lib/markdown/katex.ts +101 -0
- package/src/shared/lib/markdown/marked.ts +23 -14
- package/src/shared/lib/markdown/math.test.ts +109 -0
- package/src/shared/lib/markdown/mermaid.ts +6 -0
- package/src/shared/lib/markdown/sanitize.ts +48 -2
- package/src/shared/lib/omp/session/mapper.ts +11 -3
- package/src/shared/lib/omp/session/messages-map.ts +12 -12
- package/src/shared/types/chat.ts +3 -1
- package/src/shared/types/fs.ts +3 -0
- package/dist/client/static/css/g.f36aa429fa.css +0 -1
- package/dist/client/static/css/index.a0f07455bd.css +0 -2
- package/dist/client/static/css/katex-vendor.ca3a0600e8.css +0 -1
- package/dist/client/static/font/KaTeX_AMS-Regular.1608a09b4a.woff +0 -0
- package/dist/client/static/font/KaTeX_AMS-Regular.4aafdb685c.ttf +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Bold.b67709187b.woff +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Bold.cce5b8ecea.ttf +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Regular.07ef19e7b0.ttf +0 -0
- package/dist/client/static/font/KaTeX_Caligraphic-Regular.dad44a7fc6.woff +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Bold.9f256b8593.woff +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Bold.b18f59e1d1.ttf +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Regular.7c1871215e.woff +0 -0
- package/dist/client/static/font/KaTeX_Fraktur-Regular.ed38e79f57.ttf +0 -0
- package/dist/client/static/font/KaTeX_Main-Bold.b74a1a8b2c.ttf +0 -0
- package/dist/client/static/font/KaTeX_Main-Bold.d181c4650d.woff +0 -0
- package/dist/client/static/font/KaTeX_Main-BoldItalic.70d8b0a530.ttf +0 -0
- package/dist/client/static/font/KaTeX_Main-BoldItalic.e3f82f9d27.woff +0 -0
- package/dist/client/static/font/KaTeX_Main-Italic.47373d1e51.ttf +0 -0
- package/dist/client/static/font/KaTeX_Main-Italic.9024d815ba.woff +0 -0
- package/dist/client/static/font/KaTeX_Main-Regular.7f51fe0340.woff +0 -0
- package/dist/client/static/font/KaTeX_Main-Regular.b7f8fe9b5f.ttf +0 -0
- package/dist/client/static/font/KaTeX_Math-BoldItalic.a879cf8383.ttf +0 -0
- package/dist/client/static/font/KaTeX_Math-BoldItalic.f1035d8d5d.woff +0 -0
- package/dist/client/static/font/KaTeX_Math-Italic.5295ba483a.woff +0 -0
- package/dist/client/static/font/KaTeX_Math-Italic.939bc64440.ttf +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Bold.94e1e8dc5f.ttf +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Bold.bf59d231c3.woff +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Italic.7c9bc82b17.woff +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Italic.b4c20c84d8.ttf +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Regular.740484788f.woff +0 -0
- package/dist/client/static/font/KaTeX_SansSerif-Regular.d4d7ba4804.ttf +0 -0
- package/dist/client/static/font/KaTeX_Script-Regular.0750571032.woff +0 -0
- package/dist/client/static/font/KaTeX_Script-Regular.fe9cbbe1a0.ttf +0 -0
- package/dist/client/static/font/KaTeX_Size1-Regular.e1e279cbdd.woff +0 -0
- package/dist/client/static/font/KaTeX_Size1-Regular.fabc004aab.ttf +0 -0
- package/dist/client/static/font/KaTeX_Size2-Regular.577270225e.woff +0 -0
- package/dist/client/static/font/KaTeX_Size2-Regular.d6b476ecd3.ttf +0 -0
- package/dist/client/static/font/KaTeX_Size3-Regular.9acaf01c64.woff +0 -0
- package/dist/client/static/font/KaTeX_Size3-Regular.a144ef5840.ttf +0 -0
- package/dist/client/static/font/KaTeX_Size4-Regular.7a996c9da2.woff +0 -0
- package/dist/client/static/font/KaTeX_Size4-Regular.fbccdabe0a.ttf +0 -0
- package/dist/client/static/font/KaTeX_Typewriter-Regular.6258592bdc.woff +0 -0
- package/dist/client/static/font/KaTeX_Typewriter-Regular.d97aaf4a1e.ttf +0 -0
- package/dist/client/static/font/fira-code-cyrillic-400-normal.5971438a4a.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-400-normal.9bb7bfea9c.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-500-normal.5486265b18.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-500-normal.89f92bfb31.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-600-normal.2805b497e5.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-600-normal.86e0df22a3.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-700-normal.505a88f22a.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-700-normal.76f5e8d2e2.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.3c4b15e57d.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-400-normal.61323debaa.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.0f34210f2a.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-500-normal.2497cfa1aa.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.40fce94af9.woff2 +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-600-normal.6c8774b829.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.4b742eb865.woff +0 -0
- package/dist/client/static/font/fira-code-cyrillic-ext-700-normal.e75eb4fccc.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-400-normal.bb900468b6.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-400-normal.be76e699ba.woff +0 -0
- package/dist/client/static/font/fira-code-greek-500-normal.176c9fb001.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-500-normal.d2c596136b.woff +0 -0
- package/dist/client/static/font/fira-code-greek-600-normal.490c3deed7.woff +0 -0
- package/dist/client/static/font/fira-code-greek-600-normal.7df14376d9.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-700-normal.c82b6072fc.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-700-normal.d5f2b2917b.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-400-normal.4849a79781.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-400-normal.7f5bfc4538.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-ext-500-normal.aaccca4075.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-ext-500-normal.e383181256.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-600-normal.78cb4bff8e.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-600-normal.d6c93d54f9.woff2 +0 -0
- package/dist/client/static/font/fira-code-greek-ext-700-normal.57ec7c92d9.woff +0 -0
- package/dist/client/static/font/fira-code-greek-ext-700-normal.8fafdae5e8.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-400-normal.973295532b.woff +0 -0
- package/dist/client/static/font/fira-code-latin-500-normal.7ae2be8fb8.woff +0 -0
- package/dist/client/static/font/fira-code-latin-600-normal.1dbbe5bc27.woff +0 -0
- package/dist/client/static/font/fira-code-latin-700-normal.497a70148b.woff +0 -0
- package/dist/client/static/font/fira-code-latin-ext-400-normal.9dfae29aaf.woff +0 -0
- package/dist/client/static/font/fira-code-latin-ext-400-normal.e534ae46e2.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-ext-500-normal.24c7b781b0.woff +0 -0
- package/dist/client/static/font/fira-code-latin-ext-500-normal.aad44ab072.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-ext-600-normal.7bed0f2f7e.woff +0 -0
- package/dist/client/static/font/fira-code-latin-ext-600-normal.c509a7c9f1.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-ext-700-normal.6c66c8b0a9.woff2 +0 -0
- package/dist/client/static/font/fira-code-latin-ext-700-normal.88f64e2822.woff +0 -0
- package/dist/client/static/font/fira-code-symbols2-400-normal.97d3f4b17c.woff +0 -0
- package/dist/client/static/font/fira-code-symbols2-500-normal.b06f1a06e4.woff +0 -0
- package/dist/client/static/font/fira-code-symbols2-600-normal.304b653b93.woff +0 -0
- package/dist/client/static/font/fira-code-symbols2-700-normal.d8ce0ad586.woff +0 -0
- package/dist/client/static/js/async/3j.a2fcea99be.js +0 -2
- package/dist/client/static/js/async/51.1938cec320.js +0 -2
- package/dist/client/static/js/async/6.7929428505.js +0 -1
- package/dist/client/static/js/async/69.a18a10882f.js +0 -1
- package/dist/client/static/js/async/a.bce4542392.js +0 -1
- package/dist/client/static/js/async/ar.9dc8b1992a.js +0 -2
- package/dist/client/static/js/async/bi.cbfe8eaa8e.js +0 -1
- package/dist/client/static/js/async/ct.38d9da8a5a.js +0 -1
- package/dist/client/static/js/async/fw.fb1a08b92d.js +0 -6
- package/dist/client/static/js/async/l6.45f3b62e15.js +0 -1
- package/dist/client/static/js/async/q.0b6fb9bf8d.js +0 -21
- package/dist/client/static/js/async/rd.b77011ab7f.js +0 -2
- package/dist/client/static/js/async/rf.cfbde7f744.js +0 -2
- package/dist/client/static/js/async/s.c7d521a8ae.js +0 -2
- package/dist/client/static/js/async/sy.7345abcd67.js +0 -94
- package/dist/client/static/js/async/t.0dcb01dfde.js +0 -1
- package/dist/client/static/js/async/v.5c7a4b087b.js +0 -1
- package/dist/client/static/js/async/vu.311f1d202c.js +0 -34
- package/dist/client/static/js/async/yg.9bd4e09ecb.js +0 -149
- package/dist/client/static/js/async/yv.593a3f7d54.js +0 -1
- package/dist/client/static/js/g.124fba0826.js +0 -139
- package/dist/client/static/js/index.907fd4670e.js +0 -68
- package/dist/client/static/js/katex-vendor.95e48cf65a.js +0 -1
- package/src/shared/lib/chat/order.ts +0 -40
- /package/dist/client/static/js/async/{1k.544adbf77d.js.LICENSE.txt → 1k.5974df3b87.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3j.a2fcea99be.js.LICENSE.txt → 3j.357c80ff38.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.22075aa079.js.LICENSE.txt → 3y.ededeebde8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{51.1938cec320.js.LICENSE.txt → 51.95bcae98af.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.9dc8b1992a.js.LICENSE.txt → a.e05da304c8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.fb1a08b92d.js.LICENSE.txt → art.62139ed479.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.3fc017a055.js.LICENSE.txt → fw.6a51998a8e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.0b6fb9bf8d.js.LICENSE.txt → m.4b29d34f93.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.cfbde7f744.js.LICENSE.txt → q.7fb4d2f374.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rd.b77011ab7f.js.LICENSE.txt → rd.cb6bf495bb.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.c7d521a8ae.js.LICENSE.txt → rf.d626afc367.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.907fd4670e.js.LICENSE.txt → async/ud.3065bfee31.js.LICENSE.txt} +0 -0
- /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
|
|
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)
|
|
148
|
-
//
|
|
149
|
-
|
|
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
|
|
164
|
+
return [...prev.slice(0, pIdx), msg, ...prev.slice(pIdx)];
|
|
155
165
|
}
|
|
156
|
-
return
|
|
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
|
+
}
|