@openchambery/web 1.18.4 → 1.18.5
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-DAnelAnB.js → AgentAvatar-DDcjamcJ.js} +1 -1
- package/dist/assets/AssistantView-n8llxdCo.js +1 -0
- package/dist/assets/{ChatContainer-BVPeKR-K.js → ChatContainer-B7H_fTkm.js} +40 -40
- package/dist/assets/{DiagramView-Cmf-1RrR.js → DiagramView-DK5RkG9r.js} +1 -1
- package/dist/assets/{FadeInOnReveal-QxF1vuqX.js → FadeInOnReveal-By2F9vcI.js} +1 -1
- package/dist/assets/{Icon-D-X70irR.js → Icon-DpjYnXxn.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-TCIXUwWr.js → MarkdownRendererImpl-DOpja779.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-BVwAAhoU.js → MobileDetailNavigation-k1tBlKW5.js} +1 -1
- package/dist/assets/{MobileShareBridge-2J_Tfuji.js → MobileShareBridge-HxyfCuga.js} +3 -3
- package/dist/assets/MobileSurface-BRouCuNF.js +2 -0
- package/dist/assets/{MultiRunWindow-UffGnrG2.js → MultiRunWindow-CwzxTb3p.js} +1 -1
- package/dist/assets/{OnboardingScreen-Cx79VlTN.js → OnboardingScreen-BKmuT7AL.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CHXk_jvF.js → OverlayScrollbar-Ch3J6ASR.js} +1 -1
- package/dist/assets/{SettingsView-Da1ATyc8.js → SettingsView-Bx9tuWxH.js} +11 -11
- package/dist/assets/{SettingsWindow-nVdVW9KM.js → SettingsWindow-sVzjzaGh.js} +1 -1
- package/dist/assets/{TerminalView-BTY7HznA.js → TerminalView-DSwK1UwL.js} +1 -1
- package/dist/assets/{ToolOutputDialog-BJfWelnd.js → ToolOutputDialog-D9di4LaC.js} +1 -1
- package/dist/assets/{appThemeRegistry-BQGUOtTV.js → appThemeRegistry-CN1tLL9G.js} +2 -2
- package/dist/assets/{button-CwLFtUNT.js → button-C7OXr4ae.js} +1 -1
- package/dist/assets/{clipboard-BfgFef1i.js → clipboard-DWuULPzo.js} +1 -1
- package/dist/assets/{collapsible-CP1-_pY6.js → collapsible-DaEya6b6.js} +1 -1
- package/dist/assets/{context-menu-BwxsmiaI.js → context-menu-BrLPLxWa.js} +1 -1
- package/dist/assets/{desktopHosts-Cr3LE55y.js → desktopHosts-Txp4xOVQ.js} +1 -1
- package/dist/assets/desktopRelayRestore-C_FSEHKM.js +1 -0
- package/dist/assets/{diagram-rYL80jaS.js → diagram-CHfginjS.js} +1 -1
- package/dist/assets/{dialog-GR7KoP1o.js → dialog-DPS0W9eS.js} +1 -1
- package/dist/assets/{es-Bi4m8Mm6.js → es-BX0Vm3Ac.js} +1 -1
- package/dist/assets/fileQueries-BrUKKgPW.js +1 -0
- package/dist/assets/{fr-B5UdepyS.js → fr-CuPzXTYx.js} +1 -1
- package/dist/assets/{gitApi-BfOQScwC.js → gitApi-C_tpQa_z.js} +3 -3
- package/dist/assets/{input-D52Jicb1.js → input-BzYA603C.js} +1 -1
- package/dist/assets/insertionBoundaries-CzVPcHK4.js +1 -0
- package/dist/assets/{ja-KtFuRjvm.js → ja-DGtzjrFO.js} +1 -1
- package/dist/assets/{ko-CWpFVSCH.js → ko-DZ9Di0je.js} +1 -1
- package/dist/assets/{main-CTzdOaGn.js → main-BJBQp1tB.js} +2 -2
- package/dist/assets/{main-7rfc5WXp.js → main-BUGDfAIY.js} +23 -23
- package/dist/assets/{markdown-worker-N9d_Z-7s.js → markdown-worker-W06-MhF5.js} +1 -1
- package/dist/assets/{markdownTheme-Bl0Sy0Lf.js → markdownTheme-B89dbj3N.js} +1 -1
- package/dist/assets/{miniChat-CAbceO-F.js → miniChat-BLtGuyQZ.js} +2 -2
- package/dist/assets/mobile-0Sb3IsCl.js +2 -0
- package/dist/assets/{multirun-C2J5Yp8V.js → multirun-BC-EFVZ0.js} +1 -1
- package/dist/assets/{persistence-vZ7H8WKh.js → persistence-6sclr7B6.js} +2 -2
- package/dist/assets/{pl-30X8TD5i.js → pl-BuCO3qb1.js} +1 -1
- package/dist/assets/{platform-CTaznShj.js → platform-CbeLpKPk.js} +1 -1
- package/dist/assets/projectMeta-CYvQd9iC.js +3 -0
- package/dist/assets/{pt-BR-Cm-Klf0E.js → pt-BR-Brf4RMsw.js} +1 -1
- package/dist/assets/radio-CEt2UjvA.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-ByA4BElP.js +2 -0
- package/dist/assets/renderMobileApp-upKrXoPH.js +23 -0
- package/dist/assets/{runtimeConfig-CCrS3B1v.js → runtimeConfig-BAx9ix2e.js} +1 -1
- package/dist/assets/runtimeEndpointReset-Dst0Yqyz.js +147 -0
- package/dist/assets/{sortable-tabs-strip-BYmWSdS9.js → sortable-tabs-strip-nLsPC06f.js} +1 -1
- package/dist/assets/src-BDOLh3pc.css +2 -0
- package/dist/assets/{store-Z1MxUaP4.js → store-DQY8Fkb0.js} +2 -2
- package/dist/assets/{terminalApi-KDUc5mh2.js → terminalApi-BpPeXWIu.js} +1 -1
- package/dist/assets/{tooltip-CaCe-GAm.js → tooltip-D_orJMYd.js} +1 -1
- package/dist/assets/{uk-Cgc5wXrs.js → uk-C-wmaPTi.js} +1 -1
- package/dist/assets/{url-B-FWrnxu.js → url-BsaBG5-1.js} +1 -1
- package/dist/assets/useAppFontEffects-Bo__MTWV.js +7 -0
- package/dist/assets/{useAssistantUIStore-ifqNyja4.js → useAssistantUIStore-BHsHXCKE.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-C7JocspE.js → useDesktopWindowControlsLayout-DkmRLrOT.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-DMWATHLS.js → useEffectiveDirectory-ByQPsbk0.js} +1 -1
- package/dist/assets/{useFontPreferences-D_LFFhFS.js → useFontPreferences-ckzpXQaJ.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DLYb8f5L.js +475 -0
- package/dist/assets/useSessionAutoCleanup-C5knpuh5.js +1 -0
- package/dist/assets/useUIStore-DzwCfRqr.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-NddS5-lr.js → useWorkerHighlightedLines-Ce2Bs8_N.js} +1 -1
- package/dist/assets/{utils-C2P0wcWO.js → utils-B1EK5zKa.js} +1 -1
- package/dist/assets/{zh-CN-BdRwDJuj.js → zh-CN-DueRrIql.js} +1 -1
- package/dist/assets/{zh-TW-C3LubKzZ.js → zh-TW-6875X80V.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/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/scheduled-tasks/DOCUMENTATION.md +4 -1
- package/server/lib/scheduled-tasks/routes.js +51 -8
- package/server/lib/session-index/DOCUMENTATION.md +9 -3
- package/server/lib/session-index/service.js +45 -22
- package/server/lib/session-index/service.test.js +81 -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/session-turn-pages/DOCUMENTATION.md +2 -1
- package/server/lib/session-turn-pages/service.js +14 -1
- package/server/lib/session-turn-pages/service.test.js +133 -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-DM2dKfoR.js +0 -1
- package/dist/assets/MobileSurface-CDiflo1b.js +0 -2
- package/dist/assets/desktopRelayRestore-VfMK9fdk.js +0 -1
- package/dist/assets/fileQueries-CXaw4b1W.js +0 -1
- package/dist/assets/insertionBoundaries-Gk_rVlpX.js +0 -1
- package/dist/assets/mobile-BX6ehEeM.js +0 -2
- package/dist/assets/projectMeta-mqvgqR8a.js +0 -3
- package/dist/assets/radio-nnW-6V1r.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-C2Ya6FdB.js +0 -2
- package/dist/assets/renderMobileApp-BgDGLev_.js +0 -23
- package/dist/assets/runtimeEndpointReset-C1NE1NgZ.js +0 -147
- package/dist/assets/src-Dbdlm5x1.css +0 -2
- package/dist/assets/useAppFontEffects-BoCsqVCV.js +0 -7
- package/dist/assets/useMobileNavigationStore-CLiqmN1d.js +0 -476
- package/dist/assets/useSessionAutoCleanup-BPKBXJg4.js +0 -1
- package/dist/assets/useUIStore-NfqK-HA7.js +0 -1
|
@@ -140,6 +140,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
140
140
|
ON session_summary(runtime_key, directory, activity_updated_at DESC, session_id DESC);
|
|
141
141
|
CREATE INDEX IF NOT EXISTS session_child_parent
|
|
142
142
|
ON session_child(runtime_key, directory, parent_id);
|
|
143
|
+
CREATE TABLE IF NOT EXISTS session_pin (
|
|
144
|
+
runtime_key TEXT NOT NULL,
|
|
145
|
+
session_id TEXT NOT NULL,
|
|
146
|
+
pinned_at INTEGER NOT NULL,
|
|
147
|
+
PRIMARY KEY (runtime_key, session_id)
|
|
148
|
+
);
|
|
143
149
|
`);
|
|
144
150
|
const sessionSummaryColumns = new Set(
|
|
145
151
|
db.prepare("SELECT name FROM pragma_table_info('session_summary')").all().map((column) => column.name),
|
|
@@ -147,6 +153,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
147
153
|
if (!sessionSummaryColumns.has('pinned_at')) {
|
|
148
154
|
db.exec('ALTER TABLE session_summary ADD COLUMN pinned_at INTEGER');
|
|
149
155
|
}
|
|
156
|
+
db.prepare(`
|
|
157
|
+
INSERT OR IGNORE INTO session_pin(runtime_key, session_id, pinned_at)
|
|
158
|
+
SELECT runtime_key, session_id, pinned_at
|
|
159
|
+
FROM session_summary
|
|
160
|
+
WHERE pinned_at IS NOT NULL
|
|
161
|
+
`).run();
|
|
150
162
|
db.prepare('INSERT OR REPLACE INTO session_index_meta(key, value) VALUES (?, ?)')
|
|
151
163
|
.run('schema_version', String(SCHEMA_VERSION));
|
|
152
164
|
|
|
@@ -240,6 +252,23 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
240
252
|
SELECT COUNT(*) AS count FROM session_child
|
|
241
253
|
WHERE runtime_key = ? AND directory = ? AND parent_id = ?
|
|
242
254
|
`);
|
|
255
|
+
const readPins = db.prepare(`
|
|
256
|
+
SELECT session_id AS id, pinned_at AS pinnedAt
|
|
257
|
+
FROM session_pin
|
|
258
|
+
WHERE runtime_key = ?
|
|
259
|
+
ORDER BY pinned_at DESC, session_id DESC
|
|
260
|
+
`);
|
|
261
|
+
const upsertPin = db.prepare(`
|
|
262
|
+
INSERT INTO session_pin(runtime_key, session_id, pinned_at)
|
|
263
|
+
VALUES (?, ?, ?)
|
|
264
|
+
ON CONFLICT(runtime_key, session_id) DO UPDATE SET pinned_at = excluded.pinned_at
|
|
265
|
+
`);
|
|
266
|
+
const deletePin = db.prepare('DELETE FROM session_pin WHERE runtime_key = ? AND session_id = ?');
|
|
267
|
+
const writeSummaryPinnedAt = db.prepare(`
|
|
268
|
+
UPDATE session_summary
|
|
269
|
+
SET pinned_at = ?
|
|
270
|
+
WHERE runtime_key = ? AND session_id = ?
|
|
271
|
+
`);
|
|
243
272
|
|
|
244
273
|
const replaceDirectoryRows = ({ directory, sessions, cursor, hasMore, fullSync = true, now = Date.now() }) => {
|
|
245
274
|
const normalizedDirectory = normalizeDirectory(directory);
|
|
@@ -363,6 +392,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
363
392
|
|
|
364
393
|
const snapshot = () => {
|
|
365
394
|
const key = runtimeKey();
|
|
395
|
+
const pinRows = readPins.all(key);
|
|
396
|
+
const pinById = new Map(pinRows.map((row) => [row.id, row.pinnedAt]));
|
|
366
397
|
const directories = db.prepare(`
|
|
367
398
|
SELECT directory, cursor, has_more AS hasMore, last_synced_at AS lastSyncedAt,
|
|
368
399
|
last_full_synced_at AS lastFullSyncedAt, last_accessed_at AS lastAccessedAt
|
|
@@ -387,7 +418,9 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
387
418
|
created: session.createdAt,
|
|
388
419
|
updated: session.updatedAt,
|
|
389
420
|
...(session.archivedAt ? { archived: session.archivedAt } : {}),
|
|
390
|
-
...(session.
|
|
421
|
+
...((pinById.get(session.id) ?? session.pinnedAt)
|
|
422
|
+
? { pinned: pinById.get(session.id) ?? session.pinnedAt }
|
|
423
|
+
: {}),
|
|
391
424
|
},
|
|
392
425
|
metadata: {
|
|
393
426
|
openchamber: {
|
|
@@ -401,13 +434,17 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
401
434
|
}))
|
|
402
435
|
.filter(isVisibleSession),
|
|
403
436
|
}));
|
|
404
|
-
return {
|
|
437
|
+
return {
|
|
438
|
+
directories,
|
|
439
|
+
pinnedSessionIds: pinRows.map((row) => row.id),
|
|
440
|
+
};
|
|
405
441
|
};
|
|
406
442
|
|
|
407
443
|
function remove(sessionID) {
|
|
408
444
|
if (typeof sessionID !== 'string' || !sessionID) return false;
|
|
409
445
|
const key = runtimeKey();
|
|
410
446
|
const parent = parentForChild.get(key, sessionID);
|
|
447
|
+
const pinResult = deletePin.run(key, sessionID);
|
|
411
448
|
const result = db.prepare('DELETE FROM session_summary WHERE runtime_key = ? AND session_id = ?').run(key, sessionID);
|
|
412
449
|
deleteChildrenWithParent.run(key, sessionID);
|
|
413
450
|
deleteChild.run(key, sessionID);
|
|
@@ -419,7 +456,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
419
456
|
parent.parentID,
|
|
420
457
|
);
|
|
421
458
|
}
|
|
422
|
-
return result.changes > 0;
|
|
459
|
+
return result.changes > 0 || pinResult.changes > 0;
|
|
423
460
|
}
|
|
424
461
|
|
|
425
462
|
/**
|
|
@@ -480,36 +517,22 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
480
517
|
return result.changes > 0;
|
|
481
518
|
};
|
|
482
519
|
|
|
483
|
-
const summaryExists = db.prepare(`
|
|
484
|
-
SELECT 1 AS present FROM session_summary
|
|
485
|
-
WHERE runtime_key = ? AND session_id = ?
|
|
486
|
-
LIMIT 1
|
|
487
|
-
`);
|
|
488
|
-
|
|
489
520
|
const setPinned = (sessionID, pinnedAt = Date.now()) => {
|
|
490
521
|
if (typeof sessionID !== 'string' || !sessionID) return false;
|
|
491
522
|
const timestamp = toTimestamp(pinnedAt);
|
|
492
523
|
if (!timestamp) return false;
|
|
493
524
|
const key = runtimeKey();
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
UPDATE session_summary
|
|
497
|
-
SET pinned_at = ?
|
|
498
|
-
WHERE runtime_key = ? AND session_id = ?
|
|
499
|
-
`).run(timestamp, key, sessionID);
|
|
525
|
+
upsertPin.run(key, sessionID, timestamp);
|
|
526
|
+
writeSummaryPinnedAt.run(timestamp, key, sessionID);
|
|
500
527
|
return true;
|
|
501
528
|
};
|
|
502
529
|
|
|
503
530
|
const clearPinned = (sessionID) => {
|
|
504
531
|
if (typeof sessionID !== 'string' || !sessionID) return false;
|
|
505
532
|
const key = runtimeKey();
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
SET pinned_at = NULL
|
|
510
|
-
WHERE runtime_key = ? AND session_id = ?
|
|
511
|
-
`).run(key, sessionID);
|
|
512
|
-
return true;
|
|
533
|
+
const pinResult = deletePin.run(key, sessionID);
|
|
534
|
+
const summaryResult = writeSummaryPinnedAt.run(null, key, sessionID);
|
|
535
|
+
return pinResult.changes > 0 || summaryResult.changes > 0;
|
|
513
536
|
};
|
|
514
537
|
|
|
515
538
|
const replaceChildSessions = db.transaction((directory, parentSessionID, children) => {
|
|
@@ -385,7 +385,88 @@ describe('Electron session index', () => {
|
|
|
385
385
|
|
|
386
386
|
expect(service.upsert({ ...session('ses_a', 11), time: { created: 9, updated: 11, archived: 12 } })).toBe(true);
|
|
387
387
|
expect(service.snapshot().directories[0].sessions).toEqual([]);
|
|
388
|
+
expect(service.snapshot().pinnedSessionIds ?? []).not.toContain('ses_a');
|
|
388
389
|
expect(service.clearPinned('ses_a')).toBe(false);
|
|
389
390
|
service.close();
|
|
390
391
|
});
|
|
392
|
+
|
|
393
|
+
it('keeps a pin after the session falls outside the newest-20 rebuild window', () => {
|
|
394
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
395
|
+
const service = createService(runtimeRef);
|
|
396
|
+
service.replaceDirectory({
|
|
397
|
+
directory: '/repo',
|
|
398
|
+
sessions: [session('ses_pinned', 10)],
|
|
399
|
+
cursor: null,
|
|
400
|
+
hasMore: false,
|
|
401
|
+
});
|
|
402
|
+
expect(service.setPinned('ses_pinned', 42)).toBe(true);
|
|
403
|
+
|
|
404
|
+
const newer = Array.from({ length: 20 }, (_, index) => session(`ses_new_${index}`, 200 - index));
|
|
405
|
+
service.replaceDirectory({ directory: '/repo', sessions: newer, cursor: null, hasMore: true, now: 3000 });
|
|
406
|
+
|
|
407
|
+
const snapshot = service.snapshot();
|
|
408
|
+
expect(snapshot.directories[0].sessions).toHaveLength(20);
|
|
409
|
+
expect(snapshot.directories[0].sessions.some((item) => item.id === 'ses_pinned')).toBe(false);
|
|
410
|
+
expect(snapshot.pinnedSessionIds).toEqual(['ses_pinned']);
|
|
411
|
+
service.close();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('keeps a pin after live upserts evict the pinned summary row', () => {
|
|
415
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
416
|
+
const service = createService(runtimeRef);
|
|
417
|
+
service.replaceDirectory({
|
|
418
|
+
directory: '/repo',
|
|
419
|
+
sessions: [session('ses_pinned', 10)],
|
|
420
|
+
cursor: null,
|
|
421
|
+
hasMore: false,
|
|
422
|
+
});
|
|
423
|
+
expect(service.setPinned('ses_pinned', 43)).toBe(true);
|
|
424
|
+
|
|
425
|
+
for (let index = 0; index < 20; index += 1) {
|
|
426
|
+
service.upsert(session(`ses_new_${index}`, 200 - index));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const snapshot = service.snapshot();
|
|
430
|
+
expect(snapshot.directories[0].sessions).toHaveLength(20);
|
|
431
|
+
expect(snapshot.directories[0].sessions.some((item) => item.id === 'ses_pinned')).toBe(false);
|
|
432
|
+
expect(snapshot.pinnedSessionIds).toEqual(['ses_pinned']);
|
|
433
|
+
service.close();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('backfills session_pin from existing summary pinned_at on open', () => {
|
|
437
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
438
|
+
const service = createService(runtimeRef);
|
|
439
|
+
service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
|
|
440
|
+
expect(service.setPinned('ses_a', 66)).toBe(true);
|
|
441
|
+
service.close();
|
|
442
|
+
|
|
443
|
+
const dbPath = path.join(tempDirectories[tempDirectories.length - 1], 'session-index.sqlite');
|
|
444
|
+
const Database = createRequire(import.meta.url)('better-sqlite3');
|
|
445
|
+
const db = new Database(dbPath);
|
|
446
|
+
db.exec('DELETE FROM session_pin');
|
|
447
|
+
db.close();
|
|
448
|
+
|
|
449
|
+
const reopened = createSessionIndexService({
|
|
450
|
+
dbPath,
|
|
451
|
+
getRuntimeConfig: () => ({ apiBaseUrl: runtimeRef.value }),
|
|
452
|
+
});
|
|
453
|
+
expect(reopened.snapshot().pinnedSessionIds).toEqual(['ses_a']);
|
|
454
|
+
expect(reopened.snapshot().directories[0].sessions[0].time.pinned).toBe(66);
|
|
455
|
+
reopened.close();
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('pins a session that is not in the newest-20 index', () => {
|
|
459
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
460
|
+
const service = createService(runtimeRef);
|
|
461
|
+
const newest = Array.from({ length: 20 }, (_, index) => session(`ses_${index}`, 100 - index));
|
|
462
|
+
service.replaceDirectory({ directory: '/repo', sessions: newest, cursor: 80, hasMore: true, now: 1000 });
|
|
463
|
+
|
|
464
|
+
expect(service.setPinned('ses_old', 55)).toBe(true);
|
|
465
|
+
expect(service.snapshot().pinnedSessionIds).toEqual(['ses_old']);
|
|
466
|
+
expect(service.snapshot().directories[0].sessions.some((item) => item.id === 'ses_old')).toBe(false);
|
|
467
|
+
|
|
468
|
+
expect(service.clearPinned('ses_old')).toBe(true);
|
|
469
|
+
expect(service.snapshot().pinnedSessionIds ?? []).toEqual([]);
|
|
470
|
+
service.close();
|
|
471
|
+
});
|
|
391
472
|
});
|
|
@@ -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
|
|
@@ -484,6 +484,30 @@ export const createSessionTitleRuntime = ({
|
|
|
484
484
|
if (!session || typeof session !== 'object') return;
|
|
485
485
|
|
|
486
486
|
const meta = readTitleRefreshMeta(session);
|
|
487
|
+
|
|
488
|
+
// Lazy fork registration: when the fork's `session.created` event was lost
|
|
489
|
+
// (SSE reconnect gap, server/OpenCode restart), the in-memory pending map
|
|
490
|
+
// never learned about the fork. The first newly-created user message on a
|
|
491
|
+
// fork-titled session (message created after the fork, before any activity
|
|
492
|
+
// timestamp advanced past the fork) rebuilds that pending entry so the
|
|
493
|
+
// matching idle still triggers the first-refresh. Idempotent: the normal
|
|
494
|
+
// session.created path already holds a pending entry, and once activity
|
|
495
|
+
// advances past the fork time later messages never re-register.
|
|
496
|
+
const forkCreated = Number(session?.time?.created);
|
|
497
|
+
if (
|
|
498
|
+
!forkFirstSendPending.has(sessionId)
|
|
499
|
+
&& isForkedSessionTitle(typeof session.title === 'string' ? session.title : '')
|
|
500
|
+
&& Number.isFinite(forkCreated)
|
|
501
|
+
&& createdAt > forkCreated
|
|
502
|
+
&& !(Number(meta.titleRefresh.activityUpdatedAt) > forkCreated)
|
|
503
|
+
) {
|
|
504
|
+
forkFirstSendPending.set(sessionId, {
|
|
505
|
+
createdAt: forkCreated,
|
|
506
|
+
directory: directory || '',
|
|
507
|
+
hasNewUserMessage: true,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
487
511
|
const previous = Number(meta.titleRefresh.activityUpdatedAt);
|
|
488
512
|
const activityUpdatedAt = Number.isFinite(previous) ? Math.max(previous, createdAt) : createdAt;
|
|
489
513
|
if (activityUpdatedAt === previous) return;
|
|
@@ -410,6 +410,149 @@ describe('session-title helpers', () => {
|
|
|
410
410
|
}
|
|
411
411
|
});
|
|
412
412
|
|
|
413
|
+
it('recovers the fork first-refresh when the fork session.created event was lost', async () => {
|
|
414
|
+
const originalFetch = globalThis.fetch;
|
|
415
|
+
const forkCreated = 100;
|
|
416
|
+
const session = {
|
|
417
|
+
id: 'ses-fork-lost',
|
|
418
|
+
title: 'Original work (fork #1)',
|
|
419
|
+
time: { created: forkCreated },
|
|
420
|
+
metadata: {
|
|
421
|
+
openchamber: {
|
|
422
|
+
titleRefresh: {
|
|
423
|
+
// Inherited from the parent: activity predates the fork.
|
|
424
|
+
activityUpdatedAt: 50,
|
|
425
|
+
lastAutoTitle: 'Original work',
|
|
426
|
+
generatedAt: 40,
|
|
427
|
+
forMessageID: 'assistant-original',
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
const messages = [
|
|
433
|
+
{ info: { id: 'user-fork', role: 'user' }, parts: [{ type: 'text', text: 'Try a different approach' }] },
|
|
434
|
+
{ info: { id: 'assistant-fork', role: 'assistant' }, parts: [{ type: 'text', text: 'Different approach ready' }] },
|
|
435
|
+
];
|
|
436
|
+
let generationCalls = 0;
|
|
437
|
+
|
|
438
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
439
|
+
const url = String(input);
|
|
440
|
+
if (url.includes('/message')) {
|
|
441
|
+
return new Response(JSON.stringify(messages), { status: 200 });
|
|
442
|
+
}
|
|
443
|
+
if (init.method === 'PATCH') {
|
|
444
|
+
const body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
|
|
445
|
+
if (typeof body?.title === 'string') session.title = body.title;
|
|
446
|
+
if (body?.metadata) session.metadata = body.metadata;
|
|
447
|
+
return new Response(JSON.stringify(session), { status: 200 });
|
|
448
|
+
}
|
|
449
|
+
return new Response(JSON.stringify(session), { status: 200 });
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
const runtime = createSessionTitleRuntime({
|
|
454
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
455
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
456
|
+
getSmallModelService: async () => ({
|
|
457
|
+
generateSmallModelText: async () => {
|
|
458
|
+
generationCalls += 1;
|
|
459
|
+
return { text: 'Different approach', providerID: 'test', modelID: 'test' };
|
|
460
|
+
},
|
|
461
|
+
}),
|
|
462
|
+
now: () => 1_000,
|
|
463
|
+
isTitleRefreshEnabled: () => true,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// No session.created — it was lost across an SSE reconnect gap. The first
|
|
467
|
+
// newly-sent fork message lazily re-registers the pending fork entry.
|
|
468
|
+
runtime.processPayload({
|
|
469
|
+
type: 'message.updated',
|
|
470
|
+
properties: { directory: '/repo', info: { sessionID: 'ses-fork-lost', role: 'user', id: 'user-fork', time: { created: 200 } } },
|
|
471
|
+
});
|
|
472
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
473
|
+
|
|
474
|
+
runtime.processPayload({
|
|
475
|
+
type: 'session.status',
|
|
476
|
+
properties: { sessionID: 'ses-fork-lost', directory: '/repo', status: { type: 'idle' } },
|
|
477
|
+
});
|
|
478
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
479
|
+
|
|
480
|
+
expect(generationCalls).toBe(1);
|
|
481
|
+
expect(session.title).toBe('Different approach');
|
|
482
|
+
runtime.stop();
|
|
483
|
+
} finally {
|
|
484
|
+
globalThis.fetch = originalFetch;
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('does not lazily re-register a fork after its activity already moved past the fork', async () => {
|
|
489
|
+
const originalFetch = globalThis.fetch;
|
|
490
|
+
const forkCreated = 100;
|
|
491
|
+
const session = {
|
|
492
|
+
id: 'ses-fork-advanced',
|
|
493
|
+
title: 'Original work (fork #1)',
|
|
494
|
+
time: { created: forkCreated },
|
|
495
|
+
metadata: {
|
|
496
|
+
openchamber: {
|
|
497
|
+
titleRefresh: {
|
|
498
|
+
// A fork message already advanced activity past the fork time.
|
|
499
|
+
activityUpdatedAt: 300,
|
|
500
|
+
lastAutoTitle: 'Original work',
|
|
501
|
+
generatedAt: 40,
|
|
502
|
+
forMessageID: 'assistant-original',
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
let generationCalls = 0;
|
|
508
|
+
|
|
509
|
+
globalThis.fetch = async (input, init = {}) => {
|
|
510
|
+
if (String(input).includes('/message')) {
|
|
511
|
+
return new Response(JSON.stringify([
|
|
512
|
+
{ info: { id: 'user-x', role: 'user' }, parts: [{ type: 'text', text: 'later message' }] },
|
|
513
|
+
{ info: { id: 'assistant-x', role: 'assistant' }, parts: [{ type: 'text', text: 'later reply' }] },
|
|
514
|
+
]), { status: 200 });
|
|
515
|
+
}
|
|
516
|
+
if (init.method === 'PATCH') {
|
|
517
|
+
const body = typeof init.body === 'string' ? JSON.parse(init.body) : init.body;
|
|
518
|
+
if (body?.metadata) session.metadata = body.metadata;
|
|
519
|
+
return new Response(JSON.stringify(session), { status: 200 });
|
|
520
|
+
}
|
|
521
|
+
return new Response(JSON.stringify(session), { status: 200 });
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
const runtime = createSessionTitleRuntime({
|
|
526
|
+
buildOpenCodeUrl: (pathname) => `http://opencode${pathname}`,
|
|
527
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
528
|
+
getSmallModelService: async () => ({
|
|
529
|
+
generateSmallModelText: async () => {
|
|
530
|
+
generationCalls += 1;
|
|
531
|
+
return { text: 'Should not run', providerID: 'test', modelID: 'test' };
|
|
532
|
+
},
|
|
533
|
+
}),
|
|
534
|
+
now: () => 1_000,
|
|
535
|
+
isTitleRefreshEnabled: () => true,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
runtime.processPayload({
|
|
539
|
+
type: 'message.updated',
|
|
540
|
+
properties: { directory: '/repo', info: { sessionID: 'ses-fork-advanced', role: 'user', id: 'user-x', time: { created: 400 } } },
|
|
541
|
+
});
|
|
542
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
543
|
+
runtime.processPayload({
|
|
544
|
+
type: 'session.status',
|
|
545
|
+
properties: { sessionID: 'ses-fork-advanced', directory: '/repo', status: { type: 'idle' } },
|
|
546
|
+
});
|
|
547
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
548
|
+
|
|
549
|
+
expect(generationCalls).toBe(0);
|
|
550
|
+
runtime.stop();
|
|
551
|
+
} finally {
|
|
552
|
+
globalThis.fetch = originalFetch;
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
413
556
|
it('refreshes a new session on first idle, then ignores later idle transitions', async () => {
|
|
414
557
|
const originalFetch = globalThis.fetch;
|
|
415
558
|
const session = {
|
|
@@ -68,7 +68,7 @@ Success `200`:
|
|
|
68
68
|
- `records`: chronological oldest → newest, including intermediate non-boundary rows (synthetic, tools, subtask, compaction) inside the window.
|
|
69
69
|
- `turnCount`: count of authored user boundaries in `records`.
|
|
70
70
|
- `cursor`: host-owned opaque token for the next `before=` request; `null` when `complete` is true. The token represents the position **just before** the earliest returned authored user (so the client can load older history without overlap).
|
|
71
|
-
- `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, `complete` stays `false` with a `cursor` so the client can fetch
|
|
71
|
+
- `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, or when upstream stopped on a full page with a missing `nextCursor` (suspicious truncation), `complete` stays `false` with a `cursor` so the client can fetch / retry.
|
|
72
72
|
- `partsProjection`: `"slim-v1"` on every turn-page response (first packet and prepend). Names the projection applied to `records`; see below. Reconcile responses omit this field and keep full parts.
|
|
73
73
|
|
|
74
74
|
### Turn-page payload projection
|
|
@@ -147,6 +147,7 @@ A message is an authored user turn boundary when:
|
|
|
147
147
|
|
|
148
148
|
- Upstream pages are chronological **old→new** within each page; **do not reverse**. Older pages are **prepended** while deduping by `info.id` (global order remains old→new).
|
|
149
149
|
- Continues paging with raw upstream `before` until `turns` authored boundaries are collected or upstream reports no next cursor.
|
|
150
|
+
- **Suspicious truncation:** an upstream page whose `records.length >= scanLimit` (full page) but `nextCursor == null` is treated as a non-authoritative cut (observed OpenCode `x-next-cursor` race), not as end-of-history. The host stops scanning that round with `upstreamComplete=false`, so the response stays `complete=false` and still emits a boundary host cursor the client can retry. A short page (`records.length < scanLimit`) with no cursor, an empty page with no cursor, or an explicit `page.complete === true` remain authoritative exhaustion. Client no-growth handling still bounds retries if the upstream view stays truncated.
|
|
150
151
|
- Every upstream record must have a non-empty `info.id`; otherwise structured error, no partial `records`, HTTP 502.
|
|
151
152
|
- Hard scan caps (no partial success): **50 pages** / **5000 messages** → structured error, HTTP 413.
|
|
152
153
|
- Repeated / stalled cursor, or empty page that still carries a next cursor → structured error, no partial `records`, HTTP 502.
|
|
@@ -719,7 +719,14 @@ export const createSessionTurnPageService = ({
|
|
|
719
719
|
|
|
720
720
|
const rawNext = page.nextCursor;
|
|
721
721
|
const nextCursor = typeof rawNext === 'string' && rawNext.length > 0 ? rawNext : null;
|
|
722
|
-
|
|
722
|
+
// Explicit page.complete wins. Otherwise: no nextCursor on a short page
|
|
723
|
+
// is authoritative exhaustion; a *full* page with no nextCursor is a
|
|
724
|
+
// suspicious upstream truncation (x-next-cursor race) — stop scanning
|
|
725
|
+
// but do not mark upstreamComplete so the host still emits a retryable
|
|
726
|
+
// boundary cursor instead of permanently exhausting the client.
|
|
727
|
+
const fullPageNoCursor = nextCursor == null && records.length >= pageLimit;
|
|
728
|
+
const pageComplete = page.complete === true
|
|
729
|
+
|| (nextCursor == null && !fullPageNoCursor);
|
|
723
730
|
|
|
724
731
|
if (records.length === 0) {
|
|
725
732
|
if (nextCursor) {
|
|
@@ -795,6 +802,12 @@ export const createSessionTurnPageService = ({
|
|
|
795
802
|
break;
|
|
796
803
|
}
|
|
797
804
|
|
|
805
|
+
if (fullPageNoCursor) {
|
|
806
|
+
// Suspicious truncation: keep upstreamComplete=false so the response
|
|
807
|
+
// stays complete=false with a host cursor the client can retry.
|
|
808
|
+
break;
|
|
809
|
+
}
|
|
810
|
+
|
|
798
811
|
// Enough authored boundaries collected — stop without requiring exhaustion.
|
|
799
812
|
if (countAuthoredBoundaries(accumulated) >= turnBudget) {
|
|
800
813
|
break;
|
|
@@ -1107,4 +1107,137 @@ describe('createSessionTurnPageService', () => {
|
|
|
1107
1107
|
signal: controller.signal,
|
|
1108
1108
|
}));
|
|
1109
1109
|
});
|
|
1110
|
+
|
|
1111
|
+
describe('full-page missing nextCursor (suspicious upstream truncation)', () => {
|
|
1112
|
+
it('short page without cursor remains authoritative exhaustion', async () => {
|
|
1113
|
+
// records < scanLimit + no cursor → real end of history.
|
|
1114
|
+
const fetchPage = vi.fn(async () => pageResult([
|
|
1115
|
+
user('msg_u1'),
|
|
1116
|
+
assistant('msg_a1'),
|
|
1117
|
+
user('msg_u2'),
|
|
1118
|
+
assistant('msg_a2'),
|
|
1119
|
+
], null));
|
|
1120
|
+
const service = createSessionTurnPageService({ fetchPage });
|
|
1121
|
+
|
|
1122
|
+
const result = await service.loadPage({
|
|
1123
|
+
sessionID: 'ses_1',
|
|
1124
|
+
turns: 3,
|
|
1125
|
+
scanLimit: 100,
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
expect(result).toMatchObject({
|
|
1129
|
+
ok: true,
|
|
1130
|
+
complete: true,
|
|
1131
|
+
cursor: null,
|
|
1132
|
+
turnCount: 2,
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it('full page without cursor stays incomplete and emits a host cursor', async () => {
|
|
1137
|
+
// Upstream race: page is full (records >= scanLimit) but x-next-cursor is
|
|
1138
|
+
// missing. Must not permanently exhaust — client needs a retryable cursor.
|
|
1139
|
+
const records = [];
|
|
1140
|
+
for (let i = 1; i <= 10; i += 1) {
|
|
1141
|
+
records.push(user(`msg_u${i}`), assistant(`msg_a${i}`));
|
|
1142
|
+
}
|
|
1143
|
+
expect(records.length).toBe(20);
|
|
1144
|
+
const fetchPage = vi.fn(async () => pageResult(records, null));
|
|
1145
|
+
const service = createSessionTurnPageService({ fetchPage });
|
|
1146
|
+
|
|
1147
|
+
const result = await service.loadPage({
|
|
1148
|
+
sessionID: 'ses_1',
|
|
1149
|
+
turns: 3,
|
|
1150
|
+
scanLimit: 20,
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
expect(result.ok).toBe(true);
|
|
1154
|
+
expect(result.complete).toBe(false);
|
|
1155
|
+
expect(typeof result.cursor).toBe('string');
|
|
1156
|
+
expect(result.cursor.startsWith('oc1.')).toBe(true);
|
|
1157
|
+
expect(result.turnCount).toBe(3);
|
|
1158
|
+
expect(decodeHostCursor(result.cursor)).toEqual({
|
|
1159
|
+
ok: true,
|
|
1160
|
+
value: { before: null, boundaryID: 'msg_u8' },
|
|
1161
|
+
});
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
it('page with nextCursor keeps paging (unchanged)', async () => {
|
|
1165
|
+
const pages = new Map([
|
|
1166
|
+
[undefined, pageResult([
|
|
1167
|
+
user('msg_u2'),
|
|
1168
|
+
assistant('msg_a2'),
|
|
1169
|
+
user('msg_u3'),
|
|
1170
|
+
assistant('msg_a3'),
|
|
1171
|
+
], 'opaque_older')],
|
|
1172
|
+
['opaque_older', pageResult([
|
|
1173
|
+
user('msg_u1'),
|
|
1174
|
+
assistant('msg_a1'),
|
|
1175
|
+
], null)],
|
|
1176
|
+
]);
|
|
1177
|
+
const fetchPage = vi.fn(async ({ before }) => pages.get(before) ?? pageResult([], null));
|
|
1178
|
+
const service = createSessionTurnPageService({ fetchPage });
|
|
1179
|
+
|
|
1180
|
+
const result = await service.loadPage({
|
|
1181
|
+
sessionID: 'ses_1',
|
|
1182
|
+
turns: 3,
|
|
1183
|
+
scanLimit: 100,
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
expect(result).toMatchObject({
|
|
1187
|
+
ok: true,
|
|
1188
|
+
complete: true,
|
|
1189
|
+
cursor: null,
|
|
1190
|
+
turnCount: 3,
|
|
1191
|
+
});
|
|
1192
|
+
expect(fetchPage.mock.calls.length).toBe(2);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
it('empty page without cursor remains authoritative exhaustion', async () => {
|
|
1196
|
+
const fetchPage = vi.fn(async () => pageResult([], null));
|
|
1197
|
+
const service = createSessionTurnPageService({ fetchPage });
|
|
1198
|
+
|
|
1199
|
+
const result = await service.loadPage({
|
|
1200
|
+
sessionID: 'ses_1',
|
|
1201
|
+
turns: 3,
|
|
1202
|
+
scanLimit: 100,
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
expect(result).toMatchObject({
|
|
1206
|
+
ok: true,
|
|
1207
|
+
complete: true,
|
|
1208
|
+
cursor: null,
|
|
1209
|
+
turnCount: 0,
|
|
1210
|
+
});
|
|
1211
|
+
expect(result.records).toEqual([]);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
it('explicit page.complete=true on a full page without cursor still exhausts', async () => {
|
|
1215
|
+
// Explicit complete declaration outranks the full-page heuristic.
|
|
1216
|
+
// Exactly turns=budget so selectTurnRecords does not trim — complete
|
|
1217
|
+
// stays true solely because upstreamComplete was set by page.complete.
|
|
1218
|
+
const records = [];
|
|
1219
|
+
for (let i = 1; i <= 10; i += 1) {
|
|
1220
|
+
records.push(user(`msg_u${i}`), assistant(`msg_a${i}`));
|
|
1221
|
+
}
|
|
1222
|
+
const fetchPage = vi.fn(async () => ({
|
|
1223
|
+
records,
|
|
1224
|
+
nextCursor: null,
|
|
1225
|
+
complete: true,
|
|
1226
|
+
}));
|
|
1227
|
+
const service = createSessionTurnPageService({ fetchPage });
|
|
1228
|
+
|
|
1229
|
+
const result = await service.loadPage({
|
|
1230
|
+
sessionID: 'ses_1',
|
|
1231
|
+
turns: 10,
|
|
1232
|
+
scanLimit: 20,
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
expect(result).toMatchObject({
|
|
1236
|
+
ok: true,
|
|
1237
|
+
complete: true,
|
|
1238
|
+
cursor: null,
|
|
1239
|
+
turnCount: 10,
|
|
1240
|
+
});
|
|
1241
|
+
});
|
|
1242
|
+
});
|
|
1110
1243
|
});
|
|
@@ -714,7 +714,9 @@ export const createUiAuth = ({
|
|
|
714
714
|
const respondUnauthorized = (req, res) => {
|
|
715
715
|
res.status(401);
|
|
716
716
|
const acceptsJson = req.headers.accept?.includes('application/json');
|
|
717
|
-
|
|
717
|
+
// Express strips the mount prefix from req.path (e.g. app.use('/api') turns
|
|
718
|
+
// /api/mcp into /mcp), so resolve the original pathname instead.
|
|
719
|
+
if (acceptsJson || getRequestPathname(req).startsWith('/api')) {
|
|
718
720
|
res.json({ error: 'UI authentication required', locked: true });
|
|
719
721
|
} else {
|
|
720
722
|
res.type('text/plain').send('Authentication required');
|
|
@@ -259,6 +259,31 @@ describe('ui auth client credential seam', () => {
|
|
|
259
259
|
}
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
it('returns JSON 401 for unauthenticated API requests mounted under a sub-app', async () => {
|
|
263
|
+
const createUiAuth = await loadCreateUiAuth();
|
|
264
|
+
const auth = createUiAuth({ password: 'secret' });
|
|
265
|
+
|
|
266
|
+
// Express strips the mount prefix: app.use('/api') turns /api/mcp into
|
|
267
|
+
// req.path '/mcp'. The unauthorized response must still be identified as an
|
|
268
|
+
// API request (JSON + locked) so clients can react to the auth gate.
|
|
269
|
+
const req = {
|
|
270
|
+
method: 'GET',
|
|
271
|
+
baseUrl: '/api',
|
|
272
|
+
path: '/mcp',
|
|
273
|
+
originalUrl: '/api/mcp',
|
|
274
|
+
url: '/api/mcp',
|
|
275
|
+
headers: {},
|
|
276
|
+
};
|
|
277
|
+
const res = createResponse();
|
|
278
|
+
let called = false;
|
|
279
|
+
await auth.requireAuth(req, res, () => {
|
|
280
|
+
called = true;
|
|
281
|
+
});
|
|
282
|
+
expect(called).toBe(false);
|
|
283
|
+
expect(res.statusCode).toBe(401);
|
|
284
|
+
expect(res.body).toEqual({ error: 'UI authentication required', locked: true });
|
|
285
|
+
});
|
|
286
|
+
|
|
262
287
|
it('issues desktop client tokens with the UI session expiry', async () => {
|
|
263
288
|
const createUiAuth = await loadCreateUiAuth();
|
|
264
289
|
let createClientInput = null;
|