ompchamber 3.3.3 → 3.5.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 +315 -308
- package/dist/client/static/js/index-vx0ernrv.css +1 -0
- package/dist/client/static/js/{index-8ejde0b7.js → index-w0cem7n5.js} +641 -641
- package/package.json +1 -1
- package/src/client/components/common/session-actions-menu/index.tsx +201 -0
- package/src/client/components/common/session-delete-modal/index.tsx +128 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
- 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/client/hooks/workspace/session-delete.ts +94 -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/purge.server.ts +56 -0
- 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/fs/path-segment.test.ts +100 -0
- package/src/server/lib/fs/path-segment.ts +45 -0
- 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/delete.server.ts +136 -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/lib/omp/session/reader.ts +13 -0
- package/src/server/routes/agent/command.ts +9 -1
- package/src/server/routes/btw/topics.ts +2 -59
- package/src/server/routes/sessions/delete.test.ts +84 -0
- package/src/server/routes/sessions/delete.ts +83 -0
- package/src/server/routes/sessions/index.ts +5 -0
- 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/lib/workspace/session-state/store.ts +20 -0
- package/src/shared/types/btw.ts +13 -34
- package/dist/client/static/js/index-bsxxt89j.css +0 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Session deletion, shared by the desktop session row and the mobile one.
|
|
8
|
+
*
|
|
9
|
+
* Both sidebars render the same confirmation dialog and issue the same request,
|
|
10
|
+
* so the state machine (which row is pending, in-flight, why it was refused)
|
|
11
|
+
* lives here rather than in two components that would drift on the URL, the
|
|
12
|
+
* verb, or the event they dispatch afterwards.
|
|
13
|
+
*
|
|
14
|
+
* Two follow-ups are load-bearing and both happen only on success:
|
|
15
|
+
*
|
|
16
|
+
* - `omp:workspace-updated` refreshes the sidebar list. The deleted row is
|
|
17
|
+
* gone from disk, but the client still holds the previous snapshot.
|
|
18
|
+
* - when the deleted session is the one on screen, `?sessionId=` is dropped.
|
|
19
|
+
* Leaving the URL pointing at a removed session renders the chat's "not
|
|
20
|
+
* found" state over a row the user just deleted, and every panel keyed to
|
|
21
|
+
* that id keeps fetching for it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useCallback, useState } from 'preact/hooks';
|
|
25
|
+
import { useSearchParams } from '@/client/lib/router/search-params';
|
|
26
|
+
import { forgetSession } from '@/shared/lib/workspace/session-state/store';
|
|
27
|
+
import type { SessionItemData } from '@/shared/types';
|
|
28
|
+
|
|
29
|
+
export interface SessionDeleteActions {
|
|
30
|
+
/** The session awaiting confirmation, or null when the dialog is closed. */
|
|
31
|
+
pending: SessionItemData | null;
|
|
32
|
+
/** True while the DELETE request is in flight. */
|
|
33
|
+
isDeleting: boolean;
|
|
34
|
+
/** The server's refusal reason, shown inside the dialog. */
|
|
35
|
+
error: string | null;
|
|
36
|
+
/** Open the confirmation for this session. */
|
|
37
|
+
requestDelete: (session: SessionItemData) => void;
|
|
38
|
+
/** Close it without deleting. */
|
|
39
|
+
cancelDelete: () => void;
|
|
40
|
+
/** Issue the delete. Resolves once the request settled. */
|
|
41
|
+
confirmDelete: () => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useSessionDelete(): SessionDeleteActions {
|
|
45
|
+
const [pending, setPending] = useState<SessionItemData | null>(null);
|
|
46
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
49
|
+
|
|
50
|
+
const requestDelete = useCallback((session: SessionItemData) => {
|
|
51
|
+
setError(null);
|
|
52
|
+
setPending(session);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
const cancelDelete = useCallback(() => {
|
|
56
|
+
setError(null);
|
|
57
|
+
setPending(null);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const confirmDelete = useCallback(async () => {
|
|
61
|
+
if (!pending || isDeleting) return;
|
|
62
|
+
const sessionId = String(pending.id);
|
|
63
|
+
setIsDeleting(true);
|
|
64
|
+
setError(null);
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
|
|
67
|
+
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
// The refusal reasons the server gives (mid-run, pending chat) are
|
|
70
|
+
// actionable, so they stay on screen instead of closing the dialog.
|
|
71
|
+
setError(body?.error || `Could not delete the session (HTTP ${res.status}).`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
forgetSession(sessionId);
|
|
75
|
+
if (searchParams.get('sessionId') === sessionId) {
|
|
76
|
+
setSearchParams((prev) => {
|
|
77
|
+
const next = new URLSearchParams(prev);
|
|
78
|
+
next.delete('sessionId');
|
|
79
|
+
// A subagent transcript belongs to the session being removed.
|
|
80
|
+
next.delete('subagent');
|
|
81
|
+
return next;
|
|
82
|
+
}, { replace: true });
|
|
83
|
+
}
|
|
84
|
+
setPending(null);
|
|
85
|
+
window.dispatchEvent(new CustomEvent('omp:workspace-updated'));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
setError(err instanceof Error ? err.message : 'Could not reach the server.');
|
|
88
|
+
} finally {
|
|
89
|
+
setIsDeleting(false);
|
|
90
|
+
}
|
|
91
|
+
}, [pending, isDeleting, searchParams, setSearchParams]);
|
|
92
|
+
|
|
93
|
+
return { pending, isDeleting, error, requestDelete, cancelDelete, confirmDelete };
|
|
94
|
+
}
|
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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'
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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('
|
|
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('
|
|
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' : '
|
|
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, '
|
|
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
|
|
117
|
-
*
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
10
|
-
* (`packages/coding-agent/src/prompts/system/btw-user.md`),
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Deleting a session's side questions.
|
|
8
|
+
*
|
|
9
|
+
* A side question is keyed to the parent session but stored outside it — rows
|
|
10
|
+
* in `btw_topics`/`btw_turns`, a live omp child per topic, and a private
|
|
11
|
+
* transcript directory under `<db dir>/btw/<sessionId>/<topicId>/`. Deleting
|
|
12
|
+
* the chat session therefore leaves all three behind unless they are purged
|
|
13
|
+
* with it: orphan turns still readable in the database, and on disk a full
|
|
14
|
+
* copy of the very transcript the user just deleted.
|
|
15
|
+
*
|
|
16
|
+
* Kept out of `service.server.ts` (which owns the interactive topic
|
|
17
|
+
* operations) so that file's growth stays attributable.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { listBtwTopics, deleteBtwTopic } from '@/server/lib/btw/store.server';
|
|
22
|
+
import { forgetBtwRuntime, getBtwRuntime } from '@/server/lib/btw/registry.server';
|
|
23
|
+
import { getBtwRoot, removeBtwWorkspace, resolveBtwWorkspacePaths } from '@/server/lib/btw/session-copy.server';
|
|
24
|
+
import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Drop every topic of `sessionId`, its child, its rows, and its workspace.
|
|
28
|
+
*
|
|
29
|
+
* `sessionId` MUST name one child of the btw root. The last step below removes
|
|
30
|
+
* `join(getBtwRoot(), sessionId)` unconditionally, and `join(root, '.')` is
|
|
31
|
+
* `root` itself — so an id of `.` (or any id with a separator) turns this
|
|
32
|
+
* per-session purge into "delete every session's side questions on disk". The
|
|
33
|
+
* route validates its id; this check is the independent second gate, because
|
|
34
|
+
* this function is reachable on its own and its failure mode is other
|
|
35
|
+
* sessions' data.
|
|
36
|
+
*/
|
|
37
|
+
export async function purgeBtwForSession(sessionId: string): Promise<number> {
|
|
38
|
+
if (!isSinglePathSegment(sessionId)) return 0;
|
|
39
|
+
|
|
40
|
+
const topics = await listBtwTopics(sessionId);
|
|
41
|
+
for (const topic of topics) {
|
|
42
|
+
const runtime = getBtwRuntime(topic.id);
|
|
43
|
+
if (runtime) {
|
|
44
|
+
// Disposes the child; a running turn is settled as cancelled rather than
|
|
45
|
+
// left to write into a transcript this purge is about to remove.
|
|
46
|
+
await runtime.dispose();
|
|
47
|
+
forgetBtwRuntime(topic.id);
|
|
48
|
+
}
|
|
49
|
+
await deleteBtwTopic(topic.id);
|
|
50
|
+
await removeBtwWorkspace((await resolveBtwWorkspacePaths(sessionId, topic.id)).dir);
|
|
51
|
+
}
|
|
52
|
+
// The per-session directory as a whole: a topic whose row is already gone can
|
|
53
|
+
// still have a workspace (a crash between the two, a manual row delete).
|
|
54
|
+
await removeBtwWorkspace(path.join(await getBtwRoot(), sessionId));
|
|
55
|
+
return topics.length;
|
|
56
|
+
}
|
|
@@ -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
|
|
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
|
|