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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bounded readers for one oh-my-pi session file: a fixed-size head window and
|
|
8
|
+
* a tail window that dates the session's real last activity. Split out of
|
|
9
|
+
* ./files.ts so the scanner there stays under the file-size ceiling; both
|
|
10
|
+
* windows exist so a session file is never loaded whole into memory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { extractStringProperty } from '@/shared/lib/omp/session/jsonl';
|
|
14
|
+
import { isRecord } from '@/shared/lib/util/guards';
|
|
15
|
+
|
|
16
|
+
export const SESSION_LIST_PREFIX_BYTES = 4096;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Tail window for the last-activity timestamp. The distance from EOF to the
|
|
20
|
+
* newest timestamped line measured at most ~4 KiB across every session on this
|
|
21
|
+
* machine, so 16 KiB carries 4x headroom over the largest final entry seen.
|
|
22
|
+
*/
|
|
23
|
+
const SESSION_TAIL_BYTES = 16 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ceiling for the escalating tail read. A session can legitimately end with an
|
|
27
|
+
* entry larger than the first window — a tool result carrying a big file read
|
|
28
|
+
* or build log — and that entry is usually the newest timestamped line, so
|
|
29
|
+
* falling back to mtime there would reintroduce the bug this exists to fix.
|
|
30
|
+
* The window quadruples up to this ceiling, which is one read per step and
|
|
31
|
+
* only for a session whose final entry is genuinely that large.
|
|
32
|
+
*/
|
|
33
|
+
const SESSION_TAIL_MAX_BYTES = 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/** Head window of a session file plus the stat it was read with. */
|
|
36
|
+
export interface SessionFilePrefix {
|
|
37
|
+
text: string;
|
|
38
|
+
size: number;
|
|
39
|
+
mtime: Date;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readTextPrefix(filePath: string, prefixBytes: number): Promise<SessionFilePrefix> {
|
|
43
|
+
const file = Bun.file(filePath);
|
|
44
|
+
const stat = await file.stat();
|
|
45
|
+
const prefixLength = Math.min(prefixBytes, stat.size);
|
|
46
|
+
const text = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
|
|
47
|
+
return { text, size: stat.size, mtime: new Date(stat.mtimeMs) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* omp lifecycle markers: entries the agent writes about the SESSION PROCESS,
|
|
52
|
+
* not about the conversation. `session_exit` is emitted every time an omp
|
|
53
|
+
* child is disposed — an idle timeout, a chamber restart, a shutdown — and a
|
|
54
|
+
* session accumulates one per dispose (the file that prompted this rule holds
|
|
55
|
+
* three). Treating the newest one as last activity floats a session that has
|
|
56
|
+
* been quiet for days to the top of `LATEST_SESSION`: measured across a real
|
|
57
|
+
* agent dir, 255 of 319 sessions were ranked by a marker like that, the worst
|
|
58
|
+
* by ~14 days.
|
|
59
|
+
*
|
|
60
|
+
* Only markers KNOWN to be lifecycle are excluded. `tool_execution_start` is
|
|
61
|
+
* the commonest custom entry in the same files (16k occurrences) and IS
|
|
62
|
+
* activity — a run that ends mid-tool-call is still the newest one — so an
|
|
63
|
+
* unrecognised customType is counted rather than ignored.
|
|
64
|
+
*/
|
|
65
|
+
const LIFECYCLE_CUSTOM_TYPES: Record<string, true> = { session_exit: true };
|
|
66
|
+
|
|
67
|
+
/** A parsed entry timestamp, flagged when the entry is process bookkeeping. */
|
|
68
|
+
interface EntryStamp {
|
|
69
|
+
at: Date;
|
|
70
|
+
lifecycle: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Timestamp + lifecycle flag from one JSONL line, or undefined when the line
|
|
74
|
+
* carries no usable timestamp. */
|
|
75
|
+
function entryStampOfLine(line: string): EntryStamp | undefined {
|
|
76
|
+
let type: unknown;
|
|
77
|
+
let customType: unknown;
|
|
78
|
+
let value: unknown;
|
|
79
|
+
let parsedOk = false;
|
|
80
|
+
try {
|
|
81
|
+
const parsed: unknown = JSON.parse(line);
|
|
82
|
+
if (isRecord(parsed)) {
|
|
83
|
+
parsedOk = true;
|
|
84
|
+
type = parsed.type;
|
|
85
|
+
customType = parsed.customType;
|
|
86
|
+
value = parsed.timestamp;
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Torn or window-truncated line — fall back to the raw-text scan.
|
|
90
|
+
}
|
|
91
|
+
if (!parsedOk) {
|
|
92
|
+
value = extractStringProperty(line, 'timestamp');
|
|
93
|
+
type = extractStringProperty(line, 'type');
|
|
94
|
+
customType = extractStringProperty(line, 'customType');
|
|
95
|
+
}
|
|
96
|
+
let at: Date | undefined;
|
|
97
|
+
if (typeof value === 'number' && Number.isFinite(value)) at = new Date(value);
|
|
98
|
+
else if (typeof value === 'string') {
|
|
99
|
+
const parsed = new Date(value);
|
|
100
|
+
if (!Number.isNaN(parsed.getTime())) at = parsed;
|
|
101
|
+
}
|
|
102
|
+
if (!at) return undefined;
|
|
103
|
+
const lifecycle = type === 'custom'
|
|
104
|
+
&& typeof customType === 'string'
|
|
105
|
+
&& LIFECYCLE_CUSTOM_TYPES[customType] === true;
|
|
106
|
+
return { at, lifecycle };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Timestamp of the newest entry in a session file, read from a bounded tail
|
|
111
|
+
* window — the session's real last activity.
|
|
112
|
+
*
|
|
113
|
+
* File mtime is NOT that value. A title-slot rename rewrites the first 256
|
|
114
|
+
* bytes in place and bumps mtime without a turn having happened; a full-file
|
|
115
|
+
* title rewrite on a slot-less legacy file does the same; and a session
|
|
116
|
+
* restored or copied from a backup carries an mtime unrelated to its content.
|
|
117
|
+
* Every one of those moved the sidebar's `LATEST_SESSION` order. The last
|
|
118
|
+
* entry's own `timestamp` is what "latest session" means, and it survives all
|
|
119
|
+
* three.
|
|
120
|
+
*
|
|
121
|
+
* Process bookkeeping does not count either — see LIFECYCLE_CUSTOM_TYPES.
|
|
122
|
+
*
|
|
123
|
+
* `prefix` is the already-read 4 KiB head; a file that fits inside it needs no
|
|
124
|
+
* second read — a third of the sessions on a real machine are that small.
|
|
125
|
+
*
|
|
126
|
+
* The window starts at SESSION_TAIL_BYTES and widens toward
|
|
127
|
+
* SESSION_TAIL_MAX_BYTES while it holds no usable entry, so a session ending
|
|
128
|
+
* on one oversized entry is still dated correctly. Only when even the ceiling
|
|
129
|
+
* holds nothing but lifecycle markers — an empty file, or a single entry
|
|
130
|
+
* larger than a megabyte — does this return undefined and let the caller fall
|
|
131
|
+
* back to mtime.
|
|
132
|
+
*/
|
|
133
|
+
export async function readLastEntryTimestamp(
|
|
134
|
+
filePath: string,
|
|
135
|
+
size: number,
|
|
136
|
+
prefix: string,
|
|
137
|
+
): Promise<Date | undefined> {
|
|
138
|
+
if (size <= 0) return undefined;
|
|
139
|
+
let windowBytes = SESSION_TAIL_BYTES;
|
|
140
|
+
for (;;) {
|
|
141
|
+
const start = Math.max(0, size - windowBytes);
|
|
142
|
+
let text: string;
|
|
143
|
+
if (size <= SESSION_LIST_PREFIX_BYTES) {
|
|
144
|
+
// The whole file is inside the prefix window already.
|
|
145
|
+
text = prefix;
|
|
146
|
+
} else if (start === 0) {
|
|
147
|
+
try {
|
|
148
|
+
text = await Bun.file(filePath).text();
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
try {
|
|
154
|
+
// Read one byte before the window: if it is a newline the window opens
|
|
155
|
+
// on a line boundary, otherwise the first line is a fragment. Either
|
|
156
|
+
// way the leading split element is not a complete entry.
|
|
157
|
+
text = await Bun.file(filePath).slice(start - 1).text();
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const lines = text.split('\n');
|
|
163
|
+
if (start > 0) lines.shift();
|
|
164
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
165
|
+
const line = lines[i].trim();
|
|
166
|
+
if (!line.includes('"timestamp"')) continue;
|
|
167
|
+
const stamp = entryStampOfLine(line);
|
|
168
|
+
if (stamp && !stamp.lifecycle) return stamp.at;
|
|
169
|
+
}
|
|
170
|
+
// The window is already the whole file, or it has reached its ceiling.
|
|
171
|
+
if (start === 0 || windowBytes >= SESSION_TAIL_MAX_BYTES) return undefined;
|
|
172
|
+
windowBytes = Math.min(windowBytes * 4, SESSION_TAIL_MAX_BYTES);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
import { scanSessionInfo } from '@/server/lib/omp/session/files';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `modified` is the session's last real activity, and two signals that look
|
|
14
|
+
* like it are not:
|
|
15
|
+
*
|
|
16
|
+
* - the file mtime, which a title-slot rename bumps without a turn having
|
|
17
|
+
* happened (measured: 5 days of skew on a real session);
|
|
18
|
+
* - a `session_exit` lifecycle marker, written per process dispose, which
|
|
19
|
+
* floats a session quiet for days to the top of the sidebar (measured: 255
|
|
20
|
+
* of 319 sessions ranked by one).
|
|
21
|
+
*
|
|
22
|
+
* The cost of regressing either is a silently wrong sidebar order, which no
|
|
23
|
+
* other test observes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const tempDirs: string[] = [];
|
|
27
|
+
|
|
28
|
+
/** Write a session file whose lines are exactly the given records, in order. */
|
|
29
|
+
async function writeSession(lines: unknown[]): Promise<string> {
|
|
30
|
+
const dir = await fs.promises.mkdtemp('omp-session-files-');
|
|
31
|
+
tempDirs.push(dir);
|
|
32
|
+
const file = join(dir, 'session.jsonl');
|
|
33
|
+
await Bun.write(file, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`);
|
|
34
|
+
return file;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const at = (day: number, second = 0) =>
|
|
42
|
+
`2026-09-${String(day).padStart(2, '0')}T03:00:${String(second).padStart(2, '0')}.000Z`;
|
|
43
|
+
|
|
44
|
+
const slot = (title: string) => ({ type: 'title', v: 1, title, updatedAt: at(1), pad: '' });
|
|
45
|
+
const header = { type: 'session', version: 3, id: 'session-id', cwd: '/tmp/proj', timestamp: at(1) };
|
|
46
|
+
const message = (day: number, text = 'hello') => ({
|
|
47
|
+
type: 'message',
|
|
48
|
+
id: `m-${day}`,
|
|
49
|
+
parentId: null,
|
|
50
|
+
timestamp: at(day),
|
|
51
|
+
message: { role: 'user', content: [{ type: 'text', text }] },
|
|
52
|
+
});
|
|
53
|
+
/** The marker omp appends every time it disposes the session process. */
|
|
54
|
+
const sessionExit = (day: number) => ({
|
|
55
|
+
type: 'custom',
|
|
56
|
+
customType: 'session_exit',
|
|
57
|
+
data: { reason: 'dispose', kind: 'normal', recordedAt: at(day) },
|
|
58
|
+
id: `x-${day}`,
|
|
59
|
+
parentId: null,
|
|
60
|
+
timestamp: at(day),
|
|
61
|
+
});
|
|
62
|
+
const toolStart = (day: number) => ({
|
|
63
|
+
type: 'custom',
|
|
64
|
+
customType: 'tool_execution_start',
|
|
65
|
+
data: { toolCallId: 'call', toolName: 'bash' },
|
|
66
|
+
id: `t-${day}`,
|
|
67
|
+
parentId: null,
|
|
68
|
+
timestamp: at(day),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('scanSessionInfo last activity', () => {
|
|
72
|
+
test('dates the session by its newest entry, not the file mtime', async () => {
|
|
73
|
+
// The mtime is "now" (just written); only the entry timestamp is the answer.
|
|
74
|
+
const file = await writeSession([slot('t'), header, message(10)]);
|
|
75
|
+
const info = await scanSessionInfo(file);
|
|
76
|
+
expect(info?.modified.toISOString()).toBe(at(10));
|
|
77
|
+
expect(info?.modified.getTime()).not.toBe(info?.fileMtime.getTime());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('ignores a trailing session_exit marker', async () => {
|
|
81
|
+
const file = await writeSession([slot('t'), header, message(10), sessionExit(20)]);
|
|
82
|
+
const info = await scanSessionInfo(file);
|
|
83
|
+
expect(info?.modified.toISOString()).toBe(at(10));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('ignores a stack of session_exit markers, however many accumulated', async () => {
|
|
87
|
+
const file = await writeSession([
|
|
88
|
+
slot('t'),
|
|
89
|
+
header,
|
|
90
|
+
message(11),
|
|
91
|
+
sessionExit(20),
|
|
92
|
+
sessionExit(21),
|
|
93
|
+
sessionExit(22),
|
|
94
|
+
]);
|
|
95
|
+
const info = await scanSessionInfo(file);
|
|
96
|
+
expect(info?.modified.toISOString()).toBe(at(11));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('still counts an in-flight tool call as activity', async () => {
|
|
100
|
+
// A run killed mid-tool-call has no later message, but it IS the newest
|
|
101
|
+
// session. Excluding every custom entry would misrank it.
|
|
102
|
+
const file = await writeSession([slot('t'), header, message(12), toolStart(13)]);
|
|
103
|
+
const info = await scanSessionInfo(file);
|
|
104
|
+
expect(info?.modified.toISOString()).toBe(at(13));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('counts an unrecognised custom entry rather than assuming it is bookkeeping', async () => {
|
|
108
|
+
const file = await writeSession([
|
|
109
|
+
slot('t'),
|
|
110
|
+
header,
|
|
111
|
+
message(12),
|
|
112
|
+
{ type: 'custom', customType: 'brand_new_marker', id: 'z', timestamp: at(19) },
|
|
113
|
+
]);
|
|
114
|
+
const info = await scanSessionInfo(file);
|
|
115
|
+
expect(info?.modified.toISOString()).toBe(at(19));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('uses a later message when the exit marker came first', async () => {
|
|
119
|
+
const file = await writeSession([slot('t'), header, sessionExit(5), message(6)]);
|
|
120
|
+
const info = await scanSessionInfo(file);
|
|
121
|
+
expect(info?.modified.toISOString()).toBe(at(6));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('falls back to the creation time for a session with no activity at all', async () => {
|
|
125
|
+
const file = await writeSession([slot('t'), header, sessionExit(30)]);
|
|
126
|
+
const info = await scanSessionInfo(file);
|
|
127
|
+
expect(info?.modified.toISOString()).toBe(at(1));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('reads the timestamp past an entry larger than the tail window', async () => {
|
|
131
|
+
// A tool result carrying a big file read is the newest entry and is
|
|
132
|
+
// usually the largest one; the window must widen to reach its line.
|
|
133
|
+
const file = await writeSession([
|
|
134
|
+
slot('t'),
|
|
135
|
+
header,
|
|
136
|
+
message(2, 'head'),
|
|
137
|
+
message(4, 'x'.repeat(200_000)),
|
|
138
|
+
]);
|
|
139
|
+
const info = await scanSessionInfo(file);
|
|
140
|
+
expect(info?.modified.toISOString()).toBe(at(4));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('returns undefined for a file with no session header', async () => {
|
|
144
|
+
const file = await writeSession([slot('t'), message(3)]);
|
|
145
|
+
expect(await scanSessionInfo(file)).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
* File layout: optional fixed-width 256-byte title-slot line, then the
|
|
16
16
|
* {"type":"session"} header line, then message entries. Legacy pi v1/v2
|
|
17
17
|
* shapes parse leniently (raw-text scans); nothing here mutates on-disk data.
|
|
18
|
-
* The lenient JSONL + header parsing lives in
|
|
18
|
+
* The lenient JSONL + header parsing lives in
|
|
19
|
+
* `@/shared/lib/omp/session/jsonl`; the bounded window readers (head prefix
|
|
20
|
+
* plus escalating tail) live in ./file-scan.ts.
|
|
19
21
|
*/
|
|
20
22
|
|
|
21
23
|
import fs from 'fs';
|
|
22
24
|
import * as path from 'path';
|
|
23
25
|
import { getSessionsDir } from '@/server/lib/omp/core/paths';
|
|
26
|
+
import { readLastEntryTimestamp, readTextPrefix, SESSION_LIST_PREFIX_BYTES } from '@/server/lib/omp/session/file-scan';
|
|
24
27
|
import { countMessageMarkers, extractFirstDisplayMessageFromPrefix, extractTextFromContent, parseJsonlLenient, parseSessionListHeader } from '@/shared/lib/omp/session/jsonl';
|
|
25
28
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
26
29
|
|
|
@@ -37,37 +40,30 @@ export interface OmpSessionInfo {
|
|
|
37
40
|
title?: string;
|
|
38
41
|
parentSessionPath?: string;
|
|
39
42
|
created: Date;
|
|
43
|
+
/** Last activity: the newest JSONL entry's own `timestamp`, falling back to
|
|
44
|
+
* the file mtime for a file the tail window cannot date. */
|
|
40
45
|
modified: Date;
|
|
46
|
+
/** Filesystem mtime. Scan-cache version only — never user-visible, and never
|
|
47
|
+
* an ordering key (see readLastEntryTimestamp for why). */
|
|
48
|
+
fileMtime: Date;
|
|
41
49
|
messageCount: number;
|
|
42
50
|
size: number;
|
|
43
51
|
firstMessage: string;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
// ============================================================================
|
|
47
|
-
// Per-file scan (4 KiB prefix window)
|
|
48
|
-
// ============================================================================
|
|
49
|
-
|
|
50
|
-
const SESSION_LIST_PREFIX_BYTES = 4096;
|
|
51
|
-
|
|
52
|
-
async function readTextPrefix(filePath: string, prefixBytes: number): Promise<[string, number, Date]> {
|
|
53
|
-
const file = Bun.file(filePath);
|
|
54
|
-
const stat = await file.stat();
|
|
55
|
-
const prefixLength = Math.min(prefixBytes, stat.size);
|
|
56
|
-
const prefix = prefixLength > 0 ? await file.slice(0, prefixLength).text() : '';
|
|
57
|
-
return [prefix, stat.size, new Date(stat.mtimeMs)];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
54
|
/**
|
|
61
|
-
* Scan a single session file into an OmpSessionInfo
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
55
|
+
* Scan a single session file into an OmpSessionInfo from a 4 KiB prefix window
|
|
56
|
+
* (header, title, first message) plus a bounded tail window (last activity).
|
|
57
|
+
* Faithful port of omp's scanSessionFile except for `modified`, which is the
|
|
58
|
+
* last entry's timestamp rather than the file mtime. Missing/unreadable/
|
|
59
|
+
* header-less files yield undefined instead of throwing.
|
|
65
60
|
*/
|
|
66
61
|
export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo | undefined> {
|
|
67
62
|
try {
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
63
|
+
const { text: prefix, size, mtime } = await readTextPrefix(filePath, SESSION_LIST_PREFIX_BYTES);
|
|
64
|
+
const lastEntryAt = await readLastEntryTimestamp(filePath, size, prefix);
|
|
65
|
+
const entries = parseJsonlLenient<Record<string, unknown>>(prefix);
|
|
66
|
+
const header = parseSessionListHeader(prefix, entries);
|
|
71
67
|
if (!header) return undefined;
|
|
72
68
|
|
|
73
69
|
let parsedMessageCount = 0;
|
|
@@ -90,7 +86,7 @@ export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo
|
|
|
90
86
|
}
|
|
91
87
|
}
|
|
92
88
|
|
|
93
|
-
firstMessage ||= extractFirstDisplayMessageFromPrefix(
|
|
89
|
+
firstMessage ||= extractFirstDisplayMessageFromPrefix(prefix) ?? '';
|
|
94
90
|
return {
|
|
95
91
|
path: filePath,
|
|
96
92
|
id: header.id,
|
|
@@ -98,8 +94,9 @@ export async function scanSessionInfo(filePath: string): Promise<OmpSessionInfo
|
|
|
98
94
|
title: header.title ?? shortSummary,
|
|
99
95
|
parentSessionPath: header.parentSession,
|
|
100
96
|
created: new Date(header.timestamp ?? ''),
|
|
101
|
-
modified: mtime,
|
|
102
|
-
|
|
97
|
+
modified: lastEntryAt ?? mtime,
|
|
98
|
+
fileMtime: mtime,
|
|
99
|
+
messageCount: Math.max(parsedMessageCount, countMessageMarkers(prefix)),
|
|
103
100
|
size,
|
|
104
101
|
firstMessage: firstMessage || '(no messages)',
|
|
105
102
|
};
|
|
@@ -120,9 +117,24 @@ interface SessionFileListCacheEntry {
|
|
|
120
117
|
files: string[];
|
|
121
118
|
}
|
|
122
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Shape version of a cached OmpSessionInfo. The caches hang off `globalThis`
|
|
122
|
+
* so they survive a module reload, which means an entry written by an older
|
|
123
|
+
* build of this file is still readable after an edit — and a field added or
|
|
124
|
+
* renamed since then reads as undefined at the point of use rather than
|
|
125
|
+
* missing a scan. Bump this whenever OmpSessionInfo changes; the cache is
|
|
126
|
+
* discarded instead of misread.
|
|
127
|
+
*/
|
|
128
|
+
const SESSION_SCAN_CACHE_VERSION = 2;
|
|
129
|
+
|
|
130
|
+
interface SessionScanCache {
|
|
131
|
+
version: number;
|
|
132
|
+
entries: Map<string, OmpSessionInfo>;
|
|
133
|
+
}
|
|
134
|
+
|
|
123
135
|
declare global {
|
|
124
136
|
var __ompChamberSessionFileListCache: Map<string, SessionFileListCacheEntry> | undefined;
|
|
125
|
-
var __ompChamberSessionScanCache:
|
|
137
|
+
var __ompChamberSessionScanCache: SessionScanCache | undefined;
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
const MAX_SESSION_SCAN_CACHE_ENTRIES = 2048;
|
|
@@ -197,16 +209,18 @@ export async function listSessionFiles(sessionsRoot: string = getSessionsDir()):
|
|
|
197
209
|
}
|
|
198
210
|
|
|
199
211
|
function getSessionScanCache(): Map<string, OmpSessionInfo> {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
-
|
|
212
|
+
const slot = globalThis.__ompChamberSessionScanCache;
|
|
213
|
+
if (slot && slot.version === SESSION_SCAN_CACHE_VERSION) return slot.entries;
|
|
214
|
+
const fresh: SessionScanCache = { version: SESSION_SCAN_CACHE_VERSION, entries: new Map() };
|
|
215
|
+
globalThis.__ompChamberSessionScanCache = fresh;
|
|
216
|
+
return fresh.entries;
|
|
204
217
|
}
|
|
205
218
|
|
|
206
219
|
/** scanSessionInfo memoized on (path, size, mtimeMs) — an unchanged file costs
|
|
207
220
|
* a single stat. Cache hits share one object; callers must treat results as
|
|
208
|
-
* immutable.
|
|
209
|
-
|
|
221
|
+
* immutable. Exported so the id locator shares this cache with the sidebar
|
|
222
|
+
* list instead of re-reading every prefix and tail on each resolve. */
|
|
223
|
+
export async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo | undefined> {
|
|
210
224
|
let stat: { size: number; mtimeMs: number };
|
|
211
225
|
try {
|
|
212
226
|
stat = await Bun.file(filePath).stat();
|
|
@@ -217,8 +231,11 @@ async function scanSessionInfoCached(filePath: string): Promise<OmpSessionInfo |
|
|
|
217
231
|
const cached = cache.get(filePath);
|
|
218
232
|
if (cached) {
|
|
219
233
|
const info = cached;
|
|
220
|
-
// Re-scan when size or mtime changed.
|
|
221
|
-
|
|
234
|
+
// Re-scan when size or mtime changed. The version is the FILE mtime, not
|
|
235
|
+
// `modified` — the latter is now an entry timestamp, and a title-slot
|
|
236
|
+
// rewrite that bumps the file without appending an entry must still be
|
|
237
|
+
// picked up (it can change the title the row displays).
|
|
238
|
+
if (info.size === stat.size && info.fileMtime.getTime() === stat.mtimeMs) {
|
|
222
239
|
cache.delete(filePath);
|
|
223
240
|
cache.set(filePath, info);
|
|
224
241
|
return info;
|
|
@@ -259,7 +276,7 @@ export async function listAllSessionInfos(sessionsRoot: string = getSessionsDir(
|
|
|
259
276
|
* that mtime keys cannot detect). */
|
|
260
277
|
export function clearSessionFileCaches(): void {
|
|
261
278
|
globalThis.__ompChamberSessionFileListCache?.clear();
|
|
262
|
-
globalThis.__ompChamberSessionScanCache?.clear();
|
|
279
|
+
globalThis.__ompChamberSessionScanCache?.entries.clear();
|
|
263
280
|
}
|
|
264
281
|
|
|
265
282
|
/** Exported for tests/spot-checks: read a raw JSONL session header directly. */
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { json } from '@/server/lib/remix-compat';
|
|
13
13
|
import { getSessionsDir } from '@/server/lib/omp/core/paths';
|
|
14
|
-
import { listSessionFiles, readRawHeaderLine,
|
|
14
|
+
import { listSessionFiles, readRawHeaderLine, scanSessionInfoCached } from '@/server/lib/omp/session/files';
|
|
15
15
|
|
|
16
16
|
/** Find the absolute path of the .jsonl whose header id matches. */
|
|
17
17
|
export async function findSessionFileById(
|
|
@@ -20,7 +20,7 @@ export async function findSessionFileById(
|
|
|
20
20
|
): Promise<string | undefined> {
|
|
21
21
|
const files = await listSessionFiles(sessionsRoot);
|
|
22
22
|
for (const file of files) {
|
|
23
|
-
const info = await
|
|
23
|
+
const info = await scanSessionInfoCached(file);
|
|
24
24
|
if (info?.id === sessionId) return file;
|
|
25
25
|
}
|
|
26
26
|
return undefined;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Merging the chamber's stored turns onto JSONL-loaded ones.
|
|
8
|
+
*
|
|
9
|
+
* The two sources see different halves of an attachment: the JSONL recovers a
|
|
10
|
+
* text file's name from the delivered prompt but has no preview or content,
|
|
11
|
+
* while the chamber row has those display fields but nothing about what omp
|
|
12
|
+
* received. Taking one list over the other dropped whichever entries only
|
|
13
|
+
* existed on the losing side — a dropped `.md` disappeared from a reloaded
|
|
14
|
+
* session as soon as the same turn carried an image.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { mergeOmpAttachments, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
|
|
19
|
+
|
|
20
|
+
function userTurn(overrides: Partial<StoredMessage> = {}): StoredMessage {
|
|
21
|
+
return { id: 'u1', role: 'user', content: 'hello', ...overrides };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function storedJson(messages: StoredMessage[]): string {
|
|
25
|
+
return JSON.stringify(messages);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('mergeOmpAttachments attachments', () => {
|
|
29
|
+
test('keeps both an image from the JSONL and a text file from the store', () => {
|
|
30
|
+
const jsonl = [userTurn({
|
|
31
|
+
attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
|
|
32
|
+
})];
|
|
33
|
+
const stored = [userTurn({
|
|
34
|
+
attachments: [
|
|
35
|
+
{ name: 'notes.md', type: 'text/plain', content: '# hi' },
|
|
36
|
+
{ name: 'shot.png', type: 'image/png', preview: 'blob:http://x/1' },
|
|
37
|
+
],
|
|
38
|
+
})];
|
|
39
|
+
|
|
40
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
41
|
+
expect(merged.attachments?.map((a) => a.name)).toEqual(['shot.png', 'notes.md']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a text file recovered from the JSONL keeps its name when the store has none', () => {
|
|
45
|
+
const jsonl = [userTurn({
|
|
46
|
+
attachments: [{ name: 'script.py', type: 'text/plain', content: 'x=1' }],
|
|
47
|
+
})];
|
|
48
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson([userTurn()]));
|
|
49
|
+
expect(merged.attachments).toEqual([{ name: 'script.py', type: 'text/plain', content: 'x=1' }]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a matching name fills in the fields the JSONL could not carry', () => {
|
|
53
|
+
const jsonl = [userTurn({
|
|
54
|
+
attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
|
|
55
|
+
})];
|
|
56
|
+
const stored = [userTurn({
|
|
57
|
+
attachments: [{ name: 'shot.png', type: 'image/png', size: 42, content: 'stored' }],
|
|
58
|
+
})];
|
|
59
|
+
|
|
60
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
61
|
+
expect(merged.attachments).toEqual([
|
|
62
|
+
{ name: 'shot.png', type: 'image/png', size: 42, content: 'stored', preview: 'data:image/png;base64,AAAA' },
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('an attachment only the store knows about is still shown', () => {
|
|
67
|
+
const jsonl = [userTurn({
|
|
68
|
+
attachments: [{ name: 'shot.png', type: 'image/png', preview: 'data:image/png;base64,AAAA' }],
|
|
69
|
+
})];
|
|
70
|
+
const stored = [userTurn({
|
|
71
|
+
attachments: [
|
|
72
|
+
{ name: 'shot.png', type: 'image/png' },
|
|
73
|
+
{ name: 'dropped.txt', type: 'text/plain', content: 'kept' },
|
|
74
|
+
],
|
|
75
|
+
})];
|
|
76
|
+
|
|
77
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
78
|
+
expect(merged.attachments?.map((a) => a.name)).toEqual(['shot.png', 'dropped.txt']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a turn with no attachments on either side stays attachment-free', () => {
|
|
82
|
+
const [merged] = mergeOmpAttachments([userTurn()], storedJson([userTurn()]));
|
|
83
|
+
expect(merged.attachments).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a stored turn that never reached the JSONL is still merged in', () => {
|
|
87
|
+
const [merged] = mergeOmpAttachments([userTurn()], storedJson([userTurn({
|
|
88
|
+
attachments: [{ name: 'notes.md', type: 'text/plain', content: '# hi' }],
|
|
89
|
+
})]));
|
|
90
|
+
expect(merged.attachments).toEqual([{ name: 'notes.md', type: 'text/plain', content: '# hi' }]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('mergeOmpAttachments text restoration', () => {
|
|
95
|
+
test('a raw composer turn keeps the text the user typed', () => {
|
|
96
|
+
// The JSONL records only a synthesized `/skill:<name>`; the DB copy has the
|
|
97
|
+
// invocation with its arguments, which is what the user actually typed.
|
|
98
|
+
const jsonl = [userTurn({ content: '/skill:capacity' })];
|
|
99
|
+
const stored = [userTurn({ content: '/skill:capacity summarize this repo' })];
|
|
100
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
101
|
+
expect(merged.content).toBe('/skill:capacity summarize this repo');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('an unrelated stored turn does not overwrite the JSONL text', () => {
|
|
105
|
+
// Turns that cannot be recognized as the same request are left alone; the
|
|
106
|
+
// JSONL keeps what the model was actually sent.
|
|
107
|
+
const jsonl = [userTurn({ content: 'rewritten prompt' })];
|
|
108
|
+
const stored = [userTurn({ content: 'a completely different request' })];
|
|
109
|
+
const [merged] = mergeOmpAttachments(jsonl, storedJson(stored));
|
|
110
|
+
expect(merged.content).toBe('rewritten prompt');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('malformed stored JSON leaves the JSONL messages untouched', () => {
|
|
114
|
+
const jsonl = [userTurn()];
|
|
115
|
+
expect(mergeOmpAttachments(jsonl, 'not json')).toBe(jsonl);
|
|
116
|
+
expect(mergeOmpAttachments(jsonl, undefined)).toBe(jsonl);
|
|
117
|
+
});
|
|
118
|
+
});
|