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
|
@@ -12,11 +12,14 @@
|
|
|
12
12
|
* (no re-prompting it as text, and the parent's prompt-cache prefix survives)
|
|
13
13
|
* and never writes the parent file.
|
|
14
14
|
*
|
|
15
|
-
* The child runs
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* The child runs WITHOUT tools (`--no-tools`), the same end state omp's own
|
|
16
|
+
* `/btw` reaches through `runEphemeralTurn`: the side turn answers from the
|
|
17
|
+
* context it already has, never executes a tool, and discards any tool call the
|
|
18
|
+
* model emits anyway. With no tool surface there is no approval gate to park on,
|
|
19
|
+
* so no dialog can reach this child either.
|
|
20
|
+
*
|
|
21
|
+
* Model and thinking selector come from the CHAT (see `retarget`), never from a
|
|
22
|
+
* picker of this panel's own — omp's side turn reads `request.session.model`.
|
|
20
23
|
*
|
|
21
24
|
* The child is disposable by design: history lives in SQLite plus the topic
|
|
22
25
|
* transcript, so an idle reclaim costs a respawn, not the conversation — a
|
|
@@ -28,7 +31,6 @@
|
|
|
28
31
|
|
|
29
32
|
import { RpcProcess, type RpcFrame } from '@/server/lib/omp/rpc/process';
|
|
30
33
|
import { GET_STATE_TIMEOUT_MS, READY_TIMEOUT_MS, type RpcSessionState } from '@/server/lib/omp/rpc/constants';
|
|
31
|
-
import { PendingUiDialogs } from '@/server/lib/omp/rpc/pending-ui-dialogs';
|
|
32
34
|
import { resolveOmpBin } from '@/server/lib/omp/core/cli';
|
|
33
35
|
import { buildBtwSpawnArgs, type BtwSpawnSettings } from '@/server/lib/btw/child.server';
|
|
34
36
|
import {
|
|
@@ -42,10 +44,9 @@ import {
|
|
|
42
44
|
trackTurnMessage,
|
|
43
45
|
type BtwLifecycleHost,
|
|
44
46
|
} from '@/server/lib/btw/lifecycle.server';
|
|
45
|
-
import {
|
|
47
|
+
import { setBtwTopicLeaf, setBtwTopicModel } from '@/server/lib/btw/store.server';
|
|
46
48
|
import { createBtwWorkspace, resolveBtwWorkspacePaths, type BtwWorkspace } from '@/server/lib/btw/session-copy.server';
|
|
47
|
-
import { finalAnswer, finalStatus
|
|
48
|
-
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
49
|
+
import { finalAnswer, finalStatus } from '@/server/lib/btw/frames';
|
|
49
50
|
import type { AgentImage, BtwFrame, BtwLiveTurn, BtwModel, BtwTurnStatus, ChatMessageData } from '@/shared/types';
|
|
50
51
|
|
|
51
52
|
/** Prompt images, validated by the route before they reach the child. */
|
|
@@ -59,7 +60,6 @@ export interface BtwRuntimeContext {
|
|
|
59
60
|
cwd: string;
|
|
60
61
|
model?: BtwModel;
|
|
61
62
|
thinkingLevel?: string;
|
|
62
|
-
approvalMode?: ApprovalMode;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** Where the runtime reports what happened. */
|
|
@@ -94,10 +94,10 @@ export class BtwRuntime implements BtwLifecycleHost {
|
|
|
94
94
|
/** Last activity phrase published, so repeats are not re-published. */
|
|
95
95
|
activity = '';
|
|
96
96
|
disposing: Promise<void> | null = null;
|
|
97
|
-
/**
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
|
|
97
|
+
/** Model the live child reports, so a retarget only sends a real change. */
|
|
98
|
+
private reportedModel: { provider: string; id: string } | null = null;
|
|
99
|
+
/** Thinking selector the live child reports (`auto` included). */
|
|
100
|
+
private reportedThinkingLevel: string | null = null;
|
|
101
101
|
|
|
102
102
|
constructor(
|
|
103
103
|
private readonly context: BtwRuntimeContext,
|
|
@@ -144,41 +144,27 @@ export class BtwRuntime implements BtwLifecycleHost {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
147
|
+
* Adopt the chat's current model and thinking selector before a question is
|
|
148
|
+
* asked. omp's side turn reads `request.session.model` at run time, so a chat
|
|
149
|
+
* that switched model or level is mirrored here — a live child is re-targeted
|
|
150
|
+
* over RPC (`set_model` / `set_thinking_level` apply to the running session),
|
|
151
|
+
* and a cold one simply spawns with these values.
|
|
150
152
|
*/
|
|
151
|
-
async
|
|
152
|
-
|
|
153
|
-
this.
|
|
153
|
+
async retarget(model: BtwModel | undefined, thinkingLevel: string | undefined): Promise<void> {
|
|
154
|
+
this.context.model = model;
|
|
155
|
+
this.context.thinkingLevel = thinkingLevel;
|
|
154
156
|
const proc = this.proc;
|
|
155
157
|
if (!proc?.isAlive) return;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Adopt a new approval mode. omp has no RPC for it — the flag is spawn-time
|
|
168
|
-
* only — so an idle child is destroyed and the next question respawns it.
|
|
169
|
-
* A running turn is never killed for this (same rule as the chat session's
|
|
170
|
-
* `reconcileSpawnApprovalMode`): the mode takes effect from the next child.
|
|
171
|
-
*/
|
|
172
|
-
async setApprovalMode(mode: ApprovalMode): Promise<void> {
|
|
173
|
-
this.context.approvalMode = mode;
|
|
174
|
-
if (this.spawnedApprovalMode === mode || this.running) return;
|
|
175
|
-
await this.teardownChild();
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Answer a dialog this child is blocked on. */
|
|
179
|
-
respondToDialog(id: string, response: Record<string, unknown>): void {
|
|
180
|
-
this.proc?.sendFrame({ type: 'extension_ui_response', id, ...response });
|
|
181
|
-
if (this.dialogs.resolve(id)) this.sink.stateChanged();
|
|
158
|
+
if (model && (this.reportedModel?.provider !== model.provider || this.reportedModel.id !== model.id)) {
|
|
159
|
+
await proc.sendCommand({ type: 'set_model', provider: model.provider, modelId: model.id }, GET_STATE_TIMEOUT_MS);
|
|
160
|
+
// Remember what was sent: without this the next question re-sends the same
|
|
161
|
+
// pair, because `reportedModel` would still hold the spawn-time value.
|
|
162
|
+
this.reportedModel = { provider: model.provider, id: model.id };
|
|
163
|
+
}
|
|
164
|
+
if (thinkingLevel && thinkingLevel !== this.reportedThinkingLevel) {
|
|
165
|
+
await proc.sendCommand({ type: 'set_thinking_level', level: thinkingLevel }, GET_STATE_TIMEOUT_MS);
|
|
166
|
+
this.reportedThinkingLevel = thinkingLevel;
|
|
167
|
+
}
|
|
182
168
|
}
|
|
183
169
|
|
|
184
170
|
/** Reclaim the child; the topic's history survives in SQLite and on disk. */
|
|
@@ -206,20 +192,31 @@ export class BtwRuntime implements BtwLifecycleHost {
|
|
|
206
192
|
private async openWorkspace(): Promise<BtwWorkspace> {
|
|
207
193
|
const paths = await resolveBtwWorkspacePaths(this.context.sessionId, this.context.topicId);
|
|
208
194
|
const existing = await Bun.file(paths.sessionFile).exists();
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
195
|
+
if (existing) {
|
|
196
|
+
this.workspace = { ...paths, leafId: null };
|
|
197
|
+
return this.workspace;
|
|
198
|
+
}
|
|
199
|
+
const workspace = await createBtwWorkspace({
|
|
200
|
+
parentSessionId: this.context.sessionId,
|
|
201
|
+
topicId: this.context.topicId,
|
|
202
|
+
parentSessionFile: this.context.parentSessionFile,
|
|
203
|
+
});
|
|
204
|
+
// The snapshot's leaf is the promotion guard's baseline: the branch may only
|
|
205
|
+
// be cut while the parent still ENDS at the entry this copy was taken from.
|
|
206
|
+
// Written here, where the snapshot is actually taken, because a later
|
|
207
|
+
// transcript cannot reconstruct which entry that was.
|
|
208
|
+
if (workspace.leafId) await setBtwTopicLeaf(this.context.topicId, workspace.leafId);
|
|
216
209
|
this.workspace = workspace;
|
|
217
|
-
// The snapshot's leaf is what a later promotion must still find in the
|
|
218
|
-
// parent; a topic resumed from disk keeps the leaf it was created with.
|
|
219
210
|
return workspace;
|
|
220
211
|
}
|
|
221
212
|
|
|
222
213
|
start(): Promise<RpcProcess> {
|
|
214
|
+
// Reuse the live child. Without this guard every question spawns ANOTHER
|
|
215
|
+
// `omp --resume` for the same topic and abandons the previous one — measured:
|
|
216
|
+
// two live children with the same transcript, the older one holding the
|
|
217
|
+
// pre-retarget `--model`, both appending to the same file. A child that died
|
|
218
|
+
// (`proc` nulled by `handleSideChildExit`) still gets a fresh spawn.
|
|
219
|
+
if (this.proc?.isAlive) return Promise.resolve(this.proc);
|
|
223
220
|
if (this.starting) return this.starting;
|
|
224
221
|
const started = this.spawnChild();
|
|
225
222
|
this.starting = started;
|
|
@@ -238,7 +235,6 @@ export class BtwRuntime implements BtwLifecycleHost {
|
|
|
238
235
|
const settings: BtwSpawnSettings = {
|
|
239
236
|
model: this.context.model,
|
|
240
237
|
thinkingLevel: this.context.thinkingLevel,
|
|
241
|
-
approvalMode: this.context.approvalMode,
|
|
242
238
|
};
|
|
243
239
|
const proc = new RpcProcess({
|
|
244
240
|
cwd: this.context.cwd,
|
|
@@ -247,7 +243,8 @@ export class BtwRuntime implements BtwLifecycleHost {
|
|
|
247
243
|
onExit: (info) => handleSideChildExit(this, info.stderrTail),
|
|
248
244
|
});
|
|
249
245
|
this.proc = proc;
|
|
250
|
-
this.
|
|
246
|
+
this.reportedModel = null;
|
|
247
|
+
this.reportedThinkingLevel = null;
|
|
251
248
|
const ready = await proc.waitReady(READY_TIMEOUT_MS);
|
|
252
249
|
await proc.negotiateProtocol(ready);
|
|
253
250
|
await this.captureModel(proc);
|
|
@@ -255,22 +252,29 @@ export class BtwRuntime implements BtwLifecycleHost {
|
|
|
255
252
|
}
|
|
256
253
|
|
|
257
254
|
/**
|
|
258
|
-
* Record what the child reports about itself
|
|
259
|
-
* the run footer
|
|
260
|
-
* `
|
|
261
|
-
*
|
|
255
|
+
* Record what the child reports about itself: the display name of the model
|
|
256
|
+
* it resolved (for the run footer) and the model/selector it runs with, so a
|
|
257
|
+
* `retarget` before the next question only sends a real change.
|
|
258
|
+
*
|
|
259
|
+
* The THINKING LEVEL is NOT written back to the topic. `get_state` reports the
|
|
260
|
+
* level the child RESOLVED (an `auto` chat resolves to a concrete effort), and
|
|
261
|
+
* storing that would pin every later spawn to it — the side conversation would
|
|
262
|
+
* stop re-classifying per turn while the chat it mirrors keeps doing so. The
|
|
263
|
+
* chat's own recorded selector is what each ask reads instead.
|
|
262
264
|
*/
|
|
263
265
|
private async captureModel(proc: RpcProcess): Promise<void> {
|
|
264
266
|
try {
|
|
265
267
|
const state = await proc.sendCommand<RpcSessionState>({ type: 'get_state' }, GET_STATE_TIMEOUT_MS);
|
|
266
268
|
const reported = state.model;
|
|
267
|
-
if (reported?.provider && reported.id
|
|
268
|
-
|
|
269
|
-
|
|
269
|
+
if (reported?.provider && reported.id) {
|
|
270
|
+
this.reportedModel = { provider: reported.provider, id: reported.id };
|
|
271
|
+
if (reported.name) {
|
|
272
|
+
await setBtwTopicModel(this.topicId, { provider: reported.provider, id: reported.id, name: reported.name });
|
|
273
|
+
this.sink.stateChanged();
|
|
274
|
+
}
|
|
270
275
|
}
|
|
271
276
|
if (typeof state.thinkingLevel === 'string' && state.thinkingLevel) {
|
|
272
|
-
|
|
273
|
-
this.sink.stateChanged();
|
|
277
|
+
this.reportedThinkingLevel = state.thinkingLevel;
|
|
274
278
|
}
|
|
275
279
|
} catch {
|
|
276
280
|
// A chip without a display name is cosmetic; the topic keeps its model.
|
|
@@ -280,38 +284,25 @@ export class BtwRuntime implements BtwLifecycleHost {
|
|
|
280
284
|
private handleFrame(frame: RpcFrame): void {
|
|
281
285
|
if (frame.type === 'message_update' || frame.type === 'message_start') {
|
|
282
286
|
// The turn's conversation is accumulated in the chat's own shape (see
|
|
283
|
-
// `trackTurnMessage`): thinking, prose
|
|
284
|
-
//
|
|
287
|
+
// `trackTurnMessage`): thinking, prose and usage reach the panel through
|
|
288
|
+
// the same mapper the chat timeline uses.
|
|
285
289
|
trackTurnMessage(this, frame);
|
|
286
290
|
trackTurnActivity(this, frame);
|
|
287
291
|
} else if (frame.type === 'message_end') {
|
|
288
292
|
// The completed message is the authoritative copy: it carries the turn's
|
|
289
|
-
// real usage, where the streaming frames report zeros
|
|
290
|
-
// replaces the streaming row in place
|
|
291
|
-
// puts token counts on the
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
trackTurnActivity(this, frame);
|
|
293
|
+
// real usage, where the streaming frames report zeros, and its thinking is
|
|
294
|
+
// no longer generating. Upserting it replaces the streaming row in place
|
|
295
|
+
// (same message id), which is what puts token counts on the run footer
|
|
296
|
+
// without leaving a pulsing accordion behind on the next reload.
|
|
297
|
+
trackTurnMessage(this, frame, false);
|
|
295
298
|
} else if (frame.type === 'agent_end') {
|
|
296
299
|
// `isTerminal: false` means maintenance scheduled more work; the turn is
|
|
297
300
|
// not done until a terminal end arrives (mirrors the session wrapper).
|
|
298
301
|
if (frame.isTerminal === false) return;
|
|
299
302
|
void settleSideTurn(this, finalStatus(frame.messages), finalAnswer(frame.messages, ''));
|
|
300
|
-
} else if (frame.type === 'extension_ui_request') {
|
|
301
|
-
this.trackDialog(frame);
|
|
302
303
|
}
|
|
303
304
|
resetSideIdleTimer(this);
|
|
304
305
|
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Record an ask/approval dialog so the panel can render it. Unlike the TUI
|
|
308
|
-
* there is no tool card to host an `ask` inline, so every answerable request
|
|
309
|
-
* becomes a modal — and the state republish is what puts it on screen.
|
|
310
|
-
*/
|
|
311
|
-
private trackDialog(frame: RpcFrame): void {
|
|
312
|
-
if (typeof frame.id !== 'string' || !isAnswerableUiMethod(frame.method)) return;
|
|
313
|
-
if (this.dialogs.track(frame)) this.sink.stateChanged();
|
|
314
|
-
}
|
|
315
306
|
}
|
|
316
307
|
|
|
317
308
|
export type { BtwTurnStatus };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The BTW schema's migrations.
|
|
8
|
+
*
|
|
9
|
+
* `ensureBtwSchema` runs once per database open, so every branch has to be safe
|
|
10
|
+
* on a database that already has the newest shape AND on one created before the
|
|
11
|
+
* column or the status word existed. The status rename is the one that is easy
|
|
12
|
+
* to get wrong: a stored `failed` is not a column, so it needs a data migration
|
|
13
|
+
* rather than an `ALTER`, and a database written before the vocabulary changed
|
|
14
|
+
* would otherwise render a turn that never completes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { rmSync } from 'node:fs';
|
|
21
|
+
import { createDb, type DbClient } from '@/server/lib/db/client';
|
|
22
|
+
import { ensureBtwSchema } from '@/server/lib/btw/schema.server';
|
|
23
|
+
|
|
24
|
+
const paths: string[] = [];
|
|
25
|
+
|
|
26
|
+
function tempDb(): DbClient {
|
|
27
|
+
const path = join(tmpdir(), `btw-schema-${crypto.randomUUID()}.sqlite`);
|
|
28
|
+
paths.push(path);
|
|
29
|
+
return createDb(path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('ensureBtwSchema', () => {
|
|
33
|
+
test('creates the tables on a fresh database', async () => {
|
|
34
|
+
const db = tempDb();
|
|
35
|
+
await ensureBtwSchema(db);
|
|
36
|
+
|
|
37
|
+
const columns = (await db.all('PRAGMA table_info(btw_topics)')) as { name: string }[];
|
|
38
|
+
expect(columns.map((column) => column.name)).toContain('leaf_id');
|
|
39
|
+
expect(columns.map((column) => column.name)).toContain('thinking_level');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('is idempotent', async () => {
|
|
43
|
+
const db = tempDb();
|
|
44
|
+
await ensureBtwSchema(db);
|
|
45
|
+
await ensureBtwSchema(db);
|
|
46
|
+
|
|
47
|
+
const tables = (await db.all("SELECT name FROM sqlite_master WHERE type = 'table'")) as { name: string }[];
|
|
48
|
+
expect(tables.filter((table) => table.name === 'btw_turns')).toHaveLength(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('rewrites a legacy failed turn as error', async () => {
|
|
52
|
+
const db = tempDb();
|
|
53
|
+
// A database from before the vocabulary change: the column set is old and a
|
|
54
|
+
// turn is recorded as `failed`.
|
|
55
|
+
await db.exec(`
|
|
56
|
+
CREATE TABLE btw_topics (
|
|
57
|
+
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '',
|
|
58
|
+
provider TEXT, model_id TEXT, model_name TEXT, leaf_id TEXT, promoted_session_id TEXT,
|
|
59
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
60
|
+
);
|
|
61
|
+
CREATE TABLE btw_turns (
|
|
62
|
+
topic_id TEXT NOT NULL, turn_index INTEGER NOT NULL, question TEXT NOT NULL DEFAULT '',
|
|
63
|
+
answer TEXT NOT NULL DEFAULT '', status TEXT NOT NULL,
|
|
64
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
|
|
65
|
+
PRIMARY KEY (topic_id, turn_index)
|
|
66
|
+
);
|
|
67
|
+
`);
|
|
68
|
+
await db.run(
|
|
69
|
+
"INSERT INTO btw_turns (topic_id, turn_index, status, created_at, updated_at) VALUES ('t', 0, 'failed', 1, 1)",
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await ensureBtwSchema(db);
|
|
73
|
+
|
|
74
|
+
const turn = (await db.get("SELECT status FROM btw_turns WHERE topic_id = 't'")) as { status: string };
|
|
75
|
+
expect(turn.status).toBe('error');
|
|
76
|
+
// The additive columns arrive on the same pass.
|
|
77
|
+
const columns = (await db.all('PRAGMA table_info(btw_topics)')) as { name: string }[];
|
|
78
|
+
expect(columns.map((column) => column.name)).toContain('approval_mode');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
process.on('exit', () => {
|
|
83
|
+
for (const path of paths) rmSync(path, { force: true });
|
|
84
|
+
});
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* BTW schema, kept out of `db.server.ts`'s bootstrap block so that file's
|
|
8
8
|
* growth stays attributable (it was already at the ceiling before this
|
|
9
9
|
* feature). Called once per database open, like the other migrations there.
|
|
10
|
+
*
|
|
11
|
+
* `approval_mode` is retained for rows written while a side child ran WITH tools
|
|
12
|
+
* and an approval dropdown governed it; nothing writes or reads it now that the
|
|
13
|
+
* child runs `--no-tools`.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
16
|
import type { DbClient } from '@/server/lib/db/client';
|
|
@@ -68,4 +72,8 @@ export async function ensureBtwSchema(db: DbClient): Promise<void> {
|
|
|
68
72
|
if (await hasColumn(db, added.table, added.column)) continue;
|
|
69
73
|
await db.exec(added.ddl);
|
|
70
74
|
}
|
|
75
|
+
// A turn that ended in a provider error used to be recorded as `failed`; the
|
|
76
|
+
// vocabulary now matches omp's own (`error`). Row data, not a column, so it is
|
|
77
|
+
// an unconditional UPDATE — a second run matches nothing.
|
|
78
|
+
await db.run("UPDATE btw_turns SET status = 'error' WHERE status = 'failed'");
|
|
71
79
|
}
|
|
@@ -12,32 +12,32 @@
|
|
|
12
12
|
* cancel — a second question is refused, exactly like the TUI);
|
|
13
13
|
* - a new question is its own topic; only a follow-up continues one, and it
|
|
14
14
|
* sees only that topic's turns (plus the parent's context);
|
|
15
|
+
* - the side conversation runs on the CHAT's model and thinking selector, read
|
|
16
|
+
* from the parent transcript at every ask — the panel has no pickers of its
|
|
17
|
+
* own, exactly like omp's `/btw`, which runs the side turn on
|
|
18
|
+
* `request.session.model`;
|
|
15
19
|
* - promotion is refused once the parent transcript has moved past the point
|
|
16
|
-
* the topic was snapshotted at,
|
|
20
|
+
* the topic was snapshotted at, while a turn is still running, and for a
|
|
21
|
+
* topic with follow-ups (a branch carries one question/answer pair).
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
import { isMockMode } from '@/server/mock.server';
|
|
20
25
|
import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
21
26
|
import { readRawHeaderLine } from '@/server/lib/omp/session/files';
|
|
22
|
-
import { loadSessionModel } from '@/server/lib/omp/session/messages';
|
|
23
|
-
import { resolveSpawnCwd } from '@/server/lib/omp/rpc/manager';
|
|
24
|
-
import { loadPersistedAccessMode } from '@/shared/lib/omp/config/access-mode.server';
|
|
25
|
-
import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
27
|
+
import { loadSessionModel, loadSessionThinkingSelector } from '@/server/lib/omp/session/messages';
|
|
28
|
+
import { getRpcSession, resolveSpawnCwd } from '@/server/lib/omp/rpc/manager';
|
|
26
29
|
import { composeMessageWithTextAttachments, type AttachedTextFileData } from '@/shared/lib/chat/attachments';
|
|
27
30
|
import {
|
|
28
31
|
createBtwTopic,
|
|
29
32
|
deleteBtwTopic,
|
|
30
33
|
getBtwTopic,
|
|
31
34
|
readBtwTopicLeaf,
|
|
32
|
-
setBtwTopicApprovalMode,
|
|
33
|
-
setBtwTopicModel,
|
|
34
35
|
setBtwTopicPromoted,
|
|
35
|
-
setBtwTopicThinkingLevel,
|
|
36
36
|
} from '@/server/lib/btw/store.server';
|
|
37
37
|
import { promoteBtwTopic as materializePromotedTopic, readTranscriptLeaf, removeBtwWorkspace, resolveBtwWorkspacePaths } from '@/server/lib/btw/session-copy.server';
|
|
38
38
|
import { btwStateFor, ensureBtwRuntime, findRunningBtwRuntime, forgetBtwRuntime, getBtwRuntime, publishBtwState } from '@/server/lib/btw/registry.server';
|
|
39
39
|
import { BtwError, type BtwImages } from '@/server/lib/btw/runtime.server';
|
|
40
|
-
import type {
|
|
40
|
+
import type { BtwState } from '@/shared/types';
|
|
41
41
|
|
|
42
42
|
/** Longest a topic label may be, matching the sidebar's session-title length. */
|
|
43
43
|
const TOPIC_TITLE_CHARS = 60;
|
|
@@ -93,15 +93,6 @@ export interface AskBtwInput {
|
|
|
93
93
|
* be asked about a file the same way a chat turn can.
|
|
94
94
|
*/
|
|
95
95
|
textFiles?: AttachedTextFileData[];
|
|
96
|
-
/**
|
|
97
|
-
* Composer picks for the FIRST question of a topic, which has no row to write
|
|
98
|
-
* them to yet. Later questions address an existing topic and are re-targeted
|
|
99
|
-
* through the dedicated `set_model` / `set_thinking_level` / `set_access_mode`
|
|
100
|
-
* actions instead.
|
|
101
|
-
*/
|
|
102
|
-
model?: BtwModel;
|
|
103
|
-
thinkingLevel?: string;
|
|
104
|
-
approvalMode?: ApprovalMode;
|
|
105
96
|
}
|
|
106
97
|
|
|
107
98
|
export async function askBtw(sessionId: string, input: AskBtwInput): Promise<BtwState> {
|
|
@@ -119,15 +110,18 @@ export async function askBtw(sessionId: string, input: AskBtwInput): Promise<Btw
|
|
|
119
110
|
throw new BtwError('Unknown side question.', 'btw_topic_not_found');
|
|
120
111
|
}
|
|
121
112
|
|
|
122
|
-
|
|
113
|
+
// The side conversation mirrors the chat: model and thinking selector are read
|
|
114
|
+
// from the parent transcript at every ask, so a change made in the chat reaches
|
|
115
|
+
// the next side question (omp reads `request.session.model` the same way).
|
|
116
|
+
const model = await topicModel(parent.sessionFile);
|
|
117
|
+
const thinkingLevel = await loadSessionThinkingSelector(parent.sessionFile);
|
|
123
118
|
const topic =
|
|
124
119
|
existing ??
|
|
125
120
|
(await createBtwTopic({
|
|
126
121
|
sessionId,
|
|
127
122
|
title: topicTitle(question),
|
|
128
|
-
model
|
|
129
|
-
thinkingLevel
|
|
130
|
-
approvalMode,
|
|
123
|
+
model,
|
|
124
|
+
thinkingLevel,
|
|
131
125
|
}));
|
|
132
126
|
|
|
133
127
|
const runtime = ensureBtwRuntime({
|
|
@@ -135,68 +129,17 @@ export async function askBtw(sessionId: string, input: AskBtwInput): Promise<Btw
|
|
|
135
129
|
sessionId,
|
|
136
130
|
parentSessionFile: parent.sessionFile,
|
|
137
131
|
cwd: parent.cwd,
|
|
138
|
-
model
|
|
139
|
-
thinkingLevel
|
|
140
|
-
// The topic's own mode wins once set; a fresh topic adopts the composer's.
|
|
141
|
-
approvalMode: topic.approvalMode ?? approvalMode,
|
|
132
|
+
model,
|
|
133
|
+
thinkingLevel,
|
|
142
134
|
});
|
|
143
135
|
|
|
136
|
+
// A live child keeps the model it was spawned with; re-target it before the
|
|
137
|
+
// prompt so this question runs on what the chat is on NOW.
|
|
138
|
+
await runtime.retarget(model, thinkingLevel);
|
|
144
139
|
await runtime.ask(composeMessageWithTextAttachments(question, input.textFiles ?? []), input.images);
|
|
145
140
|
return btwStateFor(sessionId);
|
|
146
141
|
}
|
|
147
142
|
|
|
148
|
-
/** One topic of this session, or a `btw_topic_not_found` refusal. */
|
|
149
|
-
async function requireTopic(sessionId: string, topicId: string) {
|
|
150
|
-
const topic = await getBtwTopic(topicId);
|
|
151
|
-
if (!topic || topic.sessionId !== sessionId) throw new BtwError('Unknown side question.', 'btw_topic_not_found');
|
|
152
|
-
return topic;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Re-target a topic's model. With a live child the change reaches it through
|
|
157
|
-
* `set_model` (omp applies it to the running turn, which is what the dropdown
|
|
158
|
-
* promises); an idle topic simply records it for the next spawn.
|
|
159
|
-
*/
|
|
160
|
-
export async function setBtwModel(sessionId: string, topicId: string, provider: string, modelId: string): Promise<BtwState> {
|
|
161
|
-
await requireTopic(sessionId, topicId);
|
|
162
|
-
const runtime = getBtwRuntime(topicId);
|
|
163
|
-
if (runtime) {
|
|
164
|
-
await runtime.setModel(provider, modelId);
|
|
165
|
-
} else {
|
|
166
|
-
await setBtwTopicModel(topicId, { provider, id: modelId });
|
|
167
|
-
await publishBtwState(sessionId);
|
|
168
|
-
}
|
|
169
|
-
return btwStateFor(sessionId);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export async function setBtwThinkingLevel(sessionId: string, topicId: string, level: string): Promise<BtwState> {
|
|
173
|
-
await requireTopic(sessionId, topicId);
|
|
174
|
-
await setBtwTopicThinkingLevel(topicId, level === 'auto' ? null : level);
|
|
175
|
-
await getBtwRuntime(topicId)?.setThinkingLevel(level);
|
|
176
|
-
await publishBtwState(sessionId);
|
|
177
|
-
return btwStateFor(sessionId);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Adopt a new tool-approval mode for a topic. omp has no RPC for it, so an idle
|
|
182
|
-
* child is dropped and the next question respawns with the new flag; a running
|
|
183
|
-
* turn keeps its child (and therefore its mode) until it settles.
|
|
184
|
-
*/
|
|
185
|
-
export async function setBtwApprovalMode(sessionId: string, topicId: string, mode: ApprovalMode): Promise<BtwState> {
|
|
186
|
-
await requireTopic(sessionId, topicId);
|
|
187
|
-
await setBtwTopicApprovalMode(topicId, mode);
|
|
188
|
-
await getBtwRuntime(topicId)?.setApprovalMode(mode);
|
|
189
|
-
await publishBtwState(sessionId);
|
|
190
|
-
return btwStateFor(sessionId);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Answer an ask/approval dialog a side child is blocked on. */
|
|
194
|
-
export async function respondBtwDialog(sessionId: string, topicId: string, id: string, response: Record<string, unknown>): Promise<BtwState> {
|
|
195
|
-
await requireTopic(sessionId, topicId);
|
|
196
|
-
getBtwRuntime(topicId)?.respondToDialog(id, response);
|
|
197
|
-
return btwStateFor(sessionId);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
143
|
/** The parent's model, so the side conversation runs on the visible model. */
|
|
201
144
|
async function topicModel(sessionFile: string) {
|
|
202
145
|
const model = await loadSessionModel(sessionFile);
|
|
@@ -210,6 +153,19 @@ export async function abortBtw(sessionId: string, topicId: string): Promise<BtwS
|
|
|
210
153
|
return btwStateFor(sessionId);
|
|
211
154
|
}
|
|
212
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Refuse a session operation that would move the conversation out from under a
|
|
158
|
+
* running side question. omp blocks the same operations while a `/btw` answer is
|
|
159
|
+
* in flight or unsaved (`BtwController.withSessionMove` / `flush`); a chamber
|
|
160
|
+
* chat that switched sessions mid-question would leave the answer attached to a
|
|
161
|
+
* transcript nobody is looking at.
|
|
162
|
+
*/
|
|
163
|
+
export async function assertBtwIdle(sessionId: string, action: string): Promise<void> {
|
|
164
|
+
if (findRunningBtwRuntime(sessionId)) {
|
|
165
|
+
throw new BtwError(`A side question is still running — wait for it, or cancel it, before you ${action}.`, 'btw_busy');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
213
169
|
/**
|
|
214
170
|
* Turn the topic into a session of the chat's own: a branch of the parent
|
|
215
171
|
* conversation whose tip is the side answer, opened by the caller.
|
|
@@ -225,8 +181,21 @@ export async function promoteBtw(sessionId: string, topicId: string): Promise<{
|
|
|
225
181
|
if (topic.turns.some((turn) => turn.status === 'running')) {
|
|
226
182
|
throw new BtwError('Wait for the side answer to finish, or cancel it, before promoting it.', 'btw_busy');
|
|
227
183
|
}
|
|
184
|
+
// omp's branch action carries ONE question/answer pair and refuses a topic with
|
|
185
|
+
// follow-ups, so the earlier turns cannot be silently dropped from the branch.
|
|
186
|
+
if (topic.turns.length > 1) {
|
|
187
|
+
throw new BtwError(
|
|
188
|
+
'A side question with follow-ups cannot be promoted — omp branches one answer at a time.',
|
|
189
|
+
'btw_multi_turn',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
228
192
|
|
|
229
193
|
const parent = await resolveParent(sessionId);
|
|
194
|
+
// The branch is cut from the parent's transcript, so the chat must be idle:
|
|
195
|
+
// omp's guard refuses while `session.isStreaming`.
|
|
196
|
+
if (getRpcSession(sessionId)?.isRunning()) {
|
|
197
|
+
throw new BtwError('The chat is still running — wait for it to finish before promoting a side question.', 'btw_busy');
|
|
198
|
+
}
|
|
230
199
|
const snapshotLeaf = await readBtwTopicLeaf(topicId);
|
|
231
200
|
if (snapshotLeaf) {
|
|
232
201
|
const currentLeaf = await readTranscriptLeaf(parent.sessionFile);
|
|
@@ -15,10 +15,14 @@
|
|
|
15
15
|
* `leaf_id` is the parent session's transcript leaf when the topic started.
|
|
16
16
|
* Promotion refuses when the parent has moved on, exactly like omp's own
|
|
17
17
|
* `/btw` branch guard ("session changed since /btw started").
|
|
18
|
+
*
|
|
19
|
+
* `thinking_level` holds the chat's thinking SELECTOR (`auto` included), not the
|
|
20
|
+
* effort the side child resolved; `approval_mode` is no longer written — a side
|
|
21
|
+
* child runs `--no-tools`, so there is no approval gate to configure. Both
|
|
22
|
+
* columns stay in the schema for existing rows.
|
|
18
23
|
*/
|
|
19
24
|
|
|
20
25
|
import { getDb } from '@/server/db.server';
|
|
21
|
-
import { isApprovalMode, type ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
22
26
|
import type { BtwModel, BtwTopic, BtwTurn, BtwTurnStatus, ChatMessageData } from '@/shared/types';
|
|
23
27
|
|
|
24
28
|
interface TopicRow {
|
|
@@ -51,7 +55,7 @@ const TURN_STATUSES: Record<string, true> = {
|
|
|
51
55
|
running: true,
|
|
52
56
|
complete: true,
|
|
53
57
|
cancelled: true,
|
|
54
|
-
|
|
58
|
+
error: true,
|
|
55
59
|
interrupted: true,
|
|
56
60
|
};
|
|
57
61
|
|
|
@@ -72,7 +76,6 @@ function rowToTopic(row: TopicRow): BtwTopic {
|
|
|
72
76
|
updatedAt: row.updated_at,
|
|
73
77
|
...(model ? { model } : {}),
|
|
74
78
|
...(row.thinking_level ? { thinkingLevel: row.thinking_level } : {}),
|
|
75
|
-
...(isApprovalMode(row.approval_mode) ? { approvalMode: row.approval_mode } : {}),
|
|
76
79
|
...(row.promoted_session_id ? { promotedSessionId: row.promoted_session_id } : {}),
|
|
77
80
|
turns: [],
|
|
78
81
|
};
|
|
@@ -138,10 +141,8 @@ export interface NewBtwTopicInput {
|
|
|
138
141
|
sessionId: string;
|
|
139
142
|
title: string;
|
|
140
143
|
model?: BtwModel;
|
|
144
|
+
/** The chat's thinking selector, so the side child classifies like the chat. */
|
|
141
145
|
thinkingLevel?: string;
|
|
142
|
-
approvalMode?: ApprovalMode;
|
|
143
|
-
/** Parent transcript leaf when the topic was created (promotion guard). */
|
|
144
|
-
leafId?: string | null;
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic> {
|
|
@@ -155,8 +156,8 @@ export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic>
|
|
|
155
156
|
model_id: input.model?.id ?? null,
|
|
156
157
|
model_name: input.model?.name ?? null,
|
|
157
158
|
thinking_level: input.thinkingLevel ?? null,
|
|
158
|
-
approval_mode:
|
|
159
|
-
leaf_id:
|
|
159
|
+
approval_mode: null,
|
|
160
|
+
leaf_id: null,
|
|
160
161
|
promoted_session_id: null,
|
|
161
162
|
created_at: now,
|
|
162
163
|
updated_at: now,
|
|
@@ -262,18 +263,6 @@ export async function setBtwTopicModel(topicId: string, model: BtwModel): Promis
|
|
|
262
263
|
]);
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
/** Record the thinking level the side child runs at (`null` clears it). */
|
|
266
|
-
export async function setBtwTopicThinkingLevel(topicId: string, level: string | null): Promise<void> {
|
|
267
|
-
const db = await getDb();
|
|
268
|
-
await db.run('UPDATE btw_topics SET thinking_level = ?, updated_at = ? WHERE id = ?', [level, Date.now(), topicId]);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Record the approval mode the side child was spawned with. */
|
|
272
|
-
export async function setBtwTopicApprovalMode(topicId: string, mode: ApprovalMode): Promise<void> {
|
|
273
|
-
const db = await getDb();
|
|
274
|
-
await db.run('UPDATE btw_topics SET approval_mode = ?, updated_at = ? WHERE id = ?', [mode, Date.now(), topicId]);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
266
|
export async function setBtwTopicPromoted(topicId: string, promotedSessionId: string): Promise<void> {
|
|
278
267
|
const db = await getDb();
|
|
279
268
|
await db.run('UPDATE btw_topics SET promoted_session_id = ?, updated_at = ? WHERE id = ?', [
|
|
@@ -134,6 +134,16 @@ export const PASSTHROUGH_COMMANDS = new Set([
|
|
|
134
134
|
// whose liveness probe had just reported `running: false`).
|
|
135
135
|
export const OBSERVER_ONLY_COMMANDS = new Set(['get_subagents', 'get_subagent_messages']);
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Commands that MOVE the conversation: they replace the transcript the session
|
|
139
|
+
* is running on, so a side question that snapshotted the old one would be left
|
|
140
|
+
* describing a conversation the chat has already left. omp blocks the same
|
|
141
|
+
* operations while a `/btw` answer is in flight or unsaved
|
|
142
|
+
* (`BtwController.withSessionMove` / `flush`); the chamber refuses them with
|
|
143
|
+
* `btw_busy` (409) instead.
|
|
144
|
+
*/
|
|
145
|
+
export const CONVERSATION_MOVING_COMMANDS = new Set(['switch_session', 'branch', 'new_session', 'handoff']);
|
|
146
|
+
|
|
137
147
|
// Commands that can carry user-attached images to the model. All of them must
|
|
138
148
|
// pass the same server-side per-image/count/aggregate validation before the
|
|
139
149
|
// payload reaches omp — a client is free to POST any of them directly.
|