@openchambery/web 2.0.0-beta.1 → 2.0.0-beta.4
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 +15 -11
- package/bin/cli-opencode.test.js +4 -4
- package/bin/cli.js +1 -1
- package/bin/lib/cli-startup.js +1 -1
- package/bin/lib/cli-startup.test.js +18 -4
- package/dist/assets/{AgentAvatar-CLWWNu_Q.js → AgentAvatar-Q1SmFRx2.js} +1 -1
- package/dist/assets/AssistantView-CwXkiCAd.js +3 -0
- package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-BW92schO.js} +1 -1
- package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-BZmwl-zE.js} +1 -1
- package/dist/assets/{Icon-DD-BuMfZ.js → Icon-AQPvxMiU.js} +1 -1
- package/dist/assets/{MarkdownRenderer-bc_p1H7l.js → MarkdownRenderer-Bv05iDZZ.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-C81xqYib.js} +3 -3
- package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +3 -0
- package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-BMgIu7zc.js} +1 -1
- package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-BOXGNHmC.js} +2 -2
- package/dist/assets/MobileSurface-xuc_z0_G.js +2 -0
- package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-B5At2Z95.js} +1 -1
- package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-bsPLgz4r.js} +1 -1
- package/dist/assets/OverlayScrollbar-4wp4migh.js +2 -0
- package/dist/assets/SettingsView-CJ6tcH5_.js +25 -0
- package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-CMtX2bMl.js} +1 -1
- package/dist/assets/{TerminalView-Dh9MWCpS.js → TerminalView-Dvr-ysTS.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-uH1ZZDSS.js} +2 -2
- package/dist/assets/appThemeRegistry-DK7q2juD.js +1 -0
- package/dist/assets/{assistant-attachment-cache-C0aRnFkj.js → assistant-attachment-cache-DphJleHr.js} +1 -1
- package/dist/assets/{button-D-rATN7w.js → button-CcNJUxUd.js} +1 -1
- package/dist/assets/collapsible-ZmZ6rLWs.js +3 -0
- package/dist/assets/context-menu-B2LXJgXL.js +1 -0
- package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-C6HrP0jq.js} +1 -1
- package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DdVBp45U.js} +1 -1
- package/dist/assets/{diagram-DQf7Y_uR.js → diagram-CxTs9hCQ.js} +1 -1
- package/dist/assets/{dialog-bfbvR4VH.js → dialog-KMVL9QKF.js} +1 -1
- package/dist/assets/es-CN3E_zXj.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-BvrNCroG.js → fileMentionTouchSelection-7RIwlpkB.js} +1 -1
- package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-CjdBRy8y.js} +1 -1
- package/dist/assets/fr-9zZvQZD2.js +13 -0
- package/dist/assets/gitApi-yuWg9BN4.js +10 -0
- package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-9vQBq3zp.js} +1 -1
- package/dist/assets/{input-8r6F_Fza.js → input-CrIvk3HZ.js} +1 -1
- package/dist/assets/insertionBoundaries-BiUCSkx-.js +1 -0
- package/dist/assets/ja-Dnjk_82N.js +15 -0
- package/dist/assets/ko-BxCEE99C.js +15 -0
- package/dist/assets/{main-CCE_4y5F.js → main-C9mIBo77.js} +2 -2
- package/dist/assets/main-DiL6w6KF.js +169 -0
- package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
- package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-BmXVY_gc.js} +1 -1
- package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
- package/dist/assets/mcpQueries-C7jknHV8.js +1 -0
- package/dist/assets/miniChat-CwtUaElU.js +2 -0
- package/dist/assets/mobile-DB-6--ws.js +2 -0
- package/dist/assets/multirun-TAsxFI8e.js +4 -0
- package/dist/assets/{persistence-Ce3AnpEr.js → persistence-56kq8Z4V.js} +2 -2
- package/dist/assets/pl-CNNsWnPM.js +15 -0
- package/dist/assets/platform-C13fMVEs.js +6 -0
- package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
- package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +2 -0
- package/dist/assets/renderMobileApp-CGMMqbsO.js +28 -0
- package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
- package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-BEANkvOd.js} +1 -1
- package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +147 -0
- package/dist/assets/sessionChangeSummary-qOLrP9fw.js +2 -0
- package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-34XFmP9C.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-DyEV0Heg.js} +1 -1
- package/dist/assets/src-ubCWXZhR.css +2 -0
- package/dist/assets/store-DLCiZaA6.js +15 -0
- package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-CXaBEoa_.js} +1 -1
- package/dist/assets/textarea-BBOlRI9O.js +1 -0
- package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-CPI4fr1a.js} +1 -1
- package/dist/assets/uk-rwQrxeYM.js +15 -0
- package/dist/assets/{url-_z7evzdK.js → url-DRDQXjyX.js} +1 -1
- package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-CiDe42I5.js} +57 -43
- package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +1 -0
- package/dist/assets/useEffectiveDirectory-C_CouL5v.js +1 -0
- package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-B0oUZAiB.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DfPClaW-.js +414 -0
- package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +2 -0
- package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
- package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-CPNYz6Wf.js} +2 -2
- package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
- package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
- package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
- package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
- package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
- package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
- package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
- package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
- package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
- package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
- package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
- package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
- package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
- package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
- package/dist/index.html +13 -13
- package/dist/mini-chat.html +13 -13
- package/dist/mobile.html +13 -13
- package/package.json +1 -1
- package/server/index.js +327 -62
- package/server/lib/assistants/DOCUMENTATION.md +14 -9
- package/server/lib/assistants/contracts.js +11 -1
- package/server/lib/assistants/projection-history.test.js +265 -18
- package/server/lib/assistants/routes.js +6 -0
- package/server/lib/assistants/service.js +417 -231
- package/server/lib/assistants/service.test.js +392 -148
- package/server/lib/conversations/DOCUMENTATION.md +1 -1
- package/server/lib/conversations/service.js +1 -0
- package/server/lib/conversations/service.test.js +16 -0
- package/server/lib/conversations/validation.js +11 -1
- package/server/lib/event-stream/DOCUMENTATION.md +3 -1
- package/server/lib/event-stream/directory-ws-bridge.js +13 -2
- package/server/lib/event-stream/global-hub.js +49 -4
- package/server/lib/event-stream/reasoning-projection.js +16 -1
- package/server/lib/event-stream/reasoning-projection.test.js +15 -0
- package/server/lib/event-stream/runtime.js +57 -20
- package/server/lib/event-stream/runtime.test.js +112 -0
- package/server/lib/llm/DOCUMENTATION.md +33 -9
- package/server/lib/llm/catalog.js +1 -1
- package/server/lib/llm/completions.js +5 -1
- package/server/lib/llm/completions.test.js +3 -3
- package/server/lib/llm/generate.js +237 -145
- package/server/lib/llm/generate.test.js +201 -46
- package/server/lib/llm/routes.js +2 -0
- package/server/lib/message-queue/runtime.js +15 -2
- package/server/lib/notifications/runtime.js +23 -5
- package/server/lib/notifications/runtime.test.js +11 -0
- package/server/lib/opencode/DOCUMENTATION.md +85 -17
- package/server/lib/opencode/agent-document.d.ts +28 -0
- package/server/lib/opencode/agent-document.js +280 -0
- package/server/lib/opencode/agent-document.test.js +137 -0
- package/server/lib/opencode/agents.js +96 -0
- package/server/lib/opencode/auth-state-runtime.js +5 -0
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/config-entity-routes.js +42 -99
- package/server/lib/opencode/config-entity-routes.test.js +107 -0
- package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
- package/server/lib/opencode/ensure-cli.js +1 -2
- package/server/lib/opencode/env-runtime.js +40 -26
- package/server/lib/opencode/env-runtime.test.js +10 -17
- package/server/lib/opencode/feature-routes-runtime.js +43 -4
- package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
- package/server/lib/opencode/http-server-connections.js +98 -0
- package/server/lib/opencode/http-server-connections.test.js +321 -0
- package/server/lib/opencode/lifecycle.js +349 -39
- package/server/lib/opencode/lifecycle.test.js +322 -0
- package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
- package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
- package/server/lib/opencode/openchamber-routes.js +7 -1
- package/server/lib/opencode/opencode2-pin.js +4 -4
- package/server/lib/opencode/opencode2-pin.test.js +1 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +127 -4
- package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
- package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
- package/server/lib/opencode/plugin-routes.js +3 -18
- package/server/lib/opencode/plugin-routes.test.js +9 -9
- package/server/lib/opencode/provider-catalog.js +58 -12
- package/server/lib/opencode/provider-catalog.test.js +48 -0
- package/server/lib/opencode/proxy.js +252 -46
- package/server/lib/opencode/proxy.test.js +235 -2
- package/server/lib/opencode/routes.js +296 -62
- package/server/lib/opencode/routes.test.js +278 -12
- package/server/lib/opencode/runtime-contract.js +401 -0
- package/server/lib/opencode/runtime-contract.test.js +214 -0
- package/server/lib/opencode/server-opencode-fetch.js +242 -0
- package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
- package/server/lib/opencode/server-utils-runtime.js +33 -0
- package/server/lib/opencode/shared-service.integration.test.js +66 -0
- package/server/lib/opencode/shared-service.js +147 -0
- package/server/lib/opencode/shared-service.test.js +93 -0
- package/server/lib/opencode/shared.js +3 -3
- package/server/lib/opencode/shutdown-runtime.js +15 -6
- package/server/lib/opencode/shutdown-runtime.test.js +49 -1
- package/server/lib/opencode/skill-routes.js +20 -33
- package/server/lib/opencode/skill-routes.test.js +35 -2
- package/server/lib/opencode/skills.test.js +15 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
- package/server/lib/opencode/static-routes-runtime.js +3 -1
- package/server/lib/opencode/static-routes-runtime.test.js +33 -0
- package/server/lib/opencode/v2-client.js +14 -3
- package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
- package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
- package/server/lib/permission-auto-accept/runtime.js +14 -7
- package/server/lib/permission-auto-accept/runtime.test.js +27 -12
- package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -0
- package/server/lib/scheduled-tasks/runtime.js +52 -4
- package/server/lib/scheduled-tasks/runtime.test.js +80 -1
- package/server/lib/session-goal/DOCUMENTATION.md +113 -13
- package/server/lib/session-goal/routes.js +9 -0
- package/server/lib/session-goal/runtime.js +1579 -117
- package/server/lib/session-goal/runtime.test.js +1453 -84
- package/server/lib/session-index/DOCUMENTATION.md +18 -15
- package/server/lib/session-index/event-ingest.js +89 -16
- package/server/lib/session-index/event-ingest.test.js +73 -0
- package/server/lib/session-index/host-authority-snapshot.js +92 -0
- package/server/lib/session-index/routes.host-archive.test.js +201 -0
- package/server/lib/session-index/routes.js +63 -10
- package/server/lib/session-index/routes.test.js +8 -8
- package/server/lib/session-index/service.js +7 -1
- package/server/lib/session-index/sync-runtime.js +161 -34
- package/server/lib/session-index/sync-runtime.test.js +155 -1
- package/server/lib/session-metadata/DOCUMENTATION.md +61 -16
- package/server/lib/session-metadata/routes.js +108 -9
- package/server/lib/session-metadata/routes.test.js +47 -2
- package/server/lib/session-metadata/session-archive.d.ts +73 -0
- package/server/lib/session-metadata/session-archive.js +654 -0
- package/server/lib/session-metadata/session-archive.test.js +359 -0
- package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
- package/server/lib/session-metadata/session-metadata-store.js +212 -72
- package/server/lib/session-metadata/session-metadata-store.test.js +205 -10
- package/server/lib/session-metadata/session-projection.d.ts +41 -0
- package/server/lib/session-metadata/session-projection.js +255 -0
- package/server/lib/session-metadata/session-projection.test.js +64 -0
- package/server/lib/session-metadata/system-session.js +55 -0
- package/server/lib/session-metadata/system-session.test.js +55 -0
- package/server/lib/session-title/DOCUMENTATION.md +40 -7
- package/server/lib/session-title/native-runtime.test.js +167 -0
- package/server/lib/session-title/runtime.js +67 -42
- package/server/lib/session-title/session-access.js +46 -0
- package/server/lib/session-title/session-access.test.js +91 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +9 -8
- package/server/lib/session-turn-pages/routes.js +17 -102
- package/server/lib/session-turn-pages/routes.test.js +281 -0
- package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
- package/server/lib/session-turn-pages/session-message-projection.js +281 -0
- package/server/lib/small-model/DOCUMENTATION.md +101 -147
- package/server/lib/small-model/catalog.js +31 -51
- package/server/lib/small-model/catalog.test.js +65 -10
- package/server/lib/small-model/custom-api.js +68 -0
- package/server/lib/small-model/custom-api.test.js +64 -0
- package/server/lib/small-model/index.js +124 -240
- package/server/lib/small-model/resolve.js +35 -120
- package/server/lib/small-model/resolve.test.js +20 -110
- package/server/lib/small-model/routes.js +1 -3
- package/server/lib/small-model/summary-settings.test.js +157 -253
- package/server/opencode-proxy.test.js +2 -0
- package/dist/assets/AssistantView-BTCkm8yk.js +0 -3
- package/dist/assets/MarkstreamRendererImpl-CnkodGJW.js +0 -3
- package/dist/assets/MobileSurface-BXLeiZ4d.js +0 -2
- package/dist/assets/OverlayScrollbar-pMwxaCca.js +0 -2
- package/dist/assets/SettingsView-B5I0KDTZ.js +0 -25
- package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
- package/dist/assets/collapsible-BXRd5_Hr.js +0 -1
- package/dist/assets/context-menu-jGtH6fR9.js +0 -1
- package/dist/assets/es-c52LTHHe.js +0 -15
- package/dist/assets/fr-PisFbUE8.js +0 -13
- package/dist/assets/gitApi-ButwzQ0N.js +0 -9
- package/dist/assets/insertionBoundaries-DGs7S5zv.js +0 -1
- package/dist/assets/ja-C8Ikun3Z.js +0 -15
- package/dist/assets/ko-ClFys5o2.js +0 -15
- package/dist/assets/main-Dn9rpsgl.js +0 -169
- package/dist/assets/markdown-worker-CptoICmq.js +0 -2
- package/dist/assets/miniChat-BpjxVqgg.js +0 -2
- package/dist/assets/mobile-nMJCaNq1.js +0 -2
- package/dist/assets/multirun-BvSG94mW.js +0 -4
- package/dist/assets/pl-CnaKWN5F.js +0 -15
- package/dist/assets/platform-CWTIp02q.js +0 -4
- package/dist/assets/projectMeta-DOe49dM_.js +0 -3
- package/dist/assets/pt-BR-s8OpfRI6.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +0 -2
- package/dist/assets/renderMobileApp-y-S8y8v_.js +0 -28
- package/dist/assets/runtime-fetch-zIalUnbb.js +0 -1
- package/dist/assets/runtimeEndpointReset-B6BxhvR0.js +0 -147
- package/dist/assets/sessionChangeSummary-VVBlEXuh.js +0 -2
- package/dist/assets/src-BzX5UI-c.css +0 -2
- package/dist/assets/store-DJczmczv.js +0 -15
- package/dist/assets/textarea-3XR8sUfy.js +0 -1
- package/dist/assets/uk-CaX0iqkB.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +0 -1
- package/dist/assets/useEffectiveDirectory-CN6JoCFw.js +0 -1
- package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +0 -414
- package/dist/assets/useSessionAutoCleanup-BLjymaFt.js +0 -2
- package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
- package/dist/assets/zh-CN-20MYEjha.js +0 -15
- package/dist/assets/zh-TW-DiN04qTw.js +0 -15
- package/server/lib/small-model/call.js +0 -427
- package/server/lib/small-model/call.test.js +0 -382
- package/server/lib/small-model/opencode-session.js +0 -183
- package/server/lib/small-model/opencode-session.test.js +0 -147
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createSessionTitleRuntime } from './runtime.js';
|
|
3
|
+
import { mergeMetadataPatch } from '../session-metadata/session-metadata-store.js';
|
|
4
|
+
import { createTitleSessionAccess } from './session-access.js';
|
|
5
|
+
import { configureServerOpenCodeFetchGate } from '../opencode/server-opencode-fetch.js';
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.useRealTimers();
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
configureServerOpenCodeFetchGate(null);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it.each([false, true])('runs a Host smart-title request and clears loading (model failure: %s)', async (fail) => {
|
|
14
|
+
vi.useFakeTimers();
|
|
15
|
+
const metadata = { openchamber: { titleRefresh: { lastAutoTitle: 'Original', requestedAt: 1000 } } };
|
|
16
|
+
const session = { id: 'ses_title', title: 'Original', directory: '/repo', metadata };
|
|
17
|
+
const updates = [];
|
|
18
|
+
const sessionAccess = {
|
|
19
|
+
get: vi.fn(async () => structuredClone(session)),
|
|
20
|
+
update: vi.fn(async (_id, _directory, patch) => {
|
|
21
|
+
if (patch.title) session.title = patch.title;
|
|
22
|
+
if (patch.metadata) session.metadata = mergeMetadataPatch(session.metadata, patch.metadata);
|
|
23
|
+
updates.push(structuredClone(session));
|
|
24
|
+
}),
|
|
25
|
+
messages: vi.fn(async () => [
|
|
26
|
+
{ info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Implement a title refresh' }] },
|
|
27
|
+
{ info: { id: 'a1', role: 'assistant' }, parts: [{ type: 'text', text: 'Done' }] },
|
|
28
|
+
]),
|
|
29
|
+
};
|
|
30
|
+
const generate = vi.fn(async () => {
|
|
31
|
+
expect(session.metadata.openchamber.titleRefresh.isGenerating).toBe(true);
|
|
32
|
+
if (fail) throw new Error('title model unavailable');
|
|
33
|
+
return { text: 'Title refresh', providerID: 'test', modelID: 'test' };
|
|
34
|
+
});
|
|
35
|
+
const runtime = createSessionTitleRuntime({
|
|
36
|
+
sessionAccess,
|
|
37
|
+
buildOpenCodeUrl: (path) => `http://opencode${path}`,
|
|
38
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
39
|
+
getSmallModelService: async () => ({ generateSmallModelText: generate }),
|
|
40
|
+
now: () => 1000,
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
runtime.processPayload({ type: 'openchamber:session-metadata', properties: {
|
|
44
|
+
sessionID: session.id, directory: '/repo', metadata,
|
|
45
|
+
} });
|
|
46
|
+
await vi.runAllTimersAsync();
|
|
47
|
+
expect(generate).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(session.title).toBe(fail ? 'Original' : 'Title refresh');
|
|
49
|
+
if (fail) expect(session.metadata.openchamber.titleRefresh.lastError).toBe('title model unavailable');
|
|
50
|
+
expect(session.metadata.openchamber.titleRefresh.isGenerating).not.toBe(true);
|
|
51
|
+
expect(session.metadata.openchamber.titleRefresh.requestedAt).toBeUndefined();
|
|
52
|
+
expect(updates.some((row) => row.metadata.openchamber.titleRefresh.isGenerating)).toBe(true);
|
|
53
|
+
} finally {
|
|
54
|
+
runtime.stop();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('uses the real v2 client and publishes the generated title without another session click', async () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
configureServerOpenCodeFetchGate(null, { allowMissingContract: true });
|
|
61
|
+
let metadata = { openchamber: { titleRefresh: { lastAutoTitle: 'Original', requestedAt: 1000 } } };
|
|
62
|
+
const session = { id: 'ses_title', title: 'Original', location: { directory: '/repo' }, time: { created: 1, updated: 2 } };
|
|
63
|
+
const requests = [];
|
|
64
|
+
vi.stubGlobal('fetch', vi.fn(async (input, init) => {
|
|
65
|
+
const request = input instanceof Request ? input : new Request(input, init);
|
|
66
|
+
const url = new URL(request.url);
|
|
67
|
+
requests.push(`${request.method} ${url.pathname}`);
|
|
68
|
+
if (url.pathname.endsWith('/message')) {
|
|
69
|
+
expect(url.searchParams.get('order')).toBe('desc');
|
|
70
|
+
return Response.json({ data: [
|
|
71
|
+
{ id: 'a1', type: 'assistant', content: [{ type: 'text', text: 'Done' }] },
|
|
72
|
+
{ id: 'u1', type: 'user', text: 'Implement title refresh' },
|
|
73
|
+
] });
|
|
74
|
+
}
|
|
75
|
+
if (request.method === 'PATCH') {
|
|
76
|
+
const body = await request.json();
|
|
77
|
+
expect(Object.keys(body)).toEqual(['title']);
|
|
78
|
+
session.title = body.title;
|
|
79
|
+
return new Response(null, { status: 204 });
|
|
80
|
+
}
|
|
81
|
+
return Response.json({ data: session });
|
|
82
|
+
}));
|
|
83
|
+
const events = [];
|
|
84
|
+
const access = createTitleSessionAccess({
|
|
85
|
+
buildOpenCodeUrl: (path) => `http://opencode${path}`,
|
|
86
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
87
|
+
readSessionMetadata: async () => metadata,
|
|
88
|
+
persistSessionMetadata: async (_id, patch) => {
|
|
89
|
+
metadata = mergeMetadataPatch(metadata, patch);
|
|
90
|
+
events.push({ type: 'metadata', metadata: structuredClone(metadata) });
|
|
91
|
+
},
|
|
92
|
+
publishSession: (row) => events.push({ type: 'session', row }),
|
|
93
|
+
});
|
|
94
|
+
const runtime = createSessionTitleRuntime({
|
|
95
|
+
sessionAccess: access,
|
|
96
|
+
getSmallModelService: async () => ({ generateSmallModelText: async () => ({ text: 'Title refresh', providerID: 'test', modelID: 'test' }) }),
|
|
97
|
+
now: () => 1000,
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
runtime.processPayload({ type: 'openchamber:session-metadata', properties: {
|
|
101
|
+
sessionID: session.id, metadata, directory: '/repo',
|
|
102
|
+
} });
|
|
103
|
+
await vi.runAllTimersAsync();
|
|
104
|
+
expect(events.some((event) => event.metadata?.openchamber?.titleRefresh?.isGenerating === true)).toBe(true);
|
|
105
|
+
expect(events.at(-1)).toMatchObject({ type: 'session', row: { title: 'Title refresh', directory: '/repo' } });
|
|
106
|
+
expect(metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
|
|
107
|
+
expect(requests).toContain('PATCH /api/session/ses_title');
|
|
108
|
+
} finally {
|
|
109
|
+
runtime.stop();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('starts the first title from admitted user text while the assistant is still running', async () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
const session = {
|
|
116
|
+
id: 'ses_first', title: '', time: { created: 1, updated: 1 },
|
|
117
|
+
metadata: {},
|
|
118
|
+
};
|
|
119
|
+
let finishTitle;
|
|
120
|
+
const generation = new Promise((resolve) => { finishTitle = resolve; });
|
|
121
|
+
const generate = vi.fn(() => generation);
|
|
122
|
+
const access = {
|
|
123
|
+
get: vi.fn(async () => structuredClone(session)),
|
|
124
|
+
update: vi.fn(async (_id, _directory, patch) => {
|
|
125
|
+
if (patch.metadata) session.metadata = mergeMetadataPatch(session.metadata, patch.metadata);
|
|
126
|
+
if (patch.title) session.title = patch.title;
|
|
127
|
+
}),
|
|
128
|
+
messages: vi.fn(async () => []),
|
|
129
|
+
};
|
|
130
|
+
const runtime = createSessionTitleRuntime({
|
|
131
|
+
sessionAccess: access,
|
|
132
|
+
getSmallModelService: async () => ({ generateSmallModelText: generate }),
|
|
133
|
+
isTitleRefreshEnabled: () => true,
|
|
134
|
+
now: () => 1000,
|
|
135
|
+
});
|
|
136
|
+
const emit = (type, data) => runtime.processPayload({ type, data, created: 1000, location: { directory: '/repo' } });
|
|
137
|
+
try {
|
|
138
|
+
emit('session.created', { sessionID: session.id, location: { directory: '/repo' } });
|
|
139
|
+
emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_synthetic', item: {
|
|
140
|
+
type: 'synthetic', delivery: 'steer', payload: { text: 'Internal instructions' },
|
|
141
|
+
} });
|
|
142
|
+
await vi.runAllTimersAsync();
|
|
143
|
+
expect(generate).not.toHaveBeenCalled();
|
|
144
|
+
emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_first', item: {
|
|
145
|
+
type: 'user', delivery: 'steer', payload: { text: 'Implement a session title generator' },
|
|
146
|
+
} });
|
|
147
|
+
emit('session.execution.started', { sessionID: session.id });
|
|
148
|
+
emit('session.status', { sessionID: session.id, status: { type: 'busy' } });
|
|
149
|
+
await vi.runAllTimersAsync();
|
|
150
|
+
expect(generate).toHaveBeenCalledTimes(1);
|
|
151
|
+
expect(generate.mock.calls[0][0].prompt).toContain('Implement a session title generator');
|
|
152
|
+
expect(session.metadata.openchamber?.titleRefresh?.isGenerating).toBe(true);
|
|
153
|
+
expect(access.messages).not.toHaveBeenCalled();
|
|
154
|
+
finishTitle({ text: 'Session titles', providerID: 'test', modelID: 'test' });
|
|
155
|
+
await vi.runAllTimersAsync();
|
|
156
|
+
expect(session.title).toBe('Session titles');
|
|
157
|
+
expect(session.metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
|
|
158
|
+
emit('session.execution.succeeded', { sessionID: session.id });
|
|
159
|
+
emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_second', item: {
|
|
160
|
+
type: 'user', delivery: 'steer', payload: { text: 'Follow up on the implementation' },
|
|
161
|
+
} });
|
|
162
|
+
await vi.runAllTimersAsync();
|
|
163
|
+
expect(generate).toHaveBeenCalledTimes(1);
|
|
164
|
+
} finally {
|
|
165
|
+
runtime.stop();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
@@ -131,14 +131,18 @@ const buildTitleSystemPrompt = () => [
|
|
|
131
131
|
].join('\n');
|
|
132
132
|
|
|
133
133
|
const extractSessionStatus = (payload) => {
|
|
134
|
-
if (!payload
|
|
135
|
-
const
|
|
134
|
+
if (!payload) return null;
|
|
135
|
+
const executionType = payload.type === 'session.execution.started' ? 'busy'
|
|
136
|
+
: payload.type === 'session.execution.succeeded' || payload.type === 'session.execution.failed' ? 'idle'
|
|
137
|
+
: null;
|
|
138
|
+
if (payload.type !== 'session.status' && !executionType) return null;
|
|
139
|
+
const properties = payload.properties ?? payload.data ?? {};
|
|
136
140
|
const status = properties.status && typeof properties.status === 'object' ? properties.status : {};
|
|
137
141
|
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
|
|
138
142
|
const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
|
|
139
|
-
const type = typeof status.type === 'string'
|
|
143
|
+
const type = executionType ?? (typeof status.type === 'string'
|
|
140
144
|
? status.type.trim()
|
|
141
|
-
: (typeof info.type === 'string' ? info.type.trim() : '');
|
|
145
|
+
: (typeof info.type === 'string' ? info.type.trim() : ''));
|
|
142
146
|
if (!sessionId || !type) return null;
|
|
143
147
|
const directory = typeof properties.directory === 'string' && properties.directory
|
|
144
148
|
? properties.directory
|
|
@@ -159,9 +163,11 @@ export const isSystemOwnedSession = (sessionOrInfo) => {
|
|
|
159
163
|
};
|
|
160
164
|
|
|
161
165
|
const extractTitleRefreshRequest = (payload) => {
|
|
162
|
-
if (!payload || payload.type !== 'session.updated') return null;
|
|
166
|
+
if (!payload || (payload.type !== 'session.updated' && payload.type !== 'openchamber:session-metadata')) return null;
|
|
163
167
|
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
|
|
164
|
-
const info =
|
|
168
|
+
const info = payload.type === 'openchamber:session-metadata'
|
|
169
|
+
? { id: properties.sessionID, metadata: properties.metadata }
|
|
170
|
+
: (properties.info && typeof properties.info === 'object' ? properties.info : {});
|
|
165
171
|
if (isSystemOwnedSession(info)) return null;
|
|
166
172
|
const sessionId = typeof info.id === 'string' ? info.id.trim() : '';
|
|
167
173
|
const titleRefresh = info.metadata?.openchamber?.titleRefresh;
|
|
@@ -176,20 +182,35 @@ const extractTitleRefreshRequest = (payload) => {
|
|
|
176
182
|
|
|
177
183
|
const extractCreatedSession = (payload) => {
|
|
178
184
|
if (!payload || payload.type !== 'session.created') return null;
|
|
179
|
-
const properties = payload.properties
|
|
180
|
-
const info = properties.info
|
|
185
|
+
const properties = payload.properties ?? payload.data ?? {};
|
|
186
|
+
const info = properties.info ?? { ...properties, id: properties.sessionID };
|
|
181
187
|
const sessionId = typeof info.id === 'string' ? info.id.trim() : '';
|
|
182
188
|
if (!sessionId || (typeof info.parentID === 'string' && info.parentID)) return null;
|
|
183
189
|
if (isSystemOwnedSession(info)) return null;
|
|
184
190
|
const directory = typeof properties.directory === 'string' && properties.directory
|
|
185
191
|
? properties.directory
|
|
186
|
-
: (typeof info.directory === 'string' ? info.directory : '');
|
|
187
|
-
const createdAt = typeof info.time?.created === 'number' ? info.time.created : Date.now();
|
|
192
|
+
: (typeof info.location?.directory === 'string' ? info.location.directory : (typeof info.directory === 'string' ? info.directory : ''));
|
|
193
|
+
const createdAt = typeof info.time?.created === 'number' ? info.time.created : (payload.created ?? Date.now());
|
|
188
194
|
const title = typeof info.title === 'string' ? info.title : '';
|
|
189
195
|
return { sessionId, directory, createdAt, title };
|
|
190
196
|
};
|
|
191
197
|
|
|
192
198
|
const extractUserMessage = (payload) => {
|
|
199
|
+
if (payload?.type === 'session.inbox.enqueued') {
|
|
200
|
+
const properties = payload.properties ?? payload.data ?? {};
|
|
201
|
+
const item = properties.item;
|
|
202
|
+
if (typeof properties.sessionID !== 'string' || item?.type !== 'user') return null;
|
|
203
|
+
const text = typeof item.payload?.text === 'string' ? item.payload.text.slice(0, TRANSCRIPT_USER_CHAR_LIMIT) : '';
|
|
204
|
+
return {
|
|
205
|
+
sessionId: properties.sessionID,
|
|
206
|
+
createdAt: payload.created ?? 0,
|
|
207
|
+
directory: payload.location?.directory ?? '',
|
|
208
|
+
initialRecord: text.trim() ? {
|
|
209
|
+
info: { id: properties.inboxID, role: 'user' },
|
|
210
|
+
parts: [{ type: 'text', text }],
|
|
211
|
+
} : null,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
193
214
|
if (!payload || payload.type !== 'message.updated') return null;
|
|
194
215
|
const info = payload.properties?.info;
|
|
195
216
|
if (!info || typeof info !== 'object' || info.role !== 'user') return null;
|
|
@@ -351,6 +372,9 @@ export const buildLatestTitleTranscript = (
|
|
|
351
372
|
export const createSessionTitleRuntime = ({
|
|
352
373
|
buildOpenCodeUrl,
|
|
353
374
|
getOpenCodeAuthHeaders,
|
|
375
|
+
/** Ticket 11: Host write admission shared with proxy (optional). */
|
|
376
|
+
serverOpenCodeFetch = null,
|
|
377
|
+
sessionAccess = null,
|
|
354
378
|
getSmallModelService,
|
|
355
379
|
quietMs = TITLE_QUIET_MS,
|
|
356
380
|
throttleMs = TITLE_THROTTLE_MS,
|
|
@@ -362,6 +386,7 @@ export const createSessionTitleRuntime = ({
|
|
|
362
386
|
const forcedRefreshes = new Set();
|
|
363
387
|
const initialRefreshes = new Set();
|
|
364
388
|
const newSessions = new Set();
|
|
389
|
+
const initialUserMessages = new Map();
|
|
365
390
|
const forkFirstSendPending = new Map();
|
|
366
391
|
const forkFirstRefreshes = new Set();
|
|
367
392
|
/** In-memory throttle timestamps (metadata also persists across restarts). */
|
|
@@ -377,6 +402,20 @@ export const createSessionTitleRuntime = ({
|
|
|
377
402
|
};
|
|
378
403
|
|
|
379
404
|
const openCodeFetch = async (pathname, { directory, method = 'GET', body } = {}) => {
|
|
405
|
+
if (sessionAccess) {
|
|
406
|
+
const sessionId = decodeURIComponent(pathname.slice('/session/'.length));
|
|
407
|
+
return method === 'PATCH'
|
|
408
|
+
? sessionAccess.update(sessionId, directory, body)
|
|
409
|
+
: sessionAccess.get(sessionId, directory);
|
|
410
|
+
}
|
|
411
|
+
if (typeof serverOpenCodeFetch === 'function') {
|
|
412
|
+
return serverOpenCodeFetch(pathname, {
|
|
413
|
+
directory,
|
|
414
|
+
method,
|
|
415
|
+
body,
|
|
416
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
380
419
|
const base = buildOpenCodeUrl(pathname, '');
|
|
381
420
|
const url = directory ? `${base}?directory=${encodeURIComponent(directory)}` : base;
|
|
382
421
|
const response = await fetch(url, {
|
|
@@ -396,6 +435,7 @@ export const createSessionTitleRuntime = ({
|
|
|
396
435
|
};
|
|
397
436
|
|
|
398
437
|
const fetchRecentMessages = async (sessionId, directory) => {
|
|
438
|
+
if (sessionAccess) return sessionAccess.messages(sessionId, directory, TRANSCRIPT_MESSAGE_LIMIT);
|
|
399
439
|
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}/message`, '');
|
|
400
440
|
const params = new URLSearchParams({ limit: String(TRANSCRIPT_MESSAGE_LIMIT) });
|
|
401
441
|
if (directory) params.set('directory', directory);
|
|
@@ -421,20 +461,13 @@ export const createSessionTitleRuntime = ({
|
|
|
421
461
|
|
|
422
462
|
const meta = readTitleRefreshMeta(session);
|
|
423
463
|
if (meta.titleRefresh.isGenerating !== true) return;
|
|
424
|
-
const {
|
|
425
|
-
isGenerating: _isGenerating,
|
|
426
|
-
requestedAt: _requestedAt,
|
|
427
|
-
...titleRefresh
|
|
428
|
-
} = meta.titleRefresh;
|
|
429
464
|
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
|
|
430
465
|
directory,
|
|
431
466
|
method: 'PATCH',
|
|
432
467
|
body: {
|
|
433
468
|
metadata: {
|
|
434
|
-
...meta.metadata,
|
|
435
469
|
openchamber: {
|
|
436
|
-
|
|
437
|
-
titleRefresh,
|
|
470
|
+
titleRefresh: { isGenerating: null, requestedAt: null },
|
|
438
471
|
},
|
|
439
472
|
},
|
|
440
473
|
},
|
|
@@ -446,17 +479,13 @@ export const createSessionTitleRuntime = ({
|
|
|
446
479
|
.catch(() => null);
|
|
447
480
|
if (!session || typeof session !== 'object') return;
|
|
448
481
|
|
|
449
|
-
const meta = readTitleRefreshMeta(session);
|
|
450
482
|
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
|
|
451
483
|
directory,
|
|
452
484
|
method: 'PATCH',
|
|
453
485
|
body: {
|
|
454
486
|
metadata: {
|
|
455
|
-
...meta.metadata,
|
|
456
487
|
openchamber: {
|
|
457
|
-
...meta.openchamber,
|
|
458
488
|
titleRefresh: {
|
|
459
|
-
...meta.titleRefresh,
|
|
460
489
|
lastError: error instanceof Error ? error.message : String(error),
|
|
461
490
|
failedAt: now(),
|
|
462
491
|
},
|
|
@@ -505,11 +534,8 @@ export const createSessionTitleRuntime = ({
|
|
|
505
534
|
method: 'PATCH',
|
|
506
535
|
body: {
|
|
507
536
|
metadata: {
|
|
508
|
-
...meta.metadata,
|
|
509
537
|
openchamber: {
|
|
510
|
-
...meta.openchamber,
|
|
511
538
|
titleRefresh: {
|
|
512
|
-
...meta.titleRefresh,
|
|
513
539
|
activityUpdatedAt,
|
|
514
540
|
},
|
|
515
541
|
},
|
|
@@ -524,6 +550,8 @@ export const createSessionTitleRuntime = ({
|
|
|
524
550
|
const generateTitle = async (sessionId, directory) => {
|
|
525
551
|
const forceRefresh = forcedRefreshes.delete(sessionId);
|
|
526
552
|
const initialRefresh = initialRefreshes.delete(sessionId);
|
|
553
|
+
const initialUserMessage = initialUserMessages.get(sessionId);
|
|
554
|
+
initialUserMessages.delete(sessionId);
|
|
527
555
|
const forkFirstRefresh = forkFirstRefreshes.delete(sessionId);
|
|
528
556
|
if (!forceRefresh && !isTitleRefreshEnabled()) return;
|
|
529
557
|
|
|
@@ -558,15 +586,14 @@ export const createSessionTitleRuntime = ({
|
|
|
558
586
|
return;
|
|
559
587
|
}
|
|
560
588
|
|
|
561
|
-
const messages = await fetchRecentMessages(sessionId, directory);
|
|
589
|
+
const messages = initialUserMessage ? [initialUserMessage] : await fetchRecentMessages(sessionId, directory);
|
|
562
590
|
if (!messages || messages.length === 0) return;
|
|
563
591
|
|
|
564
592
|
const { transcript, lastAssistantId, realUserCount, languageSample } = buildLatestTitleTranscript(messages);
|
|
565
593
|
if (!transcript) return;
|
|
566
594
|
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
// still the default placeholder.
|
|
595
|
+
// Initial titles use the admitted first user message immediately; ordinary
|
|
596
|
+
// later refreshes still require a moved-on conversation or placeholder.
|
|
570
597
|
if (!forceRefresh && !initialRefresh && !forkFirstRefresh && realUserCount < 2 && !isDefaultSessionTitle(currentTitle)) {
|
|
571
598
|
return;
|
|
572
599
|
}
|
|
@@ -597,14 +624,11 @@ export const createSessionTitleRuntime = ({
|
|
|
597
624
|
method: 'PATCH',
|
|
598
625
|
body: {
|
|
599
626
|
metadata: {
|
|
600
|
-
...meta.metadata,
|
|
601
627
|
openchamber: {
|
|
602
|
-
...meta.openchamber,
|
|
603
628
|
titleRefresh: {
|
|
604
|
-
...meta.titleRefresh,
|
|
605
629
|
isGenerating: true,
|
|
606
|
-
lastError:
|
|
607
|
-
failedAt:
|
|
630
|
+
lastError: null,
|
|
631
|
+
failedAt: null,
|
|
608
632
|
},
|
|
609
633
|
},
|
|
610
634
|
},
|
|
@@ -677,9 +701,6 @@ export const createSessionTitleRuntime = ({
|
|
|
677
701
|
const generatedAt = now();
|
|
678
702
|
lastGeneratedAtBySession.set(sessionId, generatedAt);
|
|
679
703
|
|
|
680
|
-
const currentMetadata = freshMeta.metadata;
|
|
681
|
-
const currentNamespace = freshMeta.openchamber;
|
|
682
|
-
|
|
683
704
|
console.log(`[session-title] refreshed ${sessionId} → "${nextTitle}" via ${generated.providerID}/${generated.modelID}`);
|
|
684
705
|
await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, {
|
|
685
706
|
directory,
|
|
@@ -687,11 +708,8 @@ export const createSessionTitleRuntime = ({
|
|
|
687
708
|
body: {
|
|
688
709
|
title: nextTitle,
|
|
689
710
|
metadata: {
|
|
690
|
-
...currentMetadata,
|
|
691
711
|
openchamber: {
|
|
692
|
-
...currentNamespace,
|
|
693
712
|
titleRefresh: {
|
|
694
|
-
...freshMeta.titleRefresh,
|
|
695
713
|
lastAutoTitle: nextTitle,
|
|
696
714
|
forMessageID: lastAssistantId || latestAssistantId || '',
|
|
697
715
|
generatedAt,
|
|
@@ -723,6 +741,7 @@ export const createSessionTitleRuntime = ({
|
|
|
723
741
|
|
|
724
742
|
const processPayload = (payload, directoryHint = '') => {
|
|
725
743
|
if (stopped) return;
|
|
744
|
+
directoryHint = directoryHint || payload?.location?.directory || '';
|
|
726
745
|
const createdSession = extractCreatedSession(payload);
|
|
727
746
|
if (createdSession) {
|
|
728
747
|
if (isForkedSessionTitle(createdSession.title)) {
|
|
@@ -758,13 +777,18 @@ export const createSessionTitleRuntime = ({
|
|
|
758
777
|
initialRefreshes.add(status.sessionId);
|
|
759
778
|
armTimer(status.sessionId, status.directory || directoryHint, 0);
|
|
760
779
|
}
|
|
761
|
-
} else {
|
|
780
|
+
} else if (!initialRefreshes.has(status.sessionId)) {
|
|
762
781
|
clearTimer(status.sessionId);
|
|
763
782
|
}
|
|
764
783
|
return;
|
|
765
784
|
}
|
|
766
785
|
const userMessage = extractUserMessage(payload);
|
|
767
786
|
if (userMessage) {
|
|
787
|
+
if (userMessage.initialRecord && newSessions.delete(userMessage.sessionId)) {
|
|
788
|
+
initialRefreshes.add(userMessage.sessionId);
|
|
789
|
+
initialUserMessages.set(userMessage.sessionId, userMessage.initialRecord);
|
|
790
|
+
armTimer(userMessage.sessionId, userMessage.directory || directoryHint, 0);
|
|
791
|
+
}
|
|
768
792
|
const pendingFork = forkFirstSendPending.get(userMessage.sessionId);
|
|
769
793
|
if (pendingFork && userMessage.createdAt > pendingFork.createdAt) {
|
|
770
794
|
pendingFork.hasNewUserMessage = true;
|
|
@@ -780,7 +804,7 @@ export const createSessionTitleRuntime = ({
|
|
|
780
804
|
// session settles. Only a message created after the timer was armed
|
|
781
805
|
// means the user actually moved on.
|
|
782
806
|
const armed = timers.get(userMessage.sessionId);
|
|
783
|
-
if (armed && userMessage.createdAt >= armed.armedAt) {
|
|
807
|
+
if (armed && !initialRefreshes.has(userMessage.sessionId) && userMessage.createdAt >= armed.armedAt) {
|
|
784
808
|
clearTimer(userMessage.sessionId);
|
|
785
809
|
}
|
|
786
810
|
}
|
|
@@ -792,6 +816,7 @@ export const createSessionTitleRuntime = ({
|
|
|
792
816
|
clearTimeout(timer);
|
|
793
817
|
}
|
|
794
818
|
timers.clear();
|
|
819
|
+
initialUserMessages.clear();
|
|
795
820
|
};
|
|
796
821
|
|
|
797
822
|
return { processPayload, stop, armTimer };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
|
|
2
|
+
import { projectSessionWithHostMetadata } from '../session-metadata/session-projection.js';
|
|
3
|
+
import { projectSessionMessageRecords } from '../session-turn-pages/session-message-projection.js';
|
|
4
|
+
|
|
5
|
+
/** Title reads use OpenCode; title-refresh metadata and its notifications belong to the Host. */
|
|
6
|
+
export function createTitleSessionAccess({
|
|
7
|
+
buildOpenCodeUrl,
|
|
8
|
+
getOpenCodeAuthHeaders,
|
|
9
|
+
readSessionMetadata,
|
|
10
|
+
persistSessionMetadata,
|
|
11
|
+
publishSession,
|
|
12
|
+
}) {
|
|
13
|
+
const client = () => makeOpenCodeV2Client({
|
|
14
|
+
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
15
|
+
authHeaders: getOpenCodeAuthHeaders(),
|
|
16
|
+
});
|
|
17
|
+
const options = () => ({ signal: AbortSignal.timeout(30_000) });
|
|
18
|
+
const get = async (sessionID) => {
|
|
19
|
+
const session = await client().session.get({ sessionID }, options());
|
|
20
|
+
return projectSessionWithHostMetadata(session, await readSessionMetadata(sessionID));
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
get,
|
|
24
|
+
async messages(sessionID, _directory, limit) {
|
|
25
|
+
const page = await client().message.list({ sessionID, limit, order: 'desc' }, options());
|
|
26
|
+
if (!Array.isArray(page?.data)) throw new Error('Session title: invalid message page');
|
|
27
|
+
return projectSessionMessageRecords([...page.data].reverse());
|
|
28
|
+
},
|
|
29
|
+
async update(sessionID, directory, patch) {
|
|
30
|
+
if (typeof patch.title === 'string') {
|
|
31
|
+
await client().session.update({ sessionID, title: patch.title }, options());
|
|
32
|
+
}
|
|
33
|
+
// Never write the complete metadata snapshot back: goal/archive/ownership
|
|
34
|
+
// may have changed while the title model was running.
|
|
35
|
+
const refresh = patch.metadata?.openchamber?.titleRefresh;
|
|
36
|
+
if (refresh) {
|
|
37
|
+
await persistSessionMetadata(sessionID, { openchamber: { titleRefresh: refresh } });
|
|
38
|
+
}
|
|
39
|
+
if (typeof patch.title === 'string' || (refresh && Object.hasOwn(refresh, 'isGenerating'))) {
|
|
40
|
+
const session = await get(sessionID);
|
|
41
|
+
await publishSession(session, directory);
|
|
42
|
+
return session;
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
import { mergeMetadataPatch } from '../session-metadata/session-metadata-store.js';
|
|
3
|
+
|
|
4
|
+
const { api } = vi.hoisted(() => ({ api: {
|
|
5
|
+
session: { get: vi.fn(), update: vi.fn() },
|
|
6
|
+
message: { list: vi.fn() },
|
|
7
|
+
} }));
|
|
8
|
+
vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: () => api }));
|
|
9
|
+
import { createTitleSessionAccess } from './session-access.js';
|
|
10
|
+
|
|
11
|
+
beforeEach(() => vi.resetAllMocks());
|
|
12
|
+
|
|
13
|
+
function fixture() {
|
|
14
|
+
let metadata = { openchamber: {
|
|
15
|
+
goal: { status: 'active' },
|
|
16
|
+
titleRefresh: { isGenerating: true, requestedAt: 1000, lastError: 'old failure', failedAt: 1 },
|
|
17
|
+
} };
|
|
18
|
+
api.session.get.mockResolvedValue({
|
|
19
|
+
id: 'ses_title', title: 'Updated title', location: { directory: '/repo' },
|
|
20
|
+
time: { created: 10, updated: 20 },
|
|
21
|
+
});
|
|
22
|
+
const persist = vi.fn(async (_id, patch) => { metadata = mergeMetadataPatch(metadata, patch); });
|
|
23
|
+
const publish = vi.fn();
|
|
24
|
+
const access = createTitleSessionAccess({
|
|
25
|
+
buildOpenCodeUrl: (path) => `http://opencode${path}`,
|
|
26
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
27
|
+
readSessionMetadata: async () => metadata,
|
|
28
|
+
persistSessionMetadata: persist,
|
|
29
|
+
publishSession: publish,
|
|
30
|
+
});
|
|
31
|
+
return { access, persist, publish, metadata: () => metadata };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it('publishes the full authoritative title row and clears Host loading without overwriting unrelated state', async () => {
|
|
35
|
+
const f = fixture();
|
|
36
|
+
await f.access.update('ses_title', '/repo', {
|
|
37
|
+
title: 'Updated title',
|
|
38
|
+
metadata: { openchamber: { goal: { status: 'stale' }, titleRefresh: {
|
|
39
|
+
lastAutoTitle: 'Updated title', isGenerating: null, requestedAt: null, lastError: null, failedAt: null,
|
|
40
|
+
} } },
|
|
41
|
+
});
|
|
42
|
+
expect(api.session.update.mock.calls[0][0]).toEqual({ sessionID: 'ses_title', title: 'Updated title' });
|
|
43
|
+
expect(f.metadata()).toEqual({ openchamber: {
|
|
44
|
+
goal: { status: 'active' }, titleRefresh: { lastAutoTitle: 'Updated title' },
|
|
45
|
+
} });
|
|
46
|
+
expect(f.publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
47
|
+
id: 'ses_title', title: 'Updated title', directory: '/repo', metadata: f.metadata(),
|
|
48
|
+
}), '/repo');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('projects only the bounded newest message page in conversation order', async () => {
|
|
52
|
+
const f = fixture();
|
|
53
|
+
api.message.list.mockResolvedValue({ data: [
|
|
54
|
+
{ id: 'a1', type: 'assistant', content: [{ type: 'text', text: 'Finished' }] },
|
|
55
|
+
{ id: 'u1', type: 'user', text: 'Implement title refresh' },
|
|
56
|
+
] });
|
|
57
|
+
const messages = await f.access.messages('ses_title', '/repo', 16);
|
|
58
|
+
expect(api.message.list.mock.calls[0][0]).toEqual({ sessionID: 'ses_title', limit: 16, order: 'desc' });
|
|
59
|
+
expect(messages.map((row) => row.info.id)).toEqual(['u1', 'a1']);
|
|
60
|
+
expect(messages[0].parts[0].text).toBe('Implement title refresh');
|
|
61
|
+
expect(messages[1].parts[0].text).toBe('Finished');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('publishes a full loading row before the new session exists in client catalogs', async () => {
|
|
65
|
+
const f = fixture();
|
|
66
|
+
await f.access.update('ses_title', '/repo', { metadata: { openchamber: { titleRefresh: { isGenerating: true } } } });
|
|
67
|
+
expect(f.publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
68
|
+
id: 'ses_title', directory: '/repo', metadata: expect.objectContaining({
|
|
69
|
+
openchamber: expect.objectContaining({ titleRefresh: expect.objectContaining({ isGenerating: true }) }),
|
|
70
|
+
}),
|
|
71
|
+
}), '/repo');
|
|
72
|
+
expect(api.session.update).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not publish success on a failed title write', async () => {
|
|
76
|
+
const f = fixture();
|
|
77
|
+
api.session.update.mockRejectedValue(new Error('offline'));
|
|
78
|
+
await expect(f.access.update('ses_title', '/repo', {
|
|
79
|
+
title: 'Failed', metadata: { openchamber: { titleRefresh: { lastAutoTitle: 'Failed' } } },
|
|
80
|
+
})).rejects.toThrow('offline');
|
|
81
|
+
expect(f.persist).not.toHaveBeenCalled();
|
|
82
|
+
expect(f.publish).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not turn a failed or malformed message read into empty success', async () => {
|
|
86
|
+
const f = fixture();
|
|
87
|
+
api.message.list.mockRejectedValueOnce(new Error('offline'));
|
|
88
|
+
await expect(f.access.messages('ses_title', '/repo', 16)).rejects.toThrow('offline');
|
|
89
|
+
api.message.list.mockResolvedValueOnce({});
|
|
90
|
+
await expect(f.access.messages('ses_title', '/repo', 16)).rejects.toThrow('invalid message page');
|
|
91
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
OpenChamber-owned turn-window pagination and anchor reconcile for session messages.
|
|
6
6
|
|
|
7
|
-
1. **Turn pages** — clients request the last N **authored user turns** (default 3) rather than a raw message count. The host loops
|
|
7
|
+
1. **Turn pages** — clients request the last N **authored user turns** (default 3) rather than a raw message count. The host loops OpenCode v2 `message.list` pages (first page `limit` + `order=desc`; continuation `limit` + `cursor` from the previous body `cursor.next` — upstream rejects `order` together with `cursor` with 400) until the turn budget is met or history is exhausted.
|
|
8
8
|
2. **Anchor reconcile** — after SSE reconnect, clients request the gap from a stable turn-boundary anchor to the current head. The host scans newest→older, returns chronological gap records (including the anchor's overlap turn), and paginates via host-owned continuation tokens with page/byte/total budgets.
|
|
9
9
|
|
|
10
10
|
Exposed as:
|
|
@@ -16,7 +16,7 @@ Exposed as:
|
|
|
16
16
|
|
|
17
17
|
## Why this exists
|
|
18
18
|
|
|
19
|
-
Official OpenCode pagination is message-count based and uses **opaque** cursors (not message ids).
|
|
19
|
+
Official OpenCode pagination is message-count based and uses **opaque** cursors (not message ids). v2 pages arrive **newest-first**; `fetchPage` reverses each page to chronological **old→new** before aggregation. UI prepend/load-more needs a stable turn boundary so synthetic loop messages, subtasks, and compaction rows do not consume the turn budget. This module aggregates upstream pages on the host, returns chronological records, and exposes a continuous **host-owned opaque cursor** that points just before the earliest returned authored user.
|
|
20
20
|
|
|
21
21
|
## Scope
|
|
22
22
|
|
|
@@ -66,7 +66,7 @@ Success `200`:
|
|
|
66
66
|
}
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
- `records`: chronological oldest → newest, including intermediate non-boundary rows (synthetic, tools, subtask, compaction) inside the window.
|
|
69
|
+
- `records`: chronological oldest → newest, including intermediate non-boundary rows (synthetic, tools, subtask, compaction) inside the window. Host projection drops v2 **control** SessionMessage rows (`idle`, `model-switched`, `agent-selected`, `agent-switched`, `location-switched`) so they never reach ChatMessage as empty Assistant headers. Native assistants project `content[]` once (never also top-level `text` when `content` is present). Wire `model.id` becomes domain `modelID`; `finish`/`error` on failed assistants (e.g. `provider.auth` 401) are preserved.
|
|
70
70
|
- `turnCount`: count of authored user boundaries in `records`.
|
|
71
71
|
- `cursor`: host-owned opaque token for the next `before=` request; `null` when `complete` is true. The token represents the position **just before** the earliest returned authored user (so the client can load older history without overlap).
|
|
72
72
|
- `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, or when upstream stopped on a full page with a missing `nextCursor` (suspicious truncation), `complete` stays `false` with a `cursor` so the client can fetch / retry.
|
|
@@ -126,7 +126,7 @@ On resume:
|
|
|
126
126
|
1. Strict decode / shape / length validation of the host token.
|
|
127
127
|
2. First `fetchPage` uses the token’s raw upstream `before`.
|
|
128
128
|
3. Locate `boundaryID` in that page; keep only records strictly older than it (`slice(0, index)` in old→new order). Boundary and newer rows are excluded.
|
|
129
|
-
4. If fewer than N turns remain, continue with that raw page’s `
|
|
129
|
+
4. If fewer than N turns remain, continue with that raw page’s `cursor.next` for older pages.
|
|
130
130
|
5. Each accumulated record records its upstream page’s request-before origin; when `complete=false`, the next host cursor encodes the earliest selected authored user’s origin + id.
|
|
131
131
|
|
|
132
132
|
A raw OpenCode SDK cursor (no `oc1.` prefix) is still accepted as the first `before` and is passed through unchanged to `fetchPage`.
|
|
@@ -146,14 +146,15 @@ A message is an authored user turn boundary when:
|
|
|
146
146
|
|
|
147
147
|
## Aggregation rules
|
|
148
148
|
|
|
149
|
-
-
|
|
150
|
-
- Continues paging with raw upstream `
|
|
151
|
-
- **Suspicious truncation:** an upstream page whose `records.length >= scanLimit` (full page) but `nextCursor == null` is treated as a non-authoritative cut (observed OpenCode
|
|
149
|
+
- `fetchPage` returns each page chronological **old→new** (v2 `data` is newest-first and is reversed there). Older pages are **prepended** while deduping by `info.id` (global order remains old→new).
|
|
150
|
+
- Continues paging with the raw upstream `cursor.next` (sent as `cursor`, never with `order`) until `turns` authored boundaries are collected or upstream reports no next cursor.
|
|
151
|
+
- **Suspicious truncation:** an upstream page whose `records.length >= scanLimit` (full page) but `nextCursor == null` is treated as a non-authoritative cut (observed OpenCode next-cursor race), not as end-of-history. The host stops scanning that round with `upstreamComplete=false`, so the response stays `complete=false` and still emits a boundary host cursor the client can retry. A short page (`records.length < scanLimit`) with no cursor, an empty page with no cursor, or an explicit `page.complete === true` remain authoritative exhaustion. Client no-growth handling still bounds retries if the upstream view stays truncated.
|
|
152
152
|
- Every upstream record must have a non-empty `info.id`; otherwise structured error, no partial `records`, HTTP 502.
|
|
153
153
|
- Hard scan caps (no partial success): **50 pages** / **5000 messages** → structured error, HTTP 413.
|
|
154
154
|
- Repeated / stalled cursor, or empty page that still carries a next cursor → structured error, no partial `records`, HTTP 502.
|
|
155
155
|
- Malformed upstream payloads and SDK/transport failures → HTTP 502 (`error: "upstream"`).
|
|
156
|
-
- `
|
|
156
|
+
- Native v2 `SessionMessageInfo` rows are projected to the external `{ info, parts }` record contract by `session-message-projection.js` (control rows such as `idle` / `model-switched` are dropped). The VS Code turn-page bridge imports the same module so both runtimes emit identical records.
|
|
157
|
+
- `AbortSignal` is forwarded to each `message.list` call; route also applies a 45s linked timeout.
|
|
157
158
|
- Request abort only when the client disconnects mid-flight: `req` aborted, or `res` `close` while `!res.writableEnded`. A normal GET request `close` after the response has ended must not abort.
|
|
158
159
|
|
|
159
160
|
## Anchor reconcile API
|