@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.
- package/dist/assets/{AgentAvatar-DI2zs2WH.js → AgentAvatar-CX0a1rKE.js} +1 -1
- package/dist/assets/AssistantView-BYHD7Fcy.js +2 -0
- package/dist/assets/{DiagramView-DZhC-gPw.js → DiagramView-BUucK4Mn.js} +1 -1
- package/dist/assets/{FadeInOnReveal-BWJcUp5P.js → FadeInOnReveal-CK4asqVR.js} +1 -1
- package/dist/assets/{Icon-4My8YZwt.js → Icon-BYM02QyU.js} +1 -1
- package/dist/assets/{MarkdownLoadingSkeleton-Vezjvk9B.js → MarkdownLoadingSkeleton-DWs4Jg7b.js} +1 -1
- package/dist/assets/{MarkdownRenderer-Dd7nPtW4.js → MarkdownRenderer-uiTtRexW.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-CuGusb9c.js → MarkdownRendererImpl-DszSA6px.js} +1 -1
- package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
- package/dist/assets/MarkstreamRendererImpl-DkjZ7QYA.js +1 -0
- package/dist/assets/{MobileDetailNavigation-DXLN8AfE.js → MobileDetailNavigation-BElZdvTL.js} +1 -1
- package/dist/assets/MobileShareBridge-DiXOck30.js +4 -0
- package/dist/assets/{MobileSurface-DZys0gQW.js → MobileSurface-BCIZyDOc.js} +1 -1
- package/dist/assets/{MultiRunWindow-DNSCvp1n.js → MultiRunWindow-XXWgGd6d.js} +1 -1
- package/dist/assets/{OnboardingScreen-BNEtPmvw.js → OnboardingScreen-C3-wzeSX.js} +1 -1
- package/dist/assets/{OverlayScrollbar-BZB21Yp_.js → OverlayScrollbar-KUpvYHdv.js} +1 -1
- package/dist/assets/{SettingsView-CW0sP1G5.js → SettingsView-CdYIJh0f.js} +5 -5
- package/dist/assets/{SettingsWindow-BbY8rAaP.js → SettingsWindow-CLr0dXVg.js} +1 -1
- package/dist/assets/{TerminalView-Blou_qZo.js → TerminalView-BdtasXsi.js} +1 -1
- package/dist/assets/{ToolOutputDialog-D69bhD7_.js → ToolOutputDialog-D22RYqMI.js} +1 -1
- package/dist/assets/{appThemeRegistry-DTf7Ymvj.js → appThemeRegistry-D1wTvHoc.js} +1 -1
- package/dist/assets/{button-Or0daT9o.js → button-C0cth9tl.js} +1 -1
- package/dist/assets/{clipboard-BXxLshVq.js → clipboard-DJ4nQEuY.js} +1 -1
- package/dist/assets/{collapsible-C-kzSGkq.js → collapsible-DFT9cE80.js} +1 -1
- package/dist/assets/{context-menu-Dpjla5p4.js → context-menu-CAp74NfV.js} +1 -1
- package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
- package/dist/assets/{desktopRelayRestore-BaKjk1kQ.js → desktopRelayRestore-9EbBYQ9s.js} +1 -1
- package/dist/assets/{diagram-D0mT3mo1.js → diagram-BryzMuta.js} +1 -1
- package/dist/assets/{dialog-BkhHBTd4.js → dialog-CgbV5ljg.js} +1 -1
- package/dist/assets/{fileQueries-DFY6Kiy1.js → fileQueries-BY-9OWzp.js} +1 -1
- package/dist/assets/{fileReferenceActions-Di_DoDex.js → fileReferenceActions-BJJtX_S5.js} +1 -1
- package/dist/assets/{gitApi-s3lJ_NUC.js → gitApi-RhwRTt2X.js} +1 -1
- package/dist/assets/{input-BUsGnzwe.js → input-DFLpqjGR.js} +1 -1
- package/dist/assets/{insertionBoundaries-BXIh3qwN.js → insertionBoundaries-D-UUqego.js} +1 -1
- package/dist/assets/{main-CbWB2MxW.js → main-BRB8Kly8.js} +2 -2
- package/dist/assets/{main-6h4AIMd7.js → main-BaSeqhTm.js} +11 -11
- package/dist/assets/{markdownTheme-CVS01wHf.js → markdownTheme-CzZm3riB.js} +1 -1
- package/dist/assets/{miniChat-DuYRiHjj.js → miniChat-CDZ48aEf.js} +2 -2
- package/dist/assets/{mobile-DKja4fFV.js → mobile-B2Hpp_5U.js} +2 -2
- package/dist/assets/{multirun-CWbdKSsT.js → multirun-CfvHH_3s.js} +1 -1
- package/dist/assets/{outsideFileGrants-BB4GPvCq.js → outsideFileGrants-DnplRxr-.js} +1 -1
- package/dist/assets/persistence-DdUac14I.js +3 -0
- package/dist/assets/platform-B4KKLnZ5.js +2 -0
- package/dist/assets/{projectMeta-BjeUc8MG.js → projectMeta-vCCip4-G.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-Cv1F39fu.js → renderElectronMiniChatApp-D9RarEOS.js} +2 -2
- package/dist/assets/{renderMobileApp-C4zDY0CW.js → renderMobileApp-DOiQLCcY.js} +7 -7
- package/dist/assets/{runtimeConfig-D0G_wzhg.js → runtimeConfig-CkWKMmAR.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-Cx37ZLh4.js → runtimeEndpointReset-aW8qogl0.js} +1 -1
- package/dist/assets/{sessionChangeSummary-CX2MJIkc.js → sessionChangeSummary-DOq8EN_H.js} +1 -1
- package/dist/assets/{sessionLookup-B56D_zE1.js → sessionLookup-BNO4Ta46.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-C5gGnikj.js → sortable-tabs-strip-C8_9kHqu.js} +1 -1
- package/dist/assets/src-DasvfkEg.css +2 -0
- package/dist/assets/{terminalApi-CIIs9vFx.js → terminalApi-947l7Ra3.js} +1 -1
- package/dist/assets/{textarea-CIO34TZz.js → textarea-Cgo_nWUU.js} +1 -1
- package/dist/assets/{tooltip-BSrJYYYc.js → tooltip-BifLJ8o0.js} +1 -1
- package/dist/assets/{url-CE_gAfNa.js → url-Pk6tQ1EZ.js} +1 -1
- package/dist/assets/{useAppFontEffects-i1QzgXKU.js → useAppFontEffects-BqBeuVr8.js} +6 -6
- package/dist/assets/{useAssistantUIStore-BHacrxe_.js → useAssistantUIStore-me6s63b2.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-BqT0VDLV.js → useDesktopWindowControlsLayout-CnX7JJa1.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-CUSBKiSi.js → useEffectiveDirectory-F2fNcQ22.js} +1 -1
- package/dist/assets/{useFontPreferences-BxOHKuVw.js → useFontPreferences-BCOBlaAd.js} +1 -1
- package/dist/assets/useMobileNavigationStore-CBfSvE2P.js +413 -0
- package/dist/assets/{useSessionAutoCleanup-mitMfzXN.js → useSessionAutoCleanup-BVJKWCtK.js} +1 -1
- package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-BZHDVxzL.js → useWorkerHighlightedLines-WYqpkPyP.js} +1 -1
- package/dist/assets/{utils-B0mQXN9L.js → utils-BiK3tBGM.js} +1 -1
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/lib/assistants/contact-tools.test.js +6 -6
- package/server/lib/assistants/harness.test.js +3 -3
- package/server/lib/assistants/service.test.js +1 -1
- package/server/lib/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/directory-ws-bridge.js +11 -2
- package/server/lib/event-stream/global-ws-bridge.js +19 -3
- package/server/lib/event-stream/protocol.js +11 -1
- package/server/lib/event-stream/reasoning-projection.js +425 -0
- package/server/lib/event-stream/reasoning-projection.test.js +300 -0
- package/server/lib/event-stream/runtime.js +10 -0
- package/server/lib/event-stream/runtime.test.js +53 -0
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- package/server/lib/opencode/proxy.js +103 -9
- package/server/lib/opencode/proxy.test.js +177 -1
- package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
- package/server/lib/session-turn-pages/routes.js +22 -3
- package/server/lib/session-turn-pages/routes.test.js +43 -0
- package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
- package/server/lib/transcript-cache/routes.js +9 -2
- package/server/lib/transcript-cache/routes.test.js +32 -0
- package/dist/assets/AssistantView-DDIVh1W4.js +0 -2
- package/dist/assets/MarkstreamRendererImpl-Ca7RATbs.css +0 -1
- package/dist/assets/MarkstreamRendererImpl-DANegq9r.js +0 -1
- package/dist/assets/MobileShareBridge-BVHXNDqj.js +0 -4
- package/dist/assets/desktopHosts-BzSE0sK7.js +0 -1
- package/dist/assets/persistence-CSZp1CNB.js +0 -3
- package/dist/assets/platform-CenlWqxu.js +0 -2
- package/dist/assets/src-Cs2WiOMc.css +0 -2
- package/dist/assets/useMobileNavigationStore-C-EBQ7Zt.js +0 -412
- 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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
|