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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
- package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +35 -9
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +40 -4
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +29 -9
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +45 -70
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +260 -0
- package/src/server/lib/queue/delivery.server.ts +63 -11
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +23 -9
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +195 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +154 -21
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +77 -66
- package/src/shared/types/index.ts +2 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /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
|
|
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
|
|
16
|
-
* directory changes, so that (
|
|
17
|
-
* sufficient cache version. Both cost one stat —
|
|
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
|
-
|
|
48
|
+
sessionLastEntryAt: string,
|
|
46
49
|
): Promise<boolean> {
|
|
47
50
|
const siblingDir = siblingDirForSession(sessionPath);
|
|
48
|
-
const version = `${
|
|
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,
|
|
55
|
+
const pendingTimers = new Map<string, PendingDelivery>();
|
|
34
56
|
|
|
35
57
|
function clearPendingTimer(sessionId: string): void {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
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?.
|
|
56
|
-
.map((a) => ({
|
|
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
|
|
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:
|
|
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:
|
|
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
|
+
}
|