@openchambery/web 1.19.7-beta.25 → 1.19.7-beta.26

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 (97) hide show
  1. package/dist/assets/{AgentAvatar-DI2zs2WH.js → AgentAvatar-CX0a1rKE.js} +1 -1
  2. package/dist/assets/AssistantView-BYHD7Fcy.js +2 -0
  3. package/dist/assets/{DiagramView-DRIEYUNV.js → DiagramView-BUucK4Mn.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-BWJcUp5P.js → FadeInOnReveal-CK4asqVR.js} +1 -1
  5. package/dist/assets/{Icon-4My8YZwt.js → Icon-BYM02QyU.js} +1 -1
  6. package/dist/assets/{MarkdownLoadingSkeleton-Vezjvk9B.js → MarkdownLoadingSkeleton-DWs4Jg7b.js} +1 -1
  7. package/dist/assets/{MarkdownRenderer-SLKCbxwI.js → MarkdownRenderer-uiTtRexW.js} +2 -2
  8. package/dist/assets/{MarkdownRendererImpl-D9kcSswH.js → MarkdownRendererImpl-DszSA6px.js} +1 -1
  9. package/dist/assets/MarkstreamRendererImpl-DkjZ7QYA.js +1 -0
  10. package/dist/assets/{MobileDetailNavigation-C5lZVW1v.js → MobileDetailNavigation-BElZdvTL.js} +1 -1
  11. package/dist/assets/MobileShareBridge-DiXOck30.js +4 -0
  12. package/dist/assets/{MobileSurface-CtMp7rI8.js → MobileSurface-BCIZyDOc.js} +1 -1
  13. package/dist/assets/{MultiRunWindow-6-E944pd.js → MultiRunWindow-XXWgGd6d.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-BNEtPmvw.js → OnboardingScreen-C3-wzeSX.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-BZB21Yp_.js → OverlayScrollbar-KUpvYHdv.js} +1 -1
  16. package/dist/assets/{SettingsView-Cy_D8K8y.js → SettingsView-CdYIJh0f.js} +5 -5
  17. package/dist/assets/{SettingsWindow-BA9QjT8d.js → SettingsWindow-CLr0dXVg.js} +1 -1
  18. package/dist/assets/{TerminalView-izdQm4WZ.js → TerminalView-BdtasXsi.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-CXbfvZ3M.js → ToolOutputDialog-D22RYqMI.js} +1 -1
  20. package/dist/assets/{appThemeRegistry-Eq3WEH-h.js → appThemeRegistry-D1wTvHoc.js} +1 -1
  21. package/dist/assets/{button-Or0daT9o.js → button-C0cth9tl.js} +1 -1
  22. package/dist/assets/{clipboard-BXxLshVq.js → clipboard-DJ4nQEuY.js} +1 -1
  23. package/dist/assets/{collapsible-C-kzSGkq.js → collapsible-DFT9cE80.js} +1 -1
  24. package/dist/assets/{context-menu-Dpjla5p4.js → context-menu-CAp74NfV.js} +1 -1
  25. package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
  26. package/dist/assets/{desktopRelayRestore-BaKjk1kQ.js → desktopRelayRestore-9EbBYQ9s.js} +1 -1
  27. package/dist/assets/{diagram-D0mT3mo1.js → diagram-BryzMuta.js} +1 -1
  28. package/dist/assets/{dialog-BkhHBTd4.js → dialog-CgbV5ljg.js} +1 -1
  29. package/dist/assets/{fileQueries-DFY6Kiy1.js → fileQueries-BY-9OWzp.js} +1 -1
  30. package/dist/assets/{fileReferenceActions-Di_DoDex.js → fileReferenceActions-BJJtX_S5.js} +1 -1
  31. package/dist/assets/{gitApi-SIviXHpZ.js → gitApi-RhwRTt2X.js} +1 -1
  32. package/dist/assets/{input-BUsGnzwe.js → input-DFLpqjGR.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-q72MeGLB.js → insertionBoundaries-D-UUqego.js} +1 -1
  34. package/dist/assets/{main-Ct6J19qT.js → main-BRB8Kly8.js} +2 -2
  35. package/dist/assets/{main-VXSSCHDb.js → main-BaSeqhTm.js} +11 -11
  36. package/dist/assets/{markdownTheme-CVS01wHf.js → markdownTheme-CzZm3riB.js} +1 -1
  37. package/dist/assets/{miniChat-D_SEWy1a.js → miniChat-CDZ48aEf.js} +2 -2
  38. package/dist/assets/{mobile-Cx032aiN.js → mobile-B2Hpp_5U.js} +2 -2
  39. package/dist/assets/{multirun-BKGQQeek.js → multirun-CfvHH_3s.js} +1 -1
  40. package/dist/assets/{outsideFileGrants-BB4GPvCq.js → outsideFileGrants-DnplRxr-.js} +1 -1
  41. package/dist/assets/persistence-DdUac14I.js +3 -0
  42. package/dist/assets/platform-B4KKLnZ5.js +2 -0
  43. package/dist/assets/{projectMeta-yRCsEkJK.js → projectMeta-vCCip4-G.js} +1 -1
  44. package/dist/assets/{renderElectronMiniChatApp-BnDRaSFx.js → renderElectronMiniChatApp-D9RarEOS.js} +2 -2
  45. package/dist/assets/{renderMobileApp-BAGhZKMW.js → renderMobileApp-DOiQLCcY.js} +7 -7
  46. package/dist/assets/{runtimeConfig-D5H05aTj.js → runtimeConfig-CkWKMmAR.js} +1 -1
  47. package/dist/assets/{runtimeEndpointReset-BwSh51Vc.js → runtimeEndpointReset-aW8qogl0.js} +1 -1
  48. package/dist/assets/{sessionChangeSummary-CX2MJIkc.js → sessionChangeSummary-DOq8EN_H.js} +1 -1
  49. package/dist/assets/{sessionLookup-CSkeoKrV.js → sessionLookup-BNO4Ta46.js} +1 -1
  50. package/dist/assets/{sortable-tabs-strip-C5gGnikj.js → sortable-tabs-strip-C8_9kHqu.js} +1 -1
  51. package/dist/assets/{terminalApi-CIIs9vFx.js → terminalApi-947l7Ra3.js} +1 -1
  52. package/dist/assets/{textarea-CIO34TZz.js → textarea-Cgo_nWUU.js} +1 -1
  53. package/dist/assets/{tooltip-BSrJYYYc.js → tooltip-BifLJ8o0.js} +1 -1
  54. package/dist/assets/{url-CE_gAfNa.js → url-Pk6tQ1EZ.js} +1 -1
  55. package/dist/assets/{useAppFontEffects-Dh_NwW5O.js → useAppFontEffects-BqBeuVr8.js} +6 -6
  56. package/dist/assets/{useAssistantUIStore-BHacrxe_.js → useAssistantUIStore-me6s63b2.js} +1 -1
  57. package/dist/assets/{useDesktopWindowControlsLayout-CV96SEmA.js → useDesktopWindowControlsLayout-CnX7JJa1.js} +1 -1
  58. package/dist/assets/{useEffectiveDirectory-XSKpr7Fy.js → useEffectiveDirectory-F2fNcQ22.js} +1 -1
  59. package/dist/assets/{useFontPreferences-BxOHKuVw.js → useFontPreferences-BCOBlaAd.js} +1 -1
  60. package/dist/assets/useMobileNavigationStore-CBfSvE2P.js +413 -0
  61. package/dist/assets/{useSessionAutoCleanup-Da5ILm3s.js → useSessionAutoCleanup-BVJKWCtK.js} +1 -1
  62. package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
  63. package/dist/assets/{useWorkerHighlightedLines-dC60TMnO.js → useWorkerHighlightedLines-WYqpkPyP.js} +1 -1
  64. package/dist/assets/{utils-B0mQXN9L.js → utils-BiK3tBGM.js} +1 -1
  65. package/dist/index.html +8 -8
  66. package/dist/mini-chat.html +8 -8
  67. package/dist/mobile.html +8 -8
  68. package/package.json +1 -1
  69. package/server/lib/assistants/contact-tools.test.js +6 -6
  70. package/server/lib/assistants/harness.test.js +3 -3
  71. package/server/lib/assistants/service.test.js +1 -1
  72. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  73. package/server/lib/event-stream/directory-ws-bridge.js +11 -2
  74. package/server/lib/event-stream/global-ws-bridge.js +19 -3
  75. package/server/lib/event-stream/protocol.js +11 -1
  76. package/server/lib/event-stream/reasoning-projection.js +425 -0
  77. package/server/lib/event-stream/reasoning-projection.test.js +300 -0
  78. package/server/lib/event-stream/runtime.js +10 -0
  79. package/server/lib/event-stream/runtime.test.js +53 -0
  80. package/server/lib/opencode/DOCUMENTATION.md +2 -1
  81. package/server/lib/opencode/proxy.js +103 -9
  82. package/server/lib/opencode/proxy.test.js +177 -1
  83. package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
  84. package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
  85. package/server/lib/session-turn-pages/routes.js +22 -3
  86. package/server/lib/session-turn-pages/routes.test.js +43 -0
  87. package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
  88. package/server/lib/transcript-cache/routes.js +9 -2
  89. package/server/lib/transcript-cache/routes.test.js +32 -0
  90. package/dist/assets/AssistantView-BdZ8qKY7.js +0 -2
  91. package/dist/assets/MarkstreamRendererImpl-DnjoP9Et.js +0 -1
  92. package/dist/assets/MobileShareBridge-BeYYag96.js +0 -4
  93. package/dist/assets/desktopHosts-BzSE0sK7.js +0 -1
  94. package/dist/assets/persistence-CSZp1CNB.js +0 -3
  95. package/dist/assets/platform-CenlWqxu.js +0 -2
  96. package/dist/assets/useMobileNavigationStore-BrQwx0Jz.js +0 -412
  97. package/dist/assets/useUIStore-CqqO11ZI.js +0 -1
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Relay integration evidence for Host reasoning projection (`includeReasoning=false`).
3
+ *
4
+ * Stack (real components, not helper-only stubs):
5
+ * - Layer 1: `@openchambery/relay-server` private relay (real auth gates)
6
+ * - Host: `startRelayHost` + tunnel-host loopback dispatcher
7
+ * - Client: TS `createRelayTunnelClient` (E2EE decrypt on receive)
8
+ * - Origin: Express + `createUiAuth` (bearer) + `registerOpenCodeProxy`
9
+ * (the real Host SSE/list filtering routes)
10
+ * - Upstream: controllable fake OpenCode (SSE + session.messages)
11
+ *
12
+ * Asserts disabled clients never decrypt reasoning body markers; enabled clients
13
+ * still receive them; Host strips `includeReasoning` before OpenCode; text /
14
+ * tokens.reasoning / session status survive. Synthetic markers only — no real
15
+ * user content or credentials in assertions.
16
+ */
17
+
18
+ // @ts-nocheck — match relay-server.e2e.test.ts: runtime Vitest/bun-test shim, not tsc project.
19
+ import { afterEach, describe, expect, it } from 'bun:test';
20
+ import crypto from 'node:crypto';
21
+ import express from 'express';
22
+ import http from 'node:http';
23
+ import { mkdtemp, rm } from 'node:fs/promises';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+
27
+ import { startPrivateRelayServer } from '../../../../relay-server/src/index.js';
28
+ import { createRelayTunnelClient } from '../../../../ui/src/lib/relay/tunnel-client.ts';
29
+ import { exportPublicKeyJwk, generateEcdhKeyPair, importEcdhPrivateKey } from './e2ee.js';
30
+ import { startRelayHost } from './host-client.js';
31
+ import { registerOpenCodeProxy } from '../opencode/proxy.js';
32
+ import { createUiAuth } from '../ui-auth/ui-auth.js';
33
+
34
+ const timeoutMs = 30_000;
35
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
36
+
37
+ const eventually = async (
38
+ predicate: () => boolean | Promise<boolean>,
39
+ message: string,
40
+ timeout = timeoutMs,
41
+ ) => {
42
+ const deadline = Date.now() + timeout;
43
+ while (Date.now() < deadline) {
44
+ if (await predicate()) return;
45
+ await wait(25);
46
+ }
47
+ throw new Error(message);
48
+ };
49
+
50
+ /** Synthetic neutral markers — never real message text or secrets. */
51
+ const TEXT_MARKER = 'syn-text-visible-marker-bbbb';
52
+ const REASONING_MARKER = 'syn-reason-hidden-marker-aaaa';
53
+ const REASONING_DELTA_PAD = 'x'.repeat(768);
54
+ const REASONING_DELTA_COUNT = 128;
55
+ const SESSION_STATUS_TYPE = 'session.status';
56
+ const TOKEN_REASONING = 42;
57
+
58
+ const buildIdentity = async () => {
59
+ const enc = await generateEcdhKeyPair();
60
+ const encPrivJwk = await globalThis.crypto.subtle.exportKey('jwk', enc.privateKey);
61
+ const { privateKey: signPriv, publicKey: signPub } = crypto.generateKeyPairSync('ec', {
62
+ namedCurve: 'P-256',
63
+ });
64
+ const signPubJwk = signPub.export({ format: 'jwk' }) as JsonWebKey;
65
+ const canonical = JSON.stringify({
66
+ crv: signPubJwk.crv,
67
+ kty: signPubJwk.kty,
68
+ x: signPubJwk.x,
69
+ y: signPubJwk.y,
70
+ });
71
+ const serverId = crypto.createHash('sha256').update(canonical).digest('base64url');
72
+ return {
73
+ serverId,
74
+ hostEncPubJwk: await exportPublicKeyJwk(enc.publicKey),
75
+ hostEncPrivateKey: await importEcdhPrivateKey(encPrivJwk),
76
+ signRelayAuth: (role: string, connectionId?: string | null) => {
77
+ const ts = Date.now();
78
+ const sig = crypto
79
+ .sign('SHA256', Buffer.from(`${ts}.${serverId}.${role}.${connectionId ?? ''}`), {
80
+ key: signPriv,
81
+ dsaEncoding: 'ieee-p1363',
82
+ })
83
+ .toString('base64url');
84
+ return {
85
+ ts,
86
+ sig,
87
+ pk: Buffer.from(canonical, 'utf8').toString('base64url'),
88
+ };
89
+ },
90
+ };
91
+ };
92
+
93
+ const readResponseText = async (response: Response): Promise<string> => {
94
+ if (!response.body) return '';
95
+ const reader = response.body.getReader();
96
+ const chunks: Uint8Array[] = [];
97
+ while (true) {
98
+ const { done, value } = await reader.read();
99
+ if (done) break;
100
+ if (value && value.length > 0) chunks.push(value);
101
+ }
102
+ return new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c))));
103
+ };
104
+
105
+ const countOccurrences = (haystack: string, needle: string): number => {
106
+ if (!needle) return 0;
107
+ let count = 0;
108
+ let idx = 0;
109
+ while (true) {
110
+ const found = haystack.indexOf(needle, idx);
111
+ if (found === -1) break;
112
+ count += 1;
113
+ idx = found + needle.length;
114
+ }
115
+ return count;
116
+ };
117
+
118
+ const closeServer = (server?: http.Server | null) =>
119
+ new Promise((resolve, reject) => {
120
+ if (!server) {
121
+ resolve();
122
+ return;
123
+ }
124
+ server.close((error) => (error ? reject(error) : resolve()));
125
+ });
126
+
127
+ type UpstreamObservation = {
128
+ urls: string[];
129
+ methods: string[];
130
+ };
131
+
132
+ /**
133
+ * Controllable fake OpenCode: fixed reasoning + text parts/deltas and session status.
134
+ * Records full request URLs so the test can prove includeReasoning never arrives.
135
+ */
136
+ const startFakeOpenCode = async (): Promise<{
137
+ port: number;
138
+ observation: UpstreamObservation;
139
+ stop: () => Promise<void>;
140
+ }> => {
141
+ const observation: UpstreamObservation = { urls: [], methods: [] };
142
+ const app = express();
143
+
144
+ app.use((req, _res, next) => {
145
+ observation.urls.push(req.originalUrl || req.url || '');
146
+ observation.methods.push(req.method || 'GET');
147
+ next();
148
+ });
149
+
150
+ app.get('/global/event', (req, res) => {
151
+ res.writeHead(200, {
152
+ 'content-type': 'text/event-stream; charset=utf-8',
153
+ 'cache-control': 'no-cache',
154
+ connection: 'keep-alive',
155
+ });
156
+
157
+ // Seed part types so field=text deltas classify correctly on Host filter.
158
+ res.write(
159
+ `id: seed-r\ndata: ${JSON.stringify({
160
+ type: 'message.part.updated',
161
+ properties: {
162
+ part: {
163
+ id: 'prt_reason',
164
+ messageID: 'msg_1',
165
+ type: 'reasoning',
166
+ text: REASONING_MARKER,
167
+ },
168
+ },
169
+ })}\n\n`,
170
+ );
171
+ res.write(
172
+ `id: seed-t\ndata: ${JSON.stringify({
173
+ type: 'message.part.updated',
174
+ properties: {
175
+ part: {
176
+ id: 'prt_text',
177
+ messageID: 'msg_1',
178
+ type: 'text',
179
+ text: TEXT_MARKER,
180
+ },
181
+ },
182
+ })}\n\n`,
183
+ );
184
+
185
+ // High-volume reasoning deltas (field=text on known reasoning part).
186
+ for (let i = 0; i < REASONING_DELTA_COUNT; i += 1) {
187
+ res.write(
188
+ `id: rd-${i}\ndata: ${JSON.stringify({
189
+ type: 'message.part.delta',
190
+ properties: {
191
+ messageID: 'msg_1',
192
+ partID: 'prt_reason',
193
+ field: 'text',
194
+ delta: `${REASONING_MARKER}-${i}-${REASONING_DELTA_PAD}`,
195
+ },
196
+ })}\n\n`,
197
+ );
198
+ }
199
+
200
+ // Ordinary text delta must survive filtering.
201
+ res.write(
202
+ `id: td-1\ndata: ${JSON.stringify({
203
+ type: 'message.part.delta',
204
+ properties: {
205
+ messageID: 'msg_1',
206
+ partID: 'prt_text',
207
+ field: 'text',
208
+ delta: `${TEXT_MARKER}-delta`,
209
+ },
210
+ })}\n\n`,
211
+ );
212
+
213
+ // Versioned current-style reasoning stream event — must drop when disabled.
214
+ res.write(
215
+ `id: snr-1\ndata: ${JSON.stringify({
216
+ type: 'session.next.reasoning.delta',
217
+ properties: { sessionID: 'ses_1', delta: `${REASONING_MARKER}-snr` },
218
+ })}\n\n`,
219
+ );
220
+
221
+ // Session status + message snapshot with tokens.reasoning must keep.
222
+ res.write(
223
+ `id: st-1\ndata: ${JSON.stringify({
224
+ type: SESSION_STATUS_TYPE,
225
+ properties: { sessionID: 'ses_1', status: 'busy' },
226
+ })}\n\n`,
227
+ );
228
+ res.write(
229
+ `id: mu-1\ndata: ${JSON.stringify({
230
+ type: 'message.updated',
231
+ properties: {
232
+ info: {
233
+ id: 'msg_1',
234
+ role: 'assistant',
235
+ tokens: { input: 1, output: 2, reasoning: TOKEN_REASONING },
236
+ },
237
+ parts: [
238
+ { id: 'prt_reason', type: 'reasoning', text: REASONING_MARKER },
239
+ { id: 'prt_text', type: 'text', text: TEXT_MARKER },
240
+ ],
241
+ },
242
+ })}\n\n`,
243
+ );
244
+
245
+ res.end();
246
+ });
247
+
248
+ app.get('/session/:sessionID/message', (req, res) => {
249
+ res.json([
250
+ {
251
+ info: {
252
+ id: 'msg_1',
253
+ role: 'assistant',
254
+ tokens: { input: 3, output: 4, reasoning: TOKEN_REASONING },
255
+ },
256
+ parts: [
257
+ { id: 'prt_reason', type: 'reasoning', text: REASONING_MARKER },
258
+ { id: 'prt_text', type: 'text', text: TEXT_MARKER },
259
+ { id: 'prt_tool', type: 'tool', tool: 'bash' },
260
+ ],
261
+ },
262
+ ]);
263
+ });
264
+
265
+ const server = await new Promise<http.Server>((resolve, reject) => {
266
+ const s = app.listen(0, '127.0.0.1', () => resolve(s));
267
+ s.once('error', reject);
268
+ });
269
+ const port = (server.address() as { port: number }).port;
270
+
271
+ return {
272
+ port,
273
+ observation,
274
+ stop: () => closeServer(server),
275
+ };
276
+ };
277
+
278
+ /**
279
+ * OpenChamber origin: real UI auth + real OpenCode proxy (Host filtering boundary).
280
+ * Tunnel-host dials this loopback port.
281
+ */
282
+ const startOpenChamberOrigin = async (openCodePort: number) => {
283
+ const clientAuthController = {
284
+ authenticateBearerToken: async (token: string) =>
285
+ token === 'client-token'
286
+ ? { ok: true, clientId: 'device-relay-reason', sessionToken: 'client:device-relay-reason' }
287
+ : { ok: false },
288
+ };
289
+ const uiAuth = createUiAuth({
290
+ requireClientAuth: true,
291
+ clientAuthController: clientAuthController as never,
292
+ });
293
+
294
+ const app = express();
295
+ app.get('/health', (_req, res) => {
296
+ res.json({ ok: true, service: 'openchamber-origin' });
297
+ });
298
+
299
+ // Real auth gate before Host proxy routes (parity with remote clients).
300
+ app.use((req, res, next) => {
301
+ if (req.path === '/health') {
302
+ next();
303
+ return;
304
+ }
305
+ void uiAuth.requireAuth(req, res, next);
306
+ });
307
+
308
+ registerOpenCodeProxy(app, {
309
+ fs: { promises: { realpath: async (value: string) => value } },
310
+ os: {},
311
+ path: {},
312
+ OPEN_CODE_READY_GRACE_MS: 0,
313
+ SSE_UPSTREAM_CONNECT_TIMEOUT_MS: 5_000,
314
+ SSE_UPSTREAM_STALL_TIMEOUT_MS: 10_000,
315
+ getRuntime: () => ({
316
+ openCodePort,
317
+ isOpenCodeReady: true,
318
+ openCodeNotReadySince: 0,
319
+ isRestartingOpenCode: false,
320
+ openCodeBaseUrl: `http://127.0.0.1:${openCodePort}`,
321
+ }),
322
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer opencode-upstream-token' }),
323
+ buildOpenCodeUrl: (requestPath: string) => {
324
+ const normalized = requestPath.startsWith('/') ? requestPath : `/${requestPath}`;
325
+ return `http://127.0.0.1:${openCodePort}${normalized}`;
326
+ },
327
+ ensureOpenCodeApiPrefix: () => {},
328
+ });
329
+
330
+ const server = await new Promise<http.Server>((resolve, reject) => {
331
+ const s = app.listen(0, '127.0.0.1', () => resolve(s));
332
+ s.once('error', reject);
333
+ });
334
+ const port = (server.address() as { port: number }).port;
335
+
336
+ return {
337
+ port,
338
+ stop: async () => {
339
+ await closeServer(server);
340
+ uiAuth.dispose();
341
+ },
342
+ };
343
+ };
344
+
345
+ describe('relay reasoning projection e2e (Host filter over private relay)', () => {
346
+ let cleanup: (() => Promise<void>) | undefined;
347
+
348
+ afterEach(async () => {
349
+ await cleanup?.();
350
+ cleanup = undefined;
351
+ });
352
+
353
+ it(
354
+ 'filters reasoning on SSE + session.messages through real relay tunnel while enabled path keeps them',
355
+ { timeout: 90_000 },
356
+ async () => {
357
+ const temp = await mkdtemp(path.join(os.tmpdir(), 'openchamber-relay-reason-'));
358
+ const previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
359
+ process.env.OPENCHAMBER_DATA_DIR = temp;
360
+
361
+ let relay: Awaited<ReturnType<typeof startPrivateRelayServer>> | undefined;
362
+ let upstream: Awaited<ReturnType<typeof startFakeOpenCode>> | undefined;
363
+ let origin: Awaited<ReturnType<typeof startOpenChamberOrigin>> | undefined;
364
+ let host: ReturnType<typeof startRelayHost> | undefined;
365
+ let client: ReturnType<typeof createRelayTunnelClient> | undefined;
366
+
367
+ cleanup = async () => {
368
+ const results = await Promise.allSettled([
369
+ Promise.resolve(client?.close()),
370
+ Promise.resolve(host?.stop()),
371
+ origin?.stop(),
372
+ upstream?.stop(),
373
+ relay?.stop(),
374
+ ]);
375
+ if (previousDataDir === undefined) delete process.env.OPENCHAMBER_DATA_DIR;
376
+ else process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
377
+ await rm(temp, { recursive: true, force: true });
378
+ const failed = results.find((result) => result.status === 'rejected');
379
+ if (failed?.status === 'rejected') throw failed.reason;
380
+ };
381
+
382
+ upstream = await startFakeOpenCode();
383
+ origin = await startOpenChamberOrigin(upstream.port);
384
+ relay = await startPrivateRelayServer({ host: '127.0.0.1', port: 0 });
385
+ const relayUrl = relay.wsUrl;
386
+ if (!relayUrl) throw new Error('relay did not expose wsUrl');
387
+
388
+ const identity = await buildIdentity();
389
+ host = startRelayHost({
390
+ relayUrl,
391
+ identity,
392
+ getLocalPort: () => origin!.port,
393
+ onStatus: () => {},
394
+ logger: { warn: () => {}, info: () => {} },
395
+ });
396
+
397
+ client = createRelayTunnelClient({
398
+ relayUrl,
399
+ serverId: identity.serverId,
400
+ hostEncPubJwk: identity.hostEncPubJwk,
401
+ helloRetryMs: 50,
402
+ helloTimeoutMs: 2_000,
403
+ pingIntervalMs: 500,
404
+ pingTimeoutMs: 250,
405
+ reconnectBaseDelayMs: 50,
406
+ reconnectMaxDelayMs: 250,
407
+ batchWindowMs: 5,
408
+ });
409
+
410
+ await eventually(
411
+ () => host!.getStatus().state === 'connected' && client!.getStatus().state === 'connected',
412
+ 'host and tunnel client did not connect over private relay',
413
+ );
414
+
415
+ const authHeaders = { authorization: 'Bearer client-token' };
416
+
417
+ // --- SSE: includeReasoning=false (must go through Host proxy filter) ---
418
+ const disabledSse = await client.fetch('/api/global/event?includeReasoning=false', {
419
+ headers: authHeaders,
420
+ });
421
+ expect(disabledSse.status).toBe(200);
422
+ const disabledSseText = await readResponseText(disabledSse);
423
+ const disabledSseBytes = Buffer.byteLength(disabledSseText, 'utf8');
424
+
425
+ // --- SSE: default enabled (byte path keeps reasoning) ---
426
+ const enabledSse = await client.fetch('/api/global/event', {
427
+ headers: authHeaders,
428
+ });
429
+ expect(enabledSse.status).toBe(200);
430
+ const enabledSseText = await readResponseText(enabledSse);
431
+ const enabledSseBytes = Buffer.byteLength(enabledSseText, 'utf8');
432
+
433
+ // Disabled: zero reasoning body leakage after tunnel decrypt.
434
+ expect(disabledSseText).not.toContain(REASONING_MARKER);
435
+ expect(disabledSseText).not.toContain('session.next.reasoning');
436
+ expect(disabledSseText).not.toContain('"type":"reasoning"');
437
+ expect(countOccurrences(disabledSseText, REASONING_MARKER)).toBe(0);
438
+
439
+ // Disabled: text + session status + tokens.reasoning survive.
440
+ expect(disabledSseText).toContain(TEXT_MARKER);
441
+ expect(disabledSseText).toContain(SESSION_STATUS_TYPE);
442
+ expect(disabledSseText).toContain(`"reasoning":${TOKEN_REASONING}`);
443
+ expect(disabledSseText).toContain('"type":"text"');
444
+ expect(disabledSseText).toContain(`${TEXT_MARKER}-delta`);
445
+
446
+ // Enabled: reasoning still present (proves upstream + passthrough path).
447
+ expect(enabledSseText).toContain(REASONING_MARKER);
448
+ expect(enabledSseText).toContain('session.next.reasoning.delta');
449
+ expect(enabledSseText).toContain('"type":"reasoning"');
450
+ expect(countOccurrences(enabledSseText, REASONING_MARKER)).toBeGreaterThanOrEqual(
451
+ REASONING_DELTA_COUNT,
452
+ );
453
+ expect(enabledSseText).toContain(TEXT_MARKER);
454
+ expect(enabledSseText).toContain(SESSION_STATUS_TYPE);
455
+
456
+ // Byte comparison: disabled drops the KB-scale reasoning flood.
457
+ expect(enabledSseBytes).toBeGreaterThan(disabledSseBytes);
458
+ expect(enabledSseBytes - disabledSseBytes).toBeGreaterThan(
459
+ REASONING_DELTA_COUNT * 400,
460
+ );
461
+
462
+ // Text event parity: both paths keep the ordinary text delta once.
463
+ const disabledTextDeltas = countOccurrences(disabledSseText, `${TEXT_MARKER}-delta`);
464
+ const enabledTextDeltas = countOccurrences(enabledSseText, `${TEXT_MARKER}-delta`);
465
+ expect(disabledTextDeltas).toBe(1);
466
+ expect(enabledTextDeltas).toBe(1);
467
+
468
+ // Observable run metrics for coordinator acceptance (synthetic markers only).
469
+ // eslint-disable-next-line no-console
470
+ console.log(
471
+ JSON.stringify({
472
+ reasoningDeltaCount: REASONING_DELTA_COUNT,
473
+ reasoningMarkerHitsDisabled: countOccurrences(disabledSseText, REASONING_MARKER),
474
+ reasoningMarkerHitsEnabled: countOccurrences(enabledSseText, REASONING_MARKER),
475
+ disabledSseBytes,
476
+ enabledSseBytes,
477
+ savedBytes: enabledSseBytes - disabledSseBytes,
478
+ disabledTextDeltas,
479
+ enabledTextDeltas,
480
+ upstreamUrlCount: upstream.observation.urls.length,
481
+ }),
482
+ );
483
+
484
+ // --- HTTP session.messages list (optional second surface) ---
485
+ const disabledList = await client.fetch(
486
+ '/api/session/ses_1/message?limit=30&includeReasoning=false&directory=%2Frepo',
487
+ { headers: authHeaders },
488
+ );
489
+ expect(disabledList.status).toBe(200);
490
+ const disabledListJson = (await disabledList.json()) as Array<{
491
+ info: { tokens: { reasoning: number } };
492
+ parts: Array<{ type: string; text?: string }>;
493
+ }>;
494
+ expect(disabledListJson[0].info.tokens.reasoning).toBe(TOKEN_REASONING);
495
+ expect(disabledListJson[0].parts.every((p) => p.type !== 'reasoning')).toBe(true);
496
+ expect(JSON.stringify(disabledListJson)).not.toContain(REASONING_MARKER);
497
+ expect(disabledListJson[0].parts.some((p) => p.type === 'text' && p.text === TEXT_MARKER)).toBe(
498
+ true,
499
+ );
500
+
501
+ const enabledList = await client.fetch('/api/session/ses_1/message?directory=%2Frepo', {
502
+ headers: authHeaders,
503
+ });
504
+ expect(enabledList.status).toBe(200);
505
+ const enabledListJson = (await enabledList.json()) as Array<{
506
+ parts: Array<{ type: string; text?: string }>;
507
+ }>;
508
+ expect(enabledListJson[0].parts.some((p) => p.type === 'reasoning')).toBe(true);
509
+ expect(JSON.stringify(enabledListJson)).toContain(REASONING_MARKER);
510
+
511
+ // Host must strip includeReasoning before OpenCode upstream (all observed URLs).
512
+ expect(upstream.observation.urls.length).toBeGreaterThan(0);
513
+ for (const url of upstream.observation.urls) {
514
+ expect(url).not.toContain('includeReasoning');
515
+ }
516
+ // Sanity: upstream still saw the event + message routes.
517
+ expect(upstream.observation.urls.some((u) => u.includes('/global/event'))).toBe(true);
518
+ expect(upstream.observation.urls.some((u) => u.includes('/session/ses_1/message'))).toBe(
519
+ true,
520
+ );
521
+
522
+ // Unauthenticated tunnel request still hits real auth gate (not open).
523
+ const denied = await client.fetch('/api/global/event?includeReasoning=false', {
524
+ headers: { authorization: 'Bearer wrong-token' },
525
+ });
526
+ expect(denied.status).toBe(401);
527
+ },
528
+ );
529
+ });
@@ -34,6 +34,7 @@ Official OpenCode pagination is message-count based and uses **opaque** cursors
34
34
  | `scanLimit` | **omitted** | `10..200` when present | Optional override only. Host→OpenCode local page size; when omitted it resolves to `_inner_scanLimit`. Clients should omit this. |
35
35
  | `before` | omitted | opaque string ≤ 8192 chars | Resume older history: host cursor (`oc1.…`) or raw OpenCode SDK cursor on first page |
36
36
  | `directory` | omitted | path string | Forwarded to directory-scoped OpenCode client |
37
+ | `includeReasoning` | omitted (include) | strict `'false'` only disables | OpenChamber projection control. When `'false'`, drop `type=reasoning` parts from turn-page / reconcile / exact responses after other projections. Missing or any other value keeps existing behavior (slim reasoning identity on turn pages; full parts on reconcile/exact). **Never forwarded to OpenCode.** Same contract on transcript-cache reads, official `session.messages` list proxy, and event SSE/WS send boundaries — see `event-stream/reasoning-projection.js`. |
37
38
 
38
39
  Invalid `turns`, explicit invalid `scanLimit`, or `before` (malformed/stale host token, oversize) → HTTP 400.
39
40
 
@@ -91,7 +92,7 @@ Changes query guards: `sessionID` / `messageID` length `1..512`; optional `file`
91
92
  | Kept | Dropped |
92
93
  |---|---|
93
94
  | `tool`: `id`, `sessionID`, `messageID`, `callID`, `tool`, `state.status`, `state.title`, `state.time`, slim locator `state.input` (`path` / `pattern` / `command` / `subagent_type` / `description` / skill `name` / `id` / …), slim `state.metadata` (`sessionId`, skill `name`, `additions` / `deletions`) | `state.output`, write `content`, task `prompt`, patch/diff bodies, other metadata, `state.error` |
94
- | `reasoning`: `id`, `sessionID`, `messageID`, `time` | reasoning `text` body |
95
+ | `reasoning`: `id`, `sessionID`, `messageID`, `time` (default include path) | reasoning `text` body; when `includeReasoning=false` the entire reasoning part is removed |
95
96
  | `file` (user and assistant): `id`, `sessionID`, `messageID`, `type`, `mime`, `filename`, existing stable `size` / `width` / `height`, derived `byteSize` | `url`, base64, attachment body |
96
97
 
97
98
  Rules:
@@ -1,5 +1,9 @@
1
1
  import { createOpencodeClient } from '@opencode-ai/sdk/v2';
2
2
  import { projectMessageSummaryDiffCounts } from '../event-stream/diff-summary.js';
3
+ import {
4
+ projectMessagesPayloadForReasoning,
5
+ readIncludeReasoningQuery,
6
+ } from '../event-stream/reasoning-projection.js';
3
7
  import {
4
8
  createSessionChangesService,
5
9
  } from './changes.service.js';
@@ -509,8 +513,14 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
509
513
  return res.status(mapped.status).json(mapped.body);
510
514
  }
511
515
 
516
+ const includeReasoning = readIncludeReasoningQuery(req.query);
517
+ const records = projectMessagesPayloadForReasoning(
518
+ projectMessageDiffSummaries(Array.isArray(result.records) ? result.records : []),
519
+ includeReasoning,
520
+ );
521
+
512
522
  return res.status(200).json({
513
- records: projectMessageDiffSummaries(Array.isArray(result.records) ? result.records : []),
523
+ records,
514
524
  anchorFound: result.anchorFound === true,
515
525
  capturedHeadMessageID: result.capturedHeadMessageID ?? null,
516
526
  latestHeadMessageID: result.latestHeadMessageID ?? null,
@@ -599,8 +609,14 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
599
609
 
600
610
  // Turn-page responses (first packet and prepend) share slim-v1.
601
611
  // Reconcile stays on the other route and keeps full parts.
612
+ // includeReasoning=false drops type=reasoning parts after slim projection.
613
+ const includeReasoning = readIncludeReasoningQuery(req.query);
614
+ const records = projectMessagesPayloadForReasoning(
615
+ projectSlimParts(result.records),
616
+ includeReasoning,
617
+ );
602
618
  return res.status(200).json({
603
- records: projectSlimParts(result.records),
619
+ records,
604
620
  turnCount: result.turnCount,
605
621
  cursor: result.cursor ?? null,
606
622
  complete: result.complete === true,
@@ -652,7 +668,10 @@ export const registerSessionTurnPageRoutes = (app, dependencies = {}) => {
652
668
  directory,
653
669
  signal: timed.signal,
654
670
  });
655
- return res.status(200).json(projectExactMessagePayload(payload));
671
+ const includeReasoning = readIncludeReasoningQuery(req.query);
672
+ return res.status(200).json(
673
+ projectMessagesPayloadForReasoning(projectExactMessagePayload(payload), includeReasoning),
674
+ );
656
675
  } catch (error) {
657
676
  if (error?.name === 'AbortError' || error?.code === 'aborted') {
658
677
  if (!res.headersSent) {
@@ -120,6 +120,49 @@ describe('registerSessionTurnPageRoutes', () => {
120
120
  expect(res.body.records).toHaveLength(2);
121
121
  });
122
122
 
123
+ it('drops reasoning parts when includeReasoning=false and keeps tokens.reasoning', async () => {
124
+ const loadPage = vi.fn(async () => ({
125
+ ok: true,
126
+ records: [{
127
+ info: {
128
+ id: 'msg_a1',
129
+ role: 'assistant',
130
+ tokens: { input: 1, output: 2, reasoning: 42 },
131
+ },
132
+ parts: [
133
+ { id: 'r1', type: 'reasoning', text: 'private' },
134
+ { id: 't1', type: 'text', text: 'answer' },
135
+ ],
136
+ }],
137
+ turnCount: 0,
138
+ cursor: null,
139
+ complete: true,
140
+ }));
141
+ const { app, route } = registry();
142
+ registerSessionTurnPageRoutes(app, { sessionTurnPageService: { loadPage } });
143
+
144
+ const stripped = response();
145
+ await route('GET', ROUTE)({
146
+ params: { sessionID: 'ses_1' },
147
+ query: { includeReasoning: 'false' },
148
+ headers: {},
149
+ }, stripped);
150
+ expect(stripped.statusCode).toBe(200);
151
+ expect(stripped.body.records[0].parts).toEqual([
152
+ expect.objectContaining({ id: 't1', type: 'text', text: 'answer' }),
153
+ ]);
154
+ expect(stripped.body.records[0].info.tokens.reasoning).toBe(42);
155
+ expect(JSON.stringify(stripped.body)).not.toContain('private');
156
+
157
+ const kept = response();
158
+ await route('GET', ROUTE)({
159
+ params: { sessionID: 'ses_1' },
160
+ query: {},
161
+ headers: {},
162
+ }, kept);
163
+ expect(kept.body.records[0].parts.some((part) => part.type === 'reasoning')).toBe(true);
164
+ });
165
+
123
166
  it('maps upstream service errors to an upstream HTTP status', async () => {
124
167
  const loadPage = vi.fn(async () => ({
125
168
  ok: false,
@@ -49,8 +49,8 @@ proxy, on the existing UI-password / CORS path. Prefix:
49
49
 
50
50
  | Method | Path | Action |
51
51
  |---|---|---|
52
- | `GET` | `/session` | Read a scoped timeline (`transport`, `generation`, `directory`, `sessionID` query) |
53
- | `GET` | `/message` | Read one message (scope query + `messageID`) |
52
+ | `GET` | `/session` | Read a scoped timeline (`transport`, `generation`, `directory`, `sessionID` query). Optional `includeReasoning=false` (strict) strips `type=reasoning` parts on the response only. |
53
+ | `GET` | `/message` | Read one message (scope query + `messageID`). Same `includeReasoning` projection as session read. |
54
54
  | `PUT` | `/message` | Upsert a settled message (`{ scope, info, parts }`) |
55
55
  | `DELETE` | `/message` | Remove one message (`{ scope, messageID }`) |
56
56
  | `DELETE` | `/session` | Clear one scope |