ompchamber 1.2.1 → 2.0.1
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/README.md +143 -147
- package/dist/client/index.html +4 -2
- package/dist/client/static/css/index.5f630421b7.css +2 -0
- package/dist/client/static/js/77.0d81c74cf0.js +139 -0
- package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
- package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
- package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
- package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
- package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
- package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
- package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
- package/dist/client/static/js/async/e.93214d622b.js +79 -0
- package/dist/client/static/js/async/g.cb81980498.js +2 -0
- package/dist/client/static/js/async/h1.393f066d09.js +1 -0
- package/dist/client/static/js/async/jl.874d352278.js +1 -0
- package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
- package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
- package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
- package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
- package/dist/client/static/js/async/px.fc6877924b.js +1 -0
- package/dist/client/static/js/async/q.22588845a9.js +21 -0
- package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
- package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
- package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/x1.da8687159b.js +90 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
- package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
- package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.1fd9e1c543.js +38 -0
- package/package.json +3 -3
- package/src/client/components/common/ComposerTextarea.tsx +9 -1
- package/src/client/components/common/MarkdownRenderer.tsx +50 -12
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
- package/src/client/components/settings/Modal.tsx +11 -2
- package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
- package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
- package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
- package/src/client/components/workspace/SearchPanel.tsx +16 -3
- package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
- package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
- package/src/client/components/workspace/btw-panel/index.tsx +124 -45
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
- package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
- package/src/client/components/workspace/diff-panel/index.tsx +8 -1
- package/src/client/components/workspace/editor/index.tsx +18 -2
- package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
- package/src/client/components/workspace/file-explorer/index.tsx +86 -16
- package/src/client/components/workspace/git-panel/index.tsx +60 -78
- package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
- package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
- package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
- package/src/client/data/theme/terminal.ts +50 -52
- package/src/client/hooks/chat/btw/index.ts +139 -18
- package/src/client/hooks/chat/btw/intercept.ts +8 -8
- package/src/client/hooks/chat/btw/mode.ts +111 -16
- package/src/client/hooks/chat/composer/index.ts +10 -4
- package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
- package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
- package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
- package/src/client/hooks/chat/timeline/index.ts +8 -20
- package/src/client/hooks/chat/timeline/queue.ts +66 -9
- package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
- package/src/client/hooks/chat/timeline/scroll.ts +93 -20
- package/src/client/hooks/chat/timeline/session-load.ts +7 -1
- package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
- package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
- package/src/client/hooks/editor/save-file.ts +62 -0
- package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
- package/src/client/hooks/editor/use-file-editor.ts +38 -29
- package/src/client/hooks/editor/use-file-transfer.ts +5 -2
- package/src/client/hooks/settings/providers.ts +34 -3
- package/src/client/hooks/ui/open-file.ts +7 -1
- package/src/client/hooks/ui/theme.ts +82 -57
- package/src/client/hooks/workspace/file-tabs.ts +5 -1
- package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
- package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
- package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
- package/src/client/hooks/workspace/search-stream.ts +24 -5
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +19 -56
- package/src/server/lib/btw/child.server.ts +41 -0
- package/src/server/lib/btw/lifecycle.server.ts +254 -0
- package/src/server/lib/btw/prompt.ts +13 -5
- package/src/server/lib/btw/registry.server.ts +13 -4
- package/src/server/lib/btw/runtime.server.ts +149 -156
- package/src/server/lib/btw/schema.server.ts +27 -0
- package/src/server/lib/btw/service.server.ts +106 -5
- package/src/server/lib/btw/store.server.ts +57 -6
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/settings-store.test.ts +65 -0
- package/src/server/lib/db/settings-store.ts +25 -0
- package/src/server/lib/fs/atomic-write.ts +42 -4
- package/src/server/lib/fs/config-lock.ts +74 -0
- package/src/server/lib/fs/git-ignore.ts +55 -5
- package/src/server/lib/fs/ripgrep.ts +119 -0
- package/src/server/lib/lifecycle/identity.ts +10 -64
- package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
- package/src/server/lib/lifecycle/proc/index.ts +38 -0
- package/src/server/lib/lifecycle/proc/linux.ts +80 -0
- package/src/server/lib/lifecycle/proc/ps.ts +86 -0
- package/src/server/lib/lifecycle/proc/types.ts +33 -0
- package/src/server/lib/lifecycle/proc/win32.ts +53 -0
- package/src/server/lib/models/provider-registry.server.ts +2 -2
- package/src/server/lib/models/registry.server.ts +1 -1
- package/src/server/lib/omp/config/behavior.ts +17 -17
- package/src/server/lib/omp/config/disabled-providers.ts +84 -35
- package/src/server/lib/omp/config/document.ts +128 -0
- package/src/server/lib/omp/config/extensions.ts +11 -10
- package/src/server/lib/omp/config/mcp.ts +4 -57
- package/src/server/lib/omp/config/model-overrides.ts +123 -0
- package/src/server/lib/omp/config/models-config.ts +92 -0
- package/src/server/lib/omp/config/models-validation.ts +159 -0
- package/src/server/lib/omp/config/path-scoped.ts +74 -0
- package/src/server/lib/omp/config/providers.ts +241 -211
- package/src/server/lib/omp/config/roles.ts +6 -14
- package/src/server/lib/omp/core/auth-credentials.ts +1 -1
- package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
- package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
- package/src/server/lib/omp/rpc/manager.ts +21 -21
- package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
- package/src/server/lib/omp/rpc/session-commands.ts +17 -29
- package/src/server/lib/omp/rpc/session-registry.ts +5 -66
- package/src/server/lib/omp/rpc/web-state.ts +0 -6
- package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
- package/src/server/lib/omp/session/auto-title.server.ts +95 -20
- package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
- package/src/server/lib/queue/delivery-harness.ts +88 -0
- package/src/server/lib/queue/delivery.server.test.ts +78 -149
- package/src/server/lib/queue/delivery.server.ts +105 -13
- package/src/server/lib/queue/store.server.ts +30 -5
- package/src/server/lib/terminal/foreground.test.ts +164 -0
- package/src/server/lib/terminal/foreground.ts +34 -6
- package/src/server/lib/terminal/history.ts +8 -7
- package/src/server/plugins/compress.ts +33 -5
- package/src/server/plugins/ssr.ts +14 -7
- package/src/server/routes/btw/topics.ts +89 -2
- package/src/server/routes/fs/action.test.ts +64 -0
- package/src/server/routes/fs/action.ts +10 -1
- package/src/server/routes/fs/git.ts +5 -6
- package/src/server/routes/fs/search.ts +17 -26
- package/src/server/routes/sessions/queue.ts +17 -4
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/model-override.ts +69 -0
- package/src/server/routes/settings/provider-models.ts +35 -12
- package/src/server/routes/settings/providers.ts +31 -1
- package/src/server/routes/settings/root.ts +8 -3
- package/src/shared/lib/chat/btw/client.ts +34 -0
- package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
- package/src/shared/lib/code/line-endings.test.ts +38 -0
- package/src/shared/lib/code/line-endings.ts +56 -0
- package/src/shared/lib/code/shiki-themes.ts +9 -5
- package/src/shared/lib/fs/file-tree.ts +17 -12
- package/src/shared/lib/markdown/autolink.test.ts +32 -0
- package/src/shared/lib/markdown/autolink.ts +50 -0
- package/src/shared/lib/markdown/document-urls.test.ts +68 -0
- package/src/shared/lib/markdown/document-urls.ts +118 -0
- package/src/shared/lib/markdown/marked.ts +27 -15
- package/src/shared/lib/markdown/mermaid.ts +2 -2
- package/src/shared/lib/models/catalog.ts +1 -1
- package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
- package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
- package/src/shared/lib/settings/client.ts +10 -4
- package/src/shared/lib/settings/diff.test.ts +31 -0
- package/src/shared/lib/settings/diff.ts +37 -0
- package/src/shared/lib/theme/catalog.test.ts +168 -0
- package/src/shared/lib/theme/catalog.ts +98 -0
- package/src/shared/lib/theme/color.ts +88 -0
- package/src/shared/lib/theme/css.ts +70 -0
- package/src/shared/lib/theme/palettes/chamber.ts +61 -0
- package/src/shared/lib/theme/palettes/dark.ts +313 -0
- package/src/shared/lib/theme/palettes/light.ts +313 -0
- package/src/shared/types/btw.ts +83 -4
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +8 -1
- package/src/shared/types/settings/state.ts +10 -4
- package/src/shared/types/theme.ts +48 -0
- package/dist/client/static/css/index.b938f4a693.css +0 -2
- package/dist/client/static/js/77.8022a800b5.js +0 -139
- package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
- package/dist/client/static/js/async/66.86544ad741.js +0 -93
- package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
- package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
- package/dist/client/static/js/async/e.6443e739a2.js +0 -79
- package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
- package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
- package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
- package/dist/client/static/js/async/m.2e1ea3db9e.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
- package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
- package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
- package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
- package/dist/client/static/js/async/vt.20406a5604.js +0 -1
- package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
- package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
- package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
- package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
- package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
- /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.1fd9e1c543.js.LICENSE.txt} +0 -0
|
@@ -4,97 +4,21 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Write side of the native OMP provider registry — `models.yml`.
|
|
8
|
+
*
|
|
9
|
+
* omp never writes this file itself (its `ModelsConfigFile` is load-only), so
|
|
10
|
+
* the chamber is the only automated author and every write is a
|
|
11
|
+
* read-modify-write of a user-editable document: comments, formatting, and
|
|
12
|
+
* unrelated keys all have to survive, and the result has to pass omp's own
|
|
13
|
+
* schema before it reaches disk. Reading lives in `./models-config.ts`.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
+
import { isMap, isSeq, type Document, type YAMLMap } from 'yaml';
|
|
17
|
+
import { getModelsConfigPath } from '@/server/lib/omp/config/models-config';
|
|
18
|
+
import { plainOf, withOmpYamlDocument, OmpConfigError } from '@/server/lib/omp/config/document';
|
|
19
|
+
import { validateModelsDocument, sanitizeModelEntry } from '@/server/lib/omp/config/models-validation';
|
|
16
20
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
17
21
|
|
|
18
|
-
/** Path of the native OMP models config (~/.omp/agent/models.yml). */
|
|
19
|
-
export async function getModelsConfigPath(): Promise<string> {
|
|
20
|
-
const yml = join(getAgentDir(), 'models.yml');
|
|
21
|
-
return (await Bun.file(yml).exists()) ? yml : join(getAgentDir(), 'models.yaml');
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface NativeProviderInfo {
|
|
25
|
-
/** Provider slug, e.g. "deepseek" or "custom/my-gateway". */
|
|
26
|
-
slug: string;
|
|
27
|
-
/** Base URL of the provider API, when configured. */
|
|
28
|
-
baseUrl?: string;
|
|
29
|
-
/** Model ids registered under this provider in models.yml. */
|
|
30
|
-
modelIds: string[];
|
|
31
|
-
models: NativeModelInfo[];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface NativeModelInfo {
|
|
35
|
-
id: string;
|
|
36
|
-
name?: string;
|
|
37
|
-
contextWindow?: number;
|
|
38
|
-
maxTokens?: number;
|
|
39
|
-
reasoning?: boolean;
|
|
40
|
-
imageInput?: boolean;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Read custom providers/models registered in models.yml. Returns [] when the
|
|
45
|
-
* file is missing or has no providers — never throws for absent files.
|
|
46
|
-
*/
|
|
47
|
-
export async function readNativeProviders(): Promise<NativeProviderInfo[]> {
|
|
48
|
-
const path = await getModelsConfigPath();
|
|
49
|
-
if (!(await Bun.file(path).exists())) return [];
|
|
50
|
-
try {
|
|
51
|
-
const data = Bun.YAML.parse(await Bun.file(path).text());
|
|
52
|
-
if (!isRecord(data)) return [];
|
|
53
|
-
const providers = data.providers;
|
|
54
|
-
if (typeof providers !== 'object' || providers === null || Array.isArray(providers)) return [];
|
|
55
|
-
return Object.entries(providers as Record<string, unknown>).map(([slug, value]) => {
|
|
56
|
-
const info: NativeProviderInfo = { slug, modelIds: [], models: [] };
|
|
57
|
-
if (isRecord(value)) {
|
|
58
|
-
const record = value as Record<string, unknown>;
|
|
59
|
-
if (typeof record.baseUrl === 'string') info.baseUrl = record.baseUrl;
|
|
60
|
-
if (isRecord(record.models)) {
|
|
61
|
-
info.models = Object.entries(record.models as Record<string, unknown>).flatMap(([id, model]) => {
|
|
62
|
-
if (typeof model !== 'object' || model === null || Array.isArray(model)) return [{ id }];
|
|
63
|
-
const value = model as Record<string, unknown>;
|
|
64
|
-
return [{
|
|
65
|
-
id,
|
|
66
|
-
...(typeof value.name === 'string' ? { name: value.name } : {}),
|
|
67
|
-
...(typeof value.contextWindow === 'number' ? { contextWindow: value.contextWindow } : {}),
|
|
68
|
-
...(typeof value.maxTokens === 'number' ? { maxTokens: value.maxTokens } : {}),
|
|
69
|
-
...(value.reasoning === true ? { reasoning: true } : {}),
|
|
70
|
-
...(Array.isArray(value.input) && value.input.includes('image') ? { imageInput: true } : {}),
|
|
71
|
-
}];
|
|
72
|
-
});
|
|
73
|
-
} else if (Array.isArray(record.models)) {
|
|
74
|
-
info.models = record.models.flatMap((model) => {
|
|
75
|
-
if (typeof model === 'string') return [{ id: model }];
|
|
76
|
-
if (typeof model !== 'object' || model === null || Array.isArray(model)) return [];
|
|
77
|
-
const value = model as Record<string, unknown>;
|
|
78
|
-
if (typeof value.id !== 'string') return [];
|
|
79
|
-
return [{
|
|
80
|
-
id: value.id,
|
|
81
|
-
...(typeof value.name === 'string' ? { name: value.name } : {}),
|
|
82
|
-
...(typeof value.contextWindow === 'number' ? { contextWindow: value.contextWindow } : {}),
|
|
83
|
-
...(typeof value.maxTokens === 'number' ? { maxTokens: value.maxTokens } : {}),
|
|
84
|
-
...(value.reasoning === true ? { reasoning: true } : {}),
|
|
85
|
-
...(Array.isArray(value.input) && value.input.includes('image') ? { imageInput: true } : {}),
|
|
86
|
-
}];
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
info.modelIds = info.models.map((model) => model.id);
|
|
90
|
-
}
|
|
91
|
-
return info;
|
|
92
|
-
});
|
|
93
|
-
} catch {
|
|
94
|
-
return [];
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
22
|
/** One model entry destined for models.yml (cost numbers are USD per 1M tokens). */
|
|
99
23
|
export interface OmpProviderModelSeed {
|
|
100
24
|
id: string;
|
|
@@ -122,155 +46,261 @@ export interface OmpProviderUpsertResult {
|
|
|
122
46
|
reason?: string;
|
|
123
47
|
}
|
|
124
48
|
|
|
49
|
+
/** Masks a stored credential echoed back by the UI; not a value to persist. */
|
|
50
|
+
const MASKED_KEY_PATTERN = /•{3,}/;
|
|
51
|
+
|
|
52
|
+
/** True when `value` is a credential the UI masked rather than a real key. */
|
|
53
|
+
export function isMaskedApiKey(value: unknown): boolean {
|
|
54
|
+
return typeof value === 'string' && MASKED_KEY_PATTERN.test(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The model ids already registered under `provider`, array form only. */
|
|
58
|
+
function knownModelIds(provider: Record<string, unknown> | undefined): Set<string> {
|
|
59
|
+
if (!Array.isArray(provider?.models)) return new Set();
|
|
60
|
+
return new Set(
|
|
61
|
+
provider.models
|
|
62
|
+
.map((model) => (isRecord(model) && typeof model.id === 'string' ? model.id : ''))
|
|
63
|
+
.filter(Boolean),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The model entry as omp expects it, omitting anything omp would reject. */
|
|
68
|
+
function toModelEntry(model: OmpProviderModelSeed): Record<string, unknown> {
|
|
69
|
+
return sanitizeModelEntry({
|
|
70
|
+
id: model.id,
|
|
71
|
+
...(model.name ? { name: model.name } : {}),
|
|
72
|
+
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
|
|
73
|
+
...(model.imageInput ? { input: ['text', 'image'] } : { input: ['text'] }),
|
|
74
|
+
...(model.contextWindow && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
|
|
75
|
+
...(model.maxTokens && model.maxTokens > 0 ? { maxTokens: model.maxTokens } : {}),
|
|
76
|
+
...(model.cost ? { cost: model.cost } : {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Metadata a bare existing entry is missing, taken from the freshly fetched
|
|
82
|
+
* seed. Values already present are never touched. Writes through the YAML node
|
|
83
|
+
* so the entry's own comments and key order stay as the user wrote them.
|
|
84
|
+
*/
|
|
85
|
+
function backfillEntry(doc: Document, entry: YAMLMap, seed: OmpProviderModelSeed): void {
|
|
86
|
+
if (seed.name && !entry.has('name')) entry.set('name', seed.name);
|
|
87
|
+
if (seed.reasoning !== undefined && !entry.has('reasoning')) {
|
|
88
|
+
entry.set('reasoning', seed.reasoning);
|
|
89
|
+
}
|
|
90
|
+
const currentInput = plainOf<string[]>(doc, entry.get('input'));
|
|
91
|
+
if (seed.imageInput && (!Array.isArray(currentInput) || !currentInput.includes('image'))) {
|
|
92
|
+
entry.set('input', ['text', 'image']);
|
|
93
|
+
}
|
|
94
|
+
if (seed.contextWindow && seed.contextWindow > 0 && !plainOf(doc, entry.get('contextWindow'))) {
|
|
95
|
+
entry.set('contextWindow', seed.contextWindow);
|
|
96
|
+
}
|
|
97
|
+
if (seed.maxTokens && seed.maxTokens > 0 && !plainOf(doc, entry.get('maxTokens'))) {
|
|
98
|
+
entry.set('maxTokens', seed.maxTokens);
|
|
99
|
+
}
|
|
100
|
+
if (seed.cost && !entry.has('cost')) {
|
|
101
|
+
entry.set('cost', seed.cost);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
125
105
|
/**
|
|
126
106
|
* Add-only upsert of a provider (and its models) into the native omp
|
|
127
107
|
* models.yml — the agent's own registry, where per-model cost feeds usage
|
|
128
108
|
* tracking. Existing provider fields (apiKey, baseUrl) and existing model
|
|
129
109
|
* entries are never modified; only models whose id is not yet registered are
|
|
130
|
-
* appended. Merges
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
110
|
+
* appended. Merges preserve unrelated keys, comments and formatting (document
|
|
111
|
+
* model), and the write is atomic and mode-preserving.
|
|
112
|
+
*
|
|
113
|
+
* Provider creation requires an apiKey — omp rejects models-cfg providers
|
|
114
|
+
* without one unless auth is "none" or "oauth"; existing providers keep
|
|
115
|
+
* whatever credential they already have. The finished document is validated
|
|
116
|
+
* against omp's own rules before the write: a schema violation anywhere in the
|
|
117
|
+
* file makes omp disable every custom provider at once.
|
|
134
118
|
*/
|
|
135
119
|
export async function upsertOmpProviderModels(
|
|
136
120
|
slug: string,
|
|
137
121
|
input: OmpProviderUpsertInput,
|
|
138
122
|
): Promise<OmpProviderUpsertResult> {
|
|
139
123
|
const path = await getModelsConfigPath();
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
doc.
|
|
147
|
-
|
|
124
|
+
return withOmpYamlDocument<OmpProviderUpsertResult>(path, (doc) => {
|
|
125
|
+
const original = doc.toJS() as Record<string, unknown> | null;
|
|
126
|
+
const providersNode = doc.get('providers');
|
|
127
|
+
if (providersNode !== undefined && !isMap(providersNode)) {
|
|
128
|
+
throw new OmpConfigError(`${path} providers must be a mapping`);
|
|
129
|
+
}
|
|
130
|
+
// `doc.get`/`getIn` hand back YAML nodes, never plain values: an existing
|
|
131
|
+
// provider has to be read through the node (`get`), or it reads as absent
|
|
132
|
+
// and the "add-only" upsert overwrites the user's credentials.
|
|
133
|
+
const providerNode = providersNode === undefined ? undefined : (providersNode as YAMLMap).get(slug);
|
|
134
|
+
const existing = isMap(providerNode)
|
|
135
|
+
? (providerNode.toJS(doc) as Record<string, unknown>)
|
|
136
|
+
: undefined;
|
|
137
|
+
// omp requires `models` to be an ARRAY. A map-form entry makes omp reject
|
|
138
|
+
// the entire file ("custom providers disabled"), so rewriting it as an
|
|
139
|
+
// array would silently delete every model the user had registered there.
|
|
140
|
+
// Refuse and say what is wrong instead — the file is theirs to fix.
|
|
141
|
+
if (existing?.models !== undefined && !Array.isArray(existing.models)) {
|
|
142
|
+
throw new OmpConfigError(
|
|
143
|
+
`${path}: provider "${slug}" has a map-form "models" — omp requires an array. `
|
|
144
|
+
+ 'Fix that entry before adding models; omp currently disables every custom provider because of it.',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const known = knownModelIds(existing);
|
|
148
|
+
const incomingById = new Map(input.models.map((model) => [model.id, model]));
|
|
149
|
+
const additions = input.models.filter((model) => model.id && !known.has(model.id));
|
|
148
150
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
if (!existing && !input.apiKey) {
|
|
152
|
+
return {
|
|
153
|
+
result: {
|
|
154
|
+
written: false,
|
|
155
|
+
addedModels: [],
|
|
156
|
+
backfilledModels: [],
|
|
157
|
+
skippedModels: input.models.map((model) => model.id),
|
|
158
|
+
reason: 'provider not yet in models.yml and no api key available to register it',
|
|
159
|
+
},
|
|
160
|
+
changed: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// The UI echoes stored credentials back masked ("sk-••••…"), and this
|
|
164
|
+
// writer is the last gate before a file omp will authenticate with — a
|
|
165
|
+
// masked placeholder persisted here would look like a configured provider
|
|
166
|
+
// that cannot authenticate.
|
|
167
|
+
if (!existing && isMaskedApiKey(input.apiKey)) {
|
|
168
|
+
return {
|
|
169
|
+
result: {
|
|
170
|
+
written: false,
|
|
171
|
+
addedModels: [],
|
|
172
|
+
backfilledModels: [],
|
|
173
|
+
skippedModels: input.models.map((model) => model.id),
|
|
174
|
+
reason: 'the supplied API key is a masked placeholder, not a credential',
|
|
175
|
+
},
|
|
176
|
+
changed: false,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
154
179
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.filter((m) => {
|
|
162
|
-
const entry = isRecord(m) ? m : {};
|
|
163
|
-
const seed = incomingById.get(String(entry.id));
|
|
180
|
+
// Existing entries with bare ids (no context, no capabilities) get the
|
|
181
|
+
// fetched metadata filled in — values already present are never touched.
|
|
182
|
+
const backfillIds = (Array.isArray(existing?.models) ? existing.models : [])
|
|
183
|
+
.filter((model): model is Record<string, unknown> => {
|
|
184
|
+
if (!isRecord(model) || typeof model.id !== 'string') return false;
|
|
185
|
+
const seed = incomingById.get(model.id);
|
|
164
186
|
if (!seed) return false;
|
|
165
|
-
const missingContext = typeof
|
|
166
|
-
const missingCost = !entry.cost;
|
|
187
|
+
const missingContext = typeof model.contextWindow !== 'number' || !model.contextWindow;
|
|
167
188
|
return Boolean(
|
|
168
189
|
(missingContext && (seed.contextWindow || seed.maxTokens))
|
|
169
|
-
|| (
|
|
170
|
-
|| (seed.reasoning !== undefined &&
|
|
171
|
-
|| (
|
|
190
|
+
|| (!model.cost && seed.cost)
|
|
191
|
+
|| (seed.reasoning !== undefined && model.reasoning === undefined)
|
|
192
|
+
|| (!Array.isArray(model.input) && seed.imageInput),
|
|
172
193
|
);
|
|
173
194
|
})
|
|
174
|
-
.map((
|
|
175
|
-
.filter(Boolean)
|
|
176
|
-
: [];
|
|
177
|
-
if (additions.length === 0 && backfillIds.length === 0) {
|
|
178
|
-
return {
|
|
179
|
-
written: false,
|
|
180
|
-
addedModels: [],
|
|
181
|
-
backfilledModels: [],
|
|
182
|
-
skippedModels: input.models.map((m) => m.id),
|
|
183
|
-
reason: existingPojo ? 'all models already registered' : undefined,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
195
|
+
.map((model) => String(model.id));
|
|
186
196
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
reason: 'provider not yet in models.yml and no api key available to register it',
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (!existingPojo) {
|
|
200
|
-
providers[slug] = {
|
|
201
|
-
baseUrl: input.baseUrl,
|
|
202
|
-
apiKey: input.apiKey,
|
|
203
|
-
// omp disables every custom provider when a models-carrying provider
|
|
204
|
-
// lacks "api" (provider or model level) — always set one.
|
|
205
|
-
api: input.api ?? 'openai-completions',
|
|
206
|
-
models: [] as unknown[],
|
|
207
|
-
};
|
|
208
|
-
} else if (!existingPojo.api) {
|
|
209
|
-
// Existing provider without an api: adding models without one would make
|
|
210
|
-
// the whole models.yml fail omp validation, so fill it.
|
|
211
|
-
targetApiValue = input.api ?? 'openai-completions';
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const target = providers[slug] as Record<string, unknown>;
|
|
215
|
-
if (targetApiValue) target.api = targetApiValue;
|
|
216
|
-
if (!Array.isArray(target.models)) target.models = [];
|
|
217
|
-
const targetModels = target.models as Record<string, unknown>[];
|
|
218
|
-
for (const model of additions) {
|
|
219
|
-
targetModels.push({
|
|
220
|
-
id: model.id,
|
|
221
|
-
...(model.name ? { name: model.name } : {}),
|
|
222
|
-
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
|
|
223
|
-
...(model.imageInput ? { input: ['text', 'image'] } : { input: ['text'] }),
|
|
224
|
-
...(model.contextWindow && model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
|
|
225
|
-
...(model.maxTokens && model.maxTokens > 0 ? { maxTokens: model.maxTokens } : {}),
|
|
226
|
-
...(model.cost ? {
|
|
227
|
-
cost: {
|
|
228
|
-
input: model.cost.input,
|
|
229
|
-
output: model.cost.output,
|
|
230
|
-
cacheRead: model.cost.cacheRead,
|
|
231
|
-
cacheWrite: model.cost.cacheWrite,
|
|
197
|
+
if (additions.length === 0 && backfillIds.length === 0) {
|
|
198
|
+
return {
|
|
199
|
+
result: {
|
|
200
|
+
written: false,
|
|
201
|
+
addedModels: [],
|
|
202
|
+
backfilledModels: [],
|
|
203
|
+
skippedModels: input.models.map((model) => model.id),
|
|
204
|
+
reason: existing ? 'all models already registered' : undefined,
|
|
232
205
|
},
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
for (const entry of targetModels) {
|
|
238
|
-
const entryId = typeof entry?.id === 'string' ? entry.id : '';
|
|
239
|
-
if (!entryId || !backfillIds.includes(entryId)) continue;
|
|
240
|
-
const seed = incomingById.get(entryId);
|
|
241
|
-
if (!seed) continue;
|
|
242
|
-
if (seed.name && !entry.name) entry.name = seed.name;
|
|
243
|
-
if (seed.reasoning !== undefined && entry.reasoning === undefined) {
|
|
244
|
-
entry.reasoning = seed.reasoning;
|
|
206
|
+
changed: false,
|
|
207
|
+
};
|
|
245
208
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
209
|
+
|
|
210
|
+
// `doc.createNode` (not a plain object) — a bare object stored via `setIn`
|
|
211
|
+
// stays a JS object, so a later `getIn(...).add()` throws "Expected YAML
|
|
212
|
+
// collection" instead of appending.
|
|
213
|
+
if (!existing) {
|
|
214
|
+
doc.setIn(['providers', slug], doc.createNode({
|
|
215
|
+
baseUrl: input.baseUrl,
|
|
216
|
+
apiKey: input.apiKey,
|
|
217
|
+
// omp disables every custom provider when a models-carrying provider
|
|
218
|
+
// lacks "api" (provider or model level) — always set one.
|
|
219
|
+
api: input.api ?? 'openai-completions',
|
|
220
|
+
models: [],
|
|
221
|
+
}));
|
|
222
|
+
} else if (!existing.api) {
|
|
223
|
+
// Existing provider without an api: adding models without one would make
|
|
224
|
+
// the whole models.yml fail omp validation, so fill it.
|
|
225
|
+
doc.setIn(['providers', slug, 'api'], input.api ?? 'openai-completions');
|
|
251
226
|
}
|
|
252
|
-
|
|
253
|
-
|
|
227
|
+
|
|
228
|
+
// `addIn` on a missing key creates a MAP, not a sequence — exactly the
|
|
229
|
+
// `models: must be an array` shape omp rejects. Append through the existing
|
|
230
|
+
// sequence, or seed a new one.
|
|
231
|
+
const modelSeq = doc.getIn(['providers', slug, 'models']);
|
|
232
|
+
if (additions.length > 0) {
|
|
233
|
+
if (isSeq(modelSeq)) {
|
|
234
|
+
for (const model of additions) modelSeq.add(doc.createNode(toModelEntry(model)));
|
|
235
|
+
} else {
|
|
236
|
+
doc.setIn(['providers', slug, 'models'], doc.createNode(additions.map(toModelEntry)));
|
|
237
|
+
}
|
|
254
238
|
}
|
|
255
|
-
|
|
256
|
-
|
|
239
|
+
|
|
240
|
+
const targetModels = doc.getIn(['providers', slug, 'models']);
|
|
241
|
+
if (isSeq(targetModels)) {
|
|
242
|
+
for (const entry of targetModels.items) {
|
|
243
|
+
if (!isMap(entry)) continue;
|
|
244
|
+
const id = entry.get('id');
|
|
245
|
+
if (typeof id !== 'string' || !backfillIds.includes(id)) continue;
|
|
246
|
+
const seed = incomingById.get(id);
|
|
247
|
+
if (seed) backfillEntry(doc, entry, seed);
|
|
248
|
+
}
|
|
257
249
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
250
|
+
|
|
251
|
+
// Validate the RESULT against omp's rules, but only refuse over problems
|
|
252
|
+
// this edit introduced: a pre-existing violation elsewhere in the file
|
|
253
|
+
// (which already has omp rejecting every custom provider) must not block an
|
|
254
|
+
// unrelated provider from being added — that would make a broken file
|
|
255
|
+
// unrepairable from the UI.
|
|
256
|
+
const errorsAfter = validateModelsDocument(doc.toJS() as Record<string, unknown>);
|
|
257
|
+
const preExisting = new Set(validateModelsDocument(original));
|
|
258
|
+
const introduced = errorsAfter.filter((error) => !preExisting.has(error));
|
|
259
|
+
if (introduced.length > 0) {
|
|
260
|
+
throw new OmpConfigError(`Refusing to write an invalid models.yml: ${introduced[0]}`);
|
|
265
261
|
}
|
|
266
|
-
}
|
|
267
262
|
|
|
268
|
-
|
|
263
|
+
return {
|
|
264
|
+
result: {
|
|
265
|
+
written: true,
|
|
266
|
+
addedModels: additions.map((model) => model.id),
|
|
267
|
+
backfilledModels: backfillIds,
|
|
268
|
+
skippedModels: input.models.filter((model) => known.has(model.id)).map((model) => model.id),
|
|
269
|
+
},
|
|
270
|
+
changed: true,
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
}
|
|
269
274
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
275
|
+
export interface OmpProviderRemovalResult {
|
|
276
|
+
removed: boolean;
|
|
277
|
+
removedModels: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Remove a provider from models.yml.
|
|
282
|
+
*
|
|
283
|
+
* The whole entry goes, `modelOverrides` included: leaving the overrides behind
|
|
284
|
+
* keeps a provider entry that still carries `baseUrl`/`apiKey`, so omp goes on
|
|
285
|
+
* listing it as a configured provider whose models no longer exist — a ghost
|
|
286
|
+
* that reappears in the chamber on the next load. Removing the entry is what
|
|
287
|
+
* "delete this provider" means; overrides for a bundled provider live under
|
|
288
|
+
* that bundled provider's own name and are untouched by this call.
|
|
289
|
+
*/
|
|
290
|
+
export async function removeOmpProvider(slug: string): Promise<OmpProviderRemovalResult> {
|
|
291
|
+
const path = await getModelsConfigPath();
|
|
292
|
+
return withOmpYamlDocument<OmpProviderRemovalResult>(path, (doc) => {
|
|
293
|
+
const providersNode = doc.get('providers');
|
|
294
|
+
if (!isMap(providersNode)) {
|
|
295
|
+
return { result: { removed: false, removedModels: 0 }, changed: false };
|
|
296
|
+
}
|
|
297
|
+
const providerNode = providersNode.get(slug);
|
|
298
|
+
if (!isMap(providerNode)) {
|
|
299
|
+
return { result: { removed: false, removedModels: 0 }, changed: false };
|
|
300
|
+
}
|
|
301
|
+
const models = plainOf(doc, providerNode.get('models'));
|
|
302
|
+
const removedModels = Array.isArray(models) ? models.length : 0;
|
|
303
|
+
providersNode.delete(slug);
|
|
304
|
+
return { result: { removed: true, removedModels }, changed: true };
|
|
305
|
+
});
|
|
276
306
|
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* omp-web/lib/omp/model-roles.ts.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { getOmpConfigPath } from '@/server/lib/omp/config/yaml';
|
|
14
|
+
import { withOmpYamlDocument } from '@/server/lib/omp/config/document';
|
|
15
15
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
16
16
|
|
|
17
17
|
export type ModelRoles = Record<string, string>;
|
|
@@ -31,16 +31,8 @@ export async function readModelRoles(): Promise<{ path: string; roles: ModelRole
|
|
|
31
31
|
/** Updates only modelRoles, preserving the user's remaining native OMP config. */
|
|
32
32
|
export async function writeModelRoles(roles: ModelRoles): Promise<void> {
|
|
33
33
|
const path = getOmpConfigPath();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export async function readDisabledProviders(): Promise<Set<string>> {
|
|
41
|
-
const path = getOmpConfigPath();
|
|
42
|
-
if (!(await Bun.file(path).exists())) return new Set();
|
|
43
|
-
const data = Bun.YAML.parse(await Bun.file(path).text());
|
|
44
|
-
if (!isRecord(data) || !Array.isArray(data.disabledProviders)) return new Set();
|
|
45
|
-
return new Set(data.disabledProviders.filter((provider): provider is string => typeof provider === 'string'));
|
|
34
|
+
await withOmpYamlDocument(path, (doc) => {
|
|
35
|
+
doc.set('modelRoles', roles);
|
|
36
|
+
return { result: undefined, changed: true };
|
|
37
|
+
});
|
|
46
38
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { join } from 'path';
|
|
15
15
|
import { Database } from 'bun:sqlite';
|
|
16
16
|
import { getAgentDir } from '@/server/lib/omp/core/paths';
|
|
17
|
-
import { getModelsConfigPath } from '@/server/lib/omp/config/
|
|
17
|
+
import { getModelsConfigPath } from '@/server/lib/omp/config/models-config';
|
|
18
18
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
19
19
|
|
|
20
20
|
const AUTH_CREDENTIALS_TABLE = 'auth_credentials';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Wiring of the auto-title trigger into the frame fold.
|
|
8
|
+
*
|
|
9
|
+
* The trigger itself is covered by `session/auto-title.server.test.ts`; what
|
|
10
|
+
* these tests pin is WHEN the fold reaches it. That timing is load-bearing and
|
|
11
|
+
* was measured against omp 18.3.0: `agent_start` fires before the turn opens, so
|
|
12
|
+
* the transcript is still empty there and a title asked for at that point comes
|
|
13
|
+
* back "Could not generate a session title".
|
|
14
|
+
*
|
|
15
|
+
* The host carries no session id, so `triggerAutoSessionTitle` returns before it
|
|
16
|
+
* touches the settings database. The stage flags it flips on the way are the
|
|
17
|
+
* observable proof that the branch was reached.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, expect, test } from 'bun:test';
|
|
21
|
+
|
|
22
|
+
import { foldSessionFrame, type SessionFrameHost } from '@/server/lib/omp/rpc/frame-fold';
|
|
23
|
+
import type { AgentEvent } from '@/server/lib/omp/rpc/constants';
|
|
24
|
+
|
|
25
|
+
function makeHost(): SessionFrameHost {
|
|
26
|
+
return {
|
|
27
|
+
sessionId: '',
|
|
28
|
+
autoTitleInFlight: false,
|
|
29
|
+
autoTitleWindowUntil: 0,
|
|
30
|
+
autoTitlePending: true,
|
|
31
|
+
autoTitleRequested: false,
|
|
32
|
+
promptRunning: false,
|
|
33
|
+
streaming: false,
|
|
34
|
+
compacting: false,
|
|
35
|
+
awaitingAgentStart: false,
|
|
36
|
+
awaitingAgentStartDeadline: 0,
|
|
37
|
+
continuationGraceUntil: 0,
|
|
38
|
+
proc: { sendCommand: async () => undefined as never },
|
|
39
|
+
emit() {},
|
|
40
|
+
trackUiDialog() {},
|
|
41
|
+
observeSubagent() {},
|
|
42
|
+
isAlive: () => true,
|
|
43
|
+
isBusy: () => false,
|
|
44
|
+
send: async () => undefined,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Fold one frame and report the flags the trigger leaves behind. */
|
|
49
|
+
function fold(host: SessionFrameHost, event: AgentEvent): void {
|
|
50
|
+
foldSessionFrame(host, event);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('auto-title trigger wiring', () => {
|
|
54
|
+
test('asks for the title on the first settled user message', () => {
|
|
55
|
+
const host = makeHost();
|
|
56
|
+
fold(host, { type: 'message_end', message: { role: 'user', content: 'fix the sidebar' } });
|
|
57
|
+
|
|
58
|
+
expect(host.autoTitleRequested).toBe(true);
|
|
59
|
+
// The early attempt leaves the eligibility latch alone so the run end can
|
|
60
|
+
// still retry it.
|
|
61
|
+
expect(host.autoTitlePending).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('does not ask on an assistant or tool message', () => {
|
|
65
|
+
for (const role of ['assistant', 'toolResult', 'custom']) {
|
|
66
|
+
const host = makeHost();
|
|
67
|
+
fold(host, { type: 'message_end', message: { role, content: 'x' } });
|
|
68
|
+
expect(host.autoTitleRequested).toBe(false);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('does not ask at agent_start, where the transcript is still empty', () => {
|
|
73
|
+
// The whole reason the trigger is not on this frame: omp pushes
|
|
74
|
+
// `agent_start` before the turn opens, so a `/rename` here reads
|
|
75
|
+
// `messageCount: 0` and omp declines to generate anything.
|
|
76
|
+
const host = makeHost();
|
|
77
|
+
fold(host, { type: 'agent_start' });
|
|
78
|
+
|
|
79
|
+
expect(host.autoTitleRequested).toBe(false);
|
|
80
|
+
expect(host.autoTitlePending).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('resets the early-attempt flag so a later run gets its own try', () => {
|
|
84
|
+
const host = makeHost();
|
|
85
|
+
fold(host, { type: 'message_end', message: { role: 'user', content: 'first' } });
|
|
86
|
+
expect(host.autoTitleRequested).toBe(true);
|
|
87
|
+
|
|
88
|
+
// A run that opens after an aborted one (which skipped the settle attempt)
|
|
89
|
+
// must still be able to ask.
|
|
90
|
+
fold(host, { type: 'agent_start' });
|
|
91
|
+
expect(host.autoTitleRequested).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('retries at the terminal run end and consumes the latch there', () => {
|
|
95
|
+
const host = makeHost();
|
|
96
|
+
fold(host, { type: 'message_end', message: { role: 'user', content: 'first' } });
|
|
97
|
+
|
|
98
|
+
fold(host, { type: 'agent_end', isTerminal: true, messages: [] });
|
|
99
|
+
|
|
100
|
+
// Consumed: this conversation is never titled again after the fallback.
|
|
101
|
+
expect(host.autoTitlePending).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('skips the fallback for an aborted run', () => {
|
|
105
|
+
// A stopped turn is not a settle: the next completed run is a better moment
|
|
106
|
+
// than a half-run transcript, and the latch must survive for it.
|
|
107
|
+
const host = makeHost();
|
|
108
|
+
fold(host, { type: 'message_end', message: { role: 'user', content: 'first' } });
|
|
109
|
+
fold(host, {
|
|
110
|
+
type: 'agent_end',
|
|
111
|
+
isTerminal: true,
|
|
112
|
+
messages: [{ role: 'assistant', stopReason: 'aborted' }],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(host.autoTitlePending).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('does not retry on a non-terminal agent_end', () => {
|
|
119
|
+
const host = makeHost();
|
|
120
|
+
fold(host, { type: 'agent_end', isTerminal: false, messages: [] });
|
|
121
|
+
|
|
122
|
+
expect(host.autoTitlePending).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|