@openchambery/web 1.19.7-beta.24 → 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 (101) 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-DZhC-gPw.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-Dd7nPtW4.js → MarkdownRenderer-uiTtRexW.js} +2 -2
  8. package/dist/assets/{MarkdownRendererImpl-CuGusb9c.js → MarkdownRendererImpl-DszSA6px.js} +1 -1
  9. package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
  10. package/dist/assets/MarkstreamRendererImpl-DkjZ7QYA.js +1 -0
  11. package/dist/assets/{MobileDetailNavigation-DXLN8AfE.js → MobileDetailNavigation-BElZdvTL.js} +1 -1
  12. package/dist/assets/MobileShareBridge-DiXOck30.js +4 -0
  13. package/dist/assets/{MobileSurface-DZys0gQW.js → MobileSurface-BCIZyDOc.js} +1 -1
  14. package/dist/assets/{MultiRunWindow-DNSCvp1n.js → MultiRunWindow-XXWgGd6d.js} +1 -1
  15. package/dist/assets/{OnboardingScreen-BNEtPmvw.js → OnboardingScreen-C3-wzeSX.js} +1 -1
  16. package/dist/assets/{OverlayScrollbar-BZB21Yp_.js → OverlayScrollbar-KUpvYHdv.js} +1 -1
  17. package/dist/assets/{SettingsView-CW0sP1G5.js → SettingsView-CdYIJh0f.js} +5 -5
  18. package/dist/assets/{SettingsWindow-BbY8rAaP.js → SettingsWindow-CLr0dXVg.js} +1 -1
  19. package/dist/assets/{TerminalView-Blou_qZo.js → TerminalView-BdtasXsi.js} +1 -1
  20. package/dist/assets/{ToolOutputDialog-D69bhD7_.js → ToolOutputDialog-D22RYqMI.js} +1 -1
  21. package/dist/assets/{appThemeRegistry-DTf7Ymvj.js → appThemeRegistry-D1wTvHoc.js} +1 -1
  22. package/dist/assets/{button-Or0daT9o.js → button-C0cth9tl.js} +1 -1
  23. package/dist/assets/{clipboard-BXxLshVq.js → clipboard-DJ4nQEuY.js} +1 -1
  24. package/dist/assets/{collapsible-C-kzSGkq.js → collapsible-DFT9cE80.js} +1 -1
  25. package/dist/assets/{context-menu-Dpjla5p4.js → context-menu-CAp74NfV.js} +1 -1
  26. package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
  27. package/dist/assets/{desktopRelayRestore-BaKjk1kQ.js → desktopRelayRestore-9EbBYQ9s.js} +1 -1
  28. package/dist/assets/{diagram-D0mT3mo1.js → diagram-BryzMuta.js} +1 -1
  29. package/dist/assets/{dialog-BkhHBTd4.js → dialog-CgbV5ljg.js} +1 -1
  30. package/dist/assets/{fileQueries-DFY6Kiy1.js → fileQueries-BY-9OWzp.js} +1 -1
  31. package/dist/assets/{fileReferenceActions-Di_DoDex.js → fileReferenceActions-BJJtX_S5.js} +1 -1
  32. package/dist/assets/{gitApi-s3lJ_NUC.js → gitApi-RhwRTt2X.js} +1 -1
  33. package/dist/assets/{input-BUsGnzwe.js → input-DFLpqjGR.js} +1 -1
  34. package/dist/assets/{insertionBoundaries-BXIh3qwN.js → insertionBoundaries-D-UUqego.js} +1 -1
  35. package/dist/assets/{main-CbWB2MxW.js → main-BRB8Kly8.js} +2 -2
  36. package/dist/assets/{main-6h4AIMd7.js → main-BaSeqhTm.js} +11 -11
  37. package/dist/assets/{markdownTheme-CVS01wHf.js → markdownTheme-CzZm3riB.js} +1 -1
  38. package/dist/assets/{miniChat-DuYRiHjj.js → miniChat-CDZ48aEf.js} +2 -2
  39. package/dist/assets/{mobile-DKja4fFV.js → mobile-B2Hpp_5U.js} +2 -2
  40. package/dist/assets/{multirun-CWbdKSsT.js → multirun-CfvHH_3s.js} +1 -1
  41. package/dist/assets/{outsideFileGrants-BB4GPvCq.js → outsideFileGrants-DnplRxr-.js} +1 -1
  42. package/dist/assets/persistence-DdUac14I.js +3 -0
  43. package/dist/assets/platform-B4KKLnZ5.js +2 -0
  44. package/dist/assets/{projectMeta-BjeUc8MG.js → projectMeta-vCCip4-G.js} +1 -1
  45. package/dist/assets/{renderElectronMiniChatApp-Cv1F39fu.js → renderElectronMiniChatApp-D9RarEOS.js} +2 -2
  46. package/dist/assets/{renderMobileApp-C4zDY0CW.js → renderMobileApp-DOiQLCcY.js} +7 -7
  47. package/dist/assets/{runtimeConfig-D0G_wzhg.js → runtimeConfig-CkWKMmAR.js} +1 -1
  48. package/dist/assets/{runtimeEndpointReset-Cx37ZLh4.js → runtimeEndpointReset-aW8qogl0.js} +1 -1
  49. package/dist/assets/{sessionChangeSummary-CX2MJIkc.js → sessionChangeSummary-DOq8EN_H.js} +1 -1
  50. package/dist/assets/{sessionLookup-B56D_zE1.js → sessionLookup-BNO4Ta46.js} +1 -1
  51. package/dist/assets/{sortable-tabs-strip-C5gGnikj.js → sortable-tabs-strip-C8_9kHqu.js} +1 -1
  52. package/dist/assets/src-DasvfkEg.css +2 -0
  53. package/dist/assets/{terminalApi-CIIs9vFx.js → terminalApi-947l7Ra3.js} +1 -1
  54. package/dist/assets/{textarea-CIO34TZz.js → textarea-Cgo_nWUU.js} +1 -1
  55. package/dist/assets/{tooltip-BSrJYYYc.js → tooltip-BifLJ8o0.js} +1 -1
  56. package/dist/assets/{url-CE_gAfNa.js → url-Pk6tQ1EZ.js} +1 -1
  57. package/dist/assets/{useAppFontEffects-i1QzgXKU.js → useAppFontEffects-BqBeuVr8.js} +6 -6
  58. package/dist/assets/{useAssistantUIStore-BHacrxe_.js → useAssistantUIStore-me6s63b2.js} +1 -1
  59. package/dist/assets/{useDesktopWindowControlsLayout-BqT0VDLV.js → useDesktopWindowControlsLayout-CnX7JJa1.js} +1 -1
  60. package/dist/assets/{useEffectiveDirectory-CUSBKiSi.js → useEffectiveDirectory-F2fNcQ22.js} +1 -1
  61. package/dist/assets/{useFontPreferences-BxOHKuVw.js → useFontPreferences-BCOBlaAd.js} +1 -1
  62. package/dist/assets/useMobileNavigationStore-CBfSvE2P.js +413 -0
  63. package/dist/assets/{useSessionAutoCleanup-mitMfzXN.js → useSessionAutoCleanup-BVJKWCtK.js} +1 -1
  64. package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
  65. package/dist/assets/{useWorkerHighlightedLines-BZHDVxzL.js → useWorkerHighlightedLines-WYqpkPyP.js} +1 -1
  66. package/dist/assets/{utils-B0mQXN9L.js → utils-BiK3tBGM.js} +1 -1
  67. package/dist/index.html +9 -9
  68. package/dist/mini-chat.html +9 -9
  69. package/dist/mobile.html +9 -9
  70. package/package.json +1 -1
  71. package/server/lib/assistants/contact-tools.test.js +6 -6
  72. package/server/lib/assistants/harness.test.js +3 -3
  73. package/server/lib/assistants/service.test.js +1 -1
  74. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  75. package/server/lib/event-stream/directory-ws-bridge.js +11 -2
  76. package/server/lib/event-stream/global-ws-bridge.js +19 -3
  77. package/server/lib/event-stream/protocol.js +11 -1
  78. package/server/lib/event-stream/reasoning-projection.js +425 -0
  79. package/server/lib/event-stream/reasoning-projection.test.js +300 -0
  80. package/server/lib/event-stream/runtime.js +10 -0
  81. package/server/lib/event-stream/runtime.test.js +53 -0
  82. package/server/lib/opencode/DOCUMENTATION.md +2 -1
  83. package/server/lib/opencode/proxy.js +103 -9
  84. package/server/lib/opencode/proxy.test.js +177 -1
  85. package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
  86. package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
  87. package/server/lib/session-turn-pages/routes.js +22 -3
  88. package/server/lib/session-turn-pages/routes.test.js +43 -0
  89. package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
  90. package/server/lib/transcript-cache/routes.js +9 -2
  91. package/server/lib/transcript-cache/routes.test.js +32 -0
  92. package/dist/assets/AssistantView-DDIVh1W4.js +0 -2
  93. package/dist/assets/MarkstreamRendererImpl-Ca7RATbs.css +0 -1
  94. package/dist/assets/MarkstreamRendererImpl-DANegq9r.js +0 -1
  95. package/dist/assets/MobileShareBridge-BVHXNDqj.js +0 -4
  96. package/dist/assets/desktopHosts-BzSE0sK7.js +0 -1
  97. package/dist/assets/persistence-CSZp1CNB.js +0 -3
  98. package/dist/assets/platform-CenlWqxu.js +0 -2
  99. package/dist/assets/src-Cs2WiOMc.css +0 -2
  100. package/dist/assets/useMobileNavigationStore-C-EBQ7Zt.js +0 -412
  101. package/dist/assets/useUIStore-CqqO11ZI.js +0 -1
@@ -0,0 +1,300 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ createReasoningOutboundFilter,
5
+ createSseBlockSplitter,
6
+ filterSseBlock,
7
+ projectMessagesPayloadForReasoning,
8
+ projectMessagesPayloadWithoutReasoning,
9
+ readIncludeReasoningFromUrl,
10
+ readIncludeReasoningQuery,
11
+ shouldIncludeReasoning,
12
+ stripIncludeReasoningParam,
13
+ } from './reasoning-projection.js';
14
+
15
+ describe('shouldIncludeReasoning / query helpers', () => {
16
+ it('only treats the strict string false as disabled', () => {
17
+ expect(shouldIncludeReasoning(undefined)).toBe(true);
18
+ expect(shouldIncludeReasoning(null)).toBe(true);
19
+ expect(shouldIncludeReasoning('')).toBe(true);
20
+ expect(shouldIncludeReasoning('true')).toBe(true);
21
+ expect(shouldIncludeReasoning('0')).toBe(true);
22
+ expect(shouldIncludeReasoning('False')).toBe(true);
23
+ expect(shouldIncludeReasoning(false)).toBe(true);
24
+ expect(shouldIncludeReasoning('false')).toBe(false);
25
+ expect(shouldIncludeReasoning(['false'])).toBe(false);
26
+ });
27
+
28
+ it('reads Express query and URL forms', () => {
29
+ expect(readIncludeReasoningQuery({ includeReasoning: 'false' })).toBe(false);
30
+ expect(readIncludeReasoningQuery({})).toBe(true);
31
+ expect(readIncludeReasoningFromUrl('/api/event?includeReasoning=false')).toBe(false);
32
+ expect(readIncludeReasoningFromUrl('/api/event')).toBe(true);
33
+ });
34
+
35
+ it('strips includeReasoning from upstream paths', () => {
36
+ expect(stripIncludeReasoningParam('/event?directory=/tmp&includeReasoning=false&x=1'))
37
+ .toBe('/event?directory=%2Ftmp&x=1');
38
+ expect(stripIncludeReasoningParam('/event?directory=/tmp')).toBe('/event?directory=/tmp');
39
+ });
40
+ });
41
+
42
+ describe('HTTP snapshot projection', () => {
43
+ it('drops reasoning parts and keeps tokens.reasoning', () => {
44
+ const record = {
45
+ info: { id: 'msg_1', tokens: { input: 1, output: 2, reasoning: 99 } },
46
+ parts: [
47
+ { id: 'p_r', type: 'reasoning', text: 'secret chain' },
48
+ { id: 'p_t', type: 'text', text: 'hello' },
49
+ ],
50
+ };
51
+ const projected = projectMessagesPayloadWithoutReasoning(record);
52
+ expect(projected.info.tokens.reasoning).toBe(99);
53
+ expect(projected.parts).toEqual([{ id: 'p_t', type: 'text', text: 'hello' }]);
54
+ expect(JSON.stringify(projected)).not.toContain('secret chain');
55
+ expect(record.parts).toHaveLength(2);
56
+ });
57
+
58
+ it('projects list / records / cache envelopes with identity when unchanged', () => {
59
+ const withReasoning = {
60
+ info: { id: 'm1', tokens: { reasoning: 3 } },
61
+ parts: [{ type: 'reasoning', text: 'nope' }, { type: 'text', text: 'ok' }],
62
+ };
63
+ expect(projectMessagesPayloadWithoutReasoning([withReasoning])[0].parts).toEqual([
64
+ { type: 'text', text: 'ok' },
65
+ ]);
66
+ const page = projectMessagesPayloadWithoutReasoning({ records: [withReasoning], turnCount: 1 });
67
+ expect(page.records[0].parts).toHaveLength(1);
68
+ expect(page.turnCount).toBe(1);
69
+
70
+ const cache = projectMessagesPayloadWithoutReasoning({
71
+ available: true,
72
+ record: withReasoning,
73
+ records: [withReasoning],
74
+ });
75
+ expect(cache.record.parts).toHaveLength(1);
76
+ expect(projectMessagesPayloadForReasoning(withReasoning, true)).toBe(withReasoning);
77
+
78
+ const plain = { info: { id: 'm' }, parts: [{ type: 'text', text: 'x' }] };
79
+ const list = [plain];
80
+ expect(projectMessagesPayloadWithoutReasoning(list)).toBe(list);
81
+ expect(projectMessagesPayloadWithoutReasoning(plain)).toBe(plain);
82
+ });
83
+ });
84
+
85
+ describe('stateful stream filter', () => {
86
+ it('strips version suffixes for dispatch but keeps original type on kept events', () => {
87
+ const filter = createReasoningOutboundFilter();
88
+
89
+ expect(filter.projectEvent({
90
+ type: 'session.next.reasoning.delta.1',
91
+ properties: { delta: 'think' },
92
+ })).toBeNull();
93
+ expect(filter.projectEvent({
94
+ type: 'session.next.reasoning.delta',
95
+ data: { delta: 'think' },
96
+ })).toBeNull();
97
+
98
+ // properties envelope + versioned type
99
+ expect(filter.projectEvent({
100
+ type: 'message.part.updated.1',
101
+ properties: {
102
+ part: { id: 'p_r', messageID: 'm1', type: 'reasoning', text: 'chain' },
103
+ },
104
+ })).toBeNull();
105
+
106
+ // data envelope + versioned type — learn text, keep original type string
107
+ const textUpdated = {
108
+ type: 'message.part.updated.1',
109
+ data: {
110
+ part: { id: 'p_t', messageID: 'm1', type: 'text', text: 'hi' },
111
+ },
112
+ };
113
+ expect(filter.projectEvent(textUpdated)).toBe(textUpdated);
114
+ expect(textUpdated.type).toBe('message.part.updated.1');
115
+
116
+ const textDelta = {
117
+ type: 'message.part.delta.1',
118
+ data: { messageID: 'm1', partID: 'p_t', field: 'text', delta: '!' },
119
+ };
120
+ expect(filter.projectEvent(textDelta)).toBe(textDelta);
121
+
122
+ // reasoning learned via properties; delta via data still drops
123
+ filter.projectEvent({
124
+ type: 'message.part.updated',
125
+ properties: { part: { id: 'p_r2', messageID: 'm1', type: 'reasoning', text: '' } },
126
+ });
127
+ expect(filter.projectEvent({
128
+ type: 'message.part.delta.1',
129
+ data: { messageID: 'm1', partID: 'p_r2', field: 'text', delta: 'secret' },
130
+ })).toBeNull();
131
+ });
132
+
133
+ it('drops explicit reasoning fields and unknown part deltas', () => {
134
+ const filter = createReasoningOutboundFilter();
135
+ expect(filter.projectEvent({
136
+ type: 'message.part.delta',
137
+ properties: { messageID: 'm1', partID: 'u', field: 'reasoning_content', delta: 'x' },
138
+ })).toBeNull();
139
+ expect(filter.projectEvent({
140
+ type: 'message.part.delta',
141
+ properties: { messageID: 'm1', partID: 'never', field: 'text', delta: 'leak?' },
142
+ })).toBeNull();
143
+ });
144
+
145
+ it('forgets classification and bounds the map', () => {
146
+ const filter = createReasoningOutboundFilter();
147
+ filter.projectEvent({
148
+ type: 'message.part.updated',
149
+ properties: { part: { id: 'p1', messageID: 'm1', type: 'text' } },
150
+ });
151
+ expect(filter.knownPartCount()).toBe(1);
152
+ filter.projectEvent({
153
+ type: 'message.part.removed',
154
+ properties: { messageID: 'm1', partID: 'p1' },
155
+ });
156
+ expect(filter.knownPartCount()).toBe(0);
157
+
158
+ for (let i = 0; i < 8200; i += 1) {
159
+ filter.projectEvent({
160
+ type: 'message.part.updated',
161
+ properties: { part: { id: `p_${i}`, messageID: 'm_big', type: 'text' } },
162
+ });
163
+ }
164
+ expect(filter.knownPartCount()).toBeLessThanOrEqual(8192);
165
+ filter.dispose();
166
+ expect(filter.knownPartCount()).toBe(0);
167
+ });
168
+
169
+ it('does not mutate original objects when stripping embedded parts', () => {
170
+ const filter = createReasoningOutboundFilter();
171
+ const original = {
172
+ type: 'message.updated',
173
+ properties: {
174
+ info: { id: 'm1', tokens: { reasoning: 7 } },
175
+ parts: [
176
+ { id: 'r', type: 'reasoning', text: 'hidden' },
177
+ { id: 't', type: 'text', text: 'visible' },
178
+ ],
179
+ },
180
+ };
181
+ const projected = filter.projectEvent(original);
182
+ expect(projected).not.toBe(original);
183
+ expect(original.properties.parts).toHaveLength(2);
184
+ expect(projected.properties.parts).toEqual([{ id: 't', type: 'text', text: 'visible' }]);
185
+ });
186
+ });
187
+
188
+ describe('SSE filter + CR-safe splitter', () => {
189
+ it('preserves wire identity for kept events and drops reasoning', () => {
190
+ const filter = createReasoningOutboundFilter();
191
+ const keptBlock = 'id: evt-1\ndata: {"type":"message.part.updated","properties":{"part":{"id":"p","messageID":"m","type":"text","text":"hi"}}}';
192
+ const droppedBlock = 'id: evt-2\ndata: {"type":"session.next.reasoning.delta","properties":{"delta":"no"}}';
193
+ const wrappedDrop = 'id: evt-3\ndata: {"directory":"/repo","payload":{"type":"session.next.reasoning.delta.1","properties":{"delta":"x"}}}';
194
+ const wrappedKeep = 'id: evt-4\ndata: {"directory":"/repo","payload":{"type":"server.connected","properties":{}}}';
195
+
196
+ expect(filterSseBlock(keptBlock, filter)).toBe(`${keptBlock}\n\n`);
197
+ expect(filterSseBlock(droppedBlock, filter)).toBeNull();
198
+ expect(filterSseBlock(wrappedDrop, filter)).toBeNull();
199
+ expect(filterSseBlock(wrappedKeep, filter)).toContain('"directory":"/repo"');
200
+ });
201
+
202
+ it('does not split one event when CRLF straddles chunk boundaries', () => {
203
+ const splitter = createSseBlockSplitter();
204
+ // Multi-line data event with CRLF, split after lone CR of first line ending.
205
+ const full = 'id: 1\r\ndata: {"type":"text","n":1}\r\ndata: more\r\n\r\n';
206
+ const cut = full.indexOf('\r') + 1; // after first CR of first line's CRLF
207
+ expect(full[cut - 1]).toBe('\r');
208
+ expect(full[cut]).toBe('\n');
209
+
210
+ const first = splitter.push(full.slice(0, cut));
211
+ expect(first).toEqual([]); // must not complete on lone CR→LF
212
+ const second = splitter.push(full.slice(cut));
213
+ expect(second).toHaveLength(1);
214
+ expect(second[0]).toContain('id: 1');
215
+ expect(second[0]).toContain('data: more');
216
+ expect(second[0]).not.toMatch(/^\s*$/);
217
+ });
218
+
219
+ it('handles per-character chunking and UTF-8 multi-byte without leaking reasoning', () => {
220
+ const filter = createReasoningOutboundFilter();
221
+ const splitter = createSseBlockSplitter();
222
+ const stream =
223
+ 'id: a\ndata: {"type":"message.part.updated","properties":{"part":{"id":"t","messageID":"m","type":"text","text":"你好"}}}\n\n'
224
+ + 'id: b\ndata: {"type":"session.next.reasoning.delta","properties":{"delta":"secret"}}\n\n'
225
+ + 'id: c\ndata: {"type":"message.part.delta","properties":{"messageID":"m","partID":"t","field":"text","delta":"!"}}\n\n';
226
+
227
+ const bytes = new TextEncoder().encode(stream);
228
+ const out = [];
229
+ for (let i = 0; i < bytes.length; i += 1) {
230
+ for (const block of splitter.push(bytes.subarray(i, i + 1))) {
231
+ const filtered = filterSseBlock(block, filter);
232
+ if (filtered) out.push(filtered);
233
+ }
234
+ }
235
+ for (const block of splitter.finish()) {
236
+ const filtered = filterSseBlock(block, filter);
237
+ if (filtered) out.push(filtered);
238
+ }
239
+
240
+ expect(out).toHaveLength(2);
241
+ expect(out.join('')).toContain('"type":"message.part.updated"');
242
+ expect(out.join('')).toContain('"type":"message.part.delta"');
243
+ expect(out.join('')).toContain('你好');
244
+ expect(out.join('')).not.toContain('reasoning');
245
+ expect(out.join('')).not.toContain('secret');
246
+ });
247
+
248
+ it('keeps event order across mixed CRLF chunk splits', () => {
249
+ const splitter = createSseBlockSplitter();
250
+ const chunks = [
251
+ 'id: 1\r\ndata: {"n":1}\r',
252
+ '\n\r\nid: 2\r\ndata: {"n":2}\r\n\r\n',
253
+ ];
254
+ const blocks = [];
255
+ for (const chunk of chunks) blocks.push(...splitter.push(chunk));
256
+ blocks.push(...splitter.finish());
257
+ expect(blocks.map((b) => JSON.parse(b.split('\n').find((l) => l.startsWith('data:')).slice(5)).n))
258
+ .toEqual([1, 2]);
259
+ });
260
+ });
261
+
262
+ describe('performance: 10k reasoning deltas', () => {
263
+ it('drops 10k reasoning deltas while still emitting known text deltas', () => {
264
+ const disabled = createReasoningOutboundFilter();
265
+ disabled.projectEvent({
266
+ type: 'message.part.updated',
267
+ properties: { part: { id: 'p_text', messageID: 'm1', type: 'text', text: '' } },
268
+ });
269
+ disabled.projectEvent({
270
+ type: 'message.part.updated',
271
+ properties: { part: { id: 'p_reason', messageID: 'm1', type: 'reasoning', text: '' } },
272
+ });
273
+
274
+ let disabledEvents = 0;
275
+ let disabledBodyBytes = 0;
276
+ for (let i = 0; i < 10_000; i += 1) {
277
+ const out = disabled.projectEvent({
278
+ type: 'message.part.delta',
279
+ properties: {
280
+ messageID: 'm1',
281
+ partID: 'p_reason',
282
+ field: 'text',
283
+ delta: `reasoning-token-${i}-xxxxxxxx`,
284
+ },
285
+ });
286
+ if (out != null) {
287
+ disabledEvents += 1;
288
+ disabledBodyBytes += JSON.stringify(out).length;
289
+ }
290
+ }
291
+
292
+ const textDelta = {
293
+ type: 'message.part.delta',
294
+ properties: { messageID: 'm1', partID: 'p_text', field: 'text', delta: 'hello-world' },
295
+ };
296
+ expect(disabled.projectEvent(textDelta)).toBe(textDelta);
297
+ expect(disabledEvents).toBe(0);
298
+ expect(disabledBodyBytes).toBe(0);
299
+ });
300
+ });
@@ -10,6 +10,10 @@ import {
10
10
  import { createGlobalMessageStreamHub } from './global-hub.js';
11
11
  import { createGlobalMessageStreamWsBridge } from './global-ws-bridge.js';
12
12
  import { acceptDirectoryMessageStreamWsConnection } from './directory-ws-bridge.js';
13
+ import {
14
+ createReasoningOutboundFilter,
15
+ shouldIncludeReasoning,
16
+ } from './reasoning-projection.js';
13
17
  import {
14
18
  DEFAULT_UPSTREAM_RECONNECT_DELAY_MS,
15
19
  DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
@@ -95,10 +99,15 @@ export function createMessageStreamWsRuntime({
95
99
  const isGlobalStream = pathname === MESSAGE_STREAM_GLOBAL_WS_PATH;
96
100
  const requestedLastEventId = requestUrl.searchParams.get('lastEventId')?.trim() || '';
97
101
  const requestedDirectory = requestUrl.searchParams.get('directory')?.trim() || '';
102
+ // OpenChamber projection only — never forwarded to OpenCode upstream URLs.
103
+ // Default (include) path keeps reasoningFilter null for zero send-path overhead.
104
+ const includeReasoning = shouldIncludeReasoning(requestUrl.searchParams.get('includeReasoning'));
105
+ const reasoningFilter = includeReasoning ? null : createReasoningOutboundFilter();
98
106
 
99
107
  if (isGlobalStream) {
100
108
  globalBridge.accept(socket, {
101
109
  requestedLastEventId,
110
+ reasoningFilter,
102
111
  });
103
112
  return;
104
113
  }
@@ -107,6 +116,7 @@ export function createMessageStreamWsRuntime({
107
116
  socket,
108
117
  requestedLastEventId,
109
118
  requestedDirectory,
119
+ reasoningFilter,
110
120
  buildOpenCodeUrl,
111
121
  getOpenCodeAuthHeaders,
112
122
  processForwardedEventPayload,
@@ -127,6 +127,59 @@ describe('event stream broadcaster', () => {
127
127
  });
128
128
 
129
129
  describe('message stream websocket runtime', () => {
130
+ it('filters reasoning events per connection when includeReasoning=false', async () => {
131
+ const server = new EventEmitter();
132
+ const wsClients = new Set();
133
+
134
+ const runtime = createMessageStreamWsRuntime({
135
+ server,
136
+ uiAuthController: null,
137
+ isRequestOriginAllowed: async () => true,
138
+ rejectWebSocketUpgrade() {
139
+ throw new Error('upgrade should not be used in this test');
140
+ },
141
+ buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
142
+ getOpenCodeAuthHeaders: () => ({}),
143
+ processForwardedEventPayload() {},
144
+ wsClients,
145
+ upstreamReconnectDelayMs: 0,
146
+ fetchImpl: async (_url, options) => createSseResponse({
147
+ signal: options.signal,
148
+ holdOpen: true,
149
+ blocks: [
150
+ 'id: evt-1\ndata: {"type":"message.part.updated","properties":{"part":{"id":"p_r","messageID":"m1","type":"reasoning","text":"secret"}}}\n\n',
151
+ 'id: evt-2\ndata: {"type":"message.part.updated","properties":{"part":{"id":"p_t","messageID":"m1","type":"text","text":"hi"}}}\n\n',
152
+ 'id: evt-3\ndata: {"type":"session.next.reasoning.delta","properties":{"delta":"nope"}}\n\n',
153
+ 'id: evt-4\ndata: {"type":"message.part.delta","properties":{"messageID":"m1","partID":"p_t","field":"text","delta":"!"}}\n\n',
154
+ ],
155
+ }),
156
+ });
157
+
158
+ const filtered = new FakeSocket();
159
+ const full = new FakeSocket();
160
+ runtime.wsServer.emit('connection', filtered, { url: '/api/global/event/ws?includeReasoning=false' });
161
+ runtime.wsServer.emit('connection', full, { url: '/api/global/event/ws' });
162
+
163
+ await new Promise((resolve) => setTimeout(resolve, 20));
164
+
165
+ const filteredEvents = filtered.sent.filter((frame) => frame.type === 'event');
166
+ const fullEvents = full.sent.filter((frame) => frame.type === 'event');
167
+
168
+ expect(filteredEvents.some((frame) => frame.payload?.type === 'session.next.reasoning.delta')).toBe(false);
169
+ expect(filteredEvents.some((frame) => frame.payload?.properties?.part?.type === 'reasoning')).toBe(false);
170
+ expect(JSON.stringify(filteredEvents)).not.toContain('secret');
171
+ expect(filteredEvents.some((frame) => frame.payload?.type === 'message.part.updated'
172
+ && frame.payload?.properties?.part?.type === 'text')).toBe(true);
173
+ expect(filteredEvents.some((frame) => frame.payload?.type === 'message.part.delta')).toBe(true);
174
+
175
+ expect(fullEvents.some((frame) => frame.payload?.properties?.part?.type === 'reasoning')).toBe(true);
176
+ expect(fullEvents.some((frame) => frame.payload?.type === 'session.next.reasoning.delta')).toBe(true);
177
+
178
+ filtered.close();
179
+ full.close();
180
+ await runtime.close();
181
+ });
182
+
130
183
  it('shares one global upstream SSE reader across multiple websocket clients', async () => {
131
184
  const server = new EventEmitter();
132
185
  const wsClients = new Set();
@@ -377,7 +377,8 @@ When adding or changing Host HTTP APIs that mobile/desktop clients reach over Pr
377
377
  - `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware.
378
378
  - `resolveSessionTurnAdmissionRequest(req)`: extracts the directory/session scope for client POST turn endpoints so queue automatic admission can be invalidated without delaying the proxied request.
379
379
  - Owns:
380
- - SSE forwarders: `GET /api/global/event`, `GET /api/event`
380
+ - SSE forwarders: `GET /api/global/event`, `GET /api/event` (optional `includeReasoning=false` parse-filters reasoning events on the Host send path; param never forwarded upstream; enabled path remains byte passthrough)
381
+ - Official session.messages list: `GET /api/session/:sessionID/message` (optional `includeReasoning=false` strips reasoning parts; exact message GET is owned by session-turn-pages)
381
382
  - Session message forwarder: `POST /api/session/:sessionId/message`
382
383
  - Generic `/api/*` forwarding with hop-by-hop header filtering
383
384
  - Windows `/session` merge fallback path behavior
@@ -11,6 +11,15 @@ import {
11
11
  extractDirectoryFromRequest,
12
12
  isDirectoryTurnAdmissionPath,
13
13
  } from './instance-recovery-runtime.js';
14
+ import {
15
+ createReasoningOutboundFilter,
16
+ createSseBlockSplitter,
17
+ filterSseBlock,
18
+ projectMessagesPayloadForReasoning,
19
+ readIncludeReasoningFromUrl,
20
+ readIncludeReasoningQuery,
21
+ stripIncludeReasoningParam,
22
+ } from '../event-stream/reasoning-projection.js';
14
23
 
15
24
  export const createDirectoryQueryCanonicalizer = ({ realpath, ...cacheOptions } = {}) => {
16
25
  const realpathCache = createRealpathCache({ fallbackOnError: true, realpath, ...cacheOptions });
@@ -379,6 +388,14 @@ export const registerOpenCodeProxy = (app, deps) => {
379
388
  let connectTimedOut = false;
380
389
  let writeQueue = Promise.resolve(true);
381
390
  const sseBoundary = createSseBoundaryTracker();
391
+ const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
392
+ ? req.originalUrl
393
+ : (typeof req.url === 'string' ? req.url : '');
394
+ // OpenChamber projection control — never forwarded to OpenCode.
395
+ const includeReasoning = readIncludeReasoningQuery(req.query)
396
+ && readIncludeReasoningFromUrl(requestUrl);
397
+ const reasoningFilter = includeReasoning ? null : createReasoningOutboundFilter();
398
+ const sseSplitter = includeReasoning ? null : createSseBlockSplitter();
382
399
 
383
400
  req.on('close', closeUpstream);
384
401
 
@@ -400,10 +417,9 @@ export const registerOpenCodeProxy = (app, deps) => {
400
417
  };
401
418
 
402
419
  try {
403
- const requestUrl = typeof req.originalUrl === 'string' && req.originalUrl.length > 0
404
- ? req.originalUrl
405
- : (typeof req.url === 'string' ? req.url : '');
406
- const upstreamPath = requestUrl.startsWith('/api') ? requestUrl.slice(4) || '/' : requestUrl;
420
+ // Strip includeReasoning before building the upstream OpenCode path.
421
+ const strippedUrl = stripIncludeReasoningParam(requestUrl);
422
+ const upstreamPath = strippedUrl.startsWith('/api') ? strippedUrl.slice(4) || '/' : strippedUrl;
407
423
  const headers = normalizeForwardedDirectoryHeaders(
408
424
  collectForwardProxyHeaders(req.headers, getOpenCodeAuthHeaders())
409
425
  );
@@ -495,10 +511,40 @@ export const registerOpenCodeProxy = (app, deps) => {
495
511
  }
496
512
  if (value && value.length > 0) {
497
513
  resetStallTimer();
498
- sseBoundary.observe(value);
499
- const canContinue = await enqueueSseWrite(value);
500
- if (!canContinue) {
501
- break;
514
+ if (includeReasoning) {
515
+ // Enabled path: byte-identical passthrough (existing behavior).
516
+ sseBoundary.observe(value);
517
+ const canContinue = await enqueueSseWrite(value);
518
+ if (!canContinue) {
519
+ break;
520
+ }
521
+ } else {
522
+ // Disabled path: parse SSE blocks, drop reasoning, re-serialize.
523
+ // Preserves id/event lines and GlobalEvent wrap when unchanged drop-only.
524
+ const blocks = sseSplitter.push(value);
525
+ let canContinue = true;
526
+ for (const block of blocks) {
527
+ const filtered = filterSseBlock(block, reasoningFilter);
528
+ if (!filtered) {
529
+ continue;
530
+ }
531
+ sseBoundary.observe(filtered);
532
+ canContinue = await enqueueSseWrite(filtered);
533
+ if (!canContinue) break;
534
+ }
535
+ if (!canContinue) {
536
+ break;
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ if (!includeReasoning && sseSplitter) {
543
+ for (const block of sseSplitter.finish()) {
544
+ const filtered = filterSseBlock(block, reasoningFilter);
545
+ if (filtered) {
546
+ sseBoundary.observe(filtered);
547
+ await enqueueSseWrite(filtered);
502
548
  }
503
549
  }
504
550
  }
@@ -524,6 +570,7 @@ export const registerOpenCodeProxy = (app, deps) => {
524
570
  } finally {
525
571
  clearConnectTimer();
526
572
  clearStallTimer();
573
+ reasoningFilter?.dispose?.();
527
574
  if (heartbeatTimer) {
528
575
  clearTimeout(heartbeatTimer);
529
576
  heartbeatTimer = null;
@@ -584,7 +631,7 @@ export const registerOpenCodeProxy = (app, deps) => {
584
631
 
585
632
  const forwardSanitizedSessionListRequest = async (req, res, next, logLabel) => {
586
633
  try {
587
- const upstreamPath = await getRequestUpstreamPath(req);
634
+ const upstreamPath = stripIncludeReasoningParam(await getRequestUpstreamPath(req));
588
635
  const result = await fetchSessionListPayload(upstreamPath, { req });
589
636
 
590
637
  res.status(result.upstream.status);
@@ -617,6 +664,47 @@ export const registerOpenCodeProxy = (app, deps) => {
617
664
  }
618
665
  };
619
666
 
667
+ /**
668
+ * Official session.messages list (`GET /api/session/:id/message`).
669
+ * When includeReasoning=false, strip reasoning parts from the JSON body.
670
+ * includeReasoning is never forwarded upstream.
671
+ */
672
+ const forwardSessionMessagesListRequest = async (req, res, next) => {
673
+ try {
674
+ const includeReasoning = readIncludeReasoningQuery(req.query);
675
+ const upstreamPath = stripIncludeReasoningParam(await getRequestUpstreamPath(req));
676
+ const result = await fetchSessionListPayload(upstreamPath, { req });
677
+
678
+ res.status(result.upstream.status);
679
+ applyForwardProxyResponseHeaders(result.upstream.headers, res);
680
+
681
+ if (!result.isJson) {
682
+ res.setHeader('content-type', result.contentType);
683
+ res.end(result.bodyText);
684
+ return;
685
+ }
686
+
687
+ if (result.parseError || result.payload == null) {
688
+ res.setHeader('content-type', result.contentType);
689
+ res.end(result.bodyText);
690
+ return;
691
+ }
692
+
693
+ res.setHeader('content-type', result.contentType);
694
+ res.json(projectMessagesPayloadForReasoning(result.payload, includeReasoning));
695
+ } catch (error) {
696
+ if (isAbortError(error)) {
697
+ return;
698
+ }
699
+ console.error('[proxy] OpenCode session.messages proxy error:', error?.message ?? error);
700
+ if (!res.headersSent) {
701
+ res.status(503).json({ error: 'OpenCode service unavailable' });
702
+ return;
703
+ }
704
+ next(error);
705
+ }
706
+ };
707
+
620
708
  // Ensure API prefix is detected before proxying
621
709
  app.use('/api', (_req, _res, next) => {
622
710
  ensureOpenCodeApiPrefix();
@@ -776,6 +864,12 @@ export const registerOpenCodeProxy = (app, deps) => {
776
864
  return forwardSanitizedSessionListRequest(req, res, next, 'session.list');
777
865
  });
778
866
 
867
+ // Official session.messages list (not exact message — that is owned by
868
+ // session-turn-pages and registered before this proxy).
869
+ app.get('/api/session/:sessionID/message', (req, res, next) => {
870
+ return forwardSessionMessagesListRequest(req, res, next);
871
+ });
872
+
779
873
  app.get('/api/global/event', forwardSseRequest);
780
874
  app.get('/api/event', forwardSseRequest);
781
875