@openchambery/web 2.0.0-beta.26 → 2.0.0-beta.29
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 +14 -0
- package/dist/assets/{AgentAvatar-FyTdEOMr.js → AgentAvatar-O2x_1cFU.js} +1 -1
- package/dist/assets/{AssistantView-DvOO2Kf5.js → AssistantView-Bphg-QIn.js} +3 -3
- package/dist/assets/{DiagramView-BqjeSOIg.js → DiagramView-CRD_GFch.js} +1 -1
- package/dist/assets/{FadeInOnReveal-DHDffzHs.js → FadeInOnReveal-CMBd5BTd.js} +1 -1
- package/dist/assets/{Icon-D7NdMmbi.js → Icon-BN87CyS4.js} +1 -1
- package/dist/assets/{MarkdownRenderer-kpkRqS42.js → MarkdownRenderer-D5KEGdBn.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-CzBos-Gt.js → MarkdownRendererImpl-C143YQsV.js} +2 -2
- package/dist/assets/{MarkstreamRendererImpl-RY_rAgKD.js → MarkstreamRendererImpl-C4rgOuOP.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DgtRh0zu.js → MobileDetailNavigation-D5tcF2Kg.js} +1 -1
- package/dist/assets/{MobileShareBridge-BM3cN-gG.js → MobileShareBridge-Bm0JZKHc.js} +3 -3
- package/dist/assets/MobileSurface-BzzBCVkG.js +2 -0
- package/dist/assets/{MultiRunWindow-Bq4gz78C.js → MultiRunWindow-B6Sb3aEG.js} +1 -1
- package/dist/assets/{OnboardingScreen-DMbPMwlp.js → OnboardingScreen-BLjZIBOs.js} +1 -1
- package/dist/assets/{OverlayScrollbar-mI9WVa7E.js → OverlayScrollbar-BOqRklf0.js} +1 -1
- package/dist/assets/SettingsView-Ds6x2z0F.js +25 -0
- package/dist/assets/{SettingsWindow-CHPjbEnI.js → SettingsWindow-D6CN5ahJ.js} +1 -1
- package/dist/assets/{TerminalView-DCJuL2nP.js → TerminalView-DkzaJJfh.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Cg_Q1t3X.js → ToolOutputDialog-BegFpg1h.js} +1 -1
- package/dist/assets/{appThemeRegistry-BxuflaQt.js → appThemeRegistry-BMzuqD8_.js} +1 -1
- package/dist/assets/{button-e8fYWIMM.js → button-Ck8ipzBm.js} +1 -1
- package/dist/assets/{collapsible-B6wLXHi3.js → collapsible-nU99prkD.js} +1 -1
- package/dist/assets/{context-menu-Bq7W1d7y.js → context-menu-DMzZN1oi.js} +1 -1
- package/dist/assets/{desktopHosts-C8vf9vRY.js → desktopHosts-DuftEj8B.js} +1 -1
- package/dist/assets/{desktopRelayRestore-DDJtljeu.js → desktopRelayRestore-g378BdZf.js} +1 -1
- package/dist/assets/{diagram-BBL-LfsC.js → diagram-CvO4P8ds.js} +1 -1
- package/dist/assets/{dialog-MRsJpHzu.js → dialog-ByGUkS3c.js} +1 -1
- package/dist/assets/{es-DvPwAj0y.js → es-DCjl24dV.js} +3 -3
- package/dist/assets/fileMentionTouchSelection-Cuk9C_jN.js +2 -0
- package/dist/assets/{fileQueries-CBu-ytXS.js → fileQueries-DuF89nFO.js} +1 -1
- package/dist/assets/{fr-XQsoaNKY.js → fr-DfJ5j0t5.js} +3 -3
- package/dist/assets/{gitApi-CuIUGt04.js → gitApi-BlWYyRdx.js} +3 -3
- package/dist/assets/{input-DbOe1mdT.js → input-CXhFmHcq.js} +1 -1
- package/dist/assets/insertionBoundaries-lAwXo-R-.js +1 -0
- package/dist/assets/{ja-DslzO96s.js → ja-B_9_w2Xf.js} +3 -3
- package/dist/assets/{ko-C_9XOAL0.js → ko-DlGbo3kV.js} +3 -3
- package/dist/assets/{main-Sd4Auh_x.js → main-BmiKRvkB.js} +2 -2
- package/dist/assets/main-D5SdJE5d.js +169 -0
- package/dist/assets/main-qSeeJRW1.css +1 -0
- package/dist/assets/{markdownHeightCache-B5VSLl79.js → markdownHeightCache-g_UsWzbA.js} +1 -1
- package/dist/assets/{mcpQueries-BYNrkehM.js → mcpQueries-0Oe-8vHC.js} +1 -1
- package/dist/assets/{miniChat-Hp9bNmS0.js → miniChat-CSwFHa9K.js} +2 -2
- package/dist/assets/mobile-DzJO7usb.js +2 -0
- package/dist/assets/multirun-Bdd0yE3M.js +3 -0
- package/dist/assets/persistence-Dnj1QOdX.js +3 -0
- package/dist/assets/{pl-Dst9Wk53.js → pl-DjUsS8Ei.js} +3 -3
- package/dist/assets/platform-pfYy32vr.js +6 -0
- package/dist/assets/projectMeta-2dbON46t.js +1 -0
- package/dist/assets/{pt-BR-Dq394BPb.js → pt-BR-B8sV7qo1.js} +3 -3
- package/dist/assets/renderElectronMiniChatApp-Bjf5yS-2.js +2 -0
- package/dist/assets/renderMobileApp-Bv0VxQo1.js +28 -0
- package/dist/assets/{runtimeConfig-pTYamMLy.js → runtimeConfig-BOk04YEP.js} +1 -1
- package/dist/assets/runtimeEndpointReset-DsuP5FPT.css +1 -0
- package/dist/assets/runtimeEndpointReset-Oo4f7T5P.js +148 -0
- package/dist/assets/{sessionChangeSummary-DcnZyuWt.js → sessionChangeSummary-Bf5wJLSv.js} +2 -2
- package/dist/assets/{sessionLookup-B3t-FerN.js → sessionLookup-pga3-Etf.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-B0sZ7mm6.js → sortable-tabs-strip-CHqMkTI3.js} +1 -1
- package/dist/assets/{store-D8fmTWK3.js → store-Cuktref-.js} +3 -3
- package/dist/assets/{terminalApi-CJyWCuc8.js → terminalApi-BgGraKxs.js} +1 -1
- package/dist/assets/{textarea-B5GMntHk.js → textarea-B0Od84ZM.js} +1 -1
- package/dist/assets/{tooltip-C64SdRvR.js → tooltip-BKL7J5hx.js} +1 -1
- package/dist/assets/{uk-8XAR75Qh.js → uk-Bh7HXyLp.js} +3 -3
- package/dist/assets/{url-DF1NFwt_.js → url-DNRMq_0R.js} +1 -1
- package/dist/assets/{useAppFontEffects-BQklk6XB.js → useAppFontEffects-gMl7z7An.js} +53 -53
- package/dist/assets/{useAssistantUIStore-B75cQm_z.js → useAssistantUIStore-DBwfg9nX.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-GOgauAay.js +1 -0
- package/dist/assets/{useEffectiveDirectory-Ddx7sZ5A.js → useEffectiveDirectory-DgAB7HWq.js} +1 -1
- package/dist/assets/{useFontPreferences-CrteQiAp.js → useFontPreferences-Djxu_uRy.js} +1 -1
- package/dist/assets/useMobileNavigationStore-D7uAoBXe.js +415 -0
- package/dist/assets/useSessionAutoCleanup-Cg7KJFGa.js +2 -0
- package/dist/assets/{useUIStore-Brsa2mKs.js → useUIStore-Dfx6TKvi.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-VOtVUGkO.js → useWorkerHighlightedLines-DKa2EWBH.js} +1 -1
- package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
- package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
- package/dist/assets/{zh-CN-B-ILfrqC.js → zh-CN-Uzia0gk4.js} +3 -3
- package/dist/assets/{zh-TW-5HeTVP4z.js → zh-TW-CiI7DLdB.js} +3 -3
- package/dist/index.html +10 -10
- package/dist/mini-chat.html +10 -10
- package/dist/mobile.html +10 -10
- package/package.json +3 -3
- package/server/index.js +8 -20
- package/server/lib/git/DOCUMENTATION.md +1 -0
- package/server/lib/git/routes.js +7 -4
- package/server/lib/git/routes.test.js +34 -0
- package/server/lib/git/service.js +3 -1
- package/server/lib/llm/DOCUMENTATION.md +6 -4
- package/server/lib/llm/generate.js +8 -9
- package/server/lib/llm/generate.test.js +33 -6
- package/server/lib/opencode/DOCUMENTATION.md +24 -20
- package/server/lib/opencode/agents.js +13 -22
- package/server/lib/opencode/auth-state-runtime.js +3 -3
- package/server/lib/opencode/auth-state-runtime.test.js +59 -0
- package/server/lib/opencode/auth.d.ts +13 -0
- package/server/lib/opencode/auth.js +74 -64
- package/server/lib/opencode/auth.test.js +57 -0
- package/server/lib/opencode/commands.js +1 -1
- package/server/lib/opencode/config-entities-layering.test.js +72 -0
- package/server/lib/opencode/config-entity-routes.js +9 -122
- package/server/lib/opencode/config-entity-routes.test.js +7 -11
- package/server/lib/opencode/config-files.d.ts +17 -0
- package/server/lib/opencode/config-files.js +187 -0
- package/server/lib/opencode/config-files.test.js +201 -0
- package/server/lib/opencode/core-routes.js +0 -1
- package/server/lib/opencode/core-routes.test.js +0 -15
- package/server/lib/opencode/credential-boundary.d.ts +1 -0
- package/server/lib/opencode/credential-boundary.js +11 -0
- package/server/lib/opencode/ensure-cli.js +4 -1
- package/server/lib/opencode/env-config.js +4 -1
- package/server/lib/opencode/env-config.test.js +28 -0
- package/server/lib/opencode/env-runtime.test.js +2 -1
- package/server/lib/opencode/feature-routes-runtime.js +9 -7
- package/server/lib/opencode/hmr-state-runtime.js +4 -4
- package/server/lib/opencode/lifecycle.js +1 -49
- package/server/lib/opencode/lifecycle.test.js +30 -4
- package/server/lib/opencode/location-release.d.ts +8 -0
- package/server/lib/opencode/location-release.js +32 -0
- package/server/lib/opencode/location-release.test.js +43 -0
- package/server/lib/opencode/mcp.js +1 -1
- package/server/lib/opencode/opencode2-pin.js +2 -1
- package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
- package/server/lib/opencode/plugin-routes.test.js +4 -2
- package/server/lib/opencode/plugins.js +12 -60
- package/server/lib/opencode/plugins.test.js +5 -3
- package/server/lib/opencode/providers.js +6 -3
- package/server/lib/opencode/proxy.js +7 -0
- package/server/lib/opencode/proxy.test.js +13 -0
- package/server/lib/opencode/routes.js +5 -5
- package/server/lib/opencode/routes.test.js +18 -18
- package/server/lib/opencode/runtime-contract.js +3 -2
- package/server/lib/opencode/runtime-contract.test.js +12 -12
- package/server/lib/opencode/shared.js +6 -175
- package/server/lib/opencode/upgrade-screen.js +4 -11
- package/server/lib/opencode/upgrade-screen.test.js +25 -25
- package/server/lib/opencode/version-alignment.test.js +40 -0
- package/server/lib/quota/DOCUMENTATION.md +9 -0
- package/server/lib/quota/provider-credentials.test.js +29 -0
- package/server/lib/quota/providers/claude.js +4 -4
- package/server/lib/quota/providers/codex.js +4 -4
- package/server/lib/quota/providers/codex.test.js +1 -1
- package/server/lib/quota/providers/copilot.js +5 -5
- package/server/lib/quota/providers/google/auth.js +3 -3
- package/server/lib/quota/providers/google/index.js +2 -2
- package/server/lib/quota/providers/index.js +3 -7
- package/server/lib/quota/providers/index.test.js +2 -2
- package/server/lib/quota/providers/kimi.js +4 -4
- package/server/lib/quota/providers/minimax-shared.js +4 -4
- package/server/lib/quota/providers/nanogpt.js +4 -4
- package/server/lib/quota/providers/openai.js +4 -4
- package/server/lib/quota/providers/openrouter.js +4 -4
- package/server/lib/quota/providers/wafer.js +4 -4
- package/server/lib/quota/providers/zai.js +4 -4
- package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
- package/server/lib/quota/routes.js +1 -1
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +1 -0
- package/server/lib/scheduled-tasks/runtime.js +2 -3
- package/server/lib/session-index/DOCUMENTATION.md +7 -0
- package/server/lib/session-index/event-ingest.js +13 -0
- package/server/lib/session-index/event-ingest.test.js +7 -0
- package/server/lib/session-index/service.js +45 -0
- package/server/lib/session-index/service.test.js +47 -0
- package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
- package/server/lib/session-metadata/session-projection.d.ts +1 -0
- package/server/lib/session-metadata/session-projection.js +23 -0
- package/server/lib/session-metadata/session-projection.test.js +35 -0
- package/server/lib/session-title/DOCUMENTATION.md +36 -8
- package/server/lib/session-title/native-runtime.test.js +42 -0
- package/server/lib/session-title/runtime.js +18 -8
- package/server/lib/session-title/session-access.js +108 -3
- package/server/lib/session-title/session-access.test.js +125 -7
- package/server/lib/tts/DOCUMENTATION.md +7 -5
- package/server/lib/tts/credential-source.test.js +23 -0
- package/server/lib/tts/routes.js +2 -2
- package/server/lib/tts/service.js +16 -37
- package/dist/assets/MobileSurface-ocVWenHA.js +0 -2
- package/dist/assets/SettingsView-CIZz40Jg.js +0 -25
- package/dist/assets/fileMentionTouchSelection-Bj67pPo7.js +0 -3
- package/dist/assets/insertionBoundaries-nzs1Q3YR.js +0 -1
- package/dist/assets/main-D2BzpnUt.css +0 -1
- package/dist/assets/main-DjilfYN7.js +0 -169
- package/dist/assets/mobile-fBLEOx9_.js +0 -2
- package/dist/assets/multirun-D7W7oCj4.js +0 -4
- package/dist/assets/persistence-CMWO3jFo.js +0 -3
- package/dist/assets/platform-B0p43squ.js +0 -6
- package/dist/assets/projectMeta-DrUKrYt0.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-D1xipCO5.js +0 -2
- package/dist/assets/renderMobileApp-DSk7Qs-_.js +0 -28
- package/dist/assets/runtimeEndpointReset-B5HEMuiB.js +0 -149
- package/dist/assets/useDesktopWindowControlsLayout-CrzYwZDQ.js +0 -1
- package/dist/assets/useMobileNavigationStore-i4cXNoOf.js +0 -414
- package/dist/assets/useSessionAutoCleanup-Dzd0fGSe.js +0 -2
- package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
- package/server/lib/opencode/snippets.js +0 -242
- package/server/lib/opencode/snippets.test.js +0 -97
|
@@ -4,8 +4,8 @@ Server-side watcher that regenerates a session's sidebar title from the
|
|
|
4
4
|
conversation's **main subject** (overall feature / goal) with the small model
|
|
5
5
|
(`lib/small-model`), then PATCHes `title` plus `metadata.openchamber.titleRefresh`.
|
|
6
6
|
|
|
7
|
-
OpenCode
|
|
8
|
-
|
|
7
|
+
OpenCode starts its own initial title through `SessionTitle.generate`.
|
|
8
|
+
This module auto-refreshes sparsely (first user
|
|
9
9
|
admission of a new session, first newly-sent reply on a fork) and on explicit smart-title
|
|
10
10
|
requests, naming the durable work being done — not the last wrap-up utterance
|
|
11
11
|
like "commit and push". Background auto refreshes still respect a 5-minute
|
|
@@ -127,6 +127,8 @@ and other session updates keep the existing ordering timestamp unchanged.
|
|
|
127
127
|
`sessionTitleRefreshEnabled` in OpenChamber settings (Settings → Chat,
|
|
128
128
|
default on) controls background title refreshes. Explicit smart-title actions
|
|
129
129
|
still run when background refresh is disabled.
|
|
130
|
+
The gate controls model generation, not synchronization of an already committed
|
|
131
|
+
title authority; reconciliation continues without additional model calls.
|
|
130
132
|
|
|
131
133
|
Settings → Summary AI controls the title model and the optional
|
|
132
134
|
`summarySessionTitlePrompt` override. The configured custom API receives the
|
|
@@ -134,10 +136,36 @@ title transcript only after the user explicitly selects that source.
|
|
|
134
136
|
|
|
135
137
|
## Manual rename contract
|
|
136
138
|
|
|
137
|
-
|
|
138
|
-
`
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
OpenChamber owns the title once it generates one or the user renames it here.
|
|
140
|
+
`metadata.openchamber.titleAuthority` stores `{ sessionID, title, revision,
|
|
141
|
+
source }` on the OpenCode session via the metadata store. `sessionID` prevents
|
|
142
|
+
forks inheriting a parent's title lock. Existing `lastAutoTitle` records are not
|
|
143
|
+
promoted: they cannot distinguish an external manual rename from native generation.
|
|
144
|
+
|
|
145
|
+
`session-access.js` commits the authority before writing the official
|
|
146
|
+
`session.update({ sessionID, title })`. A conditional metadata mutation checks
|
|
147
|
+
the revision captured before model generation, so a late model result cannot
|
|
148
|
+
undo a newer manual rename. Even output equal to the current title claims
|
|
149
|
+
ownership. Sidebar rename commits a new manual authority before its official
|
|
150
|
+
title update. Smart-title requests explicitly allow a new summary generation.
|
|
151
|
+
Ordinary follow-up messages still do not regenerate titles.
|
|
152
|
+
|
|
153
|
+
Native `session.renamed`, full session updates and Host metadata writes trigger
|
|
154
|
+
coalesced per-session reconciliation. It reads current authority and upstream
|
|
155
|
+
title, writes only on drift, then verifies again. Duplicate/out-of-order events
|
|
156
|
+
are hints, never authority. Retries use exponential backoff capped at 60 seconds;
|
|
157
|
+
404 and shutdown stop pending work. Connection/startup recovery checks persisted
|
|
158
|
+
owned sessions serially, without model calls. Failed upstream writes retain the
|
|
159
|
+
durable intent and report failure rather than claiming successful synchronization.
|
|
160
|
+
|
|
161
|
+
List/detail and live/replayed rename projections retain the owned title while
|
|
162
|
+
repair runs; the real OpenCode `session.title` is repaired as well. The upstream
|
|
163
|
+
API has no atomic title lock, so other OpenCode clients can briefly observe a
|
|
164
|
+
conflicting write until repair completes. A running, connected Host is required
|
|
165
|
+
for repair; restart/reconnect recovers missed writes. Direct OpenCode renames
|
|
166
|
+
(manual or generated) cannot replace an owned title: rename in OpenChamber to
|
|
167
|
+
change it. Extension-only VS Code receives shared metadata/projection behavior,
|
|
168
|
+
but repair requires a connected OpenChamber Host.
|
|
141
169
|
|
|
142
170
|
## Limitations
|
|
143
171
|
|
|
@@ -154,5 +182,5 @@ same file descriptors. This does not perform image recognition or file parsing.
|
|
|
154
182
|
- Lives in the web server, so VS Code (extension-only) does not generate
|
|
155
183
|
refreshes; it still receives `session.updated` title changes produced by a
|
|
156
184
|
web/desktop instance of the same OpenCode server.
|
|
157
|
-
- First OpenCode-generated
|
|
158
|
-
|
|
185
|
+
- First OpenCode-generated titles may be replaced by OpenChamber's initial title,
|
|
186
|
+
regardless of which model completes first.
|
|
@@ -55,6 +55,29 @@ it.each([false, true])('runs a Host smart-title request and clears loading (mode
|
|
|
55
55
|
}
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it('does not start a pending initial title over a manual authority committed before generation', async () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
const generate = vi.fn();
|
|
61
|
+
const session = { id: 'ses_manual', title: 'Manual', metadata: { openchamber: {
|
|
62
|
+
titleAuthority: { sessionID: 'ses_manual', title: 'Manual', revision: 'manual', source: 'manual' },
|
|
63
|
+
} } };
|
|
64
|
+
const runtime = createSessionTitleRuntime({
|
|
65
|
+
sessionAccess: {
|
|
66
|
+
get: async () => session,
|
|
67
|
+
messages: async () => [{ info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Initial work' }] }],
|
|
68
|
+
update: async () => {},
|
|
69
|
+
},
|
|
70
|
+
getSmallModelService: async () => ({ generateSmallModelText: generate }),
|
|
71
|
+
isTitleRefreshEnabled: () => true,
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
runtime.processPayload({ type: 'session.created', data: { sessionID: session.id } });
|
|
75
|
+
runtime.processPayload({ type: 'session.execution.succeeded', data: { sessionID: session.id } });
|
|
76
|
+
await vi.runAllTimersAsync();
|
|
77
|
+
expect(generate).not.toHaveBeenCalled();
|
|
78
|
+
} finally { runtime.stop(); }
|
|
79
|
+
});
|
|
80
|
+
|
|
58
81
|
it('uses the real v2 client and publishes the generated title without another session click', async () => {
|
|
59
82
|
vi.useFakeTimers();
|
|
60
83
|
configureServerOpenCodeFetchGate(null, { allowMissingContract: true });
|
|
@@ -85,6 +108,12 @@ it('uses the real v2 client and publishes the generated title without another se
|
|
|
85
108
|
buildOpenCodeUrl: (path) => `http://opencode${path}`,
|
|
86
109
|
getOpenCodeAuthHeaders: () => ({}),
|
|
87
110
|
readSessionMetadata: async () => metadata,
|
|
111
|
+
mutateSessionMetadata: async (_id, decide) => {
|
|
112
|
+
const decision = decide(metadata);
|
|
113
|
+
if (!decision.ok) return { committed: false };
|
|
114
|
+
metadata = mergeMetadataPatch(metadata, decision.patch);
|
|
115
|
+
return { committed: true, metadata };
|
|
116
|
+
},
|
|
88
117
|
persistSessionMetadata: async (_id, patch) => {
|
|
89
118
|
metadata = mergeMetadataPatch(metadata, patch);
|
|
90
119
|
events.push({ type: 'metadata', metadata: structuredClone(metadata) });
|
|
@@ -105,6 +134,19 @@ it('uses the real v2 client and publishes the generated title without another se
|
|
|
105
134
|
expect(events.at(-1)).toMatchObject({ type: 'session', row: { title: 'Title refresh', directory: '/repo' } });
|
|
106
135
|
expect(metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
|
|
107
136
|
expect(requests).toContain('PATCH /api/session/ses_title');
|
|
137
|
+
session.title = 'Late native title';
|
|
138
|
+
runtime.processPayload({ type: 'session.renamed', created: 2000, data: {
|
|
139
|
+
sessionID: session.id, title: session.title,
|
|
140
|
+
} });
|
|
141
|
+
await vi.runAllTimersAsync();
|
|
142
|
+
expect(session.title).toBe('Title refresh');
|
|
143
|
+
expect(events.at(-1)).toMatchObject({ type: 'session', row: { title: 'Title refresh' } });
|
|
144
|
+
const writes = requests.filter((request) => request.startsWith('PATCH')).length;
|
|
145
|
+
runtime.processPayload({ type: 'session.renamed', created: 500, data: {
|
|
146
|
+
sessionID: session.id, title: 'Replayed stale title',
|
|
147
|
+
} });
|
|
148
|
+
await vi.runAllTimersAsync();
|
|
149
|
+
expect(requests.filter((request) => request.startsWith('PATCH'))).toHaveLength(writes);
|
|
108
150
|
} finally {
|
|
109
151
|
runtime.stop();
|
|
110
152
|
}
|
|
@@ -46,8 +46,8 @@ const FORK_TITLE_RE = / \(fork #\d+\)$/;
|
|
|
46
46
|
const MULTI_RUN_GROUP_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
|
|
47
47
|
const MULTI_RUN_GROUP = /^g[1-9]\d*$/;
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
49
|
+
// Generation switch (default on). Existing owned titles still reconcile when
|
|
50
|
+
// generation is off; explicit smart-title requests also remain available.
|
|
51
51
|
const isSessionTitleRefreshEnabled = () => {
|
|
52
52
|
try {
|
|
53
53
|
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
|
|
@@ -603,6 +603,8 @@ export const createSessionTitleRuntime = ({
|
|
|
603
603
|
|
|
604
604
|
const currentTitle = typeof session.title === 'string' ? session.title : '';
|
|
605
605
|
if (looksLikeMultiRunSessionTitle(currentTitle)) return;
|
|
606
|
+
const authority = session.metadata?.openchamber?.titleAuthority;
|
|
607
|
+
if (!forceRefresh && authority?.sessionID === sessionId && authority.source === 'manual') return;
|
|
606
608
|
|
|
607
609
|
const meta = readTitleRefreshMeta(session);
|
|
608
610
|
const canRefreshForkTitle = forkFirstRefresh && isForkedSessionTitle(currentTitle);
|
|
@@ -700,12 +702,7 @@ export const createSessionTitleRuntime = ({
|
|
|
700
702
|
}
|
|
701
703
|
|
|
702
704
|
const nextTitle = cleanGeneratedTitle(generated?.text);
|
|
703
|
-
if (!nextTitle) return;
|
|
704
|
-
if (nextTitle === currentTitle) {
|
|
705
|
-
// Still stamp metadata so we do not keep re-calling for the same tail.
|
|
706
|
-
lastGeneratedAtBySession.set(sessionId, now());
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
705
|
+
if (!nextTitle || stopped) return;
|
|
709
706
|
|
|
710
707
|
// Re-check before write: user may have renamed, or a new message arrived.
|
|
711
708
|
const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
|
|
@@ -742,6 +739,8 @@ export const createSessionTitleRuntime = ({
|
|
|
742
739
|
method: 'PATCH',
|
|
743
740
|
body: {
|
|
744
741
|
title: nextTitle,
|
|
742
|
+
expectedTitleRevision: session.metadata?.openchamber?.titleAuthority?.sessionID === sessionId
|
|
743
|
+
? session.metadata.openchamber.titleAuthority.revision : null,
|
|
745
744
|
metadata: {
|
|
746
745
|
openchamber: {
|
|
747
746
|
titleRefresh: {
|
|
@@ -777,6 +776,16 @@ export const createSessionTitleRuntime = ({
|
|
|
777
776
|
const processPayload = (payload, directoryHint = '') => {
|
|
778
777
|
if (stopped) return;
|
|
779
778
|
directoryHint = directoryHint || payload?.location?.directory || '';
|
|
779
|
+
if (payload?.type === 'server.connected') {
|
|
780
|
+
void sessionAccess?.reconcileAll?.().catch((error) => {
|
|
781
|
+
console.warn('[session-title] recovery failed:', error?.message || error);
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
if (payload?.type === 'session.renamed' || payload?.type === 'session.updated'
|
|
785
|
+
|| payload?.type === 'openchamber:session-metadata') {
|
|
786
|
+
const data = payload.data ?? payload.properties ?? {};
|
|
787
|
+
sessionAccess?.reconcile?.(data.sessionID ?? data.info?.id ?? data.id, directoryHint);
|
|
788
|
+
}
|
|
780
789
|
const createdSession = extractCreatedSession(payload);
|
|
781
790
|
if (createdSession) {
|
|
782
791
|
if (isForkedSessionTitle(createdSession.title)) {
|
|
@@ -848,6 +857,7 @@ export const createSessionTitleRuntime = ({
|
|
|
848
857
|
|
|
849
858
|
const stop = () => {
|
|
850
859
|
stopped = true;
|
|
860
|
+
sessionAccess?.stop?.();
|
|
851
861
|
for (const { timer } of timers.values()) {
|
|
852
862
|
clearTimeout(timer);
|
|
853
863
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
|
|
2
|
-
import { projectSessionWithHostMetadata } from '../session-metadata/session-projection.js';
|
|
2
|
+
import { projectSessionWithHostMetadata, readSessionTitleAuthority } from '../session-metadata/session-projection.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { projectSessionMessageRecords } from '../session-turn-pages/session-message-projection.js';
|
|
4
5
|
|
|
5
6
|
/** Title reads use OpenCode; title-refresh metadata and its notifications belong to the Host. */
|
|
@@ -8,8 +9,15 @@ export function createTitleSessionAccess({
|
|
|
8
9
|
getOpenCodeAuthHeaders,
|
|
9
10
|
readSessionMetadata,
|
|
10
11
|
persistSessionMetadata,
|
|
12
|
+
mutateSessionMetadata,
|
|
13
|
+
listSessionMetadata = async () => ({}),
|
|
11
14
|
publishSession,
|
|
12
15
|
}) {
|
|
16
|
+
const pending = new Map();
|
|
17
|
+
let stopped = false;
|
|
18
|
+
let recovery = null;
|
|
19
|
+
let recoveryTimer = null;
|
|
20
|
+
let recoveryFailures = 0;
|
|
13
21
|
const client = () => makeOpenCodeV2Client({
|
|
14
22
|
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
15
23
|
authHeaders: getOpenCodeAuthHeaders(),
|
|
@@ -17,23 +25,120 @@ export function createTitleSessionAccess({
|
|
|
17
25
|
const options = () => ({ signal: AbortSignal.timeout(30_000) });
|
|
18
26
|
const get = async (sessionID) => {
|
|
19
27
|
const session = await client().session.get({ sessionID }, options());
|
|
20
|
-
|
|
28
|
+
const metadata = await readSessionMetadata(sessionID);
|
|
29
|
+
const authority = readSessionTitleAuthority(sessionID, metadata);
|
|
30
|
+
if (authority && authority.title !== session.title) reconcile(sessionID);
|
|
31
|
+
return projectSessionWithHostMetadata(session, metadata);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Coalesce only pending work; settled sessions retain no in-memory entry.
|
|
35
|
+
const reconcile = (sessionID, directory = '') => {
|
|
36
|
+
if (stopped || !sessionID) return;
|
|
37
|
+
const existing = pending.get(sessionID);
|
|
38
|
+
if (existing) { existing.dirty = true; return; }
|
|
39
|
+
const state = { dirty: false, timer: null, failures: 0, repaired: false };
|
|
40
|
+
pending.set(sessionID, state);
|
|
41
|
+
const run = async () => {
|
|
42
|
+
state.dirty = false;
|
|
43
|
+
try {
|
|
44
|
+
const metadata = await readSessionMetadata(sessionID);
|
|
45
|
+
const authority = readSessionTitleAuthority(sessionID, metadata);
|
|
46
|
+
if (!authority || stopped) { pending.delete(sessionID); return; }
|
|
47
|
+
const session = await client().session.get({ sessionID }, options());
|
|
48
|
+
if (stopped) return;
|
|
49
|
+
if (session.title !== authority.title) {
|
|
50
|
+
await client().session.update({ sessionID, title: authority.title }, options());
|
|
51
|
+
state.repaired = true;
|
|
52
|
+
// Always verify after a write, even if its echo was lost or reordered.
|
|
53
|
+
state.dirty = true;
|
|
54
|
+
} else {
|
|
55
|
+
const latest = await readSessionMetadata(sessionID);
|
|
56
|
+
if (readSessionTitleAuthority(sessionID, latest)?.revision !== authority.revision) {
|
|
57
|
+
state.dirty = true;
|
|
58
|
+
} else if (state.repaired && !stopped) {
|
|
59
|
+
state.repaired = false;
|
|
60
|
+
await publishSession(projectSessionWithHostMetadata(session, latest), directory);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
state.failures = 0;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (Number(error?.status ?? error?.statusCode) === 404 || error?._tag === 'SessionNotFoundError') {
|
|
66
|
+
pending.delete(sessionID);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
state.failures += 1;
|
|
70
|
+
state.dirty = true;
|
|
71
|
+
if (state.failures === 1) console.warn('[session-title] reconciliation failed:', error?.message || error);
|
|
72
|
+
} finally {
|
|
73
|
+
if (stopped) { pending.delete(sessionID); return; }
|
|
74
|
+
if (pending.get(sessionID) !== state) return;
|
|
75
|
+
if (state.dirty) {
|
|
76
|
+
state.timer = setTimeout(run, state.failures ? Math.min(60_000, 1000 * 2 ** Math.min(state.failures - 1, 6)) : 0);
|
|
77
|
+
state.timer.unref?.();
|
|
78
|
+
} else pending.delete(sessionID);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
state.timer = setTimeout(run, 0);
|
|
82
|
+
state.timer.unref?.();
|
|
83
|
+
};
|
|
84
|
+
const reconcileAll = () => {
|
|
85
|
+
if (stopped) return Promise.resolve();
|
|
86
|
+
if (recovery) return recovery;
|
|
87
|
+
clearTimeout(recoveryTimer);
|
|
88
|
+
recovery = (async () => {
|
|
89
|
+
const entries = await listSessionMetadata();
|
|
90
|
+
recoveryFailures = 0;
|
|
91
|
+
for (const [id, metadata] of Object.entries(entries)) {
|
|
92
|
+
if (stopped) return;
|
|
93
|
+
if (!readSessionTitleAuthority(id, metadata)) continue;
|
|
94
|
+
// Serial discovery bounds reconnect reads; only drift queues repair.
|
|
95
|
+
try { await get(id); } catch { reconcile(id); }
|
|
96
|
+
}
|
|
97
|
+
})().catch((error) => {
|
|
98
|
+
recoveryFailures += 1;
|
|
99
|
+
if (!stopped) {
|
|
100
|
+
recoveryTimer = setTimeout(() => { void reconcileAll(); }, Math.min(60_000, 1000 * 2 ** Math.min(recoveryFailures - 1, 6)));
|
|
101
|
+
recoveryTimer.unref?.();
|
|
102
|
+
}
|
|
103
|
+
if (recoveryFailures === 1) console.warn('[session-title] recovery failed:', error?.message || error);
|
|
104
|
+
}).finally(() => { recovery = null; });
|
|
105
|
+
return recovery;
|
|
21
106
|
};
|
|
22
107
|
return {
|
|
23
108
|
get,
|
|
109
|
+
reconcile,
|
|
110
|
+
reconcileAll,
|
|
111
|
+
stop() {
|
|
112
|
+
stopped = true;
|
|
113
|
+
clearTimeout(recoveryTimer);
|
|
114
|
+
for (const state of pending.values()) clearTimeout(state.timer);
|
|
115
|
+
pending.clear();
|
|
116
|
+
},
|
|
24
117
|
async messages(sessionID, _directory, limit) {
|
|
25
118
|
const page = await client().message.list({ sessionID, limit, order: 'desc' }, options());
|
|
26
119
|
if (!Array.isArray(page?.data)) throw new Error('Session title: invalid message page');
|
|
27
120
|
return projectSessionMessageRecords([...page.data].reverse());
|
|
28
121
|
},
|
|
29
122
|
async update(sessionID, directory, patch) {
|
|
123
|
+
if (stopped) return;
|
|
30
124
|
if (typeof patch.title === 'string') {
|
|
125
|
+
const result = await mutateSessionMetadata(sessionID, (metadata) => {
|
|
126
|
+
const current = readSessionTitleAuthority(sessionID, metadata);
|
|
127
|
+
if ((current?.revision ?? null) !== (patch.expectedTitleRevision ?? null)) return { ok: false };
|
|
128
|
+
return { ok: true, patch: { openchamber: {
|
|
129
|
+
titleAuthority: { sessionID, title: patch.title, revision: randomUUID(), source: 'summary' },
|
|
130
|
+
titleRefresh: patch.metadata?.openchamber?.titleRefresh ?? {},
|
|
131
|
+
} } };
|
|
132
|
+
});
|
|
133
|
+
if (!result.committed || stopped) return;
|
|
134
|
+
// Durable intent survives failed writes and process restarts.
|
|
135
|
+
reconcile(sessionID, directory);
|
|
31
136
|
await client().session.update({ sessionID, title: patch.title }, options());
|
|
32
137
|
}
|
|
33
138
|
// Never write the complete metadata snapshot back: goal/archive/ownership
|
|
34
139
|
// may have changed while the title model was running.
|
|
35
140
|
const refresh = patch.metadata?.openchamber?.titleRefresh;
|
|
36
|
-
if (refresh) {
|
|
141
|
+
if (refresh && typeof patch.title !== 'string') {
|
|
37
142
|
await persistSessionMetadata(sessionID, { openchamber: { titleRefresh: refresh } });
|
|
38
143
|
}
|
|
39
144
|
if (typeof patch.title === 'string' || (refresh && Object.hasOwn(refresh, 'isGenerating'))) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, expect, it, vi } from 'vitest';
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
2
2
|
import { mergeMetadataPatch } from '../session-metadata/session-metadata-store.js';
|
|
3
3
|
|
|
4
4
|
const { api } = vi.hoisted(() => ({ api: {
|
|
@@ -8,17 +8,21 @@ const { api } = vi.hoisted(() => ({ api: {
|
|
|
8
8
|
vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: () => api }));
|
|
9
9
|
import { createTitleSessionAccess } from './session-access.js';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const accesses = [];
|
|
12
|
+
beforeEach(() => { vi.resetAllMocks(); vi.useFakeTimers(); });
|
|
13
|
+
afterEach(() => { for (const access of accesses.splice(0)) access.stop(); vi.useRealTimers(); });
|
|
12
14
|
|
|
13
15
|
function fixture() {
|
|
14
16
|
let metadata = { openchamber: {
|
|
15
17
|
goal: { status: 'active' },
|
|
16
18
|
titleRefresh: { isGenerating: true, requestedAt: 1000, lastError: 'old failure', failedAt: 1 },
|
|
17
19
|
} };
|
|
18
|
-
|
|
20
|
+
const session = {
|
|
19
21
|
id: 'ses_title', title: 'Updated title', location: { directory: '/repo' },
|
|
20
22
|
time: { created: 10, updated: 20 },
|
|
21
|
-
}
|
|
23
|
+
};
|
|
24
|
+
api.session.get.mockImplementation(async () => structuredClone(session));
|
|
25
|
+
api.session.update.mockImplementation(async ({ title }) => { session.title = title; });
|
|
22
26
|
const persist = vi.fn(async (_id, patch) => { metadata = mergeMetadataPatch(metadata, patch); });
|
|
23
27
|
const publish = vi.fn();
|
|
24
28
|
const access = createTitleSessionAccess({
|
|
@@ -26,9 +30,17 @@ function fixture() {
|
|
|
26
30
|
getOpenCodeAuthHeaders: () => ({}),
|
|
27
31
|
readSessionMetadata: async () => metadata,
|
|
28
32
|
persistSessionMetadata: persist,
|
|
33
|
+
mutateSessionMetadata: async (id, decide) => {
|
|
34
|
+
const decision = decide(metadata);
|
|
35
|
+
if (!decision.ok) return { committed: false, metadata };
|
|
36
|
+
await persist(id, decision.patch);
|
|
37
|
+
return { committed: true, metadata };
|
|
38
|
+
},
|
|
39
|
+
listSessionMetadata: async () => ({ ses_title: metadata }),
|
|
29
40
|
publishSession: publish,
|
|
30
41
|
});
|
|
31
|
-
|
|
42
|
+
accesses.push(access);
|
|
43
|
+
return { access, persist, publish, session, metadata: () => metadata };
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
it('publishes the full authoritative title row and clears Host loading without overwriting unrelated state', async () => {
|
|
@@ -42,6 +54,7 @@ it('publishes the full authoritative title row and clears Host loading without o
|
|
|
42
54
|
expect(api.session.update.mock.calls[0][0]).toEqual({ sessionID: 'ses_title', title: 'Updated title' });
|
|
43
55
|
expect(f.metadata()).toEqual({ openchamber: {
|
|
44
56
|
goal: { status: 'active' }, titleRefresh: { lastAutoTitle: 'Updated title' },
|
|
57
|
+
titleAuthority: { sessionID: 'ses_title', title: 'Updated title', revision: expect.any(String), source: 'summary' },
|
|
45
58
|
} });
|
|
46
59
|
expect(f.publish).toHaveBeenCalledWith(expect.objectContaining({
|
|
47
60
|
id: 'ses_title', title: 'Updated title', directory: '/repo', metadata: f.metadata(),
|
|
@@ -72,14 +85,119 @@ it('publishes a full loading row before the new session exists in client catalog
|
|
|
72
85
|
expect(api.session.update).not.toHaveBeenCalled();
|
|
73
86
|
});
|
|
74
87
|
|
|
75
|
-
it('
|
|
88
|
+
it('retains durable intent and retries a failed upstream title write without publishing success', async () => {
|
|
76
89
|
const f = fixture();
|
|
77
90
|
api.session.update.mockRejectedValue(new Error('offline'));
|
|
78
91
|
await expect(f.access.update('ses_title', '/repo', {
|
|
79
92
|
title: 'Failed', metadata: { openchamber: { titleRefresh: { lastAutoTitle: 'Failed' } } },
|
|
80
93
|
})).rejects.toThrow('offline');
|
|
81
|
-
expect(f.
|
|
94
|
+
expect(f.metadata().openchamber.titleAuthority.title).toBe('Failed');
|
|
82
95
|
expect(f.publish).not.toHaveBeenCalled();
|
|
96
|
+
api.session.update.mockImplementation(async ({ title }) => { f.session.title = title; });
|
|
97
|
+
await vi.runAllTimersAsync();
|
|
98
|
+
expect(f.session.title).toBe('Failed');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it.each(['before', 'after', 'during'])('rectifies upstream title writes %s our write', async (timing) => {
|
|
102
|
+
const f = fixture();
|
|
103
|
+
if (timing === 'before') f.session.title = 'Native';
|
|
104
|
+
await f.access.update('ses_title', '/repo', { title: 'Ours' });
|
|
105
|
+
if (timing !== 'before') f.session.title = 'Native';
|
|
106
|
+
if (timing === 'during') {
|
|
107
|
+
api.session.update.mockImplementationOnce(async () => { f.session.title = 'Native again'; });
|
|
108
|
+
}
|
|
109
|
+
for (let i = 0; i < 100; i++) f.access.reconcile('ses_title');
|
|
110
|
+
await vi.runAllTimersAsync();
|
|
111
|
+
expect(f.session.title).toBe('Ours');
|
|
112
|
+
expect(api.session.update.mock.calls.length).toBeLessThanOrEqual(3);
|
|
113
|
+
const writes = api.session.update.mock.calls.length;
|
|
114
|
+
f.access.reconcile('ses_title');
|
|
115
|
+
await vi.runAllTimersAsync();
|
|
116
|
+
expect(api.session.update).toHaveBeenCalledTimes(writes);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects stale generation after a manual rename and repairs from the newest authority', async () => {
|
|
120
|
+
const f = fixture();
|
|
121
|
+
await f.access.update('ses_title', '/repo', { title: 'Summary' });
|
|
122
|
+
const previous = f.metadata().openchamber.titleAuthority.revision;
|
|
123
|
+
await f.persist('ses_title', { openchamber: { titleAuthority: {
|
|
124
|
+
sessionID: 'ses_title', title: 'Manual', revision: 'manual-1', source: 'manual',
|
|
125
|
+
} } });
|
|
126
|
+
await f.access.update('ses_title', '/repo', { title: 'Late summary', expectedTitleRevision: previous });
|
|
127
|
+
await vi.runAllTimersAsync();
|
|
128
|
+
expect(f.session.title).toBe('Manual');
|
|
129
|
+
expect(f.metadata().openchamber.titleAuthority.source).toBe('manual');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('re-reads authority when manual rename lands during an upstream repair', async () => {
|
|
133
|
+
const f = fixture();
|
|
134
|
+
await f.access.update('ses_title', '/repo', { title: 'Summary' });
|
|
135
|
+
f.session.title = 'Native';
|
|
136
|
+
api.session.get.mockImplementationOnce(async () => {
|
|
137
|
+
await f.persist('ses_title', { openchamber: { titleAuthority: {
|
|
138
|
+
sessionID: 'ses_title', title: 'Manual', revision: 'manual-2', source: 'manual',
|
|
139
|
+
} } });
|
|
140
|
+
return structuredClone(f.session);
|
|
141
|
+
});
|
|
142
|
+
await vi.runAllTimersAsync();
|
|
143
|
+
expect(f.session.title).toBe('Manual');
|
|
144
|
+
expect(f.publish.mock.calls.at(-1)[0].title).toBe('Manual');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('recovers missed rename events after reconnect without regenerating a title', async () => {
|
|
148
|
+
const f = fixture();
|
|
149
|
+
await f.persist('ses_title', { openchamber: { titleAuthority: {
|
|
150
|
+
sessionID: 'ses_title', title: 'Durable', revision: 'saved', source: 'summary',
|
|
151
|
+
} } });
|
|
152
|
+
await f.access.reconcileAll();
|
|
153
|
+
await vi.runAllTimersAsync();
|
|
154
|
+
expect(f.session.title).toBe('Durable');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('does not adopt inherited fork authority or legacy lastAutoTitle as a lock', async () => {
|
|
158
|
+
const f = fixture();
|
|
159
|
+
await f.persist('ses_title', { openchamber: {
|
|
160
|
+
titleRefresh: { lastAutoTitle: 'Old summary' },
|
|
161
|
+
titleAuthority: { sessionID: 'ses_parent', title: 'Parent', revision: 'parent' },
|
|
162
|
+
} });
|
|
163
|
+
f.access.reconcile('ses_title');
|
|
164
|
+
await vi.runAllTimersAsync();
|
|
165
|
+
expect(api.session.update).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('does not write a title if durable authority persistence fails', async () => {
|
|
169
|
+
const f = fixture();
|
|
170
|
+
f.persist.mockRejectedValue(new Error('disk unavailable'));
|
|
171
|
+
await expect(f.access.update('ses_title', '/repo', { title: 'Unsaved' })).rejects.toThrow('disk unavailable');
|
|
172
|
+
await vi.runAllTimersAsync();
|
|
173
|
+
expect(api.session.update).not.toHaveBeenCalled();
|
|
174
|
+
expect(f.publish).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('stops repair after authoritative deletion', async () => {
|
|
178
|
+
const f = fixture();
|
|
179
|
+
await f.persist('ses_title', { openchamber: { titleAuthority: {
|
|
180
|
+
sessionID: 'ses_title', title: 'Durable', revision: 'saved',
|
|
181
|
+
} } });
|
|
182
|
+
api.session.get.mockRejectedValue(Object.assign(new Error('deleted'), { status: 404 }));
|
|
183
|
+
f.access.reconcile('ses_title');
|
|
184
|
+
await vi.runAllTimersAsync();
|
|
185
|
+
expect(api.session.get).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(api.session.update).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('backs off transient failures and cancels retry on stop', async () => {
|
|
190
|
+
const f = fixture();
|
|
191
|
+
await f.persist('ses_title', { openchamber: { titleAuthority: {
|
|
192
|
+
sessionID: 'ses_title', title: 'Durable', revision: 'saved',
|
|
193
|
+
} } });
|
|
194
|
+
api.session.get.mockRejectedValue(new Error('offline'));
|
|
195
|
+
f.access.reconcile('ses_title');
|
|
196
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
197
|
+
expect(api.session.get).toHaveBeenCalledTimes(3);
|
|
198
|
+
f.access.stop();
|
|
199
|
+
await vi.advanceTimersByTimeAsync(60_000);
|
|
200
|
+
expect(api.session.get).toHaveBeenCalledTimes(3);
|
|
83
201
|
});
|
|
84
202
|
|
|
85
203
|
it('does not turn a failed or malformed message read into empty success', async () => {
|
|
@@ -43,7 +43,8 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API.
|
|
|
43
43
|
## TTSService methods
|
|
44
44
|
|
|
45
45
|
### `isAvailable()`
|
|
46
|
-
Returns
|
|
46
|
+
Returns a Promise of boolean after reading the current OpenCode selected credential.
|
|
47
|
+
Credential-source failure rejects; logout clears the previously constructed client.
|
|
47
48
|
|
|
48
49
|
### `generateSpeechStream(options)`
|
|
49
50
|
Generates speech and returns as a web stream for direct streaming to clients.
|
|
@@ -81,10 +82,11 @@ Returns object with:
|
|
|
81
82
|
Returns Buffer containing MP3 audio data.
|
|
82
83
|
|
|
83
84
|
## API key resolution
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
Explicit request API key/custom base URL remains supported. Otherwise the host
|
|
86
|
+
reads the current OpenCode credential API (`openai`, `codex`, `chatgpt` aliases),
|
|
87
|
+
using API `key` or OAuth `access`. There is no host-local environment/file fallback
|
|
88
|
+
that could substitute another account for a remote service. A credential read
|
|
89
|
+
failure propagates; selected-account changes replace the client and logout clears it.
|
|
88
90
|
|
|
89
91
|
## Usage in web server
|
|
90
92
|
The TTS module is used by `packages/web/server/index.js` for:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { afterEach, expect, it } from 'vitest';
|
|
2
|
+
import { configureOpenCodeCredentials } from '../opencode/auth.js';
|
|
3
|
+
import { TTSService } from './service.js';
|
|
4
|
+
|
|
5
|
+
afterEach(() => configureOpenCodeCredentials(null));
|
|
6
|
+
|
|
7
|
+
it('TTS clears its old client after logout and obtains a new client after account switching', async () => {
|
|
8
|
+
let token = 'first';
|
|
9
|
+
configureOpenCodeCredentials(() => ({ credential: { list: async () => token ? [{ id: 'selected', integrationID: 'openai', active: true, value: { type: 'key', key: token } }] : [] } }));
|
|
10
|
+
const service = new TTSService();
|
|
11
|
+
const first = await service._getClient();
|
|
12
|
+
expect(await service.isAvailable()).toBe(true);
|
|
13
|
+
token = 'second';
|
|
14
|
+
expect(await service._getClient()).not.toBe(first);
|
|
15
|
+
token = '';
|
|
16
|
+
expect(await service.isAvailable()).toBe(false);
|
|
17
|
+
expect(await service._getClient()).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('TTS does not present credential read failure as unavailable success', async () => {
|
|
21
|
+
configureOpenCodeCredentials(() => ({ credential: { list: async () => { throw new Error('private'); } } }));
|
|
22
|
+
await expect(new TTSService().isAvailable()).rejects.toThrow(/^Unable to read OpenCode credentials$/);
|
|
23
|
+
});
|
package/server/lib/tts/routes.js
CHANGED
|
@@ -62,7 +62,7 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
|
|
|
62
62
|
const { ttsService } = await getTtsModule();
|
|
63
63
|
|
|
64
64
|
// Check availability - server-configured key, client-provided key, or custom server URL
|
|
65
|
-
const hasServerKey = ttsService.isAvailable();
|
|
65
|
+
const hasServerKey = await ttsService.isAvailable();
|
|
66
66
|
const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0;
|
|
67
67
|
const hasCustomBaseURL = typeof normalizedBaseURL === 'string' && normalizedBaseURL.length > 0;
|
|
68
68
|
|
|
@@ -134,7 +134,7 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
|
|
|
134
134
|
try {
|
|
135
135
|
const { ttsService } = await getTtsModule();
|
|
136
136
|
res.json({
|
|
137
|
-
available: ttsService.isAvailable(),
|
|
137
|
+
available: await ttsService.isAvailable(),
|
|
138
138
|
voices: [
|
|
139
139
|
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable',
|
|
140
140
|
'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar'
|