@openchambery/web 1.18.4-beta.2 → 1.18.4-beta.21
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/bin/cli.test.js +25 -0
- package/bin/lib/DOCUMENTATION.md +2 -1
- package/bin/lib/cli-args.js +19 -0
- package/bin/lib/commands-serve.js +73 -4
- package/dist/assets/{AgentAvatar-BvhXxFvg.js → AgentAvatar-DDcjamcJ.js} +1 -1
- package/dist/assets/AssistantView-CBUHztv3.js +1 -0
- package/dist/assets/{ChatContainer-Ba2Qqd2R.js → ChatContainer-DTMkRFkU.js} +42 -42
- package/dist/assets/{DiagramView--CkYLtF9.js → DiagramView-H_hx57OK.js} +1 -1
- package/dist/assets/{FadeInOnReveal-98buIVdv.js → FadeInOnReveal-By2F9vcI.js} +1 -1
- package/dist/assets/{Icon-BhSwbLaF.js → Icon-DpjYnXxn.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-CuaQbwUx.js → MarkdownRendererImpl-DuSIWzp7.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-ld2mS8hq.js → MobileDetailNavigation-B30JPnMG.js} +1 -1
- package/dist/assets/MobileShareBridge-BNGFVq24.js +4 -0
- package/dist/assets/MobileSurface-ib_NED0_.js +2 -0
- package/dist/assets/{MultiRunWindow-kryfNvF6.js → MultiRunWindow-DwFiIcie.js} +1 -1
- package/dist/assets/{OnboardingScreen-C1n_wVb-.js → OnboardingScreen-BKmuT7AL.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CXFN6Cqq.js → OverlayScrollbar-Ch3J6ASR.js} +1 -1
- package/dist/assets/{SettingsView-BBTUVI-a.js → SettingsView-DHcIrBaL.js} +11 -11
- package/dist/assets/{SettingsWindow-4RS_41Wb.js → SettingsWindow-15swlv3g.js} +1 -1
- package/dist/assets/{TerminalView-B1he4xRe.js → TerminalView-DLl70YcD.js} +1 -1
- package/dist/assets/{ToolOutputDialog-ak07C60J.js → ToolOutputDialog-8g4OEYEr.js} +1 -1
- package/dist/assets/appThemeRegistry-DaDEaQDy.js +2 -0
- package/dist/assets/{button-BQY5ugjh.js → button-C7OXr4ae.js} +1 -1
- package/dist/assets/{clipboard-BvJ7NunE.js → clipboard-DWuULPzo.js} +1 -1
- package/dist/assets/{collapsible-CqcZGw3W.js → collapsible-DaEya6b6.js} +1 -1
- package/dist/assets/{context-menu-DSUJY2i3.js → context-menu-FwmpZgyk.js} +1 -1
- package/dist/assets/desktopHosts-Txp4xOVQ.js +1 -0
- package/dist/assets/desktopRelayRestore-C_FSEHKM.js +1 -0
- package/dist/assets/{diagram-B8D9Klct.js → diagram-CHfginjS.js} +1 -1
- package/dist/assets/{dialog-sGFkIKP2.js → dialog-DPS0W9eS.js} +1 -1
- package/dist/assets/{es-BuinIayO.js → es-BX0Vm3Ac.js} +4 -4
- package/dist/assets/fileQueries-BrUKKgPW.js +1 -0
- package/dist/assets/{fr-CT7us58h.js → fr-CuPzXTYx.js} +4 -4
- package/dist/assets/{gitApi-DtV2V_Fp.js → gitApi-Cr_QlQXs.js} +2 -2
- package/dist/assets/{input-CgIoxUJc.js → input-BzYA603C.js} +1 -1
- package/dist/assets/insertionBoundaries-D-zzR5OY.js +1 -0
- package/dist/assets/{ja-CCuAH4nO.js → ja-DGtzjrFO.js} +4 -4
- package/dist/assets/{ko-lXbhGt6P.js → ko-DZ9Di0je.js} +4 -4
- package/dist/assets/main-CkiZazqn.js +172 -0
- package/dist/assets/{main-CoNVZvj1.js → main-CttsY04u.js} +2 -2
- package/dist/assets/{markdown-worker-C5Om4TVK.js → markdown-worker-W06-MhF5.js} +1 -1
- package/dist/assets/{markdownTheme-BdF4C4-o.js → markdownTheme-B89dbj3N.js} +1 -1
- package/dist/assets/{miniChat-CYd1PMdJ.js → miniChat-CbXvcgmp.js} +2 -2
- package/dist/assets/mobile-CXNz8Y5x.js +2 -0
- package/dist/assets/multirun-DPXFxNKu.js +4 -0
- package/dist/assets/persistence-6sclr7B6.js +3 -0
- package/dist/assets/pl-BuCO3qb1.js +15 -0
- package/dist/assets/platform-CbeLpKPk.js +2 -0
- package/dist/assets/projectMeta-BeKggJtg.js +3 -0
- package/dist/assets/{pt-BR-DWbGs1bc.js → pt-BR-Brf4RMsw.js} +4 -4
- package/dist/assets/radio-CbhIwTx1.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-BsNtI4rY.js +2 -0
- package/dist/assets/renderMobileApp-Ba7AqNsq.js +23 -0
- package/dist/assets/runtimeConfig-MkHvmR2Q.js +2 -0
- package/dist/assets/runtimeEndpointReset-CXv8MCtK.js +147 -0
- package/dist/assets/{sortable-tabs-strip-pSnPuBXA.js → sortable-tabs-strip-nLsPC06f.js} +1 -1
- package/dist/assets/src-Yqt5hwRm.css +2 -0
- package/dist/assets/{store-BuXogmxp.js → store-DQY8Fkb0.js} +4 -4
- package/dist/assets/{terminalApi-jfes-HIU.js → terminalApi-BpPeXWIu.js} +1 -1
- package/dist/assets/{tooltip-rN8LrxMF.js → tooltip-D_orJMYd.js} +1 -1
- package/dist/assets/{uk-DgOxtvS7.js → uk-C-wmaPTi.js} +4 -4
- package/dist/assets/{url-B0Ak7X6b.js → url-BsaBG5-1.js} +1 -1
- package/dist/assets/useAppFontEffects--0Fj9fci.js +7 -0
- package/dist/assets/{useAssistantUIStore-SPh025NE.js → useAssistantUIStore-BHsHXCKE.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-BSHPIcFE.js → useDesktopWindowControlsLayout-Dbt0n68f.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-Cbsu48gT.js → useEffectiveDirectory-CA5I2S-L.js} +1 -1
- package/dist/assets/{useFontPreferences-Bz3crRRO.js → useFontPreferences-ckzpXQaJ.js} +1 -1
- package/dist/assets/useMobileNavigationStore-u6UQremF.js +475 -0
- package/dist/assets/useSessionAutoCleanup-HxU0AYLr.js +1 -0
- package/dist/assets/useUIStore-DzwCfRqr.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-D7ZRwkm3.js → useWorkerHighlightedLines-BlvimLRz.js} +1 -1
- package/dist/assets/{utils-DfPDhqys.js → utils-B1EK5zKa.js} +1 -1
- package/dist/assets/{zh-CN-BPGatJp9.js → zh-CN-DueRrIql.js} +4 -4
- package/dist/assets/{zh-TW-qUWWsaxK.js → zh-TW-6875X80V.js} +4 -4
- 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/index.d.ts +0 -4
- package/server/index.js +11 -20
- package/server/lib/client-auth/pairing.js +1 -7
- package/server/lib/client-auth/pairing.test.js +0 -45
- package/server/lib/config-sync/config-sync.test.js +52 -0
- package/server/lib/config-sync/scripts.js +10 -2
- package/server/lib/notifications/DOCUMENTATION.md +2 -1
- package/server/lib/notifications/emitter-runtime.js +78 -1
- package/server/lib/notifications/emitter-runtime.test.js +94 -1
- package/server/lib/opencode/DOCUMENTATION.md +4 -4
- package/server/lib/opencode/bootstrap-runtime.js +0 -6
- package/server/lib/opencode/cli-entry-runtime.js +5 -1
- package/server/lib/opencode/cli-entry-runtime.test.js +17 -0
- package/server/lib/opencode/core-routes.js +0 -3
- package/server/lib/opencode/core-routes.test.js +0 -15
- package/server/lib/opencode/openchamber-routes.js +0 -11
- package/server/lib/relay/DOCUMENTATION.md +3 -45
- package/server/lib/relay/host-client.js +1 -4
- package/server/lib/relay/service.js +28 -16
- package/server/lib/relay/service.test.js +64 -4
- package/server/lib/relay/tunnel-host.js +2 -52
- package/server/lib/relay/tunnel-host.test.js +2 -250
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +4 -1
- package/server/lib/scheduled-tasks/routes.js +51 -8
- package/server/lib/session-assist/DOCUMENTATION.md +4 -2
- package/server/lib/session-assist/runtime.js +67 -30
- package/server/lib/session-assist/runtime.test.js +114 -0
- package/server/lib/session-title/DOCUMENTATION.md +15 -10
- package/server/lib/session-title/runtime.js +24 -0
- package/server/lib/session-title/runtime.test.js +143 -0
- package/server/lib/ui-auth/ui-auth.js +3 -1
- package/server/lib/ui-auth/ui-auth.test.js +25 -0
- package/server/sse-routes.test.js +64 -1
- package/dist/assets/AssistantView-DMK-KCPX.js +0 -1
- package/dist/assets/MobileShareBridge-lTfofNXr.js +0 -4
- package/dist/assets/MobileSurface-Bc_pAGrN.js +0 -2
- package/dist/assets/appThemeRegistry-ChhJvOPo.js +0 -2
- package/dist/assets/desktopHosts-aA40MHzB.js +0 -1
- package/dist/assets/desktopRelayRestore-CxUYRC6v.js +0 -1
- package/dist/assets/fileQueries-BSE4ycQt.js +0 -1
- package/dist/assets/insertionBoundaries-BIS62xbD.js +0 -1
- package/dist/assets/main-ClQdYXuu.js +0 -172
- package/dist/assets/mobile-CJkbELE1.js +0 -2
- package/dist/assets/multirun-DfYluvqf.js +0 -4
- package/dist/assets/persistence-DwXJK4-j.js +0 -3
- package/dist/assets/pl-DCsKBRAg.js +0 -15
- package/dist/assets/platform-CXJwZIZe.js +0 -2
- package/dist/assets/projectMeta-jG2rzIks.js +0 -3
- package/dist/assets/radio-CIR_xKSG.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-D6cOGBOZ.js +0 -2
- package/dist/assets/renderMobileApp-BuiAqCNI.js +0 -23
- package/dist/assets/runtimeConfig-B9P24SI-.js +0 -2
- package/dist/assets/runtimeEndpointReset-BLmUiRJq.js +0 -147
- package/dist/assets/src-DF4hiLrB.css +0 -2
- package/dist/assets/useAppFontEffects-DCQnaU6M.js +0 -7
- package/dist/assets/useMobileNavigationStore-BYiwDqQe.js +0 -476
- package/dist/assets/useSessionAutoCleanup-DDdudIWc.js +0 -1
- package/dist/assets/useUIStore-BJpkPwky.js +0 -1
- package/server/lib/desktop-hosts/routes.js +0 -157
- package/server/lib/desktop-hosts/routes.test.js +0 -227
|
@@ -7,18 +7,17 @@
|
|
|
7
7
|
// corrupted payload. This test must stay red until send serialization is fixed.
|
|
8
8
|
// Production send path is intentionally not changed here.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { describe, expect, it } from 'bun:test';
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
MAX_TUNNEL_PAYLOAD_BYTES,
|
|
14
14
|
TunnelFrameType,
|
|
15
15
|
createFragmentAssembler,
|
|
16
|
-
decodeJsonPayload,
|
|
17
16
|
decodeTunnelFrame,
|
|
18
17
|
encodeJsonPayload,
|
|
19
18
|
encodeTunnelFrame,
|
|
20
19
|
} from './tunnel-codec.js';
|
|
21
|
-
import { createTunnelHost
|
|
20
|
+
import { createTunnelHost } from './tunnel-host.js';
|
|
22
21
|
|
|
23
22
|
const textEncoder = new TextEncoder();
|
|
24
23
|
const textDecoder = new TextDecoder();
|
|
@@ -224,250 +223,3 @@ describe('createTunnelHost WS fragment ordering', () => {
|
|
|
224
223
|
}
|
|
225
224
|
});
|
|
226
225
|
});
|
|
227
|
-
|
|
228
|
-
const collectHttpExchange = async (outbound, streamId) => {
|
|
229
|
-
await waitFor(() => outbound.some((frame) => {
|
|
230
|
-
const decoded = decodeTunnelFrame(frame);
|
|
231
|
-
return decoded.streamId === streamId
|
|
232
|
-
&& (decoded.frameType === TunnelFrameType.StreamEnd
|
|
233
|
-
|| decoded.frameType === TunnelFrameType.StreamAbort);
|
|
234
|
-
}));
|
|
235
|
-
let status = null;
|
|
236
|
-
/** @type {Uint8Array[]} */
|
|
237
|
-
const bodyChunks = [];
|
|
238
|
-
for (const raw of outbound) {
|
|
239
|
-
const frame = decodeTunnelFrame(raw);
|
|
240
|
-
if (frame.streamId !== streamId) continue;
|
|
241
|
-
if (frame.frameType === TunnelFrameType.HttpResponse) {
|
|
242
|
-
status = decodeJsonPayload(frame.payload, (v) => v && typeof v.status === 'number').status;
|
|
243
|
-
} else if (frame.frameType === TunnelFrameType.HttpBody) {
|
|
244
|
-
bodyChunks.push(frame.payload);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
let body = null;
|
|
248
|
-
if (bodyChunks.length > 0) {
|
|
249
|
-
const total = bodyChunks.reduce((n, c) => n + c.length, 0);
|
|
250
|
-
const merged = new Uint8Array(total);
|
|
251
|
-
let off = 0;
|
|
252
|
-
for (const c of bodyChunks) {
|
|
253
|
-
merged.set(c, off);
|
|
254
|
-
off += c.length;
|
|
255
|
-
}
|
|
256
|
-
body = JSON.parse(textDecoder.decode(merged));
|
|
257
|
-
}
|
|
258
|
-
return { status, body };
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
describe('createTunnelHost SSH target-port routing', () => {
|
|
262
|
-
/** @type {typeof globalThis.fetch | undefined} */
|
|
263
|
-
let originalFetch;
|
|
264
|
-
|
|
265
|
-
afterEach(() => {
|
|
266
|
-
if (originalFetch) {
|
|
267
|
-
globalThis.fetch = originalFetch;
|
|
268
|
-
originalFetch = undefined;
|
|
269
|
-
}
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
const installFetchProbe = () => {
|
|
273
|
-
/** @type {{ url: string, headers: Record<string, string> }[]} */
|
|
274
|
-
const calls = [];
|
|
275
|
-
originalFetch = globalThis.fetch;
|
|
276
|
-
globalThis.fetch = async (url, init) => {
|
|
277
|
-
calls.push({
|
|
278
|
-
url: String(url),
|
|
279
|
-
headers: { ...(init?.headers || {}) },
|
|
280
|
-
});
|
|
281
|
-
return new Response(JSON.stringify({ ok: true }), {
|
|
282
|
-
status: 200,
|
|
283
|
-
headers: { 'content-type': 'application/json' },
|
|
284
|
-
});
|
|
285
|
-
};
|
|
286
|
-
return calls;
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
it('dials the routing-table port when x-openchamber-target-port is allowed', async () => {
|
|
290
|
-
const fetchCalls = installFetchProbe();
|
|
291
|
-
/** @type {Uint8Array[]} */
|
|
292
|
-
const outbound = [];
|
|
293
|
-
const host = createTunnelHost({
|
|
294
|
-
connectionId: 'test-conn',
|
|
295
|
-
getLocalPort: () => 3000,
|
|
296
|
-
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
297
|
-
sendFrame: (frame) => { outbound.push(frame); },
|
|
298
|
-
getBufferedAmount: () => 0,
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
const streamId = 11;
|
|
302
|
-
await host.handleFrame(encodeTunnelFrame(
|
|
303
|
-
TunnelFrameType.HttpRequest,
|
|
304
|
-
streamId,
|
|
305
|
-
encodeJsonPayload({
|
|
306
|
-
method: 'GET',
|
|
307
|
-
path: '/health',
|
|
308
|
-
query: '',
|
|
309
|
-
headers: { 'x-openchamber-target-port': '41234', accept: 'application/json' },
|
|
310
|
-
}),
|
|
311
|
-
));
|
|
312
|
-
|
|
313
|
-
const exchange = await collectHttpExchange(outbound, streamId);
|
|
314
|
-
expect(exchange.status).toBe(200);
|
|
315
|
-
expect(fetchCalls).toHaveLength(1);
|
|
316
|
-
expect(fetchCalls[0].url).toBe('http://127.0.0.1:41234/health');
|
|
317
|
-
host.close();
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it('returns 503 and does not fall back when target-port is unknown', async () => {
|
|
321
|
-
const fetchCalls = installFetchProbe();
|
|
322
|
-
/** @type {Uint8Array[]} */
|
|
323
|
-
const outbound = [];
|
|
324
|
-
const host = createTunnelHost({
|
|
325
|
-
connectionId: 'test-conn',
|
|
326
|
-
getLocalPort: () => 3000,
|
|
327
|
-
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
328
|
-
sendFrame: (frame) => { outbound.push(frame); },
|
|
329
|
-
getBufferedAmount: () => 0,
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
const streamId = 12;
|
|
333
|
-
await host.handleFrame(encodeTunnelFrame(
|
|
334
|
-
TunnelFrameType.HttpRequest,
|
|
335
|
-
streamId,
|
|
336
|
-
encodeJsonPayload({
|
|
337
|
-
method: 'GET',
|
|
338
|
-
path: '/health',
|
|
339
|
-
query: '',
|
|
340
|
-
headers: { 'x-openchamber-target-port': '9999' },
|
|
341
|
-
}),
|
|
342
|
-
));
|
|
343
|
-
|
|
344
|
-
const exchange = await collectHttpExchange(outbound, streamId);
|
|
345
|
-
expect(exchange.status).toBe(503);
|
|
346
|
-
expect(exchange.body?.error).toBe('ssh-host-unreachable');
|
|
347
|
-
expect(fetchCalls).toHaveLength(0);
|
|
348
|
-
host.close();
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it('strips x-openchamber-target-port before forwarding to loopback', async () => {
|
|
352
|
-
const fetchCalls = installFetchProbe();
|
|
353
|
-
/** @type {Uint8Array[]} */
|
|
354
|
-
const outbound = [];
|
|
355
|
-
const host = createTunnelHost({
|
|
356
|
-
connectionId: 'test-conn',
|
|
357
|
-
getLocalPort: () => 3000,
|
|
358
|
-
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
359
|
-
sendFrame: (frame) => { outbound.push(frame); },
|
|
360
|
-
getBufferedAmount: () => 0,
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
const streamId = 13;
|
|
364
|
-
await host.handleFrame(encodeTunnelFrame(
|
|
365
|
-
TunnelFrameType.HttpRequest,
|
|
366
|
-
streamId,
|
|
367
|
-
encodeJsonPayload({
|
|
368
|
-
method: 'GET',
|
|
369
|
-
path: '/api/version',
|
|
370
|
-
query: '',
|
|
371
|
-
headers: {
|
|
372
|
-
'x-openchamber-target-port': '41234',
|
|
373
|
-
authorization: 'Bearer client-token',
|
|
374
|
-
},
|
|
375
|
-
}),
|
|
376
|
-
));
|
|
377
|
-
|
|
378
|
-
await collectHttpExchange(outbound, streamId);
|
|
379
|
-
expect(fetchCalls).toHaveLength(1);
|
|
380
|
-
expect(fetchCalls[0].headers['x-openchamber-target-port']).toBeUndefined();
|
|
381
|
-
expect(fetchCalls[0].headers.authorization).toBe('Bearer client-token');
|
|
382
|
-
expect(fetchCalls[0].headers['x-openchamber-relay-connection']).toBe('test-conn');
|
|
383
|
-
host.close();
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
it('dials the default local port when target-port header is absent', async () => {
|
|
387
|
-
const fetchCalls = installFetchProbe();
|
|
388
|
-
/** @type {Uint8Array[]} */
|
|
389
|
-
const outbound = [];
|
|
390
|
-
const host = createTunnelHost({
|
|
391
|
-
connectionId: 'test-conn',
|
|
392
|
-
getLocalPort: () => 3000,
|
|
393
|
-
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
394
|
-
sendFrame: (frame) => { outbound.push(frame); },
|
|
395
|
-
getBufferedAmount: () => 0,
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
const streamId = 14;
|
|
399
|
-
await host.handleFrame(encodeTunnelFrame(
|
|
400
|
-
TunnelFrameType.HttpRequest,
|
|
401
|
-
streamId,
|
|
402
|
-
encodeJsonPayload({
|
|
403
|
-
method: 'GET',
|
|
404
|
-
path: '/health',
|
|
405
|
-
query: '',
|
|
406
|
-
headers: { accept: 'application/json' },
|
|
407
|
-
}),
|
|
408
|
-
));
|
|
409
|
-
|
|
410
|
-
await collectHttpExchange(outbound, streamId);
|
|
411
|
-
expect(fetchCalls).toHaveLength(1);
|
|
412
|
-
expect(fetchCalls[0].url).toBe('http://127.0.0.1:3000/health');
|
|
413
|
-
host.close();
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
it('WS open dials routing-table port and aborts on miss without fallback', async () => {
|
|
417
|
-
FakeWebSocket.reset();
|
|
418
|
-
/** @type {Uint8Array[]} */
|
|
419
|
-
const outbound = [];
|
|
420
|
-
const host = createTunnelHost({
|
|
421
|
-
connectionId: 'test-conn',
|
|
422
|
-
getLocalPort: () => 3000,
|
|
423
|
-
getSshRoutingTable: () => [{ id: 'ssh-1', localPort: 41234 }],
|
|
424
|
-
sendFrame: (frame) => { outbound.push(frame); },
|
|
425
|
-
getBufferedAmount: () => 0,
|
|
426
|
-
createWebSocket: (url, protocols) => new FakeWebSocket(url, protocols),
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
await host.handleFrame(encodeTunnelFrame(
|
|
430
|
-
TunnelFrameType.WsOpen,
|
|
431
|
-
21,
|
|
432
|
-
encodeJsonPayload({
|
|
433
|
-
path: '/api/event/ws',
|
|
434
|
-
query: '',
|
|
435
|
-
headers: { 'x-openchamber-target-port': '41234' },
|
|
436
|
-
}),
|
|
437
|
-
));
|
|
438
|
-
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
439
|
-
expect(FakeWebSocket.instances[0].url).toBe('ws://127.0.0.1:41234/api/event/ws');
|
|
440
|
-
|
|
441
|
-
await host.handleFrame(encodeTunnelFrame(
|
|
442
|
-
TunnelFrameType.WsOpen,
|
|
443
|
-
22,
|
|
444
|
-
encodeJsonPayload({
|
|
445
|
-
path: '/api/event/ws',
|
|
446
|
-
query: '',
|
|
447
|
-
headers: { 'x-openchamber-target-port': '9999' },
|
|
448
|
-
}),
|
|
449
|
-
));
|
|
450
|
-
// Miss must not open another socket (no fallback to 3000).
|
|
451
|
-
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
452
|
-
await waitFor(() => outbound.some((frame) => {
|
|
453
|
-
const decoded = decodeTunnelFrame(frame);
|
|
454
|
-
return decoded.streamId === 22 && decoded.frameType === TunnelFrameType.StreamAbort;
|
|
455
|
-
}));
|
|
456
|
-
const abort = outbound
|
|
457
|
-
.map((frame) => decodeTunnelFrame(frame))
|
|
458
|
-
.find((frame) => frame.streamId === 22 && frame.frameType === TunnelFrameType.StreamAbort);
|
|
459
|
-
expect(decodeJsonPayload(abort.payload, () => true).reason).toBe('ssh-host-unreachable');
|
|
460
|
-
host.close();
|
|
461
|
-
});
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
describe('resolveTargetPort', () => {
|
|
465
|
-
it('returns default when header is absent and null on table miss', () => {
|
|
466
|
-
const table = () => [{ id: 'ssh-1', localPort: 41234 }];
|
|
467
|
-
expect(resolveTargetPort({}, 3000, table)).toBe(3000);
|
|
468
|
-
expect(resolveTargetPort({ 'x-openchamber-target-port': '41234' }, 3000, table)).toBe(41234);
|
|
469
|
-
expect(resolveTargetPort({ 'X-OpenChamber-Target-Port': '41234' }, 3000, table)).toBe(41234);
|
|
470
|
-
expect(resolveTargetPort({ 'x-openchamber-target-port': '9999' }, 3000, table)).toBeNull();
|
|
471
|
-
expect(resolveTargetPort({ 'x-openchamber-target-port': 'not-a-port' }, 3000, table)).toBeNull();
|
|
472
|
-
});
|
|
473
|
-
});
|
|
@@ -31,7 +31,10 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
|
|
|
31
31
|
- Global run history list endpoint
|
|
32
32
|
- Project scheduled task CRUD endpoints
|
|
33
33
|
- Manual run endpoint
|
|
34
|
-
- OpenChamber events SSE stream endpoint
|
|
34
|
+
- OpenChamber events SSE stream endpoint (`GET /api/openchamber/events`): 25s
|
|
35
|
+
heartbeat interval unchanged; half-open clients that stay paused across two
|
|
36
|
+
consecutive heartbeat cycles are destroyed and removed from the client set
|
|
37
|
+
(idempotent cleanup). SSE is best-effort; authoritative data is HTTP pull.
|
|
35
38
|
- Returns persisted mutation results with `schedulerSynced`; a failed scheduler
|
|
36
39
|
sync schedules one bounded retry while preserving the committed response
|
|
37
40
|
|
|
@@ -321,6 +321,33 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
|
|
|
321
321
|
const clients = getOpenChamberEventClients();
|
|
322
322
|
clients.add(res);
|
|
323
323
|
|
|
324
|
+
let closed = false;
|
|
325
|
+
let heartbeatTimer = null;
|
|
326
|
+
// Half-open / stalled-drain eviction: destroy after this many consecutive
|
|
327
|
+
// heartbeat cycles while the socket remains paused (no drain). Interval
|
|
328
|
+
// itself is unchanged — only failure cleanup was added.
|
|
329
|
+
// SSE is best-effort; authoritative data is fetched over HTTP.
|
|
330
|
+
const maxPausedHeartbeatCycles = 2;
|
|
331
|
+
|
|
332
|
+
const cleanup = () => {
|
|
333
|
+
if (closed) {
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
closed = true;
|
|
337
|
+
if (heartbeatTimer) {
|
|
338
|
+
clearInterval(heartbeatTimer);
|
|
339
|
+
heartbeatTimer = null;
|
|
340
|
+
}
|
|
341
|
+
clients.delete(res);
|
|
342
|
+
try {
|
|
343
|
+
if (!res.destroyed && !res.writableEnded) {
|
|
344
|
+
res.destroy();
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
// ignore — destroy + delete must stay idempotent
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
324
351
|
try {
|
|
325
352
|
writeSseEvent(res, {
|
|
326
353
|
type: 'openchamber:event-stream-ready',
|
|
@@ -329,25 +356,41 @@ export const registerScheduledTaskRoutes = (app, dependencies) => {
|
|
|
329
356
|
},
|
|
330
357
|
});
|
|
331
358
|
} catch {
|
|
359
|
+
cleanup();
|
|
360
|
+
return;
|
|
332
361
|
}
|
|
333
362
|
|
|
334
|
-
|
|
363
|
+
heartbeatTimer = setInterval(() => {
|
|
364
|
+
if (closed || res.writableEnded || res.destroyed) {
|
|
365
|
+
cleanup();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
335
369
|
try {
|
|
336
|
-
writeSseEvent(res, {
|
|
370
|
+
const ok = writeSseEvent(res, {
|
|
337
371
|
type: 'openchamber:heartbeat',
|
|
338
372
|
properties: {
|
|
339
373
|
timestamp: Date.now(),
|
|
340
374
|
},
|
|
341
375
|
});
|
|
376
|
+
if (ok === false || res.__ssePaused) {
|
|
377
|
+
const cycles = (res.__ssePausedHeartbeatCycles || 0) + 1;
|
|
378
|
+
res.__ssePausedHeartbeatCycles = cycles;
|
|
379
|
+
if (cycles >= maxPausedHeartbeatCycles) {
|
|
380
|
+
// Intentional half-open cleanup: TCP may still look open while the
|
|
381
|
+
// relay tunnel is dead and drain never fires. SSE is best-effort;
|
|
382
|
+
// authoritative data is fetched over HTTP.
|
|
383
|
+
cleanup();
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
res.__ssePausedHeartbeatCycles = 0;
|
|
342
388
|
} catch {
|
|
343
|
-
|
|
344
|
-
clients.delete(res);
|
|
389
|
+
cleanup();
|
|
345
390
|
}
|
|
346
391
|
}, 25_000);
|
|
347
392
|
|
|
348
|
-
req.on('close',
|
|
349
|
-
|
|
350
|
-
clients.delete(res);
|
|
351
|
-
});
|
|
393
|
+
req.on('close', cleanup);
|
|
394
|
+
res.on('error', cleanup);
|
|
352
395
|
});
|
|
353
396
|
};
|
|
@@ -14,8 +14,10 @@ under `metadata.openchamber.assist`.
|
|
|
14
14
|
`retry` status or a user `message.updated` clears it (the "1 minute of
|
|
15
15
|
quiet" rule).
|
|
16
16
|
3. On fire: fetch the session (skip sub-agent sessions with `parentID`),
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
then build a trimmed excerpt via `buildAssistTranscript`: the LAST text
|
|
18
|
+
block of the last assistant message (earlier blocks, reasoning, and tool
|
|
19
|
+
output are excluded) plus the latest 3 real user messages (synthetic
|
|
20
|
+
hidden-instruction parts never count). The excerpt is sent to
|
|
19
21
|
`generateSmallModelText` with the
|
|
20
22
|
session's own provider/model taken from the last assistant message — so
|
|
21
23
|
the utility call spends the same subscription as the conversation.
|
|
@@ -33,6 +33,8 @@ const isSessionRecapEnabled = () => {
|
|
|
33
33
|
|
|
34
34
|
const IDLE_QUIET_MS = 60_000;
|
|
35
35
|
const TRANSCRIPT_MESSAGE_LIMIT = 12;
|
|
36
|
+
/** Latest real user messages fed to the recap prompt. */
|
|
37
|
+
const TRANSCRIPT_USER_MESSAGE_LIMIT = 3;
|
|
36
38
|
const TRANSCRIPT_PART_CHAR_LIMIT = 6_000;
|
|
37
39
|
const RECAP_CHAR_LIMIT = 320;
|
|
38
40
|
const FETCH_TIMEOUT_MS = 5_000;
|
|
@@ -92,13 +94,63 @@ const extractUserMessage = (payload) => {
|
|
|
92
94
|
};
|
|
93
95
|
};
|
|
94
96
|
|
|
95
|
-
|
|
97
|
+
/** Real (non-synthetic) text parts of a message — hidden instructions excluded. */
|
|
98
|
+
const realTextParts = (message) => {
|
|
96
99
|
const parts = Array.isArray(message?.parts) ? message.parts : [];
|
|
97
100
|
return parts
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
.filter((part) => part?.type === 'text'
|
|
102
|
+
&& typeof part.text === 'string'
|
|
103
|
+
&& part.text.trim().length > 0
|
|
104
|
+
&& part.synthetic !== true)
|
|
105
|
+
.map((part) => part.text);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Body text of a message: only its LAST text block, char-clamped. */
|
|
109
|
+
const lastMessageText = (message) => {
|
|
110
|
+
const texts = realTextParts(message);
|
|
111
|
+
return texts.length > 0 ? texts[texts.length - 1].slice(0, TRANSCRIPT_PART_CHAR_LIMIT) : '';
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the recap transcript from a bounded recent-message window.
|
|
116
|
+
* - Assistant: only the LAST text block of the last assistant message —
|
|
117
|
+
* earlier blocks, reasoning, and tool output are token waste for a
|
|
118
|
+
* one-line recap.
|
|
119
|
+
* - User: the latest real user messages (default 3), chronological.
|
|
120
|
+
*/
|
|
121
|
+
export const buildAssistTranscript = (
|
|
122
|
+
messages,
|
|
123
|
+
{ userLimit = TRANSCRIPT_USER_MESSAGE_LIMIT } = {},
|
|
124
|
+
) => {
|
|
125
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
126
|
+
return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
|
|
127
|
+
}
|
|
128
|
+
let lastAssistant = null;
|
|
129
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
130
|
+
if (messages[i]?.info?.role === 'assistant') {
|
|
131
|
+
lastAssistant = messages[i];
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const lastAssistantInfo = lastAssistant?.info ?? null;
|
|
136
|
+
if (!lastAssistantInfo?.id) {
|
|
137
|
+
return { transcript: '', assistantInfo: null, userTexts: [], assistantText: '' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const userTexts = [];
|
|
141
|
+
for (let i = messages.length - 1; i >= 0 && userTexts.length < userLimit; i -= 1) {
|
|
142
|
+
const message = messages[i];
|
|
143
|
+
if (message?.info?.role !== 'user') continue;
|
|
144
|
+
const text = lastMessageText(message);
|
|
145
|
+
if (!text) continue;
|
|
146
|
+
userTexts.unshift(text);
|
|
147
|
+
}
|
|
148
|
+
const assistantText = lastMessageText(lastAssistant);
|
|
149
|
+
const transcript = [
|
|
150
|
+
...userTexts.map((text) => `User:\n${text}`),
|
|
151
|
+
assistantText ? `Assistant:\n${assistantText}` : '',
|
|
152
|
+
].filter(Boolean).join('\n\n');
|
|
153
|
+
return { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText };
|
|
102
154
|
};
|
|
103
155
|
|
|
104
156
|
export const createSessionAssistRuntime = ({
|
|
@@ -169,36 +221,21 @@ export const createSessionAssistRuntime = ({
|
|
|
169
221
|
return;
|
|
170
222
|
}
|
|
171
223
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
lastAssistant = messages[i];
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
const lastAssistantInfo = lastAssistant?.info;
|
|
224
|
+
// Trimmed extraction for a one-line recap: the last assistant text block
|
|
225
|
+
// plus the latest real user messages. Everything else (earlier blocks,
|
|
226
|
+
// reasoning, tool output, older turns) is token waste.
|
|
227
|
+
const { transcript, assistantInfo: lastAssistantInfo, userTexts, assistantText } = buildAssistTranscript(messages);
|
|
181
228
|
if (!lastAssistantInfo?.id) return;
|
|
182
|
-
|
|
183
|
-
// Only the last exchange: the assistant reply plus the user message it
|
|
184
|
-
// answered (assistant info.parentID → user info.id). Everything else is
|
|
185
|
-
// token waste for a one-line recap.
|
|
186
|
-
const parentUserMessage = typeof lastAssistantInfo.parentID === 'string' && lastAssistantInfo.parentID
|
|
187
|
-
? messages.find((message) => message?.info?.id === lastAssistantInfo.parentID && message?.info?.role === 'user')
|
|
188
|
-
: null;
|
|
189
|
-
const userText = parentUserMessage ? messagePartsToText(parentUserMessage) : '';
|
|
190
|
-
const assistantText = messagePartsToText(lastAssistant);
|
|
191
|
-
const transcript = [
|
|
192
|
-
userText ? `User:\n${userText}` : '',
|
|
193
|
-
assistantText ? `Assistant:\n${assistantText}` : '',
|
|
194
|
-
].filter(Boolean).join('\n\n');
|
|
195
229
|
if (!transcript) return;
|
|
196
230
|
|
|
197
231
|
const { generateSmallModelText } = await getSmallModelService();
|
|
198
232
|
// Instruct the language by example, not by description — account-side
|
|
199
233
|
// personalization (e.g. the ChatGPT backend knowing the user's locale)
|
|
200
234
|
// otherwise leaks a different language into the output.
|
|
201
|
-
const languageSample = (
|
|
235
|
+
const languageSample = (userTexts[userTexts.length - 1] || assistantText)
|
|
236
|
+
.slice(0, 200)
|
|
237
|
+
.replace(/\s+/g, ' ')
|
|
238
|
+
.trim();
|
|
202
239
|
let generated;
|
|
203
240
|
try {
|
|
204
241
|
generated = await generateSmallModelText({
|
|
@@ -206,7 +243,7 @@ export const createSessionAssistRuntime = ({
|
|
|
206
243
|
// session's own provider unless the user explicitly picked a small
|
|
207
244
|
// model (settings override / opencode config).
|
|
208
245
|
restrictToPreferredProvider: true,
|
|
209
|
-
prompt: `The latest
|
|
246
|
+
prompt: `The latest conversation excerpt:\n\n${transcript}\n\nWrite recap in the SAME language as this sample from the conversation: "${languageSample}"`,
|
|
210
247
|
system: ASSIST_SYSTEM_PROMPT,
|
|
211
248
|
directory,
|
|
212
249
|
preferredProviderID: typeof lastAssistantInfo.providerID === 'string' ? lastAssistantInfo.providerID : undefined,
|
|
@@ -228,7 +265,7 @@ export const createSessionAssistRuntime = ({
|
|
|
228
265
|
// no Cyrillic/CJK at all, the output must not either.
|
|
229
266
|
const hasCyrillic = (text) => /[\u0400-\u04FF]/.test(text);
|
|
230
267
|
const hasCjk = (text) => /[\u3040-\u30FF\u4E00-\u9FFF\uAC00-\uD7AF]/.test(text);
|
|
231
|
-
const inputText = `${
|
|
268
|
+
const inputText = `${userTexts.join('\n')}\n${assistantText}`;
|
|
232
269
|
const scriptMismatch = (text) => (hasCyrillic(text) && !hasCyrillic(inputText))
|
|
233
270
|
|| (hasCjk(text) && !hasCjk(inputText));
|
|
234
271
|
if (recap && scriptMismatch(recap)) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { buildAssistTranscript } from './runtime.js';
|
|
3
|
+
|
|
4
|
+
const textPart = (text, { synthetic = false } = {}) => ({
|
|
5
|
+
type: 'text',
|
|
6
|
+
text,
|
|
7
|
+
...(synthetic ? { synthetic: true } : {}),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const userMessage = (id, parts) => ({ info: { id, role: 'user' }, parts });
|
|
11
|
+
const assistantMessage = (id, parts) => ({ info: { id, role: 'assistant' }, parts });
|
|
12
|
+
|
|
13
|
+
describe('buildAssistTranscript', () => {
|
|
14
|
+
test('assistant keeps only the LAST text block of the last assistant message', () => {
|
|
15
|
+
const result = buildAssistTranscript([
|
|
16
|
+
userMessage('u1', [textPart('first user message')]),
|
|
17
|
+
assistantMessage('a1', [textPart('old assistant reply')]),
|
|
18
|
+
userMessage('u2', [textPart('second user message')]),
|
|
19
|
+
assistantMessage('a2', [
|
|
20
|
+
textPart('intro before tools'),
|
|
21
|
+
{ type: 'tool', tool: 'bash' },
|
|
22
|
+
textPart('final body text'),
|
|
23
|
+
]),
|
|
24
|
+
]);
|
|
25
|
+
expect(result.assistantInfo.id).toBe('a2');
|
|
26
|
+
expect(result.assistantText).toBe('final body text');
|
|
27
|
+
expect(result.transcript).toBe(
|
|
28
|
+
'User:\nfirst user message\n\nUser:\nsecond user message\n\nAssistant:\nfinal body text',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('reasoning parts never enter the transcript', () => {
|
|
33
|
+
const result = buildAssistTranscript([
|
|
34
|
+
userMessage('u1', [textPart('请帮我看看')]),
|
|
35
|
+
assistantMessage('a1', [
|
|
36
|
+
{ type: 'reasoning', text: 'thinking about the problem' },
|
|
37
|
+
textPart('最终答复'),
|
|
38
|
+
]),
|
|
39
|
+
]);
|
|
40
|
+
expect(result.transcript).toBe('User:\n请帮我看看\n\nAssistant:\n最终答复');
|
|
41
|
+
expect(result.transcript).not.toContain('thinking');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('user side keeps only the latest 3 real user messages, chronologically', () => {
|
|
45
|
+
const result = buildAssistTranscript([
|
|
46
|
+
userMessage('u1', [textPart('question 1')]),
|
|
47
|
+
assistantMessage('a1', [textPart('answer 1')]),
|
|
48
|
+
userMessage('u2', [textPart('question 2')]),
|
|
49
|
+
assistantMessage('a2', [textPart('answer 2')]),
|
|
50
|
+
userMessage('u3', [textPart('question 3')]),
|
|
51
|
+
userMessage('u4', [textPart('question 4')]),
|
|
52
|
+
assistantMessage('a3', [textPart('answer 3')]),
|
|
53
|
+
]);
|
|
54
|
+
expect(result.userTexts).toEqual(['question 2', 'question 3', 'question 4']);
|
|
55
|
+
expect(result.transcript).toBe(
|
|
56
|
+
'User:\nquestion 2\n\nUser:\nquestion 3\n\nUser:\nquestion 4\n\nAssistant:\nanswer 3',
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('synthetic-only user messages do not count toward the user window', () => {
|
|
61
|
+
const result = buildAssistTranscript([
|
|
62
|
+
userMessage('u1', [textPart('visible request')]),
|
|
63
|
+
userMessage('u2', [textPart('hidden instruction', { synthetic: true })]),
|
|
64
|
+
userMessage('u3', [textPart('real follow-up')]),
|
|
65
|
+
assistantMessage('a1', [textPart('reply body')]),
|
|
66
|
+
]);
|
|
67
|
+
expect(result.userTexts).toEqual(['visible request', 'real follow-up']);
|
|
68
|
+
expect(result.transcript).not.toContain('hidden instruction');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('synthetic instruction part on a real user message is dropped', () => {
|
|
72
|
+
const result = buildAssistTranscript([
|
|
73
|
+
userMessage('u1', [
|
|
74
|
+
textPart('visible request'),
|
|
75
|
+
textPart('/summary hidden instructions', { synthetic: true }),
|
|
76
|
+
]),
|
|
77
|
+
assistantMessage('a1', [textPart('reply body')]),
|
|
78
|
+
]);
|
|
79
|
+
expect(result.userTexts).toEqual(['visible request']);
|
|
80
|
+
expect(result.transcript).not.toContain('/summary hidden instructions');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('fewer than 3 user messages keeps all of them', () => {
|
|
84
|
+
const result = buildAssistTranscript([
|
|
85
|
+
userMessage('u1', [textPart('only question')]),
|
|
86
|
+
assistantMessage('a1', [textPart('only answer')]),
|
|
87
|
+
]);
|
|
88
|
+
expect(result.userTexts).toEqual(['only question']);
|
|
89
|
+
expect(result.transcript).toBe('User:\nonly question\n\nAssistant:\nonly answer');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('no assistant message yields an empty transcript', () => {
|
|
93
|
+
const result = buildAssistTranscript([
|
|
94
|
+
userMessage('u1', [textPart('unanswered question')]),
|
|
95
|
+
]);
|
|
96
|
+
expect(result.transcript).toBe('');
|
|
97
|
+
expect(result.assistantInfo).toBeNull();
|
|
98
|
+
expect(result.userTexts).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('assistant with no text parts still summarizes the user side', () => {
|
|
102
|
+
const result = buildAssistTranscript([
|
|
103
|
+
userMessage('u1', [textPart('question')]),
|
|
104
|
+
assistantMessage('a1', [{ type: 'tool', tool: 'bash' }]),
|
|
105
|
+
]);
|
|
106
|
+
expect(result.assistantInfo.id).toBe('a1');
|
|
107
|
+
expect(result.transcript).toBe('User:\nquestion');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('empty input yields an empty transcript', () => {
|
|
111
|
+
expect(buildAssistTranscript([]).transcript).toBe('');
|
|
112
|
+
expect(buildAssistTranscript(null).transcript).toBe('');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -18,16 +18,21 @@ throttle (`TITLE_THROTTLE_MS`) when a refresh is armed.
|
|
|
18
18
|
pattern as session-assist. Purely event-driven — dormant sessions never
|
|
19
19
|
generate anything.
|
|
20
20
|
2. Auto title refresh is intentionally sparse (title stability first). A newly
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
observed root `session.created` generates its first title immediately on the
|
|
22
|
+
first `session.status: idle`. A fork title (`(fork #n)`) waits for its first
|
|
23
|
+
newly-created user message; the matching assistant completion triggers an
|
|
24
|
+
immediate title refresh that bypasses inherited title metadata and throttle.
|
|
25
|
+
If the fork's `session.created` was lost (SSE reconnect gap, server or
|
|
26
|
+
OpenCode restart), the first newly-created user message lazily re-registers
|
|
27
|
+
the pending fork from the session read inside `recordUserActivity` (fork
|
|
28
|
+
title + message created after the fork + activity timestamp not yet past
|
|
29
|
+
the fork time), so the first-reply refresh still fires.
|
|
30
|
+
Ordinary later idle transitions do **not** arm another refresh. Any
|
|
31
|
+
`busy`/`retry` status or a fresh user `message.updated` still clears an
|
|
32
|
+
already-armed timer (so initial/fork timers cancel if the user keeps going).
|
|
33
|
+
A sidebar smart-title request sets `titleRefresh.requestedAt`; its
|
|
34
|
+
`session.updated` event arms the same flow immediately (forced / manual
|
|
35
|
+
refresh is unaffected by the background gate).
|
|
31
36
|
3. On fire:
|
|
32
37
|
- Skip when Settings → Chat → session title refresh is off.
|
|
33
38
|
- Skip system-owned sessions whose metadata carries a non-empty
|