ompchamber 3.3.2 → 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 (63) 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-e3axkbk1.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/assets/font-css.server.test.ts +87 -0
  23. package/src/server/lib/assets/font-css.server.ts +46 -20
  24. package/src/server/lib/assets/font-faces.ts +69 -0
  25. package/src/server/lib/assets/fonts.server.ts +27 -4
  26. package/src/server/lib/btw/child.server.test.ts +68 -0
  27. package/src/server/lib/btw/child.server.ts +17 -16
  28. package/src/server/lib/btw/frames.test.ts +3 -3
  29. package/src/server/lib/btw/frames.ts +1 -1
  30. package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
  31. package/src/server/lib/btw/lifecycle.server.ts +41 -31
  32. package/src/server/lib/btw/prompt.ts +7 -11
  33. package/src/server/lib/btw/registry.server.ts +1 -9
  34. package/src/server/lib/btw/reply.ts +54 -0
  35. package/src/server/lib/btw/runtime.server.test.ts +128 -0
  36. package/src/server/lib/btw/runtime.server.ts +77 -86
  37. package/src/server/lib/btw/schema.server.test.ts +84 -0
  38. package/src/server/lib/btw/schema.server.ts +8 -0
  39. package/src/server/lib/btw/service.server.ts +47 -78
  40. package/src/server/lib/btw/store.server.ts +9 -20
  41. package/src/server/lib/bundler/build-errors.server.ts +8 -3
  42. package/src/server/lib/bundler/css.ts +12 -61
  43. package/src/server/lib/omp/rpc/constants.ts +10 -0
  44. package/src/server/lib/omp/rpc/session-commands.ts +45 -0
  45. package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
  46. package/src/server/lib/omp/session/merge-stored.ts +70 -1
  47. package/src/server/lib/omp/session/messages.ts +27 -2
  48. package/src/server/plugins/ssr.ts +4 -4
  49. package/src/server/plugins/static.ts +14 -10
  50. package/src/server/routes/agent/command.ts +9 -1
  51. package/src/server/routes/btw/topics.ts +2 -59
  52. package/src/shared/lib/chat/btw/client.ts +0 -30
  53. package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
  54. package/src/shared/lib/chat/composer/tui-only.ts +138 -0
  55. package/src/shared/lib/chat/notice-text.ts +33 -0
  56. package/src/shared/lib/chat/omp/agent-events.ts +18 -37
  57. package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
  58. package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
  59. package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
  60. package/src/shared/lib/chat/timeline/command-output.ts +65 -0
  61. package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
  62. package/src/shared/lib/omp/session/messages-map.ts +6 -1
  63. package/src/shared/types/btw.ts +13 -34
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Pulling `@font-face` blocks out of a stylesheet, and pointing their `url()`s
8
+ * at the served route.
9
+ *
10
+ * Two callers, one reason to share: the bundler's CSS loader strips faces out
11
+ * of every module it bundles (`lib/bundler/css.ts`) because Bun resolves each
12
+ * local `url()` into base64 or an absolute filesystem path, and the server
13
+ * assembles `/fonts.css` from the same stylesheets
14
+ * (`lib/assets/font-css.server.ts`). The two must agree byte for byte — a face
15
+ * stripped from the bundle has to be the one the stylesheet re-declares, or the
16
+ * font silently never loads.
17
+ *
18
+ * Only a block whose `src` names a LOCAL font moves: a face pointing at a
19
+ * remote url is valid CSS the bundle can keep. `rewriteFontUrls` replaces every
20
+ * local font url with `<FONT_ROUTE_PREFIX><basename>`, which is the only form
21
+ * that survives Bun's loader — a same-origin path is not an escape hatch, since
22
+ * `url("/fonts/x.woff2")` fails the build with "Could not resolve".
23
+ */
24
+
25
+ import { basename, isAbsolute, resolve as resolvePath } from 'path';
26
+
27
+ import { FONT_ROUTE_PREFIX } from '@/server/lib/assets/fonts.server';
28
+
29
+ /** A `url(...)` whose target is a font file. */
30
+ const URL_FUNCTION = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
31
+ const FONT_FILE = /\.(?:woff2?|ttf|otf)$/i;
32
+ /** Schemes Bun leaves alone, and the prefix this module itself emits. */
33
+ const EXTERNAL = /^(?:data:|https?:|blob:|\/\/)/i;
34
+
35
+ /** One `@font-face` block, braces included. Faces cannot nest. */
36
+ const FONT_FACE = /@font-face\s*\{[^{}]*\}/g;
37
+
38
+ /** Absolute path of a local font url, or null when it is not one. */
39
+ function localFont(specifier: string, cssDir: string): string | null {
40
+ if (EXTERNAL.test(specifier) || specifier.startsWith(FONT_ROUTE_PREFIX)) return null;
41
+ if (!FONT_FILE.test(specifier)) return null;
42
+ return isAbsolute(specifier) ? specifier : resolvePath(cssDir, specifier);
43
+ }
44
+
45
+ /** Point every font `url()` at the served path. */
46
+ export function rewriteFontUrls(css: string, cssDir: string): string {
47
+ return css.replace(URL_FUNCTION, (whole, quote: string, specifier: string) => {
48
+ const absolute = localFont(specifier, cssDir);
49
+ if (!absolute) return whole;
50
+ return `url(${quote}${FONT_ROUTE_PREFIX}${basename(absolute)}${quote})`;
51
+ });
52
+ }
53
+
54
+ /**
55
+ * Split `@font-face` blocks out of a stylesheet.
56
+ *
57
+ * `cssDir` resolves a relative `src` url, so it must be the directory of the
58
+ * file the text came from — not the bundle's.
59
+ */
60
+ export function splitFontFaces(css: string, cssDir: string): { rest: string; faces: string[] } {
61
+ const faces: string[] = [];
62
+ const rest = css.replace(FONT_FACE, (block) => {
63
+ const hasLocal = [...block.matchAll(URL_FUNCTION)].some(([, , spec]) => localFont(spec, cssDir));
64
+ if (!hasLocal) return block;
65
+ faces.push(rewriteFontUrls(block, cssDir));
66
+ return '';
67
+ });
68
+ return { rest, faces };
69
+ }
@@ -77,12 +77,16 @@ let index: Record<string, string> | null = null;
77
77
  /**
78
78
  * The package root — the first search root that holds `node_modules`.
79
79
  *
80
- * Both ends of the build need the same answer: the plugin writes the extracted
81
- * face files here and the server reads them back, and an AOT bundle runs from
82
- * `dist/client` while a source checkout runs from the repo. Falling back to the
80
+ * The AOT bundle runs from `dist/client` while a source checkout runs from the
81
+ * repo, and both resolve their dependencies through this. Falling back to the
83
82
  * cwd keeps a checkout with no `node_modules` (a publish dry-run) working.
83
+ *
84
+ * This answers "where are the DEPENDENCIES", which is not the same question as
85
+ * `packageDir()` below: a hoisted install keeps the package's own files under
86
+ * `node_modules/ompchamber/` while its dependencies sit in the parent
87
+ * `node_modules/`, so the first root holding `node_modules` is the PARENT.
84
88
  */
85
- export function packageRoot(): string {
89
+ function packageRoot(): string {
86
90
  for (const root of searchRoots()) {
87
91
  // `existsSync` rather than `Bun.file().exists()`: the latter is async and
88
92
  // file-only, and `node_modules` is a directory.
@@ -91,6 +95,25 @@ export function packageRoot(): string {
91
95
  return process.cwd();
92
96
  }
93
97
 
98
+ /**
99
+ * The package's own directory — the first search root that holds `src/`.
100
+ *
101
+ * The source stylesheets `lib/assets/font-css.server.ts` reads for `/fonts.css`
102
+ * live there, and they must be found in every install shape. In a source
103
+ * checkout and a non-hoisted install this is the same directory
104
+ * `packageRoot()` returns; in a hoisted install it is the package under
105
+ * `node_modules/`, one level BELOW the root that holds the dependencies.
106
+ * Resolving the source tree through `packageRoot()` there would look for
107
+ * `src/` beside the hoisted packages and find nothing, which serves an empty
108
+ * stylesheet and silently drops every `@font-face`.
109
+ */
110
+ export function packageDir(): string {
111
+ for (const root of searchRoots()) {
112
+ if (existsSync(join(root, 'src'))) return root;
113
+ }
114
+ return packageRoot();
115
+ }
116
+
94
117
  async function buildIndex(): Promise<Record<string, string>> {
95
118
  const found: Record<string, string> = {};
96
119
  const glob = new Glob('*.{woff2,woff,ttf,otf}');
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The side child's argv.
8
+ *
9
+ * `--no-tools` is the parity contract: omp's own `/btw` runs the side turn with
10
+ * the tool catalog attached only for prompt-cache warmth and DISCARDS any tool
11
+ * call, so a child here that could execute one would be answering a different
12
+ * question. `--approval-mode` is deliberately absent — with no tool surface it
13
+ * would govern nothing.
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+ import { buildBtwSpawnArgs } from '@/server/lib/btw/child.server';
18
+ import { buildBtwPrompt } from '@/server/lib/btw/prompt';
19
+
20
+ describe('buildBtwSpawnArgs', () => {
21
+ test('resumes the transcript without tools or a title', () => {
22
+ expect(buildBtwSpawnArgs('/tmp/topic.jsonl', {})).toEqual([
23
+ '--resume',
24
+ '/tmp/topic.jsonl',
25
+ '--no-title',
26
+ '--no-tools',
27
+ ]);
28
+ });
29
+
30
+ test('carries the chat model and thinking selector', () => {
31
+ expect(buildBtwSpawnArgs('/tmp/topic.jsonl', {
32
+ model: { provider: 'kenari', id: 'deepseek-v4' },
33
+ thinkingLevel: 'high',
34
+ })).toEqual([
35
+ '--resume',
36
+ '/tmp/topic.jsonl',
37
+ '--no-title',
38
+ '--no-tools',
39
+ '--model',
40
+ 'kenari/deepseek-v4',
41
+ '--thinking',
42
+ 'high',
43
+ ]);
44
+ });
45
+
46
+ test('passes auto through rather than dropping it', () => {
47
+ expect(buildBtwSpawnArgs('/tmp/topic.jsonl', { thinkingLevel: 'auto' })).toContain('auto');
48
+ });
49
+
50
+ test('never passes an approval mode', () => {
51
+ expect(buildBtwSpawnArgs('/tmp/topic.jsonl', { model: { provider: 'p', id: 'm' } })).not.toContain('--approval-mode');
52
+ });
53
+ });
54
+
55
+ describe('buildBtwPrompt', () => {
56
+ test("is omp's own template, tool prohibition included", () => {
57
+ expect(buildBtwPrompt('what is this?')).toBe([
58
+ '<btw>',
59
+ 'Ephemeral side question for current interactive session.',
60
+ 'Answer briefly, directly; use conversation context already provided.',
61
+ 'NEVER use tools.',
62
+ 'NEVER ask follow-up questions.',
63
+ 'Question:',
64
+ 'what is this?',
65
+ '</btw>',
66
+ ].join('\n'));
67
+ });
68
+ });
@@ -7,35 +7,36 @@
7
7
  * Spawn arguments for a topic's side child.
8
8
  *
9
9
  * The side child is an ordinary `omp --mode rpc-ui` that resumes the topic's
10
- * transcript. It runs WITH tools (unlike the TUI's `/btw`, which passes
11
- * `--no-tools`): the panel exposes a real access-control dropdown, and an
12
- * approval mode that governs nothing is a lie in the UI. What the child may
13
- * actually do is therefore the `--approval-mode` flag's business, exactly as it
14
- * is for a chat session.
10
+ * transcript, run with `--no-tools`: a side question answers from the context it
11
+ * already has and never executes a tool. That is omp's own rule — its TUI `/btw`
12
+ * runs the side turn through `runEphemeralTurn`, which attaches the tool catalog
13
+ * only to keep the prompt cache warm, tells the model in a system reminder that
14
+ * tools are NOT available, and discards any tool call the model emits anyway
15
+ * (`agent-session.ts`).
16
+ *
17
+ * `--no-tools` reaches the same end state from the CLI: the tool catalog is
18
+ * omitted entirely, so a hallucinated call cannot execute and no approval gate
19
+ * can park the turn. `--approval-mode` therefore has nothing to govern and is
20
+ * not passed.
15
21
  *
16
22
  * `--no-title` stays: auto-titling would spend a model call on a transcript
17
23
  * nobody lists in the sidebar.
18
24
  */
19
25
 
20
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
21
26
  import type { BtwModel } from '@/shared/types';
22
27
 
23
28
  export interface BtwSpawnSettings {
24
29
  model?: BtwModel;
25
- /** omp's own default when absent (`auto`). */
30
+ /** Thinking level for the side child, or undefined for omp's own default. */
26
31
  thinkingLevel?: string;
27
- /** Spawn-time only: omp exposes no RPC to change the approval mode. */
28
- approvalMode?: ApprovalMode;
29
32
  }
30
33
 
31
34
  export function buildBtwSpawnArgs(sessionFile: string, settings: BtwSpawnSettings): string[] {
32
- const args = ['--resume', sessionFile, '--no-title'];
35
+ const args = ['--resume', sessionFile, '--no-title', '--no-tools'];
33
36
  if (settings.model) args.push('--model', `${settings.model.provider}/${settings.model.id}`);
34
- // 'auto' means "leave omp's level alone" — the same rule the composer applies,
35
- // so it is omitted rather than translated into a concrete level.
36
- if (settings.thinkingLevel && settings.thinkingLevel !== 'auto') {
37
- args.push('--thinking', settings.thinkingLevel);
38
- }
39
- if (settings.approvalMode) args.push('--approval-mode', settings.approvalMode);
37
+ // 'auto' is a level omp accepts on the CLI (`CLI_THINKING_LEVELS`), so it is
38
+ // passed through rather than dropped: the side child must classify its turns
39
+ // the way the chat does when the chat is in auto mode.
40
+ if (settings.thinkingLevel) args.push('--thinking', settings.thinkingLevel);
40
41
  return args;
41
42
  }
@@ -22,18 +22,18 @@ describe('finalStatus', () => {
22
22
  expect(finalStatus([assistant()])).toBe('complete');
23
23
  });
24
24
 
25
- test('a user abort is cancelled, not failed', () => {
25
+ test('a user abort is cancelled, not an error', () => {
26
26
  expect(finalStatus([assistant({ stopReason: 'aborted' })])).toBe('cancelled');
27
27
  });
28
28
 
29
29
  test('a provider error is a failure, not a completed answer', () => {
30
- expect(finalStatus([assistant({ stopReason: 'error' })])).toBe('failed');
30
+ expect(finalStatus([assistant({ stopReason: 'error' })])).toBe('error');
31
31
  });
32
32
 
33
33
  test('an error carried as flat fields is still a failure', () => {
34
34
  // omp writes abnormal stops as flat `errorStatus`/`errorMessage` on the
35
35
  // message; a bare `stopReason` check would call this one complete.
36
- expect(finalStatus([assistant({ errorMessage: 'upstream 500', errorStatus: 500 })])).toBe('failed');
36
+ expect(finalStatus([assistant({ errorMessage: 'upstream 500', errorStatus: 500 })])).toBe('error');
37
37
  });
38
38
 
39
39
  test('the newest assistant turn decides', () => {
@@ -29,7 +29,7 @@ export function finalStatus(messages: unknown): BtwTurnStatus {
29
29
  const message = messages[index];
30
30
  if (isRecord(message) && message.role === 'assistant') {
31
31
  if (!turnStoppedAbnormally(message)) return 'complete';
32
- return message.stopReason === 'aborted' ? 'cancelled' : 'failed';
32
+ return message.stopReason === 'aborted' ? 'cancelled' : 'error';
33
33
  }
34
34
  }
35
35
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The side turn's frame fold.
8
+ *
9
+ * These pin the three behaviours that are invisible until a reload: a completed
10
+ * message is stored as COMPLETE (its thinking stops pulsing, its usage is the
11
+ * real one), a tool call the model emitted anyway is not rendered as a card for
12
+ * a tool that never ran, and the stored summary is bounded the way omp bounds
13
+ * its own side replies.
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+ import { boundSideReply } from '@/server/lib/btw/reply';
18
+ import { trackTurnMessage, type BtwLifecycleHost } from '@/server/lib/btw/lifecycle.server';
19
+ import type { ChatMessageData } from '@/shared/types';
20
+
21
+ function makeHost(): { host: BtwLifecycleHost; frames: string[] } {
22
+ const frames: string[] = [];
23
+ const host = {
24
+ topicId: 'topic',
25
+ sessionId: 'session',
26
+ sink: { publish: (frame: { type: string }) => frames.push(frame.type), stateChanged: () => {} },
27
+ running: true,
28
+ proc: null,
29
+ idleTimer: null,
30
+ abortTimer: null,
31
+ lastIdleReset: 0,
32
+ turnIndex: 0,
33
+ settling: false,
34
+ messages: [] as ChatMessageData[],
35
+ activity: '',
36
+ disposing: null,
37
+ start: async () => ({} as never),
38
+ teardownChild: async () => {},
39
+ } as unknown as BtwLifecycleHost;
40
+ return { host, frames };
41
+ }
42
+
43
+ const ASSISTANT_WITH_TOOL_CALL = {
44
+ type: 'message_end',
45
+ message: {
46
+ id: 'm1',
47
+ role: 'assistant',
48
+ timestamp: 1,
49
+ content: [
50
+ { type: 'thinking', thinking: 'weighing it up' },
51
+ { type: 'text', text: 'the answer' },
52
+ { type: 'toolCall', id: 'call_1', name: 'read', input: { path: 'x' } },
53
+ ],
54
+ usage: { input: 10, output: 4, totalTokens: 14 },
55
+ },
56
+ };
57
+
58
+ describe('trackTurnMessage', () => {
59
+ test('a completed message is stored as complete, with its usage', () => {
60
+ const { host } = makeHost();
61
+ trackTurnMessage(host, ASSISTANT_WITH_TOOL_CALL as never, false);
62
+
63
+ const [row] = host.messages;
64
+ expect(row?.content).toBe('the answer');
65
+ expect(typeof row?.thinking === 'object' ? row.thinking.isGenerating : undefined).toBe(false);
66
+ expect(row?.usage?.totalTokens).toBe(14);
67
+ });
68
+
69
+ test('a streaming segment still marks its thinking as generating', () => {
70
+ const { host } = makeHost();
71
+ trackTurnMessage(host, {
72
+ type: 'message_update',
73
+ message: { id: 'm1', role: 'assistant', timestamp: 1, content: [{ type: 'thinking', thinking: 'still going' }] },
74
+ } as never);
75
+
76
+ const thinking = host.messages[0]?.thinking;
77
+ expect(typeof thinking === 'object' ? thinking.isGenerating : undefined).toBe(true);
78
+ });
79
+
80
+ test('a tool call the model emitted anyway is not rendered', () => {
81
+ const { host } = makeHost();
82
+ trackTurnMessage(host, ASSISTANT_WITH_TOOL_CALL as never, false);
83
+
84
+ expect(host.messages[0]?.toolCalls ?? []).toHaveLength(0);
85
+ });
86
+
87
+ test('a toolResult frame never becomes a row', () => {
88
+ const { host, frames } = makeHost();
89
+ trackTurnMessage(host, {
90
+ type: 'message_end',
91
+ message: { id: 'r1', role: 'toolResult', toolCallId: 'call_1', content: [{ type: 'text', text: 'file body' }] },
92
+ } as never, false);
93
+
94
+ expect(host.messages).toHaveLength(0);
95
+ expect(frames).toHaveLength(0);
96
+ });
97
+
98
+ test('a same-id frame replaces the streaming row in place', () => {
99
+ const { host } = makeHost();
100
+ trackTurnMessage(host, { type: 'message_update', message: { id: 'm1', role: 'assistant', timestamp: 1, content: [{ type: 'text', text: 'half' }] } } as never);
101
+ trackTurnMessage(host, { type: 'message_end', message: { id: 'm1', role: 'assistant', timestamp: 1, content: [{ type: 'text', text: 'the answer' }] } } as never, false);
102
+
103
+ expect(host.messages).toHaveLength(1);
104
+ expect(host.messages[0]?.content).toBe('the answer');
105
+ });
106
+ });
107
+
108
+ describe('boundSideReply', () => {
109
+ test('a long run of identical lines collapses with a count', () => {
110
+ const reply = ['same', 'same', 'same', 'same', 'same'].join('\n');
111
+ expect(boundSideReply(reply)).toBe('same\n[…5×]');
112
+ });
113
+
114
+ test('a run at the limit is left alone', () => {
115
+ const reply = ['same', 'same', 'same'].join('\n');
116
+ expect(boundSideReply(reply)).toBe(reply);
117
+ });
118
+
119
+ test('an oversized reply is capped and marked', () => {
120
+ // Comfortably past the 4 KiB cap: 500 lines averaging ~11 bytes.
121
+ const reply = Array.from({ length: 500 }, (_, index) => `line number ${index}`).join('\n');
122
+ const bounded = boundSideReply(reply);
123
+ expect(bounded.endsWith('\n[…truncated]')).toBe(true);
124
+ expect(Buffer.byteLength(bounded, 'utf8')).toBeLessThanOrEqual(4096);
125
+ });
126
+
127
+ test('the cap does not split a surrogate pair', () => {
128
+ // 4-byte characters only: a byte-slice would leave a lone surrogate, which
129
+ // JSON round-trips as U+FFFD.
130
+ const reply = '😀'.repeat(1500);
131
+ const bounded = boundSideReply(reply);
132
+ expect(bounded).not.toContain('\uFFFD');
133
+ expect(bounded.endsWith('\n[…truncated]')).toBe(true);
134
+ });
135
+
136
+ test('a short reply is untouched', () => {
137
+ expect(boundSideReply('just an answer')).toBe('just an answer');
138
+ });
139
+ });
@@ -16,14 +16,13 @@
16
16
 
17
17
  import type { RpcProcess, RpcFrame } from '@/server/lib/omp/rpc/process';
18
18
  import { GET_STATE_TIMEOUT_MS, IDLE_DESTROY_MS, PROMPT_ACK_TIMEOUT_MS } from '@/server/lib/omp/rpc/constants';
19
- import type { PendingUiDialogs } from '@/server/lib/omp/rpc/pending-ui-dialogs';
20
19
  import { buildBtwPrompt } from '@/server/lib/btw/prompt';
20
+ import { boundSideReply } from '@/server/lib/btw/reply';
21
21
  import { appendBtwTurn, settleRunningBtwTurns, updateBtwTurn } from '@/server/lib/btw/store.server';
22
22
  import type { BtwRuntimeSink } from '@/server/lib/btw/runtime.server';
23
23
  import { toChatMessage } from '@/shared/lib/omp/session/mapper';
24
24
  import { describeAssistantPhase, describeToolActivity } from '@/shared/lib/chat/timeline/tool-verbs';
25
25
  import { isNoticeRow } from '@/shared/lib/chat/notice-row';
26
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
27
26
  import type { AgentImage, BtwTurnStatus, ChatMessageData } from '@/shared/types';
28
27
 
29
28
  /** Longest an aborted turn may stay unsettled before its child is reclaimed. */
@@ -33,8 +32,6 @@ const ABORT_GRACE_MS = 10_000;
33
32
  export interface BtwLifecycleHost {
34
33
  readonly topicId: string;
35
34
  readonly sink: BtwRuntimeSink;
36
- /** Ask/approval dialogs the child is blocked on. */
37
- readonly dialogs: PendingUiDialogs;
38
35
  /** A question is in flight in this topic. */
39
36
  readonly running: boolean;
40
37
  proc: RpcProcess | null;
@@ -49,8 +46,6 @@ export interface BtwLifecycleHost {
49
46
  /** Last activity phrase published, so repeats are not re-published. */
50
47
  activity: string;
51
48
  disposing: Promise<void> | null;
52
- /** Approval mode the live child was spawned with. */
53
- spawnedApprovalMode: ApprovalMode | undefined;
54
49
  /** Spawn (or reuse) the child. */
55
50
  start(): Promise<RpcProcess>;
56
51
  /** Drop the child and everything scoped to it. */
@@ -71,7 +66,7 @@ export async function askSideQuestion(host: BtwLifecycleHost, question: string,
71
66
  PROMPT_ACK_TIMEOUT_MS,
72
67
  );
73
68
  } catch (error) {
74
- await settleSideTurn(host, 'failed');
69
+ await settleSideTurn(host, 'error');
75
70
  host.sink.publish({
76
71
  type: 'btw_error',
77
72
  topicId: host.topicId,
@@ -106,6 +101,29 @@ export async function abortSideTurn(host: BtwLifecycleHost): Promise<void> {
106
101
  host.abortTimer = timer;
107
102
  }
108
103
 
104
+ /**
105
+ * Drop `toolCall` content blocks from a message.
106
+ *
107
+ * omp's own side turn filters them out of its final message
108
+ * (`agent-session.ts`: `content.filter(block => block.type !== 'toolCall')`)
109
+ * because the call was never executed. A model that emits one anyway — a
110
+ * hallucinated call against the tool catalog omp keeps attached for the prompt
111
+ * cache — must not surface here as a card for a tool that never ran.
112
+ */
113
+ function withoutToolCalls(record: Record<string, unknown>): Record<string, unknown> {
114
+ const content = record.content;
115
+ if (!Array.isArray(content)) return record;
116
+ const kept = content.filter((block) => !isToolCallBlock(block));
117
+ if (kept.length === content.length) return record;
118
+ return { ...record, content: kept };
119
+ }
120
+
121
+ /** Whether a content block is a tool call — the block shape omp's own filter reads. */
122
+ function isToolCallBlock(block: unknown): boolean {
123
+ if (!block || typeof block !== 'object' || !('type' in block)) return false;
124
+ return block.type === 'toolCall';
125
+ }
126
+
109
127
  /**
110
128
  * The turn's conversation as it accumulates, in the chat's own shape.
111
129
  *
@@ -113,17 +131,24 @@ export async function abortSideTurn(host: BtwLifecycleHost): Promise<void> {
113
131
  * accumulated content and its own message id — so a same-id frame replaces the
114
132
  * entry in place and a new id appends. That is exactly the upsert the chat
115
133
  * timeline's `onMessageUpdate` performs, and keeping it identical is what makes
116
- * a side answer render through the chat's own `MessageList`: a tool the side
117
- * child runs shows up here the same way it would in the chat.
134
+ * a side answer render through the chat's own `MessageList`.
135
+ *
136
+ * `streaming` is the chat stream's own distinction, and it matters in two
137
+ * places: a COMPLETED message is the authoritative copy (it carries the turn's
138
+ * real usage where the deltas report zeros), and its thinking is no longer
139
+ * generating — mapping it as a streaming row left every reloaded turn with an
140
+ * open, pulsing accordion.
118
141
  */
119
- export function trackTurnMessage(host: BtwLifecycleHost, frame: RpcFrame): void {
142
+ export function trackTurnMessage(host: BtwLifecycleHost, frame: RpcFrame, streaming = true): void {
120
143
  const raw = frame.message;
121
144
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return;
122
145
  const record = raw as Record<string, unknown>;
146
+ // A side turn executes no tool (see `child.server.ts`), so omp emits no
147
+ // `toolResult` for it. Dropping the role keeps a stray one from becoming a row.
123
148
  if (record.role === 'toolResult') return;
124
149
  const turnIndex = host.turnIndex;
125
150
  if (turnIndex === null) return;
126
- const converted = toChatMessage(record);
151
+ const converted = toChatMessage(withoutToolCalls(record), streaming);
127
152
  if (!converted) return;
128
153
  const existing = host.messages.findIndex((message) => message.id === converted.id);
129
154
  host.messages =
@@ -168,12 +193,9 @@ export async function settleSideTurn(host: BtwLifecycleHost, status: BtwTurnStat
168
193
  host.turnIndex = null;
169
194
  // `||`, not `??`: a turn that only ran tools has no prose, and the caller
170
195
  // passes an empty string rather than undefined — the summary must then fall
171
- // through to whatever the conversation does carry.
172
- const text = answer || answerText(host.messages, '');
173
- // A settled turn has no dialogs left to answer; a stale modal would sit over
174
- // an idle panel with nothing behind it.
175
- const hadDialogs = host.dialogs.list().length > 0;
176
- host.dialogs.clear();
196
+ // through to whatever the conversation does carry. Bounded the way omp bounds
197
+ // its own side replies (see `reply.ts`).
198
+ const text = boundSideReply(answer || answerText(host.messages, ''));
177
199
  host.settling = true;
178
200
  try {
179
201
  await updateBtwTurn(host.topicId, turnIndex, { answer: text, status, messages: host.messages });
@@ -181,7 +203,6 @@ export async function settleSideTurn(host: BtwLifecycleHost, status: BtwTurnStat
181
203
  host.settling = false;
182
204
  host.messages = [];
183
205
  host.sink.stateChanged();
184
- if (hadDialogs) host.sink.stateChanged();
185
206
  }
186
207
  }
187
208
 
@@ -189,13 +210,7 @@ export async function settleSideTurn(host: BtwLifecycleHost, status: BtwTurnStat
189
210
  export function handleSideChildExit(host: BtwLifecycleHost, stderrTail: string): void {
190
211
  const wasRunning = host.running;
191
212
  host.proc = null;
192
- host.spawnedApprovalMode = undefined;
193
- const hadDialogs = host.dialogs.list().length > 0;
194
- host.dialogs.clear();
195
- if (!wasRunning) {
196
- if (hadDialogs) host.sink.stateChanged();
197
- return;
198
- }
213
+ if (!wasRunning) return;
199
214
  void settleRunningBtwTurns(host.topicId, 'interrupted').then(() => {
200
215
  host.turnIndex = null;
201
216
  host.messages = [];
@@ -209,7 +224,7 @@ export function handleSideChildExit(host: BtwLifecycleHost, stderrTail: string):
209
224
  });
210
225
  }
211
226
 
212
- /** Clear the timers and the process, and drop the dialogs that belonged to it. */
227
+ /** Clear the timers and drop the child. */
213
228
  export async function teardownSideChild(host: BtwLifecycleHost): Promise<void> {
214
229
  // Type-required guards: the fields are `Timeout | null`, `clearTimeout` takes
215
230
  // `Timeout | undefined`.
@@ -217,15 +232,10 @@ export async function teardownSideChild(host: BtwLifecycleHost): Promise<void> {
217
232
  if (host.abortTimer) clearTimeout(host.abortTimer);
218
233
  host.idleTimer = null;
219
234
  host.abortTimer = null;
220
- const hadDialogs = host.dialogs.list().length > 0;
221
- host.dialogs.clear();
222
- host.spawnedApprovalMode = undefined;
223
235
  const proc = host.proc;
224
236
  host.proc = null;
225
237
  if (proc) await proc.dispose().catch(() => {});
226
238
  host.disposing = null;
227
- // A modal whose process is gone must not stay on screen.
228
- if (hadDialogs) host.sink.stateChanged();
229
239
  }
230
240
 
231
241
  async function disposeIfUnsettled(host: BtwLifecycleHost): Promise<void> {
@@ -6,17 +6,12 @@
6
6
  /**
7
7
  * The side-question prompt.
8
8
  *
9
- * Derived from omp's own `/btw` template
10
- * (`packages/coding-agent/src/prompts/system/btw-user.md`), with one
11
- * deliberate divergence: the TUI's `/btw` runs the side child with
12
- * `--no-tools` and its prompt says "NEVER use tools". The chamber's panel
13
- * exposes a real access-control dropdown, which only means something when the
14
- * child HAS a tool surface — so the tool prohibition is dropped and the
15
- * approval mode governs what the side child may actually do.
16
- *
17
- * What is kept is the shape that makes a side question a side question: it
18
- * answers briefly and directly, and it never opens a follow-up dialogue of its
19
- * own — follow-ups are explicit user turns.
9
+ * Byte-for-byte omp's own `/btw` template
10
+ * (`packages/coding-agent/src/prompts/system/btw-user.md`), including
11
+ * "NEVER use tools": the side child runs with `--no-tools`, so a tool call is
12
+ * impossible and the prompt must not invite one. Keeping the template identical
13
+ * means a side question reads the same in both clients and a model tuned against
14
+ * one behaves the same in the other.
20
15
  *
21
16
  * The parent session's history is NOT part of this text: the side session
22
17
  * resumes the parent's transcript, so the context arrives as real provider
@@ -28,6 +23,7 @@ export function buildBtwPrompt(question: string): string {
28
23
  '<btw>',
29
24
  'Ephemeral side question for current interactive session.',
30
25
  'Answer briefly, directly; use conversation context already provided.',
26
+ 'NEVER use tools.',
31
27
  'NEVER ask follow-up questions.',
32
28
  'Question:',
33
29
  question,
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { listBtwTopics, settleRunningBtwTurns } from '@/server/lib/btw/store.server';
22
22
  import { BtwRuntime, type BtwRuntimeContext } from '@/server/lib/btw/runtime.server';
23
- import type { BtwFrame, BtwState, BtwTopic, ExtensionUiDialogRequest } from '@/shared/types';
23
+ import type { BtwFrame, BtwState, BtwTopic } from '@/shared/types';
24
24
 
25
25
  interface BtwRegistryHost {
26
26
  runtimes: Map<string, BtwRuntime>;
@@ -132,14 +132,6 @@ export async function btwStateFor(sessionId: string): Promise<BtwState> {
132
132
  topics: repaired,
133
133
  runningTopicId: repaired.find((topic) => topic.turns.some((turn) => turn.status === 'running'))?.id ?? null,
134
134
  live: liveRuntime?.liveTurn() ?? null,
135
- // Dialogs live on the runtime, not in SQLite: they belong to the child
136
- // process that raised them, and a child that is gone has none.
137
- dialogs: runtimes.flatMap((runtime) =>
138
- runtime.dialogs
139
- .list()
140
- .filter((request) => typeof request.id === 'string')
141
- .map((request) => ({ topicId: runtime.topicId, request: request as unknown as ExtensionUiDialogRequest })),
142
- ),
143
135
  };
144
136
  }
145
137