@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
|
@@ -1,5 +1,70 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createSessionMetadataStore } from '../session-metadata/session-metadata-store.js';
|
|
3
|
+
import {
|
|
4
|
+
configureServerOpenCodeFetchGate,
|
|
5
|
+
} from '../opencode/server-opencode-fetch.js';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import {
|
|
11
|
+
accountGoalTokenSpend,
|
|
12
|
+
buildContinuationMessageID,
|
|
13
|
+
buildPendingContinuation,
|
|
14
|
+
createSessionGoalRuntime,
|
|
15
|
+
extractExecutionModel,
|
|
16
|
+
messageTokenSpend,
|
|
17
|
+
parsePendingContinuation,
|
|
18
|
+
projectGoalMessages,
|
|
19
|
+
readGoalExecutionGeneration,
|
|
20
|
+
shouldAdvanceGoalExecutionGeneration,
|
|
21
|
+
unwrapSessionMessages,
|
|
22
|
+
withGoalExecutionGeneration,
|
|
23
|
+
} from './runtime.js';
|
|
24
|
+
|
|
25
|
+
// Host SDK writes share the runtime-contract gate. Unit tests have no lifecycle
|
|
26
|
+
// contract — allow missing so real switchAgent/switchModel/prompt reach fetch fixtures.
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
configureServerOpenCodeFetchGate(null, { allowMissingContract: true });
|
|
29
|
+
});
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
configureServerOpenCodeFetchGate(null);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Resolve installed @opencode/schema SessionMessage next to @opencode/client (no extra dep). */
|
|
35
|
+
const loadInstalledSessionMessageSchema = async () => {
|
|
36
|
+
const candidates = [];
|
|
37
|
+
const tryClientLink = (clientLink) => {
|
|
38
|
+
try {
|
|
39
|
+
if (!fs.existsSync(clientLink)) return;
|
|
40
|
+
const clientDir = fs.realpathSync(clientLink);
|
|
41
|
+
candidates.push(path.join(path.dirname(clientDir), 'schema', 'dist', 'session-message.js'));
|
|
42
|
+
} catch {
|
|
43
|
+
// ignore missing link
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
// packages/web dependency
|
|
47
|
+
tryClientLink(path.join(process.cwd(), 'node_modules/@opencode/client'));
|
|
48
|
+
tryClientLink(path.join(process.cwd(), 'packages/web/node_modules/@opencode/client'));
|
|
49
|
+
// bun nested install layout
|
|
50
|
+
const bunRoot = path.join(process.cwd(), 'node_modules/.bun');
|
|
51
|
+
if (fs.existsSync(bunRoot)) {
|
|
52
|
+
for (const name of fs.readdirSync(bunRoot)) {
|
|
53
|
+
if (!name.startsWith('@opencode+client@')) continue;
|
|
54
|
+
candidates.push(path.join(
|
|
55
|
+
bunRoot,
|
|
56
|
+
name,
|
|
57
|
+
'node_modules/@opencode/schema/dist/session-message.js',
|
|
58
|
+
));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const schemaPath of candidates) {
|
|
62
|
+
if (fs.existsSync(schemaPath)) {
|
|
63
|
+
return import(pathToFileURL(schemaPath).href);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
throw new Error('installed @opencode/schema session-message not found beside @opencode/client');
|
|
67
|
+
};
|
|
3
68
|
|
|
4
69
|
// A session whose last assistant message is an orphaned incomplete turn: the
|
|
5
70
|
// app was force-killed mid-generation, so opencode left a message with no
|
|
@@ -36,35 +101,63 @@ const buildSession = (status = 'active') => ({
|
|
|
36
101
|
},
|
|
37
102
|
});
|
|
38
103
|
|
|
104
|
+
/** Match both legacy `/session/...` and official v2 `/api/session/...` paths. */
|
|
105
|
+
const pathEndsWith = (urlPath, suffix) => {
|
|
106
|
+
const normalized = String(urlPath || '').split('?')[0];
|
|
107
|
+
return normalized.endsWith(suffix) || normalized.endsWith(`/api${suffix}`);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const jsonResponse = (body, status = 200) => new Response(
|
|
111
|
+
body === null || body === undefined ? null : JSON.stringify(body),
|
|
112
|
+
{ status, headers: { 'Content-Type': 'application/json' } },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const emptyResponse = (status = 204) => new Response(null, { status });
|
|
116
|
+
|
|
39
117
|
// Build a mock fetch that answers the endpoints the goal tick needs. `messages`
|
|
40
118
|
// is the message list returned for the session; `liveStatus` is the type
|
|
41
|
-
// returned by /session/status for the session.
|
|
119
|
+
// returned by /session/status (or mapped from /session/active) for the session.
|
|
42
120
|
const makeFetch = ({ messages, liveStatus }) => {
|
|
43
|
-
const calls = { promptAsync: 0, patchSession: 0 };
|
|
121
|
+
const calls = { promptAsync: 0, prompt: 0, patchSession: 0 };
|
|
44
122
|
const session = buildSession();
|
|
45
123
|
return {
|
|
46
124
|
calls,
|
|
47
125
|
fetch: async (input, init = {}) => {
|
|
48
126
|
const url = String(input);
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
return
|
|
127
|
+
const reqPath = url.split('?')[0];
|
|
128
|
+
if (pathEndsWith(reqPath, '/session/status')) {
|
|
129
|
+
return jsonResponse({ 'ses-goal': { type: liveStatus } });
|
|
52
130
|
}
|
|
53
|
-
if (
|
|
54
|
-
|
|
131
|
+
if (pathEndsWith(reqPath, '/session/active')) {
|
|
132
|
+
const data = liveStatus === 'busy' || liveStatus === 'retry'
|
|
133
|
+
? { 'ses-goal': true }
|
|
134
|
+
: {};
|
|
135
|
+
return jsonResponse({ data });
|
|
55
136
|
}
|
|
56
|
-
if (
|
|
137
|
+
if (pathEndsWith(reqPath, '/session/ses-goal/message')) {
|
|
138
|
+
// Official v2 SessionMessagesResponse shape.
|
|
139
|
+
return jsonResponse({ data: messages, cursor: {} });
|
|
140
|
+
}
|
|
141
|
+
if (pathEndsWith(reqPath, '/session/ses-goal') && !reqPath.includes('/message')) {
|
|
57
142
|
if (init.method === 'PATCH') {
|
|
58
143
|
calls.patchSession += 1;
|
|
59
|
-
|
|
144
|
+
// openCodeFetch (no /api) expects bare session; SDK wraps { data }.
|
|
145
|
+
return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
|
|
60
146
|
}
|
|
61
|
-
return
|
|
147
|
+
return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
|
|
62
148
|
}
|
|
63
|
-
if (
|
|
64
|
-
calls.promptAsync += 1;
|
|
65
|
-
|
|
149
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
150
|
+
if (pathEndsWith(reqPath, '/prompt_async')) calls.promptAsync += 1;
|
|
151
|
+
else calls.prompt += 1;
|
|
152
|
+
// v2 session.prompt successStatus 200 with SessionInboxUser body.
|
|
153
|
+
return jsonResponse({
|
|
154
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
155
|
+
});
|
|
66
156
|
}
|
|
67
|
-
|
|
157
|
+
if (pathEndsWith(reqPath, '/model') || pathEndsWith(reqPath, '/agent')) {
|
|
158
|
+
return emptyResponse(204);
|
|
159
|
+
}
|
|
160
|
+
return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
|
|
68
161
|
},
|
|
69
162
|
};
|
|
70
163
|
};
|
|
@@ -92,8 +185,85 @@ const withFetch = async (fetchImpl, fn) => {
|
|
|
92
185
|
}
|
|
93
186
|
};
|
|
94
187
|
|
|
188
|
+
describe('session-goal pending continuation helpers', () => {
|
|
189
|
+
it('parses and builds durable pendingContinuation admission records', () => {
|
|
190
|
+
expect(parsePendingContinuation(null)).toBeNull();
|
|
191
|
+
expect(parsePendingContinuation({ messageID: 'msg_x' })).toBeNull();
|
|
192
|
+
const built = buildPendingContinuation({
|
|
193
|
+
messageID: 'msg_goalc_g_0_1',
|
|
194
|
+
goalId: 'g',
|
|
195
|
+
generation: 0,
|
|
196
|
+
turnsUsed: 1,
|
|
197
|
+
phase: 'reserved',
|
|
198
|
+
text: 'Continue…',
|
|
199
|
+
providerID: 'p',
|
|
200
|
+
modelID: 'm',
|
|
201
|
+
agent: 'build',
|
|
202
|
+
});
|
|
203
|
+
expect(parsePendingContinuation(built)).toMatchObject({
|
|
204
|
+
messageID: 'msg_goalc_g_0_1',
|
|
205
|
+
phase: 'reserved',
|
|
206
|
+
turnsUsed: 1,
|
|
207
|
+
providerID: 'p',
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('session-goal v2 message projection helpers', () => {
|
|
213
|
+
it('unwraps SessionMessagesResponse, bare arrays, and projects raw SessionMessageInfo', () => {
|
|
214
|
+
expect(unwrapSessionMessages(null)).toBeNull();
|
|
215
|
+
expect(unwrapSessionMessages({ data: [{ id: 'a', type: 'user', text: 'hi', time: { created: 1 } }], cursor: {} }))
|
|
216
|
+
.toEqual([{ id: 'a', type: 'user', text: 'hi', time: { created: 1 } }]);
|
|
217
|
+
const projected = projectGoalMessages({
|
|
218
|
+
data: [
|
|
219
|
+
{ id: 'u1', type: 'user', text: 'go', time: { created: 1 } },
|
|
220
|
+
{
|
|
221
|
+
id: 'a1',
|
|
222
|
+
type: 'assistant',
|
|
223
|
+
agent: 'build',
|
|
224
|
+
model: { id: 'm', providerID: 'p', variant: 'fast' },
|
|
225
|
+
time: { created: 2, completed: 3 },
|
|
226
|
+
content: [{ type: 'text', text: 'done' }],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: 'c1',
|
|
230
|
+
type: 'compaction',
|
|
231
|
+
status: 'completed',
|
|
232
|
+
reason: 'auto',
|
|
233
|
+
summary: 'summary text',
|
|
234
|
+
recent: '',
|
|
235
|
+
time: { created: 4 },
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
cursor: {},
|
|
239
|
+
}, 'ses-1');
|
|
240
|
+
expect(projected).toHaveLength(3);
|
|
241
|
+
expect(projected[0].info.role).toBe('user');
|
|
242
|
+
expect(projected[1].info.role).toBe('assistant');
|
|
243
|
+
expect(projected[1].parts[0].text).toBe('done');
|
|
244
|
+
expect(projected[2].info.summary).toBe(true);
|
|
245
|
+
expect(extractExecutionModel(projected[1].info)).toEqual({
|
|
246
|
+
providerID: 'p',
|
|
247
|
+
modelID: 'm',
|
|
248
|
+
variant: 'fast',
|
|
249
|
+
agent: 'build',
|
|
250
|
+
});
|
|
251
|
+
expect(buildContinuationMessageID({ goalId: 'goal-1', generation: 2, turnsUsed: 5 }))
|
|
252
|
+
.toBe('msg_goalc_goal-1_2_5');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('continuation id satisfies installed SessionMessage.ID (msg_ prefix)', async () => {
|
|
256
|
+
const SessionMessage = await loadInstalledSessionMessageSchema();
|
|
257
|
+
const legal = buildContinuationMessageID({ goalId: 'goal-1', generation: 2, turnsUsed: 5 });
|
|
258
|
+
expect(legal.startsWith('msg_')).toBe(true);
|
|
259
|
+
expect(SessionMessage.ID.make(legal)).toBe(legal);
|
|
260
|
+
// Legacy goalc_ prefix is rejected by the official schema contract.
|
|
261
|
+
expect(() => SessionMessage.ID.make('goalc_goal-1_2_5')).toThrow();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
95
265
|
describe('session-goal continuation delivery', () => {
|
|
96
|
-
it('
|
|
266
|
+
it('dispatches v2 session.prompt with stable continuation id and metadata', async () => {
|
|
97
267
|
const promptBodies = [];
|
|
98
268
|
const messages = [
|
|
99
269
|
{ info: { id: 'msg-user', role: 'user' }, parts: [] },
|
|
@@ -113,25 +283,33 @@ describe('session-goal continuation delivery', () => {
|
|
|
113
283
|
session.metadata.openchamber.goal.createdAt = 1;
|
|
114
284
|
const fetchImpl = async (input, init = {}) => {
|
|
115
285
|
const url = String(input);
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
return
|
|
286
|
+
const reqPath = url.split('?')[0];
|
|
287
|
+
if (pathEndsWith(reqPath, '/session/status')) {
|
|
288
|
+
return jsonResponse({ 'ses-goal': { type: 'idle' } });
|
|
119
289
|
}
|
|
120
|
-
if (
|
|
121
|
-
return
|
|
290
|
+
if (pathEndsWith(reqPath, '/session/active')) {
|
|
291
|
+
return jsonResponse({ data: {} });
|
|
122
292
|
}
|
|
123
|
-
if (
|
|
293
|
+
if (pathEndsWith(reqPath, '/session/ses-goal/message')) {
|
|
294
|
+
return jsonResponse({ data: messages, cursor: {} });
|
|
295
|
+
}
|
|
296
|
+
if (pathEndsWith(reqPath, '/session/ses-goal') && !reqPath.includes('/message')) {
|
|
124
297
|
if (init.method === 'PATCH' && init.body) {
|
|
125
298
|
const body = JSON.parse(init.body);
|
|
126
299
|
session.metadata = body.metadata;
|
|
127
300
|
}
|
|
128
|
-
return
|
|
301
|
+
return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
|
|
129
302
|
}
|
|
130
|
-
if (
|
|
303
|
+
if (pathEndsWith(reqPath, '/model') || pathEndsWith(reqPath, '/agent')) {
|
|
304
|
+
return emptyResponse(204);
|
|
305
|
+
}
|
|
306
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
131
307
|
promptBodies.push(JSON.parse(init.body || '{}'));
|
|
132
|
-
return
|
|
308
|
+
return jsonResponse({
|
|
309
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
310
|
+
});
|
|
133
311
|
}
|
|
134
|
-
return
|
|
312
|
+
return jsonResponse(reqPath.includes('/api/') ? { data: session } : session);
|
|
135
313
|
};
|
|
136
314
|
|
|
137
315
|
await withFetch(fetchImpl, async () => {
|
|
@@ -145,18 +323,25 @@ describe('session-goal continuation delivery', () => {
|
|
|
145
323
|
kickoffQuietMs: 1,
|
|
146
324
|
maxAutoTurns: 20,
|
|
147
325
|
});
|
|
148
|
-
runtime.
|
|
149
|
-
type: 'session.updated',
|
|
150
|
-
properties: {
|
|
151
|
-
sessionID: 'ses-goal',
|
|
152
|
-
directory: '/repo',
|
|
153
|
-
info: session,
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
326
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
157
327
|
expect(promptBodies.length).toBeGreaterThan(0);
|
|
158
|
-
|
|
159
|
-
expect(
|
|
328
|
+
const body = promptBodies[0];
|
|
329
|
+
expect(body.id).toBe(buildContinuationMessageID({ goalId: 'goal-1', generation: 0, turnsUsed: 1 }));
|
|
330
|
+
expect(body.id.startsWith('msg_')).toBe(true);
|
|
331
|
+
const SessionMessage = await loadInstalledSessionMessageSchema();
|
|
332
|
+
expect(SessionMessage.ID.make(body.id)).toBe(body.id);
|
|
333
|
+
expect(String(body.text)).toContain('Continue working toward the active session goal.');
|
|
334
|
+
expect(body.metadata?.openchamber?.goalContinuation).toBe(true);
|
|
335
|
+
// User-boundary prompt: synthetic lives in metadata only (not type: synthetic).
|
|
336
|
+
expect(body.metadata?.openchamber?.synthetic).toBe(true);
|
|
337
|
+
expect(body.type).not.toBe('synthetic');
|
|
338
|
+
expect(runtime.getDispatchedContinuation('ses-goal')).toMatchObject({
|
|
339
|
+
goalId: 'goal-1',
|
|
340
|
+
generation: 0,
|
|
341
|
+
turnsUsed: 1,
|
|
342
|
+
messageID: body.id,
|
|
343
|
+
phase: 'accepted',
|
|
344
|
+
});
|
|
160
345
|
runtime.stop();
|
|
161
346
|
});
|
|
162
347
|
});
|
|
@@ -259,18 +444,8 @@ describe('session-goal runtime — restart-orphan quiescence', () => {
|
|
|
259
444
|
|
|
260
445
|
await withFetch(fetch, async () => {
|
|
261
446
|
const runtime = makeRuntime();
|
|
262
|
-
runtime.
|
|
263
|
-
|
|
264
|
-
properties: {
|
|
265
|
-
sessionID: 'ses-goal',
|
|
266
|
-
directory: '/repo',
|
|
267
|
-
info: buildSession(),
|
|
268
|
-
},
|
|
269
|
-
});
|
|
270
|
-
// The resumed kickoff timer (RESUME_KICKOFF_MS = 250ms) must fire before
|
|
271
|
-
// the tick runs — wait past it.
|
|
272
|
-
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
273
|
-
expect(calls.promptAsync).toBeGreaterThan(0);
|
|
447
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
448
|
+
expect(calls.prompt + calls.promptAsync).toBeGreaterThan(0);
|
|
274
449
|
runtime.stop();
|
|
275
450
|
});
|
|
276
451
|
});
|
|
@@ -286,16 +461,8 @@ describe('session-goal runtime — restart-orphan quiescence', () => {
|
|
|
286
461
|
|
|
287
462
|
await withFetch(fetch, async () => {
|
|
288
463
|
const runtime = makeRuntime();
|
|
289
|
-
runtime.
|
|
290
|
-
|
|
291
|
-
properties: {
|
|
292
|
-
sessionID: 'ses-goal',
|
|
293
|
-
directory: '/repo',
|
|
294
|
-
info: buildSession(),
|
|
295
|
-
},
|
|
296
|
-
});
|
|
297
|
-
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
298
|
-
expect(calls.promptAsync).toBe(0);
|
|
464
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
465
|
+
expect(calls.prompt + calls.promptAsync).toBe(0);
|
|
299
466
|
runtime.stop();
|
|
300
467
|
});
|
|
301
468
|
});
|
|
@@ -311,16 +478,8 @@ describe('session-goal runtime — restart-orphan quiescence', () => {
|
|
|
311
478
|
|
|
312
479
|
await withFetch(fetch, async () => {
|
|
313
480
|
const runtime = makeRuntime();
|
|
314
|
-
runtime.
|
|
315
|
-
|
|
316
|
-
properties: {
|
|
317
|
-
sessionID: 'ses-goal',
|
|
318
|
-
directory: '/repo',
|
|
319
|
-
info: buildSession(),
|
|
320
|
-
},
|
|
321
|
-
});
|
|
322
|
-
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
323
|
-
expect(calls.promptAsync).toBeGreaterThan(0);
|
|
481
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
482
|
+
expect(calls.prompt + calls.promptAsync).toBeGreaterThan(0);
|
|
324
483
|
runtime.stop();
|
|
325
484
|
});
|
|
326
485
|
});
|
|
@@ -642,28 +801,60 @@ describe('session-goal runtime — pause for question', () => {
|
|
|
642
801
|
});
|
|
643
802
|
});
|
|
644
803
|
|
|
804
|
+
describe('session-goal execution generation helpers', () => {
|
|
805
|
+
it('advances generation on pause, resume, and condition changes — not on settle', () => {
|
|
806
|
+
const base = {
|
|
807
|
+
id: 'g1',
|
|
808
|
+
status: 'active',
|
|
809
|
+
executionGeneration: 2,
|
|
810
|
+
tokenBudget: 100,
|
|
811
|
+
objective: 'x',
|
|
812
|
+
objectiveFile: false,
|
|
813
|
+
};
|
|
814
|
+
expect(shouldAdvanceGoalExecutionGeneration(base, { ...base, status: 'paused' })).toBe(true);
|
|
815
|
+
expect(shouldAdvanceGoalExecutionGeneration(
|
|
816
|
+
{ ...base, status: 'paused' },
|
|
817
|
+
{ ...base, status: 'active' },
|
|
818
|
+
)).toBe(true);
|
|
819
|
+
expect(shouldAdvanceGoalExecutionGeneration(base, { ...base, tokenBudget: 200 })).toBe(true);
|
|
820
|
+
expect(shouldAdvanceGoalExecutionGeneration(base, { ...base, status: 'complete' })).toBe(false);
|
|
821
|
+
expect(readGoalExecutionGeneration({})).toBe(0);
|
|
822
|
+
expect(withGoalExecutionGeneration(base, { ...base, status: 'paused' }).executionGeneration).toBe(3);
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
645
826
|
describe('session-goal runtime — Host metadata store seams', () => {
|
|
646
|
-
it('writes through
|
|
827
|
+
it('writes through mutateSessionMetadata and never PATCHes OpenCode metadata when seams are injected', async () => {
|
|
647
828
|
const active = buildSession('active');
|
|
648
829
|
const goal = active.metadata.openchamber.goal;
|
|
649
830
|
const metadata = {
|
|
650
831
|
openchamber: {
|
|
651
|
-
goal: { ...goal },
|
|
832
|
+
goal: { ...goal, executionGeneration: 0 },
|
|
652
833
|
assist: { recap: 'keep-me' },
|
|
653
834
|
},
|
|
654
835
|
};
|
|
655
836
|
const readSessionMetadata = vi.fn(async () => metadata);
|
|
656
|
-
const
|
|
657
|
-
|
|
837
|
+
const mutateSessionMetadata = vi.fn(async (_sessionId, decide) => {
|
|
838
|
+
const decision = decide(metadata);
|
|
839
|
+
if (!decision?.ok) return { committed: false, reason: decision?.reason, metadata };
|
|
840
|
+
metadata.openchamber = {
|
|
841
|
+
...metadata.openchamber,
|
|
842
|
+
...(decision.patch.openchamber || {}),
|
|
843
|
+
goal: {
|
|
844
|
+
...metadata.openchamber.goal,
|
|
845
|
+
...(decision.patch.openchamber?.goal || {}),
|
|
846
|
+
},
|
|
847
|
+
};
|
|
848
|
+
return { committed: true, metadata };
|
|
658
849
|
});
|
|
659
850
|
const fetchMock = vi.fn(async (input, init = {}) => {
|
|
660
851
|
const url = String(input);
|
|
661
|
-
const
|
|
852
|
+
const reqPath = url.split('?')[0];
|
|
662
853
|
if (init.method === 'PATCH') {
|
|
663
|
-
throw new Error(`OpenCode metadata PATCH must not run when store seams are wired: ${
|
|
854
|
+
throw new Error(`OpenCode metadata PATCH must not run when store seams are wired: ${reqPath}`);
|
|
664
855
|
}
|
|
665
856
|
// resolveGoalOwner walks parentID via GET /session/:id
|
|
666
|
-
if (
|
|
857
|
+
if (reqPath.endsWith('/session/ses-goal')) {
|
|
667
858
|
return new Response(JSON.stringify({ id: 'ses-goal', directory: '/repo' }), { status: 200 });
|
|
668
859
|
}
|
|
669
860
|
return new Response(JSON.stringify({}), { status: 200 });
|
|
@@ -680,25 +871,1203 @@ describe('session-goal runtime — Host metadata store seams', () => {
|
|
|
680
871
|
kickoffQuietMs: 1,
|
|
681
872
|
maxAutoTurns: 20,
|
|
682
873
|
readSessionMetadata,
|
|
683
|
-
persistSessionGoal
|
|
874
|
+
persistSessionGoal: async () => {
|
|
875
|
+
throw new Error('persistSessionGoal must not run when mutate is wired');
|
|
876
|
+
},
|
|
877
|
+
mutateSessionMetadata,
|
|
684
878
|
});
|
|
685
879
|
|
|
686
880
|
await runtime.pauseForQuestion('ses-goal', '/repo');
|
|
687
881
|
|
|
688
882
|
expect(readSessionMetadata).toHaveBeenCalledWith('ses-goal');
|
|
689
|
-
expect(
|
|
690
|
-
expect(
|
|
691
|
-
expect(persistSessionGoal.mock.calls[0][1]).toBe('/repo');
|
|
692
|
-
expect(persistSessionGoal.mock.calls[0][2]).toMatchObject({
|
|
883
|
+
expect(mutateSessionMetadata).toHaveBeenCalledTimes(1);
|
|
884
|
+
expect(metadata.openchamber.goal).toMatchObject({
|
|
693
885
|
id: goal.id,
|
|
694
886
|
status: 'paused',
|
|
695
887
|
statusReason: 'paused for question',
|
|
888
|
+
executionGeneration: 1,
|
|
696
889
|
});
|
|
697
|
-
// Neighbouring Host namespaces must survive the goal write
|
|
698
|
-
// merge; this seam only receives the goal object).
|
|
890
|
+
// Neighbouring Host namespaces must survive the goal write.
|
|
699
891
|
expect(metadata.openchamber.assist).toEqual({ recap: 'keep-me' });
|
|
700
892
|
expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'PATCH')).toBe(false);
|
|
701
893
|
runtime.stop();
|
|
702
894
|
});
|
|
703
895
|
});
|
|
704
896
|
});
|
|
897
|
+
|
|
898
|
+
describe('session-goal runtime — pause / audit / dispatch boundary', () => {
|
|
899
|
+
const makeTempStore = async () => {
|
|
900
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-goal-race-'));
|
|
901
|
+
const store = createSessionMetadataStore({ dataDir });
|
|
902
|
+
await store.load();
|
|
903
|
+
return {
|
|
904
|
+
store,
|
|
905
|
+
dataDir,
|
|
906
|
+
cleanup: () => fs.rmSync(dataDir, { recursive: true, force: true }),
|
|
907
|
+
};
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const idleMessages = [
|
|
911
|
+
{ info: { id: 'msg-user', role: 'user' }, parts: [] },
|
|
912
|
+
{
|
|
913
|
+
info: {
|
|
914
|
+
id: 'msg-done',
|
|
915
|
+
role: 'assistant',
|
|
916
|
+
agent: 'build',
|
|
917
|
+
time: { completed: 200 },
|
|
918
|
+
providerID: 'p',
|
|
919
|
+
modelID: 'm',
|
|
920
|
+
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
921
|
+
},
|
|
922
|
+
parts: [{ type: 'text', text: 'working' }],
|
|
923
|
+
},
|
|
924
|
+
];
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* @param {object} [options]
|
|
928
|
+
* @param {Array|object} [options.messages]
|
|
929
|
+
* @param {Set<string>|string[]} [options.knownMessageIDs] exact ids found via message GET / inbox
|
|
930
|
+
* @param {boolean} [options.messageLookupUnavailable] fail != empty for reconcile
|
|
931
|
+
* @param {(body: object) => Response|Promise<Response>} [options.onPrompt]
|
|
932
|
+
*/
|
|
933
|
+
const makeOpenCodeFetch = (messagesOrOptions = idleMessages) => {
|
|
934
|
+
const options = Array.isArray(messagesOrOptions) || (messagesOrOptions && messagesOrOptions.data)
|
|
935
|
+
? { messages: messagesOrOptions }
|
|
936
|
+
: (messagesOrOptions || {});
|
|
937
|
+
const messages = options.messages ?? idleMessages;
|
|
938
|
+
const knownMessageIDs = options.knownMessageIDs instanceof Set
|
|
939
|
+
? options.knownMessageIDs
|
|
940
|
+
: new Set(options.knownMessageIDs || []);
|
|
941
|
+
const messageLookupUnavailable = options.messageLookupUnavailable === true;
|
|
942
|
+
const onPrompt = typeof options.onPrompt === 'function' ? options.onPrompt : null;
|
|
943
|
+
|
|
944
|
+
return async (input, init = {}) => {
|
|
945
|
+
const url = String(input);
|
|
946
|
+
const reqPath = url.split('?')[0];
|
|
947
|
+
if (pathEndsWith(reqPath, '/session/status')) {
|
|
948
|
+
return jsonResponse({ 'ses-goal': { type: 'idle' } });
|
|
949
|
+
}
|
|
950
|
+
if (pathEndsWith(reqPath, '/session/active')) {
|
|
951
|
+
return jsonResponse({ data: {} });
|
|
952
|
+
}
|
|
953
|
+
// Exact message GET before list suffix match.
|
|
954
|
+
const exactMatch = reqPath.match(/\/session\/ses-goal\/message\/([^/?]+)$/);
|
|
955
|
+
if (exactMatch) {
|
|
956
|
+
if (messageLookupUnavailable) {
|
|
957
|
+
return new Response(JSON.stringify({ error: 'upstream' }), {
|
|
958
|
+
status: 500,
|
|
959
|
+
headers: { 'Content-Type': 'application/json' },
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
const messageID = decodeURIComponent(exactMatch[1]);
|
|
963
|
+
if (knownMessageIDs.has(messageID)) {
|
|
964
|
+
return jsonResponse({ id: messageID, type: 'user', text: 'continuation' });
|
|
965
|
+
}
|
|
966
|
+
return new Response(JSON.stringify({ error: 'not found' }), {
|
|
967
|
+
status: 404,
|
|
968
|
+
headers: { 'Content-Type': 'application/json' },
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
if (pathEndsWith(reqPath, '/session/ses-goal/inbox') || pathEndsWith(reqPath, '/inbox')) {
|
|
972
|
+
if (messageLookupUnavailable) {
|
|
973
|
+
return new Response(JSON.stringify({ error: 'upstream' }), {
|
|
974
|
+
status: 500,
|
|
975
|
+
headers: { 'Content-Type': 'application/json' },
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
return jsonResponse({
|
|
979
|
+
data: [...knownMessageIDs].map((id) => ({ id, type: 'user', sessionID: 'ses-goal' })),
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
if (pathEndsWith(reqPath, '/session/ses-goal/message')) {
|
|
983
|
+
// Support both bare array fixtures and official `{ data, cursor }` projection.
|
|
984
|
+
const body = Array.isArray(messages)
|
|
985
|
+
? { data: messages, cursor: {} }
|
|
986
|
+
: messages;
|
|
987
|
+
return jsonResponse(body);
|
|
988
|
+
}
|
|
989
|
+
if (pathEndsWith(reqPath, '/session/ses-goal')) {
|
|
990
|
+
if (init.method === 'PATCH') {
|
|
991
|
+
throw new Error('OpenCode PATCH must not run with Host store');
|
|
992
|
+
}
|
|
993
|
+
return jsonResponse({ data: { id: 'ses-goal', directory: '/repo' } });
|
|
994
|
+
}
|
|
995
|
+
if (pathEndsWith(reqPath, '/model') || pathEndsWith(reqPath, '/agent')) {
|
|
996
|
+
return emptyResponse(204);
|
|
997
|
+
}
|
|
998
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
999
|
+
const body = JSON.parse(init.body || '{}');
|
|
1000
|
+
if (onPrompt) return onPrompt(body);
|
|
1001
|
+
if (body.id) knownMessageIDs.add(body.id);
|
|
1002
|
+
return jsonResponse({
|
|
1003
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
return jsonResponse({});
|
|
1007
|
+
};
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
it('audit start → user pause → late continue does not dispatch or raise turnsUsed', async () => {
|
|
1011
|
+
const { store, cleanup } = await makeTempStore();
|
|
1012
|
+
const goal = {
|
|
1013
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1014
|
+
executionGeneration: 0,
|
|
1015
|
+
createdAt: 1,
|
|
1016
|
+
turnsUsed: 3,
|
|
1017
|
+
};
|
|
1018
|
+
await store.setSessionMetadata('ses-goal', {
|
|
1019
|
+
openchamber: { goal, assist: { recap: 'keep' }, archive: { archivedAt: 9 } },
|
|
1020
|
+
}, { allowArchive: true });
|
|
1021
|
+
|
|
1022
|
+
let releaseAudit;
|
|
1023
|
+
const auditGate = new Promise((resolve) => { releaseAudit = resolve; });
|
|
1024
|
+
const promptBodies = [];
|
|
1025
|
+
|
|
1026
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1027
|
+
const runtime = createSessionGoalRuntime({
|
|
1028
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1029
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1030
|
+
getSmallModelService: async () => ({
|
|
1031
|
+
generateSmallModelText: async () => {
|
|
1032
|
+
await auditGate;
|
|
1033
|
+
return { text: '{"verdict":"continue","note":"late"}' };
|
|
1034
|
+
},
|
|
1035
|
+
}),
|
|
1036
|
+
idleQuietMs: 1_000_000,
|
|
1037
|
+
kickoffQuietMs: 1,
|
|
1038
|
+
maxAutoTurns: 20,
|
|
1039
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1040
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1041
|
+
persistSessionGoal: async () => {
|
|
1042
|
+
throw new Error('unexpected persistSessionGoal');
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// Intercept prompt via fetch override that already returns ok — count via monkeypatch.
|
|
1047
|
+
const originalFetch = globalThis.fetch;
|
|
1048
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1049
|
+
const reqPath = String(input).split('?')[0];
|
|
1050
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1051
|
+
promptBodies.push(JSON.parse(init.body || '{}'));
|
|
1052
|
+
return jsonResponse({
|
|
1053
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
return originalFetch(input, init);
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
try {
|
|
1060
|
+
const tickPromise = runtime.runTick('ses-goal', '/repo');
|
|
1061
|
+
// Let the tick reach the audit gate.
|
|
1062
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
1063
|
+
expect(runtime.getDispatchEpoch('ses-goal')).toBe(0);
|
|
1064
|
+
|
|
1065
|
+
const paused = await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
|
|
1066
|
+
expect(paused.status).toBe('paused');
|
|
1067
|
+
expect(paused.executionGeneration).toBe(1);
|
|
1068
|
+
expect(runtime.getDispatchEpoch('ses-goal')).toBeGreaterThan(0);
|
|
1069
|
+
|
|
1070
|
+
// Readers only see committed pause (store already published).
|
|
1071
|
+
const mid = await store.get('ses-goal');
|
|
1072
|
+
expect(mid.openchamber.goal.status).toBe('paused');
|
|
1073
|
+
expect(mid.openchamber.assist).toEqual({ recap: 'keep' });
|
|
1074
|
+
expect(mid.openchamber.archive).toEqual({ archivedAt: 9 });
|
|
1075
|
+
|
|
1076
|
+
releaseAudit();
|
|
1077
|
+
await tickPromise;
|
|
1078
|
+
|
|
1079
|
+
expect(promptBodies).toEqual([]);
|
|
1080
|
+
const finalMeta = await store.get('ses-goal');
|
|
1081
|
+
expect(finalMeta.openchamber.goal.status).toBe('paused');
|
|
1082
|
+
expect(finalMeta.openchamber.goal.turnsUsed).toBe(3);
|
|
1083
|
+
expect(finalMeta.openchamber.goal.executionGeneration).toBe(1);
|
|
1084
|
+
expect(finalMeta.openchamber.assist).toEqual({ recap: 'keep' });
|
|
1085
|
+
expect(finalMeta.openchamber.archive).toEqual({ archivedAt: 9 });
|
|
1086
|
+
runtime.stop();
|
|
1087
|
+
} finally {
|
|
1088
|
+
globalThis.fetch = originalFetch;
|
|
1089
|
+
cleanup();
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it('late audit complete after pause cannot settle the goal', async () => {
|
|
1095
|
+
const { store, cleanup } = await makeTempStore();
|
|
1096
|
+
const goal = {
|
|
1097
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1098
|
+
executionGeneration: 4,
|
|
1099
|
+
createdAt: 1,
|
|
1100
|
+
turnsUsed: 1,
|
|
1101
|
+
};
|
|
1102
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1103
|
+
|
|
1104
|
+
let releaseAudit;
|
|
1105
|
+
const auditGate = new Promise((resolve) => { releaseAudit = resolve; });
|
|
1106
|
+
|
|
1107
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1108
|
+
const runtime = createSessionGoalRuntime({
|
|
1109
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1110
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1111
|
+
getSmallModelService: async () => ({
|
|
1112
|
+
generateSmallModelText: async () => {
|
|
1113
|
+
await auditGate;
|
|
1114
|
+
return { text: '{"verdict":"complete","note":"done"}' };
|
|
1115
|
+
},
|
|
1116
|
+
}),
|
|
1117
|
+
idleQuietMs: 1_000_000,
|
|
1118
|
+
kickoffQuietMs: 1,
|
|
1119
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1120
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
const tickPromise = runtime.runTick('ses-goal', '/repo');
|
|
1124
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
1125
|
+
await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
|
|
1126
|
+
releaseAudit();
|
|
1127
|
+
await tickPromise;
|
|
1128
|
+
|
|
1129
|
+
const finalMeta = await store.get('ses-goal');
|
|
1130
|
+
expect(finalMeta.openchamber.goal.status).toBe('paused');
|
|
1131
|
+
expect(finalMeta.openchamber.goal.executionGeneration).toBe(5);
|
|
1132
|
+
runtime.stop();
|
|
1133
|
+
cleanup();
|
|
1134
|
+
});
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
it('resume advances generation so a prior-period continue cannot commit', async () => {
|
|
1138
|
+
const { store, cleanup } = await makeTempStore();
|
|
1139
|
+
const goal = {
|
|
1140
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1141
|
+
executionGeneration: 1,
|
|
1142
|
+
createdAt: 1,
|
|
1143
|
+
turnsUsed: 2,
|
|
1144
|
+
};
|
|
1145
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1146
|
+
|
|
1147
|
+
let releaseAudit;
|
|
1148
|
+
const auditGate = new Promise((resolve) => { releaseAudit = resolve; });
|
|
1149
|
+
const promptBodies = [];
|
|
1150
|
+
|
|
1151
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1152
|
+
const originalFetch = globalThis.fetch;
|
|
1153
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1154
|
+
const reqPath = String(input).split('?')[0];
|
|
1155
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1156
|
+
promptBodies.push(1);
|
|
1157
|
+
return jsonResponse({
|
|
1158
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
return originalFetch(input, init);
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
try {
|
|
1165
|
+
const runtime = createSessionGoalRuntime({
|
|
1166
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1167
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1168
|
+
getSmallModelService: async () => ({
|
|
1169
|
+
generateSmallModelText: async () => {
|
|
1170
|
+
await auditGate;
|
|
1171
|
+
return { text: '{"verdict":"continue","note":"old"}' };
|
|
1172
|
+
},
|
|
1173
|
+
}),
|
|
1174
|
+
idleQuietMs: 1_000_000,
|
|
1175
|
+
kickoffQuietMs: 1,
|
|
1176
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1177
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
const tickPromise = runtime.runTick('ses-goal', '/repo');
|
|
1181
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
1182
|
+
// Simulate pause then resume while audit is in flight (new generation).
|
|
1183
|
+
await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
|
|
1184
|
+
await store.mutateSessionMetadata('ses-goal', (current) => {
|
|
1185
|
+
const g = current.openchamber.goal;
|
|
1186
|
+
return {
|
|
1187
|
+
ok: true,
|
|
1188
|
+
patch: {
|
|
1189
|
+
openchamber: {
|
|
1190
|
+
goal: {
|
|
1191
|
+
...g,
|
|
1192
|
+
status: 'active',
|
|
1193
|
+
statusReason: 'resumed',
|
|
1194
|
+
executionGeneration: g.executionGeneration + 1,
|
|
1195
|
+
},
|
|
1196
|
+
},
|
|
1197
|
+
},
|
|
1198
|
+
};
|
|
1199
|
+
});
|
|
1200
|
+
// Local epoch was bumped on pause; resume does not lower it.
|
|
1201
|
+
releaseAudit();
|
|
1202
|
+
await tickPromise;
|
|
1203
|
+
expect(promptBodies).toEqual([]);
|
|
1204
|
+
const finalMeta = await store.get('ses-goal');
|
|
1205
|
+
expect(finalMeta.openchamber.goal.status).toBe('active');
|
|
1206
|
+
expect(finalMeta.openchamber.goal.turnsUsed).toBe(2);
|
|
1207
|
+
expect(finalMeta.openchamber.goal.executionGeneration).toBe(3);
|
|
1208
|
+
runtime.stop();
|
|
1209
|
+
} finally {
|
|
1210
|
+
globalThis.fetch = originalFetch;
|
|
1211
|
+
cleanup();
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
it('successful continue commits turnsUsed and records dispatch identity', async () => {
|
|
1217
|
+
const { store, cleanup } = await makeTempStore();
|
|
1218
|
+
const goal = {
|
|
1219
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1220
|
+
executionGeneration: 0,
|
|
1221
|
+
createdAt: 1,
|
|
1222
|
+
turnsUsed: 0,
|
|
1223
|
+
statusReason: 'resumed',
|
|
1224
|
+
};
|
|
1225
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1226
|
+
const promptBodies = [];
|
|
1227
|
+
|
|
1228
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1229
|
+
const originalFetch = globalThis.fetch;
|
|
1230
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1231
|
+
const reqPath = String(input).split('?')[0];
|
|
1232
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1233
|
+
promptBodies.push(JSON.parse(init.body || '{}'));
|
|
1234
|
+
return jsonResponse({
|
|
1235
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
return originalFetch(input, init);
|
|
1239
|
+
};
|
|
1240
|
+
try {
|
|
1241
|
+
const runtime = createSessionGoalRuntime({
|
|
1242
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1243
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1244
|
+
getSmallModelService: async () => ({
|
|
1245
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1246
|
+
}),
|
|
1247
|
+
idleQuietMs: 1_000_000,
|
|
1248
|
+
kickoffQuietMs: 1,
|
|
1249
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1250
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1251
|
+
});
|
|
1252
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
1253
|
+
expect(promptBodies.length).toBe(1);
|
|
1254
|
+
expect(promptBodies[0].id).toBe(buildContinuationMessageID({
|
|
1255
|
+
goalId: goal.id,
|
|
1256
|
+
generation: 0,
|
|
1257
|
+
turnsUsed: 1,
|
|
1258
|
+
}));
|
|
1259
|
+
const finalMeta = await store.get('ses-goal');
|
|
1260
|
+
expect(finalMeta.openchamber.goal.turnsUsed).toBe(1);
|
|
1261
|
+
expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
|
|
1262
|
+
expect(runtime.getDispatchedContinuation('ses-goal')).toMatchObject({
|
|
1263
|
+
goalId: goal.id,
|
|
1264
|
+
generation: 0,
|
|
1265
|
+
turnsUsed: 1,
|
|
1266
|
+
messageID: promptBodies[0].id,
|
|
1267
|
+
phase: 'accepted',
|
|
1268
|
+
});
|
|
1269
|
+
runtime.stop();
|
|
1270
|
+
} finally {
|
|
1271
|
+
globalThis.fetch = originalFetch;
|
|
1272
|
+
cleanup();
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
it('raw v2 projection payload + duplicate continuation id skips second dispatch', async () => {
|
|
1278
|
+
const { store, cleanup } = await makeTempStore();
|
|
1279
|
+
const goal = {
|
|
1280
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1281
|
+
executionGeneration: 0,
|
|
1282
|
+
createdAt: 1,
|
|
1283
|
+
turnsUsed: 0,
|
|
1284
|
+
statusReason: 'resumed',
|
|
1285
|
+
};
|
|
1286
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1287
|
+
|
|
1288
|
+
// Official raw SessionMessageInfo (no info/parts wrapper).
|
|
1289
|
+
const rawProjection = {
|
|
1290
|
+
data: [
|
|
1291
|
+
{ id: 'msg-user', type: 'user', text: 'hi', time: { created: 1 } },
|
|
1292
|
+
{
|
|
1293
|
+
id: 'msg-done',
|
|
1294
|
+
type: 'assistant',
|
|
1295
|
+
agent: 'build',
|
|
1296
|
+
model: { id: 'm', providerID: 'p' },
|
|
1297
|
+
time: { created: 2, completed: 200 },
|
|
1298
|
+
content: [{ type: 'text', text: 'working' }],
|
|
1299
|
+
tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
1300
|
+
},
|
|
1301
|
+
],
|
|
1302
|
+
cursor: {},
|
|
1303
|
+
};
|
|
1304
|
+
const promptBodies = [];
|
|
1305
|
+
const knownMessageIDs = new Set();
|
|
1306
|
+
|
|
1307
|
+
await withFetch(makeOpenCodeFetch({ messages: rawProjection, knownMessageIDs }), async () => {
|
|
1308
|
+
const originalFetch = globalThis.fetch;
|
|
1309
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1310
|
+
const reqPath = String(input).split('?')[0];
|
|
1311
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1312
|
+
const body = JSON.parse(init.body || '{}');
|
|
1313
|
+
promptBodies.push(body);
|
|
1314
|
+
if (body.id) knownMessageIDs.add(body.id);
|
|
1315
|
+
return jsonResponse({
|
|
1316
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
return originalFetch(input, init);
|
|
1320
|
+
};
|
|
1321
|
+
try {
|
|
1322
|
+
const runtime = createSessionGoalRuntime({
|
|
1323
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1324
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1325
|
+
getSmallModelService: async () => ({
|
|
1326
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1327
|
+
}),
|
|
1328
|
+
idleQuietMs: 1_000_000,
|
|
1329
|
+
kickoffQuietMs: 1,
|
|
1330
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1331
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1332
|
+
});
|
|
1333
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
1334
|
+
expect(promptBodies.length).toBe(1);
|
|
1335
|
+
const firstId = promptBodies[0].id;
|
|
1336
|
+
expect(firstId).toBe(buildContinuationMessageID({ goalId: goal.id, generation: 0, turnsUsed: 1 }));
|
|
1337
|
+
// Simulate crash after accept: turnsUsed rolled back but upstream still
|
|
1338
|
+
// holds the fixed message id — reconcile counts once, no second prompt.
|
|
1339
|
+
await store.mutateSessionMetadata('ses-goal', (current) => ({
|
|
1340
|
+
ok: true,
|
|
1341
|
+
patch: {
|
|
1342
|
+
openchamber: {
|
|
1343
|
+
goal: { ...current.openchamber.goal, turnsUsed: 0 },
|
|
1344
|
+
},
|
|
1345
|
+
},
|
|
1346
|
+
}));
|
|
1347
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
1348
|
+
expect(promptBodies.length).toBe(1);
|
|
1349
|
+
const finalMeta = await store.get('ses-goal');
|
|
1350
|
+
expect(finalMeta.openchamber.goal.turnsUsed).toBe(1);
|
|
1351
|
+
expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
|
|
1352
|
+
runtime.stop();
|
|
1353
|
+
} finally {
|
|
1354
|
+
globalThis.fetch = originalFetch;
|
|
1355
|
+
cleanup();
|
|
1356
|
+
}
|
|
1357
|
+
});
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
it('shutdown interrupt blocks auto-continue until authoritative idle', async () => {
|
|
1361
|
+
const { store, cleanup } = await makeTempStore();
|
|
1362
|
+
const goal = {
|
|
1363
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1364
|
+
executionGeneration: 0,
|
|
1365
|
+
createdAt: 1,
|
|
1366
|
+
turnsUsed: 0,
|
|
1367
|
+
statusReason: 'resumed',
|
|
1368
|
+
};
|
|
1369
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1370
|
+
const promptBodies = [];
|
|
1371
|
+
|
|
1372
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1373
|
+
const originalFetch = globalThis.fetch;
|
|
1374
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1375
|
+
const reqPath = String(input).split('?')[0];
|
|
1376
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1377
|
+
promptBodies.push(JSON.parse(init.body || '{}'));
|
|
1378
|
+
return jsonResponse({
|
|
1379
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
return originalFetch(input, init);
|
|
1383
|
+
};
|
|
1384
|
+
try {
|
|
1385
|
+
const runtime = createSessionGoalRuntime({
|
|
1386
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1387
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1388
|
+
getSmallModelService: async () => ({
|
|
1389
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1390
|
+
}),
|
|
1391
|
+
idleQuietMs: 1_000_000,
|
|
1392
|
+
kickoffQuietMs: 1,
|
|
1393
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1394
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1395
|
+
});
|
|
1396
|
+
runtime.processPayload({
|
|
1397
|
+
type: 'session.execution.interrupted',
|
|
1398
|
+
properties: { sessionID: 'ses-goal', reason: 'shutdown' },
|
|
1399
|
+
});
|
|
1400
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
1401
|
+
expect(promptBodies).toEqual([]);
|
|
1402
|
+
// Authoritative idle clears recovery and allows continue.
|
|
1403
|
+
runtime.processPayload({
|
|
1404
|
+
type: 'session.status',
|
|
1405
|
+
properties: { sessionID: 'ses-goal', status: { type: 'idle' }, directory: '/repo' },
|
|
1406
|
+
});
|
|
1407
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
1408
|
+
expect(promptBodies.length).toBe(1);
|
|
1409
|
+
runtime.stop();
|
|
1410
|
+
} finally {
|
|
1411
|
+
globalThis.fetch = originalFetch;
|
|
1412
|
+
cleanup();
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
it('pause during switchAgent drops before prompt with zero transport identity', async () => {
|
|
1418
|
+
const { store, cleanup } = await makeTempStore();
|
|
1419
|
+
const goal = {
|
|
1420
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1421
|
+
executionGeneration: 0,
|
|
1422
|
+
createdAt: 1,
|
|
1423
|
+
turnsUsed: 0,
|
|
1424
|
+
statusReason: 'resumed',
|
|
1425
|
+
};
|
|
1426
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1427
|
+
|
|
1428
|
+
let releaseAgent;
|
|
1429
|
+
const agentGate = new Promise((resolve) => { releaseAgent = resolve; });
|
|
1430
|
+
let agentStarted = false;
|
|
1431
|
+
const promptBodies = [];
|
|
1432
|
+
|
|
1433
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1434
|
+
const originalFetch = globalThis.fetch;
|
|
1435
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1436
|
+
const reqPath = String(input).split('?')[0];
|
|
1437
|
+
if (pathEndsWith(reqPath, '/agent')) {
|
|
1438
|
+
agentStarted = true;
|
|
1439
|
+
await agentGate;
|
|
1440
|
+
return emptyResponse(204);
|
|
1441
|
+
}
|
|
1442
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1443
|
+
promptBodies.push(JSON.parse(init.body || '{}'));
|
|
1444
|
+
return jsonResponse({
|
|
1445
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
return originalFetch(input, init);
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
try {
|
|
1452
|
+
const runtime = createSessionGoalRuntime({
|
|
1453
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1454
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1455
|
+
getSmallModelService: async () => ({
|
|
1456
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1457
|
+
}),
|
|
1458
|
+
idleQuietMs: 1_000_000,
|
|
1459
|
+
kickoffQuietMs: 1,
|
|
1460
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1461
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
const tickPromise = runtime.runTick('ses-goal', '/repo');
|
|
1465
|
+
const started = Date.now();
|
|
1466
|
+
while (!agentStarted) {
|
|
1467
|
+
if (Date.now() - started > 2000) throw new Error('switchAgent never started');
|
|
1468
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
1469
|
+
}
|
|
1470
|
+
// Selection in flight: transport must not be claimed yet.
|
|
1471
|
+
expect(runtime.getDispatchedContinuation('ses-goal')).toBeNull();
|
|
1472
|
+
|
|
1473
|
+
const paused = await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
|
|
1474
|
+
expect(paused.status).toBe('paused');
|
|
1475
|
+
expect(runtime.getDispatchEpoch('ses-goal')).toBeGreaterThan(0);
|
|
1476
|
+
|
|
1477
|
+
releaseAgent();
|
|
1478
|
+
await tickPromise;
|
|
1479
|
+
|
|
1480
|
+
expect(promptBodies).toEqual([]);
|
|
1481
|
+
expect(runtime.getDispatchedContinuation('ses-goal')).toBeNull();
|
|
1482
|
+
const finalMeta = await store.get('ses-goal');
|
|
1483
|
+
expect(finalMeta.openchamber.goal.status).toBe('paused');
|
|
1484
|
+
// Before selection/prompt: turnsUsed must stay 0 and reserved pending cleared.
|
|
1485
|
+
expect(finalMeta.openchamber.goal.turnsUsed).toBe(0);
|
|
1486
|
+
expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
|
|
1487
|
+
runtime.stop();
|
|
1488
|
+
} finally {
|
|
1489
|
+
globalThis.fetch = originalFetch;
|
|
1490
|
+
cleanup();
|
|
1491
|
+
}
|
|
1492
|
+
});
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it('pause during switchModel drops before prompt with zero transport identity', async () => {
|
|
1496
|
+
const { store, cleanup } = await makeTempStore();
|
|
1497
|
+
const goal = {
|
|
1498
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1499
|
+
executionGeneration: 0,
|
|
1500
|
+
createdAt: 1,
|
|
1501
|
+
turnsUsed: 0,
|
|
1502
|
+
statusReason: 'resumed',
|
|
1503
|
+
};
|
|
1504
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1505
|
+
|
|
1506
|
+
let releaseModel;
|
|
1507
|
+
const modelGate = new Promise((resolve) => { releaseModel = resolve; });
|
|
1508
|
+
let modelStarted = false;
|
|
1509
|
+
const promptBodies = [];
|
|
1510
|
+
|
|
1511
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1512
|
+
const originalFetch = globalThis.fetch;
|
|
1513
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1514
|
+
const reqPath = String(input).split('?')[0];
|
|
1515
|
+
if (pathEndsWith(reqPath, '/model')) {
|
|
1516
|
+
modelStarted = true;
|
|
1517
|
+
await modelGate;
|
|
1518
|
+
return emptyResponse(204);
|
|
1519
|
+
}
|
|
1520
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1521
|
+
promptBodies.push(JSON.parse(init.body || '{}'));
|
|
1522
|
+
return jsonResponse({
|
|
1523
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
return originalFetch(input, init);
|
|
1527
|
+
};
|
|
1528
|
+
|
|
1529
|
+
try {
|
|
1530
|
+
const runtime = createSessionGoalRuntime({
|
|
1531
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1532
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1533
|
+
getSmallModelService: async () => ({
|
|
1534
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1535
|
+
}),
|
|
1536
|
+
idleQuietMs: 1_000_000,
|
|
1537
|
+
kickoffQuietMs: 1,
|
|
1538
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1539
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
const tickPromise = runtime.runTick('ses-goal', '/repo');
|
|
1543
|
+
const started = Date.now();
|
|
1544
|
+
while (!modelStarted) {
|
|
1545
|
+
if (Date.now() - started > 2000) throw new Error('switchModel never started');
|
|
1546
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
1547
|
+
}
|
|
1548
|
+
expect(runtime.getDispatchedContinuation('ses-goal')).toBeNull();
|
|
1549
|
+
|
|
1550
|
+
await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
|
|
1551
|
+
releaseModel();
|
|
1552
|
+
await tickPromise;
|
|
1553
|
+
|
|
1554
|
+
expect(promptBodies).toEqual([]);
|
|
1555
|
+
expect(runtime.getDispatchedContinuation('ses-goal')).toBeNull();
|
|
1556
|
+
runtime.stop();
|
|
1557
|
+
} finally {
|
|
1558
|
+
globalThis.fetch = originalFetch;
|
|
1559
|
+
cleanup();
|
|
1560
|
+
}
|
|
1561
|
+
});
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
it('shutdown during switchAgent drops before prompt without uncertain phase', async () => {
|
|
1565
|
+
const { store, cleanup } = await makeTempStore();
|
|
1566
|
+
const goal = {
|
|
1567
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1568
|
+
executionGeneration: 0,
|
|
1569
|
+
createdAt: 1,
|
|
1570
|
+
turnsUsed: 0,
|
|
1571
|
+
statusReason: 'resumed',
|
|
1572
|
+
};
|
|
1573
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1574
|
+
|
|
1575
|
+
let releaseAgent;
|
|
1576
|
+
const agentGate = new Promise((resolve) => { releaseAgent = resolve; });
|
|
1577
|
+
let agentStarted = false;
|
|
1578
|
+
const promptBodies = [];
|
|
1579
|
+
|
|
1580
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1581
|
+
const originalFetch = globalThis.fetch;
|
|
1582
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1583
|
+
const reqPath = String(input).split('?')[0];
|
|
1584
|
+
if (pathEndsWith(reqPath, '/agent')) {
|
|
1585
|
+
agentStarted = true;
|
|
1586
|
+
await agentGate;
|
|
1587
|
+
return emptyResponse(204);
|
|
1588
|
+
}
|
|
1589
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1590
|
+
promptBodies.push(JSON.parse(init.body || '{}'));
|
|
1591
|
+
return jsonResponse({
|
|
1592
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
return originalFetch(input, init);
|
|
1596
|
+
};
|
|
1597
|
+
|
|
1598
|
+
try {
|
|
1599
|
+
const runtime = createSessionGoalRuntime({
|
|
1600
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1601
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1602
|
+
getSmallModelService: async () => ({
|
|
1603
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1604
|
+
}),
|
|
1605
|
+
idleQuietMs: 1_000_000,
|
|
1606
|
+
kickoffQuietMs: 1,
|
|
1607
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1608
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
const tickPromise = runtime.runTick('ses-goal', '/repo');
|
|
1612
|
+
const started = Date.now();
|
|
1613
|
+
while (!agentStarted) {
|
|
1614
|
+
if (Date.now() - started > 2000) throw new Error('switchAgent never started');
|
|
1615
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
runtime.processPayload({
|
|
1619
|
+
type: 'session.execution.interrupted',
|
|
1620
|
+
properties: { sessionID: 'ses-goal', reason: 'shutdown' },
|
|
1621
|
+
});
|
|
1622
|
+
releaseAgent();
|
|
1623
|
+
await tickPromise;
|
|
1624
|
+
|
|
1625
|
+
expect(promptBodies).toEqual([]);
|
|
1626
|
+
const dispatch = runtime.getDispatchedContinuation('ses-goal');
|
|
1627
|
+
expect(dispatch === null || dispatch.phase !== 'uncertain').toBe(true);
|
|
1628
|
+
runtime.stop();
|
|
1629
|
+
} finally {
|
|
1630
|
+
globalThis.fetch = originalFetch;
|
|
1631
|
+
cleanup();
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
it('switchAgent selection failure is not reported as prompt-uncertain', async () => {
|
|
1637
|
+
const { store, cleanup } = await makeTempStore();
|
|
1638
|
+
const goal = {
|
|
1639
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1640
|
+
executionGeneration: 0,
|
|
1641
|
+
createdAt: 1,
|
|
1642
|
+
turnsUsed: 0,
|
|
1643
|
+
statusReason: 'resumed',
|
|
1644
|
+
};
|
|
1645
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1646
|
+
const promptBodies = [];
|
|
1647
|
+
|
|
1648
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1649
|
+
const originalFetch = globalThis.fetch;
|
|
1650
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1651
|
+
const reqPath = String(input).split('?')[0];
|
|
1652
|
+
if (pathEndsWith(reqPath, '/agent')) {
|
|
1653
|
+
return new Response(JSON.stringify({ error: 'agent unavailable' }), {
|
|
1654
|
+
status: 500,
|
|
1655
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1659
|
+
promptBodies.push(JSON.parse(init.body || '{}'));
|
|
1660
|
+
return jsonResponse({
|
|
1661
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
return originalFetch(input, init);
|
|
1665
|
+
};
|
|
1666
|
+
|
|
1667
|
+
try {
|
|
1668
|
+
const runtime = createSessionGoalRuntime({
|
|
1669
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1670
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1671
|
+
getSmallModelService: async () => ({
|
|
1672
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1673
|
+
}),
|
|
1674
|
+
idleQuietMs: 1_000_000,
|
|
1675
|
+
kickoffQuietMs: 1,
|
|
1676
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1677
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
await runtime.runTick('ses-goal', '/repo').catch(() => {});
|
|
1681
|
+
expect(promptBodies).toEqual([]);
|
|
1682
|
+
const dispatch = runtime.getDispatchedContinuation('ses-goal');
|
|
1683
|
+
expect(dispatch === null || dispatch.phase !== 'uncertain').toBe(true);
|
|
1684
|
+
runtime.stop();
|
|
1685
|
+
} finally {
|
|
1686
|
+
globalThis.fetch = originalFetch;
|
|
1687
|
+
cleanup();
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
it('persist failure during pause keeps prior committed active state', async () => {
|
|
1693
|
+
const { store, cleanup } = await makeTempStore();
|
|
1694
|
+
const goal = {
|
|
1695
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1696
|
+
executionGeneration: 2,
|
|
1697
|
+
};
|
|
1698
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1699
|
+
|
|
1700
|
+
let failNext = false;
|
|
1701
|
+
const gated = {
|
|
1702
|
+
mutateSessionMetadata: async (id, decide) => {
|
|
1703
|
+
if (failNext) {
|
|
1704
|
+
failNext = false;
|
|
1705
|
+
throw new Error('disk full');
|
|
1706
|
+
}
|
|
1707
|
+
return store.mutateSessionMetadata(id, decide);
|
|
1708
|
+
},
|
|
1709
|
+
get: (id) => store.get(id),
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1712
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1713
|
+
const runtime = createSessionGoalRuntime({
|
|
1714
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1715
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1716
|
+
getSmallModelService: async () => ({
|
|
1717
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"x"}' }),
|
|
1718
|
+
}),
|
|
1719
|
+
idleQuietMs: 1_000_000,
|
|
1720
|
+
kickoffQuietMs: 1,
|
|
1721
|
+
readSessionMetadata: (id) => gated.get(id),
|
|
1722
|
+
mutateSessionMetadata: (id, decide) => gated.mutateSessionMetadata(id, decide),
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
failNext = true;
|
|
1726
|
+
await expect(runtime.pauseGoal('ses-goal', '/repo', 'paused by user')).rejects.toThrow('disk full');
|
|
1727
|
+
// Local epoch still bumped (pause requested) but durable state unchanged.
|
|
1728
|
+
const mid = await store.get('ses-goal');
|
|
1729
|
+
expect(mid.openchamber.goal.status).toBe('active');
|
|
1730
|
+
expect(mid.openchamber.goal.executionGeneration).toBe(2);
|
|
1731
|
+
runtime.stop();
|
|
1732
|
+
cleanup();
|
|
1733
|
+
});
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
it('before-selection pause keeps turnsUsed at 0 and clears reserved pending', async () => {
|
|
1737
|
+
const { store, cleanup } = await makeTempStore();
|
|
1738
|
+
const goal = {
|
|
1739
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1740
|
+
executionGeneration: 0,
|
|
1741
|
+
createdAt: 1,
|
|
1742
|
+
turnsUsed: 0,
|
|
1743
|
+
statusReason: 'resumed',
|
|
1744
|
+
};
|
|
1745
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1746
|
+
|
|
1747
|
+
let releaseAgent;
|
|
1748
|
+
const agentGate = new Promise((resolve) => { releaseAgent = resolve; });
|
|
1749
|
+
let agentStarted = false;
|
|
1750
|
+
const promptBodies = [];
|
|
1751
|
+
|
|
1752
|
+
await withFetch(makeOpenCodeFetch(), async () => {
|
|
1753
|
+
const originalFetch = globalThis.fetch;
|
|
1754
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1755
|
+
const reqPath = String(input).split('?')[0];
|
|
1756
|
+
if (pathEndsWith(reqPath, '/agent')) {
|
|
1757
|
+
agentStarted = true;
|
|
1758
|
+
await agentGate;
|
|
1759
|
+
return emptyResponse(204);
|
|
1760
|
+
}
|
|
1761
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1762
|
+
promptBodies.push(JSON.parse(init.body || '{}'));
|
|
1763
|
+
return jsonResponse({ data: { id: 'inbox_1' } });
|
|
1764
|
+
}
|
|
1765
|
+
return originalFetch(input, init);
|
|
1766
|
+
};
|
|
1767
|
+
try {
|
|
1768
|
+
const runtime = createSessionGoalRuntime({
|
|
1769
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1770
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1771
|
+
getSmallModelService: async () => ({
|
|
1772
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1773
|
+
}),
|
|
1774
|
+
idleQuietMs: 1_000_000,
|
|
1775
|
+
kickoffQuietMs: 1,
|
|
1776
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1777
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1778
|
+
});
|
|
1779
|
+
const tickPromise = runtime.runTick('ses-goal', '/repo');
|
|
1780
|
+
const started = Date.now();
|
|
1781
|
+
while (!agentStarted) {
|
|
1782
|
+
if (Date.now() - started > 2000) throw new Error('switchAgent never started');
|
|
1783
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
1784
|
+
}
|
|
1785
|
+
// Reserved pending should exist before selection completes.
|
|
1786
|
+
const mid = await store.get('ses-goal');
|
|
1787
|
+
expect(mid.openchamber.goal.turnsUsed).toBe(0);
|
|
1788
|
+
expect(parsePendingContinuation(mid.openchamber.goal.pendingContinuation)?.phase).toBe('reserved');
|
|
1789
|
+
|
|
1790
|
+
await runtime.pauseGoal('ses-goal', '/repo', 'paused by user');
|
|
1791
|
+
releaseAgent();
|
|
1792
|
+
await tickPromise;
|
|
1793
|
+
|
|
1794
|
+
expect(promptBodies).toEqual([]);
|
|
1795
|
+
const finalMeta = await store.get('ses-goal');
|
|
1796
|
+
expect(finalMeta.openchamber.goal.turnsUsed).toBe(0);
|
|
1797
|
+
expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
|
|
1798
|
+
expect(finalMeta.openchamber.goal.status).toBe('paused');
|
|
1799
|
+
runtime.stop();
|
|
1800
|
+
} finally {
|
|
1801
|
+
globalThis.fetch = originalFetch;
|
|
1802
|
+
cleanup();
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
it('persist failure after accept keeps accepted pending and reboot counts once', async () => {
|
|
1808
|
+
const { store, cleanup } = await makeTempStore();
|
|
1809
|
+
const messageID = buildContinuationMessageID({ goalId: 'goal-1', generation: 0, turnsUsed: 1 });
|
|
1810
|
+
const knownMessageIDs = new Set([messageID]);
|
|
1811
|
+
// Durable state after accept + crash before turnsUsed commit.
|
|
1812
|
+
await store.setSessionMetadata('ses-goal', {
|
|
1813
|
+
openchamber: {
|
|
1814
|
+
goal: {
|
|
1815
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1816
|
+
executionGeneration: 0,
|
|
1817
|
+
createdAt: 1,
|
|
1818
|
+
turnsUsed: 0,
|
|
1819
|
+
statusReason: 'resumed',
|
|
1820
|
+
pendingContinuation: buildPendingContinuation({
|
|
1821
|
+
messageID,
|
|
1822
|
+
goalId: 'goal-1',
|
|
1823
|
+
generation: 0,
|
|
1824
|
+
turnsUsed: 1,
|
|
1825
|
+
phase: 'accepted',
|
|
1826
|
+
text: 'Continue working toward the active session goal.',
|
|
1827
|
+
providerID: 'p',
|
|
1828
|
+
modelID: 'm',
|
|
1829
|
+
agent: 'build',
|
|
1830
|
+
}),
|
|
1831
|
+
},
|
|
1832
|
+
},
|
|
1833
|
+
});
|
|
1834
|
+
let promptCount = 0;
|
|
1835
|
+
|
|
1836
|
+
await withFetch(makeOpenCodeFetch({ knownMessageIDs }), async () => {
|
|
1837
|
+
const originalFetch = globalThis.fetch;
|
|
1838
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1839
|
+
const reqPath = String(input).split('?')[0];
|
|
1840
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1841
|
+
promptCount += 1;
|
|
1842
|
+
return jsonResponse({
|
|
1843
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
return originalFetch(input, init);
|
|
1847
|
+
};
|
|
1848
|
+
try {
|
|
1849
|
+
const meta = await store.get('ses-goal');
|
|
1850
|
+
expect(parsePendingContinuation(meta.openchamber.goal.pendingContinuation)?.phase).toBe('accepted');
|
|
1851
|
+
expect(meta.openchamber.goal.turnsUsed).toBe(0);
|
|
1852
|
+
|
|
1853
|
+
// Fresh runtime = reboot: reconcile finds message, counts once, no blind new id.
|
|
1854
|
+
const runtime = createSessionGoalRuntime({
|
|
1855
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1856
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1857
|
+
getSmallModelService: async () => ({
|
|
1858
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1859
|
+
}),
|
|
1860
|
+
idleQuietMs: 1_000_000,
|
|
1861
|
+
kickoffQuietMs: 1,
|
|
1862
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1863
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1864
|
+
});
|
|
1865
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
1866
|
+
const after = await store.get('ses-goal');
|
|
1867
|
+
expect(after.openchamber.goal.turnsUsed).toBe(1);
|
|
1868
|
+
expect(after.openchamber.goal.pendingContinuation == null).toBe(true);
|
|
1869
|
+
expect(promptCount).toBe(0);
|
|
1870
|
+
runtime.stop();
|
|
1871
|
+
} finally {
|
|
1872
|
+
globalThis.fetch = originalFetch;
|
|
1873
|
+
cleanup();
|
|
1874
|
+
}
|
|
1875
|
+
});
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
it('transport drop after accept pauses with stable identity and reboot reconciles', async () => {
|
|
1879
|
+
const { store, cleanup } = await makeTempStore();
|
|
1880
|
+
const goal = {
|
|
1881
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1882
|
+
executionGeneration: 0,
|
|
1883
|
+
createdAt: 1,
|
|
1884
|
+
turnsUsed: 0,
|
|
1885
|
+
statusReason: 'resumed',
|
|
1886
|
+
};
|
|
1887
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1888
|
+
const knownMessageIDs = new Set();
|
|
1889
|
+
let promptAttempts = 0;
|
|
1890
|
+
|
|
1891
|
+
await withFetch(makeOpenCodeFetch({ knownMessageIDs }), async () => {
|
|
1892
|
+
const originalFetch = globalThis.fetch;
|
|
1893
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1894
|
+
const reqPath = String(input).split('?')[0];
|
|
1895
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1896
|
+
promptAttempts += 1;
|
|
1897
|
+
const body = JSON.parse(init.body || '{}');
|
|
1898
|
+
// Simulate: upstream accepted the id, then transport dropped the response.
|
|
1899
|
+
if (body.id) knownMessageIDs.add(body.id);
|
|
1900
|
+
throw new Error('socket hang up after accept');
|
|
1901
|
+
}
|
|
1902
|
+
return originalFetch(input, init);
|
|
1903
|
+
};
|
|
1904
|
+
try {
|
|
1905
|
+
const runtime = createSessionGoalRuntime({
|
|
1906
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1907
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1908
|
+
getSmallModelService: async () => ({
|
|
1909
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1910
|
+
}),
|
|
1911
|
+
idleQuietMs: 1_000_000,
|
|
1912
|
+
kickoffQuietMs: 1,
|
|
1913
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1914
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1915
|
+
});
|
|
1916
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
1917
|
+
const mid = await store.get('ses-goal');
|
|
1918
|
+
expect(mid.openchamber.goal.status).toBe('paused');
|
|
1919
|
+
expect(String(mid.openchamber.goal.statusReason)).toMatch(/uncertain|continuation/i);
|
|
1920
|
+
const pending = parsePendingContinuation(mid.openchamber.goal.pendingContinuation);
|
|
1921
|
+
expect(pending).toBeTruthy();
|
|
1922
|
+
expect(pending.messageID).toMatch(/^msg_goalc_/);
|
|
1923
|
+
expect(mid.openchamber.goal.turnsUsed).toBe(0);
|
|
1924
|
+
expect(promptAttempts).toBe(1);
|
|
1925
|
+
|
|
1926
|
+
// Reboot + resume: reconcile finds the message, counts once, no second prompt.
|
|
1927
|
+
runtime.stop();
|
|
1928
|
+
await store.mutateSessionMetadata('ses-goal', (current) => ({
|
|
1929
|
+
ok: true,
|
|
1930
|
+
patch: {
|
|
1931
|
+
openchamber: {
|
|
1932
|
+
goal: {
|
|
1933
|
+
...current.openchamber.goal,
|
|
1934
|
+
status: 'active',
|
|
1935
|
+
statusReason: 'resumed',
|
|
1936
|
+
executionGeneration: current.openchamber.goal.executionGeneration + 1,
|
|
1937
|
+
},
|
|
1938
|
+
},
|
|
1939
|
+
},
|
|
1940
|
+
}));
|
|
1941
|
+
// Keep pending with original generation for stale-gen found path.
|
|
1942
|
+
const runtime2 = createSessionGoalRuntime({
|
|
1943
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
1944
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
1945
|
+
getSmallModelService: async () => ({
|
|
1946
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
1947
|
+
}),
|
|
1948
|
+
idleQuietMs: 1_000_000,
|
|
1949
|
+
kickoffQuietMs: 1,
|
|
1950
|
+
readSessionMetadata: (id) => store.get(id),
|
|
1951
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
1952
|
+
});
|
|
1953
|
+
await runtime2.runTick('ses-goal', '/repo');
|
|
1954
|
+
const after = await store.get('ses-goal');
|
|
1955
|
+
// Found under stale gen → counted once; may continue a new turn after.
|
|
1956
|
+
expect(after.openchamber.goal.turnsUsed).toBeGreaterThanOrEqual(1);
|
|
1957
|
+
expect(after.openchamber.goal.pendingContinuation == null
|
|
1958
|
+
|| after.openchamber.goal.turnsUsed >= 1).toBe(true);
|
|
1959
|
+
runtime2.stop();
|
|
1960
|
+
} finally {
|
|
1961
|
+
globalThis.fetch = originalFetch;
|
|
1962
|
+
cleanup();
|
|
1963
|
+
}
|
|
1964
|
+
});
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
it('reboot with reserved pending re-dispatches same message id and fixed payload', async () => {
|
|
1968
|
+
const { store, cleanup } = await makeTempStore();
|
|
1969
|
+
const messageID = buildContinuationMessageID({ goalId: 'goal-1', generation: 0, turnsUsed: 1 });
|
|
1970
|
+
const fixedText = 'Continue working toward the active session goal.\nFIXED-PAYLOAD-MARKER';
|
|
1971
|
+
const pending = buildPendingContinuation({
|
|
1972
|
+
messageID,
|
|
1973
|
+
goalId: 'goal-1',
|
|
1974
|
+
generation: 0,
|
|
1975
|
+
turnsUsed: 1,
|
|
1976
|
+
phase: 'reserved',
|
|
1977
|
+
text: fixedText,
|
|
1978
|
+
providerID: 'p',
|
|
1979
|
+
modelID: 'm',
|
|
1980
|
+
agent: 'build',
|
|
1981
|
+
});
|
|
1982
|
+
const goal = {
|
|
1983
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
1984
|
+
executionGeneration: 0,
|
|
1985
|
+
createdAt: 1,
|
|
1986
|
+
turnsUsed: 0,
|
|
1987
|
+
statusReason: 'resumed',
|
|
1988
|
+
pendingContinuation: pending,
|
|
1989
|
+
};
|
|
1990
|
+
await store.setSessionMetadata('ses-goal', { openchamber: { goal } });
|
|
1991
|
+
const promptBodies = [];
|
|
1992
|
+
const knownMessageIDs = new Set();
|
|
1993
|
+
|
|
1994
|
+
await withFetch(makeOpenCodeFetch({ knownMessageIDs }), async () => {
|
|
1995
|
+
const originalFetch = globalThis.fetch;
|
|
1996
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
1997
|
+
const reqPath = String(input).split('?')[0];
|
|
1998
|
+
if (pathEndsWith(reqPath, '/prompt') || pathEndsWith(reqPath, '/prompt_async')) {
|
|
1999
|
+
const body = JSON.parse(init.body || '{}');
|
|
2000
|
+
promptBodies.push(body);
|
|
2001
|
+
if (body.id) knownMessageIDs.add(body.id);
|
|
2002
|
+
return jsonResponse({
|
|
2003
|
+
data: { id: 'inbox_1', type: 'user', sessionID: 'ses-goal', time: { created: 1 }, payload: { text: 'x' }, delivery: 'steer' },
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
return originalFetch(input, init);
|
|
2007
|
+
};
|
|
2008
|
+
try {
|
|
2009
|
+
const runtime = createSessionGoalRuntime({
|
|
2010
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
2011
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
2012
|
+
getSmallModelService: async () => ({
|
|
2013
|
+
generateSmallModelText: async () => ({ text: '{"verdict":"continue","note":"go"}' }),
|
|
2014
|
+
}),
|
|
2015
|
+
idleQuietMs: 1_000_000,
|
|
2016
|
+
kickoffQuietMs: 1,
|
|
2017
|
+
readSessionMetadata: (id) => store.get(id),
|
|
2018
|
+
mutateSessionMetadata: (id, decide) => store.mutateSessionMetadata(id, decide),
|
|
2019
|
+
});
|
|
2020
|
+
await runtime.runTick('ses-goal', '/repo');
|
|
2021
|
+
expect(promptBodies.length).toBe(1);
|
|
2022
|
+
expect(promptBodies[0].id).toBe(messageID);
|
|
2023
|
+
expect(promptBodies[0].text).toBe(fixedText);
|
|
2024
|
+
const finalMeta = await store.get('ses-goal');
|
|
2025
|
+
expect(finalMeta.openchamber.goal.turnsUsed).toBe(1);
|
|
2026
|
+
expect(finalMeta.openchamber.goal.pendingContinuation == null).toBe(true);
|
|
2027
|
+
runtime.stop();
|
|
2028
|
+
} finally {
|
|
2029
|
+
globalThis.fetch = originalFetch;
|
|
2030
|
+
cleanup();
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
it('pending mutate is invisible to readers until persist succeeds', async () => {
|
|
2036
|
+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-goal-pending-'));
|
|
2037
|
+
let unlock;
|
|
2038
|
+
const gate = new Promise((resolve) => { unlock = resolve; });
|
|
2039
|
+
const fsPromises = {
|
|
2040
|
+
...fs.promises,
|
|
2041
|
+
writeFile: async (target, payload, encoding) => {
|
|
2042
|
+
await gate;
|
|
2043
|
+
return fs.promises.writeFile(target, payload, encoding);
|
|
2044
|
+
},
|
|
2045
|
+
};
|
|
2046
|
+
const store = createSessionMetadataStore({ dataDir, fsPromises });
|
|
2047
|
+
// First write without gate
|
|
2048
|
+
const warm = createSessionMetadataStore({ dataDir });
|
|
2049
|
+
await warm.setSessionMetadata('ses-goal', {
|
|
2050
|
+
openchamber: {
|
|
2051
|
+
goal: {
|
|
2052
|
+
...buildSession('active').metadata.openchamber.goal,
|
|
2053
|
+
executionGeneration: 0,
|
|
2054
|
+
},
|
|
2055
|
+
},
|
|
2056
|
+
});
|
|
2057
|
+
await store.load();
|
|
2058
|
+
|
|
2059
|
+
const pending = store.mutateSessionMetadata('ses-goal', () => ({
|
|
2060
|
+
ok: true,
|
|
2061
|
+
patch: { openchamber: { goal: { status: 'paused', executionGeneration: 1 } } },
|
|
2062
|
+
}));
|
|
2063
|
+
await expect(store.get('ses-goal')).resolves.toMatchObject({
|
|
2064
|
+
openchamber: { goal: { status: 'active', executionGeneration: 0 } },
|
|
2065
|
+
});
|
|
2066
|
+
unlock();
|
|
2067
|
+
await pending;
|
|
2068
|
+
await expect(store.get('ses-goal')).resolves.toMatchObject({
|
|
2069
|
+
openchamber: { goal: { status: 'paused', executionGeneration: 1 } },
|
|
2070
|
+
});
|
|
2071
|
+
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
2072
|
+
});
|
|
2073
|
+
});
|