ompchamber 3.3.3 → 3.5.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/index.js +315 -308
- package/dist/client/static/js/index-vx0ernrv.css +1 -0
- package/dist/client/static/js/{index-8ejde0b7.js → index-w0cem7n5.js} +641 -641
- package/package.json +1 -1
- package/src/client/components/common/session-actions-menu/index.tsx +201 -0
- package/src/client/components/common/session-delete-modal/index.tsx +128 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
- package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
- package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
- package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
- package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
- package/src/client/components/workspace/btw-panel/index.tsx +20 -27
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
- package/src/client/hooks/chat/btw/index.ts +7 -73
- package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
- package/src/client/hooks/chat/btw/intercept.ts +5 -2
- package/src/client/hooks/chat/btw/mode.ts +16 -76
- package/src/client/hooks/chat/timeline/actions.ts +22 -30
- package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
- package/src/client/hooks/chat/timeline/send.ts +8 -1
- package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
- package/src/client/hooks/workspace/session-delete.ts +94 -0
- package/src/server/lib/btw/child.server.test.ts +68 -0
- package/src/server/lib/btw/child.server.ts +17 -16
- package/src/server/lib/btw/frames.test.ts +3 -3
- package/src/server/lib/btw/frames.ts +1 -1
- package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
- package/src/server/lib/btw/lifecycle.server.ts +41 -31
- package/src/server/lib/btw/prompt.ts +7 -11
- package/src/server/lib/btw/purge.server.ts +56 -0
- package/src/server/lib/btw/registry.server.ts +1 -9
- package/src/server/lib/btw/reply.ts +54 -0
- package/src/server/lib/btw/runtime.server.test.ts +128 -0
- package/src/server/lib/btw/runtime.server.ts +77 -86
- package/src/server/lib/btw/schema.server.test.ts +84 -0
- package/src/server/lib/btw/schema.server.ts +8 -0
- package/src/server/lib/btw/service.server.ts +47 -78
- package/src/server/lib/btw/store.server.ts +9 -20
- package/src/server/lib/fs/path-segment.test.ts +100 -0
- package/src/server/lib/fs/path-segment.ts +45 -0
- package/src/server/lib/omp/rpc/constants.ts +10 -0
- package/src/server/lib/omp/rpc/session-commands.ts +45 -0
- package/src/server/lib/omp/session/delete.server.ts +136 -0
- package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
- package/src/server/lib/omp/session/merge-stored.ts +70 -1
- package/src/server/lib/omp/session/messages.ts +27 -2
- package/src/server/lib/omp/session/reader.ts +13 -0
- package/src/server/routes/agent/command.ts +9 -1
- package/src/server/routes/btw/topics.ts +2 -59
- package/src/server/routes/sessions/delete.test.ts +84 -0
- package/src/server/routes/sessions/delete.ts +83 -0
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/shared/lib/chat/btw/client.ts +0 -30
- package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
- package/src/shared/lib/chat/composer/tui-only.ts +138 -0
- package/src/shared/lib/chat/notice-text.ts +33 -0
- package/src/shared/lib/chat/omp/agent-events.ts +18 -37
- package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
- package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
- package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
- package/src/shared/lib/chat/timeline/command-output.ts +65 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
- package/src/shared/lib/omp/session/messages-map.ts +6 -1
- package/src/shared/lib/workspace/session-state/store.ts +20 -0
- package/src/shared/types/btw.ts +13 -34
- package/dist/client/static/js/index-bsxxt89j.css +0 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* Builtin-command output must survive a reload. omp writes NOTHING to the
|
|
6
|
+
* session JSONL for a `/context` or `/usage` turn — the output exists only in
|
|
7
|
+
* the live `command_output` frame — so the chamber's own copy is the only place
|
|
8
|
+
* it can be kept, and `mergeOmpAttachments` has to splice it back in.
|
|
9
|
+
*
|
|
10
|
+
* The anchor is the user turn that produced it, matched by CONTENT: the
|
|
11
|
+
* chamber's optimistic user row carries a `msg-…-user` id while the JSONL
|
|
12
|
+
* records omp's timestamp id, so an id match never fires.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import { mergeOmpAttachments, type StoredMessage } from '@/server/lib/omp/session/merge-stored';
|
|
18
|
+
|
|
19
|
+
const CONTEXT_NOTICE = 'Context window: 1000000 tokens (1% used)';
|
|
20
|
+
|
|
21
|
+
/** What the JSONL yields for a turn whose command wrote no entry. */
|
|
22
|
+
function jsonlTurn(): StoredMessage[] {
|
|
23
|
+
return [
|
|
24
|
+
{ id: 'omp-user-1', role: 'user', content: '/context' },
|
|
25
|
+
{ id: 'omp-ai-1', role: 'ai', content: 'Done.' },
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The chamber's own copy of that same conversation, plus the notice row. */
|
|
30
|
+
function storedWithNotice(): string {
|
|
31
|
+
const stored: StoredMessage[] = [
|
|
32
|
+
{ id: 'msg-1-user', role: 'user', content: '/context' },
|
|
33
|
+
{ id: 'cmdout-1', role: 'ai', content: '', notice: CONTEXT_NOTICE },
|
|
34
|
+
{ id: 'omp-ai-1', role: 'ai', content: 'Done.' },
|
|
35
|
+
];
|
|
36
|
+
return JSON.stringify(stored);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('mergeOmpAttachments notice restoration', () => {
|
|
40
|
+
test('splices a stored notice back under its user turn', () => {
|
|
41
|
+
const merged = mergeOmpAttachments(jsonlTurn(), storedWithNotice());
|
|
42
|
+
|
|
43
|
+
expect(merged.map((m) => m.notice ?? m.content)).toEqual([
|
|
44
|
+
'/context',
|
|
45
|
+
CONTEXT_NOTICE,
|
|
46
|
+
'Done.',
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('leaves a conversation with no stored notices untouched', () => {
|
|
51
|
+
const stored = JSON.stringify([{ id: 'msg-1-user', role: 'user', content: '/context' }]);
|
|
52
|
+
const merged = mergeOmpAttachments(jsonlTurn(), stored);
|
|
53
|
+
expect(merged.some((m) => m.notice !== undefined)).toBe(false);
|
|
54
|
+
expect(merged).toHaveLength(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('does not duplicate a notice the JSONL itself carried', () => {
|
|
58
|
+
const messages: StoredMessage[] = [
|
|
59
|
+
{ id: 'omp-user-1', role: 'user', content: 'run it' },
|
|
60
|
+
{ id: 'omp-notice-1', role: 'ai', content: '', notice: CONTEXT_NOTICE },
|
|
61
|
+
];
|
|
62
|
+
const merged = mergeOmpAttachments(messages, storedWithNotice());
|
|
63
|
+
expect(merged.filter((m) => m.notice === CONTEXT_NOTICE)).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('appends an unanchorable notice instead of dropping it', () => {
|
|
67
|
+
// The stored user turn has no counterpart in the JSONL at all (omp rewrote
|
|
68
|
+
// or pruned it), so there is nothing to anchor to — the row must still show.
|
|
69
|
+
const stored = JSON.stringify([
|
|
70
|
+
{ id: 'msg-x', role: 'user', content: 'a turn the JSONL never recorded' },
|
|
71
|
+
{ id: 'cmdout-x', role: 'ai', content: '', notice: 'Orphan notice' },
|
|
72
|
+
]);
|
|
73
|
+
const merged = mergeOmpAttachments(jsonlTurn(), stored);
|
|
74
|
+
expect(merged.map((m) => m.notice).filter(Boolean)).toContain('Orphan notice');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('tolerates malformed stored JSON', () => {
|
|
78
|
+
const merged = mergeOmpAttachments(jsonlTurn(), '{not json');
|
|
79
|
+
expect(merged).toHaveLength(2);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -42,6 +42,14 @@ export interface StoredMessage {
|
|
|
42
42
|
date?: string;
|
|
43
43
|
timestamp?: string;
|
|
44
44
|
attachments?: StoredAttachment[];
|
|
45
|
+
/**
|
|
46
|
+
* A notice row — omp's own reminder/task-result entries, or the chamber's
|
|
47
|
+
* rendering of a builtin command's output. The JSONL cannot round-trip
|
|
48
|
+
* command output at all: a `/context` or `/usage` turn writes no entry to the
|
|
49
|
+
* session file, so the notice exists ONLY in the live frame, and without this
|
|
50
|
+
* the row vanished on reload.
|
|
51
|
+
*/
|
|
52
|
+
notice?: string;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
function isRawComposerInput(content: string): boolean {
|
|
@@ -158,7 +166,7 @@ export function mergeOmpAttachments(
|
|
|
158
166
|
byContent.set(m.content, m.attachments);
|
|
159
167
|
}
|
|
160
168
|
}
|
|
161
|
-
|
|
169
|
+
const merged2 = merged.map((m) => {
|
|
162
170
|
if (m.role !== 'user' || m.attachments?.length) return m;
|
|
163
171
|
// The JSONL content may carry inlined text-file blocks appended to the
|
|
164
172
|
// original prompt, so match by prefix instead of exact equality.
|
|
@@ -168,4 +176,65 @@ export function mergeOmpAttachments(
|
|
|
168
176
|
)?.[1];
|
|
169
177
|
return atts ? { ...m, attachments: atts } : m;
|
|
170
178
|
});
|
|
179
|
+
return spliceStoredNotices(merged2, stored);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Re-attach notice rows the chamber stored but the JSONL cannot carry.
|
|
184
|
+
*
|
|
185
|
+
* A builtin slash command writes NOTHING to the session file — omp answers it
|
|
186
|
+
* on the command path and the output lives only in the `command_output` frame —
|
|
187
|
+
* so a `/context` or `/usage` row disappeared on reload, leaving a timeline that
|
|
188
|
+
* had forgotten a command the user just ran. The chamber's own copy keeps them.
|
|
189
|
+
*
|
|
190
|
+
* Placement is by the USER TURN that produced them, never by id: the chamber's
|
|
191
|
+
* optimistic user row carries a `msg-…-user` id while the JSONL records omp's
|
|
192
|
+
* own timestamp id, so the two never match. The stored list is walked backwards
|
|
193
|
+
* to the nearest user message, and that message is located in the merged list
|
|
194
|
+
* with the same content relation the attachment merge uses (omp rewrites the
|
|
195
|
+
* delivered prompt, so equality alone would miss it). A notice whose anchor
|
|
196
|
+
* cannot be resolved is appended at the end rather than dropped — losing it
|
|
197
|
+
* again is the bug being fixed.
|
|
198
|
+
*/
|
|
199
|
+
function spliceStoredNotices(merged: StoredMessage[], stored: StoredMessage[]): StoredMessage[] {
|
|
200
|
+
const storedNotices = stored
|
|
201
|
+
.map((message, index) => ({ message, index }))
|
|
202
|
+
.filter(({ message }) => message?.role !== 'user' && typeof message?.notice === 'string' && message.notice.trim());
|
|
203
|
+
if (storedNotices.length === 0) return merged;
|
|
204
|
+
|
|
205
|
+
// Already present (a notice the JSONL itself carried, e.g. a task result):
|
|
206
|
+
// matching on the text keeps the JSONL's own row authoritative.
|
|
207
|
+
const present = new Set(merged.map((m) => (typeof m.notice === 'string' ? m.notice : '')).filter(Boolean));
|
|
208
|
+
|
|
209
|
+
const mergedUsers = merged
|
|
210
|
+
.map((message, index) => ({ message, index }))
|
|
211
|
+
.filter(({ message }) => message.role === 'user');
|
|
212
|
+
|
|
213
|
+
const insertions = new Map<number, StoredMessage[]>();
|
|
214
|
+
for (const { message, index } of storedNotices) {
|
|
215
|
+
if (present.has(message.notice as string)) continue;
|
|
216
|
+
let anchor = merged.length;
|
|
217
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
218
|
+
const candidate = stored[i];
|
|
219
|
+
if (candidate?.role !== 'user' || typeof candidate.content !== 'string') continue;
|
|
220
|
+
const hit = mergedUsers.find(({ message: user }) =>
|
|
221
|
+
user.id !== undefined && candidate.id !== undefined && user.id === candidate.id,
|
|
222
|
+
) ?? mergedUsers.find(({ message: user }) => userTurnsRelate(user.content, candidate.content));
|
|
223
|
+
if (hit) {
|
|
224
|
+
anchor = hit.index + 1;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const group = insertions.get(anchor);
|
|
229
|
+
if (group) group.push(message);
|
|
230
|
+
else insertions.set(anchor, [message]);
|
|
231
|
+
}
|
|
232
|
+
if (insertions.size === 0) return merged;
|
|
233
|
+
|
|
234
|
+
const next = [...merged];
|
|
235
|
+
for (const anchor of [...insertions.keys()].sort((a, b) => b - a)) {
|
|
236
|
+
const group = insertions.get(anchor);
|
|
237
|
+
if (group) next.splice(anchor, 0, ...group);
|
|
238
|
+
}
|
|
239
|
+
return next;
|
|
171
240
|
}
|
|
@@ -235,6 +235,32 @@ export async function loadSessionModel(filePath: string): Promise<{ provider: st
|
|
|
235
235
|
* `thinking_level_change` entries (omp records the level string, e.g.
|
|
236
236
|
* "off" | "minimal" | "low" | "medium" | "high" | "max"). */
|
|
237
237
|
export async function loadSessionThinkingLevel(filePath: string): Promise<string | undefined> {
|
|
238
|
+
return readThinkingSelector(filePath, (record) => normalizeThinkingLevel(record.thinkingLevel));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The thinking SELECTOR a session is configured with — `auto` when automatic
|
|
243
|
+
* per-turn classification is on, else the concrete level.
|
|
244
|
+
*
|
|
245
|
+
* omp writes both fields on one entry (`thinkingLevel` = the provisional
|
|
246
|
+
* concrete effort, `configured` = the selector) and restores with
|
|
247
|
+
* `parseConfiguredThinkingLevel(configured) ?? parseThinkingLevel(level)`, so a
|
|
248
|
+
* reader that takes `thinkingLevel` alone turns an `auto` session into a pinned
|
|
249
|
+
* one. Anything spawning a child that must classify its turns the way the
|
|
250
|
+
* session does has to read this, not the concrete level.
|
|
251
|
+
*/
|
|
252
|
+
export async function loadSessionThinkingSelector(filePath: string): Promise<string | undefined> {
|
|
253
|
+
return readThinkingSelector(filePath, (record) =>
|
|
254
|
+
typeof record.configured === 'string' && record.configured
|
|
255
|
+
? record.configured
|
|
256
|
+
: normalizeThinkingLevel(record.thinkingLevel),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function readThinkingSelector(
|
|
261
|
+
filePath: string,
|
|
262
|
+
read: (record: Record<string, unknown>) => string,
|
|
263
|
+
): Promise<string | undefined> {
|
|
238
264
|
try {
|
|
239
265
|
const file = Bun.file(filePath);
|
|
240
266
|
if ((await file.stat()).size > MAX_SESSION_LOAD_BYTES) return undefined;
|
|
@@ -247,8 +273,7 @@ export async function loadSessionThinkingLevel(filePath: string): Promise<string
|
|
|
247
273
|
let last: string | undefined;
|
|
248
274
|
for (const record of records) {
|
|
249
275
|
if (record?.type !== 'thinking_level_change') continue;
|
|
250
|
-
|
|
251
|
-
last = normalizeThinkingLevel(record.thinkingLevel);
|
|
276
|
+
last = read(record);
|
|
252
277
|
}
|
|
253
278
|
return last;
|
|
254
279
|
} catch {
|
|
@@ -60,6 +60,19 @@ export async function loadOmpSidebarData(): Promise<OmpSidebarData> {
|
|
|
60
60
|
return slot.inFlight;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Drop the cached dataset so the next load re-scans disk. For a mutation the
|
|
65
|
+
* TTL cannot see: deleting a session removes a row rather than changing one, so
|
|
66
|
+
* nothing in the cached snapshot is stale — it is simply wrong, and the refresh
|
|
67
|
+
* that follows the delete would otherwise serve it back for up to the TTL.
|
|
68
|
+
*/
|
|
69
|
+
export function invalidateOmpSidebarData(): void {
|
|
70
|
+
const slot = globalThis.__ompChamberSidebarDataCache;
|
|
71
|
+
if (!slot) return;
|
|
72
|
+
slot.data = undefined as unknown as OmpSidebarData;
|
|
73
|
+
slot.expiresAt = 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
/** Resolve each unique cwd to its project root; bounded concurrency so 100+
|
|
64
77
|
* unique cwds don't spawn 100 parallel git processes. */
|
|
65
78
|
async function resolveRootsByCwd(cwds: string[]): Promise<Map<string, string>> {
|
|
@@ -5,8 +5,9 @@ import { WebRpcError, getRpcSession, resolveSpawnCwd, startRpcSession, type Agen
|
|
|
5
5
|
import { getSpawnApprovalMode, reconcileSpawnApprovalMode } from '@/server/lib/omp/rpc/session-registry';
|
|
6
6
|
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
7
7
|
import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
|
|
8
|
-
import { OBSERVER_ONLY_COMMANDS } from '@/server/lib/omp/rpc/constants';
|
|
8
|
+
import { CONVERSATION_MOVING_COMMANDS, OBSERVER_ONLY_COMMANDS } from '@/server/lib/omp/rpc/constants';
|
|
9
9
|
import { rpcErrorResponse } from '@/server/lib/omp/rpc/errors';
|
|
10
|
+
import { assertBtwIdle } from '@/server/lib/btw/service.server';
|
|
10
11
|
|
|
11
12
|
// POST /api/agent/:sessionId — send a command to an existing session (or spawn
|
|
12
13
|
// it lazily). Mirrors omp-web's /api/agent/[id].
|
|
@@ -28,6 +29,13 @@ export async function sendCommand({ params, request }: ActionFunctionArgs) {
|
|
|
28
29
|
// default. omp has no RPC to change the mode after spawn.
|
|
29
30
|
const explicitMode = isApprovalMode(body.accessMode) ? body.accessMode : null;
|
|
30
31
|
|
|
32
|
+
// A command that moves the conversation is refused while a side question is
|
|
33
|
+
// in flight on this session — the question's snapshot describes the
|
|
34
|
+
// transcript this command would replace (omp blocks the same operations).
|
|
35
|
+
if (CONVERSATION_MOVING_COMMANDS.has(body.type)) {
|
|
36
|
+
await assertBtwIdle(sessionId, body.type.replace(/_/g, ' '));
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
// Fast path: already-running session.
|
|
32
40
|
const existing = getRpcSession(sessionId);
|
|
33
41
|
if (existing?.isAlive()) {
|
|
@@ -14,25 +14,15 @@
|
|
|
14
14
|
|
|
15
15
|
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
16
16
|
import { toImageContents, validateAgentImages } from '@/server/lib/omp/rpc/constants';
|
|
17
|
-
import { isApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
18
17
|
import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
|
|
19
18
|
import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
|
|
20
|
-
import {
|
|
21
|
-
abortBtw,
|
|
22
|
-
askBtw,
|
|
23
|
-
getBtwState,
|
|
24
|
-
promoteBtw,
|
|
25
|
-
removeBtw,
|
|
26
|
-
respondBtwDialog,
|
|
27
|
-
setBtwApprovalMode,
|
|
28
|
-
setBtwModel,
|
|
29
|
-
setBtwThinkingLevel,
|
|
30
|
-
} from '@/server/lib/btw/service.server';
|
|
19
|
+
import { abortBtw, askBtw, getBtwState, promoteBtw, removeBtw } from '@/server/lib/btw/service.server';
|
|
31
20
|
|
|
32
21
|
/** Actions that can reject for a reason the client should show as-is. */
|
|
33
22
|
const STATUS_BY_CODE: Record<string, number> = {
|
|
34
23
|
btw_busy: 409,
|
|
35
24
|
btw_stale: 409,
|
|
25
|
+
btw_multi_turn: 409,
|
|
36
26
|
session_not_found: 404,
|
|
37
27
|
btw_topic_not_found: 404,
|
|
38
28
|
};
|
|
@@ -71,20 +61,6 @@ function readTextFiles(raw: unknown): AttachedTextFileData[] {
|
|
|
71
61
|
});
|
|
72
62
|
}
|
|
73
63
|
|
|
74
|
-
/**
|
|
75
|
-
* The response body for one dialog, in the shape omp's RPC accepts: an answer
|
|
76
|
-
* value, a confirmation, or a cancellation. Anything else is refused rather
|
|
77
|
-
* than forwarded — a malformed response would leave the child blocked.
|
|
78
|
-
*/
|
|
79
|
-
function readDialogResponse(raw: unknown): Record<string, unknown> | null {
|
|
80
|
-
if (!raw || typeof raw !== 'object') return null;
|
|
81
|
-
const response = raw as { value?: unknown; confirmed?: unknown; cancelled?: unknown };
|
|
82
|
-
if (response.cancelled === true) return { cancelled: true };
|
|
83
|
-
if (typeof response.confirmed === 'boolean') return { confirmed: response.confirmed };
|
|
84
|
-
if (typeof response.value === 'string') return { value: response.value };
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
64
|
export async function action({ params, request }: ActionFunctionArgs) {
|
|
89
65
|
const { sessionId } = params;
|
|
90
66
|
if (!sessionId) return json({ error: 'session id is required' }, { status: 400 });
|
|
@@ -100,8 +76,6 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
|
|
100
76
|
const question = typeof body?.question === 'string' ? body.question : '';
|
|
101
77
|
const parsed = readImages(body?.images);
|
|
102
78
|
if ('error' in parsed) return json({ error: parsed.error, code: 'invalid_images' }, { status: 400 });
|
|
103
|
-
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
104
|
-
const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
|
|
105
79
|
return json({
|
|
106
80
|
success: true,
|
|
107
81
|
data: await askBtw(sessionId, {
|
|
@@ -109,9 +83,6 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
|
|
109
83
|
question,
|
|
110
84
|
images: parsed.images,
|
|
111
85
|
textFiles: readTextFiles(body?.textFiles),
|
|
112
|
-
...(provider && modelId ? { model: { provider, id: modelId } } : {}),
|
|
113
|
-
...(typeof body?.thinkingLevel === 'string' ? { thinkingLevel: body.thinkingLevel } : {}),
|
|
114
|
-
...(isApprovalMode(body?.accessMode) ? { approvalMode: body.accessMode } : {}),
|
|
115
86
|
}),
|
|
116
87
|
});
|
|
117
88
|
}
|
|
@@ -119,34 +90,6 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
|
|
119
90
|
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
120
91
|
return json({ success: true, data: await abortBtw(sessionId, topicId) });
|
|
121
92
|
}
|
|
122
|
-
case 'set_model': {
|
|
123
|
-
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
124
|
-
const provider = typeof body?.provider === 'string' ? body.provider : '';
|
|
125
|
-
const modelId = typeof body?.modelId === 'string' ? body.modelId : '';
|
|
126
|
-
if (!provider || !modelId) return json({ error: 'provider and modelId are required', code: 'model_required' }, { status: 400 });
|
|
127
|
-
return json({ success: true, data: await setBtwModel(sessionId, topicId, provider, modelId) });
|
|
128
|
-
}
|
|
129
|
-
case 'set_thinking_level': {
|
|
130
|
-
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
131
|
-
const level = typeof body?.level === 'string' ? body.level : '';
|
|
132
|
-
if (!level) return json({ error: 'level is required', code: 'level_required' }, { status: 400 });
|
|
133
|
-
return json({ success: true, data: await setBtwThinkingLevel(sessionId, topicId, level) });
|
|
134
|
-
}
|
|
135
|
-
case 'set_access_mode': {
|
|
136
|
-
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
137
|
-
if (!isApprovalMode(body?.accessMode)) {
|
|
138
|
-
return json({ error: 'accessMode must be always-ask, write or yolo', code: 'invalid_access_mode' }, { status: 400 });
|
|
139
|
-
}
|
|
140
|
-
return json({ success: true, data: await setBtwApprovalMode(sessionId, topicId, body.accessMode) });
|
|
141
|
-
}
|
|
142
|
-
case 'dialog_response': {
|
|
143
|
-
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
144
|
-
const id = typeof body?.id === 'string' ? body.id : '';
|
|
145
|
-
if (!id) return json({ error: 'id is required', code: 'dialog_required' }, { status: 400 });
|
|
146
|
-
const response = readDialogResponse(body?.response);
|
|
147
|
-
if (!response) return json({ error: 'a response value is required', code: 'dialog_required' }, { status: 400 });
|
|
148
|
-
return json({ success: true, data: await respondBtwDialog(sessionId, topicId, id, response) });
|
|
149
|
-
}
|
|
150
93
|
case 'promote': {
|
|
151
94
|
if (!topicId) return json({ error: 'topicId is required', code: 'topic_required' }, { status: 400 });
|
|
152
95
|
return json({ success: true, data: await promoteBtw(sessionId, topicId) });
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The delete route's refusal branches.
|
|
8
|
+
*
|
|
9
|
+
* Each one guards a different failure, and two of them are about blast radius
|
|
10
|
+
* rather than tidiness: the id names a directory under the sessions root and
|
|
11
|
+
* another under the btw root, so a value that is not one segment deletes far
|
|
12
|
+
* more than the session it names (`.` normalized to the btw root itself and
|
|
13
|
+
* wiped every session's side questions — see `fs/path-segment`).
|
|
14
|
+
*
|
|
15
|
+
* The route is called directly, as the other route tests do: these are pure
|
|
16
|
+
* request-shape checks that must not reach the filesystem or spawn an omp
|
|
17
|
+
* child, and asserting that is half the point.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
21
|
+
import fs from 'fs';
|
|
22
|
+
import path from 'path';
|
|
23
|
+
import { deleteSession } from '@/server/routes/sessions/delete';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The 404 branch resolves the id by scanning the sessions directory, so the
|
|
27
|
+
* agent dir is pointed at an empty temp tree. Without this the test walks the
|
|
28
|
+
* developer's real `~/.omp/agent/sessions` (read-only, but seconds of I/O and a
|
|
29
|
+
* result that depends on what happens to be installed).
|
|
30
|
+
*/
|
|
31
|
+
const AGENT_DIR = '/tmp/omc-delete-route-test/agent';
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
fs.rmSync(AGENT_DIR, { recursive: true, force: true });
|
|
35
|
+
fs.mkdirSync(path.join(AGENT_DIR, 'sessions'), { recursive: true });
|
|
36
|
+
Bun.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
delete Bun.env.PI_CODING_AGENT_DIR;
|
|
41
|
+
fs.rmSync('/tmp/omc-delete-route-test', { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function del(sessionId: string): Promise<Response> {
|
|
45
|
+
return deleteSession({
|
|
46
|
+
request: new Request(`http://localhost/api/sessions/${sessionId}`, { method: 'DELETE' }),
|
|
47
|
+
params: { sessionId },
|
|
48
|
+
} as never) as Promise<Response>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('deleteSession refusals', () => {
|
|
52
|
+
test('a pending chat is refused — nothing is stored for it yet', async () => {
|
|
53
|
+
const res = await del('new-1758800000000');
|
|
54
|
+
expect(res.status).toBe(400);
|
|
55
|
+
expect(await res.json()).toMatchObject({ code: 'session_pending' });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('`.` is refused instead of wiping the btw root', async () => {
|
|
59
|
+
const res = await del('.');
|
|
60
|
+
expect(res.status).toBe(400);
|
|
61
|
+
expect(await res.json()).toMatchObject({ error: 'Invalid session id' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('a traversal is refused', async () => {
|
|
65
|
+
for (const id of ['..', '../x', 'a/b', '..\\a']) {
|
|
66
|
+
expect((await del(id)).status).toBe(400);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('an unknown but well-formed id is a 404, not a 400', async () => {
|
|
71
|
+
// Distinct from the branches above: the request is valid, the session is
|
|
72
|
+
// not there. Collapsing the two would report a typo as a malformed request.
|
|
73
|
+
const res = await del('00000000-0000-0000-0000-000000000000');
|
|
74
|
+
expect(res.status).toBe(404);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a wrong verb on the path is a 405', async () => {
|
|
78
|
+
const res = (await deleteSession({
|
|
79
|
+
request: new Request('http://localhost/api/sessions/x', { method: 'GET' }),
|
|
80
|
+
params: { sessionId: 'x' },
|
|
81
|
+
} as never)) as Response;
|
|
82
|
+
expect(res.status).toBe(405);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* DELETE /api/sessions/:sessionId — remove a session and everything keyed to
|
|
8
|
+
* it. The mirror of `archiveSession`: archive is reversible and keeps the
|
|
9
|
+
* transcript, this one is the destructive path the user opts into from the
|
|
10
|
+
* sidebar's trash button.
|
|
11
|
+
*
|
|
12
|
+
* Order matters and is enforced here, not by the callers:
|
|
13
|
+
* 1. refuse a session whose omp child is mid-turn (a run, a live subagent, or
|
|
14
|
+
* a dialog the agent is blocked on) — the delete would kill work the user
|
|
15
|
+
* can still see, and omp would flush the file back on shutdown anyway;
|
|
16
|
+
* 2. destroy the child and AWAIT its exit — omp writes session state on
|
|
17
|
+
* shutdown and would otherwise recreate the transcript just removed;
|
|
18
|
+
* 3. purge side questions, then the transcript, artifacts and chamber rows.
|
|
19
|
+
*
|
|
20
|
+
* A pending `new-…` session is refused: it is a client-side placeholder with
|
|
21
|
+
* nothing stored anywhere yet, so deleting it is the same as navigating away.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { json } from '@/server/lib/remix-compat';
|
|
25
|
+
import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
|
|
26
|
+
import { methodNotAllowed } from '@/server/lib/route-adapter';
|
|
27
|
+
import { getRpcSession } from '@/server/lib/omp/rpc/session-registry';
|
|
28
|
+
import { purgeSessionData } from '@/server/lib/omp/session/delete.server';
|
|
29
|
+
import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
|
|
30
|
+
import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
|
|
31
|
+
import { isMockMode } from '@/server/mock.server';
|
|
32
|
+
|
|
33
|
+
export async function deleteSession({ request, params }: ActionFunctionArgs) {
|
|
34
|
+
if (request.method !== 'DELETE') {
|
|
35
|
+
return methodNotAllowed({ request, params });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sessionId = params.sessionId;
|
|
39
|
+
if (!sessionId) return json({ error: 'Missing session id' }, { status: 400 });
|
|
40
|
+
if (sessionId.startsWith('new-')) {
|
|
41
|
+
return json({ error: 'This chat has not been created yet.', code: 'session_pending' }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
// The id names a directory under the sessions root and another under the btw
|
|
44
|
+
// root, so it must be exactly one path segment — see `isSinglePathSegment`
|
|
45
|
+
// for the `.` case that made this load-bearing. Both purges re-check it
|
|
46
|
+
// independently; refusing here is what turns the attempt into a 400.
|
|
47
|
+
if (!isSinglePathSegment(sessionId)) {
|
|
48
|
+
return json({ error: 'Invalid session id' }, { status: 400 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A live child is the only other writer of the transcript. Busy means a turn,
|
|
52
|
+
// a live subagent, or a dialog omp is parked on — all things the user can
|
|
53
|
+
// still act on, so the delete is refused with a reason rather than silently
|
|
54
|
+
// taking them down.
|
|
55
|
+
const live = getRpcSession(sessionId);
|
|
56
|
+
if (live?.isAlive()) {
|
|
57
|
+
if (live.isBusy()) {
|
|
58
|
+
return json(
|
|
59
|
+
{ error: 'This session is working. Stop the run before deleting it.', code: 'session_busy' },
|
|
60
|
+
{ status: 409 },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
await live.destroyAndWait();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Side questions live outside the session file and are removed first: their
|
|
67
|
+
// children must be gone before the parent transcript they were snapshotted
|
|
68
|
+
// from disappears.
|
|
69
|
+
const btwTopicsRemoved = isMockMode() ? 0 : await purgeBtwForSession(sessionId);
|
|
70
|
+
const purged = await purgeSessionData(sessionId);
|
|
71
|
+
|
|
72
|
+
if (!purged.found) {
|
|
73
|
+
return json({ error: 'Session not found' }, { status: 404 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return json({
|
|
77
|
+
success: true,
|
|
78
|
+
sessionId,
|
|
79
|
+
fileRemoved: purged.fileRemoved,
|
|
80
|
+
artifactsRemoved: purged.artifactsRemoved,
|
|
81
|
+
btwTopicsRemoved,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -10,10 +10,15 @@ import {
|
|
|
10
10
|
renameSession,
|
|
11
11
|
} from '@/server/routes/sessions/session';
|
|
12
12
|
import { listSubagents, readSubagentTranscript } from '@/server/routes/sessions/subagents';
|
|
13
|
+
import { deleteSession } from '@/server/routes/sessions/delete';
|
|
13
14
|
|
|
14
15
|
export const sessionsBindings: HandlerBinding[] = [
|
|
15
16
|
...bindingsFor(sessionsList, '/api/sessions/list'),
|
|
16
17
|
...bindingsFor(sessionsFolder, '/api/sessions/:sessionId'),
|
|
18
|
+
// DELETE-only on the same path the folder loader answers GET on: mounting it
|
|
19
|
+
// through `actionBindings` would register a 405 GET fallback that Elysia
|
|
20
|
+
// applies after the loader, replacing the workspace listing with it.
|
|
21
|
+
{ method: 'DELETE', path: '/api/sessions/:sessionId', handler: deleteSession },
|
|
17
22
|
...actionBindings(archiveSession, '/api/sessions/:sessionId/archive'),
|
|
18
23
|
// Per-item queue routes. `actionBindings` mounts ALL four mutating verbs on
|
|
19
24
|
// one path, and Elysia lets a later registration override an earlier one —
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
|
|
16
16
|
import type { AgentImage, BtwFrame, BtwState, StreamTransport } from '@/shared/types';
|
|
17
17
|
import type { AttachedTextFileData } from '@/shared/lib/chat/attachments';
|
|
18
|
-
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
19
18
|
import { connectEvents } from '@/shared/lib/chat/omp/sse';
|
|
20
19
|
import { connectSocket } from '@/shared/lib/chat/omp/socket';
|
|
21
20
|
import { btwEventsUrl, btwSocketUrl } from '@/shared/lib/chat/omp/transport';
|
|
@@ -74,11 +73,6 @@ export interface AskBtwInput {
|
|
|
74
73
|
images?: AgentImage[];
|
|
75
74
|
/** Text files inlined for this question (read at attach time by the composer). */
|
|
76
75
|
textFiles?: AttachedTextFileData[];
|
|
77
|
-
/** Composer picks for a FIRST question — a topic that does not exist yet has
|
|
78
|
-
* no row to hold them, so they ride the ask and reach the spawn. */
|
|
79
|
-
model?: { provider: string; id: string };
|
|
80
|
-
thinkingLevel?: string;
|
|
81
|
-
approvalMode?: ApprovalMode;
|
|
82
76
|
}
|
|
83
77
|
|
|
84
78
|
export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<BtwState> {
|
|
@@ -88,9 +82,6 @@ export function askBtwQuestion(sessionId: string, input: AskBtwInput): Promise<B
|
|
|
88
82
|
...(input.topicId ? { topicId: input.topicId } : {}),
|
|
89
83
|
...(input.images?.length ? { images: input.images } : {}),
|
|
90
84
|
...(input.textFiles?.length ? { textFiles: input.textFiles } : {}),
|
|
91
|
-
...(input.model ? { provider: input.model.provider, modelId: input.model.id } : {}),
|
|
92
|
-
...(input.thinkingLevel ? { thinkingLevel: input.thinkingLevel } : {}),
|
|
93
|
-
...(input.approvalMode ? { accessMode: input.approvalMode } : {}),
|
|
94
85
|
});
|
|
95
86
|
}
|
|
96
87
|
|
|
@@ -98,27 +89,6 @@ export function abortBtwQuestion(sessionId: string, topicId: string): Promise<Bt
|
|
|
98
89
|
return requestBtw<BtwState>(sessionId, 'POST', { action: 'abort', topicId });
|
|
99
90
|
}
|
|
100
91
|
|
|
101
|
-
export function setBtwModel(sessionId: string, topicId: string, provider: string, modelId: string): Promise<BtwState> {
|
|
102
|
-
return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_model', topicId, provider, modelId });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function setBtwThinkingLevel(sessionId: string, topicId: string, level: string): Promise<BtwState> {
|
|
106
|
-
return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_thinking_level', topicId, level });
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function setBtwAccessMode(sessionId: string, topicId: string, accessMode: ApprovalMode): Promise<BtwState> {
|
|
110
|
-
return requestBtw<BtwState>(sessionId, 'POST', { action: 'set_access_mode', topicId, accessMode });
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function respondBtwDialog(
|
|
114
|
-
sessionId: string,
|
|
115
|
-
topicId: string,
|
|
116
|
-
id: string,
|
|
117
|
-
response: { value: string } | { confirmed: boolean } | { cancelled: true },
|
|
118
|
-
): Promise<BtwState> {
|
|
119
|
-
return requestBtw<BtwState>(sessionId, 'POST', { action: 'dialog_response', topicId, id, response });
|
|
120
|
-
}
|
|
121
|
-
|
|
122
92
|
export function promoteBtwTopic(sessionId: string, topicId: string): Promise<{ sessionId: string }> {
|
|
123
93
|
return requestBtw<{ sessionId: string }>(sessionId, 'POST', { action: 'promote', topicId });
|
|
124
94
|
}
|