@openchambery/web 2.0.0-beta.1 → 2.0.0-beta.10
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-CzTtVMnI.js} +1 -1
- package/dist/assets/AssistantView-Cg1qQvVe.js +3 -0
- package/dist/assets/{DiagramView-xuSJTnv7.js → DiagramView-hOi72Wdx.js} +1 -1
- package/dist/assets/{FadeInOnReveal-Ov0D96d0.js → FadeInOnReveal-DCqrQkkz.js} +1 -1
- package/dist/assets/Icon-B8fSTLdP.js +1 -0
- package/dist/assets/MarkdownRenderer-Cw74oVxV.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-SKi4oG0c.js → MarkdownRendererImpl-YM7iRmzK.js} +3 -3
- package/dist/assets/MarkstreamRendererImpl-BDy_oGJQ.js +3 -0
- package/dist/assets/{MobileDetailNavigation-Bnx59U_J.js → MobileDetailNavigation-iX_hb_Xt.js} +1 -1
- package/dist/assets/{MobileShareBridge-BQlP4J-F.js → MobileShareBridge-CAMUupwR.js} +2 -2
- package/dist/assets/MobileSurface-CHbdPcLH.js +2 -0
- package/dist/assets/{MultiRunWindow-ChZGob0v.js → MultiRunWindow-Ci1ODFFX.js} +1 -1
- package/dist/assets/{OnboardingScreen-DtQJxZG-.js → OnboardingScreen-O4cGdz5p.js} +1 -1
- package/dist/assets/OverlayScrollbar-CRfGtcmG.js +2 -0
- package/dist/assets/SettingsView-Dg8BtTV1.js +25 -0
- package/dist/assets/{SettingsWindow-DfnsFa8y.js → SettingsWindow-B8d8qIQ7.js} +1 -1
- package/dist/assets/TerminalView-CMiw3s_c.js +8 -0
- package/dist/assets/{ToolOutputDialog-BejmjrRk.js → ToolOutputDialog-ByaQYRTp.js} +2 -2
- package/dist/assets/appThemeRegistry-BGGHio8T.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-DIIrsMjW.js} +1 -1
- package/dist/assets/{collapsible-BXRd5_Hr.js → collapsible-CnvUeYst.js} +1 -1
- package/dist/assets/{context-menu-jGtH6fR9.js → context-menu-BGBUpZEQ.js} +1 -1
- package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-JGFqhlaM.js} +1 -1
- package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-CHEwrI93.js} +1 -1
- package/dist/assets/{diagram-DQf7Y_uR.js → diagram-rvn7_qBM.js} +1 -1
- package/dist/assets/{dialog-bfbvR4VH.js → dialog-CQ63sc9E.js} +1 -1
- package/dist/assets/es-Dsmc0A_3.js +15 -0
- package/dist/assets/fileMentionTouchSelection-BzzcWJCi.js +3 -0
- package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-DN6gbLhM.js} +1 -1
- package/dist/assets/fr-C_ZlZDI2.js +13 -0
- package/dist/assets/gitApi-BF8vuoZc.js +10 -0
- package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-BMztRu0c.js} +1 -1
- package/dist/assets/{input-8r6F_Fza.js → input-BVu-iykl.js} +1 -1
- package/dist/assets/insertionBoundaries-EcAfGT2D.js +1 -0
- package/dist/assets/ja-BqqTaWTi.js +15 -0
- package/dist/assets/ko-DP2XPM37.js +15 -0
- package/dist/assets/{main-CCE_4y5F.js → main-B7t9lavz.js} +2 -2
- package/dist/assets/main-D2BzpnUt.css +1 -0
- package/dist/assets/main-DWP-prNB.js +169 -0
- package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
- package/dist/assets/{markdownHeightCache-1Qo6y6IJ.js → markdownHeightCache-D9-B1wdY.js} +1 -1
- package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
- package/dist/assets/mcpQueries-CfXrAkuU.js +1 -0
- package/dist/assets/miniChat-DgOrEQbN.js +2 -0
- package/dist/assets/mobile-Co_ZOHkZ.js +2 -0
- package/dist/assets/multirun-DelyUO6k.js +4 -0
- package/dist/assets/persistence-D0y7226-.js +3 -0
- package/dist/assets/pl-BCf5uLlK.js +15 -0
- package/dist/assets/platform-CurqO28D.js +6 -0
- package/dist/assets/projectMeta-CPElzJ0n.js +3 -0
- package/dist/assets/pt-BR-W09Q2pvn.js +15 -0
- package/dist/assets/{relay-image-stream-DM7atqJs.js → relay-image-stream-DrdVj99c.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-pOE93w86.js +2 -0
- package/dist/assets/renderMobileApp-F7ZSLcw8.js +28 -0
- package/dist/assets/runtime-fetch-Fs9ZV2V8.js +1 -0
- package/dist/assets/{runtimeConfig-BMvqa5we.js → runtimeConfig-DSS0KxG3.js} +1 -1
- package/dist/assets/runtimeEndpointReset-x2Wgx57R.js +149 -0
- package/dist/assets/sessionChangeSummary-BUGSYcHp.js +2 -0
- package/dist/assets/{sessionLookup-gh-hVRIc.js → sessionLookup-Bss8M20i.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CudoSgV4.js → sortable-tabs-strip-CE7W7lJ9.js} +1 -1
- package/dist/assets/src-lS1keDVV.css +2 -0
- package/dist/assets/store-jaP79mqI.js +15 -0
- package/dist/assets/{terminalApi-DiF6JmBP.js → terminalApi-mDR2jXTo.js} +1 -1
- package/dist/assets/textarea-XD2oX_Ee.js +1 -0
- package/dist/assets/{tooltip-CQvpK-UV.js → tooltip-DYeQ_Hvy.js} +1 -1
- package/dist/assets/uk-BgFzz8m2.js +15 -0
- package/dist/assets/{url-_z7evzdK.js → url-CGbJbiJq.js} +1 -1
- package/dist/assets/{useAppFontEffects-Cpmpo7Wb.js → useAppFontEffects-BAWHZ-t0.js} +64 -45
- package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-hN_nLb7T.js +1 -0
- package/dist/assets/useEffectiveDirectory-Do9DQ453.js +1 -0
- package/dist/assets/{useFontPreferences-DcbXQOkc.js → useFontPreferences-Bjx8zkGM.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BsY_gi_K.js +414 -0
- package/dist/assets/useSessionAutoCleanup-C4g5KmDf.js +2 -0
- package/dist/assets/useUIStore-syVmbuDA.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-Bqn4AiBL.js → useWorkerHighlightedLines-B5QXxBok.js} +2 -2
- package/dist/assets/vendor-opencode-client-C12gfjag.js +12 -0
- package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-Dq5ocTF8.js} +1 -1
- package/dist/assets/zh-CN-BhqSLgUY.js +15 -0
- package/dist/assets/zh-TW-BsaBgY-p.js +15 -0
- package/dist/index.html +12 -12
- package/dist/mini-chat.html +12 -12
- package/dist/mobile.html +12 -12
- package/package.json +3 -2
- package/server/index.js +359 -63
- 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/browser-provider/DOCUMENTATION.md +141 -0
- package/server/lib/browser-provider/contract.js +312 -0
- package/server/lib/browser-provider/host.js +421 -0
- package/server/lib/browser-provider/host.test.js +304 -0
- package/server/lib/browser-provider/routes.js +122 -0
- package/server/lib/browser-provider/routes.test.js +120 -0
- package/server/lib/browser-provider/selection-store.js +43 -0
- package/server/lib/browser-provider/selection-store.test.js +35 -0
- 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/DOCUMENTATION.md +3 -1
- package/server/lib/notifications/runtime.js +133 -31
- package/server/lib/notifications/runtime.test.js +103 -0
- package/server/lib/notifications/template-runtime.js +130 -38
- package/server/lib/notifications/template-runtime.test.js +96 -0
- package/server/lib/opencode/DOCUMENTATION.md +89 -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.d.ts +8 -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 +53 -4
- package/server/lib/opencode/feature-routes-runtime.test.js +25 -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 +254 -46
- package/server/lib/opencode/proxy.test.js +238 -5
- package/server/lib/opencode/routes.js +326 -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/upgrade-screen.d.ts +30 -0
- package/server/lib/opencode/upgrade-screen.js +299 -0
- package/server/lib/opencode/upgrade-screen.test.js +196 -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 +73 -18
- package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
- package/server/lib/session-metadata/opencode-session-record.js +316 -0
- package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
- package/server/lib/session-metadata/routes.js +112 -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 +38 -0
- package/server/lib/session-metadata/session-metadata-store.js +270 -83
- 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 +279 -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 +69 -44
- 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/Icon-DD-BuMfZ.js +0 -1
- package/dist/assets/MarkdownRenderer-bc_p1H7l.js +0 -2
- 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/TerminalView-Dh9MWCpS.js +0 -8
- package/dist/assets/appThemeRegistry-COGfxTFV.js +0 -1
- package/dist/assets/es-c52LTHHe.js +0 -15
- package/dist/assets/fileMentionTouchSelection-BvrNCroG.js +0 -4
- 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/main-qSeeJRW1.css +0 -1
- package/dist/assets/markdown-worker-CptoICmq.js +0 -2
- package/dist/assets/markdownTheme-DOfGg-A7.js +0 -1
- 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/persistence-Ce3AnpEr.js +0 -3
- 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/vendor-opencode-client-BOe2qpQb.js +0 -12
- 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,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
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
projectSlimParts,
|
|
19
19
|
SLIM_PARTS_PROJECTION,
|
|
20
20
|
} from './service.js';
|
|
21
|
+
import {
|
|
22
|
+
projectSessionMessage,
|
|
23
|
+
projectSessionMessageRecords as projectRecords,
|
|
24
|
+
} from './session-message-projection.js';
|
|
21
25
|
|
|
22
26
|
const TURNS_MIN = 1;
|
|
23
27
|
const TURNS_MAX = 10;
|
|
@@ -210,103 +214,6 @@ const requestSignal = (req, res) => {
|
|
|
210
214
|
return controller.signal;
|
|
211
215
|
};
|
|
212
216
|
|
|
213
|
-
/**
|
|
214
|
-
* Native SessionMessageInfo carrier fields that become `parts`.
|
|
215
|
-
* Must not remain on `info` after normalize — otherwise
|
|
216
|
-
* includeReasoning=false can strip parts while leaking content/text.
|
|
217
|
-
*/
|
|
218
|
-
const MESSAGE_PART_CARRIER_KEYS = new Set(['content', 'text', 'files', 'parts', 'agents', 'skills']);
|
|
219
|
-
|
|
220
|
-
const stripMessagePartCarriers = (value) => {
|
|
221
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
222
|
-
let changed = false;
|
|
223
|
-
const next = {};
|
|
224
|
-
for (const [key, field] of Object.entries(value)) {
|
|
225
|
-
if (MESSAGE_PART_CARRIER_KEYS.has(key)) {
|
|
226
|
-
changed = true;
|
|
227
|
-
continue;
|
|
228
|
-
}
|
|
229
|
-
next[key] = field;
|
|
230
|
-
}
|
|
231
|
-
return changed ? next : value;
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Project SessionMessageInfo (or an already-projected {info, parts} row)
|
|
236
|
-
* into the host HTTP DTO. External route contract stays {info, parts}.
|
|
237
|
-
*/
|
|
238
|
-
const projectV2Parts = (entry) => {
|
|
239
|
-
if (Array.isArray(entry?.parts)) return entry.parts;
|
|
240
|
-
const messageID = typeof entry?.id === 'string' ? entry.id : '';
|
|
241
|
-
const sessionID = typeof entry?.sessionID === 'string' ? entry.sessionID : '';
|
|
242
|
-
const parts = [];
|
|
243
|
-
if (typeof entry?.text === 'string' && entry.text) {
|
|
244
|
-
parts.push({ id: `${messageID}:text`, sessionID, messageID, type: 'text', text: entry.text });
|
|
245
|
-
}
|
|
246
|
-
if (Array.isArray(entry?.files)) {
|
|
247
|
-
entry.files.forEach((file, index) => {
|
|
248
|
-
parts.push({
|
|
249
|
-
id: `${messageID}:file:${index}`,
|
|
250
|
-
sessionID,
|
|
251
|
-
messageID,
|
|
252
|
-
type: 'file',
|
|
253
|
-
mime: file?.mime || 'application/octet-stream',
|
|
254
|
-
url: file?.uri ?? file?.url,
|
|
255
|
-
...(file?.name ? { filename: file.name } : {}),
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
if (Array.isArray(entry?.content)) {
|
|
260
|
-
entry.content.forEach((item, index) => {
|
|
261
|
-
const partID = item?.id || `${messageID}:content:${index}`;
|
|
262
|
-
if (item?.type === 'text') {
|
|
263
|
-
parts.push({ id: partID, sessionID, messageID, type: 'text', text: item.text ?? '' });
|
|
264
|
-
} else if (item?.type === 'reasoning') {
|
|
265
|
-
parts.push({ id: partID, sessionID, messageID, type: 'reasoning', text: item.text ?? '' });
|
|
266
|
-
} else if (item?.type === 'tool') {
|
|
267
|
-
parts.push({
|
|
268
|
-
id: partID,
|
|
269
|
-
sessionID,
|
|
270
|
-
messageID,
|
|
271
|
-
type: 'tool',
|
|
272
|
-
tool: item.name,
|
|
273
|
-
callID: item.id,
|
|
274
|
-
state: item.state ?? {},
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
return parts;
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
const projectSessionMessage = (entry) => {
|
|
283
|
-
if (entry && typeof entry === 'object' && entry.info && typeof entry.info === 'object') {
|
|
284
|
-
const info = stripMessagePartCarriers(entry.info);
|
|
285
|
-
return {
|
|
286
|
-
info: info === entry.info ? entry.info : info,
|
|
287
|
-
parts: Array.isArray(entry.parts) ? entry.parts : [],
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string' || entry.id.length === 0) {
|
|
291
|
-
return entry;
|
|
292
|
-
}
|
|
293
|
-
const role = entry.type === 'user' || entry.type === 'assistant' ? entry.type : (entry.role ?? entry.type);
|
|
294
|
-
const info = {
|
|
295
|
-
...stripMessagePartCarriers(entry),
|
|
296
|
-
id: entry.id,
|
|
297
|
-
sessionID: entry.sessionID,
|
|
298
|
-
role,
|
|
299
|
-
time: entry.time ?? { created: 0 },
|
|
300
|
-
};
|
|
301
|
-
return {
|
|
302
|
-
info,
|
|
303
|
-
parts: projectV2Parts(entry),
|
|
304
|
-
};
|
|
305
|
-
};
|
|
306
|
-
|
|
307
|
-
const projectRecords = (records) =>
|
|
308
|
-
(Array.isArray(records) ? records : []).map(projectSessionMessage);
|
|
309
|
-
|
|
310
217
|
/**
|
|
311
218
|
* Host outbound order: normalize → slim/L1 bounds → reasoning strip.
|
|
312
219
|
* Slim/reasoning must run on `{info, parts}` so native `content` cannot
|
|
@@ -327,11 +234,12 @@ const createV2FetchPage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger })
|
|
|
327
234
|
const headers = typeof getOpenCodeAuthHeaders === 'function' ? getOpenCodeAuthHeaders() : {};
|
|
328
235
|
const client = makeOpenCodeV2Client({ baseUrl, authHeaders: headers });
|
|
329
236
|
|
|
237
|
+
// Upstream rejects `order` together with `cursor` (400); the cursor
|
|
238
|
+
// already encodes the desc direction of the first page.
|
|
330
239
|
const result = await client.message.list({
|
|
331
240
|
sessionID,
|
|
332
241
|
...(Number.isFinite(limit) ? { limit } : {}),
|
|
333
|
-
order: 'desc',
|
|
334
|
-
...(typeof before === 'string' && before.length > 0 ? { cursor: before } : {}),
|
|
242
|
+
...(typeof before === 'string' && before.length > 0 ? { cursor: before } : { order: 'desc' }),
|
|
335
243
|
}, { signal });
|
|
336
244
|
|
|
337
245
|
const items = Array.isArray(result?.data) ? result.data : null;
|
|
@@ -342,8 +250,8 @@ const createV2FetchPage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger })
|
|
|
342
250
|
throw error;
|
|
343
251
|
}
|
|
344
252
|
|
|
345
|
-
const
|
|
346
|
-
const nextCursor = typeof
|
|
253
|
+
const next = result?.cursor?.next;
|
|
254
|
+
const nextCursor = typeof next === 'string' && next.length > 0 ? next : null;
|
|
347
255
|
|
|
348
256
|
return {
|
|
349
257
|
records: items.slice().reverse(),
|
|
@@ -418,7 +326,14 @@ const createSdkFetchMessage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logge
|
|
|
418
326
|
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
419
327
|
throwUpstream(logger, '[session-turn-pages] session.message malformed payload');
|
|
420
328
|
}
|
|
421
|
-
|
|
329
|
+
const projected = projectSessionMessage(data);
|
|
330
|
+
// Control rows (idle / model-switched) are not chat messages.
|
|
331
|
+
if (!projected || !projected.info) {
|
|
332
|
+
const notFound = new Error('not_found');
|
|
333
|
+
notFound.code = 'not_found';
|
|
334
|
+
throw notFound;
|
|
335
|
+
}
|
|
336
|
+
return projected;
|
|
422
337
|
};
|
|
423
338
|
};
|
|
424
339
|
|
|
@@ -2,6 +2,10 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
|
|
4
4
|
import { registerSessionTurnPageRoutes } from './routes.js';
|
|
5
|
+
import {
|
|
6
|
+
projectSessionMessage as projectSessionMessageRecord,
|
|
7
|
+
projectSessionMessageRecords,
|
|
8
|
+
} from './session-message-projection.js';
|
|
5
9
|
|
|
6
10
|
vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: vi.fn() }));
|
|
7
11
|
|
|
@@ -33,6 +37,85 @@ const response = () => ({
|
|
|
33
37
|
|
|
34
38
|
const ROUTE = '/api/openchamber/sessions/:sessionID/messages';
|
|
35
39
|
|
|
40
|
+
describe('projectSessionMessageRecord (host SessionMessage → {info, parts})', () => {
|
|
41
|
+
it('drops idle control and projects two-turn wire without duplicate text', () => {
|
|
42
|
+
const records = projectSessionMessageRecords([
|
|
43
|
+
{
|
|
44
|
+
id: 'msg_u1',
|
|
45
|
+
type: 'user',
|
|
46
|
+
time: { created: 1 },
|
|
47
|
+
text: 'UI-E2E-FINAL-BUILD',
|
|
48
|
+
sessionID: 'ses_1',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'msg_a1',
|
|
52
|
+
type: 'assistant',
|
|
53
|
+
time: { created: 2, completed: 3 },
|
|
54
|
+
sessionID: 'ses_1',
|
|
55
|
+
agent: 'build',
|
|
56
|
+
model: { id: 'glm-5.3-flash', providerID: 'zai-coding-plan', variant: 'default' },
|
|
57
|
+
content: [
|
|
58
|
+
{ type: 'reasoning', text: 'think' },
|
|
59
|
+
{ type: 'text', text: 'FINAL_BUILD_OK' },
|
|
60
|
+
],
|
|
61
|
+
finish: 'stop',
|
|
62
|
+
rawFinish: 'stop',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'msg_u2',
|
|
66
|
+
type: 'user',
|
|
67
|
+
time: { created: 4 },
|
|
68
|
+
text: 'UI-E2E-FINAL-FOLLOWUP',
|
|
69
|
+
sessionID: 'ses_1',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'msg_a2',
|
|
73
|
+
type: 'assistant',
|
|
74
|
+
time: { created: 5, completed: 6 },
|
|
75
|
+
sessionID: 'ses_1',
|
|
76
|
+
agent: 'build',
|
|
77
|
+
model: { id: 'glm-5.3-flash', providerID: 'zai-coding-plan', variant: 'default' },
|
|
78
|
+
text: 'FINAL_FOLLOWUP_OK',
|
|
79
|
+
content: [
|
|
80
|
+
{ type: 'reasoning', text: 'think2' },
|
|
81
|
+
{ type: 'text', text: 'FINAL_FOLLOWUP_OK' },
|
|
82
|
+
],
|
|
83
|
+
finish: 'stop',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: 'msg_idle',
|
|
87
|
+
type: 'idle',
|
|
88
|
+
outcome: 'succeeded',
|
|
89
|
+
time: { created: 7 },
|
|
90
|
+
},
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
expect(records.map((row) => [row.info.role, row.info.id])).toEqual([
|
|
94
|
+
['user', 'msg_u1'],
|
|
95
|
+
['assistant', 'msg_a1'],
|
|
96
|
+
['user', 'msg_u2'],
|
|
97
|
+
['assistant', 'msg_a2'],
|
|
98
|
+
]);
|
|
99
|
+
expect(projectSessionMessageRecord({
|
|
100
|
+
id: 'msg_idle2',
|
|
101
|
+
type: 'idle',
|
|
102
|
+
outcome: 'failed',
|
|
103
|
+
time: { created: 1 },
|
|
104
|
+
})).toBeNull();
|
|
105
|
+
|
|
106
|
+
const follow = records[3];
|
|
107
|
+
expect(follow.parts.filter((part) => part.type === 'text')).toHaveLength(1);
|
|
108
|
+
expect(follow.parts.find((part) => part.type === 'text').text).toBe('FINAL_FOLLOWUP_OK');
|
|
109
|
+
expect(follow.info.model).toEqual({
|
|
110
|
+
providerID: 'zai-coding-plan',
|
|
111
|
+
modelID: 'glm-5.3-flash',
|
|
112
|
+
variant: 'default',
|
|
113
|
+
});
|
|
114
|
+
expect(follow.info.type).toBeUndefined();
|
|
115
|
+
expect(follow.info.rawFinish).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
36
119
|
describe('registerSessionTurnPageRoutes', () => {
|
|
37
120
|
it('registers GET /api/openchamber/sessions/:sessionID/messages', () => {
|
|
38
121
|
const { app, route } = registry();
|
|
@@ -737,6 +820,204 @@ describe('registerSessionTurnPageRoutes', () => {
|
|
|
737
820
|
]);
|
|
738
821
|
});
|
|
739
822
|
|
|
823
|
+
it('default fetch continues desc pages with cursor.next only, never order + cursor', async () => {
|
|
824
|
+
const list = vi.fn()
|
|
825
|
+
.mockResolvedValueOnce({
|
|
826
|
+
data: [{ id: 'msg_a2', type: 'assistant', time: { created: 4, completed: 5 }, content: [{ type: 'text', text: 'later' }] }],
|
|
827
|
+
cursor: { previous: 'raw_prev', next: 'raw_next' },
|
|
828
|
+
})
|
|
829
|
+
.mockResolvedValueOnce({
|
|
830
|
+
data: [
|
|
831
|
+
{ id: 'msg_a1', type: 'assistant', time: { created: 2, completed: 3 }, content: [{ type: 'text', text: 'ok' }] },
|
|
832
|
+
{ id: 'msg_u1', type: 'user', time: { created: 1 }, text: 'hi' },
|
|
833
|
+
],
|
|
834
|
+
cursor: { previous: 'raw_prev_2', next: null },
|
|
835
|
+
});
|
|
836
|
+
makeOpenCodeV2Client.mockReturnValue({
|
|
837
|
+
message: { list },
|
|
838
|
+
session: { messages: undefined, status: undefined, abort: undefined },
|
|
839
|
+
});
|
|
840
|
+
const { app, route } = registry();
|
|
841
|
+
registerSessionTurnPageRoutes(app, {
|
|
842
|
+
buildOpenCodeUrl: () => 'http://open.code/',
|
|
843
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
844
|
+
});
|
|
845
|
+
const res = response();
|
|
846
|
+
await route('GET', ROUTE)({
|
|
847
|
+
params: { sessionID: 'ses_1' },
|
|
848
|
+
query: { turns: '1' },
|
|
849
|
+
headers: {},
|
|
850
|
+
}, res);
|
|
851
|
+
|
|
852
|
+
expect(list).toHaveBeenCalledTimes(2);
|
|
853
|
+
expect(list.mock.calls[0][0]).toEqual({ sessionID: 'ses_1', limit: 100, order: 'desc' });
|
|
854
|
+
expect(list.mock.calls[1][0]).toEqual({ sessionID: 'ses_1', limit: 100, cursor: 'raw_next' });
|
|
855
|
+
expect(res.statusCode).toBe(200);
|
|
856
|
+
expect(res.body.complete).toBe(true);
|
|
857
|
+
expect(res.body.records.map((record) => record.info.id)).toEqual(['msg_u1', 'msg_a1', 'msg_a2']);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it('drops v2 idle/control rows and does not double-expand text+content (FINAL-GATE cold reload)', async () => {
|
|
861
|
+
// Real wire shape from isolated backend hard-reload sessions:
|
|
862
|
+
// user + assistant(content) + idle outcome=succeeded, and assistants that
|
|
863
|
+
// occasionally carry both top-level text and content[].text.
|
|
864
|
+
const list = vi.fn(async () => ({
|
|
865
|
+
data: [
|
|
866
|
+
{
|
|
867
|
+
id: 'msg_idle_ok',
|
|
868
|
+
time: { created: 4 },
|
|
869
|
+
type: 'idle',
|
|
870
|
+
outcome: 'succeeded',
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
id: 'msg_a_follow',
|
|
874
|
+
time: { created: 3, completed: 4 },
|
|
875
|
+
type: 'assistant',
|
|
876
|
+
agent: 'build',
|
|
877
|
+
model: { id: 'glm-5.3-flash', providerID: 'zai-coding-plan', variant: 'default' },
|
|
878
|
+
// Both carriers present — must project exactly one text part.
|
|
879
|
+
text: 'FINAL_FOLLOWUP_OK',
|
|
880
|
+
content: [
|
|
881
|
+
{ type: 'reasoning', text: 'think' },
|
|
882
|
+
{ type: 'text', text: 'FINAL_FOLLOWUP_OK' },
|
|
883
|
+
],
|
|
884
|
+
finish: 'stop',
|
|
885
|
+
rawFinish: 'stop',
|
|
886
|
+
cost: 0,
|
|
887
|
+
tokens: { input: 1, output: 1, reasoning: 1, cache: { read: 0, write: 0 } },
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
id: 'msg_u_follow',
|
|
891
|
+
time: { created: 2 },
|
|
892
|
+
type: 'user',
|
|
893
|
+
text: 'UI-E2E-FINAL-FOLLOWUP',
|
|
894
|
+
},
|
|
895
|
+
{
|
|
896
|
+
id: 'msg_a1',
|
|
897
|
+
time: { created: 1, completed: 2 },
|
|
898
|
+
type: 'assistant',
|
|
899
|
+
agent: 'build',
|
|
900
|
+
model: { id: 'glm-5.3-flash', providerID: 'zai-coding-plan', variant: 'default' },
|
|
901
|
+
content: [
|
|
902
|
+
{ type: 'reasoning', text: 'first think' },
|
|
903
|
+
{ type: 'text', text: 'FINAL_BUILD_OK' },
|
|
904
|
+
],
|
|
905
|
+
finish: 'stop',
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
id: 'msg_u1',
|
|
909
|
+
time: { created: 0 },
|
|
910
|
+
type: 'user',
|
|
911
|
+
text: 'UI-E2E-FINAL-BUILD',
|
|
912
|
+
},
|
|
913
|
+
],
|
|
914
|
+
cursor: { previous: null, next: null },
|
|
915
|
+
}));
|
|
916
|
+
makeOpenCodeV2Client.mockReturnValue({
|
|
917
|
+
message: { list },
|
|
918
|
+
session: { messages: undefined, status: undefined, abort: undefined },
|
|
919
|
+
});
|
|
920
|
+
const { app, route } = registry();
|
|
921
|
+
registerSessionTurnPageRoutes(app, {
|
|
922
|
+
buildOpenCodeUrl: () => 'http://open.code/',
|
|
923
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
924
|
+
});
|
|
925
|
+
const res = response();
|
|
926
|
+
await route('GET', ROUTE)({
|
|
927
|
+
params: { sessionID: 'ses_final_build' },
|
|
928
|
+
query: { turns: '2' },
|
|
929
|
+
headers: {},
|
|
930
|
+
}, res);
|
|
931
|
+
|
|
932
|
+
expect(res.statusCode).toBe(200);
|
|
933
|
+
expect(res.body.records.map((row) => row.info.role)).toEqual([
|
|
934
|
+
'user',
|
|
935
|
+
'assistant',
|
|
936
|
+
'user',
|
|
937
|
+
'assistant',
|
|
938
|
+
]);
|
|
939
|
+
expect(res.body.records.map((row) => row.info.id)).toEqual([
|
|
940
|
+
'msg_u1',
|
|
941
|
+
'msg_a1',
|
|
942
|
+
'msg_u_follow',
|
|
943
|
+
'msg_a_follow',
|
|
944
|
+
]);
|
|
945
|
+
// No idle / control leakage into ChatMessage props.
|
|
946
|
+
expect(res.body.records.some((row) => row.info.role === 'idle' || row.info.type === 'idle')).toBe(false);
|
|
947
|
+
expect(JSON.stringify(res.body)).not.toContain('"type":"idle"');
|
|
948
|
+
|
|
949
|
+
const firstAssistant = res.body.records[1];
|
|
950
|
+
expect(firstAssistant.info.agent).toBe('build');
|
|
951
|
+
expect(firstAssistant.info.modelID).toBe('glm-5.3-flash');
|
|
952
|
+
expect(firstAssistant.info.model).toEqual({
|
|
953
|
+
providerID: 'zai-coding-plan',
|
|
954
|
+
modelID: 'glm-5.3-flash',
|
|
955
|
+
variant: 'default',
|
|
956
|
+
});
|
|
957
|
+
expect(firstAssistant.info.type).toBeUndefined();
|
|
958
|
+
expect(firstAssistant.info.rawFinish).toBeUndefined();
|
|
959
|
+
const firstTexts = firstAssistant.parts.filter((part) => part.type === 'text');
|
|
960
|
+
expect(firstTexts).toHaveLength(1);
|
|
961
|
+
expect(firstTexts[0].text).toBe('FINAL_BUILD_OK');
|
|
962
|
+
|
|
963
|
+
const followAssistant = res.body.records[3];
|
|
964
|
+
const followTexts = followAssistant.parts.filter((part) => part.type === 'text');
|
|
965
|
+
expect(followTexts).toHaveLength(1);
|
|
966
|
+
expect(followTexts[0].text).toBe('FINAL_FOLLOWUP_OK');
|
|
967
|
+
expect(followAssistant.parts.filter((part) => part.type === 'reasoning')).toHaveLength(1);
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it('keeps provider.auth 401 assistant error and drops idle failed control', async () => {
|
|
971
|
+
const list = vi.fn(async () => ({
|
|
972
|
+
data: [
|
|
973
|
+
{ id: 'msg_idle_fail', time: { created: 3 }, type: 'idle', outcome: 'failed' },
|
|
974
|
+
{
|
|
975
|
+
id: 'msg_a_401',
|
|
976
|
+
time: { created: 2, completed: 3 },
|
|
977
|
+
type: 'assistant',
|
|
978
|
+
agent: 'build',
|
|
979
|
+
model: { id: 'deepseek-flash', providerID: 'deepseek', variant: 'default' },
|
|
980
|
+
content: null,
|
|
981
|
+
finish: 'error',
|
|
982
|
+
error: {
|
|
983
|
+
type: 'provider.auth',
|
|
984
|
+
message: 'Authentication Fails, Your api key: **** is invalid',
|
|
985
|
+
status: 401,
|
|
986
|
+
},
|
|
987
|
+
},
|
|
988
|
+
{ id: 'msg_u_401', time: { created: 1 }, type: 'user', text: 'UI-E2E-FINAL-DS' },
|
|
989
|
+
],
|
|
990
|
+
cursor: { previous: null, next: null },
|
|
991
|
+
}));
|
|
992
|
+
makeOpenCodeV2Client.mockReturnValue({
|
|
993
|
+
message: { list },
|
|
994
|
+
session: { messages: undefined, status: undefined, abort: undefined },
|
|
995
|
+
});
|
|
996
|
+
const { app, route } = registry();
|
|
997
|
+
registerSessionTurnPageRoutes(app, {
|
|
998
|
+
buildOpenCodeUrl: () => 'http://open.code/',
|
|
999
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1000
|
+
});
|
|
1001
|
+
const res = response();
|
|
1002
|
+
await route('GET', ROUTE)({
|
|
1003
|
+
params: { sessionID: 'ses_ds' },
|
|
1004
|
+
query: { turns: '1' },
|
|
1005
|
+
headers: {},
|
|
1006
|
+
}, res);
|
|
1007
|
+
|
|
1008
|
+
expect(res.statusCode).toBe(200);
|
|
1009
|
+
expect(res.body.records).toHaveLength(2);
|
|
1010
|
+
expect(res.body.records.map((row) => row.info.role)).toEqual(['user', 'assistant']);
|
|
1011
|
+
const assistant = res.body.records[1];
|
|
1012
|
+
expect(assistant.info.finish).toBe('error');
|
|
1013
|
+
expect(assistant.info.error).toEqual({
|
|
1014
|
+
type: 'provider.auth',
|
|
1015
|
+
message: 'Authentication Fails, Your api key: **** is invalid',
|
|
1016
|
+
status: 401,
|
|
1017
|
+
});
|
|
1018
|
+
expect(assistant.parts).toEqual([]);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
740
1021
|
it('default fetch throws on message.list failure and maps to upstream', async () => {
|
|
741
1022
|
makeOpenCodeV2Client.mockReturnValue({
|
|
742
1023
|
message: { list: vi.fn(async () => { throw Object.assign(new Error('upstream'), { status: 500 }); }) },
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type surface for session-message-projection.js (consumed by the VS Code
|
|
3
|
+
* turn-page bridge). Runtime implementation stays in the .js module.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type ProjectedSessionMessage = {
|
|
7
|
+
info: { id: string; role: string; [key: string]: unknown };
|
|
8
|
+
parts: unknown[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function projectSessionMessage(entry: unknown): ProjectedSessionMessage | null;
|
|
12
|
+
|
|
13
|
+
export function projectSessionMessageRecords(records: unknown): ProjectedSessionMessage[];
|