ompchamber 3.3.3 → 3.4.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 +275 -268
- package/dist/client/static/js/{index-8ejde0b7.js → index-h05z90mw.js} +581 -581
- package/package.json +1 -1
- 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/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/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/omp/rpc/constants.ts +10 -0
- package/src/server/lib/omp/rpc/session-commands.ts +45 -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/routes/agent/command.ts +9 -1
- package/src/server/routes/btw/topics.ts +2 -59
- 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/types/btw.ts +13 -34
|
@@ -15,6 +15,7 @@ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
|
|
|
15
15
|
import { scheduleQueueDelivery } from '@/server/lib/queue/delivery.server';
|
|
16
16
|
import { clearStreamStatus, markStreamStatus } from '@/shared/lib/omp/session/stream-state.server';
|
|
17
17
|
import { buildWebState, type WebStateHost } from '@/server/lib/omp/rpc/web-state';
|
|
18
|
+
import { isTuiOnlySlashCommand, tuiOnlyCommandNotice } from '@/shared/lib/chat/composer/tui-only';
|
|
18
19
|
|
|
19
20
|
/** Runtime surface AgentSessionWrapper exposes to the command dispatcher. */
|
|
20
21
|
export interface SessionCommandHost extends WebStateHost {
|
|
@@ -50,6 +51,44 @@ async function settleCommandTimeout(host: SessionCommandHost): Promise<never> {
|
|
|
50
51
|
throw new WebRpcError('The OMP session stopped responding and was reset.', 'session_unresponsive');
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Refuse a prompt that invokes a command omp implements only in its TUI, and
|
|
56
|
+
* report it the way omp reports a real command result.
|
|
57
|
+
*
|
|
58
|
+
* This is the SERVER half of the guard in
|
|
59
|
+
* `client/hooks/chat/timeline/tui-only-guard.ts`. It exists because one prompt
|
|
60
|
+
* path never passes through the composer: the follow-up queue's auto-delivery
|
|
61
|
+
* calls `session.send({ type: 'prompt' })` directly from
|
|
62
|
+
* `lib/queue/delivery.server.ts`. A `/plan` queued before a run would otherwise
|
|
63
|
+
* be delivered to the model as literal text once the run ends.
|
|
64
|
+
*
|
|
65
|
+
* The refusal is framed as a real command result — a `command_output` notice
|
|
66
|
+
* plus, for a non-streaming prompt, the `agentInvoked:false` ack — so the
|
|
67
|
+
* client's existing fold renders the notice and settles the optimistic spinner
|
|
68
|
+
* with no new protocol.
|
|
69
|
+
*
|
|
70
|
+
* `streaming` is the caller's own knowledge of whether a turn is running: a
|
|
71
|
+
* steer of a refused command must not clear the flags of the turn it was aimed
|
|
72
|
+
* at (omp runs `session.steer()` with no slash handling at all, so a steer of
|
|
73
|
+
* `/plan` is exactly as meaningless as a fresh one).
|
|
74
|
+
*
|
|
75
|
+
* Returns true when the prompt was refused (the caller must not dispatch).
|
|
76
|
+
*/
|
|
77
|
+
function refuseTuiOnlyPrompt(host: SessionCommandHost, message: unknown, streaming: boolean): boolean {
|
|
78
|
+
if (typeof message !== 'string' || !isTuiOnlySlashCommand(message)) return false;
|
|
79
|
+
host.emit({ type: 'command_output', text: tuiOnlyCommandNotice(message) });
|
|
80
|
+
if (!streaming) {
|
|
81
|
+
host.promptRunning = false;
|
|
82
|
+
host.awaitingAgentStart = false;
|
|
83
|
+
host.awaitingAgentStartDeadline = 0;
|
|
84
|
+
host.emit({ type: 'prompt_result', agentInvoked: false });
|
|
85
|
+
// Nothing ran, so the queue may hold the next item — give it the same
|
|
86
|
+
// delivery window a real consumed builtin or a run end would.
|
|
87
|
+
scheduleQueueDelivery(host);
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
53
92
|
export async function dispatchSessionCommand(host: SessionCommandHost, command: Record<string, unknown>): Promise<unknown> {
|
|
54
93
|
if (host.restarting) throw new WebRpcError(RESTARTING_MESSAGE, 'session_restarting');
|
|
55
94
|
if (!host.isAlive()) throw new Error('Session is no longer running');
|
|
@@ -66,6 +105,11 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
|
|
|
66
105
|
if (host.bashRunning) {
|
|
67
106
|
throw new Error('Cannot send a prompt while a shell command is running');
|
|
68
107
|
}
|
|
108
|
+
// Checked before the streaming-behavior split: a steer of a TUI-only
|
|
109
|
+
// command is just as meaningless as a fresh one. `streaming` keeps the
|
|
110
|
+
// refusal from clearing a running turn's flags when the prompt was a
|
|
111
|
+
// steer aimed at that turn.
|
|
112
|
+
if (refuseTuiOnlyPrompt(host, command.message, Boolean(command.streamingBehavior))) return null;
|
|
69
113
|
const streamingBehavior = command.streamingBehavior as 'steer' | 'followUp' | undefined;
|
|
70
114
|
// The live `stream` row starts with the DISPATCH, not with agent_start:
|
|
71
115
|
// the spawn/ack round trip that precedes agent_start must not read as
|
|
@@ -229,6 +273,7 @@ export async function dispatchSessionCommand(host: SessionCommandHost, command:
|
|
|
229
273
|
if (!host.isRunning()) {
|
|
230
274
|
throw new WebRpcError('The session is idle — start a prompt first.', 'session_idle');
|
|
231
275
|
}
|
|
276
|
+
if (refuseTuiOnlyPrompt(host, command.message, true)) return null;
|
|
232
277
|
await host.proc.sendCommand({
|
|
233
278
|
type,
|
|
234
279
|
message: command.message as string,
|
|
@@ -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 {
|
|
@@ -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) });
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* The TUI-only guard. Two failure modes matter and both are silent:
|
|
6
|
+
*
|
|
7
|
+
* - a name omp DOES implement over RPC gets refused, so a working command stops
|
|
8
|
+
* working in the chamber. That is why the table is asserted against omp's own
|
|
9
|
+
* registry rather than trusted, and why the predicate is name-only.
|
|
10
|
+
* - a name omp does NOT implement slips through, which is the bug this module
|
|
11
|
+
* exists for: the prompt reaches the model as literal text and it improvises a
|
|
12
|
+
* turn around the string.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from 'bun:test';
|
|
16
|
+
|
|
17
|
+
import { isTuiOnlySlashCommand, slashCommandName, tuiOnlyCommandNotice } from '@/shared/lib/chat/composer/tui-only';
|
|
18
|
+
|
|
19
|
+
describe('slashCommandName', () => {
|
|
20
|
+
test('reads the token omp would dispatch on', () => {
|
|
21
|
+
expect(slashCommandName('/plan')).toBe('plan');
|
|
22
|
+
expect(slashCommandName('/PLAN')).toBe('plan');
|
|
23
|
+
// Arguments, colon form, and leading whitespace all resolve to the name.
|
|
24
|
+
expect(slashCommandName('/move /tmp/x')).toBe('move');
|
|
25
|
+
expect(slashCommandName('/force:write')).toBe('force');
|
|
26
|
+
expect(slashCommandName(' /clear')).toBe('clear');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('a slash that is not a command is not a name', () => {
|
|
30
|
+
expect(slashCommandName('fix a/b')).toBeNull();
|
|
31
|
+
expect(slashCommandName('see /tmp/foo')).toBeNull();
|
|
32
|
+
expect(slashCommandName('prose\n/plan')).toBeNull();
|
|
33
|
+
expect(slashCommandName('/')).toBeNull();
|
|
34
|
+
expect(slashCommandName('')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('isTuiOnlySlashCommand', () => {
|
|
39
|
+
test('refuses the commands omp only implements in its TUI', () => {
|
|
40
|
+
for (const command of ['/plan', '/clear', '/new', '/login', '/hotkeys', '/tree', '/resume', '/quit', '/exit']) {
|
|
41
|
+
expect(isTuiOnlySlashCommand(command)).toBe(true);
|
|
42
|
+
}
|
|
43
|
+
// Aliases count: omp resolves these to the same TUI-only entry.
|
|
44
|
+
expect(isTuiOnlySlashCommand('/providers')).toBe(true);
|
|
45
|
+
expect(isTuiOnlySlashCommand('/q')).toBe(true);
|
|
46
|
+
expect(isTuiOnlySlashCommand('/status')).toBe(true);
|
|
47
|
+
expect(isTuiOnlySlashCommand('/rewind')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('lets every command omp answers over RPC through', () => {
|
|
51
|
+
// Sampled from the 42 entries carrying a text-mode `handle`; a false
|
|
52
|
+
// positive here silently breaks a working command.
|
|
53
|
+
for (const command of [
|
|
54
|
+
'/model',
|
|
55
|
+
'/context',
|
|
56
|
+
'/usage',
|
|
57
|
+
'/compact',
|
|
58
|
+
'/todo',
|
|
59
|
+
'/mcp list',
|
|
60
|
+
'/rename x',
|
|
61
|
+
'/move /tmp',
|
|
62
|
+
'/session info',
|
|
63
|
+
'/skill:diagnose',
|
|
64
|
+
'/review',
|
|
65
|
+
]) {
|
|
66
|
+
expect(isTuiOnlySlashCommand(command)).toBe(false);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('does NOT refuse a command the chamber answers itself', () => {
|
|
71
|
+
// `/btw` is TUI-only in omp, but the chamber owns it: the composer opens
|
|
72
|
+
// the side-question panel. Refusing it here broke a working feature.
|
|
73
|
+
expect(isTuiOnlySlashCommand('/btw')).toBe(false);
|
|
74
|
+
expect(isTuiOnlySlashCommand('/btw how does this work')).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('ordinary prose is never a command', () => {
|
|
78
|
+
expect(isTuiOnlySlashCommand('explain the plan')).toBe(false);
|
|
79
|
+
expect(isTuiOnlySlashCommand('what does /clear do?')).toBe(false);
|
|
80
|
+
expect(isTuiOnlySlashCommand('')).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('tuiOnlyCommandNotice', () => {
|
|
85
|
+
test('names the command and says where it works', () => {
|
|
86
|
+
const notice = tuiOnlyCommandNotice('/plan do the thing');
|
|
87
|
+
expect(notice).toContain('`/plan`');
|
|
88
|
+
expect(notice).toContain('omp terminal only');
|
|
89
|
+
});
|
|
90
|
+
});
|