ompchamber 3.3.3 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +275 -268
- package/dist/client/static/js/{index-8ejde0b7.js → index-h05z90mw.js} +581 -581
- package/package.json +1 -1
- package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
- package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
- package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
- package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
- package/src/client/components/workspace/btw-panel/index.tsx +20 -27
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
- package/src/client/hooks/chat/btw/index.ts +7 -73
- package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
- package/src/client/hooks/chat/btw/intercept.ts +5 -2
- package/src/client/hooks/chat/btw/mode.ts +16 -76
- package/src/client/hooks/chat/timeline/actions.ts +22 -30
- package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
- package/src/client/hooks/chat/timeline/send.ts +8 -1
- package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
- package/src/server/lib/btw/child.server.test.ts +68 -0
- package/src/server/lib/btw/child.server.ts +17 -16
- package/src/server/lib/btw/frames.test.ts +3 -3
- package/src/server/lib/btw/frames.ts +1 -1
- package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
- package/src/server/lib/btw/lifecycle.server.ts +41 -31
- package/src/server/lib/btw/prompt.ts +7 -11
- package/src/server/lib/btw/registry.server.ts +1 -9
- package/src/server/lib/btw/reply.ts +54 -0
- package/src/server/lib/btw/runtime.server.test.ts +128 -0
- package/src/server/lib/btw/runtime.server.ts +77 -86
- package/src/server/lib/btw/schema.server.test.ts +84 -0
- package/src/server/lib/btw/schema.server.ts +8 -0
- package/src/server/lib/btw/service.server.ts +47 -78
- package/src/server/lib/btw/store.server.ts +9 -20
- package/src/server/lib/omp/rpc/constants.ts +10 -0
- package/src/server/lib/omp/rpc/session-commands.ts +45 -0
- package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
- package/src/server/lib/omp/session/merge-stored.ts +70 -1
- package/src/server/lib/omp/session/messages.ts +27 -2
- package/src/server/routes/agent/command.ts +9 -1
- package/src/server/routes/btw/topics.ts +2 -59
- package/src/shared/lib/chat/btw/client.ts +0 -30
- package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
- package/src/shared/lib/chat/composer/tui-only.ts +138 -0
- package/src/shared/lib/chat/notice-text.ts +33 -0
- package/src/shared/lib/chat/omp/agent-events.ts +18 -37
- package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
- package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
- package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
- package/src/shared/lib/chat/timeline/command-output.ts +65 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
- package/src/shared/lib/omp/session/messages-map.ts +6 -1
- package/src/shared/types/btw.ts +13 -34
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Slash commands oh-my-pi implements only for its own TUI.
|
|
8
|
+
*
|
|
9
|
+
* omp's registry carries a `handle` (text/ACP mode) for 42 of its 82 builtins
|
|
10
|
+
* and `handleTui` alone for the other 40. `executeAcpBuiltinSlashCommand`
|
|
11
|
+
* requires `handle`, and `buildAvailableSlashCommands` skips entries without
|
|
12
|
+
* one — which is why none of these names appear in the composer popup.
|
|
13
|
+
*
|
|
14
|
+
* The popup is not the whole surface: a name can still be typed, pasted from
|
|
15
|
+
* the docs, or arrive through the follow-up queue. RPC forwards such a prompt
|
|
16
|
+
* to `session.prompt()` verbatim, where the only slash handling left is file
|
|
17
|
+
* commands and prompt templates — so `/plan` reaches the MODEL as literal text
|
|
18
|
+
* and it improvises a turn around it. Measured on omp 18.3.5: `/hotkeys`
|
|
19
|
+
* started a run whose transcript opened with the raw string, the model read
|
|
20
|
+
* `omp://slash-command-internals.md` and ran bash and grep to work out what it
|
|
21
|
+
* meant. `/clear` cleared nothing, `/new` opened nothing, `/login` logged in
|
|
22
|
+
* nothing. The TUI answers all of these instantly and locally.
|
|
23
|
+
*
|
|
24
|
+
* The table is data extracted from the installed omp's own registry, so a name
|
|
25
|
+
* omp moves to text mode stops being guarded by deleting its row. It is
|
|
26
|
+
* deliberately keyed on NAMES ONLY: the guard must not depend on the version of
|
|
27
|
+
* the running child, because a chamber talking to an older omp would otherwise
|
|
28
|
+
* refuse a command that build actually implements.
|
|
29
|
+
*
|
|
30
|
+
* `btw` is listed because omp's entry is TUI-only — the chamber's own `/btw`
|
|
31
|
+
* is intercepted before this predicate is consulted (see `dispatchBtwCommand`),
|
|
32
|
+
* so the two never conflict.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { CHAMBER_COMMANDS } from '@/shared/lib/chat/composer/trigger';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Commands the CHAMBER answers itself, derived from the one list that defines
|
|
39
|
+
* them. They are in omp's TUI-only set (omp implements `/btw` with `handleTui`
|
|
40
|
+
* only) but they are NOT unavailable here — the composer intercepts them before
|
|
41
|
+
* any guard runs.
|
|
42
|
+
*
|
|
43
|
+
* Derived rather than restated: a second hand-kept list is exactly what drifts,
|
|
44
|
+
* and a name that moved into `CHAMBER_COMMANDS` without a matching row here
|
|
45
|
+
* would be refused by the very guard that exists to protect it.
|
|
46
|
+
*/
|
|
47
|
+
export const CHAMBER_OWNED_SLASH_COMMANDS: Record<string, true> = Object.fromEntries(
|
|
48
|
+
CHAMBER_COMMANDS.map((command) => [command.name.toLowerCase(), true as const]),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/** Every TUI-only builtin name and alias, lowercased. */
|
|
52
|
+
export const TUI_ONLY_SLASH_COMMANDS: Record<string, true> = {
|
|
53
|
+
settings: true,
|
|
54
|
+
setup: true,
|
|
55
|
+
providers: true,
|
|
56
|
+
plan: true,
|
|
57
|
+
'plan-review': true,
|
|
58
|
+
vibe: true,
|
|
59
|
+
goal: true,
|
|
60
|
+
'guided-goal': true,
|
|
61
|
+
loop: true,
|
|
62
|
+
queue: true,
|
|
63
|
+
collab: true,
|
|
64
|
+
join: true,
|
|
65
|
+
leave: true,
|
|
66
|
+
copy: true,
|
|
67
|
+
open: true,
|
|
68
|
+
hotkeys: true,
|
|
69
|
+
extensions: true,
|
|
70
|
+
status: true,
|
|
71
|
+
agents: true,
|
|
72
|
+
git: true,
|
|
73
|
+
hub: true,
|
|
74
|
+
branch: true,
|
|
75
|
+
rewind: true,
|
|
76
|
+
fork: true,
|
|
77
|
+
tree: true,
|
|
78
|
+
login: true,
|
|
79
|
+
logout: true,
|
|
80
|
+
new: true,
|
|
81
|
+
clear: true,
|
|
82
|
+
delete: true,
|
|
83
|
+
resume: true,
|
|
84
|
+
btw: true,
|
|
85
|
+
tan: true,
|
|
86
|
+
omfg: true,
|
|
87
|
+
cleanse: true,
|
|
88
|
+
debug: true,
|
|
89
|
+
exit: true,
|
|
90
|
+
restart: true,
|
|
91
|
+
skills: true,
|
|
92
|
+
live: true,
|
|
93
|
+
record: true,
|
|
94
|
+
pause: true,
|
|
95
|
+
quit: true,
|
|
96
|
+
q: true,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** The command token of a draft, lowercased, or null when it is not a slash
|
|
100
|
+
* invocation. Mirrors omp's own parse: the name ends at the first space or
|
|
101
|
+
* colon, and the token must START the prompt (leading whitespace allowed) —
|
|
102
|
+
* a `/` inside prose is a path, not a command. */
|
|
103
|
+
export function slashCommandName(text: string): string | null {
|
|
104
|
+
const trimmed = text.trimStart();
|
|
105
|
+
if (!trimmed.startsWith('/')) return null;
|
|
106
|
+
const rest = trimmed.slice(1);
|
|
107
|
+
const end = rest.search(/[\s:]/);
|
|
108
|
+
const name = end === -1 ? rest : rest.slice(0, end);
|
|
109
|
+
return name ? name.toLowerCase() : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Whether a draft invokes a command the chamber cannot run, because omp only
|
|
114
|
+
* implements it in its TUI. Such a prompt must be refused locally — forwarding
|
|
115
|
+
* it makes the model answer a command it never received.
|
|
116
|
+
*
|
|
117
|
+
* A command the CHAMBER owns is excluded: `/btw` is TUI-only in omp, but the
|
|
118
|
+
* chamber answers it with the side-question panel, so refusing it here would
|
|
119
|
+
* break a working feature. The client intercepts those before consulting this
|
|
120
|
+
* predicate; the exclusion is what keeps the SERVER-side guard (which sees only
|
|
121
|
+
* the text, e.g. on a queued delivery) from refusing them too.
|
|
122
|
+
*/
|
|
123
|
+
export function isTuiOnlySlashCommand(text: string): boolean {
|
|
124
|
+
const name = slashCommandName(text);
|
|
125
|
+
if (name === null) return false;
|
|
126
|
+
if (CHAMBER_OWNED_SLASH_COMMANDS[name] === true) return false;
|
|
127
|
+
return TUI_ONLY_SLASH_COMMANDS[name] === true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The notice shown in place of a refused turn. Names the command and says where
|
|
132
|
+
* it does work, because "not supported" alone leaves the user guessing whether
|
|
133
|
+
* they mistyped it.
|
|
134
|
+
*/
|
|
135
|
+
export function tuiOnlyCommandNotice(text: string): string {
|
|
136
|
+
const name = slashCommandName(text) ?? text.trim();
|
|
137
|
+
return `\`/${name}\` is available in the omp terminal only — it has no effect over the RPC bridge, so it was not sent.`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one normalization every notice row's text goes through.
|
|
8
|
+
*
|
|
9
|
+
* Two paths produce the same row and they must agree, or a command's output
|
|
10
|
+
* changes appearance when the page is refreshed: the live `command_output`
|
|
11
|
+
* frame (`chat/omp/agent-events.ts`) and the custom entry a reload replays
|
|
12
|
+
* (`omp/session/messages-map.ts`).
|
|
13
|
+
*
|
|
14
|
+
* What it removes, and why:
|
|
15
|
+
*
|
|
16
|
+
* - **ANSI escapes.** omp formats command output for a terminal — SGR colour
|
|
17
|
+
* runs and half-block progress bars. The notice card renders markdown through
|
|
18
|
+
* Shiki, which treats `\x1b[38;2;…m` as literal text, so `/context` drew 48
|
|
19
|
+
* escape bytes as `38;2;107;114;128m████…`. True colour stays available in the
|
|
20
|
+
* realtime xterm panel, which parses ANSI natively.
|
|
21
|
+
* - **`<system-notice>` wrappers.** omp tags its own notices; the tag is
|
|
22
|
+
* scaffolding, not content. (`<system-reminder>` / `<task-result>` are NOT
|
|
23
|
+
* stripped — those identify the card variant `SystemNotice` renders.)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { stripAnsiCodes } from '@/shared/lib/code/ansi';
|
|
27
|
+
|
|
28
|
+
/** Strip terminal formatting and notice scaffolding, then trim. */
|
|
29
|
+
export function normalizeNoticeText(text: string): string {
|
|
30
|
+
return stripAnsiCodes(text)
|
|
31
|
+
.replace(/<\/?system-notice[^>]*>/g, '')
|
|
32
|
+
.trim();
|
|
33
|
+
}
|
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
* size ceiling.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import type {
|
|
17
|
-
import type { ChatMessageData, IncomingExtensionUiRequest, OmpAgentCallbacks, OmpAgentEvent, OmpAgentState, ToolCallData } from '@/shared/types';
|
|
16
|
+
import type { IncomingExtensionUiRequest, OmpAgentCallbacks, OmpAgentEvent, ToolCallData } from '@/shared/types';
|
|
18
17
|
import { extractTextFromContent, toChatMessage, toolResultText } from '@/shared/lib/omp/session/mapper';
|
|
18
|
+
import { normalizeNoticeText } from '@/shared/lib/chat/notice-text';
|
|
19
|
+
import { invalidateComposerCache } from '@/shared/lib/chat/composer/client';
|
|
20
|
+
import { setActivity, toolHost, type OmpAgentFoldDeps } from '@/shared/lib/chat/omp/fold-deps';
|
|
19
21
|
import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
|
|
20
22
|
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
21
23
|
import { describeAssistantPhase, describeToolActivity } from '@/shared/lib/chat/timeline/tool-verbs';
|
|
@@ -25,43 +27,13 @@ import {
|
|
|
25
27
|
putToolResult,
|
|
26
28
|
recordToolResult,
|
|
27
29
|
refreshToolMessage,
|
|
28
|
-
type ToolResultHost,
|
|
29
30
|
type ToolResultRecord,
|
|
30
31
|
} from '@/shared/lib/chat/omp/tool-results';
|
|
31
32
|
|
|
32
33
|
// Re-exported so stream.ts and the timeline hook keep their existing import
|
|
33
34
|
// site; the implementation lives in tool-results.ts.
|
|
34
35
|
export type { ToolResultRecord } from '@/shared/lib/chat/omp/tool-results';
|
|
35
|
-
|
|
36
|
-
export interface OmpAgentFoldDeps extends ToolResultHost {
|
|
37
|
-
sessionId: string;
|
|
38
|
-
setState: Dispatch<SetStateAction<OmpAgentState>>;
|
|
39
|
-
callbacksRef: RefObject<OmpAgentCallbacks>;
|
|
40
|
-
toolResultsRef: RefObject<Map<string, ToolResultRecord>>;
|
|
41
|
-
lastToolMessageRef: RefObject<ChatMessageData>;
|
|
42
|
-
interruptPendingRef: RefObject<boolean>;
|
|
43
|
-
/** Last activity phrase published to the indicator; guards per-token frames
|
|
44
|
-
* from re-setting identical state. */
|
|
45
|
-
activityRef: RefObject<string>;
|
|
46
|
-
/** Thinking level in effect for the live run (last `thinking_level_changed`
|
|
47
|
-
* frame); stamped onto assistant turns as they stream. */
|
|
48
|
-
currentThinkingLevelRef: RefObject<string | undefined>;
|
|
49
|
-
/** toolCallIds of in-flight file-mutating calls, cleared on `agent_start`. */
|
|
50
|
-
fileMutatingCallsRef: RefObject<Set<string>>;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Publish a new indicator phrase, skipping repeats (thinking/text deltas
|
|
54
|
-
* arrive per token and would otherwise re-set state on every frame). */
|
|
55
|
-
function setActivity(verb: string | undefined, deps: OmpAgentFoldDeps): void {
|
|
56
|
-
if (!verb || verb === deps.activityRef.current) return;
|
|
57
|
-
deps.activityRef.current = verb;
|
|
58
|
-
deps.callbacksRef.current?.onActivity?.(verb);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Narrow a deps record to the tool-output host, adding the re-emit sink. */
|
|
62
|
-
function toolHost(deps: OmpAgentFoldDeps): ToolResultHost {
|
|
63
|
-
return { ...deps, onMessageUpdate: deps.callbacksRef.current?.onMessageUpdate };
|
|
64
|
-
}
|
|
36
|
+
export type { OmpAgentFoldDeps } from '@/shared/lib/chat/omp/fold-deps';
|
|
65
37
|
|
|
66
38
|
/** Flush assistant turns that stopped abnormally and were never streamed as
|
|
67
39
|
* `message_end`. A user abort is the one terminal path where omp emits no
|
|
@@ -274,8 +246,10 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
274
246
|
|
|
275
247
|
// Built-in slash command output (/usage, /compact result, …). Rendered as
|
|
276
248
|
// a notice row — see the callback type note: this frame is the ONLY copy.
|
|
249
|
+
// `normalizeNoticeText` strips the terminal formatting omp emits here; the
|
|
250
|
+
// reload path applies the same function so both agree.
|
|
277
251
|
case 'command_output': {
|
|
278
|
-
const text = typeof data.text === 'string' ? data.text
|
|
252
|
+
const text = typeof data.text === 'string' ? normalizeNoticeText(data.text) : '';
|
|
279
253
|
if (text) callbacks?.onCommandOutput?.(text);
|
|
280
254
|
break;
|
|
281
255
|
}
|
|
@@ -329,10 +303,17 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
|
|
|
329
303
|
deps.currentThinkingLevelRef.current = normalizeThinkingLevel(data.thinkingLevel);
|
|
330
304
|
break;
|
|
331
305
|
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
306
|
+
// omp emits this whenever the discovered command set changes — after
|
|
307
|
+
// `/reload-plugins`, `/move`, or a skill install. The composer's command
|
|
308
|
+
// pool is cached five minutes (`composer/client.ts`), so without this the
|
|
309
|
+
// popup keeps offering the pre-change list.
|
|
335
310
|
case 'available_commands_update':
|
|
311
|
+
invalidateComposerCache('command');
|
|
312
|
+
break;
|
|
313
|
+
|
|
314
|
+
// No chamber-side effect: config frames and the transport's own `connected`
|
|
315
|
+
// greeting.
|
|
316
|
+
case 'config_update':
|
|
336
317
|
case 'connected':
|
|
337
318
|
default:
|
|
338
319
|
break;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* `available_commands_update` must drop the composer's cached command pool.
|
|
6
|
+
* The cache lives five minutes, and this frame is the only signal that omp's
|
|
7
|
+
* discovered command set changed (`/reload-plugins`, `/move`, a skill install),
|
|
8
|
+
* so ignoring it leaves the popup offering a list omp no longer matches.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
|
|
13
|
+
import { foldAgentEvent, type OmpAgentFoldDeps } from '@/shared/lib/chat/omp/agent-events';
|
|
14
|
+
import { invalidateComposerCache, loadComposerItems } from '@/shared/lib/chat/composer/client';
|
|
15
|
+
|
|
16
|
+
function foldDeps(): OmpAgentFoldDeps {
|
|
17
|
+
const noop = () => {};
|
|
18
|
+
const ref = <T,>(value: T) => ({ current: value });
|
|
19
|
+
return {
|
|
20
|
+
setState: noop,
|
|
21
|
+
sessionId: 'test-session',
|
|
22
|
+
callbacksRef: { current: {} },
|
|
23
|
+
toolResultsRef: ref(new Map()),
|
|
24
|
+
lastToolMessageRef: ref(null),
|
|
25
|
+
interruptPendingRef: ref(false),
|
|
26
|
+
activityRef: ref(''),
|
|
27
|
+
currentThinkingLevelRef: ref(undefined),
|
|
28
|
+
fileMutatingCallsRef: ref(new Set()),
|
|
29
|
+
} as unknown as OmpAgentFoldDeps;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('available_commands_update', () => {
|
|
33
|
+
test('refetches the command pool on the next composer open', async () => {
|
|
34
|
+
let fetches = 0;
|
|
35
|
+
const originalFetch = globalThis.fetch;
|
|
36
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
37
|
+
const href = String(url);
|
|
38
|
+
if (href.includes('/api/settings/commands')) {
|
|
39
|
+
fetches += 1;
|
|
40
|
+
return new Response(JSON.stringify({ commands: [] }), { status: 200 });
|
|
41
|
+
}
|
|
42
|
+
if (href.includes('/api/settings/skills')) {
|
|
43
|
+
return new Response(JSON.stringify({ skills: [] }), { status: 200 });
|
|
44
|
+
}
|
|
45
|
+
return new Response('{}', { status: 200 });
|
|
46
|
+
}) as typeof fetch;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
invalidateComposerCache();
|
|
50
|
+
await loadComposerItems('command');
|
|
51
|
+
expect(fetches).toBe(1);
|
|
52
|
+
// Cached: a second open inside the TTL does not refetch.
|
|
53
|
+
await loadComposerItems('command');
|
|
54
|
+
expect(fetches).toBe(1);
|
|
55
|
+
|
|
56
|
+
// omp reports the command set changed.
|
|
57
|
+
foldAgentEvent({ type: 'available_commands_update', commands: [] }, foldDeps());
|
|
58
|
+
|
|
59
|
+
await loadComposerItems('command');
|
|
60
|
+
expect(fetches).toBe(2);
|
|
61
|
+
} finally {
|
|
62
|
+
globalThis.fetch = originalFetch;
|
|
63
|
+
invalidateComposerCache();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* Builtin-command output must be stripped of ANSI on BOTH paths that can render
|
|
6
|
+
* it: the live `command_output` frame and the custom entry a reload replays.
|
|
7
|
+
* The two are separate code paths, and a fix applied to only one makes a
|
|
8
|
+
* command's output change appearance when the page is refreshed — measured on
|
|
9
|
+
* `/context`, whose progress bars carry 48 escape bytes that the notice card
|
|
10
|
+
* drew literally as `38;2;107;114;128m████…`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test } from 'bun:test';
|
|
14
|
+
|
|
15
|
+
import { foldAgentEvent, type OmpAgentFoldDeps } from '@/shared/lib/chat/omp/agent-events';
|
|
16
|
+
import { noticeFromCustomMessage } from '@/shared/lib/omp/session/messages-map';
|
|
17
|
+
|
|
18
|
+
/** The real shape omp emits for `/context`: SGR runs around half-block bars. */
|
|
19
|
+
const CONTEXT_OUTPUT = 'Context window: 1000000 tokens (1% used)\n'
|
|
20
|
+
+ ' System prompt [\u001b[38;2;107;114;128m░░░░░░\u001b[39m\u001b[1m\u001b[38;2;0;180;255m░░░░\u001b[22m\u001b[39m] 0% 2541 tokens';
|
|
21
|
+
|
|
22
|
+
function foldDeps(): { deps: OmpAgentFoldDeps; outputs: string[] } {
|
|
23
|
+
const outputs: string[] = [];
|
|
24
|
+
const noop = () => {};
|
|
25
|
+
const ref = <T,>(value: T) => ({ current: value });
|
|
26
|
+
const deps = {
|
|
27
|
+
setState: noop,
|
|
28
|
+
sessionId: 'test-session',
|
|
29
|
+
callbacksRef: {
|
|
30
|
+
current: {
|
|
31
|
+
onCommandOutput: (text: string) => outputs.push(text),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
toolResultsRef: ref(new Map()),
|
|
35
|
+
lastToolMessageRef: ref(null),
|
|
36
|
+
interruptPendingRef: ref(false),
|
|
37
|
+
activityRef: ref(''),
|
|
38
|
+
currentThinkingLevelRef: ref(undefined),
|
|
39
|
+
fileMutatingCallsRef: ref(new Set()),
|
|
40
|
+
} as unknown as OmpAgentFoldDeps;
|
|
41
|
+
return { deps, outputs };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('command_output ANSI stripping (live path)', () => {
|
|
45
|
+
test('strips escapes before the notice row sees them', () => {
|
|
46
|
+
const { deps, outputs } = foldDeps();
|
|
47
|
+
foldAgentEvent({ type: 'command_output', text: CONTEXT_OUTPUT }, deps);
|
|
48
|
+
|
|
49
|
+
expect(outputs).toHaveLength(1);
|
|
50
|
+
expect(outputs[0]).not.toContain('\u001b');
|
|
51
|
+
// The content itself survives — only the escapes go.
|
|
52
|
+
expect(outputs[0]).toContain('Context window: 1000000 tokens');
|
|
53
|
+
expect(outputs[0]).toContain('░░░░░░');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('keeps the empty-frame guard', () => {
|
|
57
|
+
const { deps, outputs } = foldDeps();
|
|
58
|
+
foldAgentEvent({ type: 'command_output', text: ' \u001b[39m ' }, deps);
|
|
59
|
+
expect(outputs).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('noticeFromCustomMessage ANSI stripping (reload path)', () => {
|
|
64
|
+
test('strips escapes from a replayed custom entry', () => {
|
|
65
|
+
const notice = noticeFromCustomMessage({
|
|
66
|
+
type: 'custom_message',
|
|
67
|
+
customType: 'command_output',
|
|
68
|
+
content: CONTEXT_OUTPUT,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(notice).not.toBeNull();
|
|
72
|
+
expect(notice?.notice).not.toContain('\u001b');
|
|
73
|
+
expect(notice?.notice).toContain('Context window: 1000000 tokens');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('still unwraps a system-notice envelope', () => {
|
|
77
|
+
const notice = noticeFromCustomMessage({
|
|
78
|
+
type: 'custom_message',
|
|
79
|
+
customType: 'notice',
|
|
80
|
+
content: '<system-notice>\u001b[32mAll good\u001b[39m</system-notice>',
|
|
81
|
+
});
|
|
82
|
+
expect(notice?.notice).toBe('All good');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The deps record `foldAgentEvent` reads, plus the two small helpers that
|
|
8
|
+
* narrow it. Split out of `agent-events.ts` so that file — which is already the
|
|
9
|
+
* switch over every frame omp can send — stays under the repo's per-file size
|
|
10
|
+
* ceiling.
|
|
11
|
+
*
|
|
12
|
+
* These are pure accessors over caller-owned state: no closure of their own, no
|
|
13
|
+
* module state, so importing them from either side of the fold is free.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Dispatch, RefObject, SetStateAction } from 'preact/compat';
|
|
17
|
+
import type { ChatMessageData, OmpAgentCallbacks, OmpAgentState } from '@/shared/types';
|
|
18
|
+
import type { ToolResultHost, ToolResultRecord } from '@/shared/lib/chat/omp/tool-results';
|
|
19
|
+
|
|
20
|
+
export interface OmpAgentFoldDeps extends ToolResultHost {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
setState: Dispatch<SetStateAction<OmpAgentState>>;
|
|
23
|
+
callbacksRef: RefObject<OmpAgentCallbacks>;
|
|
24
|
+
toolResultsRef: RefObject<Map<string, ToolResultRecord>>;
|
|
25
|
+
lastToolMessageRef: RefObject<ChatMessageData>;
|
|
26
|
+
interruptPendingRef: RefObject<boolean>;
|
|
27
|
+
/** Last activity phrase published to the indicator; guards per-token frames
|
|
28
|
+
* from re-setting identical state. */
|
|
29
|
+
activityRef: RefObject<string>;
|
|
30
|
+
/** Thinking level in effect for the live run (last `thinking_level_changed`
|
|
31
|
+
* frame); stamped onto assistant turns as they stream. */
|
|
32
|
+
currentThinkingLevelRef: RefObject<string | undefined>;
|
|
33
|
+
/** toolCallIds of in-flight file-mutating calls, cleared on `agent_start`. */
|
|
34
|
+
fileMutatingCallsRef: RefObject<Set<string>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Publish a new indicator phrase, skipping repeats (thinking/text deltas
|
|
38
|
+
* arrive per token and would otherwise re-set state on every frame). */
|
|
39
|
+
export function setActivity(verb: string | undefined, deps: OmpAgentFoldDeps): void {
|
|
40
|
+
if (!verb || verb === deps.activityRef.current) return;
|
|
41
|
+
deps.activityRef.current = verb;
|
|
42
|
+
deps.callbacksRef.current?.onActivity?.(verb);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Narrow a deps record to the tool-output host, adding the re-emit sink. */
|
|
46
|
+
export function toolHost(deps: OmpAgentFoldDeps): ToolResultHost {
|
|
47
|
+
return { ...deps, onMessageUpdate: deps.callbacksRef.current?.onMessageUpdate };
|
|
48
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `command_output` fold step: a builtin command's result becomes a notice
|
|
8
|
+
* row, and that row is PERSISTED.
|
|
9
|
+
*
|
|
10
|
+
* Persistence is the point. omp answers a builtin on the command path and
|
|
11
|
+
* writes nothing to the session JSONL — the output exists only in this frame —
|
|
12
|
+
* so a timeline rebuilt from the file had forgotten a `/context` the user just
|
|
13
|
+
* ran. The chamber's own copy keeps it, and `mergeOmpAttachments` splices it
|
|
14
|
+
* back under the turn that produced it on the next load.
|
|
15
|
+
*
|
|
16
|
+
* Split out of `omp-callbacks.ts` (which keeps only the wiring) so both files
|
|
17
|
+
* stay under the repo's per-file size ceiling.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
21
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
22
|
+
|
|
23
|
+
export interface CommandOutputDeps {
|
|
24
|
+
setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
|
|
25
|
+
/** The optimistic AI bubble this run is streaming into, if any. */
|
|
26
|
+
aiPlaceholderIdRef: { current: string | null };
|
|
27
|
+
persistMessages: (messages: ChatMessageData[]) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Append a command's output as a notice row, before the active placeholder.
|
|
32
|
+
*
|
|
33
|
+
* Placing it before the placeholder is what keeps it under the turn that
|
|
34
|
+
* produced it: the placeholder becomes the assistant's answer, and the notice
|
|
35
|
+
* must read as the command's result rather than as part of that answer. With no
|
|
36
|
+
* placeholder the row goes at the tail.
|
|
37
|
+
*/
|
|
38
|
+
export function appendCommandOutputNotice(text: string, deps: CommandOutputDeps): void {
|
|
39
|
+
const { setLocalMessages, aiPlaceholderIdRef, persistMessages } = deps;
|
|
40
|
+
const row: ChatMessageData = {
|
|
41
|
+
id: `cmdout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
42
|
+
role: 'ai',
|
|
43
|
+
content: '',
|
|
44
|
+
notice: text,
|
|
45
|
+
};
|
|
46
|
+
setLocalMessages(prev => {
|
|
47
|
+
const placeholderId = aiPlaceholderIdRef.current;
|
|
48
|
+
const pIdx = placeholderId ? prev.findIndex(m => m.id === placeholderId) : -1;
|
|
49
|
+
if (pIdx !== -1) {
|
|
50
|
+
const next = [...prev.slice(0, pIdx), row, ...prev.slice(pIdx)];
|
|
51
|
+
// The streaming placeholder is deliberately NOT persisted — it is a
|
|
52
|
+
// client-side bubble that `onPromptSettled` removes, and executeSend
|
|
53
|
+
// filters it for the same reason. Writing it here left an empty assistant
|
|
54
|
+
// row in the chamber's copy of a command-only turn.
|
|
55
|
+
persistMessages(next.filter(m => m.id !== placeholderId));
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
// Already recorded (the same command re-run while nothing streams): keep
|
|
59
|
+
// the list as-is rather than stacking an identical card.
|
|
60
|
+
if (prev.some(m => m.notice === text)) return prev;
|
|
61
|
+
const next = [...prev, row];
|
|
62
|
+
persistMessages(next);
|
|
63
|
+
return next;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
queueStreamingUpdate,
|
|
24
24
|
} from '@/shared/lib/chat/timeline/stream-coalescer';
|
|
25
25
|
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
26
|
+
import { appendCommandOutputNotice } from '@/shared/lib/chat/timeline/command-output';
|
|
26
27
|
|
|
27
28
|
export interface OmpAgentCallbacksDeps {
|
|
28
29
|
setGenerating: (v: boolean) => void;
|
|
@@ -303,24 +304,10 @@ export function createOmpAgentCallbacks(deps: OmpAgentCallbacksDeps): OmpAgentCa
|
|
|
303
304
|
});
|
|
304
305
|
}
|
|
305
306
|
},
|
|
306
|
-
// Built-in slash command output
|
|
307
|
-
//
|
|
307
|
+
// Built-in slash command output: a notice row, persisted — see the module
|
|
308
|
+
// doc for why the chamber's own copy is the only place it can survive.
|
|
308
309
|
onCommandOutput: (text) => {
|
|
309
|
-
|
|
310
|
-
id: `cmdout-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
311
|
-
role: 'ai',
|
|
312
|
-
content: '',
|
|
313
|
-
notice: text,
|
|
314
|
-
};
|
|
315
|
-
setLocalMessages(prev => {
|
|
316
|
-
const placeholderId = aiPlaceholderIdRef.current;
|
|
317
|
-
if (placeholderId) {
|
|
318
|
-
const pIdx = prev.findIndex(m => m.id === placeholderId);
|
|
319
|
-
if (pIdx !== -1) return [...prev.slice(0, pIdx), row, ...prev.slice(pIdx)];
|
|
320
|
-
}
|
|
321
|
-
if (prev.some(m => m.notice === text)) return prev;
|
|
322
|
-
return [...prev, row];
|
|
323
|
-
});
|
|
310
|
+
appendCommandOutputNotice(text, { setLocalMessages, aiPlaceholderIdRef, persistMessages });
|
|
324
311
|
},
|
|
325
312
|
// omp renamed the session — the auto-title generation the chamber asks for
|
|
326
313
|
// after a settled run, a `/rename`, or an RPC set_session_name. The slot
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { ChatMessageData } from '@/shared/types/chat';
|
|
7
7
|
import { extractInlinedTextAttachments, extractText, extractUserImageAttachments, parseAssistantContent, resultOutput, roleFor, stripInlinedTextAttachments, type OmpMessageEntry } from '@/shared/lib/omp/session/messages-parse';
|
|
8
8
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
9
|
+
import { normalizeNoticeText } from '@/shared/lib/chat/notice-text';
|
|
9
10
|
import { reminderPartIndex } from '@/shared/lib/chat/notice-row';
|
|
10
11
|
import { deriveTurnError } from '@/shared/lib/omp/session/turn-error';
|
|
11
12
|
import { toEpochMs } from '@/shared/lib/omp/session/timestamps';
|
|
@@ -180,7 +181,11 @@ export function noticeFromCustomMessage(record: Record<string, unknown>): ChatMe
|
|
|
180
181
|
.map((b) => (isRecord(b) && b.type === 'text' && typeof b.text === 'string' ? b.text : ''))
|
|
181
182
|
.join('')
|
|
182
183
|
: '';
|
|
183
|
-
|
|
184
|
+
// Same terminal-formatted text the live `command_output` frame carries: a
|
|
185
|
+
// custom entry replayed from the JSONL keeps its ANSI runs, and the notice
|
|
186
|
+
// card renders them literally. One normalizer serves both paths so a command's
|
|
187
|
+
// output looks identical whether it was just run or reloaded.
|
|
188
|
+
const notice = normalizeNoticeText(text);
|
|
184
189
|
if (!notice) return null;
|
|
185
190
|
return {
|
|
186
191
|
id,
|