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.
Files changed (55) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +275 -268
  3. package/dist/client/static/js/{index-8ejde0b7.js → index-h05z90mw.js} +581 -581
  4. package/package.json +1 -1
  5. package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
  6. package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
  7. package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
  8. package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
  9. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
  10. package/src/client/components/workspace/btw-panel/index.tsx +20 -27
  11. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
  12. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
  13. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
  14. package/src/client/hooks/chat/btw/index.ts +7 -73
  15. package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
  16. package/src/client/hooks/chat/btw/intercept.ts +5 -2
  17. package/src/client/hooks/chat/btw/mode.ts +16 -76
  18. package/src/client/hooks/chat/timeline/actions.ts +22 -30
  19. package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
  20. package/src/client/hooks/chat/timeline/send.ts +8 -1
  21. package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
  22. package/src/server/lib/btw/child.server.test.ts +68 -0
  23. package/src/server/lib/btw/child.server.ts +17 -16
  24. package/src/server/lib/btw/frames.test.ts +3 -3
  25. package/src/server/lib/btw/frames.ts +1 -1
  26. package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
  27. package/src/server/lib/btw/lifecycle.server.ts +41 -31
  28. package/src/server/lib/btw/prompt.ts +7 -11
  29. package/src/server/lib/btw/registry.server.ts +1 -9
  30. package/src/server/lib/btw/reply.ts +54 -0
  31. package/src/server/lib/btw/runtime.server.test.ts +128 -0
  32. package/src/server/lib/btw/runtime.server.ts +77 -86
  33. package/src/server/lib/btw/schema.server.test.ts +84 -0
  34. package/src/server/lib/btw/schema.server.ts +8 -0
  35. package/src/server/lib/btw/service.server.ts +47 -78
  36. package/src/server/lib/btw/store.server.ts +9 -20
  37. package/src/server/lib/omp/rpc/constants.ts +10 -0
  38. package/src/server/lib/omp/rpc/session-commands.ts +45 -0
  39. package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +70 -1
  41. package/src/server/lib/omp/session/messages.ts +27 -2
  42. package/src/server/routes/agent/command.ts +9 -1
  43. package/src/server/routes/btw/topics.ts +2 -59
  44. package/src/shared/lib/chat/btw/client.ts +0 -30
  45. package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
  46. package/src/shared/lib/chat/composer/tui-only.ts +138 -0
  47. package/src/shared/lib/chat/notice-text.ts +33 -0
  48. package/src/shared/lib/chat/omp/agent-events.ts +18 -37
  49. package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
  50. package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
  51. package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
  52. package/src/shared/lib/chat/timeline/command-output.ts +65 -0
  53. package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
  54. package/src/shared/lib/omp/session/messages-map.ts +6 -1
  55. 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
- return merged.map((m) => {
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
- // A change entry always yields a display level: null/unset → "off".
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
+ });