ompchamber 1.1.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.a5ccbc19dc.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/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- 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 +37 -21
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- 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 +44 -40
- package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
- 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/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 +7 -1
- 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 +16 -1
- package/src/client/hooks/chat/timeline/index.ts +16 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +29 -65
- 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/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/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- 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 +57 -0
- package/src/server/lib/queue/delivery.server.ts +25 -3
- 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/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 +6 -1
- package/src/shared/lib/chat/composer/composer.test.ts +39 -1
- package/src/shared/lib/chat/composer/trigger.ts +45 -1
- 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/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/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +39 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.b859cc1cc5.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.6e065017eb.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.a5ccbc19dc.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.6e065017eb.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
|
@@ -17,13 +17,12 @@
|
|
|
17
17
|
|
|
18
18
|
import { RpcProcess, type RpcFrame } from '@/server/lib/omp/rpc/process';
|
|
19
19
|
import { PendingUiDialogs } from '@/server/lib/omp/rpc/pending-ui-dialogs';
|
|
20
|
-
import {
|
|
20
|
+
import { foldSessionFrame } from '@/server/lib/omp/rpc/frame-fold';
|
|
21
21
|
import { notifyRunningChange } from '@/server/lib/omp/rpc/session-registry';
|
|
22
22
|
import { dispatchSessionCommand } from '@/server/lib/omp/rpc/session-commands';
|
|
23
|
-
import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
|
|
24
23
|
import { SubagentLiveness } from '@/server/lib/omp/rpc/subagent-liveness';
|
|
25
|
-
import {
|
|
26
|
-
import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS,
|
|
24
|
+
import { markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
|
|
25
|
+
import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, READY_TIMEOUT_MS, SUBAGENT_STALE_MS, type AgentEvent, type EventListener, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
|
|
27
26
|
|
|
28
27
|
export type {
|
|
29
28
|
AgentEvent,
|
|
@@ -68,6 +67,13 @@ export class AgentSessionWrapper {
|
|
|
68
67
|
// set in initialize(). No other flag on this wrapper can see them, and an
|
|
69
68
|
// idle reclaim or a spawn-mode reconcile must never kill a running subagent.
|
|
70
69
|
private readonly subagents = new SubagentLiveness();
|
|
70
|
+
// One background `/rename` at a time. A settled run may be followed by
|
|
71
|
+
// another (queue delivery, a second prompt), and two overlapping generations
|
|
72
|
+
// would race to write the same title slot.
|
|
73
|
+
autoTitleInFlight = false;
|
|
74
|
+
// Epoch ms until which `command_output` frames belong to our own background
|
|
75
|
+
// rename and must not reach the timeline. 0 when nothing is outstanding.
|
|
76
|
+
autoTitleWindowUntil = 0;
|
|
71
77
|
readonly idleDestroyMs: number;
|
|
72
78
|
proc: RpcProcess;
|
|
73
79
|
readonly cwd: string;
|
|
@@ -173,130 +179,28 @@ export class AgentSessionWrapper {
|
|
|
173
179
|
this.destroy();
|
|
174
180
|
}
|
|
175
181
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
|
|
185
|
-
return (entry as Record<string, unknown>).stopReason === 'aborted';
|
|
186
|
-
});
|
|
187
|
-
const current = await loadStreamStatuses();
|
|
188
|
-
if (current[this.sessionId] !== 'stream') return;
|
|
189
|
-
await markStreamStatus(this.sessionId, aborted ? 'abort' : 'finish');
|
|
182
|
+
/** SessionFrameHost adapter: remember a blocking dialog for reattaching clients. */
|
|
183
|
+
trackUiDialog(frame: AgentEvent): void {
|
|
184
|
+
this.pendingUiDialogs.track(frame);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** SessionFrameHost adapter: fold a subagent frame into the liveness roster. */
|
|
188
|
+
observeSubagent(frame: AgentEvent, now: number): void {
|
|
189
|
+
this.subagents.observe(frame, now);
|
|
190
190
|
}
|
|
191
191
|
|
|
192
192
|
private handleFrame(frame: RpcFrame): void {
|
|
193
193
|
this.resetIdleTimer();
|
|
194
194
|
const event = frame as AgentEvent;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
clearSessionFileCaches();
|
|
205
|
-
refreshSessionList = true;
|
|
206
|
-
if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
|
|
207
|
-
break;
|
|
208
|
-
case 'turn_start':
|
|
209
|
-
// Redundant with agent_start in the happy path, but the run-level
|
|
210
|
-
// counterpart can be missed (e.g. the state was cleared by a command
|
|
211
|
-
// between frames). The turn is proof enough the session is live.
|
|
212
|
-
if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
|
|
213
|
-
break;
|
|
214
|
-
case 'message_start':
|
|
215
|
-
// Same recovery as turn_start: a message frame is only emitted inside
|
|
216
|
-
// a live run, so its arrival re-arms the `stream` row no matter what
|
|
217
|
-
// stale status sits there (upsert overwrites any terminal badge).
|
|
218
|
-
if (this.sessionId) void markStreamStatus(this.sessionId, 'stream');
|
|
219
|
-
break;
|
|
220
|
-
case 'turn_end': {
|
|
221
|
-
// A multi-turn run emits turn_end for EVERY turn — intermediates end
|
|
222
|
-
// with `toolUse`/`stop` while the run keeps going (verified against
|
|
223
|
-
// omp 18.2.8: agent_start → turn_start → turn_end('toolUse') →
|
|
224
|
-
// turn_start → turn_end('stop') → agent_end, and the frame carries no
|
|
225
|
-
// isTerminal field). Only `aborted` is unambiguous here: the run is
|
|
226
|
-
// over, so the abort badge can be written before agent_end arrives.
|
|
227
|
-
// Everything else waits for agent_end, which knows isTerminal.
|
|
228
|
-
if (!this.sessionId) break;
|
|
229
|
-
const turn = event.message as Record<string, unknown> | undefined;
|
|
230
|
-
if (turn?.stopReason === 'aborted') void markStreamStatus(this.sessionId, 'abort');
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
case 'agent_end':
|
|
234
|
-
if (event.isTerminal !== false) {
|
|
235
|
-
this.streaming = false;
|
|
236
|
-
this.promptRunning = false;
|
|
237
|
-
this.awaitingAgentStart = false;
|
|
238
|
-
this.awaitingAgentStartDeadline = 0;
|
|
239
|
-
this.continuationGraceUntil = 0;
|
|
240
|
-
clearSessionFileCaches();
|
|
241
|
-
// The turn's own stopReason is the ground truth for how it ended:
|
|
242
|
-
// `aborted` = user stop, anything else (`stop`, `toolUse`, and the
|
|
243
|
-
// ambiguous `error` that tool failures also produce) completes as
|
|
244
|
-
// `finish`. This must not overwrite a fresh `abort` row from a NEWER
|
|
245
|
-
// run — guard against downgrades by checking the current row first.
|
|
246
|
-
const aborted = Array.isArray(event.messages) && event.messages.some((entry) => {
|
|
247
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
|
|
248
|
-
return (entry as Record<string, unknown>).stopReason === 'aborted';
|
|
249
|
-
});
|
|
250
|
-
if (this.sessionId) void this.markEndStatus(event.messages);
|
|
251
|
-
// The run truly ended — the server, not the browser, decides whether
|
|
252
|
-
// a queued follow-up goes out next. A user-aborted run holds the
|
|
253
|
-
// queue (stop-all semantics): the next run end or an explicit send
|
|
254
|
-
// picks it up.
|
|
255
|
-
if (!aborted) scheduleQueueDelivery(this);
|
|
256
|
-
} else {
|
|
257
|
-
this.continuationGraceUntil = Date.now() + NON_TERMINAL_CONTINUATION_GRACE_MS;
|
|
258
|
-
}
|
|
259
|
-
break;
|
|
260
|
-
case 'prompt_result':
|
|
261
|
-
this.promptRunning = false;
|
|
262
|
-
this.awaitingAgentStart = false;
|
|
263
|
-
this.awaitingAgentStartDeadline = 0;
|
|
264
|
-
break;
|
|
265
|
-
case 'auto_compaction_start':
|
|
266
|
-
this.compacting = true;
|
|
267
|
-
break;
|
|
268
|
-
case 'auto_compaction_end':
|
|
269
|
-
this.compacting = false;
|
|
270
|
-
clearSessionFileCaches();
|
|
271
|
-
break;
|
|
272
|
-
case 'session_info_update':
|
|
273
|
-
clearSessionFileCaches();
|
|
274
|
-
refreshSessionList = true;
|
|
275
|
-
break;
|
|
276
|
-
case 'extension_ui_request':
|
|
277
|
-
this.pendingUiDialogs.track(event);
|
|
278
|
-
break;
|
|
279
|
-
// Subagent frames carry no turn state, but they are the only proof that
|
|
280
|
-
// work is still running once the parent turn has ended.
|
|
281
|
-
case 'subagent_lifecycle':
|
|
282
|
-
case 'subagent_progress':
|
|
283
|
-
case 'subagent_event':
|
|
284
|
-
this.subagents.observe(event, Date.now());
|
|
285
|
-
break;
|
|
286
|
-
case 'response': {
|
|
287
|
-
if (event.success === false && event.command === 'prompt') {
|
|
288
|
-
this.promptRunning = false;
|
|
289
|
-
this.awaitingAgentStart = false;
|
|
290
|
-
this.awaitingAgentStartDeadline = 0;
|
|
291
|
-
this.emit({ type: 'prompt_error', errorMessage: (event.error as string) ?? 'Prompt failed' });
|
|
292
|
-
notifyRunningChange();
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
break;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
this.emit(event);
|
|
195
|
+
// The state machine and its settle-time side effects live in frame-fold.ts;
|
|
196
|
+
// this method owns only the wrapper's own bookkeeping around it.
|
|
197
|
+
const { refreshSessionList, suppressForward } = foldSessionFrame(this, event);
|
|
198
|
+
// `suppressForward` withholds only the FRAME — a failed prompt response
|
|
199
|
+
// already emitted its own `prompt_error`, and the chamber's own background
|
|
200
|
+
// rename must not surface its diagnostics. The running-state notification
|
|
201
|
+
// still fires: the fold just cleared `promptRunning`, and that flip is what
|
|
202
|
+
// releases the sidebar's spinner.
|
|
203
|
+
if (!suppressForward) this.emit(event);
|
|
300
204
|
notifyRunningChange({ refreshSessionList });
|
|
301
205
|
}
|
|
302
206
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
consumeAutoTitleOutput,
|
|
10
|
+
markTitleRequestSent,
|
|
11
|
+
triggerAutoSessionTitle,
|
|
12
|
+
type AutoTitleHost,
|
|
13
|
+
} from '@/server/lib/omp/session/auto-title.server';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The safety property of auto-titling, and the one thing that must never
|
|
17
|
+
* regress: the chamber fires omp's own `/rename` (which persists through
|
|
18
|
+
* `setSessionName(title, "user")`) in the background, and that write has no
|
|
19
|
+
* "don't overwrite" guard of its own — omp only refuses an `"auto"` write over
|
|
20
|
+
* a `"user"` title. So the decision to skip a NAMED session is ours, and a bug
|
|
21
|
+
* here silently destroys a name the operator typed.
|
|
22
|
+
*
|
|
23
|
+
* These tests drive the guard directly: a fake `get_state` reports a name, and
|
|
24
|
+
* the assertion is that no `/rename` reaches the wire.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface FakeProc {
|
|
28
|
+
commands: Array<Record<string, unknown>>;
|
|
29
|
+
sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeHost(options: {
|
|
33
|
+
sessionName?: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
inFlight?: boolean;
|
|
36
|
+
}): { host: AutoTitleHost; proc: FakeProc } {
|
|
37
|
+
const proc: FakeProc = {
|
|
38
|
+
commands: [],
|
|
39
|
+
async sendCommand<T>(command: { type: string; [key: string]: unknown }): Promise<T> {
|
|
40
|
+
proc.commands.push(command);
|
|
41
|
+
if (command.type === 'get_state') {
|
|
42
|
+
return {
|
|
43
|
+
sessionId: options.sessionId ?? 'sess-1',
|
|
44
|
+
sessionName: options.sessionName,
|
|
45
|
+
} as T;
|
|
46
|
+
}
|
|
47
|
+
return undefined as T;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
host: {
|
|
52
|
+
sessionId: 'sess-1',
|
|
53
|
+
autoTitleInFlight: options.inFlight ?? false,
|
|
54
|
+
autoTitleWindowUntil: 0,
|
|
55
|
+
proc: proc as unknown as AutoTitleHost['proc'],
|
|
56
|
+
},
|
|
57
|
+
proc,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Commands other than the `get_state` probe the guard always performs. */
|
|
62
|
+
function sentRenames(proc: FakeProc): Array<Record<string, unknown>> {
|
|
63
|
+
return proc.commands.filter((command) => command.type === 'prompt');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('triggerAutoSessionTitle', () => {
|
|
67
|
+
test('names an unnamed session through omp own /rename', async () => {
|
|
68
|
+
const { host, proc } = makeHost({});
|
|
69
|
+
await triggerAutoSessionTitle(host);
|
|
70
|
+
|
|
71
|
+
const renames = sentRenames(proc);
|
|
72
|
+
expect(renames).toHaveLength(1);
|
|
73
|
+
expect(renames[0].message).toBe('/rename');
|
|
74
|
+
// The request window opens only after the command is accepted, so the
|
|
75
|
+
// diagnostics omp is about to emit can be attributed to us.
|
|
76
|
+
expect(host.autoTitleWindowUntil).toBeGreaterThan(Date.now());
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('never overwrites a name the operator set', async () => {
|
|
80
|
+
const { host, proc } = makeHost({ sessionName: 'My Deliberate Name' });
|
|
81
|
+
await triggerAutoSessionTitle(host);
|
|
82
|
+
|
|
83
|
+
expect(sentRenames(proc)).toHaveLength(0);
|
|
84
|
+
// No request was sent, so nothing may claim the output window either.
|
|
85
|
+
expect(host.autoTitleWindowUntil).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('treats a whitespace-only name as unnamed', async () => {
|
|
89
|
+
const { host, proc } = makeHost({ sessionName: ' ' });
|
|
90
|
+
await triggerAutoSessionTitle(host);
|
|
91
|
+
|
|
92
|
+
expect(sentRenames(proc)).toHaveLength(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('skips when the session changed under the read', async () => {
|
|
96
|
+
// `get_state` answered for a different conversation (a reset or switch
|
|
97
|
+
// between the trigger and the read) — titling this session would name the
|
|
98
|
+
// wrong one.
|
|
99
|
+
const { host, proc } = makeHost({ sessionId: 'someone-else' });
|
|
100
|
+
await triggerAutoSessionTitle(host);
|
|
101
|
+
|
|
102
|
+
expect(sentRenames(proc)).toHaveLength(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('does not stack a second request while one is in flight', async () => {
|
|
106
|
+
const { host, proc } = makeHost({ inFlight: true });
|
|
107
|
+
await triggerAutoSessionTitle(host);
|
|
108
|
+
|
|
109
|
+
expect(sentRenames(proc)).toHaveLength(0);
|
|
110
|
+
expect(proc.commands).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('auto-title output window', () => {
|
|
115
|
+
test('claims exactly one frame per request', () => {
|
|
116
|
+
const { host } = makeHost({});
|
|
117
|
+
markTitleRequestSent(host);
|
|
118
|
+
|
|
119
|
+
expect(consumeAutoTitleOutput(host)).toBe(true);
|
|
120
|
+
// omp emits a single command_output per /rename; a second frame inside the
|
|
121
|
+
// window belongs to something the operator typed and must reach the UI.
|
|
122
|
+
expect(consumeAutoTitleOutput(host)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('claims nothing when no request is outstanding', () => {
|
|
126
|
+
const { host } = makeHost({});
|
|
127
|
+
expect(consumeAutoTitleOutput(host)).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('expires so a stale window cannot swallow an operator command', () => {
|
|
131
|
+
const { host } = makeHost({});
|
|
132
|
+
markTitleRequestSent(host);
|
|
133
|
+
host.autoTitleWindowUntil = Date.now() - 1;
|
|
134
|
+
|
|
135
|
+
expect(consumeAutoTitleOutput(host)).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Auto session titles for OMPChamber.
|
|
8
|
+
*
|
|
9
|
+
* Chamber spawns omp with `--mode rpc-ui`, and that mode force-sets
|
|
10
|
+
* `PI_NO_TITLE=1` (omp's main.ts). The flag gates only the two AUTOMATIC title
|
|
11
|
+
* paths — `AgentSession.maybeStartTitleGeneration` (first user message) and
|
|
12
|
+
* `#scheduleReplanTitleRefresh` (todo replan) — so a chamber session is never
|
|
13
|
+
* named on its own and the sidebar keeps the `New Session - <timestamp>`
|
|
14
|
+
* placeholder for its whole life.
|
|
15
|
+
*
|
|
16
|
+
* The generate path itself is NOT gated: `/rename` with no args reaches
|
|
17
|
+
* `session.generateTitle()` through `executeAcpBuiltinSlashCommand`, which
|
|
18
|
+
* rpc-mode dispatches for a `prompt` frame before any turn starts. Verified
|
|
19
|
+
* against omp 18.2.10 — the ack returns `{agentInvoked:false}` immediately, the
|
|
20
|
+
* generation lands ~4s later as `session_info_update` + `command_output`, and
|
|
21
|
+
* no entry is written to the transcript.
|
|
22
|
+
*
|
|
23
|
+
* WHY THE NAME MUST BE EMPTY FIRST: `/rename` persists through
|
|
24
|
+
* `setSessionName(title, "user")`. omp's own guard only refuses an `"auto"`
|
|
25
|
+
* write over a `"user"` title, so a `/rename` fired against an already-named
|
|
26
|
+
* session would overwrite the operator's own name. The unnamed check below is
|
|
27
|
+
* therefore the safety property, not a nicety — it is re-read from `get_state`
|
|
28
|
+
* on every attempt rather than cached, so a rename made in another tab is
|
|
29
|
+
* respected.
|
|
30
|
+
*
|
|
31
|
+
* Firing after every settled run (not just the first) mirrors omp: a session
|
|
32
|
+
* whose opening message was a bare greeting stays unnamed, and the next real
|
|
33
|
+
* message titles it. The low-signal filter on omp's side returns no title for
|
|
34
|
+
* such input, so the retry costs one tiny-model call and nothing else.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { readSettingsJson } from '@/server/lib/db/settings-store';
|
|
38
|
+
import { getDb } from '@/server/db.server';
|
|
39
|
+
import { GET_STATE_TIMEOUT_MS, PROMPT_ACK_TIMEOUT_MS, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
|
|
40
|
+
|
|
41
|
+
/** Chamber settings blob key; mirrors `streamTransport` and friends. */
|
|
42
|
+
const CHAMBER_SETTINGS_KEY = 'omp_chamber_settings';
|
|
43
|
+
|
|
44
|
+
/** Auto-titling is opt-in per chamber install. Default ON: an unnamed session
|
|
45
|
+
* renders as `New Session - <timestamp>` forever, which is the visible defect,
|
|
46
|
+
* and the cost is one tiny-model call per session. */
|
|
47
|
+
const AUTO_SESSION_TITLE_DEFAULT = true;
|
|
48
|
+
|
|
49
|
+
/** omp's builtin that generates a title from the conversation. Sent with no
|
|
50
|
+
* args so omp derives it through its own `generateRenameTitle`. */
|
|
51
|
+
const GENERATE_TITLE_COMMAND = '/rename';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How long the chamber's own `/rename` may keep emitting its diagnostic frames.
|
|
55
|
+
*
|
|
56
|
+
* The command answers on the prompt path with an immediate ack and then streams
|
|
57
|
+
* `session_info_update` + `command_output` once the tiny model returns
|
|
58
|
+
* (measured ~4s). Both carry NO correlation id, so the only way to attribute
|
|
59
|
+
* them to our background request is this window. It is deliberately generous —
|
|
60
|
+
* a slow provider must not leak the message — because the cost of over-holding
|
|
61
|
+
* it is only that a slash command typed in the same few seconds loses its
|
|
62
|
+
* output row.
|
|
63
|
+
*/
|
|
64
|
+
const AUTO_TITLE_OUTPUT_WINDOW_MS = 60_000;
|
|
65
|
+
|
|
66
|
+
/** Wrapper state this module reads and writes. The in-flight flag and the
|
|
67
|
+
* output window are owned by the caller because they are per-session runtime
|
|
68
|
+
* state, not policy. */
|
|
69
|
+
export interface AutoTitleHost {
|
|
70
|
+
sessionId: string;
|
|
71
|
+
autoTitleInFlight: boolean;
|
|
72
|
+
/** Epoch ms until which frames belong to our own background rename; 0 when
|
|
73
|
+
* no request is outstanding. */
|
|
74
|
+
autoTitleWindowUntil: number;
|
|
75
|
+
proc: {
|
|
76
|
+
sendCommand<T = unknown>(command: { type: string; [key: string]: unknown }, timeoutMs?: number): Promise<T>;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Claim the frames our own `/rename` is about to emit, so the wrapper can keep
|
|
82
|
+
* its diagnostics out of the timeline. Called once the request is accepted.
|
|
83
|
+
*/
|
|
84
|
+
export function markTitleRequestSent(host: AutoTitleHost): void {
|
|
85
|
+
host.autoTitleWindowUntil = Date.now() + AUTO_TITLE_OUTPUT_WINDOW_MS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* True when this frame is the tail of our own background rename, consuming the
|
|
90
|
+
* window. One frame per request: omp emits exactly one `command_output` for it,
|
|
91
|
+
* on both the success and the "could not generate" path.
|
|
92
|
+
*/
|
|
93
|
+
export function consumeAutoTitleOutput(host: AutoTitleHost): boolean {
|
|
94
|
+
if (host.autoTitleWindowUntil === 0 || Date.now() > host.autoTitleWindowUntil) return false;
|
|
95
|
+
host.autoTitleWindowUntil = 0;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Read the `autoSessionTitle` flag from the chamber settings blob. Absent or
|
|
101
|
+
* malformed → the default. Never throws: a settings read must not be able to
|
|
102
|
+
* break a turn's settle path.
|
|
103
|
+
*/
|
|
104
|
+
async function isAutoTitleEnabled(): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
const db = await getDb();
|
|
107
|
+
const blob = await readSettingsJson<Record<string, unknown> | null>(db, CHAMBER_SETTINGS_KEY, null);
|
|
108
|
+
const value = blob?.autoSessionTitle;
|
|
109
|
+
return typeof value === 'boolean' ? value : AUTO_SESSION_TITLE_DEFAULT;
|
|
110
|
+
} catch {
|
|
111
|
+
return AUTO_SESSION_TITLE_DEFAULT;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Generate and persist a session title when the session has none yet.
|
|
117
|
+
*
|
|
118
|
+
* Called once per settled run. Every bail-out is deliberate: a named session
|
|
119
|
+
* must never be renamed by this path, and a generation already in flight must
|
|
120
|
+
* not be duplicated. Failures are swallowed — an auto-title is a convenience,
|
|
121
|
+
* and the next turn retries.
|
|
122
|
+
*/
|
|
123
|
+
export async function triggerAutoSessionTitle(host: AutoTitleHost): Promise<void> {
|
|
124
|
+
if (host.autoTitleInFlight || !host.sessionId) return;
|
|
125
|
+
if (!(await isAutoTitleEnabled())) return;
|
|
126
|
+
|
|
127
|
+
host.autoTitleInFlight = true;
|
|
128
|
+
try {
|
|
129
|
+
// Re-read the name from omp rather than trusting a cached value: a rename
|
|
130
|
+
// made in another tab (or by omp's own TUI on the same session file) would
|
|
131
|
+
// otherwise be overwritten by the `"user"` write `/rename` performs.
|
|
132
|
+
const state = await host.proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
|
|
133
|
+
if (state.sessionName?.trim()) return;
|
|
134
|
+
// The session changed under us (a reset, or a `switch_session`): the name
|
|
135
|
+
// we just read belongs to a different conversation.
|
|
136
|
+
if (state.sessionId !== host.sessionId) return;
|
|
137
|
+
|
|
138
|
+
// Direct `proc` write, NOT the chamber's prompt dispatch: this is
|
|
139
|
+
// background work, so it must not mark the session streaming or claim the
|
|
140
|
+
// sidebar's run row. omp's ack is immediate (`agentInvoked:false`); the
|
|
141
|
+
// generation itself streams back later as ordinary frames.
|
|
142
|
+
await host.proc.sendCommand({ type: 'prompt', message: GENERATE_TITLE_COMMAND }, PROMPT_ACK_TIMEOUT_MS);
|
|
143
|
+
// Accepted — the diagnostics omp is about to emit belong to this request,
|
|
144
|
+
// not to anything the operator typed.
|
|
145
|
+
markTitleRequestSent(host);
|
|
146
|
+
} catch {
|
|
147
|
+
// Provider error, timeout, or a child that went away — leave the session
|
|
148
|
+
// unnamed; the next settled run tries again.
|
|
149
|
+
} finally {
|
|
150
|
+
host.autoTitleInFlight = false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Blob-reference resolution for persisted session images.
|
|
8
|
+
*
|
|
9
|
+
* omp externalizes a large attached image out of the session JSONL: the entry
|
|
10
|
+
* keeps a content-addressed ref (`blob:sha256:<hash>`) and the bytes live in the
|
|
11
|
+
* blob store. Nothing else resolves them, so a reloaded session used to render
|
|
12
|
+
* `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
|
|
13
|
+
* string, which the browser cannot decode.
|
|
14
|
+
*
|
|
15
|
+
* The ref suffix feeds a `path.join` against the blob directory, so the
|
|
16
|
+
* canonical-hash guard is the security boundary and is tested as such.
|
|
17
|
+
*
|
|
18
|
+
* The agent dir is read per call (not at import time), so pointing
|
|
19
|
+
* `PI_CODING_AGENT_DIR` at a temp directory is enough to redirect the store.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
23
|
+
import * as fsp from 'node:fs/promises';
|
|
24
|
+
import * as os from 'node:os';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import { getBlobStoreDir, isBlobRef, readBlob, readBlobRef } from '@/server/lib/omp/session/blobs.server';
|
|
27
|
+
import { extractUserImageAttachments, isBlobImageRef } from '@/shared/lib/omp/session/parse-message-blocks';
|
|
28
|
+
|
|
29
|
+
/** A minimal but structurally valid PNG, so the assertion is about real bytes. */
|
|
30
|
+
const PNG_BYTES = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
|
31
|
+
const HASH = 'a'.repeat(64);
|
|
32
|
+
|
|
33
|
+
let agentDir = '';
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
agentDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'omp-blob-test-'));
|
|
37
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
38
|
+
await fsp.mkdir(getBlobStoreDir(), { recursive: true });
|
|
39
|
+
await fsp.writeFile(path.join(getBlobStoreDir(), HASH), PNG_BYTES);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
44
|
+
await fsp.rm(agentDir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('blob store reads', () => {
|
|
48
|
+
test('reads the bytes a hash points at', async () => {
|
|
49
|
+
expect(await readBlob(HASH)).toEqual(PNG_BYTES);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('reads through a blob reference', async () => {
|
|
53
|
+
expect(await readBlobRef(`blob:sha256:${HASH}`)).toEqual(PNG_BYTES);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a missing blob yields null instead of throwing', async () => {
|
|
57
|
+
expect(await readBlob('b'.repeat(64))).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a non-reference string yields null', async () => {
|
|
61
|
+
expect(await readBlobRef('iVBORw0KGgo=')).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// The hash is interpolated into a path, so anything but a canonical digest
|
|
65
|
+
// must be refused before the filesystem is touched.
|
|
66
|
+
test('rejects refs that would escape the blob directory', async () => {
|
|
67
|
+
for (const bad of ['../../../../etc/passwd', '..%2f..%2fetc%2fpasswd', `${'a'.repeat(63)}/`, 'A'.repeat(64), 'abc', '']) {
|
|
68
|
+
expect(await readBlob(bad)).toBeNull();
|
|
69
|
+
expect(await readBlobRef(`blob:sha256:${bad}`)).toBeNull();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('recognises the reference prefix', () => {
|
|
74
|
+
expect(isBlobRef(`blob:sha256:${HASH}`)).toBe(true);
|
|
75
|
+
expect(isBlobRef('data:image/png;base64,AAAA')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('image block extraction', () => {
|
|
80
|
+
test('an externalized block carries the ref instead of a fake data URL', () => {
|
|
81
|
+
const ref = `blob:sha256:${HASH}`;
|
|
82
|
+
const [attachment] = extractUserImageAttachments([{ type: 'image', data: ref, mimeType: 'image/png' }]);
|
|
83
|
+
expect(attachment.blobRef).toBe(ref);
|
|
84
|
+
// The bug: `data:image/png;base64,blob:sha256:…` is not decodable.
|
|
85
|
+
expect(attachment.preview).toBe('');
|
|
86
|
+
expect(attachment.preview).not.toContain('base64,blob:');
|
|
87
|
+
expect(attachment.type).toBe('image/png');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('an inlined block still becomes a data URL', () => {
|
|
91
|
+
const [attachment] = extractUserImageAttachments([{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' }]);
|
|
92
|
+
expect(attachment.preview).toBe('data:image/png;base64,iVBORw0KGgo=');
|
|
93
|
+
expect(attachment.blobRef).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('distinguishes the two payload shapes', () => {
|
|
97
|
+
expect(isBlobImageRef(`blob:sha256:${HASH}`)).toBe(true);
|
|
98
|
+
expect(isBlobImageRef('iVBORw0KGgo=')).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Blob reference resolution for persisted session images.
|
|
8
|
+
*
|
|
9
|
+
* omp keeps an image's bytes out of the session JSONL once they are large: the
|
|
10
|
+
* entry stores a content-addressed reference (`blob:sha256:<hash>`) and the raw
|
|
11
|
+
* bytes live in the blob store (`$OMP_AGENT_DIR/blobs/<hash>`, an XDG-aware
|
|
12
|
+
* path — see `getBlobStoreDir`). Verified against omp 18.2.10:
|
|
13
|
+
* `pi-coding-agent/src/session/blob-store.ts` is the producer, and only omp's
|
|
14
|
+
* own loader (`session-loader.ts:resolveBlobRefs`) resolves the refs back on
|
|
15
|
+
* read.
|
|
16
|
+
*
|
|
17
|
+
* Nothing in the chamber did that, so a reloaded session rendered
|
|
18
|
+
* `data:image/png;base64,blob:sha256:…` — a data URL whose payload is the ref
|
|
19
|
+
* string, which no browser can decode. The timeline showed a broken image and
|
|
20
|
+
* opening it dumped the ref as text.
|
|
21
|
+
*
|
|
22
|
+
* Resolution is confined to the blob store: the suffix must be a canonical
|
|
23
|
+
* 64-char lowercase hex digest, so a crafted ref cannot escape the directory
|
|
24
|
+
* (the same guard omp applies in `parseBlobRef`).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fsp from 'node:fs/promises';
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
29
|
+
import * as path from 'node:path';
|
|
30
|
+
import { getAgentDir } from '@/server/lib/omp/core/paths';
|
|
31
|
+
|
|
32
|
+
const BLOB_PREFIX = 'blob:sha256:';
|
|
33
|
+
|
|
34
|
+
/** Canonical blob hash shape; anything else is rejected before touching the fs. */
|
|
35
|
+
const BLOB_HASH_RE = /^[a-f0-9]{64}$/;
|
|
36
|
+
|
|
37
|
+
/** True when `value` is a content-addressed blob reference. */
|
|
38
|
+
export function isBlobRef(value: string): boolean {
|
|
39
|
+
return value.startsWith(BLOB_PREFIX);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The blob store directory. Mirrors omp's `getBlobsDir()`: `<agentDir>/blobs`,
|
|
44
|
+
* relocated to `$XDG_DATA_HOME/omp/blobs` when omp is running the XDG layout
|
|
45
|
+
* (which drops the `agent/` prefix). The existing-directory probe decides which
|
|
46
|
+
* layout is live, because omp's own switch is opt-in and file-based.
|
|
47
|
+
*/
|
|
48
|
+
export function getBlobStoreDir(): string {
|
|
49
|
+
const xdgDataHome = Bun.env.XDG_DATA_HOME;
|
|
50
|
+
if (xdgDataHome) {
|
|
51
|
+
const xdgPath = path.join(xdgDataHome, 'omp', 'blobs');
|
|
52
|
+
if (existsSync(xdgPath)) return xdgPath;
|
|
53
|
+
}
|
|
54
|
+
return path.join(getAgentDir(), 'blobs');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read a blob's raw bytes, or null when the ref is malformed or the blob is
|
|
59
|
+
* gone (a pruned store, a session copied between machines). A missing blob is
|
|
60
|
+
* not an error the caller can act on — the caller falls back to showing the
|
|
61
|
+
* attachment without a preview.
|
|
62
|
+
*/
|
|
63
|
+
export async function readBlob(hash: string): Promise<Buffer | null> {
|
|
64
|
+
if (!BLOB_HASH_RE.test(hash)) return null;
|
|
65
|
+
try {
|
|
66
|
+
return await fsp.readFile(path.join(getBlobStoreDir(), hash));
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read the bytes behind a `blob:sha256:<hash>` reference, or null. */
|
|
73
|
+
export async function readBlobRef(ref: string): Promise<Buffer | null> {
|
|
74
|
+
if (!isBlobRef(ref)) return null;
|
|
75
|
+
return readBlob(ref.slice(BLOB_PREFIX.length));
|
|
76
|
+
}
|