ompchamber 1.2.0 → 2.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.
Files changed (223) hide show
  1. package/README.md +131 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.74ff36419f.js +2 -0
  14. package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
  15. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  16. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  17. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  18. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  19. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  20. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  21. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  22. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  23. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  24. package/dist/client/static/js/async/q.33d6779f98.js +21 -0
  25. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  26. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  27. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  28. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  29. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  31. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  32. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  33. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  34. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  35. package/dist/client/static/js/index.c05535dbde.js +38 -0
  36. package/package.json +3 -3
  37. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  38. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  39. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  40. package/src/client/components/settings/Modal.tsx +11 -2
  41. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  42. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  43. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  45. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  46. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  47. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  48. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  49. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  50. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  51. package/src/client/components/workspace/btw-panel/index.tsx +129 -45
  52. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  53. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  54. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  59. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  63. package/src/client/components/workspace/editor/index.tsx +11 -2
  64. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  65. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  66. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  67. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  68. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  69. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  70. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  71. package/src/client/data/theme/terminal.ts +50 -52
  72. package/src/client/hooks/chat/btw/index.ts +139 -18
  73. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  74. package/src/client/hooks/chat/btw/mode.ts +111 -16
  75. package/src/client/hooks/chat/composer/index.ts +10 -4
  76. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  77. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  78. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  79. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  80. package/src/client/hooks/chat/timeline/index.ts +8 -20
  81. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  82. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  83. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  84. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  85. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  86. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  87. package/src/client/hooks/settings/providers.ts +34 -3
  88. package/src/client/hooks/ui/open-file.ts +7 -1
  89. package/src/client/hooks/ui/theme.ts +82 -57
  90. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  91. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  92. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  93. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  94. package/src/client/hooks/workspace/search-stream.ts +24 -5
  95. package/src/client/main.tsx +6 -0
  96. package/src/client/tailwind.css +19 -56
  97. package/src/server/lib/btw/child.server.ts +41 -0
  98. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  99. package/src/server/lib/btw/prompt.ts +13 -5
  100. package/src/server/lib/btw/registry.server.ts +13 -4
  101. package/src/server/lib/btw/runtime.server.ts +149 -156
  102. package/src/server/lib/btw/schema.server.ts +27 -0
  103. package/src/server/lib/btw/service.server.ts +106 -5
  104. package/src/server/lib/btw/store.server.ts +57 -6
  105. package/src/server/lib/db/schema.ts +4 -0
  106. package/src/server/lib/db/settings-store.test.ts +65 -0
  107. package/src/server/lib/db/settings-store.ts +25 -0
  108. package/src/server/lib/fs/atomic-write.ts +42 -4
  109. package/src/server/lib/fs/config-lock.ts +74 -0
  110. package/src/server/lib/fs/git-ignore.ts +55 -5
  111. package/src/server/lib/fs/ripgrep.ts +119 -0
  112. package/src/server/lib/lifecycle/identity.ts +10 -64
  113. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  114. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  115. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  116. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  117. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  118. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  119. package/src/server/lib/models/provider-registry.server.ts +2 -2
  120. package/src/server/lib/models/registry.server.ts +1 -1
  121. package/src/server/lib/omp/config/behavior.ts +17 -17
  122. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  123. package/src/server/lib/omp/config/document.ts +128 -0
  124. package/src/server/lib/omp/config/extensions.ts +11 -10
  125. package/src/server/lib/omp/config/mcp.ts +4 -57
  126. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  127. package/src/server/lib/omp/config/models-config.ts +92 -0
  128. package/src/server/lib/omp/config/models-validation.ts +159 -0
  129. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  130. package/src/server/lib/omp/config/providers.ts +241 -211
  131. package/src/server/lib/omp/config/roles.ts +6 -14
  132. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  133. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  134. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  135. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  136. package/src/server/lib/omp/rpc/manager.ts +21 -21
  137. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  138. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  139. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  140. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  141. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  142. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  143. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  144. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  145. package/src/server/lib/queue/delivery-harness.ts +88 -0
  146. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  147. package/src/server/lib/queue/delivery.server.ts +105 -13
  148. package/src/server/lib/queue/store.server.ts +30 -5
  149. package/src/server/lib/terminal/foreground.test.ts +164 -0
  150. package/src/server/lib/terminal/foreground.ts +34 -6
  151. package/src/server/lib/terminal/history.ts +8 -7
  152. package/src/server/plugins/compress.ts +33 -5
  153. package/src/server/plugins/ssr.ts +14 -7
  154. package/src/server/routes/btw/topics.ts +89 -2
  155. package/src/server/routes/fs/git.ts +5 -6
  156. package/src/server/routes/fs/search.ts +17 -26
  157. package/src/server/routes/sessions/queue.ts +17 -4
  158. package/src/server/routes/settings/index.ts +2 -0
  159. package/src/server/routes/settings/model-override.ts +69 -0
  160. package/src/server/routes/settings/provider-models.ts +35 -12
  161. package/src/server/routes/settings/providers.ts +31 -1
  162. package/src/server/routes/settings/root.ts +8 -3
  163. package/src/shared/lib/chat/btw/client.ts +34 -0
  164. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  165. package/src/shared/lib/code/shiki-themes.ts +9 -5
  166. package/src/shared/lib/fs/file-tree.ts +17 -12
  167. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  168. package/src/shared/lib/markdown/autolink.ts +50 -0
  169. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  170. package/src/shared/lib/markdown/document-urls.ts +118 -0
  171. package/src/shared/lib/markdown/marked.ts +27 -15
  172. package/src/shared/lib/markdown/mermaid.ts +2 -2
  173. package/src/shared/lib/models/catalog.ts +1 -1
  174. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  175. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  176. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  177. package/src/shared/lib/settings/client.ts +10 -4
  178. package/src/shared/lib/settings/diff.test.ts +31 -0
  179. package/src/shared/lib/settings/diff.ts +37 -0
  180. package/src/shared/lib/theme/catalog.test.ts +168 -0
  181. package/src/shared/lib/theme/catalog.ts +98 -0
  182. package/src/shared/lib/theme/color.ts +88 -0
  183. package/src/shared/lib/theme/css.ts +70 -0
  184. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  185. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  186. package/src/shared/lib/theme/palettes/light.ts +313 -0
  187. package/src/shared/types/btw.ts +83 -4
  188. package/src/shared/types/index.ts +1 -0
  189. package/src/shared/types/settings/provider.ts +8 -1
  190. package/src/shared/types/settings/state.ts +10 -4
  191. package/src/shared/types/theme.ts +48 -0
  192. package/dist/client/static/css/index.b938f4a693.css +0 -2
  193. package/dist/client/static/js/77.8022a800b5.js +0 -139
  194. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  195. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  196. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  197. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  198. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  199. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  200. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  201. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  202. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  203. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  204. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  205. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  206. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  207. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  208. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  209. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  210. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  211. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  212. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  213. package/dist/client/static/js/index.57b39ffa7f.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -181
  215. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  216. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  217. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  218. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  219. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  220. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  221. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
  222. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/{index.57b39ffa7f.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -4,97 +4,21 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Native OMP provider access — models.yml discovery + config.yml
8
- * disabledProviders writes. Faithful adaptation of omp-web/lib/omp/models-config.ts
9
- * and omp-web/app/api/providers/enable/route.ts.
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 { join } from 'path';
13
- import { getAgentDir } from '@/server/lib/omp/core/paths';
14
- import { writeFileAtomic } from '@/server/lib/fs/atomic-write';
15
- import { asMapping } from '@/server/lib/omp/config/yaml';
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 use an atomic temp-file write preserving unrelated keys
131
- * (full-document Bun.YAML round-trip). Provider creation requires an
132
- * apiKey — omp rejects models-cfg providers without one unless auth is "none"
133
- * or "oauth"; existing providers keep whatever credential they already have.
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
- const doc: Record<string, unknown> = (await Bun.file(path).exists())
141
- ? asMapping(Bun.YAML.parse(await Bun.file(path).text()), path)
142
- : {};
143
- let providers: Record<string, unknown> | undefined = isRecord(doc.providers) ? doc.providers : undefined;
144
- if (!providers) {
145
- providers = {};
146
- doc.providers = providers;
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
- const existingPojo = isRecord(providers[slug]) ? (providers[slug] as Record<string, unknown>) : undefined;
150
- const existingModels = Array.isArray(existingPojo?.models) ? existingPojo.models : [];
151
- const knownIds = new Set<string>(
152
- existingModels.map((m) => (isRecord(m) && typeof m.id === 'string' ? m.id : '')).filter(Boolean),
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
- const incomingById = new Map(input.models.map((m) => [m.id, m]));
156
- const additions = input.models.filter((m) => m.id && !knownIds.has(m.id));
157
- // Existing entries with bare ids (no context, no capabilities) get the
158
- // fetched metadata filled in — values already present are never touched.
159
- const backfillIds: string[] = existingPojo
160
- ? existingModels
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 entry.contextWindow !== 'number' || !entry.contextWindow;
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
- || (missingCost && seed.cost)
170
- || (seed.reasoning !== undefined && entry.reasoning === undefined)
171
- || (typeof entry.input !== 'object' && seed.imageInput),
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((m) => String((isRecord(m) ? m : {}).id ?? ''))
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
- let targetApiValue: string | undefined;
188
-
189
- if (!existingPojo && !input.apiKey) {
190
- return {
191
- written: false,
192
- addedModels: [],
193
- backfilledModels: [],
194
- skippedModels: input.models.map((m) => m.id),
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
- const currentInput = entry.input;
247
- if (seed.imageInput && !Array.isArray(currentInput)) {
248
- entry.input = ['text', 'image'];
249
- } else if (seed.imageInput && Array.isArray(currentInput) && !(currentInput as unknown[]).includes('image')) {
250
- entry.input = ['text', 'image'];
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
- if (seed.contextWindow && seed.contextWindow > 0 && !entry.contextWindow) {
253
- entry.contextWindow = seed.contextWindow;
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
- if (seed.maxTokens && seed.maxTokens > 0 && !entry.maxTokens) {
256
- entry.maxTokens = seed.maxTokens;
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
- if (seed.cost && !entry.cost) {
259
- entry.cost = {
260
- input: seed.cost.input,
261
- output: seed.cost.output,
262
- cacheRead: seed.cost.cacheRead,
263
- cacheWrite: seed.cost.cacheWrite,
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
- await writeFileAtomic(path, Bun.YAML.stringify(doc, null, 2));
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
- return {
271
- written: true,
272
- addedModels: additions.map((m) => m.id),
273
- backfilledModels: backfillIds,
274
- skippedModels: input.models.filter((m) => knownIds.has(m.id)).map((m) => m.id),
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 { writeFileAtomic } from '@/server/lib/fs/atomic-write';
14
- import { asMapping, getOmpConfigPath } from '@/server/lib/omp/config/yaml';
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
- const source = (await Bun.file(path).exists()) ? await Bun.file(path).text() : '';
35
- const doc = asMapping(Bun.YAML.parse(source), path);
36
- doc.modelRoles = roles;
37
- await writeFileAtomic(path, Bun.YAML.stringify(doc, null, 2));
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/providers';
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
+ });