@openchambery/web 1.19.7-beta.8 → 1.19.7

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 (201) hide show
  1. package/dist/assets/AgentAvatar-CX0a1rKE.js +1 -0
  2. package/dist/assets/AssistantView-nVF0kdFh.js +2 -0
  3. package/dist/assets/DiagramView-B2UtQ8mw.js +1 -0
  4. package/dist/assets/FadeInOnReveal-CK4asqVR.js +1 -0
  5. package/dist/assets/Icon-BYM02QyU.js +1 -0
  6. package/dist/assets/MarkdownLoadingSkeleton-DWs4Jg7b.js +3 -0
  7. package/dist/assets/MarkdownRenderer-DT_GN1Ex.js +2 -0
  8. package/dist/assets/MarkdownRendererImpl-Cslh4XN_.js +7 -0
  9. package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
  10. package/dist/assets/MarkstreamRendererImpl-Ymm12x6J.js +1 -0
  11. package/dist/assets/MobileDetailNavigation-DD29ENgU.js +1 -0
  12. package/dist/assets/MobileShareBridge-CrTe_QND.js +4 -0
  13. package/dist/assets/MobileSurface-DjxchpUC.js +2 -0
  14. package/dist/assets/{MultiRunWindow-D_jo8cqz.js → MultiRunWindow-jG5dCEMH.js} +1 -1
  15. package/dist/assets/OnboardingScreen-C3-wzeSX.js +1 -0
  16. package/dist/assets/OverlayScrollbar-KUpvYHdv.js +2 -0
  17. package/dist/assets/SettingsView-UKlEewdq.js +25 -0
  18. package/dist/assets/{SettingsWindow-CzMoz2fi.js → SettingsWindow-DO1aAplT.js} +1 -1
  19. package/dist/assets/TerminalView-BG1UPLsz.js +8 -0
  20. package/dist/assets/ToolOutputDialog-CWr5eVTK.js +10 -0
  21. package/dist/assets/appThemeRegistry-BI5drVpC.js +1 -0
  22. package/dist/assets/{button-z0GOBtWi.js → button-C0cth9tl.js} +1 -1
  23. package/dist/assets/clipboard-DJ4nQEuY.js +1 -0
  24. package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-DFT9cE80.js} +1 -1
  25. package/dist/assets/context-menu-CAp74NfV.js +1 -0
  26. package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
  27. package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +1 -0
  28. package/dist/assets/{diagram-CoEqmReY.js → diagram-BryzMuta.js} +1 -1
  29. package/dist/assets/{dialog-x2hirWBd.js → dialog-CgbV5ljg.js} +1 -1
  30. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  31. package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
  32. package/dist/assets/fileQueries-BY-9OWzp.js +1 -0
  33. package/dist/assets/fileReferenceActions-BJJtX_S5.js +1 -0
  34. package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
  35. package/dist/assets/{gitApi-BE0vA4zm.js → gitApi-D9hFlE3T.js} +3 -3
  36. package/dist/assets/{input-BG3VWTIF.js → input-DFLpqjGR.js} +1 -1
  37. package/dist/assets/insertionBoundaries-D2L7D0Oe.js +4 -0
  38. package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
  39. package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
  40. package/dist/assets/main-BRedOs_V.js +169 -0
  41. package/dist/assets/main-DlXpJ0Vw.js +2 -0
  42. package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
  43. package/dist/assets/markdownTheme-CzZm3riB.js +2 -0
  44. package/dist/assets/miniChat-CBLbFpfy.js +2 -0
  45. package/dist/assets/mobile-CoZsWdWk.js +2 -0
  46. package/dist/assets/multirun-BF-I0r2_.js +4 -0
  47. package/dist/assets/outsideFileGrants-DnplRxr-.js +1 -0
  48. package/dist/assets/path-utils-jGozTg9d.js +1 -0
  49. package/dist/assets/persistence-DdUac14I.js +3 -0
  50. package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
  51. package/dist/assets/platform-B4KKLnZ5.js +2 -0
  52. package/dist/assets/projectMeta-2t4L5_4a.js +3 -0
  53. package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-fl7O3iHy.js +2 -0
  55. package/dist/assets/renderMobileApp-CKYDwf4U.js +28 -0
  56. package/dist/assets/runtimeConfig-B4eb4ZiU.js +2 -0
  57. package/dist/assets/runtimeEndpointReset-BHuTYyzc.js +147 -0
  58. package/dist/assets/sessionChangeSummary-DOq8EN_H.js +1 -0
  59. package/dist/assets/sessionLookup-DNc0ZeR3.js +1 -0
  60. package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +1 -0
  61. package/dist/assets/src-DasvfkEg.css +2 -0
  62. package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
  63. package/dist/assets/terminalApi-947l7Ra3.js +1 -0
  64. package/dist/assets/textarea-Cgo_nWUU.js +1 -0
  65. package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
  66. package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BifLJ8o0.js} +1 -1
  67. package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
  68. package/dist/assets/url-Pk6tQ1EZ.js +1 -0
  69. package/dist/assets/{ChatContainer-D3onaUkL.js → useAppFontEffects-Dle8xs5h.js} +44 -39
  70. package/dist/assets/useAssistantUIStore-me6s63b2.js +1 -0
  71. package/dist/assets/useDesktopWindowControlsLayout-_QAwKcQn.js +1 -0
  72. package/dist/assets/useEffectiveDirectory-BSyuAgz6.js +1 -0
  73. package/dist/assets/useFontPreferences-BCOBlaAd.js +1 -0
  74. package/dist/assets/useMobileNavigationStore-f8xHJk7C.js +413 -0
  75. package/dist/assets/useSessionAutoCleanup-BHiXaW0J.js +1 -0
  76. package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
  77. package/dist/assets/useWorkerHighlightedLines-DuvXTSm_.js +7 -0
  78. package/dist/assets/utils-BiK3tBGM.js +1 -0
  79. package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
  80. package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
  81. package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
  82. package/dist/index.html +9 -11
  83. package/dist/mini-chat.html +9 -11
  84. package/dist/mobile.html +15 -17
  85. package/package.json +2 -1
  86. package/server/index.js +2 -0
  87. package/server/lib/assistant-delivery-parts.js +3 -1
  88. package/server/lib/assistant-delivery-parts.test.js +11 -0
  89. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  90. package/server/lib/assistants/assign.js +225 -0
  91. package/server/lib/assistants/assign.test.js +118 -0
  92. package/server/lib/assistants/bubbles.js +45 -0
  93. package/server/lib/assistants/bubbles.test.js +36 -0
  94. package/server/lib/assistants/cards.js +148 -0
  95. package/server/lib/assistants/cards.test.js +116 -0
  96. package/server/lib/assistants/contact-store.js +284 -0
  97. package/server/lib/assistants/contact-store.test.js +131 -0
  98. package/server/lib/assistants/contact-tools.js +761 -0
  99. package/server/lib/assistants/contact-tools.test.js +328 -0
  100. package/server/lib/assistants/contracts.js +20 -1
  101. package/server/lib/assistants/harness.js +551 -0
  102. package/server/lib/assistants/harness.test.js +607 -0
  103. package/server/lib/assistants/routes.js +44 -4
  104. package/server/lib/assistants/service.js +930 -17
  105. package/server/lib/assistants/service.test.js +1163 -45
  106. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  107. package/server/lib/event-stream/directory-ws-bridge.js +11 -2
  108. package/server/lib/event-stream/global-ws-bridge.js +19 -3
  109. package/server/lib/event-stream/protocol.js +11 -1
  110. package/server/lib/event-stream/reasoning-projection.js +425 -0
  111. package/server/lib/event-stream/reasoning-projection.test.js +300 -0
  112. package/server/lib/event-stream/runtime.js +10 -0
  113. package/server/lib/event-stream/runtime.test.js +53 -0
  114. package/server/lib/llm/DOCUMENTATION.md +84 -0
  115. package/server/lib/llm/catalog.js +100 -0
  116. package/server/lib/llm/catalog.test.js +78 -0
  117. package/server/lib/llm/completions.js +151 -0
  118. package/server/lib/llm/completions.test.js +127 -0
  119. package/server/lib/llm/generate.js +570 -0
  120. package/server/lib/llm/generate.test.js +459 -0
  121. package/server/lib/llm/routes.js +47 -0
  122. package/server/lib/llm/temp-directory.js +37 -0
  123. package/server/lib/notifications/APNS.md +19 -13
  124. package/server/lib/notifications/DOCUMENTATION.md +4 -3
  125. package/server/lib/notifications/runtime.js +153 -38
  126. package/server/lib/notifications/runtime.test.js +122 -0
  127. package/server/lib/opencode/DOCUMENTATION.md +2 -1
  128. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  129. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  130. package/server/lib/opencode/proxy.js +103 -9
  131. package/server/lib/opencode/proxy.test.js +177 -1
  132. package/server/lib/opencode/settings-runtime.js +2 -2
  133. package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
  134. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  135. package/server/lib/session-goal/runtime.js +20 -0
  136. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  137. package/server/lib/session-index/service.js +10 -4
  138. package/server/lib/session-index/service.test.js +37 -0
  139. package/server/lib/session-title/runtime.js +1 -0
  140. package/server/lib/session-title/runtime.test.js +11 -0
  141. package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
  142. package/server/lib/session-turn-pages/routes.js +22 -3
  143. package/server/lib/session-turn-pages/routes.test.js +43 -0
  144. package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
  145. package/server/lib/transcript-cache/routes.js +9 -2
  146. package/server/lib/transcript-cache/routes.test.js +32 -0
  147. package/dist/assets/AgentAvatar-Di8F4QJh.js +0 -1
  148. package/dist/assets/AssistantView-DYdXsGDh.js +0 -1
  149. package/dist/assets/ContextPanel-Cj6RVUAe.js +0 -285
  150. package/dist/assets/DiagramView-BoRTG67Z.js +0 -1
  151. package/dist/assets/FadeInOnReveal-CV2s6YN8.js +0 -1
  152. package/dist/assets/Icon-CLpn1mIz.js +0 -1
  153. package/dist/assets/MarkdownRendererImpl-DwUKY9k4.js +0 -7
  154. package/dist/assets/MarkstreamRendererImpl-BRvprELo.js +0 -1
  155. package/dist/assets/MarkstreamRendererImpl-Ca7RATbs.css +0 -1
  156. package/dist/assets/MobileDetailNavigation-C0Z8beRL.js +0 -1
  157. package/dist/assets/MobileShareBridge-DTvmm9vz.js +0 -4
  158. package/dist/assets/MobileSurface-CfrI-5uf.js +0 -2
  159. package/dist/assets/OnboardingScreen-D6rtE7rj.js +0 -1
  160. package/dist/assets/OverlayScrollbar-DlNZho3q.js +0 -2
  161. package/dist/assets/SettingsView-BOT158gD.js +0 -25
  162. package/dist/assets/TerminalView-sf_LfP6Z.js +0 -8
  163. package/dist/assets/ToolOutputDialog-BI5vwuaT.js +0 -10
  164. package/dist/assets/appThemeRegistry-G_zwgqAH.js +0 -2
  165. package/dist/assets/clipboard-D35XSwID.js +0 -1
  166. package/dist/assets/desktop-CfLlmafK.js +0 -1
  167. package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
  168. package/dist/assets/desktopRelayRestore-8pvI_dSc.js +0 -1
  169. package/dist/assets/fileQueries-DGHZqZHc.js +0 -1
  170. package/dist/assets/insertionBoundaries-CJksyugt.js +0 -4
  171. package/dist/assets/main-BTeHOAU_.js +0 -29
  172. package/dist/assets/main-BUBx-y1E.js +0 -2
  173. package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
  174. package/dist/assets/markdownTheme-SPe4jOwX.js +0 -2
  175. package/dist/assets/miniChat-Btnf42TH.js +0 -2
  176. package/dist/assets/mobile-CAjwV6ly.js +0 -2
  177. package/dist/assets/multirun-Rpz1_-fz.js +0 -4
  178. package/dist/assets/persistence-w-AiX2i4.js +0 -3
  179. package/dist/assets/platform-4eCmsdol.js +0 -2
  180. package/dist/assets/projectMeta-CvUuy-j5.js +0 -3
  181. package/dist/assets/radio-D_a28OlM.js +0 -1
  182. package/dist/assets/renderElectronMiniChatApp-DCzBQk2w.js +0 -2
  183. package/dist/assets/renderMobileApp-U5sclQRp.js +0 -28
  184. package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
  185. package/dist/assets/runtimeConfig-6inIl99b.js +0 -2
  186. package/dist/assets/runtimeEndpointReset-B_rUFDT5.js +0 -4
  187. package/dist/assets/shikiHighlight-CMpSAuTl.js +0 -1
  188. package/dist/assets/sortable-tabs-strip-CNDgyX0B.js +0 -1
  189. package/dist/assets/src-DZt1nWf5.css +0 -2
  190. package/dist/assets/terminalApi-CGtOtc8I.js +0 -1
  191. package/dist/assets/url-KeihbE0d.js +0 -1
  192. package/dist/assets/useAppFontEffects-3xHRVJcf.js +0 -7
  193. package/dist/assets/useAssistantUIStore-I0VWrB5V.js +0 -1
  194. package/dist/assets/useDesktopWindowControlsLayout-QJlddPRn.js +0 -1
  195. package/dist/assets/useEffectiveDirectory-Bcj1wP95.js +0 -1
  196. package/dist/assets/useFontPreferences-BLZ3y6mN.js +0 -1
  197. package/dist/assets/useMobileNavigationStore-CD0GRjaV.js +0 -412
  198. package/dist/assets/useSessionAutoCleanup-CN_6ABvx.js +0 -1
  199. package/dist/assets/useUIStore-CNLmCoPU.js +0 -1
  200. package/dist/assets/useWorkerHighlightedLines-DX9znDoW.js +0 -7
  201. package/dist/assets/utils-CTL16t2f.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();
@@ -0,0 +1,84 @@
1
+ # OpenChamber LLM Gateway
2
+
3
+ OpenAI-shaped Chat Completions for the in-app Assistant contact. This is the
4
+ only model path the Assistant harness (`pi-agent-core` `streamFn`) talks to.
5
+
6
+ ## Contract
7
+
8
+ - `GET /api/openchamber/llm/models` — connected `{providerID, modelID}` catalog
9
+ from OpenCode `GET /provider` (`connected`) plus `GET /config/providers`.
10
+ Plugin adapter formats stay inside OpenCode; this route does not parse them.
11
+ - `POST /api/openchamber/llm/chat/completions` — `{ model, messages, stream?,
12
+ providerID?, modelID? }` → OpenAI `chat.completion` JSON.
13
+ This **public** gateway is **non-streaming**. Bundled OpenCode 1.18.4 generate
14
+ (`POST /generate` when present, otherwise throwaway `session.promptAsync`
15
+ plus idle wait) returns a full assistant turn. `stream: true` is rejected with
16
+ `validation_error` (HTTP 400). Do not emit fake SSE after the fact on this
17
+ route (never typewrite a completed string into tokens over HTTP).
18
+ The contact UI may stream later via a **server-internal** path only (below).
19
+
20
+ `model` may be `providerID/modelID` or a bare `modelID` paired with `providerID`.
21
+
22
+ ## Internals (verified on bundled `@opencode-ai/sdk` 1.18.4)
23
+
24
+ The 1.18.4 client exposes `GET /provider`, `GET /config/providers`, and
25
+ `session.prompt` / `session.promptAsync`. It does **not** expose
26
+ `POST /api/generate` or any other sessionless generate method.
27
+
28
+ 1. Probe `POST /generate` (and SDK `generate` if present). Use it only when
29
+ the response is JSON (`Content-Type` or a JSON object body). SPA / OpenCode
30
+ HTML `200 <!doctype` is not generate — fall through to the throwaway path.
31
+ Bundled 1.18.4 has no sessionless generate.
32
+ 2. Otherwise create a throwaway archived OpenCode session, deny every tool
33
+ (`client.tool.ids()` → `{ [id]: false }`), send our messages as
34
+ `system` + user text via `session.promptAsync` (v2 `session.prompt` only
35
+ forwards `{ id, prompt, delivery, resume }` and drops `model`/`parts` —
36
+ that produced empty assistant text and a 502). Contact file parts reuse the
37
+ existing OpenCode `{ type: 'file', mime, url, filename? }` delivery shape
38
+ (data URLs in the contact SQLite store — not a second attachment store) and
39
+ are forwarded on `promptAsync` only when the connected catalog marks that
40
+ model as image-capable (`modalities.input`, `input`, or `attachment`).
41
+ Non-vision models (for example deepseek-v4-flash) keep the `[image: …]`
42
+ description and any text-file bytes, and skip image data URLs so generate
43
+ cannot stall on unsupported vision parts. Non-image
44
+ text files are also inlined into the flattened prompt. Wait for idle via
45
+ `session.status` + `session.messages`, then delete the session. This is a
46
+ text generator only — never the contact transcript and never a coding
47
+ SessionPrompt loop. Upstream `info.error.message` is forwarded on 502.
48
+
49
+ ### Internal token callback (in-process only)
50
+
51
+ `generateOpenCodeText` / `createChatCompletion` accept optional
52
+ `onTextDelta(text: string)` and `globalEventHub` (the shared
53
+ `globalMessageStreamHub` / `subscribeEvent` surface).
54
+
55
+ - **Throwaway session path:** after archive, before `promptAsync`, subscribe to
56
+ the hub. Forward real OpenCode `message.part.delta` events whose
57
+ `properties.sessionID` matches this throwaway session, `field` is `text`
58
+ (or omitted), and `messageID` matches the first assistant message locked for
59
+ that generate. Still wait for idle and return the **full** text (strip/parse
60
+ needs the complete string). Unsubscribe in `finally` so failed generates
61
+ cannot leak listeners.
62
+ - **Sessionless `/generate` JSON path:** cannot emit live deltas — skip
63
+ `onTextDelta` honestly (no fake typewriter of the completed string).
64
+ - **HTTP `POST .../chat/completions`:** does not pass these options and still
65
+ rejects `stream: true`. Token deltas never leave the server as SSE on that
66
+ route.
67
+
68
+ Bundled OpenCode 1.18.4 emits `message.part.delta` on the global event stream
69
+ for normal session prompts (same events the UI transcript already consumes).
70
+ Throwaway LLM sessions use the same `session.promptAsync` path, so they are
71
+ expected to emit the same delta events when the model streams tokens.
72
+
73
+ Credentials stay in OpenCode. This module does not read `auth.json`, does not
74
+ call Anthropic/OpenAI/plugin SDKs, and does not use the `openai` npm package.
75
+
76
+ No connected provider → `no_provider` (HTTP 400). Upstream failure is never
77
+ an empty success. The 502 body includes `{ error, message }`.
78
+
79
+ ## Ownership
80
+
81
+ The Assistant contact harness owns system prompt, OpenChamber transcript,
82
+ bubble splitting, and OpenChamber API tools (`assign_session`). Those tools
83
+ deliver through contact **cards**, not this completions payload. The gateway
84
+ stays a text generator: OpenCode coding tools stay denied.
@@ -0,0 +1,100 @@
1
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
2
+
3
+ const stringID = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
4
+
5
+ const listIncludesImage = (value) => Array.isArray(value)
6
+ && value.some((item) => String(item).toLowerCase() === 'image');
7
+
8
+ function modelAcceptsImages(model) {
9
+ if (!isRecord(model)) return false;
10
+ if (isRecord(model.modalities) && listIncludesImage(model.modalities.input)) return true;
11
+ if (listIncludesImage(model.input)) return true;
12
+ return model.attachment === true;
13
+ }
14
+
15
+ /**
16
+ * Merge OpenCode `GET /provider` (connected ids) with `GET /config/providers`
17
+ * (model catalog). Do not interpret plugin-specific provider configs.
18
+ *
19
+ * @param {{
20
+ * connected?: unknown,
21
+ * providers?: unknown,
22
+ * }} source
23
+ */
24
+ export function projectConnectedModels(source) {
25
+ const connected = new Set(
26
+ Array.isArray(source?.connected)
27
+ ? source.connected.map(stringID).filter(Boolean)
28
+ : [],
29
+ );
30
+ const models = [];
31
+ const providers = [];
32
+ const list = Array.isArray(source?.providers) ? source.providers : [];
33
+ for (const provider of list) {
34
+ if (!isRecord(provider)) continue;
35
+ const providerID = stringID(provider.id);
36
+ if (!providerID || !connected.has(providerID)) continue;
37
+ const providerModels = [];
38
+ const rawModels = isRecord(provider.models) ? Object.values(provider.models) : [];
39
+ for (const model of rawModels) {
40
+ if (!isRecord(model)) continue;
41
+ const modelID = stringID(model.id);
42
+ if (!modelID) continue;
43
+ const entry = { providerID, modelID, name: stringID(model.name) || modelID, acceptsImages: modelAcceptsImages(model) };
44
+ models.push(entry);
45
+ providerModels.push({ id: modelID, name: entry.name });
46
+ }
47
+ providers.push({
48
+ id: providerID,
49
+ name: stringID(provider.name) || providerID,
50
+ models: providerModels,
51
+ });
52
+ }
53
+ return { connected: [...connected], providers, models };
54
+ }
55
+
56
+ export function parseModelRef(model, providerID, modelID) {
57
+ const explicitProvider = stringID(providerID);
58
+ const explicitModel = stringID(modelID);
59
+ if (explicitProvider && explicitModel) {
60
+ return { providerID: explicitProvider, modelID: explicitModel };
61
+ }
62
+ const raw = stringID(model);
63
+ if (!raw) return null;
64
+ const slash = raw.indexOf('/');
65
+ if (slash > 0) {
66
+ return { providerID: raw.slice(0, slash), modelID: raw.slice(slash + 1) };
67
+ }
68
+ if (explicitProvider && raw) {
69
+ return { providerID: explicitProvider, modelID: raw };
70
+ }
71
+ return null;
72
+ }
73
+
74
+ export function isConnectedModel(catalog, providerID, modelID) {
75
+ if (!catalog || !stringID(providerID) || !stringID(modelID)) return false;
76
+ return catalog.models.some((entry) => entry.providerID === providerID && entry.modelID === modelID);
77
+ }
78
+
79
+ /**
80
+ * Load the connected catalog from an OpenCode SDK client.
81
+ * Failure is distinct from a successful empty catalog.
82
+ */
83
+ export async function loadConnectedCatalog(client) {
84
+ const listed = await client.provider.list({});
85
+ if (listed?.error || !isRecord(listed?.data)) {
86
+ const error = new Error('OpenCode provider list is unavailable');
87
+ error.code = 'upstream_error';
88
+ throw error;
89
+ }
90
+ const configured = await client.config.providers({});
91
+ if (configured?.error || !isRecord(configured?.data)) {
92
+ const error = new Error('OpenCode provider catalog is unavailable');
93
+ error.code = 'upstream_error';
94
+ throw error;
95
+ }
96
+ return projectConnectedModels({
97
+ connected: listed.data.connected,
98
+ providers: configured.data.providers,
99
+ });
100
+ }
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { loadConnectedCatalog, parseModelRef, projectConnectedModels } from './catalog.js'
3
+
4
+ describe('parseModelRef', () => {
5
+ it('splits provider/model refs', () => {
6
+ expect(parseModelRef('openai/gpt-5.2')).toEqual({
7
+ providerID: 'openai',
8
+ modelID: 'gpt-5.2',
9
+ })
10
+ })
11
+
12
+ it('prefers explicit providerID/modelID', () => {
13
+ expect(parseModelRef('ignored', 'anthropic', 'claude-sonnet-4')).toEqual({
14
+ providerID: 'anthropic',
15
+ modelID: 'claude-sonnet-4',
16
+ })
17
+ })
18
+
19
+ it('returns null for invalid refs', () => {
20
+ expect(parseModelRef('')).toBeNull()
21
+ expect(parseModelRef('openai')).toBeNull()
22
+ })
23
+ })
24
+
25
+ describe('projectConnectedModels', () => {
26
+ it('projects only connected providers', () => {
27
+ const catalog = projectConnectedModels({
28
+ connected: ['openai'],
29
+ providers: [
30
+ {
31
+ id: 'openai',
32
+ name: 'OpenAI',
33
+ models: {
34
+ 'gpt-5.2': { id: 'gpt-5.2', name: 'GPT-5.2' },
35
+ },
36
+ },
37
+ {
38
+ id: 'anthropic',
39
+ name: 'Anthropic',
40
+ models: { 'claude-sonnet-4': { id: 'claude-sonnet-4', name: 'Claude' } },
41
+ },
42
+ ],
43
+ })
44
+ expect(catalog.models).toEqual([
45
+ { providerID: 'openai', modelID: 'gpt-5.2', name: 'GPT-5.2', acceptsImages: false },
46
+ ])
47
+ })
48
+
49
+ it('marks vision models from modalities or attachment', () => {
50
+ const catalog = projectConnectedModels({
51
+ connected: ['openai'],
52
+ providers: [{
53
+ id: 'openai',
54
+ name: 'OpenAI',
55
+ models: {
56
+ 'gpt-4o': { id: 'gpt-4o', name: 'GPT-4o', modalities: { input: ['text', 'image'] } },
57
+ 'deepseek-v4-flash': { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
58
+ },
59
+ }],
60
+ })
61
+ expect(catalog.models.find((model) => model.modelID === 'gpt-4o')?.acceptsImages).toBe(true)
62
+ expect(catalog.models.find((model) => model.modelID === 'deepseek-v4-flash')?.acceptsImages).toBe(false)
63
+ expect(catalog.connected).toEqual(['openai'])
64
+ })
65
+ })
66
+
67
+ describe('loadConnectedCatalog', () => {
68
+ afterEach(() => {
69
+ vi.restoreAllMocks()
70
+ })
71
+
72
+ it('requires both /provider and /config/providers', async () => {
73
+ await expect(loadConnectedCatalog({
74
+ provider: { list: async () => ({ data: { connected: ['openai'] } }) },
75
+ config: { providers: async () => ({ error: { status: 500 } }) },
76
+ })).rejects.toThrow(/provider catalog/)
77
+ })
78
+ })