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