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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +275 -268
- package/dist/client/static/js/{index-e3axkbk1.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/assets/font-css.server.test.ts +87 -0
- package/src/server/lib/assets/font-css.server.ts +46 -20
- package/src/server/lib/assets/font-faces.ts +69 -0
- package/src/server/lib/assets/fonts.server.ts +27 -4
- 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/bundler/build-errors.server.ts +8 -3
- package/src/server/lib/bundler/css.ts +12 -61
- 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/plugins/ssr.ts +4 -4
- package/src/server/plugins/static.ts +14 -10
- 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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The side reply's stored summary, bounded the way omp bounds its own.
|
|
8
|
+
*
|
|
9
|
+
* omp runs every ephemeral side reply through `dedupeEphemeralReply`
|
|
10
|
+
* (`session/messages.ts`): a run of more than three identical lines collapses to
|
|
11
|
+
* one line plus `[…N×]`, and the whole reply is capped at 4 KiB with a
|
|
12
|
+
* `[…truncated]` suffix. The cap exists because a degenerate model can emit
|
|
13
|
+
* thousands of identical lines, and this summary is what the panel's history
|
|
14
|
+
* labels and a promotion's title are built from.
|
|
15
|
+
*
|
|
16
|
+
* Only the turn's `answer` summary goes through here. The rendered conversation
|
|
17
|
+
* (`messages`) is stored whole — truncating that would hide content the panel is
|
|
18
|
+
* there to show.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Longest stored summary, matching omp's `EPHEMERAL_REPLY_MAX_BYTES`. */
|
|
22
|
+
const MAX_REPLY_BYTES = 4096;
|
|
23
|
+
|
|
24
|
+
/** Runs longer than this collapse to one line plus a count. */
|
|
25
|
+
const MAX_REPEATED_LINES = 3;
|
|
26
|
+
|
|
27
|
+
export function boundSideReply(text: string): string {
|
|
28
|
+
if (!text) return text;
|
|
29
|
+
const lines = text.split('\n');
|
|
30
|
+
const collapsed: string[] = [];
|
|
31
|
+
let index = 0;
|
|
32
|
+
while (index < lines.length) {
|
|
33
|
+
let next = index + 1;
|
|
34
|
+
while (next < lines.length && lines[next] === lines[index]) next += 1;
|
|
35
|
+
const runLength = next - index;
|
|
36
|
+
if (runLength > MAX_REPEATED_LINES) {
|
|
37
|
+
collapsed.push(lines[index], `[…${runLength}×]`);
|
|
38
|
+
} else {
|
|
39
|
+
for (let repeat = 0; repeat < runLength; repeat += 1) collapsed.push(lines[index]);
|
|
40
|
+
}
|
|
41
|
+
index = next;
|
|
42
|
+
}
|
|
43
|
+
let result = collapsed.join('\n');
|
|
44
|
+
if (Buffer.byteLength(result, 'utf8') <= MAX_REPLY_BYTES) return result;
|
|
45
|
+
const suffix = '\n[…truncated]';
|
|
46
|
+
const budget = MAX_REPLY_BYTES - Buffer.byteLength(suffix, 'utf8');
|
|
47
|
+
// Cut by code point: slicing at an arbitrary byte would leave half a surrogate
|
|
48
|
+
// pair, which JSON-serializes as U+FFFD and never round-trips.
|
|
49
|
+
while (result.length > 0 && Buffer.byteLength(result, 'utf8') > budget) {
|
|
50
|
+
const last = result.charCodeAt(result.length - 1);
|
|
51
|
+
result = result.slice(0, last >= 0xdc00 && last <= 0xdfff ? -2 : -1);
|
|
52
|
+
}
|
|
53
|
+
return result + suffix;
|
|
54
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `BtwRuntime.retarget` — the path that makes a side question follow the chat's
|
|
8
|
+
* model and thinking selector.
|
|
9
|
+
*
|
|
10
|
+
* A live child keeps the model it was SPAWNED with, so a chat that switched
|
|
11
|
+
* model between two side questions would otherwise be answered by the stale one.
|
|
12
|
+
* omp's side turn reads `request.session.model` per run; this is the equivalent,
|
|
13
|
+
* and it has to be a no-op when the values already match or every question would
|
|
14
|
+
* spend two RPC round-trips re-sending what the child already runs.
|
|
15
|
+
*
|
|
16
|
+
* The child is a real `RpcProcess` here (no omp binary is spawned): only the
|
|
17
|
+
* command surface is replaced, so the assertions are on the actual commands the
|
|
18
|
+
* runtime would put on the wire.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, expect, test } from 'bun:test';
|
|
22
|
+
import { BtwRuntime } from '@/server/lib/btw/runtime.server';
|
|
23
|
+
import type { RpcProcess } from '@/server/lib/omp/rpc/process';
|
|
24
|
+
import type { BtwFrame } from '@/shared/types';
|
|
25
|
+
|
|
26
|
+
interface SentCommand {
|
|
27
|
+
type: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A runtime whose child is alive and records every command sent to it. */
|
|
32
|
+
function makeRuntime(options: { alive: boolean }): { runtime: BtwRuntime; sent: SentCommand[]; setState: (state: unknown) => void } {
|
|
33
|
+
const sent: SentCommand[] = [];
|
|
34
|
+
let state: unknown = { model: { provider: 'kenari', id: 'deepseek-v4' }, thinkingLevel: 'high' };
|
|
35
|
+
const proc = {
|
|
36
|
+
get isAlive() {
|
|
37
|
+
return options.alive;
|
|
38
|
+
},
|
|
39
|
+
async sendCommand(command: SentCommand) {
|
|
40
|
+
sent.push(command);
|
|
41
|
+
return command.type === 'get_state' ? state : {};
|
|
42
|
+
},
|
|
43
|
+
sendFrame: () => {},
|
|
44
|
+
onFrame: () => () => {},
|
|
45
|
+
dispose: async () => {},
|
|
46
|
+
};
|
|
47
|
+
const runtime = new BtwRuntime(
|
|
48
|
+
{
|
|
49
|
+
topicId: 'topic',
|
|
50
|
+
sessionId: 'session',
|
|
51
|
+
parentSessionFile: '/tmp/parent.jsonl',
|
|
52
|
+
cwd: '/tmp',
|
|
53
|
+
model: { provider: 'kenari', id: 'deepseek-v4' },
|
|
54
|
+
thinkingLevel: 'high',
|
|
55
|
+
},
|
|
56
|
+
{ publish: (_frame: BtwFrame) => {}, stateChanged: () => {} },
|
|
57
|
+
);
|
|
58
|
+
// `proc` is the private field the runtime reads; the cast is the seam that
|
|
59
|
+
// keeps this a unit test instead of a spawned-child integration test.
|
|
60
|
+
(runtime as unknown as { proc: unknown }).proc = proc;
|
|
61
|
+
return {
|
|
62
|
+
runtime,
|
|
63
|
+
sent,
|
|
64
|
+
setState: (next) => {
|
|
65
|
+
state = next;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('BtwRuntime.start', () => {
|
|
71
|
+
test('reuses the live child instead of spawning a second one', async () => {
|
|
72
|
+
const { runtime } = makeRuntime({ alive: true });
|
|
73
|
+
// The seam this suite installs, read back as the type `start` returns.
|
|
74
|
+
const proc = (runtime as unknown as { proc: RpcProcess }).proc;
|
|
75
|
+
|
|
76
|
+
// A live child is returned as-is; `spawnChild` is never reached (it would
|
|
77
|
+
// throw here, since no omp binary is configured for this unit test).
|
|
78
|
+
expect(await runtime.start()).toBe(proc);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('BtwRuntime.retarget', () => {
|
|
83
|
+
test('re-sends both values when the chat moved on', async () => {
|
|
84
|
+
const { runtime, sent } = makeRuntime({ alive: true });
|
|
85
|
+
// Seed what the child currently reports (as `captureModel` would).
|
|
86
|
+
await runtime.retarget({ provider: 'kenari', id: 'deepseek-v4' }, 'high');
|
|
87
|
+
sent.length = 0;
|
|
88
|
+
|
|
89
|
+
await runtime.retarget({ provider: 'anthropic', id: 'claude-sonnet-5' }, 'low');
|
|
90
|
+
|
|
91
|
+
expect(sent.map((command) => command.type)).toEqual(['set_model', 'set_thinking_level']);
|
|
92
|
+
expect(sent[0]).toMatchObject({ provider: 'anthropic', modelId: 'claude-sonnet-5' });
|
|
93
|
+
expect(sent[1]).toMatchObject({ level: 'low' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('sends nothing when the child already runs them', async () => {
|
|
97
|
+
const { runtime, sent } = makeRuntime({ alive: true });
|
|
98
|
+
await runtime.retarget({ provider: 'kenari', id: 'deepseek-v4' }, 'high');
|
|
99
|
+
sent.length = 0;
|
|
100
|
+
|
|
101
|
+
await runtime.retarget({ provider: 'kenari', id: 'deepseek-v4' }, 'high');
|
|
102
|
+
|
|
103
|
+
expect(sent).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('records the values for the next spawn when no child is alive', async () => {
|
|
107
|
+
const { runtime, sent } = makeRuntime({ alive: false });
|
|
108
|
+
|
|
109
|
+
await runtime.retarget({ provider: 'anthropic', id: 'claude-sonnet-5' }, 'auto');
|
|
110
|
+
|
|
111
|
+
expect(sent).toHaveLength(0);
|
|
112
|
+
// The spawn reads these; nothing was sent to a dead process.
|
|
113
|
+
expect((runtime as unknown as { context: { model?: unknown; thinkingLevel?: string } }).context.model)
|
|
114
|
+
.toEqual({ provider: 'anthropic', id: 'claude-sonnet-5' });
|
|
115
|
+
expect((runtime as unknown as { context: { thinkingLevel?: string } }).context.thinkingLevel).toBe('auto');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('carries `auto` through to the child rather than dropping it', async () => {
|
|
119
|
+
const { runtime, sent } = makeRuntime({ alive: true });
|
|
120
|
+
await runtime.retarget(undefined, 'high');
|
|
121
|
+
sent.length = 0;
|
|
122
|
+
|
|
123
|
+
await runtime.retarget(undefined, 'auto');
|
|
124
|
+
|
|
125
|
+
expect(sent.map((command) => command.type)).toEqual(['set_thinking_level']);
|
|
126
|
+
expect(sent[0]).toMatchObject({ level: 'auto' });
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -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
|
}
|