@vantran-se/openchamber-web 2.0.3 → 2.0.5
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 +20 -28
- package/bin/cli.js +3 -1
- package/bin/cli.test.js +13 -4
- package/bin/lib/DOCUMENTATION.md +4 -1
- package/bin/lib/commands-serve.js +9 -0
- package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
- package/dist/assets/DiffView-uzNpHBlV.js +4 -0
- package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
- package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
- package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
- package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
- package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
- package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
- package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
- package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
- package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
- package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
- package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
- package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
- package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
- package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
- package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
- package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
- package/dist/assets/de-BMnfXm-G.js +10 -0
- package/dist/assets/es-DKEvCCL6.js +16 -0
- package/dist/assets/fr-D9fgE2xE.js +14 -0
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
- package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
- package/dist/assets/ja-BEXad-KW.js +16 -0
- package/dist/assets/ko-DGrQ0lEe.js +16 -0
- package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
- package/dist/assets/main-CI9DtHJv.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
- package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
- package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
- package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
- package/dist/assets/pl-BlhkmR5b.js +16 -0
- package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
- package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
- package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
- package/dist/assets/tr-CehUK1M9.js +7 -0
- package/dist/assets/uk-Bno5WGNz.js +16 -0
- package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
- package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
- package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
- package/dist/assets/zh-CN-DnAweIWo.js +16 -0
- package/dist/assets/zh-TW-BbBBK25p.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +3 -3
- package/server/index.d.ts +2 -0
- package/server/index.js +196 -52
- package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
- package/server/lib/agent-tool/runtime.js +89 -51
- package/server/lib/agent-tool/runtime.test.js +17 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/global-hub.js +45 -7
- package/server/lib/event-stream/global-hub.test.js +128 -2
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/rebind.test.js +1 -0
- package/server/lib/event-stream/runtime.js +1 -2
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/DOCUMENTATION.md +23 -23
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +156 -2
- package/server/lib/opencode/lifecycle.test.js +255 -1
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/network-runtime.js +16 -7
- package/server/lib/opencode/network-runtime.test.js +29 -1
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +53 -16
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-startup-runtime.js +25 -0
- package/server/lib/opencode/server-startup-runtime.test.js +67 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared-service-runtime.js +155 -0
- package/server/lib/opencode/shared-service-runtime.test.js +180 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +14 -5
- package/server/lib/opencode/shutdown-runtime.test.js +77 -2
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
- package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/watcher.test.js +54 -0
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +271 -0
- package/server/proxy-headers.js +8 -2
- package/server/proxy-headers.test.js +20 -0
- package/dist/assets/DiffView-CX3wAX2S.js +0 -4
- package/dist/assets/FilesView-OL3CON3r.js +0 -17
- package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
- package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
- package/dist/assets/PluginPane-BibJit86.js +0 -3
- package/dist/assets/SettingsView-h-VpVO47.js +0 -29
- package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
- package/dist/assets/de-BqCNGROu.js +0 -10
- package/dist/assets/es-BTPpTRgN.js +0 -16
- package/dist/assets/fr-hxbzMdAg.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-_zLwwYiy.js +0 -16
- package/dist/assets/ko-CFn3Dzhc.js +0 -16
- package/dist/assets/main-BLJaug9I.js +0 -896
- package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
- package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
- package/dist/assets/pl-DFHE7xac.js +0 -16
- package/dist/assets/pt-BR-CUjZF179.js +0 -16
- package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
- package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
- package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
- package/dist/assets/tr-BB6sA_zH.js +0 -7
- package/dist/assets/uk-CB4o8g-o.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BRyDuWhc.js +0 -16
- package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
|
@@ -63,7 +63,7 @@ chmod 755 ${quote(binary)}
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
describe('on Windows', () => {
|
|
66
|
-
const
|
|
66
|
+
const tarballOf = (name) => `https://registry.npmjs.org/@opencode/${name}/-/${name}-2.0.15.tgz`;
|
|
67
67
|
let exe;
|
|
68
68
|
const integrityOf = (bytes) => `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
|
|
69
69
|
// The platform package keeps its binary at package/bin/opencode.exe. Here it is a
|
|
@@ -77,14 +77,17 @@ chmod 755 ${quote(binary)}
|
|
|
77
77
|
await fs.rm(root, { recursive: true, force: true });
|
|
78
78
|
return bytes;
|
|
79
79
|
};
|
|
80
|
-
const
|
|
80
|
+
const PACKAGE_BY_ARCH = { x64: 'cli-windows-x64-baseline', arm64: 'cli-windows-arm64' };
|
|
81
|
+
const runWindows = (archive, integrity = integrityOf(archive), arch = 'x64') => installOpenCodeV2({
|
|
81
82
|
homeDirectory,
|
|
82
83
|
platform: 'win32',
|
|
84
|
+
arch,
|
|
83
85
|
tarCommand: 'tar',
|
|
84
86
|
fetchImpl: async (url) => {
|
|
87
|
+
const name = PACKAGE_BY_ARCH[arch];
|
|
85
88
|
if (url.endsWith('/@opencode%2Fcli/latest')) return Response.json({ version: '2.0.15' });
|
|
86
|
-
if (url.endsWith(
|
|
87
|
-
if (url ===
|
|
89
|
+
if (url.endsWith(`/@opencode%2F${name}/2.0.15`)) return Response.json({ dist: { tarball: tarballOf(name), integrity } });
|
|
90
|
+
if (url === tarballOf(name)) return new Response(archive);
|
|
88
91
|
return new Response(null, { status: 404 });
|
|
89
92
|
},
|
|
90
93
|
});
|
|
@@ -101,6 +104,12 @@ chmod 755 ${quote(binary)}
|
|
|
101
104
|
await expect(fs.stat(path.join(path.dirname(exe), '.openchamber-install'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
102
105
|
});
|
|
103
106
|
|
|
107
|
+
it('installs the native arm64 package on Windows ARM64', async () => {
|
|
108
|
+
const archive = await pack('2.0.15');
|
|
109
|
+
expect(await runWindows(archive, integrityOf(archive), 'arm64')).toBe(exe);
|
|
110
|
+
expect(await readOpenCodeCliVersion({ binary: exe, args: [] })).toBe('2.0.15');
|
|
111
|
+
});
|
|
112
|
+
|
|
104
113
|
it('rejects a package that does not match its published integrity before touching the binary', async () => {
|
|
105
114
|
const archive = await pack('2.0.15');
|
|
106
115
|
await expect(runWindows(archive, integrityOf(Buffer.from('other')))).rejects.toThrow('integrity');
|
|
@@ -47,13 +47,15 @@ export const createOpenCodeWatcherRuntime = (deps) => {
|
|
|
47
47
|
const signal = abortController.signal;
|
|
48
48
|
|
|
49
49
|
if (globalEventHub) {
|
|
50
|
+
// The events of isolated spaces feed this watcher too, so live status, unread marks and
|
|
51
|
+
// notifications work for a space's sessions as for the host's.
|
|
50
52
|
unsubscribeEvent = globalEventHub.subscribeEvent((event) => {
|
|
51
53
|
const payload = unwrapGlobalEventPayload(event.payload);
|
|
52
54
|
if (!payload || typeof payload !== 'object') {
|
|
53
55
|
return;
|
|
54
56
|
}
|
|
55
57
|
emitTranslated(payload);
|
|
56
|
-
});
|
|
58
|
+
}, { spaces: true });
|
|
57
59
|
unsubscribeStatus = globalEventHub.subscribeStatus((status) => {
|
|
58
60
|
if (signal.aborted) {
|
|
59
61
|
return;
|
|
@@ -3,6 +3,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
3
3
|
import { createGlobalMessageStreamHub } from '../event-stream/global-hub.js';
|
|
4
4
|
import { createOpenCodeWatcherRuntime } from './watcher.js';
|
|
5
5
|
|
|
6
|
+
async function waitForAssertion(assertion) {
|
|
7
|
+
const deadline = Date.now() + 1000;
|
|
8
|
+
let lastError;
|
|
9
|
+
while (Date.now() < deadline) {
|
|
10
|
+
try {
|
|
11
|
+
assertion();
|
|
12
|
+
return;
|
|
13
|
+
} catch (error) {
|
|
14
|
+
lastError = error;
|
|
15
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
throw lastError;
|
|
19
|
+
}
|
|
20
|
+
|
|
6
21
|
function createSseResponse({ blocks = [], signal, holdOpen = false }) {
|
|
7
22
|
const encoder = new TextEncoder();
|
|
8
23
|
let index = 0;
|
|
@@ -189,6 +204,45 @@ describe('createOpenCodeWatcherRuntime', () => {
|
|
|
189
204
|
});
|
|
190
205
|
});
|
|
191
206
|
|
|
207
|
+
it('keeps its shared subscription across a hub rebind', async () => {
|
|
208
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
209
|
+
let generation = 0;
|
|
210
|
+
const payloads = [];
|
|
211
|
+
const globalEventHub = createGlobalMessageStreamHub({
|
|
212
|
+
buildOpenCodeUrl: () => `http://127.0.0.1:${generation === 0 ? 4096 : 5096}/api/event`,
|
|
213
|
+
getOpenCodeAuthHeaders: () => ({ Authorization: generation === 0 ? 'Basic first' : 'Basic second' }),
|
|
214
|
+
upstreamReconnectDelayMs: 60_000,
|
|
215
|
+
fetchImpl: async (_url, options) => createSseResponse({
|
|
216
|
+
signal: options.signal,
|
|
217
|
+
holdOpen: true,
|
|
218
|
+
blocks: [generation === 0
|
|
219
|
+
? 'data: {"id":"evt-1","type":"server.connected","data":{}}\n\n'
|
|
220
|
+
: 'data: {"id":"evt-2","type":"session.renamed","location":{"directory":"/tmp/project"},"data":{"sessionID":"ses_1","title":"Recovered"}}\n\n'],
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
const watcher = createOpenCodeWatcherRuntime({
|
|
224
|
+
waitForOpenCodePort: async () => {},
|
|
225
|
+
buildOpenCodeUrl: () => '',
|
|
226
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
227
|
+
globalEventHub,
|
|
228
|
+
onPayload: (payload) => payloads.push(payload),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
await watcher.start();
|
|
233
|
+
await waitForAssertion(() => expect(payloads.map((payload) => payload.type)).toContain('server.connected'));
|
|
234
|
+
generation = 1;
|
|
235
|
+
globalEventHub.rebind();
|
|
236
|
+
await waitForAssertion(() => expect(payloads).toContainEqual(expect.objectContaining({
|
|
237
|
+
type: 'session.updated',
|
|
238
|
+
properties: expect.objectContaining({ sessionID: 'ses_1' }),
|
|
239
|
+
})));
|
|
240
|
+
} finally {
|
|
241
|
+
watcher.stop();
|
|
242
|
+
globalEventHub.stop();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
192
246
|
it('does not stop a shared global event hub when the watcher stops', async () => {
|
|
193
247
|
const events = new Set();
|
|
194
248
|
const statuses = new Set();
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
readConfigLayers,
|
|
10
10
|
writeConfig,
|
|
11
11
|
} from './shared.js';
|
|
12
|
-
import { findWebSearchProjectOverride, writeWebSearchSelection } from './config-v2.js';
|
|
12
|
+
import { findWebSearchProjectOverride, writeWarmingEnabled, writeWebSearchSelection } from './config-v2.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Writes the `websearch` choice into the config OpenCode reads last among the
|
|
@@ -26,6 +26,18 @@ export function setWebSearchSelection(selection) {
|
|
|
26
26
|
return { path: target.path, changed };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Turns session warming on or off in the same file the web search choice goes
|
|
31
|
+
* to; OpenCode watches it, so the change applies without a restart.
|
|
32
|
+
*/
|
|
33
|
+
export function setWarmingEnabled(enabled) {
|
|
34
|
+
const layers = readConfigLayers(null);
|
|
35
|
+
const target = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
|
|
36
|
+
const changed = writeWarmingEnabled(target.config, enabled);
|
|
37
|
+
if (changed) writeConfig(target.config, target.path);
|
|
38
|
+
return { path: target.path, changed };
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
/**
|
|
30
42
|
* Where the effective `websearch` value for `directory` comes from, as far as
|
|
31
43
|
* a Settings write is concerned: `projectPath` is the project config that
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
|
|
13
13
|
export const providerId = 'kimi-for-coding';
|
|
14
14
|
export const providerName = 'Kimi for Coding';
|
|
15
|
-
const aliases = ['kimi-for-coding', 'kimi'];
|
|
15
|
+
const aliases = ['kimi-for-coding', 'kimi', 'kimi-code-plan-global'];
|
|
16
16
|
|
|
17
17
|
// Kimi's weekly `usage` block reports `used`; its rate-limit `limits[].detail`
|
|
18
18
|
// blocks report `remaining` instead. Neither field is guaranteed present, so
|
|
@@ -10,10 +10,14 @@ const isAllowedSsePath = (pathname) => {
|
|
|
10
10
|
|| pathname === '/api/notifications/stream';
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
+
// The same three sockets of an isolated space, under `/api/spaces/<id>/`, matched by shape.
|
|
14
|
+
const SPACE_WS_PATH = /^\/api\/spaces\/[0-9a-f]{12}\/(?:terminal\/ws|event\/ws|global\/event\/ws)$/;
|
|
15
|
+
|
|
13
16
|
const isAllowedWebSocketPath = (pathname) => {
|
|
14
17
|
return pathname === '/api/event/ws'
|
|
15
18
|
|| pathname === '/api/global/event/ws'
|
|
16
|
-
|| pathname === '/api/terminal/ws'
|
|
19
|
+
|| pathname === '/api/terminal/ws'
|
|
20
|
+
|| SPACE_WS_PATH.test(pathname);
|
|
17
21
|
};
|
|
18
22
|
|
|
19
23
|
const normalizeBaseUrl = (value) => {
|
|
@@ -228,6 +228,35 @@ describe('realtime proxy', () => {
|
|
|
228
228
|
}
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
it('proxies the sockets of an isolated space by path shape, and refuses another path under the prefix', async () => {
|
|
232
|
+
const seen = [];
|
|
233
|
+
const upstreamServer = http.createServer();
|
|
234
|
+
const upstreamWs = new WebSocketServer({ server: upstreamServer });
|
|
235
|
+
upstreamWs.on('connection', (socket, request) => {
|
|
236
|
+
seen.push(request.url);
|
|
237
|
+
socket.send('ready');
|
|
238
|
+
});
|
|
239
|
+
const upstreamOrigin = await listen(upstreamServer);
|
|
240
|
+
const { origin, runtime } = await startProxyServer({ apiBaseUrl: upstreamOrigin });
|
|
241
|
+
const attempt = (path) => new Promise((resolve, reject) => {
|
|
242
|
+
const client = new WebSocket(buildRealtimeProxyWsUrl(origin, `${upstreamOrigin.replace(/^http:/, 'ws:')}${path}`), { headers: { Origin: 'openchamber-ui://app' } });
|
|
243
|
+
client.once('message', (data) => { client.close(); resolve({ message: data.toString() }); });
|
|
244
|
+
client.once('close', (code) => resolve({ code }));
|
|
245
|
+
client.once('error', reject);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
expect(await attempt('/api/spaces/a1b2c3d4e5f6/terminal/ws')).toEqual({ message: 'ready' });
|
|
250
|
+
expect(await attempt('/api/spaces/a1b2c3d4e5f6/event/ws?directory=%2Fspaces%2Fa1b2c3d4e5f6%2Frepo')).toEqual({ message: 'ready' });
|
|
251
|
+
expect(await attempt('/api/spaces/a1b2c3d4e5f6/dictation/ws')).toEqual({ code: 1008 });
|
|
252
|
+
expect(await attempt('/api/spaces/A1B2C3D4E5F6/terminal/ws')).toEqual({ code: 1008 });
|
|
253
|
+
expect(seen).toEqual(['/api/spaces/a1b2c3d4e5f6/terminal/ws', '/api/spaces/a1b2c3d4e5f6/event/ws?directory=%2Fspaces%2Fa1b2c3d4e5f6%2Frepo']);
|
|
254
|
+
upstreamWs.close();
|
|
255
|
+
} finally {
|
|
256
|
+
runtime.stop();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
231
260
|
it('allows first passwordless WebSocket proxy upgrade without an existing cookie', async () => {
|
|
232
261
|
const upstreamServer = http.createServer();
|
|
233
262
|
const upstreamWs = new WebSocketServer({ server: upstreamServer });
|
|
@@ -37,7 +37,10 @@ const ALLOWED_WS_PATHS = new Set([
|
|
|
37
37
|
// Extension surfaces carry the extension id in the path, so they are matched
|
|
38
38
|
// by shape rather than listed.
|
|
39
39
|
const GUEST_SURFACE_WS_PATH = /^\/api\/guests\/[a-z][a-z0-9-]*\/surface\/ws$/;
|
|
40
|
-
|
|
40
|
+
// The sockets of an isolated space carry the space id in the path: the same four sockets,
|
|
41
|
+
// under `/api/spaces/<id>/`, matched by shape.
|
|
42
|
+
const SPACE_WS_PATH = /^\/api\/spaces\/[0-9a-f]{12}\/(?:terminal\/ws|dev-tunnel|event\/ws|global\/event\/ws)$/;
|
|
43
|
+
export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname) || GUEST_SURFACE_WS_PATH.test(pathname) || SPACE_WS_PATH.test(pathname);
|
|
41
44
|
|
|
42
45
|
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
|
|
43
46
|
// to the loopback origin. content-length is dropped too because the body is
|
|
@@ -153,6 +153,17 @@ describe('relay host WebSocket allowlist', () => {
|
|
|
153
153
|
expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
test('allows the sockets of an isolated space by path shape only', () => {
|
|
157
|
+
for (const socket of ['terminal/ws', 'dev-tunnel', 'event/ws', 'global/event/ws']) {
|
|
158
|
+
expect(isAllowedRelayWebSocketPath(`/api/spaces/a1b2c3d4e5f6/${socket}`)).toBe(true);
|
|
159
|
+
}
|
|
160
|
+
expect(isAllowedRelayWebSocketPath('/api/spaces/a1b2c3d4e5f6/dictation/ws')).toBe(false);
|
|
161
|
+
expect(isAllowedRelayWebSocketPath('/api/spaces/A1B2C3D4E5F6/terminal/ws')).toBe(false);
|
|
162
|
+
expect(isAllowedRelayWebSocketPath('/api/spaces/a1b2c3d4e5f/terminal/ws')).toBe(false);
|
|
163
|
+
expect(isAllowedRelayWebSocketPath('/api/spaces/a1b2c3d4e5f6/terminal/ws/x')).toBe(false);
|
|
164
|
+
expect(isAllowedRelayWebSocketPath('/api/spaces//terminal/ws')).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
156
167
|
test('allows an extension surface socket by path shape only', () => {
|
|
157
168
|
expect(isAllowedRelayWebSocketPath('/api/guests/server-chrome/surface/ws')).toBe(true);
|
|
158
169
|
expect(isAllowedRelayWebSocketPath('/api/guests/Server/surface/ws')).toBe(false);
|
|
@@ -121,13 +121,25 @@ before work and before writing. With both off there are no reads, model calls,
|
|
|
121
121
|
or writes. With one on, the shared recent context is still available, but only
|
|
122
122
|
that field is requested. An empty suggestion does not erase a valid recap.
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
Freshness has one rule, `getCurrentSessionAssist` in
|
|
125
|
+
`packages/ui/src/lib/sessionAssistMetadata.ts`, computed from the session
|
|
126
|
+
record alone so the chat and the sidebar row always agree: the payload is
|
|
127
|
+
current while `generatedAt >= session.time.idle` and the session is not
|
|
128
|
+
reverted. OpenCode moves `time.idle` at every turn end, succeeded or failed.
|
|
129
|
+
Do not compare `forMessageID` with the last loaded message: in v2 the newest
|
|
130
|
+
record is the turn's `idle` marker or a switch record, never the answer.
|
|
131
|
+
When a session turns busy, the runtime also deletes the assist it wrote
|
|
132
|
+
(`persistSessionAssist(id, dir, null)`), so stored state goes stale only for
|
|
133
|
+
payloads written by an earlier process; the `time.idle` rule retires those.
|
|
134
|
+
|
|
135
|
+
- `packages/ui/src/lib/sessionAssistMetadata.ts` parses the payload and owns freshness.
|
|
136
|
+
- `packages/ui/src/hooks/useSessionAssist.ts` adds live-status and settings gating.
|
|
129
137
|
- `SessionRecapSpacer` shows the reminder in the reserved gap under the reply.
|
|
130
138
|
- `SessionSuggestionChip` fills the composer; it never sends automatically.
|
|
139
|
+
- Sidebar rows (`SessionNodeItem`, both Projects and Timeline) mark a session
|
|
140
|
+
whose suggestion is still open with a small icon and the suggestion as its
|
|
141
|
+
title, using the same freshness rule (`getOpenSessionSuggestion`). The marker hides while a turn runs, on the open session, and
|
|
142
|
+
when `sessionSuggestionEnabled` is off.
|
|
131
143
|
|
|
132
144
|
Web, Electron, hosted mobile, and Capacitor use the server watcher. VS Code's
|
|
133
145
|
extension-only runtime does not generate assists; shared UI can render payloads
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Background session assistance. Only live idle events arm generation; there
|
|
2
|
-
// is no backfill.
|
|
2
|
+
// is no backfill. A new turn deletes the assist this process wrote; clients
|
|
3
|
+
// retire any other payload older than the session's `time.idle`.
|
|
3
4
|
import fs from 'fs';
|
|
4
5
|
import os from 'os';
|
|
5
6
|
import path from 'path';
|
|
@@ -99,6 +100,8 @@ export const createSessionAssistRuntime = ({
|
|
|
99
100
|
const timers = new Map();
|
|
100
101
|
const inflight = new Map();
|
|
101
102
|
const ready = new Map();
|
|
103
|
+
// Sessions holding an assist this process wrote, keyed to their directory.
|
|
104
|
+
const persisted = new Map();
|
|
102
105
|
let stopped = false;
|
|
103
106
|
|
|
104
107
|
const clearTimer = (sessionId) => {
|
|
@@ -115,6 +118,16 @@ export const createSessionAssistRuntime = ({
|
|
|
115
118
|
inflight.get(sessionId)?.controller.abort();
|
|
116
119
|
};
|
|
117
120
|
|
|
121
|
+
// A new turn makes the stored recap and suggestion describe an older turn:
|
|
122
|
+
// delete them so "has a suggestion" in metadata means the same everywhere.
|
|
123
|
+
const retireStored = (sessionId, directory) => {
|
|
124
|
+
if (!persisted.has(sessionId)) return;
|
|
125
|
+
const storedDirectory = persisted.get(sessionId);
|
|
126
|
+
persisted.delete(sessionId);
|
|
127
|
+
Promise.resolve(persistSessionAssist(sessionId, directory || storedDirectory, null))
|
|
128
|
+
.catch(() => console.warn('[session-assist] failed to retire a stale assist'));
|
|
129
|
+
};
|
|
130
|
+
|
|
118
131
|
const generateAssist = async (sessionId, directory, signal) => {
|
|
119
132
|
const targets = getTargets();
|
|
120
133
|
if (!targets.recap && !targets.suggestion) return;
|
|
@@ -206,6 +219,7 @@ export const createSessionAssistRuntime = ({
|
|
|
206
219
|
forMessageID: last.id,
|
|
207
220
|
generatedAt: Date.now(),
|
|
208
221
|
});
|
|
222
|
+
persisted.set(sessionId, directory);
|
|
209
223
|
};
|
|
210
224
|
|
|
211
225
|
const startGeneration = (sessionId, directory, armedAt) => {
|
|
@@ -254,7 +268,10 @@ export const createSessionAssistRuntime = ({
|
|
|
254
268
|
const status = extractSessionStatus(payload);
|
|
255
269
|
if (status) {
|
|
256
270
|
if (status.type === 'idle') armTimer(status.sessionId, status.directory || directoryHint);
|
|
257
|
-
else
|
|
271
|
+
else {
|
|
272
|
+
invalidate(status.sessionId);
|
|
273
|
+
retireStored(status.sessionId, status.directory || directoryHint);
|
|
274
|
+
}
|
|
258
275
|
return;
|
|
259
276
|
}
|
|
260
277
|
const userMessage = extractUserMessage(payload);
|
|
@@ -153,6 +153,14 @@ describe('session assist runtime', () => {
|
|
|
153
153
|
expect(persistSessionAssist.mock.calls[0][2]).toMatchObject({ recap: 'Did the thing.', suggestion: 'Verify it.', forMessageID: 'msg_a' });
|
|
154
154
|
// The pre-write re-check must see past the idle marker as well.
|
|
155
155
|
expect(listLimits.at(-1)).toBeGreaterThan(2);
|
|
156
|
+
|
|
157
|
+
// A new turn deletes the stored assist, once.
|
|
158
|
+
const busy = { type: 'session.status', properties: { sessionID: 'ses_1', status: { type: 'busy' } } };
|
|
159
|
+
runtime.processPayload(busy);
|
|
160
|
+
runtime.processPayload(busy);
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
162
|
+
expect(persistSessionAssist).toHaveBeenCalledTimes(2);
|
|
163
|
+
expect(persistSessionAssist.mock.calls[1][2]).toBeNull();
|
|
156
164
|
vi.unstubAllGlobals();
|
|
157
165
|
});
|
|
158
166
|
|
|
@@ -70,6 +70,17 @@ before touching the filesystem). Rationale: metadata rides every
|
|
|
70
70
|
objective fitting, inline fallback, metadata creation, and the synthetic
|
|
71
71
|
first-turn reminder shared by scheduled tasks and CLI-created sessions.
|
|
72
72
|
|
|
73
|
+
- Forks: OpenCode copies the source metadata into a fork, goal included.
|
|
74
|
+
The UI fork paths (`forkAfterMessage`, `forkFromMessage`, `/fork`) then copy
|
|
75
|
+
the objective file to the fork's id, or inline the text when that write
|
|
76
|
+
fails (`packages/ui/src/lib/sessionForkInheritance.ts`). The copy re-reads
|
|
77
|
+
the fork's goal id first and is skipped when the user armed a new goal on
|
|
78
|
+
the fork meanwhile. Forks made through `POST /api/openchamber/sessions/:id/fork`
|
|
79
|
+
(CLI, scheduled tasks) get the same repair server-side before the prompt is
|
|
80
|
+
dispatched (`openchamber-sessions/fork-inheritance.js`); UI forks call
|
|
81
|
+
OpenCode directly, so both implementations exist. `/btw` forks drop the goal
|
|
82
|
+
with the rest of the inherited namespace.
|
|
83
|
+
|
|
73
84
|
## Flow
|
|
74
85
|
|
|
75
86
|
1. `createSessionGoalRuntime` subscribes to the global SSE hub (same pattern
|
|
@@ -81,12 +81,10 @@ Four things are decided here, in order:
|
|
|
81
81
|
5. Otherwise `GET /api/model/default` — `source: 'default'`. This is
|
|
82
82
|
OpenCode's default chat model, not a small one; it is the last resort.
|
|
83
83
|
|
|
84
|
-
Claude Code is
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
user's Claude subscription rate limit. An available endpoint does not lift the
|
|
89
|
-
refusal — the cost is the reason, not the transport.
|
|
84
|
+
Claude Code (`claude-code`, from the opencode-claude plugin) is a provider
|
|
85
|
+
like any other: its generate path runs a clean one-shot turn with no tools and
|
|
86
|
+
no session, so `claude-code/haiku` (family `claude-haiku`) is picked in the
|
|
87
|
+
normal family order.
|
|
90
88
|
|
|
91
89
|
## Prompt shape
|
|
92
90
|
|
|
@@ -120,10 +118,13 @@ model they got. The resolved value comes back as `outputTokens`.
|
|
|
120
118
|
longer wanted. The timeout covers generation, retry waits and structured-output
|
|
121
119
|
retries together.
|
|
122
120
|
|
|
123
|
-
OpenCode 2 can reject an explicit model before its cold catalog finishes loading
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
121
|
+
OpenCode 2 can reject an explicit model before its cold catalog finishes loading;
|
|
122
|
+
plugin-provided models (Claude Code) stay unavailable for 20-40 s after OpenCode
|
|
123
|
+
starts because plugins for the global location load lazily. An
|
|
124
|
+
`InvalidRequestError` whose message exactly names the selected model as
|
|
125
|
+
`Model unavailable: provider/model` is retried with exponential backoff (0.5, 1,
|
|
126
|
+
2, 4, 8, 16 s, ~31 s in total), with the same model and prompt. The rejection
|
|
127
|
+
precedes provider dispatch, so retries cost no tokens. Cancellation also stops the wait. Other errors are not retried.
|
|
127
128
|
If the model remains unavailable, the route returns 503 with
|
|
128
129
|
`code: 'small-model-unavailable'` and the model-specific reason. Commit and PR
|
|
129
130
|
generation own their error toast and suppress the shared request toast.
|
|
@@ -11,16 +11,20 @@ import {
|
|
|
11
11
|
listProviderInfos,
|
|
12
12
|
} from './client.js';
|
|
13
13
|
|
|
14
|
-
// Never a small model, whatever the transport looks like. A plugin can publish
|
|
15
|
-
// an OpenAI-compatible endpoint for Claude Code, but it is a façade over the
|
|
16
|
-
// Claude Agent SDK, which spawns the Claude Code CLI per request and spends
|
|
17
|
-
// the user's Claude subscription rate limit. Paying that for a session title
|
|
18
|
-
// or a summary is the wrong trade, so the refusal is unconditional rather than
|
|
19
|
-
// conditional on an endpoint existing.
|
|
20
|
-
const CLAUDE_CODE_PROVIDER = 'claude-code';
|
|
21
|
-
|
|
22
14
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
23
15
|
|
|
16
|
+
// Waits between retries of `Model unavailable`, ~31 s in total. Right after
|
|
17
|
+
// OpenCode starts, plugin-provided models (claude-code) stay unavailable for
|
|
18
|
+
// 20-40 s while plugins for the global location load lazily. The rejection
|
|
19
|
+
// precedes provider dispatch, so a retry costs no tokens.
|
|
20
|
+
const UNAVAILABLE_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000, 16_000];
|
|
21
|
+
let unavailableRetryDelaysMs = UNAVAILABLE_RETRY_DELAYS_MS;
|
|
22
|
+
|
|
23
|
+
/** Test hook: replace the backoff schedule; no argument restores the default. */
|
|
24
|
+
export const setUnavailableRetryDelaysForTest = (delays = UNAVAILABLE_RETRY_DELAYS_MS) => {
|
|
25
|
+
unavailableRetryDelaysMs = delays;
|
|
26
|
+
};
|
|
27
|
+
|
|
24
28
|
const OPENCHAMBER_SETTINGS_FILE = path.join(
|
|
25
29
|
process.env.OPENCHAMBER_DATA_DIR
|
|
26
30
|
? path.resolve(process.env.OPENCHAMBER_DATA_DIR)
|
|
@@ -151,7 +155,6 @@ export const pickSmallModelAnywhere = (models) => pickSmallModel(models, () => t
|
|
|
151
155
|
const pickSmallModel = (models, accept) => {
|
|
152
156
|
const candidates = models
|
|
153
157
|
.filter((model) => model && accept(model)
|
|
154
|
-
&& model.providerID !== CLAUDE_CODE_PROVIDER
|
|
155
158
|
&& model.enabled !== false
|
|
156
159
|
&& (model.status === undefined || model.status === 'active')
|
|
157
160
|
&& (model.capabilities?.input ?? ['text']).some((item) => String(item).startsWith('text'))
|
|
@@ -260,13 +263,6 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
|
|
260
263
|
);
|
|
261
264
|
}
|
|
262
265
|
|
|
263
|
-
if (resolved.providerID === CLAUDE_CODE_PROVIDER) {
|
|
264
|
-
throw Object.assign(
|
|
265
|
-
new Error('Claude Code cannot be used for background small-model actions. Choose another Small Model in Settings → Sessions.'),
|
|
266
|
-
{ statusCode: 422, code: 'small-model-provider-unsupported' },
|
|
267
|
-
);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
266
|
// A caller that must stay on its session's provider is only overruled by an
|
|
271
267
|
// explicit user choice (the settings override or a request model).
|
|
272
268
|
if (restrictToPreferredProvider
|
|
@@ -302,7 +298,6 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
|
|
302
298
|
|
|
303
299
|
const generationOptions = requestOptions({ timeoutMs, signal });
|
|
304
300
|
const unavailableMessage = `Model unavailable: ${resolved.providerID}/${resolved.modelID}`;
|
|
305
|
-
let retriedUnavailable = false;
|
|
306
301
|
const send = async () => {
|
|
307
302
|
const result = await client.generate.text(
|
|
308
303
|
{ prompt: fullPrompt, model: { id: resolved.modelID, providerID: resolved.providerID } },
|
|
@@ -312,21 +307,22 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
|
|
312
307
|
};
|
|
313
308
|
|
|
314
309
|
const sendWithCatalogRetry = async () => {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
310
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
311
|
+
try {
|
|
312
|
+
return await send();
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error?._tag !== 'InvalidRequestError' || error.message !== unavailableMessage) throw error;
|
|
315
|
+
// OpenCode 2 can resolve a cold catalog before its models arrive.
|
|
316
|
+
// This rejection precedes provider dispatch; other failures must not retry.
|
|
317
|
+
if (attempt < unavailableRetryDelaysMs.length) {
|
|
318
|
+
await delay(unavailableRetryDelaysMs[attempt], undefined, { signal: generationOptions.signal });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
throw Object.assign(new Error(unavailableMessage), {
|
|
322
|
+
statusCode: 503,
|
|
323
|
+
code: 'small-model-unavailable',
|
|
324
|
+
});
|
|
325
325
|
}
|
|
326
|
-
throw Object.assign(new Error(unavailableMessage), {
|
|
327
|
-
statusCode: 503,
|
|
328
|
-
code: 'small-model-unavailable',
|
|
329
|
-
});
|
|
330
326
|
}
|
|
331
327
|
};
|
|
332
328
|
|
|
@@ -386,7 +382,6 @@ export async function listAuthenticatedProviders() {
|
|
|
386
382
|
if (typeof provider?.id === 'string' && enabled.has(provider.id)) ids.add(provider.id);
|
|
387
383
|
}
|
|
388
384
|
for (const id of enabled) ids.add(id);
|
|
389
|
-
ids.delete(CLAUDE_CODE_PROVIDER);
|
|
390
385
|
return Array.from(ids);
|
|
391
386
|
} catch {
|
|
392
387
|
return [];
|
|
@@ -3,14 +3,14 @@ import { registerSmallModelRoutes } from './routes.js';
|
|
|
3
3
|
import http from 'node:http';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import path from 'path';
|
|
6
|
-
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
6
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
|
7
7
|
|
|
8
8
|
// The settings override is read straight from disk, so without this the suite
|
|
9
9
|
// would resolve whatever small model the developer running it has configured.
|
|
10
10
|
const TEMP_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'small-model-settings-'));
|
|
11
11
|
process.env.OPENCHAMBER_DATA_DIR = TEMP_DATA_DIR;
|
|
12
12
|
|
|
13
|
-
const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders } = await import('./index.js');
|
|
13
|
+
const { generateSmallModelText, describeSmallModel, listAuthenticatedProviders, setUnavailableRetryDelaysForTest } = await import('./index.js');
|
|
14
14
|
const { configureOpenCodeRuntimeProviders, resetOpenCodeRuntimeProviders } = await import('./client.js');
|
|
15
15
|
|
|
16
16
|
// A real OpenCode stub over HTTP. The module talks to OpenCode through
|
|
@@ -103,7 +103,10 @@ describe('generateSmallModelText', () => {
|
|
|
103
103
|
const unavailable = { _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' };
|
|
104
104
|
const options = { prompt: 'write a commit', model: 'zai-coding-plan/glm-5.3-flash' };
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
beforeEach(() => setUnavailableRetryDelaysForTest([1, 1, 1]));
|
|
107
|
+
afterEach(() => setUnavailableRetryDelaysForTest());
|
|
108
|
+
|
|
109
|
+
it('retries a cold catalog with the same model and prompt', async () => {
|
|
107
110
|
state.generateErrors = [unavailable];
|
|
108
111
|
const result = await generateSmallModelText(options);
|
|
109
112
|
expect(result.text).toBe('generated');
|
|
@@ -112,12 +115,19 @@ describe('generateSmallModelText', () => {
|
|
|
112
115
|
expect(calls[0].body).toEqual(calls[1].body);
|
|
113
116
|
});
|
|
114
117
|
|
|
115
|
-
it('
|
|
116
|
-
state.generateErrors = [unavailable, unavailable];
|
|
118
|
+
it('keeps backing off while the plugin model loads', async () => {
|
|
119
|
+
state.generateErrors = [unavailable, unavailable, unavailable];
|
|
120
|
+
const result = await generateSmallModelText(options);
|
|
121
|
+
expect(result.text).toBe('generated');
|
|
122
|
+
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(4);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('reports persistent model unavailability once the backoff runs out', async () => {
|
|
126
|
+
state.generateErrors = [unavailable, unavailable, unavailable, unavailable];
|
|
117
127
|
await expect(generateSmallModelText(options)).rejects.toMatchObject({
|
|
118
128
|
message: unavailable.message, statusCode: 503, code: 'small-model-unavailable',
|
|
119
129
|
});
|
|
120
|
-
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(
|
|
130
|
+
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(4);
|
|
121
131
|
});
|
|
122
132
|
|
|
123
133
|
it('does not retry other invalid requests', async () => {
|
|
@@ -133,6 +143,7 @@ describe('generateSmallModelText', () => {
|
|
|
133
143
|
});
|
|
134
144
|
|
|
135
145
|
it('cancels without sending the retry', async () => {
|
|
146
|
+
setUnavailableRetryDelaysForTest();
|
|
136
147
|
const controller = new AbortController();
|
|
137
148
|
state.generateErrors = [unavailable];
|
|
138
149
|
const pending = generateSmallModelText({ ...options, signal: controller.signal });
|
|
@@ -146,6 +157,7 @@ describe('generateSmallModelText', () => {
|
|
|
146
157
|
});
|
|
147
158
|
|
|
148
159
|
it('honors the timeout during the retry delay', async () => {
|
|
160
|
+
setUnavailableRetryDelaysForTest();
|
|
149
161
|
state.generateErrors = [unavailable];
|
|
150
162
|
await expect(generateSmallModelText({ ...options, timeoutMs: 100 })).rejects.toThrow();
|
|
151
163
|
expect(state.requests.filter((entry) => entry.path === '/api/experimental/generate')).toHaveLength(1);
|
|
@@ -182,11 +194,11 @@ describe('generateSmallModelText', () => {
|
|
|
182
194
|
expect(lastGenerate().body.model).toEqual({ id: 'gpt-5.6-luna', providerID: 'openai' });
|
|
183
195
|
});
|
|
184
196
|
|
|
185
|
-
it('
|
|
186
|
-
await
|
|
187
|
-
.rejects.toMatchObject({ statusCode: 422, code: 'small-model-provider-unsupported' });
|
|
197
|
+
it('sends an explicit Claude Code model like any other', async () => {
|
|
198
|
+
const result = await generateSmallModelText({ prompt: 'hi', model: 'claude-code/haiku', directory: '/proj' });
|
|
188
199
|
|
|
189
|
-
expect(
|
|
200
|
+
expect(result).toMatchObject({ providerID: 'claude-code', modelID: 'haiku', source: 'request' });
|
|
201
|
+
expect(lastGenerate().body.model).toEqual({ id: 'haiku', providerID: 'claude-code' });
|
|
190
202
|
});
|
|
191
203
|
|
|
192
204
|
it('stays on the session provider when the caller forbids switching', async () => {
|
|
@@ -276,14 +288,12 @@ describe('generateSmallModelText', () => {
|
|
|
276
288
|
expect(result).toMatchObject({ providerID: 'my-proxy', modelID: 'gemini-3.6-flash', source: 'session-provider-small' });
|
|
277
289
|
});
|
|
278
290
|
|
|
279
|
-
it('
|
|
291
|
+
it('picks Claude Code haiku as the small model like any other provider', async () => {
|
|
280
292
|
state.models = [MODEL({ id: 'haiku', modelID: 'haiku', providerID: 'claude-code', family: 'claude-haiku' })];
|
|
281
293
|
|
|
282
294
|
const result = await generateSmallModelText({ prompt: 'hi', directory: '/proj' });
|
|
283
295
|
|
|
284
|
-
|
|
285
|
-
// and OpenCode's default (the fixture's anthropic haiku) is used.
|
|
286
|
-
expect(result).toMatchObject({ providerID: 'anthropic', modelID: 'claude-haiku-4-5', source: 'default' });
|
|
296
|
+
expect(result).toMatchObject({ providerID: 'claude-code', modelID: 'haiku', source: 'small' });
|
|
287
297
|
});
|
|
288
298
|
|
|
289
299
|
it('ignores a disabled small model and falls back to the session model', async () => {
|
|
@@ -487,10 +497,10 @@ describe('listAuthenticatedProviders', () => {
|
|
|
487
497
|
expect(await listAuthenticatedProviders()).toEqual(['anthropic']);
|
|
488
498
|
});
|
|
489
499
|
|
|
490
|
-
it('
|
|
500
|
+
it('offers Claude Code', async () => {
|
|
491
501
|
state.models = [MODEL(), MODEL({ id: 'sonnet', modelID: 'sonnet', providerID: 'claude-code' })];
|
|
492
502
|
|
|
493
|
-
expect(await listAuthenticatedProviders()).
|
|
503
|
+
expect(await listAuthenticatedProviders()).toContain('claude-code');
|
|
494
504
|
});
|
|
495
505
|
|
|
496
506
|
it('answers an empty list when OpenCode is not reachable', async () => {
|
|
@@ -510,6 +520,7 @@ describe('small model failure response', () => {
|
|
|
510
520
|
}, {
|
|
511
521
|
getSmallModelService: async () => ({ generateSmallModelText }),
|
|
512
522
|
});
|
|
523
|
+
setUnavailableRetryDelaysForTest([1]);
|
|
513
524
|
state.generateErrors = [
|
|
514
525
|
{ _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' },
|
|
515
526
|
{ _tag: 'InvalidRequestError', message: 'Model unavailable: zai-coding-plan/glm-5.3-flash' },
|
|
@@ -526,5 +537,6 @@ describe('small model failure response', () => {
|
|
|
526
537
|
error: 'Model unavailable: zai-coding-plan/glm-5.3-flash',
|
|
527
538
|
code: 'small-model-unavailable',
|
|
528
539
|
});
|
|
540
|
+
setUnavailableRetryDelaysForTest();
|
|
529
541
|
});
|
|
530
542
|
});
|