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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Liveness of a session's subagents, folded from the frames the wrapper already
|
|
8
|
+
* receives (`set_subagent_subscription` in AgentSessionWrapper.initialize).
|
|
9
|
+
*
|
|
10
|
+
* Every other "this session is working" flag on the wrapper (promptRunning /
|
|
11
|
+
* streaming / compacting / bashRunning) is blind to subagents: a parent turn
|
|
12
|
+
* can end — or a detached subagent outlive it — while subagents keep working,
|
|
13
|
+
* and a frame-silent subagent otherwise looks exactly like an idle session to
|
|
14
|
+
* the idle reaper. Used only for lifecycle decisions (idle reclaim, spawn-mode
|
|
15
|
+
* reconcile), never for the UI's running set.
|
|
16
|
+
*
|
|
17
|
+
* Identity mirrors the UI fold (`SubagentList`): frames carry an id, a
|
|
18
|
+
* lifecycle may also carry `index`, and progress frames may be id-less and are
|
|
19
|
+
* then matched by index — hence the index→key alias, so one subagent is never
|
|
20
|
+
* tracked under two keys (a leftover key would pin the session alive until it
|
|
21
|
+
* goes stale).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { parseSubagentLifecycle, parseSubagentProgress } from '@/shared/lib/omp/subagent/parse';
|
|
25
|
+
import { isRecord } from '@/shared/lib/util/guards';
|
|
26
|
+
import type { SubagentProgress } from '@/shared/types/omp/subagent';
|
|
27
|
+
|
|
28
|
+
/** Statuses that end a subagent. Mirrors omp's own registry, which deletes an
|
|
29
|
+
* entry the moment a non-`started` lifecycle arrives. */
|
|
30
|
+
const TERMINAL_STATUS: Record<string, true> = { completed: true, failed: true, aborted: true };
|
|
31
|
+
|
|
32
|
+
/** Progress frames arrive AgentProgress-shaped; some wrappers nest the snapshot. */
|
|
33
|
+
function readProgress(payload: unknown): SubagentProgress | undefined {
|
|
34
|
+
const direct = parseSubagentProgress(payload);
|
|
35
|
+
if (!isRecord(payload) || !isRecord(payload.progress)) return direct;
|
|
36
|
+
const nested = parseSubagentProgress(payload.progress);
|
|
37
|
+
return nested ? { ...nested, id: nested.id ?? direct?.id, index: nested.index ?? direct?.index } : direct;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class SubagentLiveness {
|
|
41
|
+
private readonly lastSeen = new Map<string, number>();
|
|
42
|
+
/** index → key, so an id-less progress frame lands on the entry its lifecycle
|
|
43
|
+
* created instead of opening a second one. */
|
|
44
|
+
private readonly keyByIndex = new Map<number, string>();
|
|
45
|
+
|
|
46
|
+
/** Fold one `subagent_lifecycle|subagent_progress|subagent_event` frame. */
|
|
47
|
+
observe(frame: { type: string; payload?: unknown }, now: number): void {
|
|
48
|
+
if (frame.type === 'subagent_lifecycle') {
|
|
49
|
+
const entry = parseSubagentLifecycle(frame.payload);
|
|
50
|
+
if (!entry) return;
|
|
51
|
+
if (TERMINAL_STATUS[entry.status] === true) this.forget(entry.id);
|
|
52
|
+
else this.mark(entry.id, entry.index >= 0 ? entry.index : undefined, now);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (frame.type === 'subagent_progress') {
|
|
57
|
+
const progress = readProgress(frame.payload);
|
|
58
|
+
if (!progress) return;
|
|
59
|
+
const index = progress.index !== undefined && progress.index >= 0 ? progress.index : undefined;
|
|
60
|
+
const key = progress.id ?? (index !== undefined ? this.keyByIndex.get(index) ?? `idx:${index}` : undefined);
|
|
61
|
+
if (key === undefined) return;
|
|
62
|
+
if (progress.status !== undefined && TERMINAL_STATUS[progress.status] === true) this.forget(key);
|
|
63
|
+
else this.mark(key, index, now);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (frame.type === 'subagent_event') {
|
|
68
|
+
// An event proves *some* subagent is working but carries no identity of
|
|
69
|
+
// its own: refresh what is already tracked, and never invent an entry —
|
|
70
|
+
// an invented one could never be cleared by a terminal frame.
|
|
71
|
+
for (const key of this.lastSeen.keys()) this.lastSeen.set(key, now);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Live subagents as of `now`. Entries silent past `staleMs` are dropped
|
|
76
|
+
* first: a terminal frame lost to a protocol hiccup must not pin a session
|
|
77
|
+
* alive forever (the price of the window is at most one idle omp process). */
|
|
78
|
+
liveCount(now: number, staleMs: number): number {
|
|
79
|
+
for (const [key, lastSeen] of this.lastSeen) {
|
|
80
|
+
if (now - lastSeen > staleMs) this.forget(key);
|
|
81
|
+
}
|
|
82
|
+
return this.lastSeen.size;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private mark(key: string, index: number | undefined, now: number): void {
|
|
86
|
+
this.lastSeen.set(key, now);
|
|
87
|
+
if (index === undefined) return;
|
|
88
|
+
// A lifecycle naming the same index supersedes the synthetic key an earlier
|
|
89
|
+
// id-less progress frame created for it.
|
|
90
|
+
if (key !== `idx:${index}`) this.lastSeen.delete(`idx:${index}`);
|
|
91
|
+
this.keyByIndex.set(index, key);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private forget(key: string): void {
|
|
95
|
+
this.lastSeen.delete(key);
|
|
96
|
+
for (const [index, alias] of this.keyByIndex) {
|
|
97
|
+
if (alias === key) this.keyByIndex.delete(index);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
import { loadSessionMessages } from '@/server/lib/omp/session/messages';
|
|
11
|
+
import { isNoticeRow } from '@/shared/lib/chat/notice-row';
|
|
12
|
+
|
|
13
|
+
const tempDirs: string[] = [];
|
|
14
|
+
|
|
15
|
+
/** Write a session JSONL whose lines are exactly the given records, in order. */
|
|
16
|
+
async function writeSession(lines: unknown[]): Promise<string> {
|
|
17
|
+
const dir = await fs.promises.mkdtemp('omp-messages-');
|
|
18
|
+
tempDirs.push(dir);
|
|
19
|
+
const file = join(dir, 'session.jsonl');
|
|
20
|
+
await Bun.write(file, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`);
|
|
21
|
+
return file;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const at = (second: number) => `2026-09-21T03:00:${String(second).padStart(2, '0')}.000Z`;
|
|
29
|
+
|
|
30
|
+
/** omp records harness notices as their own `custom_message` entry. */
|
|
31
|
+
const notice = (id: string, text: string, second: number) => ({
|
|
32
|
+
type: 'custom_message',
|
|
33
|
+
id,
|
|
34
|
+
customType: 'ultrathink-notice',
|
|
35
|
+
timestamp: at(second),
|
|
36
|
+
content: [{ type: 'text', text: `<system-notice>${text}</system-notice>` }],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const turn = (id: string, role: 'user' | 'assistant', text: string, second: number) => ({
|
|
40
|
+
type: 'message',
|
|
41
|
+
id,
|
|
42
|
+
timestamp: at(second),
|
|
43
|
+
message: { role, content: [{ type: 'text', text }] },
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('loadSessionMessages ordering', () => {
|
|
47
|
+
test('returns entries in file order, leaving a notice before its following user turn', async () => {
|
|
48
|
+
const file = await writeSession([
|
|
49
|
+
notice('n1', 'Background job finished.', 0),
|
|
50
|
+
turn('u1', 'user', 'halo', 1),
|
|
51
|
+
turn('a1', 'assistant', 'hai', 2),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const messages = await loadSessionMessages(file);
|
|
55
|
+
|
|
56
|
+
expect(messages.map((m) => m.id)).toEqual(['n1', 'u1', 'a1']);
|
|
57
|
+
expect(messages[0].notice).toBe('Background job finished.');
|
|
58
|
+
expect(messages[1]).toMatchObject({ role: 'user', content: 'halo' });
|
|
59
|
+
expect(messages[2]).toMatchObject({ role: 'ai', content: 'hai' });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('preserves a notice that sits between two turns', async () => {
|
|
63
|
+
const file = await writeSession([
|
|
64
|
+
turn('u1', 'user', 'satu', 0),
|
|
65
|
+
turn('a1', 'assistant', 'dua', 1),
|
|
66
|
+
notice('n1', 'Job done.', 2),
|
|
67
|
+
turn('u2', 'user', 'tiga', 3),
|
|
68
|
+
turn('a2', 'assistant', 'empat', 4),
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
expect((await loadSessionMessages(file)).map((m) => m.id)).toEqual(['u1', 'a1', 'n1', 'u2', 'a2']);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('loadSessionMessages reminder classification', () => {
|
|
76
|
+
const blocks = (id: string, texts: string[], second: number) => ({
|
|
77
|
+
type: 'message',
|
|
78
|
+
id,
|
|
79
|
+
timestamp: at(second),
|
|
80
|
+
message: { role: 'assistant', content: texts.map((text) => ({ type: 'text', text })), usage: { input: 1, output: 2 } },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('prose that quotes the tag stays the answer', async () => {
|
|
84
|
+
const prose = 'Chip shows the peeled tag (`system-reminder`).\n\nExample: <system-reminder>…</system-reminder> then free text.';
|
|
85
|
+
const file = await writeSession([blocks('a1', [prose], 0)]);
|
|
86
|
+
|
|
87
|
+
const [message] = await loadSessionMessages(file);
|
|
88
|
+
|
|
89
|
+
expect(message.notice).toBeUndefined();
|
|
90
|
+
expect(message.content).toBe(prose);
|
|
91
|
+
expect(isNoticeRow(message)).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a text block that IS the reminder envelope becomes a notice', async () => {
|
|
95
|
+
const file = await writeSession([blocks('a1', ['<system-reminder>\n10 todo items still open.\n</system-reminder>'], 0)]);
|
|
96
|
+
|
|
97
|
+
const [message] = await loadSessionMessages(file);
|
|
98
|
+
|
|
99
|
+
expect(message.content).toBe('');
|
|
100
|
+
expect(message.notice).toBe('<system-reminder>\n10 todo items still open.\n</system-reminder>');
|
|
101
|
+
expect(isNoticeRow(message)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('an envelope and prose in one turn split between notice and content', async () => {
|
|
105
|
+
const file = await writeSession([blocks('a1', ['Jawaban akhir.', '<system-reminder>2 items open</system-reminder>'], 0)]);
|
|
106
|
+
|
|
107
|
+
const [message] = await loadSessionMessages(file);
|
|
108
|
+
|
|
109
|
+
expect(message.content).toBe('Jawaban akhir.');
|
|
110
|
+
expect(message.notice).toBe('<system-reminder>2 items open</system-reminder>');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -21,7 +21,6 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { parseJsonlLenient } from '@/shared/lib/omp/session/jsonl';
|
|
24
|
-
import { normalizeNoticePositions } from '@/shared/lib/chat/order';
|
|
25
24
|
import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
|
|
26
25
|
import type { ChatMessageData } from '@/shared/types/chat';
|
|
27
26
|
import { collectToolOutputs, noticeFromCustomMessage, toChatMessage, type SequenceState } from '@/shared/lib/omp/session/messages-map';
|
|
@@ -53,9 +52,11 @@ function skillUserMessageFromRecord(record: Record<string, unknown>): ChatMessag
|
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
/**
|
|
56
|
-
* Load a session file and return the chat timeline in
|
|
57
|
-
*
|
|
58
|
-
*
|
|
55
|
+
* Load a session file and return the chat timeline in **file order** — the
|
|
56
|
+
* order omp wrote the entries, which is the session's chronology. The API
|
|
57
|
+
* response is a faithful projection of the JSONL: nothing here reorders rows,
|
|
58
|
+
* and no display-side reordering is applied anywhere either (a notice row
|
|
59
|
+
* renders exactly where omp wrote it).
|
|
59
60
|
*/
|
|
60
61
|
export async function loadSessionMessages(filePath: string): Promise<ChatMessageData[]> {
|
|
61
62
|
try {
|
|
@@ -120,7 +121,7 @@ export async function loadSessionMessages(filePath: string): Promise<ChatMessage
|
|
|
120
121
|
}
|
|
121
122
|
state.messages.push(mapped);
|
|
122
123
|
}
|
|
123
|
-
return
|
|
124
|
+
return state.messages;
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
/** Derive the display title from the JSONL header/title slot (cheap read). */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { json } from '@/server/lib/remix-compat';
|
|
2
2
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
3
3
|
import { resolveSessionPathOr404 } from '@/server/lib/omp/session/locator';
|
|
4
|
-
import { WebRpcError, getRpcSession, resolveSpawnCwd, startRpcSession } from '@/server/lib/omp/rpc/manager';
|
|
4
|
+
import { WebRpcError, getRpcSession, resolveSpawnCwd, startRpcSession, type AgentSessionWrapper } from '@/server/lib/omp/rpc/manager';
|
|
5
5
|
import { getSpawnApprovalMode, reconcileSpawnApprovalMode } from '@/server/lib/omp/rpc/session-registry';
|
|
6
6
|
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
7
7
|
import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
|
|
@@ -53,29 +53,47 @@ export async function sendCommand({ params, request }: ActionFunctionArgs) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** RPC-free view of a busy session. `state` carries only the two flags the
|
|
57
|
+
* attach probe reads; the full snapshot resumes once the turn settles. */
|
|
58
|
+
function busySessionPayload(session: AgentSessionWrapper) {
|
|
59
|
+
return {
|
|
60
|
+
running: true,
|
|
61
|
+
busy: true,
|
|
62
|
+
state: { isStreaming: session.streaming, isPromptRunning: session.promptRunning },
|
|
63
|
+
pendingUiRequests: session.getPendingUiDialogs(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
// GET /api/agent/:sessionId — current agent state (running set + live state).
|
|
57
68
|
export async function getAgentState({ params }: LoaderFunctionArgs) {
|
|
58
69
|
const { sessionId } = params;
|
|
59
70
|
if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
|
|
60
71
|
|
|
72
|
+
const session = getRpcSession(sessionId);
|
|
73
|
+
if (!session || !session.isAlive()) {
|
|
74
|
+
return json({ running: false });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A busy session answers from local flags: its `get_state` would queue behind
|
|
78
|
+
// the running turn (omp runs RPC handlers one at a time), and a timeout there
|
|
79
|
+
// is no reason to reset a session that is demonstrably working — subagents
|
|
80
|
+
// included. These flags are all the client needs to reattach its stream.
|
|
81
|
+
if (session.isBusy()) return json(busySessionPayload(session));
|
|
82
|
+
|
|
61
83
|
try {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
84
|
+
const state = await session.send({ type: 'get_state' });
|
|
85
|
+
// Dialogs omp is still blocked on: a client that reloaded mid-ask has no
|
|
86
|
+
// other way to learn the request id it must answer, and omp never
|
|
87
|
+
// re-emits the frame.
|
|
88
|
+
return json({ running: true, state, pendingUiRequests: session.getPendingUiDialogs() });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof WebRpcError && error.code === 'session_unresponsive') {
|
|
91
|
+
return json({ running: false, recovered: true });
|
|
65
92
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// other way to learn the request id it must answer, and omp never
|
|
70
|
-
// re-emits the frame.
|
|
71
|
-
return json({ running: true, state, pendingUiRequests: session.getPendingUiDialogs() });
|
|
72
|
-
} catch (error) {
|
|
73
|
-
if (error instanceof WebRpcError && error.code === 'session_unresponsive') {
|
|
74
|
-
return json({ running: false, recovered: true });
|
|
75
|
-
}
|
|
76
|
-
throw error;
|
|
93
|
+
// A turn started between the check above and the RPC: report busy, not dead.
|
|
94
|
+
if (error instanceof WebRpcError && error.code === 'session_busy') {
|
|
95
|
+
return json(busySessionPayload(session));
|
|
77
96
|
}
|
|
78
|
-
} catch (error) {
|
|
79
97
|
return rpcErrorResponse(error);
|
|
80
98
|
}
|
|
81
99
|
}
|
|
@@ -11,6 +11,7 @@ import fs from 'fs';
|
|
|
11
11
|
import path from 'path';
|
|
12
12
|
import { isMockMode } from '@/server/mock.server';
|
|
13
13
|
import { getDefaultFsRoot, resolveRoot, resolveWithinRoot } from '@/server/lib/fs/root';
|
|
14
|
+
import { collectIgnoredPaths } from '@/server/lib/fs/git-ignore';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* GET /api/fs/browse?path=<abs> — list subdirectories of a folder for the
|
|
@@ -92,19 +93,26 @@ const NOISE_DIRS: Record<string, true> = { node_modules: true, '.git': true, dis
|
|
|
92
93
|
// fetch them on demand — directories are NOT recursed here (keeps the initial
|
|
93
94
|
// payload tiny for deep workspaces). Hidden (dot-prefixed) entries are part of
|
|
94
95
|
// the listing: lazy children keep `.github`, `.config`, `.env`, … cheap.
|
|
96
|
+
// Entries git would refuse to track carry `ignored: true` so the panel can dim
|
|
97
|
+
// them; that is one `git check-ignore` per listing, never one per entry.
|
|
95
98
|
async function listEntries(dirPath: string, rootPath: string): Promise<any[]> {
|
|
96
|
-
const
|
|
97
|
-
.filter(child => !NOISE_DIRS[child])
|
|
99
|
+
const listed = (await fs.promises.readdir(dirPath))
|
|
100
|
+
.filter(child => !NOISE_DIRS[child])
|
|
101
|
+
.map(child => {
|
|
102
|
+
const full = path.join(dirPath, child);
|
|
103
|
+
return { child, full, rel: path.relative(rootPath, full) };
|
|
104
|
+
});
|
|
98
105
|
|
|
99
|
-
const
|
|
100
|
-
|
|
106
|
+
const ignored = await collectIgnoredPaths(rootPath, listed.map(entry => entry.rel));
|
|
107
|
+
|
|
108
|
+
const mapped = await Promise.all(listed.map(async ({ child, full, rel }) => {
|
|
101
109
|
try {
|
|
102
110
|
const st = await Bun.file(full).stat();
|
|
103
|
-
const
|
|
111
|
+
const isIgnored = ignored.has(rel);
|
|
104
112
|
if (st.isDirectory()) {
|
|
105
|
-
return { id: rel, name: child, type: 'folder', path: rel, children: null, is_expanded: 0 };
|
|
113
|
+
return { id: rel, name: child, type: 'folder', path: rel, children: null, is_expanded: 0, ignored: isIgnored };
|
|
106
114
|
}
|
|
107
|
-
return { id: rel, name: child, type: 'file', path: rel };
|
|
115
|
+
return { id: rel, name: child, type: 'file', path: rel, ignored: isIgnored };
|
|
108
116
|
} catch {
|
|
109
117
|
return null; // unreadable entry / broken symlink — skip
|
|
110
118
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
isNoticeRow,
|
|
10
|
+
messageAnswerText,
|
|
11
|
+
noticeIsAssistantText,
|
|
12
|
+
reminderPartIndex,
|
|
13
|
+
} from '@/shared/lib/chat/notice-row';
|
|
14
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
15
|
+
|
|
16
|
+
const row = (over: Partial<ChatMessageData>): ChatMessageData => ({
|
|
17
|
+
id: 'n1',
|
|
18
|
+
role: 'ai',
|
|
19
|
+
content: '',
|
|
20
|
+
...over,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const answer = { model: 'deepseek-v4', usage: { totalTokens: 150_640 }, durationMs: 5_574 };
|
|
24
|
+
|
|
25
|
+
describe('reminderPartIndex', () => {
|
|
26
|
+
test('finds a text block that is a reminder envelope', () => {
|
|
27
|
+
expect(reminderPartIndex(['<system-reminder>\n10 todos open\n</system-reminder>'])).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('ignores a tag quoted inside prose', () => {
|
|
31
|
+
expect(reminderPartIndex(['Chip shows the peeled tag (`system-reminder`).'])).toBeUndefined();
|
|
32
|
+
expect(reminderPartIndex(['Example: <system-reminder>…</system-reminder> then free text'])).toBeUndefined();
|
|
33
|
+
expect(
|
|
34
|
+
reminderPartIndex(['A wrapper is `<a>1</a>`; see <system-reminder> handling below']),
|
|
35
|
+
).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('ignores an unclosed or mismatched tag pair', () => {
|
|
39
|
+
expect(reminderPartIndex(['<system-reminder>\nno closing tag'])).toBeUndefined();
|
|
40
|
+
expect(reminderPartIndex(['<system-reminder><b>10 todos</system-reminder>'])).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('picks the envelope out of a multi-block turn', () => {
|
|
44
|
+
expect(reminderPartIndex(['prose first', '<system-reminder>note</system-reminder>'])).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('notice row classification', () => {
|
|
49
|
+
test('a bare notice row is a notice card', () => {
|
|
50
|
+
const notice = row({ notice: 'Late LSP diagnostics arrived after the edit returned.' });
|
|
51
|
+
expect(noticeIsAssistantText(notice)).toBe(false);
|
|
52
|
+
expect(isNoticeRow(notice)).toBe(true);
|
|
53
|
+
expect(messageAnswerText(notice)).toBe('');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a notice row that kept the turn metadata is the answer', () => {
|
|
57
|
+
const text = 'Implementasi selesai.\n\n## Aturan';
|
|
58
|
+
const diverted = row({ notice: text, ...answer });
|
|
59
|
+
expect(noticeIsAssistantText(diverted)).toBe(true);
|
|
60
|
+
expect(isNoticeRow(diverted)).toBe(false);
|
|
61
|
+
expect(messageAnswerText(diverted)).toBe(text);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a wrapped reminder stays a notice card even with turn metadata', () => {
|
|
65
|
+
const reminder = row({ notice: '<system-reminder>10 todo items still open.</system-reminder>', ...answer });
|
|
66
|
+
expect(noticeIsAssistantText(reminder)).toBe(false);
|
|
67
|
+
expect(isNoticeRow(reminder)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a task result stays a notice card', () => {
|
|
71
|
+
const task = row({ notice: '<task-result id="Job" status="completed">done</task-result>', ...answer });
|
|
72
|
+
expect(isNoticeRow(task)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('the live thinking-level stamp alone does not make a notice an answer', () => {
|
|
76
|
+
expect(isNoticeRow(row({ notice: 'Job done.', thinkingLevel: 'high' }))).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('an ordinary answer row is unaffected', () => {
|
|
80
|
+
const plain = row({ content: 'Halo', ...answer });
|
|
81
|
+
expect(isNoticeRow(plain)).toBe(false);
|
|
82
|
+
expect(messageAnswerText(plain)).toBe('Halo');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('an empty notice is not a notice row', () => {
|
|
86
|
+
expect(isNoticeRow(row({ notice: ' ' }))).toBe(false);
|
|
87
|
+
expect(isNoticeRow(row({}))).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `notice` rows — and the one case where the field carries the ANSWER.
|
|
8
|
+
*
|
|
9
|
+
* A real notice row holds nothing but the notice: omp writes harness reminders,
|
|
10
|
+
* task results and late diagnostics as their own entry, so those rows have no
|
|
11
|
+
* model, no usage and no thinking. When the assistant's answer itself ends up
|
|
12
|
+
* in `notice` (omp diverted the turn's text block), the row keeps the turn's
|
|
13
|
+
* own metadata — model/provider/usage/duration/thinking/toolCalls — and is
|
|
14
|
+
* therefore an answer that must render as text, never as a System Notice card.
|
|
15
|
+
*
|
|
16
|
+
* The decision lives here once so every consumer agrees: the timeline row
|
|
17
|
+
* (`MessageItem`), the boundary rules of the run footer, and the live-stream
|
|
18
|
+
* callbacks.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
22
|
+
import { isReminderTag, unwrapXmlEnvelope } from '@/shared/lib/chat/xml-envelope';
|
|
23
|
+
|
|
24
|
+
/** Tag that identifies a notice payload rather than prose. A notice keeps its
|
|
25
|
+
* wrapper — that tag is the field's identity, and `SystemNotice` reads it to
|
|
26
|
+
* pick "System Reminder" / "Task Result" over the generic card. */
|
|
27
|
+
const NOTICE_TAG_RE = /<\/?(?:system-reminder|reminder|system-notice|task-result)\b/i;
|
|
28
|
+
|
|
29
|
+
/** Fields only a real assistant turn carries. `thinkingLevel` is deliberately
|
|
30
|
+
* absent: the live stream stamps it on every non-user frame, notice rows
|
|
31
|
+
* included, so it proves nothing about the row being an answer. */
|
|
32
|
+
const TURN_FIELDS = [
|
|
33
|
+
'model',
|
|
34
|
+
'provider',
|
|
35
|
+
'usage',
|
|
36
|
+
'durationMs',
|
|
37
|
+
'startedAt',
|
|
38
|
+
'completedAt',
|
|
39
|
+
'thinking',
|
|
40
|
+
'toolCalls',
|
|
41
|
+
] as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Index of the text part that IS a reminder envelope — `undefined` when the
|
|
45
|
+
* turn's prose merely quotes the tag (`see <system-reminder> handling` is
|
|
46
|
+
* content, not a wrapper). The envelope must open the part and enclose a whole
|
|
47
|
+
* element, so both statuses of a reminder turn are distinguishable before
|
|
48
|
+
* anything is moved into `notice`.
|
|
49
|
+
*/
|
|
50
|
+
export function reminderPartIndex(parts: string[]): number | undefined {
|
|
51
|
+
const index = parts.findIndex((part) => {
|
|
52
|
+
const envelope = unwrapXmlEnvelope(part);
|
|
53
|
+
return envelope !== undefined && isReminderTag(envelope.tag);
|
|
54
|
+
});
|
|
55
|
+
return index === -1 ? undefined : index;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True when the row's `notice` is the assistant answer omp diverted there. */
|
|
59
|
+
export function noticeIsAssistantText(
|
|
60
|
+
msg: ChatMessageData | null | undefined,
|
|
61
|
+
): msg is ChatMessageData & { notice: string } {
|
|
62
|
+
if (typeof msg?.notice !== 'string' || !msg.notice.trim()) return false;
|
|
63
|
+
if (NOTICE_TAG_RE.test(msg.notice)) return false;
|
|
64
|
+
return TURN_FIELDS.some((field) => msg[field] !== undefined && msg[field] !== null);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when the row renders as a notice card (a real notice, not an answer). */
|
|
68
|
+
export function isNoticeRow(
|
|
69
|
+
msg: ChatMessageData | null | undefined,
|
|
70
|
+
): msg is ChatMessageData & { notice: string } {
|
|
71
|
+
return typeof msg?.notice === 'string' && Boolean(msg.notice.trim()) && !noticeIsAssistantText(msg);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The row's answer text: `content`, or the notice when the answer landed there. */
|
|
75
|
+
export function messageAnswerText(msg: ChatMessageData | null | undefined): string {
|
|
76
|
+
if (noticeIsAssistantText(msg)) return msg.notice;
|
|
77
|
+
return typeof msg?.content === 'string' ? msg.content : '';
|
|
78
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Stop-button escalation for a live session.
|
|
8
|
+
*
|
|
9
|
+
* `abort` is awaited server-side until the turn actually stops, so a session
|
|
10
|
+
* wedged on a subagent — or on a child that stopped answering — never replies
|
|
11
|
+
* to it and the Stop button would hang with it. If the abort request is still
|
|
12
|
+
* pending after the grace period it is cancelled here and the stop escalates to
|
|
13
|
+
* `force_reset`, which the server answers by destroying the session's omp child
|
|
14
|
+
* (and every subagent under it) instead of waiting for a clean stop.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** How long a stop may take before the process is reset instead. */
|
|
18
|
+
const ABORT_GRACE_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
function postCommand(sessionId: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<Response> {
|
|
21
|
+
return fetch(`/api/agent/${encodeURIComponent(sessionId)}`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
...(signal ? { signal } : {}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Ask the session to stop, then reset its process if it does not stop in time.
|
|
30
|
+
* Failures are swallowed: the event stream is the source of truth for state. */
|
|
31
|
+
export async function stopAgentSession(sessionId: string): Promise<void> {
|
|
32
|
+
const grace = new AbortController();
|
|
33
|
+
const timer = setTimeout(() => grace.abort(), ABORT_GRACE_MS);
|
|
34
|
+
try {
|
|
35
|
+
const stopped = await postCommand(sessionId, { type: 'abort' }, grace.signal).then(() => true, () => false);
|
|
36
|
+
if (stopped) return;
|
|
37
|
+
await postCommand(sessionId, { type: 'force_reset' }).catch(() => null);
|
|
38
|
+
} finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
}
|
|
41
|
+
}
|