@vantran-se/openchamber-web 1.24.3 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -8
- package/bin/lib/commands-connect-url.js +3 -8
- package/bin/lib/commands-tunnel.js +3 -0
- package/dist/assets/{CodeMirrorCommentWidgets-BjJHrzmU.js → CodeMirrorCommentWidgets-ntXIbhV2.js} +1 -1
- package/dist/assets/DiffView-CjxUovbM.js +4 -0
- package/dist/assets/FilesView-DFWD0RQR.js +17 -0
- package/dist/assets/{GitView-CDOJy5Ay.js → GitView-BWa7e5vw.js} +4 -4
- package/dist/assets/{GuestAttachDialog-D3S0Rjic.js → GuestAttachDialog-DdOuyZzp.js} +1 -1
- package/dist/assets/GuestSurfacePane-DDfzid3U.js +1 -0
- package/dist/assets/{JsonTreeView-D0C9nU7K.js → JsonTreeView-3sGwqexh.js} +1 -1
- package/dist/assets/LinearIssuesView-C6dpIdjw.js +1 -0
- package/dist/assets/MarkdownImageGallery-Q7YR_tl4.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DxDV56bL.js +12 -0
- package/dist/assets/OnboardingScreen-rK4Stg__.js +1 -0
- package/dist/assets/{PatchDiff-DnEhjXcC.js → PatchDiff-DS-IMU1j.js} +1 -1
- package/dist/assets/{PierreDiffViewer-DyxZVVVR.js → PierreDiffViewer-DW23KWKZ.js} +1 -1
- package/dist/assets/PlanView-BCk_VobP.js +5 -0
- package/dist/assets/PluginPane-BPP1dbck.js +3 -0
- package/dist/assets/SettingsView-kMmrpn43.js +29 -0
- package/dist/assets/SettingsWindow-BpvD6IYQ.js +1 -0
- package/dist/assets/SubmoduleDiffSummary-BLttSS1X.js +1 -0
- package/dist/assets/ToolOutputDialog-CtqDqbyr.js +15 -0
- package/dist/assets/ToolPartDiffPreview-5FcKdyRy.js +8 -0
- package/dist/assets/{VirtualizedFileDiff-0pyr_mHY.js → VirtualizedFileDiff-C763hNCR.js} +4 -4
- package/dist/assets/{WalkthroughView-yNj4nOP5.js → WalkthroughView-lap5btCF.js} +2 -2
- package/dist/assets/appThemeRegistry-u4zw1l0B.js +1 -0
- package/dist/assets/{commitAndPush-DvJKa3Ks.js → commitAndPush-CwGJfJG-.js} +1 -1
- package/dist/assets/{computeVirtualFileMetrics-DxTBMuu-.js → computeVirtualFileMetrics-DW5jjX4n.js} +1 -1
- package/dist/assets/de-CndEg67T.js +10 -0
- package/dist/assets/es-DglvPlaC.js +16 -0
- package/dist/assets/fr-DejPCpbS.js +14 -0
- package/dist/assets/index-C2r64QfD.js +1 -0
- package/dist/assets/index-CLcM8HKx.css +1 -0
- package/dist/assets/index-CvLgnuNI.js +41 -0
- package/dist/assets/{isDiffPlainText-D7FkBMP6.js → isDiffPlainText-D47a6hMP.js} +1 -1
- package/dist/assets/{isFilePlainText-CL2vsrIZ.js → isFilePlainText-B-mGgeKl.js} +1 -1
- package/dist/assets/ja-CVpQawCd.js +16 -0
- package/dist/assets/ko-Bw446h9j.js +16 -0
- package/dist/assets/main-Bz4WWXbr.js +2 -0
- package/dist/assets/main-oreqDsOV.js +896 -0
- package/dist/assets/{markdownCore-Dh7WEsdM.js → markdownCore-D2e5sp9y.js} +4 -4
- package/dist/assets/miniChat-D1v2gsBI.js +2 -0
- package/dist/assets/mobile-DBj3lCf2.js +2 -0
- package/dist/assets/{patchFileDiff-Bsba8KFp.js → patchFileDiff-BpeUOV91.js} +3 -3
- package/dist/assets/pl-BYNlPdhm.js +16 -0
- package/dist/assets/pt-BR-CL-Yfv9H.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-DVtfEB2z.js +2 -0
- package/dist/assets/renderMobileApp-DFp9-xbG.js +25 -0
- package/dist/assets/runtimeEndpointReset-CsIpUep1.js +27 -0
- package/dist/assets/scheduledTasksApi-B5OHVpPM.js +1 -0
- package/dist/assets/{shikiHighlight-8_3csmr6.js → shikiHighlight-DNm2Z_Jc.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-CHo2fCgW.js → textMateThemeFromAppTheme-DGF2kG7g.js} +1 -1
- package/dist/assets/tr-DzmbxLy-.js +7 -0
- package/dist/assets/uk-C7vQnwro.js +16 -0
- package/dist/assets/useAppFontEffects-Ci3FEaPf.js +7337 -0
- package/dist/assets/{useInlineCommentController-DkQ_gqpn.js → useInlineCommentController-CuUuakrt.js} +2 -2
- package/dist/assets/useRootScrollLock-CQIsyERq.js +2 -0
- package/dist/assets/{vendor-codemirror-language-data-CdzUmUyZ.js → vendor-codemirror-language-data-CQa7_1cr.js} +2 -2
- package/dist/assets/vendor-cron-parser-CjvYVigk.js +2 -0
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +12 -0
- package/dist/assets/{vendor-remixicon-react-BDmGmFCA.js → vendor-remixicon-react-D6dZ0UwF.js} +1 -1
- package/dist/assets/{vendor-zod-CgAdXWZO.js → vendor-zod-Gea9uBlY.js} +1 -1
- package/dist/assets/{web-components-B8IcVQAA.js → web-components-B6tKQyJN.js} +2 -2
- package/dist/assets/zh-CN-Cl0srS-z.js +16 -0
- package/dist/assets/zh-TW-DtII5bDb.js +16 -0
- package/dist/index.html +13 -7
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +6 -5
- package/server/index.d.ts +1 -0
- package/server/index.js +246 -91
- package/server/lib/agent-tool/DOCUMENTATION.md +32 -15
- package/server/lib/agent-tool/runtime.js +135 -54
- package/server/lib/agent-tool/runtime.test.js +155 -87
- package/server/lib/browser-control/DOCUMENTATION.md +37 -2
- package/server/lib/browser-control/provider.js +217 -0
- package/server/lib/browser-control/provider.test.js +251 -0
- package/server/lib/context-obligatory/runtime.js +61 -50
- package/server/lib/context-obligatory/runtime.test.js +62 -162
- package/server/lib/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/delta-coalescer.js +0 -0
- package/server/lib/event-stream/delta-coalescer.test.js +68 -57
- package/server/lib/event-stream/directory-ws-bridge.js +1 -1
- package/server/lib/event-stream/global-hub.js +10 -1
- package/server/lib/event-stream/global-hub.test.js +27 -26
- package/server/lib/event-stream/index.js +6 -0
- package/server/lib/event-stream/protocol.js +26 -19
- package/server/lib/event-stream/protocol.test.js +18 -7
- package/server/lib/event-stream/rebind.test.js +5 -5
- package/server/lib/event-stream/runtime.test.js +17 -15
- package/server/lib/event-stream/translate-v2.js +313 -0
- package/server/lib/event-stream/translate-v2.test.js +209 -0
- package/server/lib/event-stream/upstream-reader.test.js +16 -16
- package/server/lib/fs/DOCUMENTATION.md +4 -2
- package/server/lib/fs/byte-range.js +35 -0
- package/server/lib/fs/byte-range.test.js +31 -0
- package/server/lib/fs/routes.js +47 -42
- package/server/lib/fs/routes.test.js +123 -18
- package/server/lib/git/service.js +12 -0
- package/server/lib/github/DOCUMENTATION.md +1 -1
- package/server/lib/github/pr-status.js +22 -2
- package/server/lib/github/pr-status.test.js +27 -14
- package/server/lib/guests/DOCUMENTATION.md +13 -0
- package/server/lib/guests/catalog.js +12 -0
- package/server/lib/guests/catalog.test.js +33 -0
- package/server/lib/guests/routes.js +10 -1
- package/server/lib/guests/service.js +192 -14
- package/server/lib/guests/service.test.js +201 -1
- package/server/lib/guests/surface.js +630 -0
- package/server/lib/guests/surface.test.js +486 -0
- package/server/lib/markdown-image-grants/routes.js +18 -5
- package/server/lib/markdown-image-grants/routes.test.js +9 -6
- package/server/lib/message-queue/DOCUMENTATION.md +33 -22
- package/server/lib/message-queue/runtime.js +155 -100
- package/server/lib/message-queue/runtime.test.js +168 -82
- package/server/lib/notifications/DOCUMENTATION.md +28 -0
- package/server/lib/notifications/control-stream.http.test.js +68 -0
- package/server/lib/notifications/emitter-runtime.js +15 -0
- package/server/lib/notifications/emitter-runtime.test.js +32 -0
- package/server/lib/notifications/push-runtime.js +5 -1
- package/server/lib/notifications/push-runtime.test.js +2 -2
- package/server/lib/notifications/routes.js +1 -1
- package/server/lib/notifications/runtime.js +115 -26
- package/server/lib/notifications/template-runtime.js +13 -15
- package/server/lib/notifications/template-runtime.test.js +16 -9
- package/server/lib/notifications/trigger-runtime.test.js +187 -0
- package/server/lib/openchamber-control/DOCUMENTATION.md +10 -0
- package/server/lib/openchamber-control/actions.js +3 -2
- package/server/lib/openchamber-control/error.js +1 -1
- package/server/lib/openchamber-control/file-open.js +59 -0
- package/server/lib/openchamber-control/file-open.test.js +57 -0
- package/server/lib/openchamber-control/routes.test.js +12 -0
- package/server/lib/openchamber-control/service.js +102 -49
- package/server/lib/openchamber-control/service.test.js +84 -44
- package/server/lib/openchamber-sessions/archive-store.js +224 -0
- package/server/lib/openchamber-sessions/archive-store.test.js +187 -0
- package/server/lib/openchamber-sessions/opencode-client.js +22 -0
- package/server/lib/openchamber-sessions/routes.js +398 -328
- package/server/lib/openchamber-sessions/routes.test.js +777 -740
- package/server/lib/openchamber-sessions/session-metadata-store.js +285 -0
- package/server/lib/openchamber-sessions/session-metadata-store.test.js +259 -0
- package/server/lib/opencode/DOCUMENTATION.md +449 -29
- package/server/lib/opencode/agents.js +233 -327
- package/server/lib/opencode/auth.js +41 -44
- package/server/lib/opencode/auth.test.js +92 -0
- package/server/lib/opencode/cli-upgrade.d.ts +4 -0
- package/server/lib/opencode/cli-upgrade.js +18 -0
- package/server/lib/opencode/cli-upgrade.test.js +36 -0
- package/server/lib/opencode/commands.js +164 -145
- package/server/lib/opencode/compatibility.d.ts +16 -0
- package/server/lib/opencode/compatibility.js +88 -0
- package/server/lib/opencode/compatibility.test.js +62 -0
- package/server/lib/opencode/config-entity-routes.js +58 -29
- package/server/lib/opencode/config-entity-v2.test.js +290 -0
- package/server/lib/opencode/config-mutation-response.js +17 -7
- package/server/lib/opencode/config-mutation-response.test.js +4 -7
- package/server/lib/opencode/config-paths.test.js +2 -2
- package/server/lib/opencode/config-v2.d.ts +183 -0
- package/server/lib/opencode/config-v2.js +801 -0
- package/server/lib/opencode/config-v2.test.js +466 -0
- package/server/lib/opencode/credential-db.js +144 -0
- package/server/lib/opencode/credential-db.test.js +104 -0
- package/server/lib/opencode/env-runtime.js +48 -9
- package/server/lib/opencode/env-runtime.test.js +45 -0
- package/server/lib/opencode/feature-routes-runtime.js +15 -5
- package/server/lib/opencode/lifecycle-process.test.js +7 -4
- package/server/lib/opencode/lifecycle.js +85 -30
- package/server/lib/opencode/lifecycle.test.js +80 -8
- package/server/lib/opencode/login-shell-env.js +27 -0
- package/server/lib/opencode/managed-config-file.js +119 -0
- package/server/lib/opencode/managed-config-file.test.js +152 -0
- package/server/lib/opencode/managed-plugin-config.js +33 -13
- package/server/lib/opencode/mcp.js +43 -149
- package/server/lib/opencode/network-runtime.js +6 -7
- package/server/lib/opencode/openchamber-routes.test.js +2 -5
- package/server/lib/opencode/plugin-routes.js +3 -3
- package/server/lib/opencode/plugin-routes.test.js +6 -24
- package/server/lib/opencode/plugins.js +91 -40
- package/server/lib/opencode/plugins.test.js +28 -15
- package/server/lib/opencode/providers.js +63 -83
- package/server/lib/opencode/providers.test.js +128 -59
- package/server/lib/opencode/proxy-agent-wiring.test.js +4 -2
- package/server/lib/opencode/proxy.js +179 -129
- package/server/lib/opencode/pwa-manifest-routes.js +4 -2
- package/server/lib/opencode/pwa-manifest-routes.test.js +3 -2
- package/server/lib/opencode/response-envelope.js +14 -0
- package/server/lib/opencode/response-envelope.test.js +23 -0
- package/server/lib/opencode/routes-upgrade.test.js +73 -116
- package/server/lib/opencode/routes.js +121 -428
- package/server/lib/opencode/server-utils-runtime.js +10 -4
- package/server/lib/opencode/session-activity.js +78 -0
- package/server/lib/opencode/session-runtime.js +21 -10
- package/server/lib/opencode/session-runtime.test.js +9 -9
- package/server/lib/opencode/settings-helpers.js +9 -9
- package/server/lib/opencode/settings-helpers.test.js +7 -12
- package/server/lib/opencode/settings-registry.json +6 -8
- package/server/lib/opencode/settings-runtime.js +18 -1
- package/server/lib/opencode/settings-runtime.test.js +31 -2
- package/server/lib/opencode/shared.js +54 -22
- package/server/lib/opencode/shared.test.js +8 -4
- package/server/lib/opencode/shutdown-runtime.js +54 -7
- package/server/lib/opencode/shutdown-runtime.test.js +224 -30
- package/server/lib/opencode/skill-routes.js +23 -24
- package/server/lib/opencode/skills.js +7 -5
- package/server/lib/opencode/tunnel-wiring-runtime.js +2 -1
- package/server/lib/opencode/upgrade-capability.test.js +1 -1
- package/server/lib/opencode/v1-migration-topup.js +288 -0
- package/server/lib/opencode/v1-migration-topup.test.js +266 -0
- package/server/lib/opencode/v2-install.d.ts +8 -0
- package/server/lib/opencode/v2-install.js +158 -0
- package/server/lib/opencode/v2-install.test.js +124 -0
- package/server/lib/opencode/watcher.js +10 -3
- package/server/lib/opencode/watcher.test.js +17 -21
- package/server/lib/opencode/websearch-config.js +66 -0
- package/server/lib/package-manager.js +55 -68
- package/server/lib/package-manager.test.js +241 -75
- package/server/lib/permission-auto-accept/runtime.js +38 -22
- package/server/lib/permission-auto-accept/runtime.test.js +39 -17
- package/server/lib/quota/DOCUMENTATION.md +4 -0
- package/server/lib/quota/providers/xai.js +4 -4
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +7 -0
- package/server/lib/routing/DOCUMENTATION.md +52 -21
- package/server/lib/routing/defaults.js +23 -6
- package/server/lib/routing/jev.js +17 -7
- package/server/lib/routing/routes.http.test.js +68 -33
- package/server/lib/routing/routes.js +65 -22
- package/server/lib/routing/runtime.js +145 -48
- package/server/lib/routing/runtime.test.js +79 -55
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -0
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +51 -46
- package/server/lib/scheduled-tasks/runtime.js +81 -74
- package/server/lib/scheduled-tasks/runtime.test.js +53 -1
- package/server/lib/session-assist/DOCUMENTATION.md +8 -3
- package/server/lib/session-assist/context.js +122 -46
- package/server/lib/session-assist/context.test.js +117 -49
- package/server/lib/session-assist/runtime.js +52 -22
- package/server/lib/session-assist/runtime.test.js +145 -173
- package/server/lib/session-goal/DOCUMENTATION.md +23 -15
- package/server/lib/session-goal/create.js +9 -12
- package/server/lib/session-goal/create.test.js +27 -28
- package/server/lib/session-goal/runtime.js +164 -77
- package/server/lib/session-goal/runtime.test.js +271 -519
- package/server/lib/session-knowledge/DOCUMENTATION.md +5 -0
- package/server/lib/session-knowledge/runtime.js +59 -41
- package/server/lib/session-knowledge/runtime.test.js +90 -20
- package/server/lib/small-model/DOCUMENTATION.md +154 -208
- package/server/lib/small-model/client.js +113 -0
- package/server/lib/small-model/index.js +296 -158
- package/server/lib/small-model/index.test.js +423 -256
- package/server/lib/small-model/routes.js +1 -1
- package/server/lib/spaces/DOCUMENTATION.md +807 -0
- package/server/lib/spaces/code-in-bait.js +185 -0
- package/server/lib/spaces/code-in.js +641 -0
- package/server/lib/spaces/code-in.test.js +1048 -0
- package/server/lib/spaces/errors.js +8 -0
- package/server/lib/spaces/exec-http.js +94 -0
- package/server/lib/spaces/gatekeeper-channel.js +202 -0
- package/server/lib/spaces/gatekeeper-channel.test.js +215 -0
- package/server/lib/spaces/gatekeeper-program.cjs +914 -0
- package/server/lib/spaces/gatekeeper-program.test.js +1391 -0
- package/server/lib/spaces/hardening.js +457 -0
- package/server/lib/spaces/hardening.test.js +368 -0
- package/server/lib/spaces/host-git.js +127 -0
- package/server/lib/spaces/host-git.test.js +137 -0
- package/server/lib/spaces/labels.js +234 -0
- package/server/lib/spaces/labels.test.js +219 -0
- package/server/lib/spaces/layout.js +184 -0
- package/server/lib/spaces/layout.test.js +30 -0
- package/server/lib/spaces/manager.js +82 -0
- package/server/lib/spaces/manager.test.js +114 -0
- package/server/lib/spaces/places/code-in.docker.live.test.js +216 -0
- package/server/lib/spaces/places/contract-suite.js +156 -0
- package/server/lib/spaces/places/contract.docker.live.test.js +7 -0
- package/server/lib/spaces/places/contract.test.js +6 -0
- package/server/lib/spaces/places/docker-engine.js +126 -0
- package/server/lib/spaces/places/docker-live-support.js +202 -0
- package/server/lib/spaces/places/docker-tools.js +186 -0
- package/server/lib/spaces/places/docker.js +587 -0
- package/server/lib/spaces/places/docker.test.js +1610 -0
- package/server/lib/spaces/places/escape-suite.js +1018 -0
- package/server/lib/spaces/places/escape.docker.live.test.js +7 -0
- package/server/lib/spaces/places/fake-docker.js +375 -0
- package/server/lib/spaces/places/memory-place.js +103 -0
- package/server/lib/spaces/places/packed.docker.live.test.js +77 -0
- package/server/lib/spaces/places/registry.js +43 -0
- package/server/lib/spaces/places/registry.test.js +50 -0
- package/server/lib/spaces/places/server.docker.live.test.js +225 -0
- package/server/lib/spaces/run-command.js +261 -0
- package/server/lib/spaces/run-command.test.js +396 -0
- package/server/lib/spaces/space-server.js +190 -0
- package/server/lib/spaces/space-server.test.js +335 -0
- package/server/lib/spaces/tools-filler.js +82 -0
- package/server/lib/spaces/tools-filler.test.js +148 -0
- package/server/lib/spaces/tools-pack.js +49 -0
- package/server/lib/spaces/tools-pack.test.js +57 -0
- package/server/lib/spaces/tools.js +138 -0
- package/server/lib/spaces/tools.test.js +153 -0
- package/server/lib/tts/openai-sdk.js +11 -0
- package/server/lib/tts/service.js +7 -5
- package/server/lib/tts/stt.js +3 -2
- package/server/lib/tunnels/DOCUMENTATION.md +2 -0
- package/server/lib/tunnels/routes.js +7 -0
- package/server/lib/tunnels/routes.test.js +55 -0
- package/server/lib/ui-auth/ui-auth.js +38 -2
- package/server/lib/ui-auth/ui-auth.test.js +42 -0
- package/server/lib/ui-auth/ui-passkeys.js +12 -10
- package/server/lib/walkthrough/DOCUMENTATION.md +10 -1
- package/server/lib/walkthrough/pull-request.js +61 -8
- package/server/lib/walkthrough/pull-request.test.js +54 -1
- package/server/lib/walkthrough/routes.js +20 -0
- package/server/lib/walkthrough/routes.test.js +19 -0
- package/server/opencode-proxy.test.js +190 -166
- package/server/opencode-worktree-readiness.test.js +5 -4
- package/dist/assets/DiffView-COVQhWGd.js +0 -4
- package/dist/assets/FilesView-B1V5JxFX.js +0 -13
- package/dist/assets/LinearIssuesView-D7FKg1XJ.js +0 -1
- package/dist/assets/MarkdownImageGallery-BrFjAVEf.js +0 -1
- package/dist/assets/MarkdownRendererImpl-DWepTafh.js +0 -12
- package/dist/assets/OnboardingScreen-Dyxxiqdt.js +0 -1
- package/dist/assets/PlanView-LW2z_EAP.js +0 -5
- package/dist/assets/PluginPane-B4tKno2h.js +0 -3
- package/dist/assets/SettingsView-DEaMulN9.js +0 -29
- package/dist/assets/SettingsWindow-FLDJXOgQ.js +0 -1
- package/dist/assets/SubmoduleDiffSummary-CvVwD-Vi.js +0 -1
- package/dist/assets/ToolOutputDialog-BiP0x2Rw.js +0 -15
- package/dist/assets/ToolPartDiffPreview-CkR4msKP.js +0 -8
- package/dist/assets/appThemeRegistry-BuUKhbYQ.js +0 -1
- package/dist/assets/de-DgxgFyGG.js +0 -10
- package/dist/assets/es-BFsZj5F_.js +0 -16
- package/dist/assets/fr-jdZ5KKQi.js +0 -14
- package/dist/assets/index-CKHjPKbq.js +0 -1
- package/dist/assets/index-Cbci4i1W.js +0 -40
- package/dist/assets/index-DqaMWNrC.css +0 -1
- package/dist/assets/ja-C5kKrhO4.js +0 -16
- package/dist/assets/ko-DXDTT_0O.js +0 -16
- package/dist/assets/main-CZALtVek.js +0 -896
- package/dist/assets/main-CgXMBNvG.js +0 -2
- package/dist/assets/miniChat-CwTmDixi.js +0 -2
- package/dist/assets/mobile-CLf2vCtO.js +0 -2
- package/dist/assets/pl-D-7x4Y8Q.js +0 -16
- package/dist/assets/pt-BR-CSBZrHa8.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-C5gAFBk0.js +0 -2
- package/dist/assets/renderMobileApp-V4hYd32H.js +0 -25
- package/dist/assets/runtimeEndpointReset-DhlHCn-2.js +0 -25
- package/dist/assets/scheduledTasksApi-DzjbLd8P.js +0 -1
- package/dist/assets/tr-Dr-oJ22L.js +0 -7
- package/dist/assets/uk-9Nx83UcI.js +0 -16
- package/dist/assets/useAppFontEffects-khZXpFL_.js +0 -7333
- package/dist/assets/useRootScrollLock-DdvHf9Pt.js +0 -2
- package/dist/assets/vendor-cron-parser-CUcP7OfX.js +0 -2
- package/dist/assets/vendor-opencode-sdk-BJ73u-bH.js +0 -7
- package/dist/assets/zh-CN-Bxy_Hj3r.js +0 -16
- package/dist/assets/zh-TW-0BB4gpHM.js +0 -16
- package/server/lib/opencode/mcp-oauth-callback.test.js +0 -111
- package/server/lib/routing/feature-flag.js +0 -11
- package/server/lib/small-model/call.js +0 -851
- package/server/lib/small-model/call.test.js +0 -1292
- package/server/lib/small-model/catalog.js +0 -13
- package/server/lib/small-model/resolve.js +0 -131
- package/server/lib/small-model/resolve.test.js +0 -197
- package/server/lib/small-model/runtime-providers.js +0 -164
- package/server/lib/small-model/runtime-providers.test.js +0 -118
- package/server/lib/system-prompt/DOCUMENTATION.md +0 -36
- package/server/lib/system-prompt/runtime.js +0 -49
- package/server/lib/system-prompt/runtime.test.js +0 -76
- package/server/lib/walkthrough/reproduce-2607.test.js +0 -148
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
1
4
|
import express from 'express';
|
|
2
5
|
import request from 'supertest';
|
|
3
6
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
@@ -14,10 +17,20 @@ const getWorktreeBootstrapStatusMock = vi.fn(async () => ({
|
|
|
14
17
|
error: null,
|
|
15
18
|
updatedAt: Date.now(),
|
|
16
19
|
}));
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
// `@opencode/client` unwraps single-record responses, so these mocks return
|
|
21
|
+
// the record itself; only the paged/list endpoints keep a `{ data }` envelope.
|
|
22
|
+
const sessionCreateMock = vi.fn(async () => ({ id: 'ses_123' }));
|
|
23
|
+
const sessionForkMock = vi.fn(async () => ({ id: 'ses_fork', title: 'Forked session' }));
|
|
19
24
|
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
|
|
20
|
-
const
|
|
25
|
+
const sessionGetMock = vi.fn(async ({ sessionID }) => ({ id: sessionID, location: { directory: '/repo/app' } }));
|
|
26
|
+
const sessionUpdateMock = vi.fn(async () => undefined);
|
|
27
|
+
const sessionPromptMock = vi.fn(async () => ({ id: 'msg_dispatched' }));
|
|
28
|
+
const sessionSyntheticMock = vi.fn(async () => ({ id: 'msg_synthetic' }));
|
|
29
|
+
const sessionSwitchModelMock = vi.fn(async () => undefined);
|
|
30
|
+
const sessionSwitchAgentMock = vi.fn(async () => undefined);
|
|
31
|
+
const modelListMock = vi.fn(async () => ({ data: [] }));
|
|
32
|
+
const agentListMock = vi.fn(async () => ({ data: [] }));
|
|
33
|
+
const configGetMock = vi.fn(async () => ([]));
|
|
21
34
|
|
|
22
35
|
let existingSessionMessages = [];
|
|
23
36
|
let dispatchedUserMessageSeq = 0;
|
|
@@ -34,57 +47,67 @@ const recordedSessionMessages = async () => {
|
|
|
34
47
|
data: [
|
|
35
48
|
...existingSessionMessages,
|
|
36
49
|
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
time: { created: 1000 + dispatchedUserMessageSeq },
|
|
41
|
-
},
|
|
50
|
+
id: `msg_dispatched_${dispatchedUserMessageSeq}`,
|
|
51
|
+
type: 'user',
|
|
52
|
+
time: { created: 1000 + dispatchedUserMessageSeq },
|
|
42
53
|
},
|
|
43
54
|
],
|
|
44
55
|
};
|
|
45
56
|
};
|
|
46
57
|
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
if (text.includes('/agent')) {
|
|
63
|
-
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }, { name: 'plan', mode: 'primary' }] };
|
|
64
|
-
}
|
|
65
|
-
if (text.includes('/config')) return { ok: true, json: async () => ({}) };
|
|
66
|
-
return null;
|
|
58
|
+
// v2 serves one flat model catalogue and a flat agent list through the client,
|
|
59
|
+
// so the selection inputs are stubbed on the client mocks rather than on fetch.
|
|
60
|
+
const CATALOG_MODELS = [
|
|
61
|
+
{ id: 'gpt-5.5', modelID: 'gpt-5.5', providerID: 'openai', variants: [{ id: 'high' }] },
|
|
62
|
+
{ id: 'claude-sonnet-5', modelID: 'claude-sonnet-5', providerID: 'anthropic', variants: [{ id: 'high' }] },
|
|
63
|
+
];
|
|
64
|
+
const CATALOG_AGENTS = [
|
|
65
|
+
{ id: 'build', name: 'Build', mode: 'primary', hidden: false },
|
|
66
|
+
{ id: 'plan', name: 'plan', mode: 'primary', hidden: false },
|
|
67
|
+
];
|
|
68
|
+
const useCatalog = ({ models = CATALOG_MODELS, agents = CATALOG_AGENTS, config = [] } = {}) => {
|
|
69
|
+
modelListMock.mockImplementation(async () => ({ data: models }));
|
|
70
|
+
agentListMock.mockImplementation(async () => ({ data: agents }));
|
|
71
|
+
configGetMock.mockImplementation(async () => config);
|
|
67
72
|
};
|
|
68
|
-
|
|
73
|
+
|
|
74
|
+
const sessionCommandMock = vi.fn(async () => undefined);
|
|
69
75
|
const commandListMock = vi.fn(async () => ({ data: [] }));
|
|
70
76
|
globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
|
|
71
77
|
globalThis.__openchamberGetWorktreeBootstrapStatusMock = getWorktreeBootstrapStatusMock;
|
|
72
78
|
|
|
73
79
|
let registerOpenChamberSessionRoutes;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
let createSessionMetadataStore;
|
|
81
|
+
let createOpenCodeSessionMetadata;
|
|
82
|
+
|
|
83
|
+
// Every `OpenCode.make` call is recorded so tests can assert on the scoping
|
|
84
|
+
// headers the routes build.
|
|
85
|
+
const clientOptions = [];
|
|
86
|
+
|
|
87
|
+
vi.mock('@opencode/client', () => ({
|
|
88
|
+
OpenCode: {
|
|
89
|
+
make: (options) => {
|
|
90
|
+
clientOptions.push(options);
|
|
91
|
+
return {
|
|
92
|
+
session: {
|
|
93
|
+
create: sessionCreateMock,
|
|
94
|
+
fork: sessionForkMock,
|
|
95
|
+
get: sessionGetMock,
|
|
96
|
+
update: sessionUpdateMock,
|
|
97
|
+
command: sessionCommandMock,
|
|
98
|
+
prompt: sessionPromptMock,
|
|
99
|
+
synthetic: sessionSyntheticMock,
|
|
100
|
+
switchModel: sessionSwitchModelMock,
|
|
101
|
+
switchAgent: sessionSwitchAgentMock,
|
|
102
|
+
},
|
|
103
|
+
message: { list: sessionMessagesMock },
|
|
104
|
+
command: { list: commandListMock },
|
|
105
|
+
model: { list: modelListMock },
|
|
106
|
+
agent: { list: agentListMock },
|
|
107
|
+
config: { get: configGetMock },
|
|
108
|
+
};
|
|
86
109
|
},
|
|
87
|
-
}
|
|
110
|
+
},
|
|
88
111
|
}));
|
|
89
112
|
|
|
90
113
|
vi.mock('../git/index.js', () => ({
|
|
@@ -93,13 +116,84 @@ vi.mock('../git/index.js', () => ({
|
|
|
93
116
|
resolvePrimaryWorktreeRoot: async (directory) => ({ root: directory === '/repo/worktrees/side-task' ? '/repo/app' : directory }),
|
|
94
117
|
}));
|
|
95
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Archive state is OpenChamber's own now, so the routes take a store rather
|
|
121
|
+
* than talking to OpenCode. The tests use an in-memory one with the same
|
|
122
|
+
* contract as `archive-store.js`.
|
|
123
|
+
*/
|
|
124
|
+
const createMemorySessionMetadataStore = () => {
|
|
125
|
+
const entries = new Map();
|
|
126
|
+
const merge = (current, patch) => {
|
|
127
|
+
const base = { ...(current ?? {}) };
|
|
128
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
129
|
+
if (value === null) delete base[key];
|
|
130
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) base[key] = merge(base[key], value);
|
|
131
|
+
else base[key] = value;
|
|
132
|
+
}
|
|
133
|
+
return base;
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
entries,
|
|
137
|
+
failFor: null,
|
|
138
|
+
get: async (id) => entries.get(id) ?? {},
|
|
139
|
+
has: async (id) => entries.has(id),
|
|
140
|
+
getAll: async () => Object.fromEntries(entries),
|
|
141
|
+
setSessionMetadata: async (id, patch) => {
|
|
142
|
+
const merged = merge(entries.get(id), patch);
|
|
143
|
+
if (Object.keys(merged).length === 0) entries.delete(id);
|
|
144
|
+
else entries.set(id, merged);
|
|
145
|
+
return merged;
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const createMemoryArchiveStore = () => {
|
|
151
|
+
const entries = new Map();
|
|
152
|
+
let failFor = new Set();
|
|
153
|
+
return {
|
|
154
|
+
failIds: (ids) => { failFor = new Set(ids); },
|
|
155
|
+
entries,
|
|
156
|
+
getAll: async () => Object.fromEntries(entries),
|
|
157
|
+
list: async () => Object.fromEntries(entries),
|
|
158
|
+
isArchived: async (id) => entries.has(id),
|
|
159
|
+
archivedAt: async (id) => entries.get(id) ?? null,
|
|
160
|
+
archive: async (ids, archivedAt) => {
|
|
161
|
+
const stamp = Number.isSafeInteger(archivedAt) && archivedAt > 0 ? archivedAt : 1;
|
|
162
|
+
const archived = [];
|
|
163
|
+
const failedIds = [];
|
|
164
|
+
for (const id of ids) {
|
|
165
|
+
if (failFor.has(id)) { failedIds.push(id); continue; }
|
|
166
|
+
entries.set(id, stamp);
|
|
167
|
+
archived.push({ id, archivedAt: stamp });
|
|
168
|
+
}
|
|
169
|
+
return { archived, failedIds };
|
|
170
|
+
},
|
|
171
|
+
unarchive: async (ids) => {
|
|
172
|
+
const restored = [];
|
|
173
|
+
const failedIds = [];
|
|
174
|
+
for (const id of ids) {
|
|
175
|
+
if (failFor.has(id)) { failedIds.push(id); continue; }
|
|
176
|
+
entries.delete(id);
|
|
177
|
+
restored.push({ id, archivedAt: null });
|
|
178
|
+
}
|
|
179
|
+
return { restored, failedIds };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
96
184
|
const createApp = (overrides = {}, options = {}) => {
|
|
97
185
|
const app = express();
|
|
98
186
|
if (options.globalJson !== false) {
|
|
99
187
|
app.use(express.json());
|
|
100
188
|
}
|
|
101
189
|
const calls = [];
|
|
190
|
+
const archiveStore = overrides.archiveStore ?? createMemoryArchiveStore();
|
|
191
|
+
const sessionMetadataStore = overrides.sessionMetadataStore ?? createMemorySessionMetadataStore();
|
|
192
|
+
const broadcastGlobalUiEvent = overrides.broadcastGlobalUiEvent ?? vi.fn();
|
|
102
193
|
registerOpenChamberSessionRoutes(app, {
|
|
194
|
+
archiveStore,
|
|
195
|
+
sessionMetadataStore,
|
|
196
|
+
broadcastGlobalUiEvent,
|
|
103
197
|
readSettingsFromDiskMigrated: async () => ({ projects: [{ id: 'proj_1', path: '/repo/app' }] }),
|
|
104
198
|
sanitizeProjects: (projects) => projects,
|
|
105
199
|
validateDirectoryPath: async (directory) => ({ ok: true, directory }),
|
|
@@ -108,12 +202,13 @@ const createApp = (overrides = {}, options = {}) => {
|
|
|
108
202
|
waitForOpenCodeReady: vi.fn(async () => undefined),
|
|
109
203
|
...overrides,
|
|
110
204
|
});
|
|
111
|
-
return { app, calls };
|
|
205
|
+
return { app, calls, archiveStore, sessionMetadataStore, broadcastGlobalUiEvent };
|
|
112
206
|
};
|
|
113
207
|
|
|
114
208
|
describe('openchamber session routes', () => {
|
|
115
209
|
beforeAll(async () => {
|
|
116
210
|
({ registerOpenChamberSessionRoutes } = await import('./routes.js'));
|
|
211
|
+
({ createSessionMetadataStore, createOpenCodeSessionMetadata } = await import('./session-metadata-store.js'));
|
|
117
212
|
});
|
|
118
213
|
|
|
119
214
|
beforeEach(() => {
|
|
@@ -126,6 +221,7 @@ describe('openchamber session routes', () => {
|
|
|
126
221
|
updatedAt: Date.now(),
|
|
127
222
|
}));
|
|
128
223
|
sessionCreateMock.mockClear();
|
|
224
|
+
sessionUpdateMock.mockClear();
|
|
129
225
|
sessionForkMock.mockClear();
|
|
130
226
|
existingSessionMessages = [];
|
|
131
227
|
dispatchedUserMessageSeq = 0;
|
|
@@ -133,205 +229,299 @@ describe('openchamber session routes', () => {
|
|
|
133
229
|
sessionMessagesMock.mockImplementation(recordedSessionMessages);
|
|
134
230
|
sessionCommandMock.mockReset();
|
|
135
231
|
sessionCommandMock.mockResolvedValue({ data: {} });
|
|
232
|
+
clientOptions.length = 0;
|
|
136
233
|
commandListMock.mockReset();
|
|
137
234
|
commandListMock.mockResolvedValue({ data: [] });
|
|
138
|
-
|
|
139
|
-
|
|
235
|
+
sessionGetMock.mockReset();
|
|
236
|
+
sessionGetMock.mockImplementation(async ({ sessionID }) => ({ id: sessionID, location: { directory: '/repo/app' } }));
|
|
237
|
+
sessionPromptMock.mockReset();
|
|
238
|
+
sessionPromptMock.mockImplementation(async () => ({ id: 'msg_dispatched' }));
|
|
239
|
+
sessionSyntheticMock.mockReset();
|
|
240
|
+
sessionSyntheticMock.mockImplementation(async () => ({ id: 'msg_synthetic' }));
|
|
241
|
+
sessionSwitchModelMock.mockReset();
|
|
242
|
+
sessionSwitchModelMock.mockImplementation(async () => undefined);
|
|
243
|
+
sessionSwitchAgentMock.mockReset();
|
|
244
|
+
sessionSwitchAgentMock.mockImplementation(async () => undefined);
|
|
245
|
+
modelListMock.mockReset();
|
|
246
|
+
modelListMock.mockImplementation(async () => ({ data: [] }));
|
|
247
|
+
agentListMock.mockReset();
|
|
248
|
+
agentListMock.mockImplementation(async () => ({ data: [] }));
|
|
249
|
+
configGetMock.mockReset();
|
|
250
|
+
useCatalog();
|
|
140
251
|
});
|
|
141
252
|
|
|
142
253
|
describe('archiving a batch of sessions', () => {
|
|
143
|
-
it('archives every id
|
|
144
|
-
const { app } = createApp();
|
|
254
|
+
it('archives every id and reports what it stored', async () => {
|
|
255
|
+
const { app, archiveStore, broadcastGlobalUiEvent } = createApp();
|
|
145
256
|
const response = await request(app)
|
|
146
257
|
.post('/api/openchamber/sessions/archive')
|
|
147
|
-
.send({
|
|
258
|
+
.send({ ids: ['ses_a', 'ses_b'], archivedAt: 1700 })
|
|
148
259
|
.expect(200);
|
|
149
260
|
|
|
150
|
-
expect(response.body.
|
|
151
|
-
|
|
261
|
+
expect(response.body.archived).toEqual([
|
|
262
|
+
{ id: 'ses_a', archivedAt: 1700 },
|
|
263
|
+
{ id: 'ses_b', archivedAt: 1700 },
|
|
264
|
+
]);
|
|
152
265
|
expect(response.body.failedIds).toEqual([]);
|
|
153
|
-
expect(
|
|
154
|
-
expect(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
time: { archived: 1700 },
|
|
266
|
+
await expect(archiveStore.getAll()).resolves.toEqual({ ses_a: 1700, ses_b: 1700 });
|
|
267
|
+
expect(broadcastGlobalUiEvent).toHaveBeenCalledWith({
|
|
268
|
+
type: 'openchamber:session-archived',
|
|
269
|
+
properties: { sessionID: 'ses_a', archivedAt: 1700 },
|
|
158
270
|
});
|
|
159
271
|
});
|
|
160
272
|
|
|
161
273
|
it('keeps archiving after a failed session and reports it as failed', async () => {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
});
|
|
274
|
+
const archiveStore = createMemoryArchiveStore();
|
|
275
|
+
archiveStore.failIds(['ses_b']);
|
|
276
|
+
const { app } = createApp({ archiveStore });
|
|
166
277
|
|
|
167
|
-
const { app } = createApp();
|
|
168
278
|
const response = await request(app)
|
|
169
279
|
.post('/api/openchamber/sessions/archive')
|
|
170
|
-
.send({
|
|
280
|
+
.send({ ids: ['ses_a', 'ses_b', 'ses_c'] })
|
|
171
281
|
.expect(200);
|
|
172
282
|
|
|
173
283
|
expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']);
|
|
174
284
|
expect(response.body.failedIds).toEqual(['ses_b']);
|
|
175
285
|
});
|
|
176
286
|
|
|
177
|
-
it('
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)
|
|
287
|
+
it('does not announce a session it failed to store', async () => {
|
|
288
|
+
const archiveStore = createMemoryArchiveStore();
|
|
289
|
+
archiveStore.failIds(['ses_b']);
|
|
290
|
+
const { app, broadcastGlobalUiEvent } = createApp({ archiveStore });
|
|
181
291
|
|
|
182
|
-
|
|
183
|
-
const response = await request(app)
|
|
292
|
+
await request(app)
|
|
184
293
|
.post('/api/openchamber/sessions/archive')
|
|
185
|
-
.send({
|
|
294
|
+
.send({ ids: ['ses_a', 'ses_b'] })
|
|
186
295
|
.expect(200);
|
|
187
296
|
|
|
188
|
-
|
|
189
|
-
expect(
|
|
297
|
+
const announced = broadcastGlobalUiEvent.mock.calls.map(([event]) => event.properties.sessionID);
|
|
298
|
+
expect(announced).toEqual(['ses_a']);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('restores a batch and announces the cleared state', async () => {
|
|
302
|
+
const { app, archiveStore, broadcastGlobalUiEvent } = createApp();
|
|
303
|
+
await request(app).post('/api/openchamber/sessions/archive').send({ ids: ['ses_a'] }).expect(200);
|
|
304
|
+
broadcastGlobalUiEvent.mockClear();
|
|
305
|
+
|
|
306
|
+
const response = await request(app)
|
|
307
|
+
.post('/api/openchamber/sessions/unarchive')
|
|
308
|
+
.send({ ids: ['ses_a'] })
|
|
309
|
+
.expect(200);
|
|
310
|
+
|
|
311
|
+
expect(response.body.restored).toEqual([{ id: 'ses_a', archivedAt: null }]);
|
|
312
|
+
expect(response.body.failedIds).toEqual([]);
|
|
313
|
+
await expect(archiveStore.getAll()).resolves.toEqual({});
|
|
314
|
+
expect(broadcastGlobalUiEvent).toHaveBeenCalledWith({
|
|
315
|
+
type: 'openchamber:session-archived',
|
|
316
|
+
properties: { sessionID: 'ses_a', archivedAt: null },
|
|
317
|
+
});
|
|
190
318
|
});
|
|
191
319
|
|
|
192
320
|
it('rejects an empty batch, an oversized batch, and non-string ids', async () => {
|
|
193
|
-
const { app } = createApp();
|
|
321
|
+
const { app, archiveStore } = createApp();
|
|
194
322
|
|
|
195
|
-
await request(app).post('/api/openchamber/sessions/archive').send({
|
|
323
|
+
await request(app).post('/api/openchamber/sessions/archive').send({ ids: [] }).expect(400);
|
|
196
324
|
await request(app)
|
|
197
325
|
.post('/api/openchamber/sessions/archive')
|
|
198
|
-
.send({
|
|
326
|
+
.send({ ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) })
|
|
199
327
|
.expect(400);
|
|
200
328
|
await request(app)
|
|
201
329
|
.post('/api/openchamber/sessions/archive')
|
|
202
|
-
.send({
|
|
330
|
+
.send({ ids: ['ses_a', ''] })
|
|
203
331
|
.expect(400);
|
|
204
332
|
await request(app)
|
|
205
333
|
.post('/api/openchamber/sessions/archive')
|
|
206
|
-
.send({
|
|
334
|
+
.send({ ids: ['ses_a'], archivedAt: -1 })
|
|
207
335
|
.expect(400);
|
|
336
|
+
await request(app).post('/api/openchamber/sessions/unarchive').send({ ids: [] }).expect(400);
|
|
208
337
|
|
|
209
|
-
expect(
|
|
338
|
+
await expect(archiveStore.getAll()).resolves.toEqual({});
|
|
210
339
|
});
|
|
340
|
+
});
|
|
211
341
|
|
|
212
|
-
|
|
342
|
+
describe('session metadata OpenChamber owns', () => {
|
|
343
|
+
it('merges a patch onto the OpenCode record and writes it back with PATCH', async () => {
|
|
344
|
+
sessionGetMock.mockImplementationOnce(async ({ sessionID }) => ({
|
|
345
|
+
id: sessionID,
|
|
346
|
+
location: { directory: '/repo/app' },
|
|
347
|
+
metadata: { openchamber: { kind: 'review', assist: { recap: 'from v1' } } },
|
|
348
|
+
}));
|
|
349
|
+
// The real store over the same mocked client the routes use.
|
|
350
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-routes-metadata-'));
|
|
213
351
|
const { app } = createApp({
|
|
214
|
-
|
|
352
|
+
sessionMetadataStore: createSessionMetadataStore({
|
|
353
|
+
dataDir,
|
|
354
|
+
openCode: createOpenCodeSessionMetadata({
|
|
355
|
+
buildOpenCodeUrl: (route) => `http://opencode.test${route}`,
|
|
356
|
+
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test' }),
|
|
357
|
+
}),
|
|
358
|
+
}),
|
|
215
359
|
});
|
|
216
360
|
|
|
361
|
+
const response = await request(app)
|
|
362
|
+
.post('/api/openchamber/sessions/ses_v1/metadata')
|
|
363
|
+
.send({ patch: { openchamber: { goal: { status: 'active' } } } })
|
|
364
|
+
.expect(200);
|
|
365
|
+
|
|
366
|
+
const merged = { openchamber: { kind: 'review', assist: { recap: 'from v1' }, goal: { status: 'active' } } };
|
|
367
|
+
expect(response.body.metadata).toEqual(merged);
|
|
368
|
+
expect(sessionUpdateMock).toHaveBeenCalledWith({ sessionID: 'ses_v1', metadata: merged });
|
|
369
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('merge-patches metadata, returns the merged object, and announces it', async () => {
|
|
373
|
+
const { app, sessionMetadataStore, broadcastGlobalUiEvent } = createApp();
|
|
374
|
+
|
|
217
375
|
await request(app)
|
|
218
|
-
.post('/api/openchamber/sessions/
|
|
219
|
-
.send({
|
|
220
|
-
.expect(
|
|
376
|
+
.post('/api/openchamber/sessions/ses_a/metadata')
|
|
377
|
+
.send({ patch: { openchamber: { assist: { recap: 'first' } } } })
|
|
378
|
+
.expect(200);
|
|
379
|
+
|
|
380
|
+
const response = await request(app)
|
|
381
|
+
.post('/api/openchamber/sessions/ses_a/metadata')
|
|
382
|
+
.send({ patch: { openchamber: { goal: { status: 'active' } } } })
|
|
383
|
+
.expect(200);
|
|
221
384
|
|
|
222
|
-
|
|
385
|
+
// The second write must not erase the first: OpenCode's PATCH merged, so
|
|
386
|
+
// this does too.
|
|
387
|
+
expect(response.body).toEqual({
|
|
388
|
+
metadata: { openchamber: { assist: { recap: 'first' }, goal: { status: 'active' } } },
|
|
389
|
+
});
|
|
390
|
+
await expect(sessionMetadataStore.get('ses_a')).resolves.toEqual(response.body.metadata);
|
|
391
|
+
expect(broadcastGlobalUiEvent).toHaveBeenLastCalledWith({
|
|
392
|
+
type: 'openchamber:session-metadata',
|
|
393
|
+
properties: { sessionID: 'ses_a', metadata: response.body.metadata },
|
|
394
|
+
});
|
|
223
395
|
});
|
|
224
|
-
});
|
|
225
396
|
|
|
226
|
-
|
|
227
|
-
const originalFetch = globalThis.fetch;
|
|
228
|
-
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
|
|
229
|
-
try {
|
|
397
|
+
it('deletes a key when the patch value is null', async () => {
|
|
230
398
|
const { app } = createApp();
|
|
399
|
+
await request(app)
|
|
400
|
+
.post('/api/openchamber/sessions/ses_a/metadata')
|
|
401
|
+
.send({ patch: { openchamber: { goal: { id: 'g1' }, assist: { recap: 'r' } } } })
|
|
402
|
+
.expect(200);
|
|
403
|
+
|
|
231
404
|
const response = await request(app)
|
|
232
|
-
.post('/api/openchamber/sessions')
|
|
233
|
-
.send({
|
|
405
|
+
.post('/api/openchamber/sessions/ses_a/metadata')
|
|
406
|
+
.send({ patch: { openchamber: { assist: null } } })
|
|
234
407
|
.expect(200);
|
|
235
408
|
|
|
236
|
-
expect(response.body
|
|
237
|
-
|
|
238
|
-
expect(response.body.directory).toBe('/repo/app');
|
|
239
|
-
expect(response.body.promptDispatched).toBe(false);
|
|
240
|
-
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
241
|
-
'http://opencode.test/session?directory=%2Frepo%2Fapp',
|
|
242
|
-
expect.objectContaining({
|
|
243
|
-
method: 'POST',
|
|
244
|
-
body: JSON.stringify({ directory: '/repo/app', title: 'Side task' }),
|
|
245
|
-
}),
|
|
246
|
-
);
|
|
247
|
-
expect(sessionCreateMock).not.toHaveBeenCalled();
|
|
248
|
-
} finally {
|
|
249
|
-
globalThis.fetch = originalFetch;
|
|
250
|
-
}
|
|
251
|
-
});
|
|
409
|
+
expect(response.body).toEqual({ metadata: { openchamber: { goal: { id: 'g1' } } } });
|
|
410
|
+
});
|
|
252
411
|
|
|
253
|
-
|
|
254
|
-
const originalFetch = globalThis.fetch;
|
|
255
|
-
globalThis.fetch = vi.fn(async () => ({ ok: true, json: async () => ({ id: 'ses_123' }) }));
|
|
256
|
-
try {
|
|
412
|
+
it('reads back what it stored, per session', async () => {
|
|
257
413
|
const { app } = createApp();
|
|
258
|
-
await request(app)
|
|
259
|
-
|
|
260
|
-
|
|
414
|
+
await request(app).post('/api/openchamber/sessions/ses_a/metadata').send({ patch: { a: 1 } }).expect(200);
|
|
415
|
+
|
|
416
|
+
await expect(request(app).get('/api/openchamber/sessions/ses_a/metadata').expect(200))
|
|
417
|
+
.resolves.toMatchObject({ body: { metadata: { a: 1 } } });
|
|
418
|
+
await expect(request(app).get('/api/openchamber/sessions/ses_b/metadata').expect(200))
|
|
419
|
+
.resolves.toMatchObject({ body: { metadata: {} } });
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('rejects a missing or non-object patch', async () => {
|
|
423
|
+
const { app, broadcastGlobalUiEvent } = createApp();
|
|
424
|
+
|
|
425
|
+
await request(app).post('/api/openchamber/sessions/ses_a/metadata').send({}).expect(400);
|
|
426
|
+
await request(app).post('/api/openchamber/sessions/ses_a/metadata').send({ patch: 'nope' }).expect(400);
|
|
427
|
+
await request(app).post('/api/openchamber/sessions/ses_a/metadata').send({ patch: ['a'] }).expect(400);
|
|
428
|
+
|
|
429
|
+
expect(broadcastGlobalUiEvent).not.toHaveBeenCalled();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('routes every write through the server-owned writer when one is injected', async () => {
|
|
433
|
+
const persistSessionMetadata = vi.fn(async () => ({ openchamber: { goal: { status: 'active' } } }));
|
|
434
|
+
const { app, sessionMetadataStore } = createApp({ persistSessionMetadata });
|
|
435
|
+
|
|
436
|
+
const response = await request(app)
|
|
437
|
+
.post('/api/openchamber/sessions/ses_a/metadata')
|
|
438
|
+
.send({ patch: { openchamber: { goal: { status: 'active' } } }, directory: '/repo/app' })
|
|
261
439
|
.expect(200);
|
|
262
440
|
|
|
263
|
-
expect(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
'x-opencode-directory': encodeURIComponent('/home/user/Masaüstü/projeler'),
|
|
268
|
-
}),
|
|
269
|
-
}),
|
|
441
|
+
expect(persistSessionMetadata).toHaveBeenCalledWith(
|
|
442
|
+
'ses_a',
|
|
443
|
+
{ openchamber: { goal: { status: 'active' } } },
|
|
444
|
+
{ directory: '/repo/app' },
|
|
270
445
|
);
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
446
|
+
expect(response.body.metadata).toEqual({ openchamber: { goal: { status: 'active' } } });
|
|
447
|
+
// The injected writer owns the store; the route must not write twice.
|
|
448
|
+
await expect(sessionMetadataStore.get('ses_a')).resolves.toEqual({});
|
|
449
|
+
});
|
|
274
450
|
});
|
|
275
451
|
|
|
276
|
-
it('
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
452
|
+
it('creates a session for a directory', async () => {
|
|
453
|
+
const { app } = createApp();
|
|
454
|
+
const response = await request(app)
|
|
455
|
+
.post('/api/openchamber/sessions')
|
|
456
|
+
.send({ directory: '/repo/app', title: 'Side task' })
|
|
457
|
+
.expect(200);
|
|
458
|
+
|
|
459
|
+
expect(response.body.sessionId).toBe('ses_123');
|
|
460
|
+
expect(response.body.directory).toBe('/repo/app');
|
|
461
|
+
expect(response.body.promptDispatched).toBe(false);
|
|
462
|
+
expect(sessionCreateMock).toHaveBeenCalledWith({
|
|
463
|
+
location: { directory: '/repo/app' },
|
|
464
|
+
title: 'Side task',
|
|
465
|
+
});
|
|
466
|
+
});
|
|
285
467
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
468
|
+
it('percent-encodes the directory header for non-ASCII checkout paths', async () => {
|
|
469
|
+
const { app } = createApp();
|
|
470
|
+
await request(app)
|
|
471
|
+
.post('/api/openchamber/sessions')
|
|
472
|
+
.send({ directory: '/home/user/Masaüstü/projeler', title: 'Side task' })
|
|
473
|
+
.expect(200);
|
|
474
|
+
|
|
475
|
+
expect(clientOptions.at(-1)?.headers['x-opencode-directory'])
|
|
476
|
+
.toBe(encodeURIComponent('/home/user/Masaüstü/projeler'));
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('parses JSON body without global middleware', async () => {
|
|
480
|
+
const { app } = createApp({}, { globalJson: false });
|
|
481
|
+
const response = await request(app)
|
|
482
|
+
.post('/api/openchamber/sessions')
|
|
483
|
+
.send({ directory: '/repo/app' })
|
|
484
|
+
.expect(200);
|
|
485
|
+
|
|
486
|
+
expect(response.body.sessionId).toBe('ses_123');
|
|
487
|
+
expect(response.body.directory).toBe('/repo/app');
|
|
291
488
|
});
|
|
292
489
|
|
|
293
490
|
it('emits a session-created event after creating a session', async () => {
|
|
294
|
-
const originalFetch = globalThis.fetch;
|
|
295
491
|
const emitSessionCreatedEvent = vi.fn();
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
492
|
+
const { app } = createApp({ emitSessionCreatedEvent });
|
|
493
|
+
await request(app)
|
|
494
|
+
.post('/api/openchamber/sessions')
|
|
495
|
+
.send({ directory: '/repo/app', title: 'Side task' })
|
|
496
|
+
.expect(200);
|
|
497
|
+
|
|
498
|
+
expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
|
|
499
|
+
sessionID: 'ses_123',
|
|
500
|
+
directory: '/repo/app',
|
|
501
|
+
title: 'Side task',
|
|
502
|
+
promptDispatched: false,
|
|
503
|
+
dispatchedAsCommand: false,
|
|
504
|
+
}));
|
|
505
|
+
});
|
|
303
506
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
})
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
507
|
+
it('resolves a default agent stored by v1 under its display name', async () => {
|
|
508
|
+
useCatalog({ agents: [{ id: 'build', name: 'Build', mode: 'primary' }, { id: 'plan', name: 'Plan', mode: 'primary' }] });
|
|
509
|
+
const { app } = createApp({
|
|
510
|
+
readSettingsFromDiskMigrated: async () => ({
|
|
511
|
+
defaultAgent: 'Plan',
|
|
512
|
+
projects: [{ id: 'proj_1', path: '/repo/app' }],
|
|
513
|
+
}),
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const response = await request(app)
|
|
517
|
+
.post('/api/openchamber/sessions')
|
|
518
|
+
.send({ directory: '/repo/app', prompt: 'Run this' })
|
|
519
|
+
.expect(200);
|
|
520
|
+
|
|
521
|
+
expect(response.body.agent).toBe('plan');
|
|
314
522
|
});
|
|
315
523
|
|
|
316
524
|
it('resolves default model and agent when prompt omits them', async () => {
|
|
317
|
-
const originalFetch = globalThis.fetch;
|
|
318
|
-
const fetchMock = vi.fn(async (url) => {
|
|
319
|
-
const text = String(url);
|
|
320
|
-
if (text.includes('/prompt_async')) {
|
|
321
|
-
return { ok: true, text: async () => '' };
|
|
322
|
-
}
|
|
323
|
-
if (text.includes('/config/providers')) {
|
|
324
|
-
return { ok: true, json: async () => ({ providers: [{ id: 'openai', models: { 'gpt-5.5': { id: 'gpt-5.5' } } }] }) };
|
|
325
|
-
}
|
|
326
|
-
if (text.includes('/agent')) {
|
|
327
|
-
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
|
|
328
|
-
}
|
|
329
|
-
if (text.includes('/config')) {
|
|
330
|
-
return { ok: true, json: async () => ({}) };
|
|
331
|
-
}
|
|
332
|
-
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
|
333
|
-
});
|
|
334
|
-
globalThis.fetch = fetchMock;
|
|
335
525
|
const { app } = createApp({
|
|
336
526
|
readSettingsFromDiskMigrated: async () => ({
|
|
337
527
|
defaultModel: 'openai/gpt-5.5',
|
|
@@ -339,75 +529,71 @@ describe('openchamber session routes', () => {
|
|
|
339
529
|
projects: [{ id: 'proj_1', path: '/repo/app' }],
|
|
340
530
|
}),
|
|
341
531
|
});
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
expect(JSON.parse(promptCall?.[1]?.body)).toMatchObject({
|
|
356
|
-
model: { providerID: 'openai', modelID: 'gpt-5.5' },
|
|
357
|
-
agent: 'build',
|
|
358
|
-
});
|
|
359
|
-
} finally {
|
|
360
|
-
globalThis.fetch = originalFetch;
|
|
361
|
-
}
|
|
532
|
+
const response = await request(app)
|
|
533
|
+
.post('/api/openchamber/sessions')
|
|
534
|
+
.send({ directory: '/repo/app', prompt: 'Run this' })
|
|
535
|
+
.expect(200);
|
|
536
|
+
|
|
537
|
+
expect(response.body.model).toEqual({ providerID: 'openai', modelID: 'gpt-5.5' });
|
|
538
|
+
expect(response.body.agent).toBe('build');
|
|
539
|
+
expect(modelListMock).toHaveBeenCalled();
|
|
540
|
+
expect(sessionSwitchModelMock).toHaveBeenCalledWith({
|
|
541
|
+
sessionID: 'ses_123',
|
|
542
|
+
model: { id: 'gpt-5.5', providerID: 'openai' },
|
|
543
|
+
});
|
|
544
|
+
expect(sessionSwitchAgentMock).toHaveBeenCalledWith({ sessionID: 'ses_123', agent: 'build' });
|
|
362
545
|
});
|
|
363
546
|
|
|
364
|
-
it('
|
|
365
|
-
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (text.includes('/config/providers')) {
|
|
372
|
-
return { ok: true, json: async () => ({ providers: [{ id: 'openai', models: { 'gpt-5.5': { id: 'gpt-5.5' } } }] }) };
|
|
373
|
-
}
|
|
374
|
-
if (text.includes('/agent')) {
|
|
375
|
-
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }] };
|
|
376
|
-
}
|
|
377
|
-
if (text.includes('/config')) {
|
|
378
|
-
return { ok: true, json: async () => ({}) };
|
|
379
|
-
}
|
|
380
|
-
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
|
381
|
-
});
|
|
382
|
-
globalThis.fetch = fetchMock;
|
|
383
|
-
const seen = [];
|
|
384
|
-
const resolvePromptBody = vi.fn(async (body, target) => {
|
|
385
|
-
seen.push({ model: body.model, target });
|
|
386
|
-
body.model = { providerID: 'openai', modelID: 'gpt-5.5' };
|
|
387
|
-
});
|
|
547
|
+
it('resolves an Auto default through the routing hook before switching the session', async () => {
|
|
548
|
+
useCatalog();
|
|
549
|
+
const resolveAutoSelection = vi.fn(async () => ({
|
|
550
|
+
model: { providerID: 'openai', id: 'gpt-5.5', variant: 'high' },
|
|
551
|
+
agent: 'plan',
|
|
552
|
+
decision: {},
|
|
553
|
+
}));
|
|
388
554
|
const { app } = createApp({
|
|
389
555
|
readSettingsFromDiskMigrated: async () => ({
|
|
390
556
|
defaultModel: 'openchamber/auto',
|
|
391
557
|
defaultAgent: 'build',
|
|
392
558
|
projects: [{ id: 'proj_1', path: '/repo/app' }],
|
|
393
559
|
}),
|
|
394
|
-
|
|
560
|
+
resolveAutoSelection,
|
|
395
561
|
});
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
562
|
+
const response = await request(app)
|
|
563
|
+
.post('/api/openchamber/sessions')
|
|
564
|
+
.send({ directory: '/repo/app', prompt: 'Run this' })
|
|
565
|
+
.expect(200);
|
|
566
|
+
|
|
567
|
+
expect(resolveAutoSelection).toHaveBeenCalledWith({
|
|
568
|
+
sessionId: 'ses_123',
|
|
569
|
+
directory: '/repo/app',
|
|
570
|
+
model: { providerID: 'openchamber', id: 'auto' },
|
|
571
|
+
agent: 'build',
|
|
572
|
+
requestText: 'Run this',
|
|
573
|
+
});
|
|
574
|
+
expect(response.body.model).toEqual({ providerID: 'openai', modelID: 'gpt-5.5' });
|
|
575
|
+
expect(response.body.agent).toBe('plan');
|
|
576
|
+
expect(sessionSwitchModelMock).toHaveBeenCalledWith({
|
|
577
|
+
sessionID: 'ses_123',
|
|
578
|
+
model: { id: 'gpt-5.5', providerID: 'openai', variant: 'high' },
|
|
579
|
+
});
|
|
580
|
+
expect(sessionSwitchAgentMock).toHaveBeenCalledWith({ sessionID: 'ses_123', agent: 'plan' });
|
|
581
|
+
});
|
|
401
582
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
583
|
+
it('refuses an Auto default when routing is not wired in', async () => {
|
|
584
|
+
useCatalog();
|
|
585
|
+
const { app } = createApp({
|
|
586
|
+
readSettingsFromDiskMigrated: async () => ({
|
|
587
|
+
defaultModel: 'openchamber/auto',
|
|
588
|
+
projects: [{ id: 'proj_1', path: '/repo/app' }],
|
|
589
|
+
}),
|
|
590
|
+
});
|
|
591
|
+
const response = await request(app)
|
|
592
|
+
.post('/api/openchamber/sessions')
|
|
593
|
+
.send({ directory: '/repo/app', prompt: 'Run this' });
|
|
594
|
+
expect(response.status).toBe(400);
|
|
595
|
+
expect(response.body.error).toMatch(/not available/);
|
|
596
|
+
expect(sessionSwitchModelMock).not.toHaveBeenCalled();
|
|
411
597
|
});
|
|
412
598
|
|
|
413
599
|
it.each([
|
|
@@ -417,24 +603,7 @@ describe('openchamber session routes', () => {
|
|
|
417
603
|
['/ses_existing/send', { directory: '/repo/worktrees/side-task' }],
|
|
418
604
|
['/ses_existing/fork', { directory: '/repo/worktrees/side-task' }],
|
|
419
605
|
])('prefers project defaults for %s with %j', async (endpoint, scope) => {
|
|
420
|
-
|
|
421
|
-
const fetchMock = vi.fn(async (url) => {
|
|
422
|
-
const text = String(url);
|
|
423
|
-
if (text.includes('/prompt_async')) {
|
|
424
|
-
return { ok: true, text: async () => '' };
|
|
425
|
-
}
|
|
426
|
-
if (text.includes('/config/providers')) {
|
|
427
|
-
return { ok: true, json: async () => ({ providers: [{ id: 'openai', models: { 'gpt-5.5': { id: 'gpt-5.5' } } }] }) };
|
|
428
|
-
}
|
|
429
|
-
if (text.includes('/agent')) {
|
|
430
|
-
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }, { name: 'plan', mode: 'primary' }] };
|
|
431
|
-
}
|
|
432
|
-
if (text.includes('/config')) {
|
|
433
|
-
return { ok: true, json: async () => ({}) };
|
|
434
|
-
}
|
|
435
|
-
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
|
436
|
-
});
|
|
437
|
-
globalThis.fetch = fetchMock;
|
|
606
|
+
useCatalog();
|
|
438
607
|
const { app } = createApp({
|
|
439
608
|
readSettingsFromDiskMigrated: async () => ({
|
|
440
609
|
defaultModel: 'openai/gpt-5.5',
|
|
@@ -442,150 +611,106 @@ describe('openchamber session routes', () => {
|
|
|
442
611
|
projects: [{ id: 'proj_1', path: '/repo/app', defaultAgent: 'plan' }],
|
|
443
612
|
}),
|
|
444
613
|
});
|
|
445
|
-
try {
|
|
446
|
-
const response = await request(app)
|
|
447
|
-
.post(`/api/openchamber/sessions${endpoint}`)
|
|
448
|
-
.send({ ...scope, prompt: 'Run this' })
|
|
449
|
-
.expect(200);
|
|
450
614
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
615
|
+
const response = await request(app)
|
|
616
|
+
.post(`/api/openchamber/sessions${endpoint}`)
|
|
617
|
+
.send({ ...scope, prompt: 'Run this' })
|
|
618
|
+
.expect(200);
|
|
619
|
+
|
|
620
|
+
expect(response.body.agent).toBe('plan');
|
|
621
|
+
// v2 puts the agent on the session, not in the prompt body.
|
|
622
|
+
expect(sessionSwitchAgentMock).toHaveBeenCalledWith(expect.objectContaining({ agent: 'plan' }));
|
|
457
623
|
});
|
|
458
624
|
|
|
459
625
|
it('dispatches an initial prompt when model is provided', async () => {
|
|
460
|
-
const
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const { app } = createApp();
|
|
470
|
-
const response = await request(app)
|
|
471
|
-
.post('/api/openchamber/sessions')
|
|
472
|
-
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
|
|
473
|
-
.expect(200);
|
|
474
|
-
|
|
475
|
-
expect(response.body.sessionId).toBe('ses_123');
|
|
476
|
-
expect(response.body.promptDispatched).toBe(true);
|
|
477
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
478
|
-
'http://opencode.test/session/ses_123/prompt_async?directory=%2Frepo%2Fapp',
|
|
479
|
-
expect.objectContaining({ method: 'POST' }),
|
|
480
|
-
);
|
|
481
|
-
} finally {
|
|
482
|
-
globalThis.fetch = originalFetch;
|
|
483
|
-
}
|
|
626
|
+
const { app } = createApp();
|
|
627
|
+
const response = await request(app)
|
|
628
|
+
.post('/api/openchamber/sessions')
|
|
629
|
+
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
|
|
630
|
+
.expect(200);
|
|
631
|
+
|
|
632
|
+
expect(response.body.sessionId).toBe('ses_123');
|
|
633
|
+
expect(response.body.promptDispatched).toBe(true);
|
|
634
|
+
expect(sessionPromptMock).toHaveBeenCalledWith({ sessionID: 'ses_123', text: 'Run this' });
|
|
484
635
|
});
|
|
485
636
|
|
|
486
637
|
it('creates goal metadata before dispatching the initial goal prompt', async () => {
|
|
487
|
-
const originalFetch = globalThis.fetch;
|
|
488
|
-
const fetchMock = vi.fn(async (url) => {
|
|
489
|
-
if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
|
|
490
|
-
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
|
491
|
-
});
|
|
492
638
|
const createSessionGoal = vi.fn(async () => undefined);
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
.post('/api/openchamber/sessions')
|
|
498
|
-
.send({
|
|
499
|
-
directory: '/repo/app',
|
|
500
|
-
prompt: 'Finish and verify the migration',
|
|
501
|
-
model: 'openai/gpt-5.5',
|
|
502
|
-
goal: true,
|
|
503
|
-
goalTokenBudget: 200000,
|
|
504
|
-
})
|
|
505
|
-
.expect(200);
|
|
506
|
-
|
|
507
|
-
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
|
|
508
|
-
const promptPayload = JSON.parse(promptCall[1].body);
|
|
509
|
-
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
|
|
510
|
-
sessionID: 'ses_123',
|
|
639
|
+
const { app } = createApp({ createSessionGoal });
|
|
640
|
+
const response = await request(app)
|
|
641
|
+
.post('/api/openchamber/sessions')
|
|
642
|
+
.send({
|
|
511
643
|
directory: '/repo/app',
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
})
|
|
517
|
-
expect(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
644
|
+
prompt: 'Finish and verify the migration',
|
|
645
|
+
model: 'openai/gpt-5.5',
|
|
646
|
+
goal: true,
|
|
647
|
+
goalTokenBudget: 200000,
|
|
648
|
+
})
|
|
649
|
+
.expect(200);
|
|
650
|
+
|
|
651
|
+
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
|
|
652
|
+
sessionID: 'ses_123',
|
|
653
|
+
directory: '/repo/app',
|
|
654
|
+
objective: 'Finish and verify the migration',
|
|
655
|
+
tokenBudget: 200000,
|
|
656
|
+
providerID: 'openai',
|
|
657
|
+
modelID: 'gpt-5.5',
|
|
658
|
+
}));
|
|
659
|
+
expect(createSessionGoal.mock.invocationCallOrder[0])
|
|
660
|
+
.toBeLessThan(sessionPromptMock.mock.invocationCallOrder[0]);
|
|
661
|
+
// v2 cannot append to a message, so the goal reminder follows the prompt as
|
|
662
|
+
// its own synthetic message.
|
|
663
|
+
expect(sessionPromptMock).toHaveBeenCalledWith({ sessionID: 'ses_123', text: 'Finish and verify the migration' });
|
|
664
|
+
expect(sessionSyntheticMock).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_123', resume: false }));
|
|
665
|
+
expect(sessionPromptMock.mock.invocationCallOrder[0])
|
|
666
|
+
.toBeLessThan(sessionSyntheticMock.mock.invocationCallOrder[0]);
|
|
667
|
+
expect(response.body).toMatchObject({ goalEnabled: true, goalTokenBudget: 200000, promptDispatched: true });
|
|
526
668
|
});
|
|
527
669
|
|
|
528
670
|
it('rejects invalid goal requests before creating a session', async () => {
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
.send({ directory: '/repo/app', prompt: 'Run', goal: true, goalTokenBudget: 999 })
|
|
545
|
-
.expect(400, { error: 'goalTokenBudget must be an integer from 1000 to 100000000' });
|
|
546
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
547
|
-
} finally {
|
|
548
|
-
globalThis.fetch = originalFetch;
|
|
549
|
-
}
|
|
671
|
+
const { app } = createApp();
|
|
672
|
+
await request(app)
|
|
673
|
+
.post('/api/openchamber/sessions')
|
|
674
|
+
.send({ directory: '/repo/app', goal: true })
|
|
675
|
+
.expect(400, { error: 'prompt is required when goal is enabled' });
|
|
676
|
+
await request(app)
|
|
677
|
+
.post('/api/openchamber/sessions')
|
|
678
|
+
.send({ directory: '/repo/app', prompt: 'Run', goalTokenBudget: 200000 })
|
|
679
|
+
.expect(400, { error: 'goalTokenBudget requires goal' });
|
|
680
|
+
await request(app)
|
|
681
|
+
.post('/api/openchamber/sessions')
|
|
682
|
+
.send({ directory: '/repo/app', prompt: 'Run', goal: true, goalTokenBudget: 999 })
|
|
683
|
+
.expect(400, { error: 'goalTokenBudget must be an integer from 1000 to 100000000' });
|
|
684
|
+
|
|
685
|
+
expect(sessionCreateMock).not.toHaveBeenCalled();
|
|
550
686
|
});
|
|
551
687
|
|
|
552
688
|
it('creates a worktree before creating a session', async () => {
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
});
|
|
560
|
-
try {
|
|
561
|
-
const { app } = createApp();
|
|
562
|
-
const response = await request(app)
|
|
563
|
-
.post('/api/openchamber/sessions')
|
|
564
|
-
.send({
|
|
565
|
-
directory: '/repo/app',
|
|
566
|
-
worktree: { name: 'side-task', branchName: 'openchamber/side-task', startRef: 'main' },
|
|
567
|
-
setUpstream: false,
|
|
568
|
-
prompt: 'Run this',
|
|
569
|
-
model: 'openai/gpt-5.5',
|
|
570
|
-
})
|
|
571
|
-
.expect(200);
|
|
572
|
-
|
|
573
|
-
expect(createWorktreeMock).toHaveBeenCalledWith('/repo/app', {
|
|
574
|
-
mode: 'new',
|
|
575
|
-
name: 'side-task',
|
|
576
|
-
branchName: 'openchamber/side-task',
|
|
577
|
-
startRef: 'main',
|
|
689
|
+
const { app } = createApp();
|
|
690
|
+
const response = await request(app)
|
|
691
|
+
.post('/api/openchamber/sessions')
|
|
692
|
+
.send({
|
|
693
|
+
directory: '/repo/app',
|
|
694
|
+
worktree: { name: 'side-task', branchName: 'openchamber/side-task', startRef: 'main' },
|
|
578
695
|
setUpstream: false,
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
expect(
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
696
|
+
prompt: 'Run this',
|
|
697
|
+
model: 'openai/gpt-5.5',
|
|
698
|
+
})
|
|
699
|
+
.expect(200);
|
|
700
|
+
|
|
701
|
+
expect(createWorktreeMock).toHaveBeenCalledWith('/repo/app', {
|
|
702
|
+
mode: 'new',
|
|
703
|
+
name: 'side-task',
|
|
704
|
+
branchName: 'openchamber/side-task',
|
|
705
|
+
startRef: 'main',
|
|
706
|
+
setUpstream: false,
|
|
707
|
+
});
|
|
708
|
+
expect(response.body.directory).toBe('/repo/worktrees/side-task');
|
|
709
|
+
expect(response.body.worktree.path).toBe('/repo/worktrees/side-task');
|
|
710
|
+
expect(sessionCreateMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
711
|
+
location: { directory: '/repo/worktrees/side-task' },
|
|
712
|
+
}));
|
|
713
|
+
expect(sessionPromptMock).toHaveBeenCalledWith({ sessionID: 'ses_123', text: 'Run this' });
|
|
589
714
|
});
|
|
590
715
|
|
|
591
716
|
it('waits for the worktree bootstrap to complete before creating the session', async () => {
|
|
@@ -595,38 +720,24 @@ describe('openchamber session routes', () => {
|
|
|
595
720
|
{ status: 'ready', phase: 'setup-ready', error: null, updatedAt: 3 },
|
|
596
721
|
];
|
|
597
722
|
getWorktreeBootstrapStatusMock.mockImplementation(async () => statuses.shift() || statuses[statuses.length - 1]);
|
|
598
|
-
const originalFetch = globalThis.fetch;
|
|
599
|
-
globalThis.fetch = vi.fn(async (url) => {
|
|
600
|
-
if (String(url).includes('/prompt_async')) {
|
|
601
|
-
return { ok: true, text: async () => '' };
|
|
602
|
-
}
|
|
603
|
-
return { ok: true, json: async () => ({ id: 'ses_123' }) };
|
|
604
|
-
});
|
|
605
|
-
try {
|
|
606
|
-
const { app } = createApp();
|
|
607
|
-
const response = await request(app)
|
|
608
|
-
.post('/api/openchamber/sessions')
|
|
609
|
-
.send({
|
|
610
|
-
directory: '/repo/app',
|
|
611
|
-
worktree: { name: 'side-task' },
|
|
612
|
-
prompt: 'Run this',
|
|
613
|
-
model: 'openai/gpt-5.5',
|
|
614
|
-
})
|
|
615
|
-
.expect(200);
|
|
616
723
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
expect(
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
724
|
+
const { app } = createApp();
|
|
725
|
+
const response = await request(app)
|
|
726
|
+
.post('/api/openchamber/sessions')
|
|
727
|
+
.send({
|
|
728
|
+
directory: '/repo/app',
|
|
729
|
+
worktree: { name: 'side-task' },
|
|
730
|
+
prompt: 'Run this',
|
|
731
|
+
model: 'openai/gpt-5.5',
|
|
732
|
+
})
|
|
733
|
+
.expect(200);
|
|
734
|
+
|
|
735
|
+
expect(response.body.promptDispatched).toBe(true);
|
|
736
|
+
expect(getWorktreeBootstrapStatusMock).toHaveBeenCalled();
|
|
737
|
+
expect(getWorktreeBootstrapStatusMock.mock.invocationCallOrder[0])
|
|
738
|
+
.toBeLessThan(sessionCreateMock.mock.invocationCallOrder[0]);
|
|
739
|
+
expect(sessionCreateMock.mock.invocationCallOrder[0])
|
|
740
|
+
.toBeLessThan(sessionPromptMock.mock.invocationCallOrder[0]);
|
|
630
741
|
});
|
|
631
742
|
|
|
632
743
|
it('fails the create when the worktree bootstrap failed', async () => {
|
|
@@ -636,380 +747,306 @@ describe('openchamber session routes', () => {
|
|
|
636
747
|
error: 'branch already exists',
|
|
637
748
|
updatedAt: Date.now(),
|
|
638
749
|
}));
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
const promptCalls = globalThis.fetch.mock.calls.filter(([url]) => String(url).includes('/prompt_async'));
|
|
653
|
-
expect(promptCalls.length).toBe(0);
|
|
654
|
-
} finally {
|
|
655
|
-
globalThis.fetch = originalFetch;
|
|
656
|
-
}
|
|
750
|
+
|
|
751
|
+
const { app } = createApp();
|
|
752
|
+
await request(app)
|
|
753
|
+
.post('/api/openchamber/sessions')
|
|
754
|
+
.send({
|
|
755
|
+
directory: '/repo/app',
|
|
756
|
+
worktree: { name: 'side-task' },
|
|
757
|
+
prompt: 'Run this',
|
|
758
|
+
model: 'openai/gpt-5.5',
|
|
759
|
+
})
|
|
760
|
+
.expect(500, { error: 'Worktree bootstrap failed: branch already exists' });
|
|
761
|
+
|
|
762
|
+
expect(sessionPromptMock).not.toHaveBeenCalled();
|
|
657
763
|
});
|
|
658
764
|
|
|
659
765
|
it('sends a goal prompt to an existing session after creating goal metadata', async () => {
|
|
660
|
-
const originalFetch = globalThis.fetch;
|
|
661
|
-
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
|
|
662
766
|
const createSessionGoal = vi.fn(async () => undefined);
|
|
663
|
-
|
|
664
|
-
try {
|
|
665
|
-
setSessionMessages([{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }]);
|
|
666
|
-
const { app } = createApp({ createSessionGoal });
|
|
667
|
-
const response = await request(app)
|
|
668
|
-
.post('/api/openchamber/sessions/ses_source/send')
|
|
669
|
-
.send({
|
|
670
|
-
directory: '/repo/app',
|
|
671
|
-
prompt: 'Apply and verify the review feedback',
|
|
672
|
-
model: 'openai/gpt-5.5',
|
|
673
|
-
agent: 'build',
|
|
674
|
-
variant: 'high',
|
|
675
|
-
goal: true,
|
|
676
|
-
goalTokenBudget: 200000,
|
|
677
|
-
})
|
|
678
|
-
.expect(200);
|
|
767
|
+
setSessionMessages([{ id: 'msg_before', type: 'assistant', time: { created: 10, completed: 20 } }]);
|
|
679
768
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
promptDispatched: true,
|
|
685
|
-
goalEnabled: true,
|
|
686
|
-
baselineAssistantMessageId: 'msg_before',
|
|
687
|
-
});
|
|
688
|
-
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
|
|
689
|
-
sessionID: 'ses_source',
|
|
769
|
+
const { app } = createApp({ createSessionGoal });
|
|
770
|
+
const response = await request(app)
|
|
771
|
+
.post('/api/openchamber/sessions/ses_source/send')
|
|
772
|
+
.send({
|
|
690
773
|
directory: '/repo/app',
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
774
|
+
prompt: 'Apply and verify the review feedback',
|
|
775
|
+
model: 'openai/gpt-5.5',
|
|
776
|
+
agent: 'build',
|
|
777
|
+
variant: 'high',
|
|
778
|
+
goal: true,
|
|
779
|
+
goalTokenBudget: 200000,
|
|
780
|
+
})
|
|
781
|
+
.expect(200);
|
|
782
|
+
|
|
783
|
+
expect(response.body).toMatchObject({
|
|
784
|
+
action: 'send',
|
|
785
|
+
sessionId: 'ses_source',
|
|
786
|
+
directory: '/repo/app',
|
|
787
|
+
promptDispatched: true,
|
|
788
|
+
goalEnabled: true,
|
|
789
|
+
baselineAssistantMessageId: 'msg_before',
|
|
790
|
+
});
|
|
791
|
+
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
|
|
792
|
+
sessionID: 'ses_source',
|
|
793
|
+
directory: '/repo/app',
|
|
794
|
+
objective: 'Apply and verify the review feedback',
|
|
795
|
+
}));
|
|
796
|
+
expect(sessionPromptMock).toHaveBeenCalledWith({
|
|
797
|
+
sessionID: 'ses_source',
|
|
798
|
+
text: 'Apply and verify the review feedback',
|
|
799
|
+
});
|
|
800
|
+
expect(createSessionGoal.mock.invocationCallOrder[0])
|
|
801
|
+
.toBeLessThan(sessionPromptMock.mock.invocationCallOrder[0]);
|
|
699
802
|
});
|
|
700
803
|
|
|
701
|
-
it('
|
|
702
|
-
|
|
804
|
+
it('dispatches a slash command and keeps the typed prompt as the goal objective', async () => {
|
|
805
|
+
// v2 no longer publishes a command's template (`CommandInfo` is just name
|
|
806
|
+
// and description), so the objective is what the user typed.
|
|
703
807
|
const createSessionGoal = vi.fn(async () => undefined);
|
|
704
|
-
commandListMock.mockResolvedValue({
|
|
705
|
-
data: [{
|
|
706
|
-
name: 'issue--to-pr',
|
|
707
|
-
template: 'Take $ARGUMENTS from issue through a verified pull request. Confirm the PR covers $ARGUMENTS.',
|
|
708
|
-
}],
|
|
709
|
-
});
|
|
710
|
-
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url));
|
|
711
|
-
try {
|
|
712
|
-
const { app } = createApp({ createSessionGoal });
|
|
713
|
-
const response = await request(app)
|
|
714
|
-
.post('/api/openchamber/sessions/ses_source/send')
|
|
715
|
-
.send({
|
|
716
|
-
directory: '/repo/app',
|
|
717
|
-
prompt: '/issue--to-pr LIN-123',
|
|
718
|
-
model: 'openai/gpt-5.5',
|
|
719
|
-
agent: 'build',
|
|
720
|
-
goal: true,
|
|
721
|
-
})
|
|
722
|
-
.expect(200);
|
|
808
|
+
commandListMock.mockResolvedValue({ data: [{ name: 'issue--to-pr', description: 'Issue to PR' }] });
|
|
723
809
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
810
|
+
const { app } = createApp({ createSessionGoal });
|
|
811
|
+
const response = await request(app)
|
|
812
|
+
.post('/api/openchamber/sessions/ses_source/send')
|
|
813
|
+
.send({
|
|
814
|
+
directory: '/repo/app',
|
|
815
|
+
prompt: '/issue--to-pr LIN-123',
|
|
816
|
+
model: 'openai/gpt-5.5',
|
|
817
|
+
agent: 'build',
|
|
818
|
+
goal: true,
|
|
819
|
+
})
|
|
820
|
+
.expect(200);
|
|
821
|
+
|
|
822
|
+
expect(createSessionGoal).toHaveBeenCalledWith(expect.objectContaining({
|
|
823
|
+
objective: '/issue--to-pr LIN-123',
|
|
824
|
+
}));
|
|
825
|
+
expect(sessionCommandMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
826
|
+
name: 'issue--to-pr',
|
|
827
|
+
text: 'LIN-123',
|
|
828
|
+
}));
|
|
829
|
+
expect(createSessionGoal.mock.invocationCallOrder[0])
|
|
830
|
+
.toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]);
|
|
831
|
+
expect(response.body).toMatchObject({ goalEnabled: true, dispatchedAsCommand: true });
|
|
832
|
+
expect(sessionPromptMock).not.toHaveBeenCalled();
|
|
737
833
|
});
|
|
738
834
|
|
|
739
|
-
it('
|
|
740
|
-
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
info: {
|
|
747
|
-
id: 'msg_user',
|
|
748
|
-
role: 'user',
|
|
749
|
-
agent: 'plan',
|
|
750
|
-
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5', variant: 'high' },
|
|
751
|
-
time: { created: 5 },
|
|
752
|
-
},
|
|
753
|
-
},
|
|
754
|
-
{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } },
|
|
755
|
-
]);
|
|
756
|
-
const { app } = createApp();
|
|
757
|
-
const response = await request(app)
|
|
758
|
-
.post('/api/openchamber/sessions/ses_source/send')
|
|
759
|
-
.send({ directory: '/repo/app', prompt: 'Continue where you left off' })
|
|
760
|
-
.expect(200);
|
|
835
|
+
it('admits standing project context ahead of a dispatched slash command', async () => {
|
|
836
|
+
commandListMock.mockResolvedValue({ data: [{ name: 'review', description: 'Review' }] });
|
|
837
|
+
const recordDelivered = vi.fn(async () => undefined);
|
|
838
|
+
const sessionKnowledgeRuntime = {
|
|
839
|
+
resolvePendingForSession: vi.fn(async () => ({ text: 'Memory guidance', signature: 'sig_1' })),
|
|
840
|
+
recordDelivered,
|
|
841
|
+
};
|
|
761
842
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
843
|
+
const { app } = createApp({ sessionKnowledgeRuntime });
|
|
844
|
+
await request(app)
|
|
845
|
+
.post('/api/openchamber/sessions/ses_source/send')
|
|
846
|
+
.send({ directory: '/repo/app', prompt: '/review', model: 'openai/gpt-5.5', agent: 'build' })
|
|
847
|
+
.expect(200);
|
|
848
|
+
|
|
849
|
+
expect(sessionSyntheticMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
850
|
+
text: 'Memory guidance',
|
|
851
|
+
resume: false,
|
|
852
|
+
}));
|
|
853
|
+
expect(sessionSyntheticMock.mock.invocationCallOrder[0])
|
|
854
|
+
.toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]);
|
|
855
|
+
expect(recordDelivered).toHaveBeenCalledWith('ses_source', '/repo/app', 'sig_1');
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('reuses the previous session selection when send omits model, agent, and variant', async () => {
|
|
859
|
+
// v2 keeps the selection on the session record, so the history is no longer
|
|
860
|
+
// walked for it.
|
|
861
|
+
sessionGetMock.mockImplementation(async ({ sessionID }) => ({
|
|
862
|
+
id: sessionID,
|
|
863
|
+
agent: 'plan',
|
|
864
|
+
model: { providerID: 'anthropic', id: 'claude-sonnet-5', variant: 'high' },
|
|
865
|
+
location: { directory: '/repo/app' },
|
|
866
|
+
}));
|
|
867
|
+
setSessionMessages([{ id: 'msg_before', type: 'assistant', time: { created: 10, completed: 20 } }]);
|
|
868
|
+
|
|
869
|
+
const { app } = createApp();
|
|
870
|
+
const response = await request(app)
|
|
871
|
+
.post('/api/openchamber/sessions/ses_source/send')
|
|
872
|
+
.send({ directory: '/repo/app', prompt: 'Continue where you left off' })
|
|
873
|
+
.expect(200);
|
|
874
|
+
|
|
875
|
+
expect(response.body).toMatchObject({
|
|
876
|
+
action: 'send',
|
|
877
|
+
sessionId: 'ses_source',
|
|
878
|
+
model: { providerID: 'anthropic', modelID: 'claude-sonnet-5' },
|
|
879
|
+
agent: 'plan',
|
|
880
|
+
variant: 'high',
|
|
881
|
+
promptDispatched: true,
|
|
882
|
+
});
|
|
883
|
+
// The default-selection catalogue must not be consulted.
|
|
884
|
+
expect(modelListMock).not.toHaveBeenCalled();
|
|
885
|
+
expect(agentListMock).not.toHaveBeenCalled();
|
|
782
886
|
});
|
|
783
887
|
|
|
784
888
|
it('forks from a message, dispatches the prompt, and emits the new session', async () => {
|
|
785
|
-
const originalFetch = globalThis.fetch;
|
|
786
889
|
const emitSessionCreatedEvent = vi.fn();
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
.post('/api/openchamber/sessions/ses_source/fork')
|
|
792
|
-
.send({
|
|
793
|
-
directory: '/repo/app',
|
|
794
|
-
messageId: 'msg_branch_point',
|
|
795
|
-
prompt: 'Try the alternative implementation',
|
|
796
|
-
model: 'openai/gpt-5.5',
|
|
797
|
-
agent: 'build',
|
|
798
|
-
variant: 'high',
|
|
799
|
-
})
|
|
800
|
-
.expect(200);
|
|
801
|
-
|
|
802
|
-
expect(sessionForkMock).toHaveBeenCalledWith({
|
|
803
|
-
sessionID: 'ses_source',
|
|
804
|
-
directory: '/repo/app',
|
|
805
|
-
messageID: 'msg_branch_point',
|
|
806
|
-
});
|
|
807
|
-
expect(response.body).toMatchObject({
|
|
808
|
-
action: 'fork',
|
|
809
|
-
sourceSessionId: 'ses_source',
|
|
810
|
-
sessionId: 'ses_fork',
|
|
811
|
-
directory: '/repo/app',
|
|
812
|
-
promptDispatched: true,
|
|
813
|
-
});
|
|
814
|
-
expect(sessionMessagesMock).toHaveBeenCalledWith({
|
|
815
|
-
sessionID: 'ses_fork',
|
|
890
|
+
const { app } = createApp({ emitSessionCreatedEvent });
|
|
891
|
+
const response = await request(app)
|
|
892
|
+
.post('/api/openchamber/sessions/ses_source/fork')
|
|
893
|
+
.send({
|
|
816
894
|
directory: '/repo/app',
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
'
|
|
821
|
-
|
|
822
|
-
)
|
|
823
|
-
expect(
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
895
|
+
messageId: 'msg_branch_point',
|
|
896
|
+
prompt: 'Try the alternative implementation',
|
|
897
|
+
model: 'openai/gpt-5.5',
|
|
898
|
+
agent: 'build',
|
|
899
|
+
variant: 'high',
|
|
900
|
+
})
|
|
901
|
+
.expect(200);
|
|
902
|
+
|
|
903
|
+
expect(sessionForkMock).toHaveBeenCalledWith({
|
|
904
|
+
sessionID: 'ses_source',
|
|
905
|
+
before: 'msg_branch_point',
|
|
906
|
+
});
|
|
907
|
+
expect(response.body).toMatchObject({
|
|
908
|
+
action: 'fork',
|
|
909
|
+
sourceSessionId: 'ses_source',
|
|
910
|
+
sessionId: 'ses_fork',
|
|
911
|
+
directory: '/repo/app',
|
|
912
|
+
promptDispatched: true,
|
|
913
|
+
});
|
|
914
|
+
expect(sessionPromptMock).toHaveBeenCalledWith({ sessionID: 'ses_fork', text: 'Try the alternative implementation' });
|
|
915
|
+
expect(emitSessionCreatedEvent).toHaveBeenCalledWith(expect.objectContaining({
|
|
916
|
+
sessionID: 'ses_fork',
|
|
917
|
+
sourceSessionID: 'ses_source',
|
|
918
|
+
directory: '/repo/app',
|
|
919
|
+
promptDispatched: true,
|
|
920
|
+
}));
|
|
832
921
|
});
|
|
833
922
|
|
|
834
923
|
it('rejects send and fork requests without a prompt before calling OpenCode', async () => {
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
.expect(400, { error: 'prompt is required' });
|
|
848
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
849
|
-
expect(sessionForkMock).not.toHaveBeenCalled();
|
|
850
|
-
} finally {
|
|
851
|
-
globalThis.fetch = originalFetch;
|
|
852
|
-
}
|
|
924
|
+
const { app } = createApp();
|
|
925
|
+
await request(app)
|
|
926
|
+
.post('/api/openchamber/sessions/ses_source/send')
|
|
927
|
+
.send({ directory: '/repo/app' })
|
|
928
|
+
.expect(400, { error: 'prompt is required' });
|
|
929
|
+
await request(app)
|
|
930
|
+
.post('/api/openchamber/sessions/ses_source/fork')
|
|
931
|
+
.send({ directory: '/repo/app' })
|
|
932
|
+
.expect(400, { error: 'prompt is required' });
|
|
933
|
+
|
|
934
|
+
expect(sessionForkMock).not.toHaveBeenCalled();
|
|
935
|
+
expect(sessionPromptMock).not.toHaveBeenCalled();
|
|
853
936
|
});
|
|
854
937
|
|
|
855
938
|
it('reports the forked session when prompt dispatch fails', async () => {
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
.send({
|
|
863
|
-
directory: '/repo/app',
|
|
864
|
-
prompt: 'Try another approach',
|
|
865
|
-
model: 'openai/gpt-5.5',
|
|
866
|
-
agent: 'build',
|
|
867
|
-
variant: 'high',
|
|
868
|
-
})
|
|
869
|
-
.expect(500);
|
|
870
|
-
|
|
871
|
-
expect(response.body).toMatchObject({
|
|
872
|
-
partial: true,
|
|
873
|
-
partialAction: 'fork-created',
|
|
874
|
-
sessionId: 'ses_fork',
|
|
939
|
+
sessionPromptMock.mockRejectedValue(new Error('dispatch failed'));
|
|
940
|
+
|
|
941
|
+
const { app } = createApp();
|
|
942
|
+
const response = await request(app)
|
|
943
|
+
.post('/api/openchamber/sessions/ses_source/fork')
|
|
944
|
+
.send({
|
|
875
945
|
directory: '/repo/app',
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
946
|
+
prompt: 'Try another approach',
|
|
947
|
+
model: 'openai/gpt-5.5',
|
|
948
|
+
agent: 'build',
|
|
949
|
+
variant: 'high',
|
|
950
|
+
})
|
|
951
|
+
.expect(500);
|
|
952
|
+
|
|
953
|
+
expect(response.body).toMatchObject({
|
|
954
|
+
partial: true,
|
|
955
|
+
partialAction: 'fork-created',
|
|
956
|
+
sessionId: 'ses_fork',
|
|
957
|
+
directory: '/repo/app',
|
|
958
|
+
});
|
|
880
959
|
});
|
|
881
960
|
|
|
882
961
|
it('does not apply a default variant to an explicitly requested model', async () => {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
962
|
+
useCatalog({
|
|
963
|
+
models: [
|
|
964
|
+
{ id: 'requested', modelID: 'requested', providerID: 'openai', variants: [] },
|
|
965
|
+
{ id: 'default', modelID: 'default', providerID: 'openai', variants: [{ id: 'high' }] },
|
|
966
|
+
],
|
|
967
|
+
});
|
|
968
|
+
const { app } = createApp({
|
|
969
|
+
readSettingsFromDiskMigrated: async () => ({
|
|
970
|
+
defaultModel: 'openai/default',
|
|
971
|
+
defaultVariant: 'high',
|
|
972
|
+
projects: [{ id: 'proj_1', path: '/repo/app' }],
|
|
973
|
+
}),
|
|
974
|
+
});
|
|
975
|
+
await request(app)
|
|
976
|
+
.post('/api/openchamber/sessions/ses_source/send')
|
|
977
|
+
.send({ directory: '/repo/app', prompt: 'Continue', model: 'openai/requested', agent: 'build' })
|
|
978
|
+
.expect(200);
|
|
979
|
+
|
|
980
|
+
expect(sessionSwitchModelMock).toHaveBeenCalledWith({
|
|
981
|
+
sessionID: 'ses_source',
|
|
982
|
+
model: { id: 'requested', providerID: 'openai' },
|
|
900
983
|
});
|
|
901
|
-
globalThis.fetch = fetchMock;
|
|
902
|
-
try {
|
|
903
|
-
const { app } = createApp({
|
|
904
|
-
readSettingsFromDiskMigrated: async () => ({
|
|
905
|
-
defaultModel: 'openai/default',
|
|
906
|
-
defaultVariant: 'high',
|
|
907
|
-
projects: [{ id: 'proj_1', path: '/repo/app' }],
|
|
908
|
-
}),
|
|
909
|
-
});
|
|
910
|
-
await request(app)
|
|
911
|
-
.post('/api/openchamber/sessions/ses_source/send')
|
|
912
|
-
.send({ directory: '/repo/app', prompt: 'Continue', model: 'openai/requested', agent: 'build' })
|
|
913
|
-
.expect(200);
|
|
914
|
-
|
|
915
|
-
const promptCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/prompt_async'));
|
|
916
|
-
expect(JSON.parse(promptCall[1].body)).not.toHaveProperty('variant');
|
|
917
|
-
} finally {
|
|
918
|
-
globalThis.fetch = originalFetch;
|
|
919
|
-
}
|
|
920
984
|
});
|
|
921
985
|
|
|
922
986
|
it('rejects an unknown agent before creating a session or worktree', async () => {
|
|
923
|
-
const
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
expect(createWorktreeMock).not.toHaveBeenCalled();
|
|
939
|
-
expect(fetchMock.mock.calls.some(([url]) => String(url) === 'http://opencode.test/session?directory=%2Frepo%2Fapp')).toBe(false);
|
|
940
|
-
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
|
|
941
|
-
} finally {
|
|
942
|
-
globalThis.fetch = originalFetch;
|
|
943
|
-
}
|
|
987
|
+
const { app } = createApp();
|
|
988
|
+
await request(app)
|
|
989
|
+
.post('/api/openchamber/sessions')
|
|
990
|
+
.send({
|
|
991
|
+
directory: '/repo/app',
|
|
992
|
+
prompt: 'Run this',
|
|
993
|
+
agent: 'not-an-agent',
|
|
994
|
+
worktree: { name: 'side-task' },
|
|
995
|
+
})
|
|
996
|
+
.expect(400, { error: "Unknown agent 'not-an-agent' for /repo/app" });
|
|
997
|
+
|
|
998
|
+
expect(createWorktreeMock).not.toHaveBeenCalled();
|
|
999
|
+
expect(sessionCreateMock).not.toHaveBeenCalled();
|
|
1000
|
+
expect(sessionPromptMock).not.toHaveBeenCalled();
|
|
944
1001
|
});
|
|
945
1002
|
|
|
946
1003
|
it('rejects an unknown model and an unknown variant before dispatching', async () => {
|
|
947
|
-
const
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5', variant: 'ultra' })
|
|
959
|
-
.expect(400, { error: "Unknown variant 'ultra' for model 'openai/gpt-5.5'" });
|
|
960
|
-
|
|
961
|
-
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
|
|
962
|
-
} finally {
|
|
963
|
-
globalThis.fetch = originalFetch;
|
|
964
|
-
}
|
|
1004
|
+
const { app } = createApp();
|
|
1005
|
+
await request(app)
|
|
1006
|
+
.post('/api/openchamber/sessions')
|
|
1007
|
+
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-nope' })
|
|
1008
|
+
.expect(400, { error: "Unknown model 'openai/gpt-nope' for /repo/app" });
|
|
1009
|
+
await request(app)
|
|
1010
|
+
.post('/api/openchamber/sessions')
|
|
1011
|
+
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5', variant: 'ultra' })
|
|
1012
|
+
.expect(400, { error: "Unknown variant 'ultra' for model 'openai/gpt-5.5'" });
|
|
1013
|
+
|
|
1014
|
+
expect(sessionPromptMock).not.toHaveBeenCalled();
|
|
965
1015
|
});
|
|
966
1016
|
|
|
967
|
-
it('reports promptDispatched false when the accepted prompt
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
return selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) };
|
|
972
|
-
});
|
|
973
|
-
globalThis.fetch = fetchMock;
|
|
974
|
-
sessionMessagesMock.mockResolvedValue({ data: [] });
|
|
975
|
-
try {
|
|
976
|
-
const { app } = createApp();
|
|
977
|
-
const response = await request(app)
|
|
978
|
-
.post('/api/openchamber/sessions')
|
|
979
|
-
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
|
|
980
|
-
.expect(200);
|
|
1017
|
+
it('reports promptDispatched false when the accepted prompt returns no queued message', async () => {
|
|
1018
|
+
// v2 answers a prompt with the inbox item it recorded; no id means nothing
|
|
1019
|
+
// is queued, so the dispatch must not be reported as done.
|
|
1020
|
+
sessionPromptMock.mockResolvedValue({});
|
|
981
1021
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1022
|
+
const { app } = createApp();
|
|
1023
|
+
const response = await request(app)
|
|
1024
|
+
.post('/api/openchamber/sessions')
|
|
1025
|
+
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
|
|
1026
|
+
.expect(200);
|
|
1027
|
+
|
|
1028
|
+
expect(response.body.sessionId).toBe('ses_123');
|
|
1029
|
+
expect(response.body.promptDispatched).toBe(false);
|
|
1030
|
+
expect(response.body.promptError).toBeTruthy();
|
|
988
1031
|
}, 20_000);
|
|
989
1032
|
|
|
990
1033
|
it('does not retry a failed slash command as a normal prompt', async () => {
|
|
991
|
-
const originalFetch = globalThis.fetch;
|
|
992
|
-
const fetchMock = vi.fn(async (url) => selectionInputResponse(url));
|
|
993
1034
|
commandListMock.mockResolvedValue({ data: [{ name: 'review' }] });
|
|
994
1035
|
sessionCommandMock.mockRejectedValue(new Error('command response failed'));
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
|
|
1011
|
-
} finally {
|
|
1012
|
-
globalThis.fetch = originalFetch;
|
|
1013
|
-
}
|
|
1036
|
+
|
|
1037
|
+
const { app } = createApp();
|
|
1038
|
+
await request(app)
|
|
1039
|
+
.post('/api/openchamber/sessions/ses_source/send')
|
|
1040
|
+
.send({
|
|
1041
|
+
directory: '/repo/app',
|
|
1042
|
+
prompt: '/review fix this',
|
|
1043
|
+
model: 'openai/gpt-5.5',
|
|
1044
|
+
agent: 'build',
|
|
1045
|
+
variant: 'high',
|
|
1046
|
+
})
|
|
1047
|
+
.expect(500);
|
|
1048
|
+
|
|
1049
|
+
expect(sessionCommandMock).toHaveBeenCalledTimes(1);
|
|
1050
|
+
expect(sessionPromptMock).not.toHaveBeenCalled();
|
|
1014
1051
|
});
|
|
1015
1052
|
});
|