@openchambery/web 1.19.21-beta.1 → 2.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-Bh06xinl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-f2agCc4q.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-ChD4WD2v.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-D7SG0ueZ.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-CjCw1X4n.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-C2DgbXJ3.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-lubDPKMs.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-CzgW3-Cb.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-BKc-qXbQ.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-BTkP7aYI.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/multirun-BvSG94mW.js +4 -0
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/projectMeta-DOe49dM_.js +3 -0
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-PpnZiwcW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BGjzNuQl.js → runtimeEndpointReset-B6BxhvR0.js} +6 -6
  52. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +2 -0
  53. package/dist/assets/{sessionLookup-CbbzalcH.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/src-BzX5UI-c.css +2 -0
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/textarea-3XR8sUfy.js +1 -0
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-BJBcy-84.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +1 -0
  63. package/dist/assets/{useEffectiveDirectory-CzHynoi_.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-DzV9N6G-.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-Bi9ASKr_.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BtGYjSW5.js +0 -3
  168. package/dist/assets/MarkdownRenderer-AwDKOCvD.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-B6fGRYsg.js +0 -1
  170. package/dist/assets/MobileSurface-BY7eErHN.js +0 -2
  171. package/dist/assets/SettingsView-lcRi35Y3.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CcIuekQ3.js +0 -7
  174. package/dist/assets/insertionBoundaries-BocNNP9Q.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-CFLmpNC5.js +0 -169
  178. package/dist/assets/main-Ckj_IpUy.js +0 -2
  179. package/dist/assets/miniChat-Dq1CTKNs.js +0 -2
  180. package/dist/assets/mobile-DlJ0Yr7h.js +0 -2
  181. package/dist/assets/multirun-BGUKIJys.js +0 -4
  182. package/dist/assets/pl-DW2XJGC4.js +0 -15
  183. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  184. package/dist/assets/projectMeta-Bft2t6GF.js +0 -3
  185. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  186. package/dist/assets/renderElectronMiniChatApp-CTbXbB0Y.js +0 -2
  187. package/dist/assets/renderMobileApp-DLewUqoE.js +0 -28
  188. package/dist/assets/sessionChangeSummary-DiqwX3vZ.js +0 -2
  189. package/dist/assets/src-rS3zCcsC.css +0 -2
  190. package/dist/assets/textarea-DpK2Z0Gp.js +0 -1
  191. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  192. package/dist/assets/useDesktopWindowControlsLayout-3Bm0rXCQ.js +0 -1
  193. package/dist/assets/useMobileNavigationStore-BIyCYi_z.js +0 -413
  194. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  195. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -1,4 +1,4 @@
1
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
1
+ import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
2
2
  import { projectMessageSummaryDiffCounts } from '../event-stream/diff-summary.js';
3
3
  import {
4
4
  projectMessagesPayloadForReasoning,
@@ -210,102 +210,235 @@ const requestSignal = (req, res) => {
210
210
  return controller.signal;
211
211
  };
212
212
 
213
- const createSdkClient = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders }) => {
214
- const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
215
- const headers = typeof getOpenCodeAuthHeaders === 'function' ? getOpenCodeAuthHeaders() : {};
216
- return createOpencodeClient({ baseUrl, headers });
213
+ /**
214
+ * Native SessionMessageInfo carrier fields that become `parts`.
215
+ * Must not remain on `info` after normalize — otherwise
216
+ * includeReasoning=false can strip parts while leaking content/text.
217
+ */
218
+ const MESSAGE_PART_CARRIER_KEYS = new Set(['content', 'text', 'files', 'parts', 'agents', 'skills']);
219
+
220
+ const stripMessagePartCarriers = (value) => {
221
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value;
222
+ let changed = false;
223
+ const next = {};
224
+ for (const [key, field] of Object.entries(value)) {
225
+ if (MESSAGE_PART_CARRIER_KEYS.has(key)) {
226
+ changed = true;
227
+ continue;
228
+ }
229
+ next[key] = field;
230
+ }
231
+ return changed ? next : value;
217
232
  };
218
233
 
219
- const throwUpstream = (logger, label) => {
220
- logger?.warn?.(label);
221
- const error = new Error('upstream');
222
- error.code = 'upstream';
223
- throw error;
234
+ /**
235
+ * Project SessionMessageInfo (or an already-projected {info, parts} row)
236
+ * into the host HTTP DTO. External route contract stays {info, parts}.
237
+ */
238
+ const projectV2Parts = (entry) => {
239
+ if (Array.isArray(entry?.parts)) return entry.parts;
240
+ const messageID = typeof entry?.id === 'string' ? entry.id : '';
241
+ const sessionID = typeof entry?.sessionID === 'string' ? entry.sessionID : '';
242
+ const parts = [];
243
+ if (typeof entry?.text === 'string' && entry.text) {
244
+ parts.push({ id: `${messageID}:text`, sessionID, messageID, type: 'text', text: entry.text });
245
+ }
246
+ if (Array.isArray(entry?.files)) {
247
+ entry.files.forEach((file, index) => {
248
+ parts.push({
249
+ id: `${messageID}:file:${index}`,
250
+ sessionID,
251
+ messageID,
252
+ type: 'file',
253
+ mime: file?.mime || 'application/octet-stream',
254
+ url: file?.uri ?? file?.url,
255
+ ...(file?.name ? { filename: file.name } : {}),
256
+ });
257
+ });
258
+ }
259
+ if (Array.isArray(entry?.content)) {
260
+ entry.content.forEach((item, index) => {
261
+ const partID = item?.id || `${messageID}:content:${index}`;
262
+ if (item?.type === 'text') {
263
+ parts.push({ id: partID, sessionID, messageID, type: 'text', text: item.text ?? '' });
264
+ } else if (item?.type === 'reasoning') {
265
+ parts.push({ id: partID, sessionID, messageID, type: 'reasoning', text: item.text ?? '' });
266
+ } else if (item?.type === 'tool') {
267
+ parts.push({
268
+ id: partID,
269
+ sessionID,
270
+ messageID,
271
+ type: 'tool',
272
+ tool: item.name,
273
+ callID: item.id,
274
+ state: item.state ?? {},
275
+ });
276
+ }
277
+ });
278
+ }
279
+ return parts;
224
280
  };
225
281
 
226
- const createSdkFetchPage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }) => {
227
- return async ({ sessionID, directory, before, limit, signal }) => {
228
- const client = createSdkClient({ buildOpenCodeUrl, getOpenCodeAuthHeaders });
282
+ const projectSessionMessage = (entry) => {
283
+ if (entry && typeof entry === 'object' && entry.info && typeof entry.info === 'object') {
284
+ const info = stripMessagePartCarriers(entry.info);
285
+ return {
286
+ info: info === entry.info ? entry.info : info,
287
+ parts: Array.isArray(entry.parts) ? entry.parts : [],
288
+ };
289
+ }
290
+ if (!entry || typeof entry !== 'object' || typeof entry.id !== 'string' || entry.id.length === 0) {
291
+ return entry;
292
+ }
293
+ const role = entry.type === 'user' || entry.type === 'assistant' ? entry.type : (entry.role ?? entry.type);
294
+ const info = {
295
+ ...stripMessagePartCarriers(entry),
296
+ id: entry.id,
297
+ sessionID: entry.sessionID,
298
+ role,
299
+ time: entry.time ?? { created: 0 },
300
+ };
301
+ return {
302
+ info,
303
+ parts: projectV2Parts(entry),
304
+ };
305
+ };
229
306
 
230
- const result = await client.session.messages({
307
+ const projectRecords = (records) =>
308
+ (Array.isArray(records) ? records : []).map(projectSessionMessage);
309
+
310
+ /**
311
+ * Host outbound order: normalize → slim/L1 bounds → reasoning strip.
312
+ * Slim/reasoning must run on `{info, parts}` so native `content` cannot
313
+ * rebuild filtered parts or leak through info expansion.
314
+ */
315
+ const projectTurnPageRecords = (records, includeReasoning) =>
316
+ projectMessagesPayloadForReasoning(projectSlimParts(projectRecords(records)), includeReasoning);
317
+
318
+ const projectReconcileRecords = (records, includeReasoning) =>
319
+ projectMessagesPayloadForReasoning(
320
+ projectMessageDiffSummaries(projectRecords(records)),
321
+ includeReasoning,
322
+ );
323
+
324
+ const createV2FetchPage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }) => {
325
+ return async ({ sessionID, before, limit, signal }) => {
326
+ const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
327
+ const headers = typeof getOpenCodeAuthHeaders === 'function' ? getOpenCodeAuthHeaders() : {};
328
+ const client = makeOpenCodeV2Client({ baseUrl, authHeaders: headers });
329
+
330
+ const result = await client.message.list({
231
331
  sessionID,
232
- ...(typeof directory === 'string' && directory.length > 0 ? { directory } : {}),
233
332
  ...(Number.isFinite(limit) ? { limit } : {}),
234
- ...(typeof before === 'string' && before.length > 0 ? { before } : {}),
333
+ order: 'desc',
334
+ ...(typeof before === 'string' && before.length > 0 ? { cursor: before } : {}),
235
335
  }, { signal });
236
336
 
237
- if (result?.error) {
238
- throwUpstream(logger, '[session-turn-pages] session.messages SDK error');
239
- }
240
-
241
- const data = result?.data;
242
- const records = Array.isArray(data)
243
- ? data
244
- : Array.isArray(data?.items)
245
- ? data.items
246
- : null;
247
- if (!records) {
248
- throwUpstream(logger, '[session-turn-pages] session.messages malformed payload');
337
+ const items = Array.isArray(result?.data) ? result.data : null;
338
+ if (!items) {
339
+ logger?.warn?.('[session-turn-pages] message.list malformed payload');
340
+ const error = new Error('upstream');
341
+ error.code = 'upstream';
342
+ throw error;
249
343
  }
250
344
 
251
- const headerCursor = result?.response?.headers?.get?.('x-next-cursor');
252
- const nextCursor = typeof headerCursor === 'string' && headerCursor.length > 0
253
- ? headerCursor
254
- : null;
345
+ const previous = result?.cursor?.previous;
346
+ const nextCursor = typeof previous === 'string' && previous.length > 0 ? previous : null;
255
347
 
256
348
  return {
257
- records,
349
+ records: items.slice().reverse(),
258
350
  nextCursor,
259
351
  complete: nextCursor == null,
260
352
  };
261
353
  };
262
354
  };
263
355
 
356
+ const createSdkClient = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders }) => {
357
+ const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
358
+ const headers = typeof getOpenCodeAuthHeaders === 'function' ? getOpenCodeAuthHeaders() : {};
359
+ return makeOpenCodeV2Client({ baseUrl, authHeaders: headers });
360
+ };
361
+
362
+ const throwUpstream = (logger, label) => {
363
+ logger?.warn?.(label);
364
+ const error = new Error('upstream');
365
+ error.code = 'upstream';
366
+ throw error;
367
+ };
368
+
369
+ const getHttpStatus = (error) => {
370
+ const candidates = [error?.cause?.status, error?.status, error?.response?.status, error?.statusCode];
371
+ for (const value of candidates) {
372
+ if (Number.isFinite(value)) return value;
373
+ }
374
+ return undefined;
375
+ };
376
+
377
+ /**
378
+ * Real `@opencode/client` throws declared JSON bodies with `_tag`
379
+ * (e.g. MessageNotFoundError) — no HTTP status on the object. Also accept
380
+ * legacy status/code shapes from tests and older surfaces.
381
+ */
382
+ const isNotFoundError = (error) => {
383
+ if (!error || typeof error !== 'object') return false;
384
+ const tag = error._tag ?? error.name;
385
+ if (tag === 'MessageNotFoundError' || tag === 'SessionNotFoundError') return true;
386
+ if (getHttpStatus(error) === 404) return true;
387
+ const code = error.code ?? error.type ?? error.error?.code ?? error.error?.type ?? error.error?._tag;
388
+ return code === 'not_found'
389
+ || code === 'NotFound'
390
+ || code === 'MessageNotFoundError'
391
+ || code === 'SessionNotFoundError';
392
+ };
393
+
394
+ const isAbortError = (error) => (
395
+ error?.name === 'AbortError'
396
+ || error?.code === 'aborted'
397
+ || error?.cause?.name === 'AbortError'
398
+ );
399
+
264
400
  const createSdkFetchMessage = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }) => {
265
- return async ({ sessionID, messageID, directory, signal }) => {
401
+ return async ({ sessionID, messageID, signal }) => {
266
402
  const client = createSdkClient({ buildOpenCodeUrl, getOpenCodeAuthHeaders });
267
- const result = await client.session.message({
268
- sessionID,
269
- messageID,
270
- ...(typeof directory === 'string' && directory.length > 0 ? { directory } : {}),
271
- }, { signal });
272
-
273
- if (result?.error) {
274
- const status = result?.response?.status;
275
- if (status === 404) {
276
- const error = new Error('not_found');
277
- error.code = 'not_found';
278
- throw error;
403
+ let result;
404
+ try {
405
+ result = await client.session.message({ sessionID, messageID }, { signal });
406
+ } catch (error) {
407
+ // Preserve abort so routes map 499; never fold into upstream 502.
408
+ if (isAbortError(error) || signal?.aborted) throw error;
409
+ if (isNotFoundError(error)) {
410
+ const notFound = new Error('not_found');
411
+ notFound.code = 'not_found';
412
+ throw notFound;
279
413
  }
280
414
  throwUpstream(logger, '[session-turn-pages] session.message SDK error');
281
415
  }
282
416
 
283
- const data = result?.data;
417
+ const data = result?.data ?? result;
284
418
  if (!data || typeof data !== 'object' || Array.isArray(data)) {
285
419
  throwUpstream(logger, '[session-turn-pages] session.message malformed payload');
286
420
  }
287
- return data;
421
+ return projectSessionMessage(data);
288
422
  };
289
423
  };
290
424
 
425
+ /**
426
+ * L3 diff source. v2 SessionMessageInfo has no summary.diffs; keep failure
427
+ * explicit until the parent lands an authoritative per-message diff API.
428
+ * Do not substitute working-tree / vcs.diff here.
429
+ */
291
430
  const createSdkFetchDiff = ({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger }) => {
292
431
  return async ({ sessionID, messageID, directory, signal }) => {
293
- const client = createSdkClient({ buildOpenCodeUrl, getOpenCodeAuthHeaders });
294
- const result = await client.session.diff({
295
- sessionID,
296
- messageID,
297
- ...(typeof directory === 'string' && directory.length > 0 ? { directory } : {}),
298
- }, { signal });
299
-
300
- if (result?.error) {
301
- throwUpstream(logger, '[session-changes] session.diff SDK error');
302
- }
303
-
304
- const data = result?.data;
305
- if (!Array.isArray(data)) {
306
- throwUpstream(logger, '[session-changes] session.diff malformed payload');
432
+ const payload = await createSdkFetchMessage({
433
+ buildOpenCodeUrl,
434
+ getOpenCodeAuthHeaders,
435
+ logger,
436
+ })({ sessionID, messageID, directory, signal });
437
+ const diffs = payload?.info?.summary?.diffs;
438
+ if (!Array.isArray(diffs)) {
439
+ throwUpstream(logger, '[session-changes] session.message summary.diffs unavailable (L3 source pending)');
307
440
  }
308
- return data;
441
+ return diffs;
309
442
  };
310
443
  };
311
444
 
@@ -441,7 +574,7 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
441
574
 
442
575
  const needsDefaultFetch = !injectedService || !injectedReconcileService;
443
576
  const defaultFetchPage = needsDefaultFetch
444
- ? createSdkFetchPage({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger })
577
+ ? createV2FetchPage({ buildOpenCodeUrl, getOpenCodeAuthHeaders, logger })
445
578
  : null;
446
579
 
447
580
  const service = injectedService ?? createSessionTurnPageService({
@@ -514,8 +647,8 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
514
647
  }
515
648
 
516
649
  const includeReasoning = readIncludeReasoningQuery(req.query);
517
- const records = projectMessagesPayloadForReasoning(
518
- projectMessageDiffSummaries(Array.isArray(result.records) ? result.records : []),
650
+ const records = projectReconcileRecords(
651
+ Array.isArray(result.records) ? result.records : [],
519
652
  includeReasoning,
520
653
  );
521
654
 
@@ -609,10 +742,10 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
609
742
 
610
743
  // Turn-page responses (first packet and prepend) share slim-v1.
611
744
  // Reconcile stays on the other route and keeps full parts.
612
- // includeReasoning=false drops type=reasoning parts after slim projection.
745
+ // Normalize native SessionMessageInfo first, then slim, then reasoning strip.
613
746
  const includeReasoning = readIncludeReasoningQuery(req.query);
614
- const records = projectMessagesPayloadForReasoning(
615
- projectSlimParts(result.records),
747
+ const records = projectTurnPageRecords(
748
+ Array.isArray(result.records) ? result.records : [],
616
749
  includeReasoning,
617
750
  );
618
751
  return res.status(200).json({
@@ -673,13 +806,13 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
673
806
  projectMessagesPayloadForReasoning(projectExactMessagePayload(payload), includeReasoning),
674
807
  );
675
808
  } catch (error) {
676
- if (error?.name === 'AbortError' || error?.code === 'aborted') {
809
+ if (isAbortError(error) || error?.code === 'aborted') {
677
810
  if (!res.headersSent) {
678
811
  return res.status(499).json({ error: SAFE_ERRORS.aborted });
679
812
  }
680
813
  return undefined;
681
814
  }
682
- if (error?.code === 'not_found') {
815
+ if (error?.code === 'not_found' || isNotFoundError(error)) {
683
816
  if (!res.headersSent) {
684
817
  return res.status(404).json({ error: 'not found', code: 'not_found' });
685
818
  }
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
 
3
+ import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
3
4
  import { registerSessionTurnPageRoutes } from './routes.js';
4
5
 
6
+ vi.mock('../opencode/v2-client.js', () => ({ makeOpenCodeV2Client: vi.fn() }));
7
+
5
8
  const registry = () => {
6
9
  const routes = new Map();
7
10
  return {
@@ -163,6 +166,59 @@ describe('registerSessionTurnPageRoutes', () => {
163
166
  expect(kept.body.records[0].parts.some((part) => part.type === 'reasoning')).toBe(true);
164
167
  });
165
168
 
169
+ it('normalizes native SessionMessage content before slim+reasoning so content cannot leak', async () => {
170
+ const loadPage = vi.fn(async () => ({
171
+ ok: true,
172
+ records: [{
173
+ id: 'msg_a1',
174
+ sessionID: 'ses_1',
175
+ type: 'assistant',
176
+ time: { created: 1, completed: 2 },
177
+ agent: 'build',
178
+ model: { providerID: 'p', id: 'm' },
179
+ tokens: { input: 1, output: 2, reasoning: 9 },
180
+ content: [
181
+ { type: 'reasoning', text: 'SECRET_REASONING_BODY' },
182
+ { type: 'text', text: 'visible-answer' },
183
+ {
184
+ type: 'tool',
185
+ name: 'bash',
186
+ id: 'call_1',
187
+ state: { status: 'completed', title: 'ran', output: 'HUGE_TOOL_OUTPUT' },
188
+ },
189
+ ],
190
+ }],
191
+ turnCount: 0,
192
+ cursor: null,
193
+ complete: true,
194
+ }));
195
+ const { app, route } = registry();
196
+ registerSessionTurnPageRoutes(app, { sessionTurnPageService: { loadPage } });
197
+
198
+ const stripped = response();
199
+ await route('GET', ROUTE)({
200
+ params: { sessionID: 'ses_1' },
201
+ query: { includeReasoning: 'false' },
202
+ headers: {},
203
+ }, stripped);
204
+
205
+ expect(stripped.statusCode).toBe(200);
206
+ const record = stripped.body.records[0];
207
+ expect(record.info.id).toBe('msg_a1');
208
+ expect(record.info.role).toBe('assistant');
209
+ expect(record.info.tokens.reasoning).toBe(9);
210
+ expect(record.info.content).toBeUndefined();
211
+ expect(record.parts.every((part) => part.type !== 'reasoning')).toBe(true);
212
+ expect(record.parts.some((part) => part.type === 'text' && part.text === 'visible-answer')).toBe(true);
213
+ const tool = record.parts.find((part) => part.type === 'tool');
214
+ expect(tool.slim).toBe(true);
215
+ expect(tool.state.output).toBeUndefined();
216
+ expect(stripped.body.partsProjection).toBe('slim-v1');
217
+ const serialized = JSON.stringify(stripped.body);
218
+ expect(serialized).not.toContain('SECRET_REASONING_BODY');
219
+ expect(serialized).not.toContain('HUGE_TOOL_OUTPUT');
220
+ });
221
+
166
222
  it('maps upstream service errors to an upstream HTTP status', async () => {
167
223
  const loadPage = vi.fn(async () => ({
168
224
  ok: false,
@@ -637,6 +693,87 @@ describe('registerSessionTurnPageRoutes', () => {
637
693
  resolveLoad?.();
638
694
  await pending;
639
695
  });
696
+
697
+ it('default fetch uses message.list SessionMessageInfo[] + cursor and projects {info, parts}', async () => {
698
+ const list = vi.fn(async () => ({
699
+ data: [
700
+ { id: 'msg_a1', type: 'assistant', time: { created: 2, completed: 3 }, content: [{ type: 'text', text: 'ok' }] },
701
+ { id: 'msg_u1', type: 'user', time: { created: 1 }, text: 'hi' },
702
+ ],
703
+ cursor: { previous: null, next: null },
704
+ }));
705
+ makeOpenCodeV2Client.mockReturnValue({
706
+ message: { list },
707
+ session: { messages: undefined, status: undefined, abort: undefined },
708
+ });
709
+ const { app, route } = registry();
710
+ registerSessionTurnPageRoutes(app, {
711
+ buildOpenCodeUrl: () => 'http://open.code/',
712
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'secret' }),
713
+ });
714
+ const res = response();
715
+ await route('GET', ROUTE)({
716
+ params: { sessionID: 'ses_1' },
717
+ query: { turns: '1' },
718
+ headers: {},
719
+ }, res);
720
+
721
+ expect(list).toHaveBeenCalledWith({
722
+ sessionID: 'ses_1',
723
+ limit: 100,
724
+ order: 'desc',
725
+ }, expect.objectContaining({ signal: expect.any(Object) }));
726
+ expect(res.statusCode).toBe(200);
727
+ expect(res.body.complete).toBe(true);
728
+ expect(res.body.records).toEqual([
729
+ expect.objectContaining({
730
+ info: expect.objectContaining({ id: 'msg_u1', role: 'user' }),
731
+ parts: [expect.objectContaining({ type: 'text', text: 'hi' })],
732
+ }),
733
+ expect.objectContaining({
734
+ info: expect.objectContaining({ id: 'msg_a1', role: 'assistant' }),
735
+ parts: [expect.objectContaining({ type: 'text', text: 'ok' })],
736
+ }),
737
+ ]);
738
+ });
739
+
740
+ it('default fetch throws on message.list failure and maps to upstream', async () => {
741
+ makeOpenCodeV2Client.mockReturnValue({
742
+ message: { list: vi.fn(async () => { throw Object.assign(new Error('upstream'), { status: 500 }); }) },
743
+ });
744
+ const { app, route } = registry();
745
+ registerSessionTurnPageRoutes(app, {
746
+ buildOpenCodeUrl: () => 'http://open.code/',
747
+ getOpenCodeAuthHeaders: () => ({}),
748
+ });
749
+ const res = response();
750
+ await route('GET', ROUTE)({
751
+ params: { sessionID: 'ses_1' },
752
+ query: { turns: '1' },
753
+ headers: {},
754
+ }, res);
755
+ expect(res.statusCode).toBeGreaterThanOrEqual(502);
756
+ expect(res.body).toMatchObject({ error: expect.stringMatching(/upstream/i) });
757
+ });
758
+
759
+ it('default fetch rejects HeyAPI data/error shapes without a SessionMessageInfo array', async () => {
760
+ makeOpenCodeV2Client.mockReturnValue({
761
+ message: { list: vi.fn(async () => ({ error: { status: 500 }, data: undefined })) },
762
+ });
763
+ const { app, route } = registry();
764
+ registerSessionTurnPageRoutes(app, {
765
+ buildOpenCodeUrl: () => 'http://open.code/',
766
+ getOpenCodeAuthHeaders: () => ({}),
767
+ });
768
+ const res = response();
769
+ await route('GET', ROUTE)({
770
+ params: { sessionID: 'ses_1' },
771
+ query: { turns: '1' },
772
+ headers: {},
773
+ }, res);
774
+ expect(res.statusCode).toBeGreaterThanOrEqual(502);
775
+ expect(res.body.records).toBeUndefined();
776
+ });
640
777
  });
641
778
 
642
779
  const EXACT_MESSAGE_ROUTE = '/api/session/:sessionID/message/:messageID';
@@ -752,6 +889,154 @@ describe('registerSessionTurnPageRoutes — exact message GET', () => {
752
889
  expect(res.statusCode).toBe(404);
753
890
  expect(res.body.code).toBe('not_found');
754
891
  });
892
+
893
+ it('maps AbortError to 499 and does not fold it into upstream 502', async () => {
894
+ const fetchExactMessage = vi.fn(async () => {
895
+ const error = new Error('aborted');
896
+ error.name = 'AbortError';
897
+ throw error;
898
+ });
899
+ const { app, route } = registry();
900
+ registerSessionTurnPageRoutes(app, {
901
+ sessionTurnPageService: { loadPage: vi.fn() },
902
+ fetchExactMessage,
903
+ });
904
+ const res = response();
905
+ await route('GET', EXACT_MESSAGE_ROUTE)({
906
+ params: { sessionID: 'ses_1', messageID: 'msg_1' },
907
+ query: {},
908
+ headers: {},
909
+ }, res);
910
+ expect(res.statusCode).toBe(499);
911
+ expect(res.statusCode).not.toBe(502);
912
+ });
913
+ });
914
+
915
+ describe('registerSessionTurnPageRoutes — real OpenCode.make + fake HTTP', () => {
916
+ const jsonResponse = (status, body) => ({
917
+ ok: status >= 200 && status < 300,
918
+ status,
919
+ headers: {
920
+ get: (name) => (String(name).toLowerCase() === 'content-type' ? 'application/json' : null),
921
+ },
922
+ json: async () => body,
923
+ text: async () => JSON.stringify(body ?? null),
924
+ arrayBuffer: async () => new TextEncoder().encode(JSON.stringify(body ?? null)).buffer,
925
+ });
926
+
927
+ it('session.message MessageNotFoundError _tag maps to HTTP 404 via real client', async () => {
928
+ const fetchImpl = vi.fn(async (url) => {
929
+ const path = String(url);
930
+ if (path.includes('/message/msg_missing')) {
931
+ return jsonResponse(404, {
932
+ _tag: 'MessageNotFoundError',
933
+ sessionID: 'ses_1',
934
+ messageID: 'msg_missing',
935
+ message: 'Message not found',
936
+ });
937
+ }
938
+ return jsonResponse(500, { _tag: 'UnknownError', message: 'unexpected' });
939
+ });
940
+ const { makeOpenCodeV2Client: realMake } = await vi.importActual('../opencode/v2-client.js');
941
+ makeOpenCodeV2Client.mockImplementation(({ baseUrl, authHeaders }) =>
942
+ realMake({ baseUrl, authHeaders, fetchImpl }));
943
+
944
+ const { app, route } = registry();
945
+ registerSessionTurnPageRoutes(app, {
946
+ sessionTurnPageService: { loadPage: vi.fn() },
947
+ buildOpenCodeUrl: () => 'http://open.code/',
948
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic test' }),
949
+ });
950
+ const res = response();
951
+ await route('GET', EXACT_MESSAGE_ROUTE)({
952
+ params: { sessionID: 'ses_1', messageID: 'msg_missing' },
953
+ query: {},
954
+ headers: {},
955
+ }, res);
956
+
957
+ expect(res.statusCode).toBe(404);
958
+ expect(res.body.code).toBe('not_found');
959
+ expect(fetchImpl).toHaveBeenCalled();
960
+ });
961
+
962
+ it('exact message serializes native assistant with reasoning strip and L1 slim', async () => {
963
+ const nativeAssistant = {
964
+ id: 'msg_a1',
965
+ type: 'assistant',
966
+ time: { created: 10, completed: 20 },
967
+ agent: 'build',
968
+ model: { providerID: 'openai', id: 'gpt' },
969
+ tokens: { input: 3, output: 4, reasoning: 7 },
970
+ content: [
971
+ { type: 'reasoning', text: 'PRIVATE_CHAIN' },
972
+ { type: 'text', text: 'hello-world' },
973
+ ],
974
+ // Host may still see legacy summary on some mirrors; L1 must slim it.
975
+ summary: {
976
+ diffs: [{
977
+ file: 'a.ts',
978
+ status: 'modified',
979
+ additions: 1,
980
+ deletions: 0,
981
+ patch: 'PATCH_BODY_SHOULD_NOT_LEAVE',
982
+ }],
983
+ },
984
+ };
985
+ const fetchImpl = vi.fn(async () => jsonResponse(200, { data: nativeAssistant }));
986
+ const { makeOpenCodeV2Client: realMake } = await vi.importActual('../opencode/v2-client.js');
987
+ makeOpenCodeV2Client.mockImplementation(({ baseUrl, authHeaders }) =>
988
+ realMake({ baseUrl, authHeaders, fetchImpl }));
989
+
990
+ const { app, route } = registry();
991
+ registerSessionTurnPageRoutes(app, {
992
+ sessionTurnPageService: { loadPage: vi.fn() },
993
+ buildOpenCodeUrl: () => 'http://open.code/',
994
+ getOpenCodeAuthHeaders: () => ({}),
995
+ });
996
+
997
+ const stripped = response();
998
+ await route('GET', EXACT_MESSAGE_ROUTE)({
999
+ params: { sessionID: 'ses_1', messageID: 'msg_a1' },
1000
+ query: { includeReasoning: 'false' },
1001
+ headers: {},
1002
+ }, stripped);
1003
+
1004
+ expect(stripped.statusCode).toBe(200);
1005
+ expect(stripped.body.info.id).toBe('msg_a1');
1006
+ expect(stripped.body.info.role).toBe('assistant');
1007
+ expect(stripped.body.info.content).toBeUndefined();
1008
+ expect(stripped.body.info.tokens.reasoning).toBe(7);
1009
+ expect(stripped.body.parts.every((part) => part.type !== 'reasoning')).toBe(true);
1010
+ expect(stripped.body.parts.some((part) => part.type === 'text' && part.text === 'hello-world')).toBe(true);
1011
+ expect(stripped.body.info.summary?.diffs?.[0]?.patch).toBeUndefined();
1012
+ expect(JSON.stringify(stripped.body)).not.toContain('PRIVATE_CHAIN');
1013
+ expect(JSON.stringify(stripped.body)).not.toContain('PATCH_BODY_SHOULD_NOT_LEAVE');
1014
+ });
1015
+
1016
+ it('preserves abort from real client transport instead of 502', async () => {
1017
+ const fetchImpl = vi.fn(async () => {
1018
+ const error = new Error('The operation was aborted');
1019
+ error.name = 'AbortError';
1020
+ throw error;
1021
+ });
1022
+ const { makeOpenCodeV2Client: realMake } = await vi.importActual('../opencode/v2-client.js');
1023
+ makeOpenCodeV2Client.mockImplementation(({ baseUrl, authHeaders }) =>
1024
+ realMake({ baseUrl, authHeaders, fetchImpl }));
1025
+
1026
+ const { app, route } = registry();
1027
+ registerSessionTurnPageRoutes(app, {
1028
+ sessionTurnPageService: { loadPage: vi.fn() },
1029
+ buildOpenCodeUrl: () => 'http://open.code/',
1030
+ getOpenCodeAuthHeaders: () => ({}),
1031
+ });
1032
+ const res = response();
1033
+ await route('GET', EXACT_MESSAGE_ROUTE)({
1034
+ params: { sessionID: 'ses_1', messageID: 'msg_1' },
1035
+ query: {},
1036
+ headers: {},
1037
+ }, res);
1038
+ expect(res.statusCode).toBe(499);
1039
+ });
755
1040
  });
756
1041
 
757
1042
  describe('registerSessionTurnPageRoutes — changes API', () => {