ompchamber 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -15,8 +15,8 @@
15
15
  * - cwd → repository root → lib/omp/worktree.ts
16
16
  * - managed projects registry → lib/omp/project-registry.ts
17
17
  *
18
- * All reads are bounded (4 KiB prefix per session file), cached by mtime, and
19
- * never mutate the agent's files.
18
+ * All reads are bounded (a 4 KiB prefix plus a 16 KiB tail window per session
19
+ * file), cached by file mtime, and never mutate the agent's files.
20
20
  */
21
21
 
22
22
  import { listAllSessionInfos, type OmpSessionInfo } from '@/server/lib/omp/session/files';
@@ -12,10 +12,13 @@
12
12
  * megabytes, and doing that per session per poll dominated the endpoint's cost
13
13
  * (the dataset scan itself is cached; this probe was not).
14
14
  *
15
- * The roster can only change when the session file or its sibling artifacts
16
- * directory changes, so that (session mtime, sibling-dir mtime) pair is a
17
- * sufficient cache version. Both cost one stat — orders of magnitude cheaper
18
- * than the parse they gate.
15
+ * The roster can only change when the session file gains entries or its
16
+ * sibling artifacts directory changes, so that (last-entry timestamp,
17
+ * sibling-dir mtime) pair is a sufficient cache version. Both cost one stat —
18
+ * orders of magnitude cheaper than the parse they gate. The timestamp is the
19
+ * file's last ENTRY time, not its mtime: a title-slot rewrite bumps mtime
20
+ * without appending anything, and a spurious version bump would re-run the
21
+ * parse it exists to avoid.
19
22
  */
20
23
 
21
24
  import { promises as fs } from 'fs';
@@ -42,10 +45,10 @@ async function siblingDirMtimeMs(dir: string): Promise<number> {
42
45
 
43
46
  export async function sessionHasSubagents(
44
47
  sessionPath: string,
45
- sessionModified: string,
48
+ sessionLastEntryAt: string,
46
49
  ): Promise<boolean> {
47
50
  const siblingDir = siblingDirForSession(sessionPath);
48
- const version = `${sessionModified}:${await siblingDirMtimeMs(siblingDir)}`;
51
+ const version = `${sessionLastEntryAt}:${await siblingDirMtimeMs(siblingDir)}`;
49
52
 
50
53
  const cached = cache.get(sessionPath);
51
54
  if (cached && cached.version === version) return cached.hasSubagents;
@@ -0,0 +1,260 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The follow-up queue must never wedge.
8
+ *
9
+ * A scheduled delivery whose timer never runs (a `bun --hot` module
10
+ * re-evaluation discards the pending closure; a wedged event loop drops the
11
+ * callback) used to poison the session permanently: the in-flight guard
12
+ * early-returned on the leftover entry, so no later trigger — another
13
+ * agent_end, a tab's mount nudge — could ever schedule again. The queue simply
14
+ * stopped draining, with no error anywhere. Observed live: a dev server whose
15
+ * `setTimeout` from request handlers had stopped firing left every queued
16
+ * follow-up stranded, while a freshly started server delivered the identical
17
+ * item correctly.
18
+ *
19
+ * The store is mocked so the contract under test is delivery's own: which RPCs
20
+ * a claimed item produces, and that a lost timer is superseded rather than
21
+ * allowed to wedge the session. Time is driven by fake timers, so nothing here
22
+ * waits on the wall clock.
23
+ */
24
+
25
+ import { afterAll, afterEach, beforeEach, describe, expect, jest, mock, test } from 'bun:test';
26
+ import type { QueuedMessage } from '@/shared/types/chat';
27
+ import type { QueueDeliveryHost } from '@/server/lib/queue/delivery.server';
28
+
29
+ /** In-memory stand-in for the SQLite-backed queue. */
30
+ const queue = new Map<string, QueuedMessage[]>();
31
+
32
+ mock.module('@/server/lib/queue/store.server', () => ({
33
+ claimHeadQueueItem: (sessionId: string): QueuedMessage | null => {
34
+ const items = queue.get(sessionId);
35
+ return items?.shift() ?? null;
36
+ },
37
+ requeueHeadQueueItem: async (sessionId: string, item: QueuedMessage): Promise<void> => {
38
+ const items = queue.get(sessionId) ?? [];
39
+ items.unshift(item);
40
+ queue.set(sessionId, items);
41
+ },
42
+ }));
43
+
44
+ const { scheduleQueueDelivery } = await import('@/server/lib/queue/delivery.server');
45
+
46
+ const MODEL = { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max', accessMode: 'yolo' as const };
47
+
48
+ function queueItem(text: string, model: QueuedMessage['model'] = MODEL): QueuedMessage {
49
+ return { id: `item-${text}`, text, attachments: [], model };
50
+ }
51
+
52
+ /** Records the RPCs a delivery pushes at the session. */
53
+ function makeHost(sessionId: string) {
54
+ const sent: Record<string, unknown>[] = [];
55
+ const host: QueueDeliveryHost = {
56
+ sessionId,
57
+ isAlive: () => true,
58
+ isBusy: () => false,
59
+ send: async (command) => {
60
+ sent.push(command);
61
+ return {};
62
+ },
63
+ };
64
+ return { host, sent };
65
+ }
66
+
67
+ /** Let the delivery's promise chain settle without advancing the clock. */
68
+ async function flush(): Promise<void> {
69
+ for (let i = 0; i < 20; i++) await Promise.resolve();
70
+ }
71
+
72
+ let sessionId = '';
73
+ let counter = 0;
74
+
75
+ beforeEach(() => {
76
+ sessionId = `queue-test-${counter++}`;
77
+ queue.clear();
78
+ jest.useFakeTimers();
79
+ });
80
+
81
+ afterEach(() => {
82
+ jest.useRealTimers();
83
+ });
84
+
85
+ afterAll(() => {
86
+ mock.restore();
87
+ });
88
+
89
+ describe('queue delivery', () => {
90
+ test('delivers the head with the model snapshot it was queued with', async () => {
91
+ queue.set(sessionId, [queueItem('queued hello')]);
92
+ const { host, sent } = makeHost(sessionId);
93
+
94
+ scheduleQueueDelivery(host);
95
+ jest.advanceTimersByTime(600);
96
+ await flush();
97
+
98
+ expect(sent).toEqual([
99
+ { type: 'set_model', provider: 'kenari', modelId: 'deepseek-v4-pro' },
100
+ { type: 'set_thinking_level', level: 'max' },
101
+ { type: 'prompt', message: 'queued hello', accessMode: 'yolo' },
102
+ ]);
103
+ expect(queue.get(sessionId)).toEqual([]);
104
+ });
105
+
106
+ test("an 'auto' thinking level is left to the session", async () => {
107
+ queue.set(sessionId, [queueItem('no level', { ...MODEL, thinkingLevel: 'auto' })]);
108
+ const { host, sent } = makeHost(sessionId);
109
+
110
+ scheduleQueueDelivery(host);
111
+ jest.advanceTimersByTime(600);
112
+ await flush();
113
+
114
+ expect(sent).toEqual([
115
+ { type: 'set_model', provider: 'kenari', modelId: 'deepseek-v4-pro' },
116
+ { type: 'prompt', message: 'no level', accessMode: 'yolo' },
117
+ ]);
118
+ });
119
+
120
+ test('an item with no model snapshot is sent without touching the model', async () => {
121
+ queue.set(sessionId, [queueItem('session model', null)]);
122
+ const { host, sent } = makeHost(sessionId);
123
+
124
+ scheduleQueueDelivery(host);
125
+ jest.advanceTimersByTime(600);
126
+ await flush();
127
+
128
+ expect(sent).toEqual([{ type: 'prompt', message: 'session model' }]);
129
+ });
130
+
131
+ test('a busy session holds the item instead of sending it', async () => {
132
+ queue.set(sessionId, [queueItem('held')]);
133
+ const { host, sent } = makeHost(sessionId);
134
+ host.isBusy = () => true;
135
+
136
+ scheduleQueueDelivery(host);
137
+ jest.advanceTimersByTime(600);
138
+ await flush();
139
+
140
+ expect(sent).toEqual([]);
141
+ expect(queue.get(sessionId)?.length).toBe(1);
142
+ });
143
+
144
+ test('a failed dispatch returns the item to the head', async () => {
145
+ queue.set(sessionId, [queueItem('retry me')]);
146
+ const { host } = makeHost(sessionId);
147
+ host.send = async () => {
148
+ throw new Error('rpc down');
149
+ };
150
+
151
+ scheduleQueueDelivery(host);
152
+ jest.advanceTimersByTime(600);
153
+ await flush();
154
+
155
+ expect(queue.get(sessionId)?.map((i) => i.text)).toEqual(['retry me']);
156
+ });
157
+
158
+ test('repeated triggers deliver the head exactly once', async () => {
159
+ queue.set(sessionId, [queueItem('once')]);
160
+ const { host, sent } = makeHost(sessionId);
161
+
162
+ scheduleQueueDelivery(host);
163
+ scheduleQueueDelivery(host);
164
+ scheduleQueueDelivery(host);
165
+ jest.advanceTimersByTime(600);
166
+ await flush();
167
+
168
+ expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
169
+ expect(queue.get(sessionId)).toEqual([]);
170
+ });
171
+
172
+ // Attachments reach the server as the persisted display fields only: the
173
+ // composer's `File` never survives the queue row's JSON round trip, and its
174
+ // image preview is a `blob:` object URL the server cannot read. These pin the
175
+ // fields the delivery must use instead — `dataBase64` for the bytes, `type`
176
+ // for the media type, `content` for an inline-able text file.
177
+ test('a queued image rides its persisted base64 payload', async () => {
178
+ const item = queueItem('with image', null);
179
+ item.attachments = [
180
+ { id: 'i', name: 'shot.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: 'iVBORw0KGgo=' },
181
+ ] as QueuedMessage['attachments'];
182
+ queue.set(sessionId, [item]);
183
+ const { host, sent } = makeHost(sessionId);
184
+
185
+ scheduleQueueDelivery(host);
186
+ jest.advanceTimersByTime(600);
187
+ await flush();
188
+
189
+ expect(sent).toEqual([
190
+ {
191
+ type: 'prompt',
192
+ message: 'with image',
193
+ images: [{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }],
194
+ },
195
+ ]);
196
+ });
197
+
198
+ test('a queued text file is inlined into the delivered prompt', async () => {
199
+ const item = queueItem('review this', null);
200
+ item.attachments = [
201
+ { id: 't', name: 'notes.md', type: 'text/markdown', size: 5, preview: '', content: '# hi' },
202
+ ] as QueuedMessage['attachments'];
203
+ queue.set(sessionId, [item]);
204
+ const { host, sent } = makeHost(sessionId);
205
+
206
+ scheduleQueueDelivery(host);
207
+ jest.advanceTimersByTime(600);
208
+ await flush();
209
+
210
+ const prompt = sent.find((c) => c.type === 'prompt');
211
+ expect(prompt?.message).toBe('review this\n\nAttached file: notes.md\n```markdown\n# hi\n```');
212
+ });
213
+
214
+ test('an attachment without a payload is not sent as a blank image', async () => {
215
+ const item = queueItem('no payload', null);
216
+ item.attachments = [
217
+ { id: 'i', name: 'broken.png', type: 'image/png', size: 4, preview: 'blob:http://x/1', dataBase64: '' },
218
+ ] as QueuedMessage['attachments'];
219
+ queue.set(sessionId, [item]);
220
+ const { host, sent } = makeHost(sessionId);
221
+
222
+ scheduleQueueDelivery(host);
223
+ jest.advanceTimersByTime(600);
224
+ await flush();
225
+
226
+ expect(sent).toEqual([{ type: 'prompt', message: 'no payload' }]);
227
+ });
228
+
229
+ test('a lost timer delays the queue instead of wedging it', async () => {
230
+ queue.set(sessionId, [queueItem('recovered')]);
231
+ const { host, sent } = makeHost(sessionId);
232
+
233
+ // A delivery was scheduled but its callback never ran — the entry is left
234
+ // behind, which is exactly what a hot-reloaded module or a wedged event
235
+ // loop leaves. `clearAllTimers` drops the pending callback WITHOUT running
236
+ // it, so the bookkeeping outlives the timer.
237
+ scheduleQueueDelivery(host);
238
+ jest.clearAllTimers();
239
+ jest.advanceTimersByTime(600);
240
+ await flush();
241
+ expect(sent).toEqual([]);
242
+
243
+ // Inside the staleness bound a new trigger still defers to the pending
244
+ // entry: the head must not be double-booked.
245
+ jest.advanceTimersByTime(1000);
246
+ scheduleQueueDelivery(host);
247
+ jest.advanceTimersByTime(600);
248
+ await flush();
249
+ expect(sent).toEqual([]);
250
+
251
+ // Past it, a later trigger recovers the queue rather than giving up.
252
+ jest.advanceTimersByTime(5000);
253
+ scheduleQueueDelivery(host);
254
+ jest.advanceTimersByTime(600);
255
+ await flush();
256
+
257
+ expect(sent.filter((c) => c.type === 'prompt').length).toBe(1);
258
+ expect(queue.get(sessionId)).toEqual([]);
259
+ });
260
+ });
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { claimHeadQueueItem, requeueHeadQueueItem } from '@/server/lib/queue/store.server';
24
+ import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
24
25
  import type { QueuedMessage } from '@/shared/types/chat';
25
26
 
26
27
  /** Settling time between agent_end and the next prompt dispatch. Small on
@@ -28,16 +29,36 @@ import type { QueuedMessage } from '@/shared/types/chat';
28
29
  * the same child, and an instant dispatch races it. */
29
30
  const DELIVERY_DELAY_MS = 500;
30
31
 
32
+ /**
33
+ * How long a scheduled-but-not-yet-run delivery may sit before the next
34
+ * trigger supersedes it.
35
+ *
36
+ * Without this, one lost timer wedges the session's queue FOREVER: the guard
37
+ * below early-returns on any existing entry, so nothing would ever schedule
38
+ * again. A timer can be lost without the process dying — `bun --hot`
39
+ * re-evaluating this module discards the pending closure, and a wedged event
40
+ * loop drops the callback — and the symptom is silent: the queue simply never
41
+ * drains and no error is reported. Past this bound the entry is presumed lost
42
+ * and rescheduled, so a client nudge (sent whenever a session with queued
43
+ * items mounts or regains focus) always recovers the queue.
44
+ */
45
+ const DELIVERY_STALE_MS = 5_000;
46
+
47
+ interface PendingDelivery {
48
+ timer: ReturnType<typeof setTimeout>;
49
+ /** Wall-clock after which this entry is presumed lost and superseded. */
50
+ expiresAt: number;
51
+ }
52
+
31
53
  /** Per-session pending delivery timer, so a second agent_end while one is
32
54
  * already scheduled cannot double-book the head. */
33
- const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
55
+ const pendingTimers = new Map<string, PendingDelivery>();
34
56
 
35
57
  function clearPendingTimer(sessionId: string): void {
36
- const timer = pendingTimers.get(sessionId);
37
- if (timer !== undefined) {
38
- clearTimeout(timer);
39
- pendingTimers.delete(sessionId);
40
- }
58
+ const entry = pendingTimers.get(sessionId);
59
+ if (entry === undefined) return;
60
+ clearTimeout(entry.timer);
61
+ pendingTimers.delete(sessionId);
41
62
  }
42
63
 
43
64
  /** Send one queued item to the session as a normal prompt. The model snapshot
@@ -51,12 +72,33 @@ async function dispatchQueuedItem(session: QueueDeliveryHost, item: QueuedMessag
51
72
  await session.send({ type: 'set_thinking_level', level: item.model.thinkingLevel });
52
73
  }
53
74
  }
75
+ // Attachments reach this point as the persisted display fields — the live
76
+ // `File` never survived the queue's JSON round trip. The base64 payload and
77
+ // the MIME type are therefore read from the fields that DID persist:
78
+ // `dataBase64` for the bytes, `type` for the media type. (Matching on a
79
+ // `data:` preview used to yield zero images, because the composer's preview
80
+ // is a `blob:` object URL that the server cannot read.)
54
81
  const images = item.attachments
55
- .filter((a) => typeof a?.preview === 'string' && a.preview.startsWith('data:image/') && typeof a?.dataBase64 === 'string')
56
- .map((a) => ({ type: 'image' as const, data: a.dataBase64 as string, mimeType: a.preview.slice(5).split(';')[0] || 'image/png' }));
82
+ .filter((a) => typeof a?.dataBase64 === 'string' && a.dataBase64.length > 0)
83
+ .map((a) => ({
84
+ type: 'image' as const,
85
+ data: a.dataBase64 as string,
86
+ mimeType: typeof a.type === 'string' && a.type.startsWith('image/') ? a.type : 'image/png',
87
+ }));
88
+ // Text attachments are inlined into the prompt, exactly as the composer
89
+ // does at send time — the item's own `text` is the raw composer input, so
90
+ // without this a queued file would deliver as a bare filename mention.
91
+ const textFiles = item.attachments
92
+ .filter((a) => typeof a?.content === 'string' && a.content.length > 0)
93
+ .map((a) => ({
94
+ name: typeof a.name === 'string' && a.name ? a.name : 'attachment',
95
+ mimeType: typeof a.type === 'string' ? a.type : 'text/plain',
96
+ content: a.content as string,
97
+ size: typeof a.size === 'number' ? a.size : (a.content as string).length,
98
+ }));
57
99
  await session.send({
58
100
  type: 'prompt',
59
- message: item.text,
101
+ message: composeMessageWithTextAttachments(item.text, textFiles),
60
102
  ...(images.length ? { images } : {}),
61
103
  ...(item.model ? { accessMode: item.model.accessMode } : {}),
62
104
  });
@@ -79,15 +121,25 @@ export interface QueueDeliveryHost {
79
121
  * Schedule the next queued delivery for this session. Called on terminal
80
122
  * agent_end and on a client's mount nudge. Idempotent: an in-flight timer is
81
123
  * reused, a claimed head can only be won by one caller.
124
+ *
125
+ * A pending entry older than {@link DELIVERY_STALE_MS} is presumed lost (see
126
+ * that constant) and replaced, so a dropped timer delays the queue instead of
127
+ * wedging it.
82
128
  */
83
129
  export function scheduleQueueDelivery(session: QueueDeliveryHost): void {
84
130
  const sessionId = session.sessionId;
85
- if (!sessionId || pendingTimers.has(sessionId)) return;
131
+ if (!sessionId) return;
132
+ const pending = pendingTimers.get(sessionId);
133
+ if (pending !== undefined) {
134
+ if (Date.now() < pending.expiresAt) return;
135
+ // Lost timer: clear it so its callback cannot also fire and double-deliver.
136
+ clearTimeout(pending.timer);
137
+ }
86
138
  const timer = setTimeout(() => {
87
139
  pendingTimers.delete(sessionId);
88
140
  void deliverNext(session);
89
141
  }, DELIVERY_DELAY_MS);
90
- pendingTimers.set(sessionId, timer);
142
+ pendingTimers.set(sessionId, { timer, expiresAt: Date.now() + DELIVERY_STALE_MS });
91
143
  }
92
144
 
93
145
  async function deliverNext(session: QueueDeliveryHost): Promise<void> {
@@ -36,7 +36,7 @@ function missingBuildResponse(): Response {
36
36
  <p>For a rebuild-on-change loop while developing:</p>
37
37
  <pre style="background:#f4f1ea;padding:1rem;border-radius:6px">bunx rsbuild build --watch</pre>
38
38
  </body>`,
39
- { status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } },
39
+ { status: 503, headers: HTML_HEADERS },
40
40
  );
41
41
  }
42
42
 
@@ -64,6 +64,33 @@ function themeColor(theme: string): string {
64
64
 
65
65
  const MOBILE_UA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
66
66
 
67
+ /**
68
+ * The HTML shell is generated per request, so its cacheability is declared
69
+ * rather than left to the browser's heuristics.
70
+ *
71
+ * It is not a static file: the theme and the bootstrap settings are injected on
72
+ * every request, and its only link to the bundle is a content hash inside the
73
+ * markup. A copy that outlives its build does not merely go stale — it points at
74
+ * an asset hash the build no longer emits, leaving the page running previous
75
+ * code with nothing in the response to signal it.
76
+ *
77
+ * Chrome does not currently cache it: the response carries no `Cache-Control`,
78
+ * `Last-Modified` or `ETag`, so there is no freshness to heuristically derive
79
+ * and every navigation re-fetches (measured: a second load of the shell reported
80
+ * the full 41 KB `transferSize`). That is the behaviour this header makes
81
+ * explicit — the day a validator is added for the template, heuristic caching
82
+ * would start and nothing else here would prevent it.
83
+ *
84
+ * `no-store` rather than `no-cache`: with no validator there is nothing to
85
+ * revalidate against, so a stored copy is never usable.
86
+ */
87
+ const SHELL_CACHE = 'no-store';
88
+
89
+ const HTML_HEADERS = {
90
+ 'content-type': 'text/html; charset=utf-8',
91
+ 'cache-control': SHELL_CACHE,
92
+ } as const;
93
+
67
94
  export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }) => {
68
95
  const pathname = new URL(request.url).pathname;
69
96
 
@@ -95,6 +122,6 @@ export const ssrRoutes = new Elysia({ name: 'ssr' }).get('*', async ({ request }
95
122
  .replace('data-theme="paper"', `data-theme="${theme}"`)
96
123
  .replace('<!--app-head-->', `<meta name="theme-color" content="${themeColor(theme)}">`)
97
124
  .replace('<!--app-bootstrap-->', `<script>window.__OMP_BOOTSTRAP__=${bootstrap}</script>`),
98
- { headers: { 'content-type': 'text/html; charset=utf-8' } },
125
+ { headers: HTML_HEADERS },
99
126
  );
100
127
  });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `GET /api/btw/:sessionId/events` — SSE stream of a session's BTW frames.
8
+ *
9
+ * Unlike the agent stream this one is not observer-only: a side question's
10
+ * history must be readable for a session whose omp child is not running (that
11
+ * is the normal state of a session opened from the sidebar), so the route
12
+ * subscribes to the registry and never spawns anything.
13
+ */
14
+
15
+ import { createEventCoalescer, createSseStream, type EventCoalescer } from '@/server/lib/sse';
16
+ import { btwStateFor, subscribeBtw } from '@/server/lib/btw/registry.server';
17
+ import type { BtwFrame } from '@/shared/types';
18
+
19
+ /** Longest a coalesced frame may wait before it is flushed anyway. */
20
+ const FLUSH_DELAY_MS = 50;
21
+
22
+ export async function loader({ params, request }: { params: Record<string, string>; request: Request }) {
23
+ const { sessionId } = params;
24
+ if (!sessionId) return new Response('session id is required', { status: 400 });
25
+
26
+ let coalescer: EventCoalescer | null = null;
27
+
28
+ const stream = createSseStream({
29
+ heartbeatMs: 30_000,
30
+ signal: request.signal,
31
+ beforeHeartbeat: () => coalescer?.flush() ?? true,
32
+ onStart(handlers) {
33
+ let unsubscribe: (() => void) | null = null;
34
+ coalescer = createEventCoalescer({
35
+ send: (event, data) => handlers.send(event, data),
36
+ isBackpressured: handlers.isBackpressured,
37
+ flushDelayMs: FLUSH_DELAY_MS,
38
+ });
39
+ const push = (frame: BtwFrame) => coalescer?.push('', frame);
40
+
41
+ handlers.send('', { type: 'connected', sessionId });
42
+ if (handlers.isClosed()) return;
43
+ unsubscribe = subscribeBtw(sessionId, push);
44
+ void btwStateFor(sessionId)
45
+ .then((state) => push({ type: 'btw_state', state }))
46
+ .catch((error: unknown) => {
47
+ push({
48
+ type: 'btw_error',
49
+ topicId: null,
50
+ message: error instanceof Error ? error.message : String(error),
51
+ });
52
+ });
53
+
54
+ return () => {
55
+ coalescer?.dispose();
56
+ coalescer = null;
57
+ if (unsubscribe) {
58
+ try {
59
+ unsubscribe();
60
+ } catch {
61
+ // Subscriber cleanup is best-effort.
62
+ }
63
+ unsubscribe = null;
64
+ }
65
+ };
66
+ },
67
+ });
68
+
69
+ return stream.response;
70
+ }
@@ -0,0 +1,8 @@
1
+ import { actionBindings, bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
2
+ import { action, loader } from '@/server/routes/btw/topics';
3
+ import * as btwEvents from '@/server/routes/btw/events';
4
+
5
+ export const btwBindings: HandlerBinding[] = [
6
+ ...actionBindings(action, '/api/btw/:sessionId', loader),
7
+ ...bindingsFor(btwEvents, '/api/btw/:sessionId/events'),
8
+ ];
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * BTW commands: `GET /api/btw/:sessionId` returns a session's side questions,
8
+ * `POST` runs one action against them.
9
+ *
10
+ * Mutating verbs stay in one handler with its own dispatch (the ported Remix
11
+ * shape the rest of the API uses), so an unsupported action gets a named error
12
+ * instead of a silent success.
13
+ */
14
+
15
+ import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
16
+ import { toImageContents, validateAgentImages } from '@/server/lib/omp/rpc/constants';
17
+ import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
18
+ import { abortBtw, askBtw, getBtwState, promoteBtw, removeBtw } from '@/server/lib/btw/service.server';
19
+
20
+ /** Actions that can reject for a reason the client should show as-is. */
21
+ const STATUS_BY_CODE: Record<string, number> = {
22
+ btw_busy: 409,
23
+ btw_stale: 409,
24
+ session_not_found: 404,
25
+ btw_topic_not_found: 404,
26
+ };
27
+
28
+ function btwErrorResponse(error: unknown): Response {
29
+ if (error instanceof BtwError) {
30
+ return json({ error: error.message, code: error.code }, { status: STATUS_BY_CODE[error.code] ?? 400 });
31
+ }
32
+ return json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
33
+ }
34
+
35
+ function readImages(raw: unknown): { images?: BtwImages } | { error: string } {
36
+ const problem = validateAgentImages(raw);
37
+ if (problem) return { error: problem };
38
+ const images = toImageContents(raw);
39
+ return images ? { images } : {};
40
+ }
41
+
42
+ export async function action({ params, request }: ActionFunctionArgs) {
43
+ const { sessionId } = params;
44
+ if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
45
+
46
+ const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
47
+ const action = typeof body?.action === 'string' ? body.action : '';
48
+ const topicId = typeof body?.topicId === 'string' ? body.topicId : undefined;
49
+ if (!action) return json({ error: 'action is required', code: 'action_required' }, { status: 400 });
50
+
51
+ try {
52
+ switch (action) {
53
+ case 'ask': {
54
+ const question = typeof body?.question === 'string' ? body.question : '';
55
+ const parsed = readImages(body?.images);
56
+ if ('error' in parsed) return json({ error: parsed.error, code: 'invalid_images' }, { status: 400 });
57
+ return json({ success: true, data: await askBtw(sessionId, { topicId, question, images: parsed.images }) });
58
+ }
59
+ case 'abort': {
60
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
61
+ return json({ success: true, data: await abortBtw(sessionId, topicId) });
62
+ }
63
+ case 'promote': {
64
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
65
+ return json({ success: true, data: await promoteBtw(sessionId, topicId) });
66
+ }
67
+ case 'delete': {
68
+ if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
69
+ return json({ success: true, data: await removeBtw(sessionId, topicId) });
70
+ }
71
+ default:
72
+ return json({ error: `Unknown btw action: ${action}`, code: 'unknown_action' }, { status: 400 });
73
+ }
74
+ } catch (error) {
75
+ return btwErrorResponse(error);
76
+ }
77
+ }
78
+
79
+ export async function loader({ params }: LoaderFunctionArgs) {
80
+ const { sessionId } = params;
81
+ if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
82
+ try {
83
+ return json({ success: true, data: await getBtwState(sessionId) });
84
+ } catch (error) {
85
+ return btwErrorResponse(error);
86
+ }
87
+ }