ompchamber 0.7.0 → 1.0.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/static/css/index.e880517fcd.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/g.a06ca3b3e9.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/ot.84c71b308f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +12 -0
- package/dist/client/static/js/index.0de15c5e17.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +2 -1
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +11 -4
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +11 -4
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +124 -108
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/omp/session-list.tsx +23 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +23 -34
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +18 -28
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +13 -27
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/client/tailwind.css +38 -12
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/omp/rpc/manager.ts +72 -6
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +10 -4
- package/src/server/lib/queue/delivery.server.ts +114 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +1 -1
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/workspace/session-state/store.ts +1 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/omp/agent.ts +14 -2
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +6 -2
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/g.86e2256a70.js +0 -2
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/uf.24ca3d49c3.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.eccad0b70a.js +0 -33
- package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → ms.98960af4eb.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.0de15c5e17.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Terminal WebSocket: the only transport between a terminal panel and its PTY.
|
|
8
|
+
*
|
|
9
|
+
* Text frames are JSON control, binary frames are raw PTY bytes in both
|
|
10
|
+
* directions. The client's first frame is `attach`, which also creates the
|
|
11
|
+
* shell when the id is new — one round trip, and no separate create endpoint to
|
|
12
|
+
* race against.
|
|
13
|
+
*
|
|
14
|
+
* The upgrade is origin-checked. Browsers happily open a WebSocket to
|
|
15
|
+
* `localhost` from any page, and there is no preflight to stop them, so a
|
|
16
|
+
* cross-origin page could otherwise drive a shell on this machine.
|
|
17
|
+
*
|
|
18
|
+
* Per-connection state lives on `ws.data`, not in a `WeakMap` keyed by the `ws`
|
|
19
|
+
* object: Elysia hands a *different* wrapper to `message`/`close` than the one
|
|
20
|
+
* `open` received (verified on 1.4.30), so a WeakMap lookup finds nothing and
|
|
21
|
+
* every frame is silently dropped. `ws.data` is the same object throughout.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Elysia, t } from 'elysia';
|
|
25
|
+
import {
|
|
26
|
+
attachTerminal,
|
|
27
|
+
closeTerminal,
|
|
28
|
+
detachTerminal,
|
|
29
|
+
resizeTerminalDebounced,
|
|
30
|
+
writeTerminalInput,
|
|
31
|
+
} from '@/server/lib/terminal/runtime.server';
|
|
32
|
+
import { TerminalRuntimeError, type TerminalViewer } from '@/server/lib/terminal/session.server';
|
|
33
|
+
import {
|
|
34
|
+
decodeClientFrame,
|
|
35
|
+
encodeServerFrame,
|
|
36
|
+
isValidTerminalId,
|
|
37
|
+
} from '@/shared/lib/workspace/terminal/protocol';
|
|
38
|
+
|
|
39
|
+
interface Attachment {
|
|
40
|
+
id: string;
|
|
41
|
+
viewer: TerminalViewer;
|
|
42
|
+
/** One attach per socket; a duplicate would replay the scrollback twice. */
|
|
43
|
+
attached: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Elysia's ws context, narrowed to the per-connection store this route adds. */
|
|
47
|
+
type TerminalWsData = { params: { terminalId: string }; terminal?: Attachment };
|
|
48
|
+
|
|
49
|
+
export const terminalWsRoutes = new Elysia({ prefix: '/api/terminal' }).ws('/:terminalId/ws', {
|
|
50
|
+
params: t.Object({ terminalId: t.String() }),
|
|
51
|
+
|
|
52
|
+
beforeHandle({ request, status }) {
|
|
53
|
+
if (!isSameOriginUpgrade(request)) return status(403, 'Cross-origin terminal upgrade rejected');
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
open(ws) {
|
|
57
|
+
const data = ws.data as unknown as TerminalWsData;
|
|
58
|
+
const id = data.params.terminalId;
|
|
59
|
+
if (!isValidTerminalId(id)) {
|
|
60
|
+
ws.close();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
data.terminal = {
|
|
64
|
+
id,
|
|
65
|
+
attached: false,
|
|
66
|
+
viewer: {
|
|
67
|
+
send(frame) {
|
|
68
|
+
try {
|
|
69
|
+
ws.send(frame);
|
|
70
|
+
} catch {
|
|
71
|
+
// Socket closed mid-send; the close handler detaches.
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async message(ws, raw) {
|
|
79
|
+
const attachment = (ws.data as unknown as TerminalWsData).terminal;
|
|
80
|
+
if (!attachment) return;
|
|
81
|
+
|
|
82
|
+
// Raw PTY input (keystrokes, paste, mouse reports) — never JSON-escaped.
|
|
83
|
+
if (raw instanceof Uint8Array) {
|
|
84
|
+
writeTerminalInput(attachment.id, raw);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const frame = decodeClientFrame(raw);
|
|
89
|
+
if (!frame) return;
|
|
90
|
+
|
|
91
|
+
if (frame.t === 'resize') {
|
|
92
|
+
resizeTerminalDebounced(attachment.id, frame.cols, frame.rows);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (frame.t === 'close') {
|
|
97
|
+
closeTerminal(attachment.id);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (attachment.attached) return;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const { snapshot, replay } = await attachTerminal(attachment.viewer, {
|
|
105
|
+
id: attachment.id,
|
|
106
|
+
cols: frame.cols,
|
|
107
|
+
rows: frame.rows,
|
|
108
|
+
root: frame.root,
|
|
109
|
+
repo: frame.repo,
|
|
110
|
+
theme: frame.theme,
|
|
111
|
+
});
|
|
112
|
+
attachment.attached = true;
|
|
113
|
+
ws.send(encodeServerFrame({ t: 'ready', ...snapshot, replayBytes: replay.length }));
|
|
114
|
+
// Ordering is safe without sequence numbers: attach captured the history
|
|
115
|
+
// and registered this viewer in one synchronous block, so nothing can be
|
|
116
|
+
// delivered between this frame and the replay that follows it.
|
|
117
|
+
if (replay.length > 0) ws.send(replay);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const code = error instanceof TerminalRuntimeError ? error.code : 'unknown';
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
ws.send(encodeServerFrame({ t: 'error', code, message }));
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
close(ws) {
|
|
126
|
+
const data = ws.data as unknown as TerminalWsData;
|
|
127
|
+
const attachment = data.terminal;
|
|
128
|
+
if (!attachment) return;
|
|
129
|
+
data.terminal = undefined;
|
|
130
|
+
detachTerminal(attachment.viewer, attachment.id);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A WebSocket upgrade carries the page's `Origin`, and no preflight protects
|
|
136
|
+
* it. Requests without one (curl, tests) are allowed: they are not a browser
|
|
137
|
+
* being tricked into dialing localhost.
|
|
138
|
+
*/
|
|
139
|
+
function isSameOriginUpgrade(request: Request): boolean {
|
|
140
|
+
const origin = request.headers.get('origin');
|
|
141
|
+
if (!origin) return true;
|
|
142
|
+
const host = request.headers.get('host');
|
|
143
|
+
if (!host) return false;
|
|
144
|
+
try {
|
|
145
|
+
return new URL(origin).host === host;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import type { ToolCallData } from '@/shared/types';
|
|
9
|
+
import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
|
|
10
|
+
import {
|
|
11
|
+
groupAskFrames,
|
|
12
|
+
parseAskQuestions,
|
|
13
|
+
parseAskResult,
|
|
14
|
+
normalizeAskText,
|
|
15
|
+
} from '@/shared/lib/chat/ask-questions';
|
|
16
|
+
|
|
17
|
+
function askTool(input: unknown, extra: Partial<ToolCallData> = {}): ToolCallData {
|
|
18
|
+
return { id: 'call_ask_1', type: 'ask', title: 'ask', name: 'ask', input: input as Record<string, unknown>, ...extra };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function selectFrame(id: string, title: string, options: string[] = ['A', 'B']): ExtensionUiDialogRequest {
|
|
22
|
+
return { type: 'extension_ui_request', id, method: 'select', title, options };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const THREE_QUESTIONS = {
|
|
26
|
+
questions: [
|
|
27
|
+
{
|
|
28
|
+
id: 'language',
|
|
29
|
+
header: 'Language',
|
|
30
|
+
question: 'Which language should we use for this test?',
|
|
31
|
+
options: [{ label: 'Python', description: 'Use Python for testing' }, { label: 'TypeScript' }],
|
|
32
|
+
recommended: 0,
|
|
33
|
+
},
|
|
34
|
+
{ id: 'style', question: 'What answer style do you prefer for testing?', options: [{ label: 'Balanced' }] },
|
|
35
|
+
{ id: 'confirm', question: 'Did this render correctly?', options: [{ label: 'Yes' }, { label: 'No' }] },
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('parseAskQuestions', () => {
|
|
40
|
+
test('reads every question, not just the first', () => {
|
|
41
|
+
const questions = parseAskQuestions(askTool(THREE_QUESTIONS));
|
|
42
|
+
expect(questions.map((q) => q.id)).toEqual(['language', 'style', 'confirm']);
|
|
43
|
+
expect(questions[0].options[0]).toEqual({ label: 'Python', description: 'Use Python for testing' });
|
|
44
|
+
expect(questions[0].recommended).toBe(0);
|
|
45
|
+
expect(questions[1].multi).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('is empty for a non-ask payload, so the card falls back to the raw result', () => {
|
|
49
|
+
expect(parseAskQuestions(askTool({ question: 'single legacy shape' }))).toEqual([]);
|
|
50
|
+
expect(parseAskQuestions(askTool(undefined))).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('drops entries without question text instead of rendering a blank block', () => {
|
|
54
|
+
const questions = parseAskQuestions(askTool({ questions: [{ id: 'a' }, { id: 'b', question: ' Keep me ' }] }));
|
|
55
|
+
expect(questions).toEqual([expect.objectContaining({ id: 'b', question: 'Keep me' })]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('normalizeAskText', () => {
|
|
60
|
+
test('strips the progress suffix and the multi-select prefix omp decorates titles with', () => {
|
|
61
|
+
expect(normalizeAskText('Which language? (2/3)')).toBe('which language?');
|
|
62
|
+
expect(normalizeAskText('(2 selected) Which language?')).toBe('which language?');
|
|
63
|
+
expect(normalizeAskText('(1 selected) Pick one (3/3)')).toBe('pick one');
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('groupAskFrames', () => {
|
|
68
|
+
const questions = parseAskQuestions(askTool(THREE_QUESTIONS));
|
|
69
|
+
|
|
70
|
+
test('puts each dialog on the question that raised it', () => {
|
|
71
|
+
const frames = [
|
|
72
|
+
selectFrame('f1', 'Which language should we use for this test? (1/3)'),
|
|
73
|
+
selectFrame('f2', 'What answer style do you prefer for testing? (2/3)'),
|
|
74
|
+
selectFrame('f3', 'Did this render correctly? (3/3)'),
|
|
75
|
+
];
|
|
76
|
+
const groups = groupAskFrames(questions, frames);
|
|
77
|
+
expect(groups.map((group) => group.map((frame) => frame.id))).toEqual([['f1'], ['f2'], ['f3']]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('keeps the free-text follow-up dialog on the question that asked for it', () => {
|
|
81
|
+
const frames: ExtensionUiDialogRequest[] = [
|
|
82
|
+
selectFrame('f1', 'Which language should we use for this test? (1/3)'),
|
|
83
|
+
{ type: 'extension_ui_request', id: 'f1b', method: 'editor', title: 'Which language should we use for this test?' },
|
|
84
|
+
selectFrame('f2', 'What answer style do you prefer for testing? (2/3)'),
|
|
85
|
+
];
|
|
86
|
+
const groups = groupAskFrames(questions, frames);
|
|
87
|
+
expect(groups[0].map((frame) => frame.id)).toEqual(['f1', 'f1b']);
|
|
88
|
+
expect(groups[1].map((frame) => frame.id)).toEqual(['f2']);
|
|
89
|
+
expect(groups[2]).toEqual([]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('falls back to order when a rebuild decorates the title in a way we cannot parse', () => {
|
|
93
|
+
const frames = [
|
|
94
|
+
selectFrame('f1', 'Some unknown header'),
|
|
95
|
+
selectFrame('f2', 'Another unknown header'),
|
|
96
|
+
selectFrame('f3', 'A third unknown header'),
|
|
97
|
+
];
|
|
98
|
+
const groups = groupAskFrames(questions, frames);
|
|
99
|
+
expect(groups.map((group) => group.map((frame) => frame.id))).toEqual([['f1'], ['f2'], ['f3']]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('keeps repeated multi-select dialogs on the question being answered', () => {
|
|
103
|
+
const frames = [
|
|
104
|
+
selectFrame('f1', '(1 selected) Which language should we use for this test? (1/3)'),
|
|
105
|
+
selectFrame('f2', '(1 selected) Which language should we use for this test? (1/3)'),
|
|
106
|
+
selectFrame('f3', 'What answer style do you prefer for testing? (2/3)'),
|
|
107
|
+
];
|
|
108
|
+
const groups = groupAskFrames(questions, frames);
|
|
109
|
+
expect(groups[0].map((frame) => frame.id)).toEqual(['f1', 'f2']);
|
|
110
|
+
expect(groups[1].map((frame) => frame.id)).toEqual(['f3']);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('parseAskResult', () => {
|
|
115
|
+
const questions = parseAskQuestions(askTool(THREE_QUESTIONS));
|
|
116
|
+
|
|
117
|
+
test('reads every recorded answer from details.results', () => {
|
|
118
|
+
const answers = parseAskResult(askTool(THREE_QUESTIONS, {
|
|
119
|
+
details: {
|
|
120
|
+
results: [
|
|
121
|
+
{ id: 'language', question: 'Which language?', selectedOptions: ['Python'] },
|
|
122
|
+
{ id: 'style', question: 'Style?', selectedOptions: [], customInput: 'Dadadada' },
|
|
123
|
+
{ id: 'confirm', question: 'Correct?', selectedOptions: ['Yes'], timedOut: true },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
}), questions);
|
|
127
|
+
|
|
128
|
+
expect(answers[0]).toEqual({ selectedOptions: ['Python'], customInput: undefined, timedOut: false });
|
|
129
|
+
expect(answers[1]?.customInput).toBe('Dadadada');
|
|
130
|
+
expect(answers[2]?.timedOut).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('reads the flat shape omp returns for a single-question ask', () => {
|
|
134
|
+
const single = parseAskQuestions(askTool({ questions: [THREE_QUESTIONS.questions[0]] }));
|
|
135
|
+
const answers = parseAskResult(askTool({}, { details: { selectedOptions: ['Go'], multi: false } }), single);
|
|
136
|
+
expect(answers[0]?.selectedOptions).toEqual(['Go']);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('leaves every question unanswered while the ask is still running', () => {
|
|
140
|
+
expect(parseAskResult(askTool(THREE_QUESTIONS), questions)).toEqual([null, null, null]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('falls back to the rendered text when details never reached the client', () => {
|
|
144
|
+
const output = 'User answers:\nlanguage: Python\nstyle: Balanced\nconfirm: Yes';
|
|
145
|
+
const answers = parseAskResult(askTool(THREE_QUESTIONS, { output }), questions);
|
|
146
|
+
expect(answers.map((answer) => answer?.selectedOptions)).toEqual([['Python'], ['Balanced'], ['Yes']]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('reads a typed custom answer out of the rendered text', () => {
|
|
150
|
+
const output = 'User answers:\nlanguage: Python\nstyle: "Dadadada"\nconfirm: Yes';
|
|
151
|
+
const answers = parseAskResult(askTool(THREE_QUESTIONS, { output }), questions);
|
|
152
|
+
expect(answers[1]).toEqual({ selectedOptions: [], customInput: 'Dadadada' });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('reads the single-question result shapes', () => {
|
|
156
|
+
const single = parseAskQuestions(askTool({ questions: [THREE_QUESTIONS.questions[0]] }));
|
|
157
|
+
expect(parseAskResult(askTool({}, { output: 'User selected: Python' }), single)[0]?.selectedOptions).toEqual(['Python']);
|
|
158
|
+
expect(parseAskResult(askTool({}, { output: 'User provided custom input: Dadadada' }), single)[0]?.customInput)
|
|
159
|
+
.toBe('Dadadada');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('never reads a failure message as an answer', () => {
|
|
163
|
+
const failed = askTool(THREE_QUESTIONS, {
|
|
164
|
+
output: 'Ask tool was cancelled by the user',
|
|
165
|
+
status: 'error' as const,
|
|
166
|
+
});
|
|
167
|
+
expect(parseAskResult(failed, questions)).toEqual([null, null, null]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pure helpers for omp's `ask` tool: read its `questions` payload, pair the
|
|
8
|
+
* per-question dialogs omp raises for them back to the question they belong to,
|
|
9
|
+
* and read the recorded answers out of the tool result.
|
|
10
|
+
*
|
|
11
|
+
* The pairing is load-bearing, and the reason is worth stating: omp's RPC UI
|
|
12
|
+
* context implements `select`/`confirm`/`input`/`editor` but NOT the rich
|
|
13
|
+
* `askDialog` primitive the TUI uses. `AskTool` therefore falls back to its
|
|
14
|
+
* per-question loop, and over RPC a multi-question ask reaches the chamber as
|
|
15
|
+
* ONE blocking `select` frame per question, in order — never as one payload. The
|
|
16
|
+
* frame carries the question as its `title` (plus a ` (2/3)` progress suffix and
|
|
17
|
+
* a `(2 selected) ` prefix for multi-select questions), which is what lets the
|
|
18
|
+
* inline card put each frame back on the question it asked.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ToolCallData } from '@/shared/types';
|
|
22
|
+
import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
|
|
23
|
+
|
|
24
|
+
export interface AskOption {
|
|
25
|
+
label: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
preview?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AskQuestion {
|
|
31
|
+
id?: string;
|
|
32
|
+
/** Short chip omp shows above the question ("Authentication"). */
|
|
33
|
+
header?: string;
|
|
34
|
+
question: string;
|
|
35
|
+
options: AskOption[];
|
|
36
|
+
multi: boolean;
|
|
37
|
+
/** Index of the option omp marks ` (Recommended)`. */
|
|
38
|
+
recommended?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AskAnswer {
|
|
42
|
+
selectedOptions: string[];
|
|
43
|
+
customInput?: string;
|
|
44
|
+
/** omp auto-selected the answer after `ask.timeout` elapsed. */
|
|
45
|
+
timedOut?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The free-text escape hatch omp appends to every question's option list. */
|
|
49
|
+
const OTHER_OPTION = /^other\b/i;
|
|
50
|
+
/** Trailing ` (2/3)` omp adds while looping over several questions. */
|
|
51
|
+
const PROGRESS_SUFFIX = /\s+\(\d+\s*\/\s*\d+\)$/;
|
|
52
|
+
/** Leading `(2 selected) ` omp prepends to a multi-select question's title. */
|
|
53
|
+
const SELECTED_COUNT_PREFIX = /^\(\d+\s+selected\)\s*/;
|
|
54
|
+
|
|
55
|
+
/** True for the literal "Other (type your own)" row, which is a prompt to type
|
|
56
|
+
* an answer rather than an answer itself. */
|
|
57
|
+
export function isOtherOption(label: string): boolean {
|
|
58
|
+
return OTHER_OPTION.test(label.trim());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Normalized question text, with the decorations omp adds to a live dialog
|
|
62
|
+
* title stripped, so a frame can be matched against the question that raised it. */
|
|
63
|
+
export function normalizeAskText(value: string): string {
|
|
64
|
+
return value
|
|
65
|
+
.replace(SELECTED_COUNT_PREFIX, '')
|
|
66
|
+
.replace(PROGRESS_SUFFIX, '')
|
|
67
|
+
.replace(/\s+/g, ' ')
|
|
68
|
+
.trim()
|
|
69
|
+
.toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readOption(raw: unknown): AskOption | null {
|
|
73
|
+
if (typeof raw === 'string') {
|
|
74
|
+
const label = raw.trim();
|
|
75
|
+
return label ? { label } : null;
|
|
76
|
+
}
|
|
77
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
78
|
+
const record = raw as Record<string, unknown>;
|
|
79
|
+
const label = typeof record.label === 'string' ? record.label.trim() : '';
|
|
80
|
+
if (!label) return null;
|
|
81
|
+
return {
|
|
82
|
+
label,
|
|
83
|
+
description: typeof record.description === 'string' ? record.description : undefined,
|
|
84
|
+
preview: typeof record.preview === 'string' ? record.preview : undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** True for a tool call that is omp's `ask` tool. */
|
|
89
|
+
export function isAskToolCall(tool: ToolCallData): boolean {
|
|
90
|
+
const name = typeof tool.name === 'string' && tool.name ? tool.name : tool.type;
|
|
91
|
+
return name === 'ask' || tool.title === 'ask';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The `questions` array from an `ask` tool call's arguments. Empty when the tool
|
|
96
|
+
* is not an ask call or its arguments were truncated — callers fall back to the
|
|
97
|
+
* raw arguments/output rendering in that case.
|
|
98
|
+
*/
|
|
99
|
+
export function parseAskQuestions(tool: ToolCallData): AskQuestion[] {
|
|
100
|
+
const input = tool.input;
|
|
101
|
+
const record = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : null;
|
|
102
|
+
const raw = record && Array.isArray(record.questions) ? record.questions : [];
|
|
103
|
+
const questions: AskQuestion[] = [];
|
|
104
|
+
|
|
105
|
+
for (const entry of raw) {
|
|
106
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
107
|
+
const question = entry as Record<string, unknown>;
|
|
108
|
+
const text = typeof question.question === 'string' ? question.question.trim() : '';
|
|
109
|
+
if (!text) continue;
|
|
110
|
+
questions.push({
|
|
111
|
+
id: typeof question.id === 'string' ? question.id : undefined,
|
|
112
|
+
header: typeof question.header === 'string' && question.header.trim() ? question.header.trim() : undefined,
|
|
113
|
+
question: text,
|
|
114
|
+
options: Array.isArray(question.options)
|
|
115
|
+
? question.options.map(readOption).filter((option): option is AskOption => option !== null)
|
|
116
|
+
: [],
|
|
117
|
+
multi: question.multi === true,
|
|
118
|
+
recommended: typeof question.recommended === 'number' ? question.recommended : undefined,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return questions;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readAnswer(row: Record<string, unknown>): AskAnswer {
|
|
126
|
+
return {
|
|
127
|
+
selectedOptions: Array.isArray(row.selectedOptions)
|
|
128
|
+
? row.selectedOptions.filter((value): value is string => typeof value === 'string')
|
|
129
|
+
: [],
|
|
130
|
+
customInput: typeof row.customInput === 'string' && row.customInput ? row.customInput : undefined,
|
|
131
|
+
timedOut: row.timedOut === true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One `<id>: <answer>` line of a multi-question result. */
|
|
136
|
+
const ANSWER_LINE = /^([\w.-]+):\s*(.+)$/;
|
|
137
|
+
/** The single-question result's two prose shapes. */
|
|
138
|
+
const SELECTED_LINE = /^User selected:\s*(.+)$/m;
|
|
139
|
+
const CUSTOM_LINE = /^User provided custom input:\s*([\s\S]+)$/m;
|
|
140
|
+
|
|
141
|
+
/** `value` as omp renders it: a quoted string is typed input, a bare one is a
|
|
142
|
+
* picked label (comma-joined when the question allows several). */
|
|
143
|
+
function toAnswer(value: string, multi: boolean): AskAnswer {
|
|
144
|
+
const text = value.trim();
|
|
145
|
+
if (/^"[\s\S]*"$/.test(text)) return { selectedOptions: [], customInput: text.slice(1, -1) };
|
|
146
|
+
const labels = multi && text.includes(', ') ? text.split(', ') : [text];
|
|
147
|
+
return { selectedOptions: labels.map((label) => label.trim()).filter(Boolean) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Answers recovered from the tool's rendered text, used when `details` never
|
|
152
|
+
* reached this client. `details` is the richer source, but it is dropped by the
|
|
153
|
+
* live `tool_execution_end` frame on some omp builds; the text is the one part
|
|
154
|
+
* of a settled result every path carries, and it names each answer by question
|
|
155
|
+
* id (`language: Python`) or as a single sentence for a one-question ask.
|
|
156
|
+
*/
|
|
157
|
+
function parseAskOutputAnswers(output: string, questions: AskQuestion[]): (AskAnswer | null)[] {
|
|
158
|
+
const answers = questions.map((): AskAnswer | null => null);
|
|
159
|
+
const text = output.trim();
|
|
160
|
+
if (!text) return answers;
|
|
161
|
+
|
|
162
|
+
const byId = new Map<string, AskAnswer>();
|
|
163
|
+
for (const line of text.split('\n')) {
|
|
164
|
+
const match = ANSWER_LINE.exec(line.trim());
|
|
165
|
+
if (match) byId.set(match[1], toAnswer(match[2], questions.some((question) => question.multi)));
|
|
166
|
+
}
|
|
167
|
+
if (byId.size > 0) {
|
|
168
|
+
const ordered = [...byId.values()];
|
|
169
|
+
for (let index = 0; index < questions.length; index += 1) {
|
|
170
|
+
// The question id is the key omp prints; position is what is left when a
|
|
171
|
+
// payload carries no ids at all (omp requires them, so this is defensive).
|
|
172
|
+
const id = questions[index].id;
|
|
173
|
+
answers[index] = (id ? byId.get(id) : undefined) ?? ordered[index] ?? null;
|
|
174
|
+
}
|
|
175
|
+
return answers;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const selected = SELECTED_LINE.exec(text);
|
|
179
|
+
if (selected) {
|
|
180
|
+
answers[0] = toAnswer(selected[1], questions[0].multi);
|
|
181
|
+
return answers;
|
|
182
|
+
}
|
|
183
|
+
const custom = CUSTOM_LINE.exec(text);
|
|
184
|
+
if (custom) {
|
|
185
|
+
answers[0] = { selectedOptions: [], customInput: custom[1].trim() };
|
|
186
|
+
return answers;
|
|
187
|
+
}
|
|
188
|
+
if (questions.length === 1 && !text.includes('\n')) {
|
|
189
|
+
answers[0] = { selectedOptions: [text] };
|
|
190
|
+
}
|
|
191
|
+
return answers;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Answers recorded in an `ask` tool result, positionally aligned with
|
|
196
|
+
* `questions`. omp returns `details.results[]` for a multi-question ask and the
|
|
197
|
+
* bare answer fields for a single-question one; the rendered text backs both up.
|
|
198
|
+
*/
|
|
199
|
+
export function parseAskResult(tool: ToolCallData, questions: AskQuestion[]): (AskAnswer | null)[] {
|
|
200
|
+
const answers = questions.map((): AskAnswer | null => null);
|
|
201
|
+
if (questions.length === 0) return answers;
|
|
202
|
+
|
|
203
|
+
const details = tool.details ?? {};
|
|
204
|
+
const results = Array.isArray(details.results) ? (details.results as Record<string, unknown>[]) : null;
|
|
205
|
+
if (results) {
|
|
206
|
+
for (let index = 0; index < answers.length; index += 1) {
|
|
207
|
+
const row = results[index];
|
|
208
|
+
if (row && typeof row === 'object') answers[index] = readAnswer(row);
|
|
209
|
+
}
|
|
210
|
+
return answers;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (typeof details.selectedOptions !== 'undefined' || typeof details.customInput === 'string') {
|
|
214
|
+
answers[0] = readAnswer(details);
|
|
215
|
+
return answers;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A failed/cancelled ask answers nothing; its text is the reason, which would
|
|
219
|
+
// otherwise read as an answer for whichever id its first sentence names.
|
|
220
|
+
if (tool.status === 'error' || tool.status === 'aborted' || tool.isError) return answers;
|
|
221
|
+
|
|
222
|
+
return parseAskOutputAnswers(typeof tool.output === 'string' ? tool.output : '', questions);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Bucket the ask's dialogs by question index.
|
|
227
|
+
*
|
|
228
|
+
* Assignment follows omp's own order rather than pure title matching: a frame
|
|
229
|
+
* whose title names a question moves the cursor onto it, and any other frame
|
|
230
|
+
* (the `editor`/`input` dialog omp opens after "Other (type your own)") belongs
|
|
231
|
+
* to whichever question was last named. A title that names an EARLIER question
|
|
232
|
+
* is ignored — omp walks the questions forward and never re-asks one, so a
|
|
233
|
+
* repeat can only be a multi-select re-render of the question already in hand.
|
|
234
|
+
*/
|
|
235
|
+
export function groupAskFrames(
|
|
236
|
+
questions: AskQuestion[],
|
|
237
|
+
frames: ExtensionUiDialogRequest[],
|
|
238
|
+
): ExtensionUiDialogRequest[][] {
|
|
239
|
+
const groups = questions.map((): ExtensionUiDialogRequest[] => []);
|
|
240
|
+
if (questions.length === 0) return groups;
|
|
241
|
+
const titles = questions.map((question) => normalizeAskText(question.question));
|
|
242
|
+
|
|
243
|
+
let cursor = 0;
|
|
244
|
+
for (const frame of frames) {
|
|
245
|
+
const title = typeof frame.title === 'string' ? normalizeAskText(frame.title) : '';
|
|
246
|
+
const index = titles.indexOf(title);
|
|
247
|
+
if (index > cursor) {
|
|
248
|
+
cursor = index;
|
|
249
|
+
} else if (
|
|
250
|
+
index === -1 &&
|
|
251
|
+
frame.method === 'select' &&
|
|
252
|
+
groups[cursor].some((held) => held.method === 'select') &&
|
|
253
|
+
cursor + 1 < groups.length
|
|
254
|
+
) {
|
|
255
|
+
// Title matching is the authority, but a question is asked by exactly one
|
|
256
|
+
// `select` — a second one that matches nothing is the NEXT question with
|
|
257
|
+
// decorations this build does not know about, not a repeat.
|
|
258
|
+
cursor += 1;
|
|
259
|
+
}
|
|
260
|
+
groups[cursor].push(frame);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return groups;
|
|
264
|
+
}
|
|
@@ -25,10 +25,14 @@ const ABORTED_TURN = {
|
|
|
25
25
|
function makeDeps() {
|
|
26
26
|
const ended: ChatMessageData[] = [];
|
|
27
27
|
const activity: string[] = [];
|
|
28
|
+
const commandOutputs: string[] = [];
|
|
29
|
+
let settledCount = 0;
|
|
28
30
|
let state: OmpAgentState = { isGenerating: false, connected: true, error: null };
|
|
29
31
|
const callbacks: OmpAgentCallbacks = {
|
|
30
32
|
onMessageEnd: (msg) => ended.push(msg),
|
|
31
33
|
onActivity: (verb) => activity.push(verb),
|
|
34
|
+
onPromptSettled: () => { settledCount += 1; },
|
|
35
|
+
onCommandOutput: (text) => commandOutputs.push(text),
|
|
32
36
|
};
|
|
33
37
|
const deps: OmpAgentFoldDeps = {
|
|
34
38
|
sessionId: 's1',
|
|
@@ -43,7 +47,7 @@ function makeDeps() {
|
|
|
43
47
|
currentThinkingLevelRef: { current: undefined },
|
|
44
48
|
fileMutatingCallsRef: { current: new Set<string>() },
|
|
45
49
|
};
|
|
46
|
-
return { deps, ended, activity };
|
|
50
|
+
return { deps, ended, activity, commandOutputs, settled: () => settledCount, state: () => state };
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
describe('toChatMessage error derivation', () => {
|
|
@@ -60,6 +64,38 @@ describe('toChatMessage error derivation', () => {
|
|
|
60
64
|
});
|
|
61
65
|
});
|
|
62
66
|
|
|
67
|
+
describe('foldAgentEvent prompt_result (builtin slash commands)', () => {
|
|
68
|
+
test('agentInvoked:false settles generating state and fires onPromptSettled', () => {
|
|
69
|
+
const { deps, state, settled } = makeDeps();
|
|
70
|
+
foldAgentEvent({ type: 'agent_start' }, deps);
|
|
71
|
+
expect(state().isGenerating).toBe(true);
|
|
72
|
+
foldAgentEvent({ type: 'prompt_result', agentInvoked: false }, deps);
|
|
73
|
+
expect(state().isGenerating).toBe(false);
|
|
74
|
+
expect(settled()).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('prompt_result with agentInvoked:true does not settle (a run follows)', () => {
|
|
78
|
+
const { deps, settled } = makeDeps();
|
|
79
|
+
foldAgentEvent({ type: 'prompt_result', agentInvoked: true }, deps);
|
|
80
|
+
expect(settled()).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('foldAgentEvent command_output (builtin slash output)', () => {
|
|
85
|
+
test('forwards trimmed text to onCommandOutput', () => {
|
|
86
|
+
const { deps, commandOutputs } = makeDeps();
|
|
87
|
+
foldAgentEvent({ type: 'command_output', text: 'Usage\nInput tokens: 0\n' }, deps);
|
|
88
|
+
expect(commandOutputs).toEqual(['Usage\nInput tokens: 0']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('drops empty output frames', () => {
|
|
92
|
+
const { deps, commandOutputs } = makeDeps();
|
|
93
|
+
foldAgentEvent({ type: 'command_output', text: ' ' }, deps);
|
|
94
|
+
foldAgentEvent({ type: 'command_output' }, deps);
|
|
95
|
+
expect(commandOutputs).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
63
99
|
describe('foldAgentEvent activity phrases', () => {
|
|
64
100
|
test('names the tool being executed, not a generic reasoning verb', () => {
|
|
65
101
|
const { deps, activity } = makeDeps();
|