mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 22:12:31 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f42e870e74 | ||
|
|
24c2e6beff | ||
|
|
c59ea270e2 | ||
|
|
9b29c115df |
No files matched your search
@@ -99,7 +99,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
|
||||
'ai.providers.advancedParams.default': 'Provider default',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh and max are only sent when the selected model supports them; otherwise they are clamped to the deepest supported level.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -661,10 +660,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': 'Chat Shortcuts',
|
||||
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
|
||||
'ai.chatHistory.title': 'Chat History',
|
||||
'ai.chatHistory.messageCap': 'Messages kept per chat',
|
||||
'ai.chatHistory.messageCap.description': 'Maximum number of messages stored per chat (50–2000). Lowering it drops the oldest messages from storage on the next write. Larger values increase storage and cloud-sync size.',
|
||||
'ai.chatHistory.messageCap.unit': 'messages',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.directory.open': 'Current directory: {path}. Change directory',
|
||||
'terminal.directory.unknown': 'Directory unknown',
|
||||
'terminal.directory.search': 'Search child directories...',
|
||||
'terminal.directory.directories': 'Directories',
|
||||
'terminal.directory.parent': '.. (Parent directory)',
|
||||
'terminal.directory.loading': 'Reading directories...',
|
||||
'terminal.directory.empty': 'No child directories',
|
||||
'terminal.directory.noMatches': 'No matching directories',
|
||||
'terminal.directory.hidden': 'Show hidden directories',
|
||||
'terminal.directory.refresh': 'Refresh',
|
||||
'terminal.directory.unavailable': 'Directory navigation is unavailable for this session.',
|
||||
'terminal.directory.busy': 'Wait for an empty shell prompt.',
|
||||
'terminal.directory.readFailed': 'Could not read this directory. Try refreshing.',
|
||||
'terminal.directory.changeFailed': 'Could not confirm the directory change. Refresh before retrying.',
|
||||
'terminal.directory.unsupportedPath': 'This directory name cannot be sent safely to the terminal.',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||
'settings.terminal.behavior.contextChips.desc': 'Show the context toolbar below quick commands in the compose bar.',
|
||||
|
||||
'terminal.toolbar.openSessionLog': 'Open session log',
|
||||
'terminal.toolbar.sessionLogActions': 'Session log actions',
|
||||
'terminal.toolbar.sessionLog.menu': 'Session log',
|
||||
|
||||
@@ -90,7 +90,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh y max solo se envían cuando el modelo elegido los admite; de lo contrario se ajustan al nivel más profundo compatible.',
|
||||
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
|
||||
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
|
||||
'ai.providers.headers.name': 'Nombre de la cabecera',
|
||||
@@ -661,10 +660,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': 'Atajos de chat',
|
||||
'ai.chatShortcuts.selectionAction': 'Mostrar "Agregar a la conversación" al seleccionar texto de la terminal',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Muestra un pequeño botón de IA junto al texto seleccionado de la terminal.',
|
||||
'ai.chatHistory.title': 'Historial de chat',
|
||||
'ai.chatHistory.messageCap': 'Mensajes conservados por chat',
|
||||
'ai.chatHistory.messageCap.description': 'Número máximo de mensajes almacenados por chat (50–2000). Al reducirlo, los mensajes más antiguos se descartan del almacenamiento en la siguiente escritura. Los valores más altos aumentan el almacenamiento y el tamaño de la sincronización en la nube.',
|
||||
'ai.chatHistory.messageCap.unit': 'mensajes',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Los controladores del proceso principal de Codex aún no están cargados. Reinicia Netcatty por completo o reinicia el proceso de desarrollo de Electron y vuelve a intentarlo.',
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const esTerminalMessages: Messages = {
|
||||
'terminal.directory.open': 'Directorio actual: {path}. Cambiar directorio',
|
||||
'terminal.directory.unknown': 'Directorio desconocido',
|
||||
'terminal.directory.search': 'Buscar subdirectorios...',
|
||||
'terminal.directory.directories': 'Directorios',
|
||||
'terminal.directory.parent': '.. (Directorio padre)',
|
||||
'terminal.directory.loading': 'Leyendo directorios...',
|
||||
'terminal.directory.empty': 'Sin subdirectorios',
|
||||
'terminal.directory.noMatches': 'Sin directorios coincidentes',
|
||||
'terminal.directory.hidden': 'Mostrar directorios ocultos',
|
||||
'terminal.directory.refresh': 'Actualizar',
|
||||
'terminal.directory.unavailable': 'La navegación de directorios no está disponible para esta sesión.',
|
||||
'terminal.directory.busy': 'Espera a que el prompt esté vacío.',
|
||||
'terminal.directory.readFailed': 'No se pudo leer el directorio. Actualiza e inténtalo de nuevo.',
|
||||
'terminal.directory.changeFailed': 'No se pudo confirmar el cambio. Actualiza antes de reintentar.',
|
||||
'terminal.directory.unsupportedPath': 'No se puede enviar este nombre de directorio de forma segura.',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||
'settings.terminal.behavior.contextChips.desc': 'Mostrar la barra de contexto debajo de los comandos rápidos.',
|
||||
|
||||
'terminal.toolbar.openSessionLog': 'Abrir registro de sesión',
|
||||
'terminal.toolbar.sessionLogActions': 'Acciones del registro',
|
||||
'terminal.toolbar.sessionLog.menu': 'Registro de sesión',
|
||||
|
||||
@@ -71,7 +71,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Оставьте пустым, чтобы использовать настройки провайдера по умолчанию.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'например, 4096',
|
||||
'ai.providers.advancedParams.default': 'По умолчанию у провайдера',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': 'xhigh и max отправляются только если выбранная модель их поддерживает; иначе значение понижается до максимального поддерживаемого уровня.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -600,10 +599,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': 'Быстрые действия чата',
|
||||
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
|
||||
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
|
||||
'ai.chatHistory.title': 'История чата',
|
||||
'ai.chatHistory.messageCap': 'Сообщений, сохраняемых в чате',
|
||||
'ai.chatHistory.messageCap.description': 'Максимальное число сообщений, хранимых для одного чата (50–2000). При уменьшении самые старые сообщения удаляются из хранилища при следующей записи. Большие значения увеличивают объём хранилища и синхронизации с облаком.',
|
||||
'ai.chatHistory.messageCap.unit': 'сообщений',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.directory.open': 'Текущий каталог: {path}. Сменить каталог',
|
||||
'terminal.directory.unknown': 'Каталог неизвестен',
|
||||
'terminal.directory.search': 'Поиск подкаталогов...',
|
||||
'terminal.directory.directories': 'Каталоги',
|
||||
'terminal.directory.parent': '.. (Родительский каталог)',
|
||||
'terminal.directory.loading': 'Чтение каталогов...',
|
||||
'terminal.directory.empty': 'Нет подкаталогов',
|
||||
'terminal.directory.noMatches': 'Нет подходящих каталогов',
|
||||
'terminal.directory.hidden': 'Показывать скрытые каталоги',
|
||||
'terminal.directory.refresh': 'Обновить',
|
||||
'terminal.directory.unavailable': 'Навигация по каталогам недоступна для этого сеанса.',
|
||||
'terminal.directory.busy': 'Дождитесь пустой командной строки.',
|
||||
'terminal.directory.readFailed': 'Не удалось прочитать каталог. Попробуйте обновить.',
|
||||
'terminal.directory.changeFailed': 'Не удалось подтвердить смену каталога. Обновите перед повтором.',
|
||||
'terminal.directory.unsupportedPath': 'Это имя каталога нельзя безопасно передать в терминал.',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips',
|
||||
'settings.terminal.behavior.contextChips.desc': 'Показывать панель контекста под быстрыми командами.',
|
||||
|
||||
'terminal.toolbar.openSessionLog': 'Открыть журнал сессии',
|
||||
'terminal.toolbar.sessionLogActions': 'Действия с журналом',
|
||||
'terminal.toolbar.sessionLog.menu': 'Журнал сессии',
|
||||
|
||||
@@ -99,7 +99,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '提供商默认',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': '仅当所选模型支持时才会发送 xhigh 和 max,否则自动收敛到该模型支持的最高档位。',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -661,10 +660,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
|
||||
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
|
||||
'ai.chatHistory.title': '聊天历史',
|
||||
'ai.chatHistory.messageCap': '每个会话保留的消息数',
|
||||
'ai.chatHistory.messageCap.description': '每个会话最多存储的消息条数(50–2000)。调低后会在下一次写入时从存储中丢弃最旧的消息。数值越大,存储占用和云同步体积也越大。',
|
||||
'ai.chatHistory.messageCap.unit': '条消息',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.directory.open': '当前目录:{path}。更改目录',
|
||||
'terminal.directory.unknown': '目录未知',
|
||||
'terminal.directory.search': '搜索当前目录下的文件夹…',
|
||||
'terminal.directory.directories': '目录',
|
||||
'terminal.directory.parent': '..(父目录)',
|
||||
'terminal.directory.loading': '正在读取目录…',
|
||||
'terminal.directory.empty': '没有子目录',
|
||||
'terminal.directory.noMatches': '没有匹配的子目录',
|
||||
'terminal.directory.hidden': '显示隐藏目录',
|
||||
'terminal.directory.refresh': '刷新',
|
||||
'terminal.directory.unavailable': '此会话暂不支持目录导航。',
|
||||
'terminal.directory.busy': '请等待终端回到空命令提示符。',
|
||||
'terminal.directory.readFailed': '无法读取此目录,请刷新重试。',
|
||||
'terminal.directory.changeFailed': '未能确认目录切换,请刷新后重试。',
|
||||
'terminal.directory.unsupportedPath': '此目录名称暂不支持快捷切换。',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具栏',
|
||||
'settings.terminal.behavior.contextChips.desc': '在撰写栏的快捷命令下方显示上下文工具栏。',
|
||||
|
||||
'terminal.toolbar.openSessionLog': '打开会话日志',
|
||||
'terminal.toolbar.sessionLogActions': '会话日志操作',
|
||||
'terminal.toolbar.sessionLog.menu': '会话日志',
|
||||
|
||||
@@ -90,7 +90,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': '留空則使用供應商預設值。',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
|
||||
'ai.providers.advancedParams.default': '供應商預設',
|
||||
'ai.providers.advancedParams.reasoningEffort.help': '僅當所選模型支援時才會送出 xhigh 與 max,否則自動收斂到該模型支援的最高檔位。',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -651,10 +650,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chatShortcuts.title': '聊天快捷入口',
|
||||
'ai.chatShortcuts.selectionAction': '選取終端內容時顯示「新增到對話」',
|
||||
'ai.chatShortcuts.selectionAction.description': '在終端裡選取文字後顯示 AI 快捷按鈕。',
|
||||
'ai.chatHistory.title': '聊天歷史',
|
||||
'ai.chatHistory.messageCap': '每個會話保留的訊息數',
|
||||
'ai.chatHistory.messageCap.description': '每個會話最多儲存的訊息數(50–2000)。調低後會在下一次寫入時從儲存中捨棄最舊的訊息。數值越大,儲存空間與雲端同步體積也越大。',
|
||||
'ai.chatHistory.messageCap.unit': '則訊息',
|
||||
|
||||
// AI Error
|
||||
'ai.codex.bridgeError': 'Codex 主程序處理器尚未載入。請完全重新啟動 Netcatty 或重新啟動 Electron 開發程序,然後重試。',
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhTWTerminalMessages: Messages = {
|
||||
'terminal.directory.open': '目前目錄:{path}。變更目錄',
|
||||
'terminal.directory.unknown': '目錄未知',
|
||||
'terminal.directory.search': '搜尋目前目錄下的資料夾…',
|
||||
'terminal.directory.directories': '目錄',
|
||||
'terminal.directory.parent': '..(父目錄)',
|
||||
'terminal.directory.loading': '正在讀取目錄…',
|
||||
'terminal.directory.empty': '沒有子目錄',
|
||||
'terminal.directory.noMatches': '沒有符合的子目錄',
|
||||
'terminal.directory.hidden': '顯示隱藏目錄',
|
||||
'terminal.directory.refresh': '重新整理',
|
||||
'terminal.directory.unavailable': '此工作階段暫不支援目錄導覽。',
|
||||
'terminal.directory.busy': '請等待終端回到空白命令提示字元。',
|
||||
'terminal.directory.readFailed': '無法讀取此目錄,請重新整理後重試。',
|
||||
'terminal.directory.changeFailed': '未能確認目錄切換,請重新整理後重試。',
|
||||
'terminal.directory.unsupportedPath': '此目錄名稱暫不支援快速切換。',
|
||||
'settings.terminal.behavior.contextChips': 'Context Chips 上下文工具列',
|
||||
'settings.terminal.behavior.contextChips.desc': '在撰寫列的快捷命令下方顯示上下文工具列。',
|
||||
|
||||
'terminal.toolbar.openSessionLog': '開啟工作階段日誌',
|
||||
'terminal.toolbar.sessionLogActions': '工作階段日誌操作',
|
||||
'terminal.toolbar.sessionLog.menu': '工作階段日誌',
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
|
||||
import {
|
||||
AI_SESSIONS_META_LAST_WRITE_KEY,
|
||||
AI_SESSIONS_META_MIGRATION_KEY,
|
||||
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
|
||||
idbClearSessions,
|
||||
idbDeleteSessionIds,
|
||||
idbGetAllSessions,
|
||||
idbGetMeta,
|
||||
idbPutMeta,
|
||||
idbPutSession,
|
||||
idbPutSessions,
|
||||
isIndexedDbAvailable,
|
||||
loadOrMigrateAiSessions,
|
||||
mirrorActiveSessionMapToIdb,
|
||||
openAiSessionsIdb,
|
||||
readActiveSessionMapFromIdb,
|
||||
resetAiSessionsIdbForTests,
|
||||
} from './aiSessionIdbStorage';
|
||||
|
||||
function makeSession(id: string, updatedAt: number): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'agent',
|
||||
scope: { type: 'terminal', targetId: 't' },
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as AISession;
|
||||
}
|
||||
|
||||
test('module wrapper roundtrips sessions through the object stores', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
assert.equal(isIndexedDbAvailable(), true);
|
||||
const db = await openAiSessionsIdb();
|
||||
assert.equal(db.objectStoreNames.contains('sessions'), true);
|
||||
assert.equal(db.objectStoreNames.contains('meta'), true);
|
||||
|
||||
await idbPutSessions(db, [makeSession('chat-a', 2), makeSession('chat-b', 1)]);
|
||||
await idbPutSession(db, { ...makeSession('chat-a', 3), title: 'updated' });
|
||||
const all = await idbGetAllSessions(db);
|
||||
assert.deepEqual(all.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
|
||||
assert.equal(all.find((s) => s.id === 'chat-a')?.title, 'updated');
|
||||
|
||||
await idbDeleteSessionIds(db, ['chat-a']);
|
||||
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['chat-b']);
|
||||
await idbClearSessions(db);
|
||||
assert.deepEqual(await idbGetAllSessions(db), []);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('meta store roundtrips schema and active-map records', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, 1);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, 1234);
|
||||
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
|
||||
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY), 1234);
|
||||
assert.equal(await idbGetMeta<number>(db, 'missing'), undefined);
|
||||
|
||||
await mirrorActiveSessionMapToIdb({ 'terminal:t': 'chat-a' });
|
||||
// Mirror uses its own connection.
|
||||
assert.deepEqual(await readActiveSessionMapFromIdb(), { 'terminal:t': 'chat-a' });
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions reads IndexedDB first and returns its sessions', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutSessions(db, [makeSession('idb-chat', 1)]);
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy-chat', 2)]));
|
||||
assert.equal(result.source, 'indexeddb');
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.deepEqual(result.sessions.map((s) => s.id), ['idb-chat']);
|
||||
// Legacy key content was not consumed for a migration when IDB has data.
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 1);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions migrates a populated legacy store into empty IndexedDB, keeping the key untouched', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
|
||||
let readCount = 0;
|
||||
const result = await loadOrMigrateAiSessions(() => {
|
||||
readCount += 1;
|
||||
return legacyRaw;
|
||||
});
|
||||
assert.equal(result.source, 'legacy-localstorage');
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.deepEqual(result.sessions.map((s) => s.id).sort(), ['legacy-1', 'legacy-2']);
|
||||
// Now in IndexedDB (one record per session).
|
||||
assert.deepEqual(
|
||||
idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort(),
|
||||
['legacy-1', 'legacy-2'],
|
||||
);
|
||||
// Meta records written for the migration.
|
||||
const db = await openAiSessionsIdb();
|
||||
assert.equal(await idbGetMeta<number>(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY), 1);
|
||||
assert.ok(await idbGetMeta<number>(db, AI_SESSIONS_META_LAST_WRITE_KEY));
|
||||
assert.ok(readCount >= 1);
|
||||
// The legacy raw string itself is only read, never rewritten or removed
|
||||
// — the callback is the only interface to the legacy store.
|
||||
assert.equal(readCount >= 1, true);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions returns none for an empty store without legacy data', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const result = await loadOrMigrateAiSessions(() => null);
|
||||
assert.equal(result.source, 'none');
|
||||
assert.deepEqual(result.sessions, []);
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.equal(result.authoritativeEmpty, false, 'a newly created store still permits disk recovery');
|
||||
assert.equal(result.migrationPending, false);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions treats a legacy empty array as an intentional wipe (no migration, no restore source)', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const result = await loadOrMigrateAiSessions(() => '[]');
|
||||
assert.equal(result.source, 'none');
|
||||
assert.deepEqual(result.sessions, []);
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 0);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is corrupt', async () => {
|
||||
const idb = installFakeIndexedDb({ failOpen: true });
|
||||
try {
|
||||
const legacy = [makeSession('legacy-chat', 1)];
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
|
||||
assert.equal(result.source, 'legacy-localstorage');
|
||||
assert.equal(result.idbFailed, true);
|
||||
assert.deepEqual(result.sessions, legacy);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('loadOrMigrateAiSessions falls back to legacy localStorage when IndexedDB is unavailable', async () => {
|
||||
const previous = (globalThis as { indexedDB?: unknown }).indexedDB;
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = undefined;
|
||||
try {
|
||||
const legacy = [makeSession('legacy-chat', 1)];
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify(legacy));
|
||||
assert.equal(isIndexedDbAvailable(), false);
|
||||
assert.equal(result.source, 'legacy-localstorage');
|
||||
assert.equal(result.idbFailed, false);
|
||||
assert.deepEqual(result.sessions, legacy);
|
||||
} finally {
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = previous;
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Issue #4125 review P1②: the migration is one-shot via a persisted marker ──
|
||||
|
||||
test('a completed migration is one-shot: a chat deleted afterwards stays deleted on the next boot', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const legacyRaw = JSON.stringify([makeSession('legacy-1', 2), makeSession('legacy-2', 1)]);
|
||||
const first = await loadOrMigrateAiSessions(() => legacyRaw);
|
||||
assert.equal(first.source, 'legacy-localstorage');
|
||||
// The marker was persisted after the migration data became durable.
|
||||
let db = await openAiSessionsIdb();
|
||||
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||
// The user deletes a chat; the legacy key stays behind by design.
|
||||
await idbDeleteSessionIds(db, ['legacy-1']);
|
||||
// Next boot: the marker is present, so the legacy key is never
|
||||
// re-migrated and the deletion is not resurrected.
|
||||
const second = await loadOrMigrateAiSessions(() => legacyRaw);
|
||||
assert.equal(second.source, 'indexeddb');
|
||||
assert.deepEqual(second.sessions.map((s) => s.id), ['legacy-2']);
|
||||
assert.equal((await openAiSessionsIdb()).objectStoreNames.contains('sessions'), true);
|
||||
db = await openAiSessionsIdb();
|
||||
assert.deepEqual((await idbGetAllSessions(db)).map((s) => s.id), ['legacy-2']);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('an empty IndexedDB store with the migration marker ignores a populated legacy key', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
// First boot with nothing anywhere records the marker.
|
||||
const first = await loadOrMigrateAiSessions(() => null);
|
||||
assert.equal(first.source, 'none');
|
||||
assert.ok(await idbGetMeta(await openAiSessionsIdb(), AI_SESSIONS_META_MIGRATION_KEY));
|
||||
// The user deleted every chat, but the stale legacy key is still there.
|
||||
const second = await loadOrMigrateAiSessions(
|
||||
() => JSON.stringify([makeSession('resurrected', 1)]),
|
||||
);
|
||||
assert.equal(second.source, 'none');
|
||||
assert.equal(second.authoritativeEmpty, true);
|
||||
assert.deepEqual(second.sessions, []);
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 0);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('partial disk migration retries missing IDs beside existing IDB records until all are durable', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const primary = makeSession('primary', 3);
|
||||
const diskA = makeSession('disk-a', 2);
|
||||
const diskB = makeSession('disk-b', 1);
|
||||
const diskIds = [diskA.id, diskB.id];
|
||||
const db = await openAiSessionsIdb();
|
||||
const first = await loadOrMigrateAiSessions(() => JSON.stringify([primary]), diskIds);
|
||||
assert.equal(first.migrationPending, true);
|
||||
assert.deepEqual(await idbGetAllSessions(db), [primary]);
|
||||
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
|
||||
|
||||
const updated = { ...primary, title: 'edited in IndexedDB', updatedAt: 4 };
|
||||
await idbPutSession(db, updated);
|
||||
const partial = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA]), diskIds);
|
||||
assert.equal(partial.migrationPending, true, 'one recovered disk ID does not complete migration');
|
||||
assert.deepEqual(partial.sessions, [updated, diskA]);
|
||||
assert.equal(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY), undefined);
|
||||
|
||||
const complete = await loadOrMigrateAiSessions(() => JSON.stringify([primary, diskA, diskB]), diskIds);
|
||||
assert.equal(complete.source, 'indexeddb');
|
||||
assert.equal(complete.migrationPending, false);
|
||||
assert.deepEqual(complete.sessions, [updated, diskA, diskB]);
|
||||
assert.deepEqual(await idbGetAllSessions(db), [updated, diskA, diskB]);
|
||||
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||
|
||||
await idbClearSessions(db);
|
||||
const deleted = await loadOrMigrateAiSessions(() => {
|
||||
assert.fail('completed migration must not retry the stale overflow markers');
|
||||
}, diskIds);
|
||||
assert.equal(deleted.authoritativeEmpty, true);
|
||||
assert.deepEqual(deleted.sessions, []);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pre-marker store that already holds sessions records the marker on boot', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutSessions(db, [makeSession('already-here', 1)]);
|
||||
const result = await loadOrMigrateAiSessions(() => JSON.stringify([makeSession('legacy', 2)]));
|
||||
assert.equal(result.source, 'indexeddb');
|
||||
assert.ok(await idbGetMeta(db, AI_SESSIONS_META_MIGRATION_KEY));
|
||||
assert.equal(idb.db.dumpStore('sessions').length, 1);
|
||||
} finally {
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
@@ -1,372 +0,0 @@
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
|
||||
/**
|
||||
* IndexedDB persistence for AI chat sessions (issue #4123).
|
||||
*
|
||||
* The legacy store kept every session as one JSON blob in localStorage under
|
||||
* a 2 MB byte budget. Each write re-serialized the whole store, and when the
|
||||
* budget was exceeded the degradation ladder dropped whole oldest sessions —
|
||||
* silently destroying capped chats (~1 MB each) on every write.
|
||||
*
|
||||
* IndexedDB removes both halves of that failure mode: records are written
|
||||
* per session (no global re-serialization), and there is no shared byte
|
||||
* quota to defend, so byte-driven eviction disappears entirely.
|
||||
*
|
||||
* Layout:
|
||||
* - db `netcatty_ai_sessions`
|
||||
* - object store `sessions` (keyPath `id`): one record per AISession
|
||||
* - object store `meta` (keyPath `key`): schemaVersion, lastWriteAt and the
|
||||
* migrated `netcatty_ai_active_session_map_v1` active-pointer map
|
||||
*
|
||||
* The legacy localStorage key `netcatty_ai_sessions_v1` is kept UNTOUCHED for
|
||||
* one release as a read-only fallback; it is no longer written or pruned.
|
||||
*/
|
||||
|
||||
export const AI_SESSIONS_IDB_NAME = 'netcatty_ai_sessions';
|
||||
export const AI_SESSIONS_IDB_VERSION = 1;
|
||||
export const AI_SESSIONS_IDB_SESSION_STORE = 'sessions';
|
||||
export const AI_SESSIONS_IDB_META_STORE = 'meta';
|
||||
export const AI_SESSIONS_META_SCHEMA_VERSION_KEY = 'schemaVersion';
|
||||
export const AI_SESSIONS_META_LAST_WRITE_KEY = 'lastWriteAt';
|
||||
export const AI_SESSIONS_META_ACTIVE_MAP_KEY = 'activeSessionMap';
|
||||
export const AI_SESSIONS_META_MIGRATION_KEY = 'migrationCompleted';
|
||||
|
||||
/** Cross-window change notification (replaces the localStorage `storage` event for sessions). */
|
||||
const AI_SESSIONS_IDB_CHANGE_CHANNEL = 'netcatty_ai_sessions_changed';
|
||||
|
||||
export function isIndexedDbAvailable(): boolean {
|
||||
return typeof indexedDB !== 'undefined' && indexedDB !== null;
|
||||
}
|
||||
|
||||
function requestToPromise<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'));
|
||||
});
|
||||
}
|
||||
|
||||
function transactionDone(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'));
|
||||
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'));
|
||||
});
|
||||
}
|
||||
|
||||
let openPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
function upgradeDatabase(db: IDBDatabase) {
|
||||
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_SESSION_STORE)) {
|
||||
db.createObjectStore(AI_SESSIONS_IDB_SESSION_STORE, { keyPath: 'id' });
|
||||
}
|
||||
if (!db.objectStoreNames.contains(AI_SESSIONS_IDB_META_STORE)) {
|
||||
db.createObjectStore(AI_SESSIONS_IDB_META_STORE, { keyPath: 'key' });
|
||||
}
|
||||
}
|
||||
|
||||
export function openAiSessionsIdb(): Promise<IDBDatabase> {
|
||||
if (!isIndexedDbAvailable()) {
|
||||
return Promise.reject(new Error('IndexedDB is not available'));
|
||||
}
|
||||
if (!openPromise) {
|
||||
openPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(AI_SESSIONS_IDB_NAME, AI_SESSIONS_IDB_VERSION);
|
||||
request.onupgradeneeded = () => upgradeDatabase(request.result);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
// Drop the cached handle so a later open() re-establishes it instead
|
||||
// of handing out a closed connection.
|
||||
db.onclose = () => { openPromise = null; };
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
openPromise = null;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => {
|
||||
openPromise = null;
|
||||
reject(request.error ?? new Error('Failed to open IndexedDB'));
|
||||
};
|
||||
request.onblocked = () => {
|
||||
openPromise = null;
|
||||
};
|
||||
});
|
||||
}
|
||||
return openPromise;
|
||||
}
|
||||
|
||||
export function resetAiSessionsIdbForTests() {
|
||||
openPromise = null;
|
||||
}
|
||||
|
||||
export async function idbGetAllSessions(db?: IDBDatabase): Promise<AISession[]> {
|
||||
const database = db ?? await openAiSessionsIdb();
|
||||
const transaction = database.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readonly');
|
||||
const result = await requestToPromise(
|
||||
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).getAll() as IDBRequest<AISession[]>,
|
||||
);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
export async function idbPutSessions(db: IDBDatabase, sessions: AISession[]): Promise<void> {
|
||||
if (sessions.length === 0) return;
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
|
||||
for (const session of sessions) store.put(session);
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function idbPutSession(db: IDBDatabase, session: AISession): Promise<void> {
|
||||
await idbPutSessions(db, [session]);
|
||||
}
|
||||
|
||||
export async function idbDeleteSessionIds(db: IDBDatabase, ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||
const store = transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE);
|
||||
for (const id of ids) store.delete(id);
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function idbClearSessions(db: IDBDatabase): Promise<void> {
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_SESSION_STORE, 'readwrite');
|
||||
transaction.objectStore(AI_SESSIONS_IDB_SESSION_STORE).clear();
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
export async function idbGetMeta<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readonly');
|
||||
const record = await requestToPromise(
|
||||
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).get(key) as IDBRequest<{ key: string; value: T } | undefined>,
|
||||
);
|
||||
return record?.value;
|
||||
}
|
||||
|
||||
export async function idbPutMeta<T>(db: IDBDatabase, key: string, value: T): Promise<void> {
|
||||
const transaction = db.transaction(AI_SESSIONS_IDB_META_STORE, 'readwrite');
|
||||
transaction.objectStore(AI_SESSIONS_IDB_META_STORE).put({ key, value });
|
||||
await transactionDone(transaction);
|
||||
}
|
||||
|
||||
// ── Cross-window change notification ──
|
||||
|
||||
let changeChannel: BroadcastChannel | null = null;
|
||||
|
||||
function getChangeChannel(): BroadcastChannel | null {
|
||||
if (typeof BroadcastChannel === 'undefined') return null;
|
||||
try {
|
||||
if (!changeChannel) {
|
||||
changeChannel = new BroadcastChannel(AI_SESSIONS_IDB_CHANGE_CHANNEL);
|
||||
// The channel must never keep the process alive (Node exposes unref).
|
||||
(changeChannel as unknown as { unref?: () => void }).unref?.();
|
||||
}
|
||||
return changeChannel;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Announce a session-store change to OTHER windows (never the sender). */
|
||||
export function publishAiSessionsIdbChange(): void {
|
||||
try {
|
||||
getChangeChannel()?.postMessage(Date.now());
|
||||
} catch {
|
||||
// Notification is best-effort; the write itself already succeeded.
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeAiSessionsIdbChange(listener: () => void): () => void {
|
||||
const channel = getChangeChannel();
|
||||
if (!channel) return () => {};
|
||||
const handler = () => listener();
|
||||
channel.addEventListener('message', handler);
|
||||
return () => channel.removeEventListener('message', handler);
|
||||
}
|
||||
|
||||
// ── Boot load + one-release legacy migration ──
|
||||
|
||||
export interface AiSessionsBootLoadResult {
|
||||
sessions: AISession[];
|
||||
/** Where the returned sessions came from. */
|
||||
source: 'indexeddb' | 'legacy-localstorage' | 'none';
|
||||
/** True when IndexedDB itself could not be opened (corrupt/blocked). */
|
||||
idbFailed: boolean;
|
||||
/** The store was already initialized before this boot; empty means deleted. */
|
||||
authoritativeEmpty: boolean;
|
||||
/** Marked disk-only records are still missing; keep their backup generations. */
|
||||
migrationPending: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot-time store resolution, shared with tests:
|
||||
* 1. IndexedDB `getAll()` — the primary store.
|
||||
* 2. If empty, migrate the legacy localStorage store into IndexedDB (the
|
||||
* legacy key itself stays untouched as a read-only fallback for one
|
||||
* release).
|
||||
* 3. If IndexedDB is corrupt/unavailable, fall back to legacy localStorage.
|
||||
*
|
||||
* The migration is one-shot (issue #4125 review P1②): a `migrationCompleted`
|
||||
* marker is persisted in the meta store after the first successful boot
|
||||
* resolution. Once present, the legacy key is never consulted again —
|
||||
* otherwise a boot whose migration ran while the legacy key could not be
|
||||
* removed would re-migrate the stale legacy list and resurrect deleted
|
||||
* chats. The marker also distinguishes "intentionally empty" (the user
|
||||
* deleted every chat) from "uninitialized".
|
||||
*
|
||||
* Callers decide what to do when both sources are empty (the #4018 backup
|
||||
* generations remain the last restore step in useAIState).
|
||||
* The legacy reader may await disk-only recovery before migration completes.
|
||||
* Missing disk-only IDs keep migration resumable, including in a populated IDB.
|
||||
*/
|
||||
export async function loadOrMigrateAiSessions(
|
||||
readLegacyRaw: () => string | null | undefined | Promise<string | null | undefined>,
|
||||
diskOnlySessionIds: readonly string[] = [],
|
||||
): Promise<AiSessionsBootLoadResult> {
|
||||
const hasUnresolvedDiskSessions = (sessions: AISession[]) => {
|
||||
const ids = new Set(sessions.map(session => session.id));
|
||||
return diskOnlySessionIds.some(id => !ids.has(id));
|
||||
};
|
||||
const legacyFallback = async (idbFailed: boolean): Promise<AiSessionsBootLoadResult> => {
|
||||
const legacy = parseLegacySessions(await readLegacyRaw());
|
||||
return {
|
||||
sessions: legacy,
|
||||
source: legacy.length > 0 ? 'legacy-localstorage' : 'none',
|
||||
idbFailed,
|
||||
authoritativeEmpty: false,
|
||||
migrationPending: hasUnresolvedDiskSessions(legacy),
|
||||
};
|
||||
};
|
||||
if (!isIndexedDbAvailable()) {
|
||||
return legacyFallback(false);
|
||||
}
|
||||
let db: IDBDatabase;
|
||||
try {
|
||||
db = await openAiSessionsIdb();
|
||||
} catch (error) {
|
||||
console.warn('[AIState] IndexedDB unavailable, falling back to the legacy localStorage store:', error);
|
||||
return legacyFallback(true);
|
||||
}
|
||||
let sessions: AISession[];
|
||||
try {
|
||||
sessions = await idbGetAllSessions(db);
|
||||
} catch (error) {
|
||||
console.warn('[AIState] IndexedDB read failed, falling back to the legacy localStorage store:', error);
|
||||
return legacyFallback(true);
|
||||
}
|
||||
let marker: AiSessionsMigrationMarker | undefined;
|
||||
try {
|
||||
marker = await idbGetMeta<AiSessionsMigrationMarker>(db, AI_SESSIONS_META_MIGRATION_KEY);
|
||||
} catch {
|
||||
marker = undefined;
|
||||
}
|
||||
if (marker || (sessions.length > 0 && !hasUnresolvedDiskSessions(sessions))) {
|
||||
if (!marker) {
|
||||
// First boot on a store that already holds sessions (e.g. migrated by
|
||||
// a build without the marker): record one-shot completion so the
|
||||
// legacy key can never re-seed the store.
|
||||
await writeMigrationMarker(db).catch(() => {});
|
||||
}
|
||||
return {
|
||||
sessions,
|
||||
source: sessions.length > 0 ? 'indexeddb' : 'none',
|
||||
idbFailed: false,
|
||||
authoritativeEmpty: sessions.length === 0,
|
||||
migrationPending: false,
|
||||
};
|
||||
}
|
||||
// Unfinished migration (empty or partially populated IDB). The legacy key is
|
||||
// intentionally NOT removed: it stays as a read-only fallback for one
|
||||
// release.
|
||||
const legacy = parseLegacySessions(await readLegacyRaw());
|
||||
// A partial migration may already have primary/chunk records in IDB. Only
|
||||
// fill missing marked IDs; never replay stale primary data over live IDB.
|
||||
const existingIds = new Set(sessions.map(session => session.id));
|
||||
const diskOnlyIds = new Set(diskOnlySessionIds);
|
||||
const recovered = sessions.length === 0
|
||||
? legacy
|
||||
: legacy.filter(session => diskOnlyIds.has(session.id) && !existingIds.has(session.id));
|
||||
const merged = [...sessions, ...recovered].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
const migrationPending = hasUnresolvedDiskSessions(merged);
|
||||
const result: AiSessionsBootLoadResult = {
|
||||
sessions: merged,
|
||||
source: sessions.length > 0 ? 'indexeddb' : merged.length > 0 ? 'legacy-localstorage' : 'none',
|
||||
idbFailed: false,
|
||||
authoritativeEmpty: false,
|
||||
migrationPending,
|
||||
};
|
||||
if (merged.length === 0) {
|
||||
// Nothing to migrate: mark the store initialized so this state is
|
||||
// distinguishable from "uninitialized" on the next boot.
|
||||
if (!migrationPending) await writeMigrationMarker(db).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
await idbPutSessions(db, recovered);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
|
||||
// Written AFTER the migration data is durable: a crash before this point
|
||||
// leaves the marker absent and the next boot safely re-migrates.
|
||||
if (!migrationPending) await writeMigrationMarker(db);
|
||||
} catch (error) {
|
||||
console.warn('[AIState] IndexedDB migration write failed; keeping legacy store authoritative:', error);
|
||||
return { ...result, idbFailed: true };
|
||||
}
|
||||
console.info(`[AIState] Migrated ${recovered.length} AI session(s) from localStorage to IndexedDB.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
interface AiSessionsMigrationMarker {
|
||||
migrationCompleted: true;
|
||||
migrationAt: number;
|
||||
}
|
||||
|
||||
async function writeMigrationMarker(db: IDBDatabase): Promise<void> {
|
||||
await idbPutMeta<AiSessionsMigrationMarker>(
|
||||
db,
|
||||
AI_SESSIONS_META_MIGRATION_KEY,
|
||||
{ migrationCompleted: true, migrationAt: Date.now() },
|
||||
);
|
||||
}
|
||||
|
||||
function parseLegacySessions(raw: string | null | undefined): AISession[] {
|
||||
if (raw == null || raw === '') return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed as AISession[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Active-session map mirror (meta store) ──
|
||||
|
||||
/**
|
||||
* Mirror the active-pointer map into the IndexedDB meta store. The
|
||||
* localStorage key remains written as before (small, quota-safe) and stays
|
||||
* the read-mostly authority for this release; the mirror makes the pointer
|
||||
* available to future store work without another migration.
|
||||
*/
|
||||
export async function mirrorActiveSessionMapToIdb(
|
||||
map: Record<string, string | null>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
await idbPutMeta(db, AI_SESSIONS_META_ACTIVE_MAP_KEY, map);
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Failed to mirror the active-session map to IndexedDB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the mirrored active-pointer map (boot restore aid when the
|
||||
* localStorage key is missing).
|
||||
*/
|
||||
export async function readActiveSessionMapFromIdb(): Promise<Record<string, string | null> | null> {
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
const value = await idbGetMeta<Record<string, string | null>>(db, AI_SESSIONS_META_ACTIVE_MAP_KEY);
|
||||
return value && typeof value === 'object' ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -66,12 +66,10 @@ test('parseBackupSessions ignores corrupt payloads and drops malformed entries',
|
||||
});
|
||||
|
||||
test('restore preserves all sessions when cloud sync is enabled', () => {
|
||||
// Issue #4123: the retention cap is a session count (raised to 1000) —
|
||||
// there is no byte budget any more.
|
||||
const sessions = Array.from({ length: 1001 }, (_, index) => makeSession(`s${index}`, index));
|
||||
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
|
||||
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
|
||||
const local = prepareRestoredSessions(sessions, false);
|
||||
assert.equal(local.length, 1000);
|
||||
assert.equal(local.length, 50);
|
||||
assert.equal(local.some(session => session.id === 's0'), false);
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2,13 +2,10 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
|
||||
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
|
||||
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
normalizeAiSessionMessageCap,
|
||||
} from '../../domain/aiSessionMessageCap';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
@@ -16,6 +13,7 @@ import type {
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import type { ProviderContinuationOptions } from '../../infrastructure/ai/providerContinuation';
|
||||
import { findSafeChatMessageCompactionSplitIndex } from '../../infrastructure/ai/contextCompaction';
|
||||
import {
|
||||
bumpDraftMutationVersionState,
|
||||
@@ -29,16 +27,7 @@ import {
|
||||
import { emitAIStateChanged } from './aiStateEvents';
|
||||
import { peekAgentRuntimeCleanup } from '../../infrastructure/ai/harness/agentRuntimeHandle';
|
||||
import { setReversibleModelPrivacy } from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
import {
|
||||
idbDeleteSessionIds,
|
||||
idbPutMeta,
|
||||
idbPutSessions,
|
||||
isIndexedDbAvailable,
|
||||
openAiSessionsIdb,
|
||||
publishAiSessionsIdbChange,
|
||||
resetAiSessionsIdbForTests,
|
||||
} from './aiSessionIdbStorage';
|
||||
import { DebouncedBackupMirror, mergeImportedSessions, readLiveSessionsArray } from './aiSessionsBackup';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
export interface AIBridge {
|
||||
@@ -75,13 +64,6 @@ export async function cleanupSdkAgentSessions(sessionIds: string[]): Promise<voi
|
||||
await Promise.all(sessionIds.map((sessionId) => bridge?.aiSdkAgentCleanup?.(sessionId).catch(() => {})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Session ids explicitly deleted by the user (or by an authoritative cloud
|
||||
* merge). Their disappearance from the persisted set must not trigger the
|
||||
* pre-eviction backup generation — only cap-driven drops should.
|
||||
*/
|
||||
const intentionallyDeletedSessionIds = new Set<string>();
|
||||
|
||||
export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (sessionIds.length === 0) return;
|
||||
@@ -91,7 +73,6 @@ export function cleanupDeletedAIChatSessions(sessionIds: string[]) {
|
||||
// main-process privacy mapping may still exist, so always clear those.
|
||||
const runtime = peekAgentRuntimeCleanup();
|
||||
for (const sessionId of sessionIds) {
|
||||
intentionallyDeletedSessionIds.add(sessionId);
|
||||
if (runtime) {
|
||||
runtime.clearChatSession(sessionId);
|
||||
} else {
|
||||
@@ -127,11 +108,8 @@ export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
latestAIActiveTargetIdsSnapshot = activeTargetIds;
|
||||
// Child maintenance effects run before the provider's boot effects. Keep
|
||||
// the live targets for post-hydration cleanup without adopting stale legacy
|
||||
// history as authoritative state while IndexedDB is still loading.
|
||||
if (!isAiSessionsHydrationComplete()) return;
|
||||
const currentSessions = latestAISessionsSnapshot ?? getPersistedAiSessionsSnapshot();
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? readSessionsFromStorage();
|
||||
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
@@ -182,7 +160,14 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
|
||||
if (sessionsMarked) {
|
||||
setLatestAISessionsSnapshot(markedSessions);
|
||||
writeSessionsForStorage(markedSessions);
|
||||
// Startup cleanup can run before the disk backup read resolves. Defer
|
||||
// persistence until those chats are merged, preserving their recovery
|
||||
// marker and avoiding a partial backup of only the loaded sessions.
|
||||
const diskOnlyIds = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? [];
|
||||
const loadedIds = new Set(markedSessions.map(session => session.id));
|
||||
if (!diskOnlyIds.some(id => !loadedIds.has(id))) {
|
||||
writeSessionsForStorage(markedSessions);
|
||||
}
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
@@ -218,53 +203,159 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
/** Maximum number of messages per session when persisting to localStorage. */
|
||||
const MAX_SESSION_MESSAGES = 200;
|
||||
/**
|
||||
* Maximum number of sessions kept in the persisted store. IndexedDB has no
|
||||
* shared byte quota, so retention is a plain count cap; the cap only exists
|
||||
* to keep history from growing without bound.
|
||||
* String-length budget for the primary sessions JSON and each overflow chunk.
|
||||
* Replay bulk is shed from the primary blob first; whole sessions that still
|
||||
* do not fit are retained in overflow rather than silently evicted.
|
||||
*/
|
||||
const MAX_STORED_SESSIONS = 1000;
|
||||
const MAX_SESSIONS_JSON_BYTES = 2 * 1024 * 1024;
|
||||
/** Retry budgets used when the primary budget still fails to persist. */
|
||||
const RETRY_SESSIONS_JSON_BYTES = [
|
||||
1024 * 1024,
|
||||
512 * 1024,
|
||||
256 * 1024,
|
||||
128 * 1024,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Per-session message cap, read from user settings (clamped to 50–2000,
|
||||
* default 200). Read on every write so a lowered cap trims the oldest
|
||||
* messages on the next write; a raised cap stops further trimming.
|
||||
* Best-effort: an unavailable storage layer keeps the default cap.
|
||||
* Maximum persisted length of a single tool result payload. Terminal tool
|
||||
* output can reach hundreds of KB per command, and AI-driven terminal work
|
||||
* accumulates many of them. When storage pressure forces degradation, shedding
|
||||
* this replay bulk keeps every visible conversation persisted instead of
|
||||
* evicting whole chats that happen to carry it.
|
||||
*/
|
||||
export function readAiSessionMessageCap(): number {
|
||||
try {
|
||||
return normalizeAiSessionMessageCap(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_AI_SESSION_MESSAGE_CAP;
|
||||
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
|
||||
|
||||
function truncatePersistedToolResultContent(content: string): string {
|
||||
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
|
||||
// The marker consumes budget and must stay single-line: JSON-escaped
|
||||
// newlines would cost 2 characters each, letting a slightly-over-cap
|
||||
// result serialize at the same (or larger) size than the original — the
|
||||
// ladder assumes the degraded form is the smallest. Shrink the retained
|
||||
// head+tail until the whole persisted copy fits the cap.
|
||||
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
|
||||
let omitted = content.length - 2 * half;
|
||||
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
|
||||
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
|
||||
omitted = content.length - 2 * half;
|
||||
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
|
||||
}
|
||||
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to the persisted store to bound retention.
|
||||
* Only affects what is persisted — the in-memory state retains all messages
|
||||
* until the session is reloaded.
|
||||
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
|
||||
* continuation and shed bulk tool output. The ciphertext exists so stateless
|
||||
* Responses turns can replay prior reasoning items; tool output dominates
|
||||
* persisted size in AI-driven terminal chats. When storage pressure forces
|
||||
* it, shedding this bulk keeps the visible conversation intact at the cost of
|
||||
* reasoning replay and full tool output for affected messages.
|
||||
*/
|
||||
function stripReasoningEncryptedContent(
|
||||
options: ProviderContinuationOptions,
|
||||
): ProviderContinuationOptions | undefined {
|
||||
const hasCiphertext = Object.values(options).some(
|
||||
providerOptions => typeof providerOptions?.reasoningEncryptedContent === 'string',
|
||||
);
|
||||
if (!hasCiphertext) return options;
|
||||
const stripped: ProviderContinuationOptions = {};
|
||||
for (const [provider, providerOptions] of Object.entries(options)) {
|
||||
const rest = { ...providerOptions };
|
||||
delete rest.reasoningEncryptedContent;
|
||||
if (Object.keys(rest).length) stripped[provider] = rest;
|
||||
}
|
||||
return Object.keys(stripped).length ? stripped : undefined;
|
||||
}
|
||||
|
||||
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
|
||||
const continuation = message.providerContinuation;
|
||||
if (!continuation?.reasoningParts) return message;
|
||||
let changed = false;
|
||||
const parts = continuation.reasoningParts.map(part => {
|
||||
if (!part.providerOptions) return part;
|
||||
const providerOptions = stripReasoningEncryptedContent(part.providerOptions);
|
||||
if (providerOptions === part.providerOptions) return part;
|
||||
changed = true;
|
||||
return providerOptions ? { text: part.text, providerOptions } : { text: part.text };
|
||||
});
|
||||
if (!changed) return message;
|
||||
return {
|
||||
...message,
|
||||
providerContinuation: {
|
||||
...continuation,
|
||||
reasoningParts: parts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
|
||||
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
|
||||
const toolResults = strippedContinuationMessage.toolResults;
|
||||
if (!toolResults?.length) return strippedContinuationMessage;
|
||||
let toolResultsChanged = false;
|
||||
const nextToolResults = toolResults.map((result) => {
|
||||
const content = truncatePersistedToolResultContent(result.content);
|
||||
if (content === result.content) return result;
|
||||
toolResultsChanged = true;
|
||||
return { ...result, content };
|
||||
});
|
||||
return toolResultsChanged
|
||||
? { ...strippedContinuationMessage, toolResults: nextToolResults }
|
||||
: strippedContinuationMessage;
|
||||
}
|
||||
|
||||
function stripEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
let changed = false;
|
||||
const messages = session.messages.map(message => {
|
||||
const stripped = stripEncryptedReasoningFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
function stripCompactedEncryptedReasoningFromSession(session: AISession): AISession {
|
||||
const compactedMessageCount = Math.min(
|
||||
session.messages.length,
|
||||
Math.max(0, session.contextCompaction?.compactedMessageCount ?? 0),
|
||||
);
|
||||
if (compactedMessageCount === 0) return session;
|
||||
|
||||
let changed = false;
|
||||
const messages = session.messages.map((message, index) => {
|
||||
if (index >= compactedMessageCount) return message;
|
||||
const stripped = stripReasoningCiphertextFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
return stripped;
|
||||
});
|
||||
return changed ? { ...session, messages } : session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune sessions before writing to localStorage to prevent hitting the
|
||||
* ~5-10 MB storage quota. Only affects what is persisted — the in-memory
|
||||
* state retains all messages until the session is reloaded.
|
||||
*
|
||||
* - Keeps only the MAX_STORED_SESSIONS most-recently-updated sessions.
|
||||
* - Trims each session's messages to the last `messageCap` messages.
|
||||
* - Trims each session's messages to the last MAX_SESSION_MESSAGES.
|
||||
*/
|
||||
export function pruneSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
messageCap: number = readAiSessionMessageCap(),
|
||||
): AISession[] {
|
||||
export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
// Sort by updatedAt descending so we keep the newest
|
||||
const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
const limited = sorted.slice(0, MAX_STORED_SESSIONS);
|
||||
return limited.map(s => {
|
||||
if (s.messages.length > messageCap) {
|
||||
if (s.messages.length > MAX_SESSION_MESSAGES) {
|
||||
// Do not start the retained tail with a tool result whose assistant call
|
||||
// was just trimmed. Reuse the same tool-safe boundary logic as context
|
||||
// compaction, even if that retains a few more than the nominal cap.
|
||||
const removedMessageCount = findSafeChatMessageCompactionSplitIndex(
|
||||
s.messages,
|
||||
messageCap,
|
||||
MAX_SESSION_MESSAGES,
|
||||
);
|
||||
const contextCompaction = s.contextCompaction
|
||||
? {
|
||||
@@ -289,119 +380,150 @@ export function prepareRestoredSessions(sessions: AISession[], syncEnabled: bool
|
||||
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
}
|
||||
|
||||
// ── IndexedDB persistence (issue #4123) ──
|
||||
//
|
||||
// Sessions are persisted one record per session. There is no global byte
|
||||
// budget and no re-serialization ladder: the 2 MB localStorage budget existed
|
||||
// to defend a shared quota against the cost of serializing the whole store on
|
||||
// every write, and both are gone with per-record writes. Only a count cap
|
||||
// (MAX_STORED_SESSIONS) and the per-session message cap remain.
|
||||
/**
|
||||
* Serialize sessions for localStorage under a byte budget, escalating pruning
|
||||
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
|
||||
* sessions that JSON represents.
|
||||
*
|
||||
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
|
||||
* tool output (kept at a bounded head+tail per result), and only then drop
|
||||
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
|
||||
* emptied history for users under storage pressure.
|
||||
*/
|
||||
export function serializeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
budgetBytes: number = MAX_SESSIONS_JSON_BYTES,
|
||||
): { json: string; sessions: AISession[] } {
|
||||
const prepared = pruneSessionsForStorage(sessions).map(rawSession => {
|
||||
// These messages are replaced by the durable summary before every later
|
||||
// request, so their replay ciphertext can never be used again. Remove it
|
||||
// before deciding whether the newest session deserves full protection;
|
||||
// otherwise dead metadata can evict other visible conversations.
|
||||
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
|
||||
return { session, json: JSON.stringify(session) };
|
||||
});
|
||||
|
||||
/** Last set of sessions known to be persisted, for per-record diffing. */
|
||||
let lastPersistedSessionsById: Map<string, AISession> = new Map();
|
||||
/** Serialized write queue: IDB operations never interleave. */
|
||||
let idbWriteQueue: Promise<void> = Promise.resolve();
|
||||
// Check whether retained history fits before preparing degradation. Use
|
||||
// the existing JSON string-length budget including array delimiters, and
|
||||
// avoid preparing unused stripped messages and serializing payloads again.
|
||||
const fullLength = 2 + prepared.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, prepared.length - 1);
|
||||
if (fullLength <= budgetBytes) {
|
||||
return {
|
||||
json: `[${prepared.map(entry => entry.json).join(',')}]`,
|
||||
sessions: prepared.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
/** Test hook: reset diff state between tests. */
|
||||
export function __resetIdbPersistenceForTests() {
|
||||
lastPersistedSessionsById = new Map();
|
||||
idbWriteQueue = Promise.resolve();
|
||||
// Tests swap the fake indexedDB factory between cases; the cached open
|
||||
// handle must not survive the swap.
|
||||
resetAiSessionsIdbForTests();
|
||||
// Tests simulate the post-boot state by default; the boot-gating cases
|
||||
// call __resetAiSessionsHydrationForTests() to go back to pending.
|
||||
markAiSessionsHydrationComplete();
|
||||
}
|
||||
// Reuse the full representation when the unchanged degradation ladder is
|
||||
// needed, including on quota retries with a smaller budget.
|
||||
const serialized = prepared.map(({ session, json }) => {
|
||||
const degradableMessages = session.messages.flatMap((message, index) => {
|
||||
const degradedMessage = stripEncryptedReasoningFromMessage(message);
|
||||
if (degradedMessage === message) return [];
|
||||
return [{
|
||||
index,
|
||||
degradedMessage,
|
||||
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
|
||||
}];
|
||||
});
|
||||
const strippedSession = stripEncryptedReasoningFromSession(session);
|
||||
return {
|
||||
session,
|
||||
json,
|
||||
strippedSession,
|
||||
strippedJson: strippedSession === session
|
||||
? json
|
||||
: JSON.stringify(strippedSession),
|
||||
degradableMessages,
|
||||
};
|
||||
});
|
||||
|
||||
/** Test hook: resolve once every queued IndexedDB sync has settled. */
|
||||
export function __flushIdbWritesForTests(): Promise<void> {
|
||||
return idbWriteQueue.then(() => undefined, () => undefined);
|
||||
}
|
||||
// Preserve the newest session's full continuation whenever it can fit by
|
||||
// itself. That session is the one the user is most likely continuing now;
|
||||
// older visible history must not make its next tool turn unreplayable.
|
||||
const protectNewestContinuation = serialized.length > 0
|
||||
&& serialized[0].json.length + 2 <= budgetBytes;
|
||||
|
||||
// ── Boot hydration signal (issue #4125 review) ──
|
||||
//
|
||||
// Settles once useAIState's boot load has finished (IndexedDB read, legacy
|
||||
// migration, disk-backup restore). Sync collection and the active-pointer
|
||||
// validation must not run before it: a pre-hydration collect reads the
|
||||
// stale legacy localStorage store and would overwrite newer IndexedDB
|
||||
// records, and pointer validation against the initially-empty in-memory
|
||||
// array would null every pointer before the real sessions arrive.
|
||||
// Determine how many sessions can fit using the smallest representation for
|
||||
// older sessions while reserving the newest session's full representation
|
||||
// when possible. This also prevents an old, oversized visible chat that must
|
||||
// be dropped anyway from causing newer replay ciphertext to be stripped.
|
||||
let minimalLength = 2
|
||||
+ serialized.reduce((total, entry, index) => (
|
||||
total + (protectNewestContinuation && index === 0 ? entry.json.length : entry.strippedJson.length)
|
||||
), 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
while (minimalLength > budgetBytes && serialized.length > 1) {
|
||||
const removed = serialized.pop();
|
||||
if (!removed) break;
|
||||
minimalLength -= removed.strippedJson.length + 1;
|
||||
}
|
||||
|
||||
let aiSessionsHydrationComplete = false;
|
||||
// Then keep full continuation data for the retained sessions and shed
|
||||
// replay-only bulk (ciphertext, oversized tool output) from the oldest
|
||||
// retained sessions only as needed, never touching the protected newest
|
||||
// session. Within one session, shed it one message at a time from oldest to
|
||||
// newest so a long active chat can retain its most recent replayable tool
|
||||
// exchange when that still fits.
|
||||
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
|
||||
+ Math.max(0, serialized.length - 1);
|
||||
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
|
||||
for (
|
||||
let index = serialized.length - 1;
|
||||
index >= firstStrippableIndex && jsonLength > budgetBytes;
|
||||
index -= 1
|
||||
) {
|
||||
const current = serialized[index];
|
||||
let degradedMessageCount = 0;
|
||||
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
|
||||
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
|
||||
degradedMessageCount += 1;
|
||||
}
|
||||
if (degradedMessageCount > 0) {
|
||||
const degradedMessagesByIndex = new Map(
|
||||
current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.map(entry => [entry.index, entry.degradedMessage] as const),
|
||||
);
|
||||
const nextSession = {
|
||||
...current.session,
|
||||
messages: current.session.messages.map((message, messageIndex) => (
|
||||
degradedMessagesByIndex.get(messageIndex) ?? message
|
||||
)),
|
||||
};
|
||||
const nextJson = JSON.stringify(nextSession);
|
||||
const projectedJsonLength = current.json.length
|
||||
+ current.degradableMessages
|
||||
.slice(0, degradedMessageCount)
|
||||
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
|
||||
jsonLength += nextJson.length - projectedJsonLength;
|
||||
serialized[index] = {
|
||||
...current,
|
||||
session: nextSession,
|
||||
json: nextJson,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function isAiSessionsHydrationComplete(): boolean {
|
||||
return aiSessionsHydrationComplete;
|
||||
}
|
||||
|
||||
/** Called by useAIState's boot effect when the boot load settles. */
|
||||
export function markAiSessionsHydrationComplete(): void {
|
||||
aiSessionsHydrationComplete = true;
|
||||
}
|
||||
|
||||
/** Test hook: return the hydration signal to the pre-boot (pending) state. */
|
||||
export function __resetAiSessionsHydrationForTests(): void {
|
||||
aiSessionsHydrationComplete = false;
|
||||
}
|
||||
|
||||
/** Seed the diff baseline after a boot load or migration. */
|
||||
export function seedLastPersistedSessions(sessions: AISession[]) {
|
||||
lastPersistedSessionsById = new Map(sessions.map(session => [session.id, session]));
|
||||
return {
|
||||
json: `[${serialized.map(entry => entry.json).join(',')}]`,
|
||||
sessions: serialized.map(entry => entry.session),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted session set synchronously. Source of truth is the
|
||||
* in-memory snapshot held by useAIState; this accessor returns the last set
|
||||
* handed to `writeSessionsForStorage`, falling back to the read-only legacy
|
||||
* localStorage store (kept untouched for one release).
|
||||
* Persist sessions to localStorage with byte-budgeted primary/overflow slices.
|
||||
* Returns true when local history was saved, possibly as a newest-primary
|
||||
* fallback with the full history scheduled for disk backup under quota pressure.
|
||||
*/
|
||||
export function getPersistedAiSessionsSnapshot(): AISession[] {
|
||||
if (lastPersistedSessionsById.size > 0) {
|
||||
return Array.from(lastPersistedSessionsById.values())
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
}
|
||||
return readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
|
||||
}
|
||||
|
||||
function queueIdbSessionSync(
|
||||
changed: AISession[],
|
||||
removedIds: string[],
|
||||
prevById: Map<string, AISession>,
|
||||
persisted: AISession[],
|
||||
) {
|
||||
if (changed.length === 0 && removedIds.length === 0) return;
|
||||
idbWriteQueue = idbWriteQueue
|
||||
.then(async () => {
|
||||
if (!isIndexedDbAvailable()) return;
|
||||
const db = await openAiSessionsIdb();
|
||||
// Per-record write: only the changed sessions are put, only removed
|
||||
// ids are deleted.
|
||||
if (changed.length > 0) await idbPutSessions(db, changed);
|
||||
if (removedIds.length > 0) await idbDeleteSessionIds(db, removedIds);
|
||||
await idbPutMeta(db, 'schemaVersion', 1);
|
||||
await idbPutMeta(db, 'lastWriteAt', Date.now());
|
||||
publishAiSessionsIdbChange();
|
||||
})
|
||||
.catch((error) => {
|
||||
// IndexedDB failures must never break the in-memory session state.
|
||||
// Reset the diff baseline so the next write retries every record, and
|
||||
// fall back to the legacy localStorage key so a corrupt/unavailable
|
||||
// IndexedDB still leaves a restorable trace (via the disk mirror).
|
||||
console.warn('[AIState] IndexedDB session sync failed:', error);
|
||||
lastPersistedSessionsById = new Map(prevById);
|
||||
try {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, JSON.stringify(persisted));
|
||||
} catch {
|
||||
// Legacy fallback is best-effort too.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Durability mirror (issue #4015 follow-up): every write also schedules a
|
||||
// debounced disk backup so a wiped/recreated profile does not take the chat
|
||||
// history with it. The persisted JSON is mirrored; when the count cap evicts
|
||||
// sessions, a PRE-eviction generation is written first so evicted chats stay
|
||||
// recoverable from disk (the blind spot found in issue #4123).
|
||||
// Durability mirror (issue #4015 follow-up): every successful store write
|
||||
// also schedules a debounced disk backup so a wiped/recreated profile does
|
||||
// not take the chat history with it. Persisted history is mirrored, including
|
||||
// overflow sessions, throttled to at most one write per mutation batch.
|
||||
// Before a byte-budget drop, mirror the full
|
||||
// count-capped set even if the subsequent localStorage writes fail.
|
||||
const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
try {
|
||||
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
|
||||
@@ -410,14 +532,6 @@ const backupMirror = new DebouncedBackupMirror((json) => {
|
||||
}
|
||||
});
|
||||
|
||||
let aiSessionsBackupRecoveryPending = false;
|
||||
|
||||
/** Partial migration must not rotate away generations holding disk-only chats. */
|
||||
export function setAiSessionsBackupRecoveryPending(pending: boolean) {
|
||||
aiSessionsBackupRecoveryPending = pending;
|
||||
if (pending) backupMirror.reset();
|
||||
}
|
||||
|
||||
export function flushBackupMirror() {
|
||||
try {
|
||||
backupMirror.flushNow();
|
||||
@@ -428,11 +542,9 @@ export function flushBackupMirror() {
|
||||
|
||||
export function __resetBackupMirrorForTests() {
|
||||
backupMirror.reset();
|
||||
aiSessionsBackupRecoveryPending = false;
|
||||
}
|
||||
|
||||
export function scheduleBackupMirror(json: string) {
|
||||
if (aiSessionsBackupRecoveryPending) return;
|
||||
try {
|
||||
backupMirror.schedule(json);
|
||||
} catch {
|
||||
@@ -440,79 +552,257 @@ export function scheduleBackupMirror(json: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write one immediate backup generation (bypasses the debounce window). */
|
||||
function writeImmediateBackupGeneration(json: string) {
|
||||
if (aiSessionsBackupRecoveryPending) return;
|
||||
/**
|
||||
* Last successful write: the same immutable sessions array is written again by
|
||||
* the debounced save path and by cloud-sync collection, and each write used to
|
||||
* re-serialize the whole (often multi-MB) history.
|
||||
*/
|
||||
let lastSessionsWrite: { sessions: AISession[]; synced: boolean; json: string } | null = null;
|
||||
|
||||
function rememberSessionsWrite(sessions: AISession[], synced: boolean, json: string): void {
|
||||
lastSessionsWrite = { sessions, synced, json };
|
||||
}
|
||||
|
||||
function storedSessionsJsonEquals(json: string): boolean {
|
||||
try {
|
||||
backupMirror.schedule(json);
|
||||
backupMirror.flushNow();
|
||||
return localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS) === json;
|
||||
} catch {
|
||||
// Backup failures must never break chat persistence.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perf (#4121): the exact same immutable sessions array is often written
|
||||
* again by the debounced save path and by cloud-sync collection; skipping
|
||||
* the re-stringify for a reference-identical set keeps that cheap. The
|
||||
* per-record IDB diff below already makes the store write itself cheap.
|
||||
*/
|
||||
let lastMirroredSessions: AISession[] | null = null;
|
||||
function overflowChunkKey(index: number): string {
|
||||
return index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
|
||||
}
|
||||
|
||||
function readOverflowChunks(): string[] {
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; ; index += 1) {
|
||||
const chunk = localStorageAdapter.readString(overflowChunkKey(index));
|
||||
if (chunk == null) return chunks;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
function clearOverflowChunks(startIndex = 0) {
|
||||
for (let index = startIndex; localStorageAdapter.readString(overflowChunkKey(index)) != null; index += 1) {
|
||||
localStorageAdapter.remove(overflowChunkKey(index));
|
||||
}
|
||||
}
|
||||
|
||||
function writeDiskOverflowIds(ids: string[]): boolean {
|
||||
if (ids.length > 0) {
|
||||
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, ids);
|
||||
}
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replace the entire overflow blob; never leave a partially written JSON array. */
|
||||
function replaceOverflowChunks(chunks: string[]): boolean {
|
||||
clearOverflowChunks();
|
||||
let complete = false;
|
||||
try {
|
||||
complete = chunks.every((chunk, index) => (
|
||||
localStorageAdapter.writeString(overflowChunkKey(index), chunk)
|
||||
));
|
||||
return complete;
|
||||
} finally {
|
||||
if (!complete) clearOverflowChunks();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read both local retention slices; newer copies win if a session is duplicated. */
|
||||
export function readSessionsFromStorage(): AISession[] {
|
||||
return mergeImportedSessions(
|
||||
readLiveSessionsArray(readOverflowChunks().join('')),
|
||||
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist sessions. Writes go to IndexedDB, one record per session, through a
|
||||
* serialized queue; errors are logged, never thrown. Returns true when the
|
||||
* persisted set was computed (in-memory state remains authoritative either
|
||||
* way).
|
||||
*/
|
||||
export function writeSessionsForStorage(
|
||||
sessions: AISession[],
|
||||
options?: { deletedSessionIds?: readonly string[] },
|
||||
options: { deletedSessionIds?: readonly string[] } = {},
|
||||
): boolean {
|
||||
try {
|
||||
// Explicitly deleted ids never count as cap eviction (they also must not
|
||||
// trigger a pre-eviction backup generation).
|
||||
for (const id of options?.deletedSessionIds ?? []) intentionallyDeletedSessionIds.add(id);
|
||||
// Synced records are authoritative after restart: never prune them, or
|
||||
// local retention would turn into cloud deletion on the next sync.
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(
|
||||
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
|
||||
)?.enabled;
|
||||
const persisted = syncEnabled ? sessions : pruneSessionsForStorage(sessions);
|
||||
|
||||
const prevById = lastPersistedSessionsById;
|
||||
const persistedIds = new Set(persisted.map(session => session.id));
|
||||
|
||||
// Count-cap eviction: sessions that drop out of the persisted set without
|
||||
// being explicitly deleted get a pre-eviction disk generation containing
|
||||
// the full pre-eviction store, so they remain recoverable.
|
||||
const evicted = Array.from(prevById.entries())
|
||||
.filter(([id]) => !persistedIds.has(id) && !intentionallyDeletedSessionIds.has(id))
|
||||
.map(([, session]) => session);
|
||||
intentionallyDeletedSessionIds.clear();
|
||||
if (evicted.length > 0) {
|
||||
writeImmediateBackupGeneration(JSON.stringify(sessions));
|
||||
console.warn(
|
||||
`[AIState] Retention cap evicted ${evicted.length} session(s); a pre-eviction backup generation was written to disk.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Per-record diff (synchronous: the baseline must advance even when
|
||||
// IndexedDB is unavailable, and callers read the snapshot right after).
|
||||
const changed = persisted.filter(session => prevById.get(session.id) !== session);
|
||||
const removedIds = Array.from(prevById.keys()).filter(id => !persistedIds.has(id));
|
||||
lastPersistedSessionsById = new Map(persisted.map(session => [session.id, session]));
|
||||
|
||||
if (changed.length > 0 || removedIds.length > 0 || lastMirroredSessions !== persisted) {
|
||||
scheduleBackupMirror(JSON.stringify(persisted));
|
||||
lastMirroredSessions = persisted;
|
||||
}
|
||||
queueIdbSessionSync(changed, removedIds, prevById, persisted);
|
||||
const synced = Boolean(
|
||||
localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled,
|
||||
);
|
||||
// Nothing to do when this exact array was already written in this mode and
|
||||
// storage still holds that write (another window may have replaced it).
|
||||
if (
|
||||
lastSessionsWrite?.sessions === sessions
|
||||
&& lastSessionsWrite.synced === synced
|
||||
&& storedSessionsJsonEquals(lastSessionsWrite.json)
|
||||
// A matching primary alone cannot establish that overflow is intact.
|
||||
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_OVERFLOW) == null
|
||||
&& localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) == null
|
||||
) {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Failed to persist AI sessions:', error);
|
||||
return false;
|
||||
}
|
||||
// Synced records are authoritative after restart: silently evicting a
|
||||
// session or turn here would turn local retention into cloud deletion.
|
||||
if (synced) {
|
||||
const json = JSON.stringify(sessions);
|
||||
const previousChunks = readOverflowChunks();
|
||||
// The authoritative primary replaces overflow, so free its quota first.
|
||||
clearOverflowChunks();
|
||||
let syncedOk = false;
|
||||
try {
|
||||
syncedOk = localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, json);
|
||||
if (syncedOk) {
|
||||
clearOverflowChunks();
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
}
|
||||
scheduleBackupMirror(json);
|
||||
rememberSessionsWrite(sessions, true, json);
|
||||
}
|
||||
return syncedOk;
|
||||
} finally {
|
||||
if (!syncedOk) replaceOverflowChunks(previousChunks);
|
||||
}
|
||||
}
|
||||
const inputIds = new Set(sessions.map(session => session.id));
|
||||
const pruned = pruneSessionsForStorage(sessions);
|
||||
const retainedIds = new Set(pruned.map(session => session.id));
|
||||
const deletedIds = new Set(options.deletedSessionIds);
|
||||
// A live write can race startup's backup read. Preserve marker IDs absent
|
||||
// from the live input unless explicitly deleted. IDs present in the input
|
||||
// are resolved even if the count cap evicts them; pending IDs block mirroring.
|
||||
const diskOverflowIds = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
const unresolvedDiskIds: string[] = Array.isArray(diskOverflowIds)
|
||||
? diskOverflowIds.filter((id): id is string => (
|
||||
typeof id === 'string' && !retainedIds.has(id) && !deletedIds.has(id) && !inputIds.has(id)
|
||||
))
|
||||
: [];
|
||||
let fullBackupScheduled = false;
|
||||
const ensureFullBackup = () => {
|
||||
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) {
|
||||
scheduleBackupMirror(JSON.stringify(pruned));
|
||||
fullBackupScheduled = true;
|
||||
}
|
||||
};
|
||||
const persistCandidate = (
|
||||
candidate: { json: string; sessions: AISession[] },
|
||||
budget: number,
|
||||
chunkBudget = budget,
|
||||
): boolean => {
|
||||
const primaryIds = new Set(candidate.sessions.map(session => session.id));
|
||||
const omitted = pruned.filter(session => !primaryIds.has(session.id));
|
||||
if (omitted.length > 0) ensureFullBackup();
|
||||
// Apply the same replay-bulk ladder to every overflow chat as budgets
|
||||
// tighten. Chunking alone cannot reduce an origin's aggregate quota use.
|
||||
const overflow = omitted.map(session => serializeSessionsForStorage([session], budget).sessions[0]);
|
||||
const overflowJson = overflow.length > 0 ? JSON.stringify(overflow) : '';
|
||||
// Split the array's JSON string, so even one oversized session can span
|
||||
// chunks without losing messages. Concatenate all chunks before parsing.
|
||||
const chunks: string[] = [];
|
||||
// Primary budgets can shrink to a tiny empty chat. Keep overflow writes
|
||||
// bounded independently while still supporting small per-key quotas.
|
||||
const chunkSize = Math.max(32 * 1024, chunkBudget);
|
||||
for (let offset = 0; offset < overflowJson.length; offset += chunkSize) {
|
||||
chunks.push(overflowJson.slice(offset, offset + chunkSize));
|
||||
}
|
||||
const previousChunks = readOverflowChunks();
|
||||
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
let primaryWritten = false;
|
||||
let persisted = false;
|
||||
try {
|
||||
// Reclaim both old slices before writing overflow: the final history may
|
||||
// fit even when overflow plus the old, larger primary would exceed quota.
|
||||
clearOverflowChunks();
|
||||
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return false;
|
||||
primaryWritten = true;
|
||||
if (!replaceOverflowChunks(chunks)) return false;
|
||||
if (!writeDiskOverflowIds(unresolvedDiskIds)) return false;
|
||||
persisted = true;
|
||||
if (!fullBackupScheduled && unresolvedDiskIds.length === 0) scheduleBackupMirror(candidate.json);
|
||||
rememberSessionsWrite(sessions, false, candidate.json);
|
||||
return true;
|
||||
} finally {
|
||||
if (!persisted) {
|
||||
clearOverflowChunks();
|
||||
if (primaryWritten) {
|
||||
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
|
||||
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
|
||||
}
|
||||
replaceOverflowChunks(previousChunks);
|
||||
}
|
||||
}
|
||||
};
|
||||
const primaryCandidates: { json: string; sessions: AISession[] }[] = [];
|
||||
let previousLength = Number.POSITIVE_INFINITY;
|
||||
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
|
||||
// A real quota failure means the next attempt must be smaller even when
|
||||
// the failed payload was already below the nominal retry budget. Reduce
|
||||
// materially on each bounded retry so several small sessions can be
|
||||
// removed before the retry sequence is exhausted.
|
||||
const reducedBudget = Number.isFinite(previousLength)
|
||||
? Math.floor(previousLength * 0.75)
|
||||
: configuredBudget;
|
||||
const budget = Math.min(configuredBudget, reducedBudget);
|
||||
if (budget < 2) break;
|
||||
const candidate = serializeSessionsForStorage(pruned, budget);
|
||||
primaryCandidates.push(candidate);
|
||||
// Even an unchanged primary can succeed with smaller overflow chunks or
|
||||
// more degraded overflow replay data at this retry budget.
|
||||
if (persistCandidate(candidate, budget)) return true;
|
||||
previousLength = candidate.json.length;
|
||||
}
|
||||
// Last resort: use the smallest primary representation (the newest chat
|
||||
// with replay-only bulk removed), retaining other chats in overflow.
|
||||
const minimalCandidate = serializeSessionsForStorage(pruned, 0);
|
||||
const minimalChunkBudget = Math.min(minimalCandidate.json.length, RETRY_SESSIONS_JSON_BYTES[RETRY_SESSIONS_JSON_BYTES.length - 1]);
|
||||
primaryCandidates.push(minimalCandidate);
|
||||
if (persistCandidate(minimalCandidate, 0, minimalChunkBudget)) return true;
|
||||
|
||||
// If the complete retained set cannot fit, save the newest primary anyway.
|
||||
// Schedule the full disk mirror before discarding overflow; do not let an
|
||||
// old valid primary block persistence of every subsequent conversation turn.
|
||||
ensureFullBackup();
|
||||
const previousChunks = readOverflowChunks();
|
||||
const previousPrimary = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
const previousDiskOverflow = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
clearOverflowChunks();
|
||||
let primaryWritten = false;
|
||||
let primaryPersisted = false;
|
||||
try {
|
||||
for (const candidate of primaryCandidates) {
|
||||
const primaryIds = new Set(candidate.sessions.map(session => session.id));
|
||||
const diskOnlyIds = [...new Set([
|
||||
...unresolvedDiskIds,
|
||||
...pruned.filter(session => !primaryIds.has(session.id)).map(session => session.id),
|
||||
])];
|
||||
// Replacing a large old primary can free the space needed by metadata.
|
||||
// Roll it back below if the recovery marker still cannot be persisted.
|
||||
if (!localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) continue;
|
||||
primaryWritten = true;
|
||||
if (writeDiskOverflowIds(diskOnlyIds)) {
|
||||
primaryPersisted = true;
|
||||
rememberSessionsWrite(sessions, false, candidate.json);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!primaryPersisted) {
|
||||
if (primaryWritten) {
|
||||
if (previousPrimary == null) localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS);
|
||||
else localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, previousPrimary);
|
||||
}
|
||||
if (previousDiskOverflow != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, previousDiskOverflow);
|
||||
} else if (localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) != null) {
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
}
|
||||
replaceOverflowChunks(previousChunks);
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
'[AIState] Failed to persist AI sessions within the storage quota; recent chat history may not survive a restart.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
@@ -554,10 +844,10 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
|
||||
|
||||
export function prewarmAIStateStorageSnapshots() {
|
||||
try {
|
||||
// Sessions are no longer prewarmed from localStorage: the primary store is
|
||||
// IndexedDB and loads asynchronously in useAIState. Prewarming the stale
|
||||
// read-only legacy key would make the boot loader skip the IndexedDB
|
||||
// contents ("never restore over non-empty live state").
|
||||
if (latestAISessionsSnapshot === null) {
|
||||
latestAISessionsSnapshot =
|
||||
readSessionsFromStorage();
|
||||
}
|
||||
if (latestAIActiveSessionMapSnapshot === null) {
|
||||
latestAIActiveSessionMapSnapshot =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
|
||||
@@ -30,20 +30,3 @@ test('terminalCwdStore bumps version only when cwd or provenance changes', () =>
|
||||
unsubscribe();
|
||||
assert.ok(versions.length >= 2);
|
||||
});
|
||||
|
||||
test('session revisions isolate terminals and retain identical OSC 7 reports without rerendering', () => {
|
||||
const a = 'revision-a'; const b = 'revision-b';
|
||||
terminalCwdStore.setCwd(a, '/same', 'osc7');
|
||||
const revisionA = terminalCwdStore.getSessionRevision(a);
|
||||
terminalCwdStore.setCwd(b, '/elsewhere', 'osc7');
|
||||
assert.equal(terminalCwdStore.getSessionRevision(a), revisionA);
|
||||
const globalVersion = terminalCwdStore.getVersion();
|
||||
assert.equal(terminalCwdStore.setCwd(a, '/same', 'osc7'), false);
|
||||
assert.ok(terminalCwdStore.getSessionRevision(a) > revisionA);
|
||||
assert.equal(terminalCwdStore.getVersion(), globalVersion);
|
||||
const revisionB = terminalCwdStore.getSessionRevision(b);
|
||||
terminalCwdStore.prune(new Set([b]));
|
||||
assert.equal(terminalCwdStore.getSessionRevision(a), 0);
|
||||
assert.equal(terminalCwdStore.getSessionRevision(b), revisionB);
|
||||
terminalCwdStore.prune(new Set());
|
||||
});
|
||||
@@ -10,16 +10,11 @@ export type TerminalCwdSource = "osc7" | "inferred" | "backend-strict" | "backen
|
||||
class TerminalCwdStore {
|
||||
private cwdBySession = new Map<string, string>();
|
||||
private sourceBySession = new Map<string, TerminalCwdSource>();
|
||||
private revisionBySession = new Map<string, number>();
|
||||
private revision = 0;
|
||||
private version = 0;
|
||||
private listeners = new Set<Listener>();
|
||||
|
||||
getVersion = (): number => this.version;
|
||||
|
||||
/** Tracks reports for one session, including unchanged authoritative OSC 7. */
|
||||
getSessionRevision = (sessionId: string): number => this.revisionBySession.get(sessionId) ?? 0;
|
||||
|
||||
getCwd = (sessionId: string | null | undefined): string | null => {
|
||||
if (!sessionId) return null;
|
||||
return this.cwdBySession.get(sessionId) ?? null;
|
||||
@@ -44,10 +39,7 @@ class TerminalCwdStore {
|
||||
const nextSource = next
|
||||
? (source ?? (current === next ? currentSource : undefined) ?? "unknown")
|
||||
: undefined;
|
||||
if (current === next && currentSource === nextSource) {
|
||||
if (nextSource === 'osc7') this.revisionBySession.set(sessionId, ++this.revision);
|
||||
return false;
|
||||
}
|
||||
if (current === next && currentSource === nextSource) return false;
|
||||
|
||||
if (next) {
|
||||
this.cwdBySession.set(sessionId, next);
|
||||
@@ -56,7 +48,6 @@ class TerminalCwdStore {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
}
|
||||
this.revisionBySession.set(sessionId, ++this.revision);
|
||||
this.version += 1;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
@@ -70,13 +61,9 @@ class TerminalCwdStore {
|
||||
if (!validSessionIds.has(sessionId)) {
|
||||
this.cwdBySession.delete(sessionId);
|
||||
this.sourceBySession.delete(sessionId);
|
||||
this.revisionBySession.delete(sessionId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
for (const id of this.revisionBySession.keys()) {
|
||||
if (!validSessionIds.has(id)) this.revisionBySession.delete(id);
|
||||
}
|
||||
if (!changed) return;
|
||||
this.version += 1;
|
||||
for (const listener of this.listeners) {
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||
|
||||
function setup(overrides: Partial<TerminalDirectoryIO> = {}) {
|
||||
let cwd = '/old'; let ready = true; let current = true;
|
||||
const writes: string[] = [];
|
||||
const published: string[] = [];
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: 'test-directory', confirmationMs: 10,
|
||||
io: { cwd: async () => cwd, list: async () => ({ entries: [], home: null }), realpath: async path => path,
|
||||
write: data => { writes.push(data); cwd = '/next'; }, ...overrides },
|
||||
isCurrent: () => current, isReady: () => ready,
|
||||
publish: value => { published.push(value); terminalCwdStore.setCwd('test-directory', value, 'backend-strict'); },
|
||||
});
|
||||
return { controller, writes, published, busy: () => { ready = false; }, stale: () => { current = false; } };
|
||||
}
|
||||
|
||||
test('navigation writes one quoted cd and waits for actual cwd before confirming', async () => {
|
||||
const ctx = setup(); const accepted: string[] = [];
|
||||
await ctx.controller.change('/next', new AbortController().signal, () => accepted.push('accepted'));
|
||||
assert.deepEqual(ctx.writes, ["cd -- '/next'\r"]);
|
||||
assert.deepEqual(accepted, ['accepted']);
|
||||
assert.deepEqual(ctx.published, ['/next']);
|
||||
ctx.controller.dispose();
|
||||
});
|
||||
|
||||
test('busy, control-character and stale sessions never receive bytes', async () => {
|
||||
const ctx = setup(); ctx.busy();
|
||||
await assert.rejects(ctx.controller.change('/next', new AbortController().signal, () => {}), { code: 'busy' });
|
||||
const invalid = setup();
|
||||
await assert.rejects(invalid.controller.change('/next\n', new AbortController().signal, () => {}), { code: 'unsupportedPath' });
|
||||
const stale = setup(); stale.stale();
|
||||
await assert.rejects(stale.controller.change('/next', new AbortController().signal, () => {}), { code: 'unavailable' });
|
||||
assert.deepEqual([...ctx.writes, ...invalid.writes, ...stale.writes], []);
|
||||
});
|
||||
|
||||
test('rechecks readiness after async path resolution; double submission is rejected', async () => {
|
||||
let finish!: (path: string) => void;
|
||||
const ctx = setup({ realpath: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const first = ctx.controller.change('/next', new AbortController().signal, () => {});
|
||||
await assert.rejects(ctx.controller.change('/other', new AbortController().signal, () => {}), { code: 'busy' });
|
||||
ctx.busy(); finish('/next');
|
||||
await assert.rejects(first, { code: 'busy' });
|
||||
assert.deepEqual(ctx.writes, []);
|
||||
});
|
||||
|
||||
test('a disposed connection rejects delayed reads and cannot publish into a new generation', async () => {
|
||||
let finish!: (cwd: string) => void;
|
||||
const ctx = setup({ cwd: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const read = ctx.controller.refresh();
|
||||
ctx.controller.dispose(); finish('/stale');
|
||||
await assert.rejects(read, { code: 'unavailable' });
|
||||
assert.deepEqual(ctx.published, []);
|
||||
});
|
||||
|
||||
test('a failed cd keeps the observed cwd and never retries the write', async () => {
|
||||
const ctx = setup({ write: () => {}, cwd: async () => '/old' });
|
||||
await assert.rejects(ctx.controller.change('/missing', new AbortController().signal, () => {}), { code: 'changeFailed' });
|
||||
assert.ok(ctx.published.every(value => value === '/old'));
|
||||
});
|
||||
|
||||
test('directory symlinks confirm against physical cwd', async () => {
|
||||
const ctx = setup({ realpath: async path => path === '/link' ? '/real' : path, cwd: async () => '/real' });
|
||||
await ctx.controller.change('/link', new AbortController().signal, () => {});
|
||||
assert.equal(ctx.published.at(-1), '/real');
|
||||
});
|
||||
|
||||
test('output arriving during an old cwd query retains one fresh follow-up query', async () => {
|
||||
let finish!: (cwd: string) => void;
|
||||
let reads = 0;
|
||||
const ctx = setup({ cwd: () => {
|
||||
reads++;
|
||||
return reads === 1 ? new Promise(resolve => { finish = resolve; }) : Promise.resolve('/after-cd');
|
||||
} });
|
||||
const oldRead = ctx.controller.refresh();
|
||||
const queued = ctx.controller.refresh();
|
||||
finish('/before-cd');
|
||||
await Promise.all([oldRead, queued]);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(reads, 2);
|
||||
assert.equal(ctx.published.at(-1), '/after-cd');
|
||||
ctx.controller.dispose();
|
||||
});
|
||||
|
||||
test('another terminal updating cannot override the current terminal strict cwd result', async t => {
|
||||
const id = 'strict-after-other-session';
|
||||
let finish!: (cwd: string) => void;
|
||||
const published: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/stale-osc7', 'stale');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: value => { published.push(value); terminalCwdStore.setCwd(id, value, 'backend-strict'); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const pending = controller.refresh();
|
||||
terminalCwdStore.setCwd('unrelated-session', '/other', 'osc7');
|
||||
finish('/actual-after-cd');
|
||||
assert.equal(await pending, '/actual-after-cd');
|
||||
assert.deepEqual(published, ['/actual-after-cd']);
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/actual-after-cd');
|
||||
});
|
||||
|
||||
test('another terminal cannot turn this submission pre-write OSC 7 into a fresh report', async t => {
|
||||
const id = 'pre-write-osc7-after-other-session';
|
||||
let finish!: (cwd: string) => void;
|
||||
const published: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/before', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const changing = controller.change('/after', new AbortController().signal, () => {});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
terminalCwdStore.setCwd(id + '-other', '/other', 'osc7');
|
||||
finish('/after');
|
||||
await changing;
|
||||
assert.deepEqual(published, ['/after']);
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/after');
|
||||
});
|
||||
|
||||
for (const nextCwd of ['/new-osc7', '/original']) {
|
||||
test(`a later OSC 7 report from this terminal wins (${nextCwd})`, async t => {
|
||||
const id = 'same-session-osc7-' + nextCwd;
|
||||
let finish!: (cwd: string) => void;
|
||||
const published: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/original', 'stale');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(resolve => { finish = resolve; }),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: value => { published.push(value); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const pending = controller.refresh();
|
||||
terminalCwdStore.setCwd(id, nextCwd, 'osc7');
|
||||
finish('/older-backend-result');
|
||||
assert.equal(await pending, nextCwd);
|
||||
assert.deepEqual(published, []);
|
||||
});
|
||||
}
|
||||
|
||||
for (const probe of ['fails', 'stalls'] as const) {
|
||||
test(`a fresh post-write OSC 7 confirms navigation when the strict cwd probe ${probe}`, { timeout: 2000 }, async t => {
|
||||
const id = 'osc7-confirm-' + probe;
|
||||
const writes: string[] = [];
|
||||
terminalCwdStore.setCwd(id, '/old', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, confirmationMs: 1000, isCurrent: () => true, isReady: () => true,
|
||||
io: {
|
||||
cwd: () => probe === 'fails' ? Promise.reject(new Error('no process cwd support')) : new Promise(() => {}),
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value,
|
||||
write: data => {
|
||||
writes.push(data);
|
||||
setImmediate(() => terminalCwdStore.setCwd(id, '/next', 'osc7'));
|
||||
},
|
||||
},
|
||||
publish() { assert.fail('an unavailable probe must not publish a guessed cwd'); },
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
await controller.change('/next', new AbortController().signal, () => {});
|
||||
assert.deepEqual(writes, ["cd -- '/next'\r"]);
|
||||
assert.equal(controller.hasConfirmedCwd(), true);
|
||||
});
|
||||
}
|
||||
|
||||
test('navigation can probe past its pre-write OSC 7 when the shell stops reporting', async t => {
|
||||
const id = 'probe-after-pre-write-osc7';
|
||||
terminalCwdStore.setCwd(id, '/before', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => '/after', list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value, write() {} },
|
||||
publish: cwd => terminalCwdStore.setCwd(id, cwd, 'backend-strict'),
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
await controller.change('/after', new AbortController().signal, () => {});
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/after');
|
||||
assert.equal(terminalCwdStore.getSource(id), 'backend-strict');
|
||||
});
|
||||
|
||||
for (const report of ['stale', 'other-session', 'other-source', 'fresh-unchanged'] as const) {
|
||||
test(`OSC 7 confirmation uses this submission's session revision (${report})`, { timeout: 2000 }, async t => {
|
||||
const id = 'osc7-revision-confirm-' + report;
|
||||
terminalCwdStore.setCwd(id, '/next', 'osc7');
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, confirmationMs: 20, isCurrent: () => true, isReady: () => true,
|
||||
io: {
|
||||
cwd: async () => { throw new Error('no process cwd support'); },
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value,
|
||||
write() {
|
||||
if (report !== 'stale') setImmediate(() => terminalCwdStore.setCwd(
|
||||
report === 'other-session' ? id + '-other' : id, '/next', report === 'other-source' ? 'inferred' : 'osc7',
|
||||
));
|
||||
},
|
||||
},
|
||||
publish() {},
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
const change = controller.change('/next', new AbortController().signal, () => {});
|
||||
if (report === 'fresh-unchanged') await change;
|
||||
else await assert.rejects(change, { code: 'changeFailed' });
|
||||
});
|
||||
}
|
||||
|
||||
for (const cancellation of ['abort', 'dispose'] as const) {
|
||||
test(`a fresh OSC 7 cannot confirm a cancelled directory change (${cancellation})`, { timeout: 2000 }, async t => {
|
||||
const id = 'osc7-cancel-' + cancellation;
|
||||
const abort = new AbortController();
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: () => new Promise(() => {}), list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value,
|
||||
write() { setImmediate(() => {
|
||||
if (cancellation === 'abort') abort.abort(); else controller.dispose();
|
||||
terminalCwdStore.setCwd(id, '/next', 'osc7');
|
||||
}); } },
|
||||
publish() {},
|
||||
});
|
||||
t.after(() => controller.dispose());
|
||||
await assert.rejects(controller.change('/next', abort.signal, () => {}), { code: 'unavailable' });
|
||||
});
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { DirectoryPickerError, isDirectoryPath } from '../../domain/directoryPicker';
|
||||
import { resolveInteractiveTerminalCdIntent } from '../../domain/sessionRestore';
|
||||
import type { TerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
|
||||
export type TerminalDirectoryController = ReturnType<typeof createTerminalDirectoryController>;
|
||||
|
||||
/** One registration represents one connected interactive shell generation. */
|
||||
export function createTerminalDirectoryController(options: {
|
||||
sessionId: string;
|
||||
io: TerminalDirectoryIO;
|
||||
isCurrent: () => boolean;
|
||||
isReady: () => boolean;
|
||||
publish: (cwd: string) => void;
|
||||
confirmationMs?: number;
|
||||
}) {
|
||||
let disposed = false;
|
||||
let changing = false;
|
||||
let confirmed = false;
|
||||
let refreshing: Promise<string> | null = null;
|
||||
let refreshAgain = false;
|
||||
const lifetime = new AbortController();
|
||||
const current = () => !disposed && options.isCurrent();
|
||||
function check(signal?: AbortSignal) {
|
||||
if (!current() || signal?.aborted) throw new DirectoryPickerError('unavailable');
|
||||
}
|
||||
function ready() {
|
||||
check();
|
||||
if (!options.isReady()) throw new DirectoryPickerError('busy');
|
||||
}
|
||||
function bounded<T>(task: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (error?: unknown, value?: T) => {
|
||||
clearTimeout(timer);
|
||||
lifetime.signal.removeEventListener('abort', cancel);
|
||||
signal?.removeEventListener('abort', cancel);
|
||||
if (error) reject(error); else resolve(value!);
|
||||
};
|
||||
const cancel = () => finish(new DirectoryPickerError('unavailable'));
|
||||
const timer = setTimeout(() => finish(new DirectoryPickerError('readFailed')), 5000);
|
||||
lifetime.signal.addEventListener('abort', cancel, { once: true });
|
||||
signal?.addEventListener('abort', cancel, { once: true });
|
||||
if (signal?.aborted || lifetime.signal.aborted) cancel();
|
||||
task.then(value => finish(undefined, value), error => finish(error));
|
||||
});
|
||||
}
|
||||
async function refresh(submittedRevision?: number): Promise<string> {
|
||||
check();
|
||||
const activeShellCwd = () => {
|
||||
const cwd = terminalCwdStore.getCwd(options.sessionId);
|
||||
return cwd && terminalCwdStore.getSource(options.sessionId) === 'osc7'
|
||||
&& (submittedRevision === undefined || terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision)
|
||||
? cwd : null;
|
||||
};
|
||||
// A process probe may read the outer PTY shell while a nested shell is
|
||||
// active. OSC 7 remains authoritative across debounce delays, completion
|
||||
// events and picker remounts. Only a cd submission may probe past its own
|
||||
// pre-write report while waiting for fresh confirmation.
|
||||
const reported = activeShellCwd();
|
||||
if (reported) { confirmed = true; return reported; }
|
||||
if (refreshing) {
|
||||
// Output may settle while a pre-cd query is still in flight. Keep one
|
||||
// follow-up query so that its older result cannot be the last update.
|
||||
refreshAgain = true;
|
||||
return refreshing;
|
||||
}
|
||||
const pending = (async () => {
|
||||
const cwd = await bounded(options.io.cwd());
|
||||
check();
|
||||
const reported = activeShellCwd();
|
||||
const live = terminalCwdStore.getCwd(options.sessionId);
|
||||
confirmed = true;
|
||||
if (reported) return reported;
|
||||
if (live !== cwd || terminalCwdStore.getSource(options.sessionId) === 'stale') options.publish(cwd);
|
||||
return cwd;
|
||||
})();
|
||||
refreshing = pending;
|
||||
try { return await pending; } finally {
|
||||
if (refreshing === pending) refreshing = null;
|
||||
if (refreshAgain && current()) {
|
||||
refreshAgain = false;
|
||||
void refresh().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
async function change(path: string, signal: AbortSignal, accepted: () => void, expectedCwd?: string) {
|
||||
ready();
|
||||
if (changing) throw new DirectoryPickerError('busy');
|
||||
const intent = isDirectoryPath(path) ? resolveInteractiveTerminalCdIntent(path) : null;
|
||||
if (!intent) throw new DirectoryPickerError('unsupportedPath');
|
||||
changing = true;
|
||||
let confirmationFinished = false;
|
||||
try {
|
||||
const canonical = await bounded(options.io.realpath(path, signal), signal);
|
||||
check(signal);
|
||||
ready(); // The shell can become busy while resolving a remote symlink.
|
||||
if (expectedCwd && terminalCwdStore.getCwd(options.sessionId) !== expectedCwd) throw new DirectoryPickerError('unavailable');
|
||||
const submittedRevision = terminalCwdStore.getSessionRevision(options.sessionId);
|
||||
options.io.write(intent.command + '\r');
|
||||
accepted();
|
||||
const deadline = Date.now() + (options.confirmationMs ?? 8000);
|
||||
const hasFreshOsc7 = () => {
|
||||
const cwd = terminalCwdStore.getCwd(options.sessionId);
|
||||
return terminalCwdStore.getSessionRevision(options.sessionId) !== submittedRevision
|
||||
&& terminalCwdStore.getSource(options.sessionId) === 'osc7'
|
||||
&& (cwd === path || cwd === canonical);
|
||||
};
|
||||
const pause = () => new Promise<void>(resolve => {
|
||||
const done = () => { clearTimeout(timer); lifetime.signal.removeEventListener('abort', done); resolve(); };
|
||||
const timer = setTimeout(done, 200);
|
||||
lifetime.signal.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
const confirmFromProbe = async () => {
|
||||
while (!confirmationFinished && Date.now() < deadline) {
|
||||
check(signal);
|
||||
if (hasFreshOsc7()) return;
|
||||
if (options.isReady()) {
|
||||
try {
|
||||
const cwd = await refresh(submittedRevision);
|
||||
check(signal);
|
||||
if (cwd === path || cwd === canonical || await bounded(options.io.realpath(cwd, signal), signal) === canonical) return;
|
||||
} catch (error) {
|
||||
if (!current() || signal.aborted) throw error;
|
||||
}
|
||||
}
|
||||
await pause();
|
||||
}
|
||||
if (!confirmationFinished) throw new DirectoryPickerError('changeFailed');
|
||||
};
|
||||
// OSC 7 is authoritative even when process cwd lookup is unavailable or
|
||||
// stalled. Poll revisions too: an unchanged OSC 7 path emits no store event.
|
||||
const confirmFromOsc7 = async () => {
|
||||
while (!confirmationFinished) {
|
||||
check(signal);
|
||||
if (hasFreshOsc7()) return;
|
||||
if (Date.now() >= deadline) throw new DirectoryPickerError('changeFailed');
|
||||
await pause();
|
||||
}
|
||||
};
|
||||
await Promise.race([confirmFromProbe(), confirmFromOsc7()]);
|
||||
check(signal);
|
||||
confirmed = true;
|
||||
} finally { confirmationFinished = true; changing = false; }
|
||||
}
|
||||
return {
|
||||
refresh: () => refresh(),
|
||||
hasConfirmedCwd: () => confirmed,
|
||||
async home() {
|
||||
check();
|
||||
const result = await bounded(options.io.home?.() ?? Promise.resolve(null));
|
||||
check(); return result;
|
||||
},
|
||||
async list(path: string, signal: AbortSignal) {
|
||||
check(signal);
|
||||
const result = await bounded(options.io.list(path, signal), signal);
|
||||
check(signal);
|
||||
return result;
|
||||
},
|
||||
change,
|
||||
dispose() { disposed = true; lifetime.abort(); },
|
||||
};
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import {
|
||||
observeTerminalDirectory, registerTerminalDirectory,
|
||||
refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh,
|
||||
} from './terminalDirectoryRegistry';
|
||||
|
||||
test('manual terminal output refreshes cwd without a trusted command-completion event', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let cwd = '/before'; let reads = 0;
|
||||
const published: string[] = [];
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: 'unrecognized-prompt', isCurrent: () => true, isReady: () => false,
|
||||
io: { cwd: async () => { reads++; return cwd; },
|
||||
list: async () => ({ entries: [], home: null }), realpath: async value => value, write() {} },
|
||||
publish: value => { published.push(value); },
|
||||
});
|
||||
const unregister = registerTerminalDirectory('unrecognized-prompt', controller);
|
||||
const stopObserving = observeTerminalDirectory('unrecognized-prompt');
|
||||
t.after(() => { stopObserving(); unregister(); });
|
||||
cwd = '/after-manual-cd';
|
||||
// Multiple prompt/output chunks produce just one read. No trusted prompt
|
||||
// detection, command-completed callback, or parsed `cd` is involved.
|
||||
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
|
||||
t.mock.timers.tick(80);
|
||||
scheduleObservedTerminalDirectoryRefresh('unrecognized-prompt');
|
||||
t.mock.timers.tick(149);
|
||||
assert.equal(reads, 0);
|
||||
t.mock.timers.tick(1);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(published, ['/after-manual-cd']);
|
||||
assert.equal(reads, 1);
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(reads, 1, 'no polling after output has settled');
|
||||
});
|
||||
|
||||
test('settled output and prompt completion preserve the active nested shell OSC 7 cwd', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const id = 'nested-shell-output';
|
||||
const published: string[] = [];
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
// The PTY process stays in its original directory while a child shell
|
||||
// runs in /. Its cwd is only a fallback for shells without OSC 7.
|
||||
io: { cwd: async () => '/outer-shell', list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value, write() {} },
|
||||
publish: cwd => { published.push(cwd); terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
const unregister = registerTerminalDirectory(id, controller);
|
||||
const stopObserving = observeTerminalDirectory(id);
|
||||
t.after(() => { stopObserving(); unregister(); });
|
||||
scheduleObservedTerminalDirectoryRefresh(id);
|
||||
terminalCwdStore.setCwd(id, '/', 'osc7');
|
||||
t.mock.timers.tick(150);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(terminalCwdStore.getCwd(id), '/');
|
||||
// Later output chunks, prompt completion and reopening/manual refresh must
|
||||
// not treat that same active-shell report as stale merely due to timing.
|
||||
scheduleObservedTerminalDirectoryRefresh(id);
|
||||
t.mock.timers.tick(150);
|
||||
refreshObservedTerminalDirectory(id);
|
||||
assert.equal(await controller.refresh(), '/');
|
||||
assert.deepEqual(published, []);
|
||||
assert.equal(terminalCwdStore.getSource(id), 'osc7');
|
||||
});
|
||||
|
||||
test('disabling chips cancels a queued output refresh and ignores future terminal output', async t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let reads = 0;
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId: 'hidden-chips', isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => { reads++; return '/unused'; }, list: async () => ({ entries: [], home: null }),
|
||||
realpath: async value => value, write() {} }, publish() {},
|
||||
});
|
||||
const unregister = registerTerminalDirectory('hidden-chips', controller);
|
||||
t.after(unregister);
|
||||
const stopObserving = observeTerminalDirectory('hidden-chips');
|
||||
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
|
||||
stopObserving();
|
||||
t.mock.timers.tick(1000);
|
||||
scheduleObservedTerminalDirectoryRefresh('hidden-chips');
|
||||
t.mock.timers.tick(1000);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(reads, 0);
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { TerminalDirectoryController } from './terminalDirectoryController';
|
||||
|
||||
const sessions = new Map<string, TerminalDirectoryController>();
|
||||
const consumers = new Map<string, number>();
|
||||
const scheduledRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const OUTPUT_SETTLE_MS = 150;
|
||||
|
||||
function cancelScheduledRefresh(id: string) {
|
||||
const timer = scheduledRefreshes.get(id);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
scheduledRefreshes.delete(id);
|
||||
}
|
||||
const listeners = new Set<() => void>();
|
||||
export const subscribeTerminalDirectories = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
};
|
||||
export const getTerminalDirectory = (id: string) => sessions.get(id) ?? null;
|
||||
export function registerTerminalDirectory(id: string, controller: TerminalDirectoryController) {
|
||||
cancelScheduledRefresh(id);
|
||||
sessions.set(id, controller);
|
||||
listeners.forEach(listener => listener());
|
||||
return () => {
|
||||
controller.dispose();
|
||||
if (sessions.get(id) !== controller) return;
|
||||
cancelScheduledRefresh(id);
|
||||
sessions.delete(id);
|
||||
listeners.forEach(listener => listener());
|
||||
};
|
||||
}
|
||||
export function observeTerminalDirectory(id: string) {
|
||||
consumers.set(id, (consumers.get(id) ?? 0) + 1);
|
||||
return () => {
|
||||
const next = (consumers.get(id) ?? 1) - 1;
|
||||
if (next) consumers.set(id, next);
|
||||
else { consumers.delete(id); cancelScheduledRefresh(id); }
|
||||
};
|
||||
}
|
||||
/** Called by the existing command completion path, only while chips are visible. */
|
||||
export function refreshObservedTerminalDirectory(id: string) {
|
||||
cancelScheduledRefresh(id);
|
||||
if (consumers.has(id)) void sessions.get(id)?.refresh().catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shells with custom prompts may emit neither OSC 7/133 nor a trusted command
|
||||
* completion. Debounce actual output/submission activity instead of polling or
|
||||
* interpreting `cd` text. The controller prefers active-shell OSC 7 and uses
|
||||
* the bound shell's process cwd only as a fallback.
|
||||
*/
|
||||
export function scheduleObservedTerminalDirectoryRefresh(id: string) {
|
||||
if (!consumers.has(id) || !sessions.has(id)) return;
|
||||
cancelScheduledRefresh(id);
|
||||
scheduledRefreshes.set(id, setTimeout(() => {
|
||||
scheduledRefreshes.delete(id);
|
||||
refreshObservedTerminalDirectory(id);
|
||||
}, OUTPUT_SETTLE_MS));
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Minimal in-memory IndexedDB stub covering the exact subset used by
|
||||
* `aiSessionIdbStorage.ts`: open + upgrade (createObjectStore), readonly
|
||||
* getAll/get, readwrite put/delete/clear, and meta-store records. Not a
|
||||
* general-purpose IDB implementation — extend deliberately if the wrapper
|
||||
* grows new usage.
|
||||
*/
|
||||
|
||||
type Resolve = () => void;
|
||||
|
||||
const microtask2 = (fn: Resolve) => {
|
||||
queueMicrotask(() => queueMicrotask(fn));
|
||||
};
|
||||
|
||||
export class FakeRequest<T> {
|
||||
onsuccess: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
|
||||
onerror: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
|
||||
onupgradeneeded: ((this: FakeRequest<T>, ev?: unknown) => void) | null = null;
|
||||
result: T;
|
||||
error: Error | null = null;
|
||||
|
||||
constructor(result: T, mode: 'immediate' | 'upgrade' | 'failure' = 'immediate', error?: Error) {
|
||||
this.result = result;
|
||||
if (mode === 'failure') {
|
||||
this.error = error ?? new Error('fake idb failure');
|
||||
queueMicrotask(() => this.onerror?.(this));
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (mode === 'upgrade') this.onupgradeneeded?.(this);
|
||||
this.onsuccess?.(this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface StoreSpec {
|
||||
name: string;
|
||||
keyPath: string;
|
||||
}
|
||||
|
||||
export class FakeObjectStore {
|
||||
/** Instrumentation for per-record write assertions. */
|
||||
putCount = 0;
|
||||
deleteCount = 0;
|
||||
|
||||
constructor(
|
||||
public readonly spec: StoreSpec,
|
||||
public readonly records: Map<string, unknown>,
|
||||
) {}
|
||||
|
||||
put(value: unknown): FakeRequest<IDBValidKey> {
|
||||
this.putCount += 1;
|
||||
const key = (value as Record<string, unknown>)[this.spec.keyPath] as string;
|
||||
this.records.set(key, value);
|
||||
return new FakeRequest(key);
|
||||
}
|
||||
|
||||
delete(key: IDBValidKey): FakeRequest<undefined> {
|
||||
this.deleteCount += 1;
|
||||
this.records.delete(String(key));
|
||||
return new FakeRequest(undefined);
|
||||
}
|
||||
|
||||
clear(): FakeRequest<undefined> {
|
||||
this.records.clear();
|
||||
return new FakeRequest(undefined);
|
||||
}
|
||||
|
||||
get(key: IDBValidKey): FakeRequest<unknown> {
|
||||
return new FakeRequest(this.records.get(String(key)));
|
||||
}
|
||||
|
||||
getAll(): FakeRequest<unknown[]> {
|
||||
return new FakeRequest(Array.from(this.records.values()));
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeTransaction {
|
||||
oncomplete: Resolve | null = null;
|
||||
onabort: ((ev?: unknown) => void) | null = null;
|
||||
onerror: ((ev?: unknown) => void) | null = null;
|
||||
error: Error | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly database: FakeIndexedDb,
|
||||
private readonly storeName: string,
|
||||
private readonly fail: boolean,
|
||||
) {
|
||||
microtask2(() => {
|
||||
if (this.fail) {
|
||||
this.error = new Error('fake transaction failure');
|
||||
this.onerror?.(this);
|
||||
this.onabort?.(this);
|
||||
return;
|
||||
}
|
||||
this.oncomplete?.();
|
||||
});
|
||||
}
|
||||
|
||||
objectStore(name: string): FakeObjectStore {
|
||||
if (name !== this.storeName) {
|
||||
throw new Error(`fake idb: transaction opened for "${this.storeName}", not "${name}"`);
|
||||
}
|
||||
return this.database.getOrCreateStore(name);
|
||||
}
|
||||
}
|
||||
|
||||
export class FakeIndexedDb {
|
||||
/** DOMStringList-like: the wrapper calls `.contains()` (browser IDB API). */
|
||||
readonly objectStoreNames = new Set<string>() as Set<string> & { contains(name: string): boolean };
|
||||
|
||||
constructor() {
|
||||
this.objectStoreNames.contains = (name: string) => this.objectStoreNames.has(name);
|
||||
}
|
||||
|
||||
private readonly storeSpecs = new Map<string, string>();
|
||||
private readonly records = new Map<string, Map<string, unknown>>();
|
||||
/** Instrumentation per store: { put, delete } counters. */
|
||||
readonly opCounts = new Map<string, { put: number; delete: number }>();
|
||||
onclose: (() => void) | null = null;
|
||||
onversionchange: (() => void) | null = null;
|
||||
|
||||
createObjectStore(name: string, options?: { keyPath?: string }) {
|
||||
this.objectStoreNames.add(name);
|
||||
this.storeSpecs.set(name, options?.keyPath ?? 'key');
|
||||
return { name };
|
||||
}
|
||||
|
||||
getOrCreateStore(name: string): FakeObjectStore {
|
||||
if (!this.storeSpecs.has(name)) {
|
||||
this.storeSpecs.set(name, 'key');
|
||||
this.objectStoreNames.add(name);
|
||||
}
|
||||
if (!this.records.has(name)) this.records.set(name, new Map());
|
||||
const spec = { name, keyPath: this.storeSpecs.get(name)! };
|
||||
const store = new FakeObjectStore(spec, this.records.get(name)!);
|
||||
const counts = this.opCounts.get(name) ?? { put: 0, delete: 0 };
|
||||
// Aggregate counts lazily via proxy-free bookkeeping on each op.
|
||||
const originalPut = store.put.bind(store);
|
||||
store.put = (value: unknown) => {
|
||||
counts.put += 1;
|
||||
this.opCounts.set(name, counts);
|
||||
return originalPut(value);
|
||||
};
|
||||
const originalDelete = store.delete.bind(store);
|
||||
store.delete = (key: IDBValidKey) => {
|
||||
counts.delete += 1;
|
||||
this.opCounts.set(name, counts);
|
||||
return originalDelete(key);
|
||||
};
|
||||
return store;
|
||||
}
|
||||
|
||||
transaction(storeName: string, _mode?: string): FakeTransaction {
|
||||
return new FakeTransaction(this, storeName, false);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
/** Direct read for assertions (bypasses the async wrapper). */
|
||||
dumpStore(name: string): unknown[] {
|
||||
return Array.from(this.records.get(name)?.values() ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FakeIndexedDbHandle {
|
||||
db: FakeIndexedDb;
|
||||
openCount: number;
|
||||
restore: () => void;
|
||||
}
|
||||
|
||||
export function installFakeIndexedDb(options: { failOpen?: boolean; failOpenTimes?: number; onOperation?: (storeName: string, op: 'put' | 'delete') => void } = {}): FakeIndexedDbHandle {
|
||||
const previous = (globalThis as { indexedDB?: IDBFactory }).indexedDB;
|
||||
const handle: FakeIndexedDbHandle = {
|
||||
db: new FakeIndexedDb(),
|
||||
openCount: 0,
|
||||
restore: () => {
|
||||
(globalThis as { indexedDB?: IDBFactory }).indexedDB = previous;
|
||||
},
|
||||
};
|
||||
if (options.onOperation) {
|
||||
const onOperation = options.onOperation;
|
||||
const originalGet = handle.db.getOrCreateStore.bind(handle.db);
|
||||
handle.db.getOrCreateStore = (name: string) => {
|
||||
const store = originalGet(name);
|
||||
const originalPut = store.put.bind(store);
|
||||
store.put = (value: unknown) => {
|
||||
onOperation(name, 'put');
|
||||
return originalPut(value);
|
||||
};
|
||||
const originalDelete = store.delete.bind(store);
|
||||
store.delete = (key: IDBValidKey) => {
|
||||
onOperation(name, 'delete');
|
||||
return originalDelete(key);
|
||||
};
|
||||
return store;
|
||||
};
|
||||
}
|
||||
(globalThis as { indexedDB?: unknown }).indexedDB = {
|
||||
open(_name: string, _version: number) {
|
||||
handle.openCount += 1;
|
||||
if (options.failOpen) {
|
||||
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
|
||||
}
|
||||
if (options.failOpenTimes != null && handle.openCount <= options.failOpenTimes) {
|
||||
// Fail only the first N opens, then recover (boot-retry scenarios).
|
||||
return new FakeRequest<IDBDatabase>(null as unknown as IDBDatabase, 'failure', new Error('fake open failure'));
|
||||
}
|
||||
return new FakeRequest<IDBDatabase>(handle.db as unknown as IDBDatabase, 'upgrade');
|
||||
},
|
||||
};
|
||||
return handle;
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_RESPONSE_IDLE_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSION_MESSAGE_CAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
@@ -35,10 +34,6 @@ import {
|
||||
normalizeResponseIdleTimeoutSeconds,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import {
|
||||
DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
normalizeAiSessionMessageCap,
|
||||
} from '../../domain/aiSessionMessageCap';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import { getAIBridge } from './aiStateSnapshots';
|
||||
import { useStoredBoolean } from './useStoredBoolean';
|
||||
@@ -92,12 +87,6 @@ export function useAISettingsState() {
|
||||
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
|
||||
);
|
||||
const [aiSessionMessageCap, setAiSessionMessageCapRaw] = useState<number>(() =>
|
||||
normalizeAiSessionMessageCap(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
)
|
||||
);
|
||||
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
@@ -208,12 +197,6 @@ export function useAISettingsState() {
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(value);
|
||||
}, []);
|
||||
|
||||
const setAiSessionMessageCap = useCallback((value: number) => {
|
||||
const normalizedValue = normalizeAiSessionMessageCap(value);
|
||||
setAiSessionMessageCapRaw(normalizedValue);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP, normalizedValue);
|
||||
}, []);
|
||||
|
||||
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
|
||||
setWebSearchConfigRaw(config);
|
||||
if (config) {
|
||||
@@ -294,14 +277,6 @@ export function useAISettingsState() {
|
||||
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_SESSION_MESSAGE_CAP:
|
||||
setAiSessionMessageCapRaw(
|
||||
normalizeAiSessionMessageCap(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_MESSAGE_CAP)
|
||||
?? DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
@@ -365,8 +340,6 @@ export function useAISettingsState() {
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
aiSessionMessageCap,
|
||||
setAiSessionMessageCap,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
@@ -400,8 +373,6 @@ export function useAISettingsState() {
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
aiSessionMessageCap,
|
||||
setAiSessionMessageCap,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
/**
|
||||
* Boot-scenario regression tests for the IndexedDB migration review fixes
|
||||
* (issue #4125 review): P1 maintenance gating and complete legacy migration,
|
||||
* P2① baseline seeding after an IndexedDB failure,
|
||||
* P2② pointer validation gated on hydration, P2③ disk-backup restore when
|
||||
* IndexedDB stays unopenable, and P2④ adopting an intentional cross-window
|
||||
* deletion.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW,
|
||||
STORAGE_KEY_AI_SESSIONS_OVERFLOW,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { useAIState } from './useAIState';
|
||||
import { getAISessionsSnapshot } from './aiSessionsStore';
|
||||
import {
|
||||
__flushIdbWritesForTests,
|
||||
__resetAiSessionsHydrationForTests,
|
||||
__resetBackupMirrorForTests,
|
||||
__resetIdbPersistenceForTests,
|
||||
getPersistedAiSessionsSnapshot,
|
||||
flushBackupMirror,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
setLatestAIPanelViewByScopeSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
import {
|
||||
idbDeleteSessionIds,
|
||||
AI_SESSIONS_META_MIGRATION_KEY,
|
||||
idbPutSessions,
|
||||
loadOrMigrateAiSessions,
|
||||
openAiSessionsIdb,
|
||||
resetAiSessionsIdbForTests,
|
||||
} from './aiSessionIdbStorage';
|
||||
import { installFakeIndexedDb } from './testSupport/fakeIndexedDb';
|
||||
|
||||
function makeSession(id: string, updatedAt: number): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: 'catty',
|
||||
scope: { type: 'global' },
|
||||
messages: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
} as AISession;
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
return dom;
|
||||
}
|
||||
|
||||
interface Mounted {
|
||||
ai: ReturnType<typeof useAIState>;
|
||||
root: ReturnType<typeof createRoot>;
|
||||
container: HTMLDivElement;
|
||||
}
|
||||
|
||||
async function mountAiState(onMaintenance?: (ai: ReturnType<typeof useAIState>) => void): Promise<Mounted> {
|
||||
const holder: { ai?: ReturnType<typeof useAIState> } = {};
|
||||
function Maintenance({ ai }: { ai: ReturnType<typeof useAIState> }) {
|
||||
const mountAi = React.useRef(ai);
|
||||
React.useEffect(() => { onMaintenance?.(mountAi.current); }, []);
|
||||
return null;
|
||||
}
|
||||
function Harness() {
|
||||
holder.ai = useAIState();
|
||||
return onMaintenance ? React.createElement(Maintenance, { ai: holder.ai }) : null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
return { ai: holder.ai!, root, container };
|
||||
}
|
||||
|
||||
async function unmount(mounted: Mounted, dom: JSDOM) {
|
||||
await act(async () => { mounted.root.unmount(); });
|
||||
mounted.container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
function resetBootState() {
|
||||
// Post-boot defaults first, then back to pending so every test starts
|
||||
// from the pre-hydration state its own hook instance controls.
|
||||
__resetIdbPersistenceForTests();
|
||||
__resetAiSessionsHydrationForTests();
|
||||
// Module-level snapshots leak across tests (the hook's initial state and
|
||||
// the boot merge path read them) — reset to the cold-start state. The
|
||||
// map snapshot must be null so the hook falls back to localStorage.
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot(null as unknown as Record<string, string | null>);
|
||||
setLatestAIDraftsByScopeSnapshot({});
|
||||
setLatestAIPanelViewByScopeSnapshot({});
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
__resetBackupMirrorForTests();
|
||||
resetBootState();
|
||||
});
|
||||
test.afterEach(() => { __resetBackupMirrorForTests(); });
|
||||
|
||||
test('P1: deleting the last migrated chat with no legacy key never restores a stale disk backup', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const deleted = makeSession('deleted-chat', 1);
|
||||
await loadOrMigrateAiSessions(() => JSON.stringify([deleted]));
|
||||
await idbDeleteSessionIds(await openAiSessionsIdb(), [deleted.id]);
|
||||
let backupReads = 0;
|
||||
const backupWrites: string[] = [];
|
||||
Object.assign(dom.window, {
|
||||
netcatty: {
|
||||
aiSessionsBackupReadLatest: async () => {
|
||||
backupReads += 1;
|
||||
return { ok: true, json: JSON.stringify([deleted]) };
|
||||
},
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backupWrites.push(json);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
flushBackupMirror();
|
||||
await __flushIdbWritesForTests();
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), null);
|
||||
assert.equal(backupReads, 0);
|
||||
assert.deepEqual(backupWrites, []);
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), []);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('P1: an uninitialized IndexedDB still restores the disk backup', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const backup = [makeSession('backup-chat', 1)];
|
||||
Object.assign(dom.window, {
|
||||
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
|
||||
});
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
await __flushIdbWritesForTests();
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, backup);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), backup);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
for (const failure of ['not-ok', 'rejection', 'missing-id'] as const) {
|
||||
test(`P1: disk overflow ${failure} keeps migration pending and backups intact, then retries beside primary IDB records`, async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const primary = makeSession('primary-chat', 3);
|
||||
const chunk = makeSession('chunk-chat', 2);
|
||||
const diskOnly = makeSession('disk-only-chat', 1);
|
||||
const unmarked = makeSession('unmarked-deleted-chat', 0);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([chunk]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||
let recoveryAvailable = false;
|
||||
let backupReads = 0;
|
||||
const backupWrites: string[] = [];
|
||||
Object.assign(dom.window, {
|
||||
netcatty: {
|
||||
aiSessionsBackupReadLatest: async () => {
|
||||
backupReads += 1;
|
||||
if (recoveryAvailable) return { ok: true, json: JSON.stringify([primary, chunk, diskOnly, unmarked]) };
|
||||
if (failure === 'rejection') throw new Error('disk temporarily unavailable');
|
||||
if (failure === 'missing-id') return { ok: true, json: JSON.stringify([unmarked]) };
|
||||
return { ok: false };
|
||||
},
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backupWrites.push(json);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
let mounted = await mountAiState();
|
||||
const hasMarker = () => idb.db.dumpStore('meta').some(entry => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY);
|
||||
try {
|
||||
assert.equal(backupReads, 1);
|
||||
assert.equal(hasMarker(), false);
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, chunk]);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, chunk]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backupWrites, [], 'boot must not mirror a partial migration');
|
||||
|
||||
// Ordinary edits and unload must also preserve the recovery generation.
|
||||
await act(async () => {
|
||||
mounted.ai.addMessageToSession(primary.id, { id: 'new-message', role: 'user', content: 'New edit', timestamp: 4 });
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
await __flushIdbWritesForTests();
|
||||
dom.window.dispatchEvent(new dom.window.Event('beforeunload'));
|
||||
assert.deepEqual(backupWrites, []);
|
||||
const updated = getAISessionsSnapshot().sessions.find(session => session.id === primary.id)!;
|
||||
assert.equal(updated.messages.length, 1);
|
||||
assert.equal(hasMarker(), false);
|
||||
|
||||
await act(async () => { mounted.root.unmount(); });
|
||||
mounted.container.remove();
|
||||
resetBootState();
|
||||
recoveryAvailable = true;
|
||||
mounted = await mountAiState();
|
||||
await __flushIdbWritesForTests();
|
||||
assert.equal(backupReads, 2, 'populated IDB must still retry unresolved disk IDs');
|
||||
assert.equal(hasMarker(), true);
|
||||
const expected = [updated, chunk, diskOnly];
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
||||
flushBackupMirror();
|
||||
assert.equal(backupWrites.length, 1, 'mirroring resumes only after all marked IDs are recovered');
|
||||
assert.deepEqual(JSON.parse(backupWrites[0]), expected);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('P1: child mount maintenance waits for hydration and marks the IndexedDB transcript dangling', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const stale: AISession = {
|
||||
...makeSession('closed-chat', 1),
|
||||
scope: { type: 'terminal', targetId: 'closed-terminal' },
|
||||
messages: [{ id: 'm-1', role: 'user', content: 'Old history', timestamp: 1 }],
|
||||
};
|
||||
const current: AISession = {
|
||||
...stale,
|
||||
updatedAt: 2,
|
||||
messages: [...stale.messages, { id: 'm-2', role: 'assistant', content: 'New history', timestamp: 2 }],
|
||||
};
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([stale]));
|
||||
await idbPutSessions(await openAiSessionsIdb(), [current]);
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
let snapshotAfterMaintenance: AISession[] | null | undefined;
|
||||
let putsAfterMaintenance = 0;
|
||||
const mounted = await mountAiState((ai) => {
|
||||
ai.cleanupOrphanedSessions(new Set(['live-terminal']));
|
||||
snapshotAfterMaintenance = latestAISessionsSnapshot;
|
||||
putsAfterMaintenance = idb.db.opCounts.get('sessions')!.put;
|
||||
});
|
||||
try {
|
||||
assert.equal(snapshotAfterMaintenance, null, 'pending maintenance must not adopt the legacy transcript');
|
||||
assert.equal(putsAfterMaintenance, 1, 'pending maintenance must not persist stale history');
|
||||
await __flushIdbWritesForTests();
|
||||
const expected = [{ ...current, scopeDangling: true }];
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS), JSON.stringify([stale]));
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('P1: migration includes chunked overflow alongside a populated primary', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const primary = makeSession('primary-chat', 3);
|
||||
const overflow = [makeSession('overflow-a', 2), makeSession('overflow-b', 1), { ...primary, updatedAt: 0 }];
|
||||
const overflowJson = JSON.stringify(overflow);
|
||||
const split = Math.floor(overflowJson.length / 2);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, overflowJson.slice(0, split));
|
||||
dom.window.localStorage.setItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`, overflowJson.slice(split));
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
const expected = [primary, ...overflow.slice(0, 2)];
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, expected);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), expected);
|
||||
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW), overflowJson.slice(0, split));
|
||||
assert.equal(dom.window.localStorage.getItem(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_2`), overflowJson.slice(split));
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('P1: migration awaits disk-only recovery before recording completion beside a populated primary', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const primary = makeSession('primary-chat', 3);
|
||||
const diskOnly = makeSession('disk-only-chat', 2);
|
||||
const deleted = makeSession('deleted-chat', 1);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||
let resolveBackup!: (result: { ok: boolean; json: string }) => void;
|
||||
const backup = new Promise<{ ok: boolean; json: string }>((resolve) => { resolveBackup = resolve; });
|
||||
let backupReads = 0;
|
||||
Object.assign(dom.window, {
|
||||
netcatty: { aiSessionsBackupReadLatest: () => { backupReads += 1; return backup; } },
|
||||
});
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
assert.equal(backupReads, 1);
|
||||
assert.equal(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY), false);
|
||||
await act(async () => {
|
||||
resolveBackup({ ok: true, json: JSON.stringify([primary, diskOnly, deleted]) });
|
||||
});
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, [primary, diskOnly]);
|
||||
assert.deepEqual(idb.db.dumpStore('sessions'), [primary, diskOnly]);
|
||||
assert.ok(idb.db.dumpStore('meta').some((entry) => (entry as { key: string }).key === AI_SESSIONS_META_MIGRATION_KEY));
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), JSON.stringify([diskOnly.id]));
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
// ── P2①: idbFailed boot must persist the legacy set before seeding the baseline ──
|
||||
|
||||
test('P2①a: after a transient IndexedDB open failure the legacy sessions are persisted and the baseline seeded', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb({ failOpenTimes: 1 });
|
||||
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
// One open retry happened inside the boot effect: the legacy set now
|
||||
// lives in IndexedDB (old code never retried and left the store empty).
|
||||
const ids = idb.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
|
||||
assert.deepEqual(ids, ['chat-a', 'chat-b']);
|
||||
assert.deepEqual(
|
||||
getPersistedAiSessionsSnapshot().map((s) => s.id).sort(),
|
||||
['chat-a', 'chat-b'],
|
||||
);
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id).sort(), ['chat-a', 'chat-b']);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test('P2①b: with IndexedDB unreachable the baseline stays unset, so a later edit rewrites every record', async () => {
|
||||
const dom = installDom();
|
||||
const failing = installFakeIndexedDb({ failOpen: true });
|
||||
const legacy = [makeSession('chat-a', 2), makeSession('chat-b', 1)];
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify(legacy));
|
||||
const mounted = await mountAiState();
|
||||
let recovered: ReturnType<typeof installFakeIndexedDb> | null = null;
|
||||
try {
|
||||
// The retry open also failed: nothing was persisted, and the diff
|
||||
// baseline must remain unset.
|
||||
assert.deepEqual(failing.db.dumpStore('sessions'), []);
|
||||
|
||||
// IndexedDB recovers, then the user edits ONE session. The full set
|
||||
// must reach the store, not only the edited record.
|
||||
failing.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
recovered = installFakeIndexedDb();
|
||||
await act(async () => {
|
||||
mounted.ai.addMessageToSession('chat-a', { id: 'm-1', role: 'user', content: 'Edited', timestamp: 3 });
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 600)); // debounced persist
|
||||
await act(async () => {});
|
||||
await __flushIdbWritesForTests();
|
||||
const ids = recovered.db.dumpStore('sessions').map((s) => (s as AISession).id).sort();
|
||||
assert.deepEqual(ids, ['chat-a', 'chat-b']);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
if (recovered) recovered.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
// ── P2②: pointer validation waits for hydration ──
|
||||
|
||||
test('P2②: pointers survive cold mount before hydration and stay valid after the boot load', async () => {
|
||||
const dom = installDom();
|
||||
const source = makeSession('chat-1', 1);
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
|
||||
dom.window.localStorage.setItem(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
JSON.stringify({ 'terminal:t1': 'chat-1' }),
|
||||
);
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
// Old code nulled the pointer here: the validation effect ran against
|
||||
// the initially-empty in-memory session array.
|
||||
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
|
||||
assert.equal(
|
||||
JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)!)['terminal:t1'],
|
||||
'chat-1',
|
||||
);
|
||||
// After hydration the session is restored and the pointer is intact.
|
||||
await act(async () => {});
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
|
||||
assert.equal(getAISessionsSnapshot().activeSessionIdMap['terminal:t1'], 'chat-1');
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
}
|
||||
});
|
||||
|
||||
// ── P2③: unopenable IndexedDB + corrupt legacy → disk backup is applied ──
|
||||
|
||||
test('P2③: with IndexedDB unopenable and the legacy store corrupt the newest disk backup generation is applied', async () => {
|
||||
const dom = installDom();
|
||||
installFakeIndexedDb({ failOpen: true });
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, 'not-json'); // corrupt legacy
|
||||
const backup = [makeSession('backup-chat', 5)];
|
||||
const warns: unknown[][] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => { warns.push(args); };
|
||||
try {
|
||||
Object.assign(dom.window, {
|
||||
netcatty: { aiSessionsBackupReadLatest: async () => ({ ok: true, json: JSON.stringify(backup) }) },
|
||||
});
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
await act(async () => {});
|
||||
// The backup generation became the recovery source (old code: the
|
||||
// failing IndexedDB recheck rejection was swallowed and the restore
|
||||
// never ran).
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['backup-chat']);
|
||||
assert.deepEqual(getPersistedAiSessionsSnapshot().map((s) => s.id), ['backup-chat']);
|
||||
// The unopenable-store error was surfaced, not swallowed.
|
||||
assert.ok(warns.some((args) => String(args[0]).includes('IndexedDB recheck failed')));
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
}
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
delete (dom.window as unknown as { netcatty?: unknown }).netcatty;
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
|
||||
// ── P2④: an empty getAll after hydration is an intentional deletion ──
|
||||
|
||||
test('P2④: adopting an empty IndexedDB store after another window deleted the last chat', async () => {
|
||||
const dom = installDom();
|
||||
const idb = installFakeIndexedDb();
|
||||
const source = makeSession('chat-1', 1);
|
||||
await idbPutSessions(await openAiSessionsIdb(), [source]);
|
||||
const mounted = await mountAiState();
|
||||
try {
|
||||
await act(async () => {});
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions.map((s) => s.id), ['chat-1']);
|
||||
// Another window deletes the last chat and announces the change. The
|
||||
// announcement must come from a second BroadcastChannel: a channel never
|
||||
// delivers to itself (the module-level channel is the receiving window).
|
||||
await idbDeleteSessionIds(await openAiSessionsIdb(), ['chat-1']);
|
||||
const otherWindow = new BroadcastChannel('netcatty_ai_sessions_changed');
|
||||
await act(async () => {
|
||||
otherWindow.postMessage(Date.now());
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
});
|
||||
otherWindow.close();
|
||||
// Old code early-returned on the empty getAll and kept the deleted
|
||||
// session alive locally.
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
assert.deepEqual(getPersistedAiSessionsSnapshot(), []);
|
||||
} finally {
|
||||
await unmount(mounted, dom);
|
||||
idb.restore();
|
||||
resetAiSessionsIdbForTests();
|
||||
}
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import type { AISession } from '../../infrastructure/ai/types.ts';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SOURCE_SESSION: AISession = {
|
||||
id: 'chat-source',
|
||||
@@ -121,18 +121,11 @@ for (const newerCount of [49, 50]) {
|
||||
let branch: AISession | null = null;
|
||||
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
|
||||
assert.ok(branch);
|
||||
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
|
||||
// nothing is evicted at these sizes) — not in a 50-capped localStorage
|
||||
// blob.
|
||||
const saved: AISession[] = getPersistedAiSessionsSnapshot();
|
||||
assert.equal(saved.length, newerCount + 2);
|
||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.equal(saved.length, 50);
|
||||
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
await act(async () => root.unmount()); container.remove();
|
||||
// Reload from the read-only legacy store (the stand-in for what the
|
||||
// migration persists on a profile without IndexedDB).
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
|
||||
dom.window.close();
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
const reloaded = await setupAiState(saved);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
|
||||
|
||||
@@ -4,10 +4,10 @@ import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_ACTIVE_SESSION_MAP, STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_AI_SESSIONS_OVERFLOW, STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
|
||||
import { useAIState } from './useAIState';
|
||||
import { useAISessionsStore } from './aiSessionsStore';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots';
|
||||
import { __resetBackupMirrorForTests, flushBackupMirror, readSessionsFromStorage, setLatestAIActiveSessionMapSnapshot, setLatestAISessionsSnapshot, writeSessionsForStorage } from './aiStateSnapshots';
|
||||
|
||||
for (const enabled of [true, false]) {
|
||||
test(`appending to long history preserves the synced prefix (chat sync ${enabled})`, async () => {
|
||||
@@ -54,3 +54,403 @@ for (const enabled of [true, false]) {
|
||||
});
|
||||
}
|
||||
|
||||
test('startup and cross-window updates load overflow chats and mirror the merged history', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const primary: AISession = {
|
||||
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
const overflow = { ...primary, id: 'overflow', title: 'Overflow', updatedAt: 1 };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
__resetBackupMirrorForTests();
|
||||
const backups: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backups.push(json);
|
||||
return { ok: true };
|
||||
} } });
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.deepEqual(sessions, [primary, overflow]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups[0]), [primary, overflow]);
|
||||
const updatedOverflow = { ...overflow, title: 'Updated elsewhere', updatedAt: 3 };
|
||||
await act(async () => {
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([updatedOverflow]));
|
||||
dom.window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_SESSIONS_OVERFLOW }));
|
||||
});
|
||||
assert.deepEqual(sessions, [updatedOverflow, primary]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const scopeType of ['global', 'terminal'] as const) {
|
||||
test(`import retains disk-only chats in live history across rename and deletion (${scopeType})`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
|
||||
});
|
||||
const previousDOMException = globalThis.DOMException;
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const source: AISession[] = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `imported-${index}`, title: `Imported ${index}`, agentId: 'catty',
|
||||
scope: scopeType === 'global' ? { type: 'global' } : { type: 'terminal', targetId: 'closed-terminal' },
|
||||
createdAt: 1, updatedAt: 6 - index,
|
||||
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
|
||||
}));
|
||||
const ids = source.map(session => session.id);
|
||||
const backups: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupImport: async () => ({ success: true, json: JSON.stringify(source) }),
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
} });
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, '[]');
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot({});
|
||||
__resetBackupMirrorForTests();
|
||||
let ai!: ReturnType<typeof useAIState>;
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
ai = useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
await act(async () => { ai.cleanupOrphanedSessions(new Set()); });
|
||||
let importedCount = 0;
|
||||
await act(async () => { importedCount = await ai.importSessionsBackup(); });
|
||||
assert.equal(importedCount, source.length);
|
||||
assert.deepEqual(sessions.map(session => session.id), ids);
|
||||
assert.deepEqual(readSessionsFromStorage().map(session => session.id), [ids[0]]);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
|
||||
assert.equal(sessions.every(session => !!session.scopeDangling === (scopeType === 'terminal')), true);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
|
||||
|
||||
await act(async () => { ai.updateSessionTitle(ids[0], 'Renamed after import'); });
|
||||
assert.deepEqual(sessions.map(session => session.id), ids);
|
||||
assert.equal(sessions[0].title, 'Renamed after import');
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1));
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
|
||||
|
||||
await act(async () => { ai.deleteSession(ids[5]); });
|
||||
assert.deepEqual(sessions.map(session => session.id), ids.slice(0, 5));
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), ids.slice(1, 5));
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), sessions);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot({});
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('startup recovers disk-only chats before mirroring a valid partial primary', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost', storageQuota: 5 * 1024 * 1024,
|
||||
});
|
||||
const previousDOMException = globalThis.DOMException;
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event', 'DOMException'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const source: AISession[] = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 6 - index,
|
||||
messages: [{ id: 'm', role: 'user', content: 'x'.repeat(Math.floor(1.1 * 1024 * 1024)), timestamp: 1 }],
|
||||
}));
|
||||
const backups: string[] = [];
|
||||
let reads = 0;
|
||||
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
aiSessionsBackupReadLatest: () => {
|
||||
reads += 1;
|
||||
return new Promise(resolve => { resolveRead = resolve; });
|
||||
},
|
||||
} });
|
||||
__resetBackupMirrorForTests();
|
||||
assert.equal(writeSessionsForStorage(source), true);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups[0]), source);
|
||||
assert.deepEqual(readSessionsFromStorage(), [source[0]]);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), source.slice(1).map(session => session.id));
|
||||
// A newer local copy must win, and an old deleted chat in the backup must
|
||||
// stay deleted: only the explicitly recorded disk-only IDs are recovered.
|
||||
const primary = { ...source[0], title: 'Newer primary', updatedAt: 10 };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
const expected = [primary, ...source.slice(1)];
|
||||
const staleBackup = JSON.stringify([...source, { ...source[5], id: 'deleted-chat' }]);
|
||||
backups.length = 0;
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(reads, 1);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups, []);
|
||||
await act(async () => { resolveRead({ ok: true, json: staleBackup }); });
|
||||
assert.deepEqual(sessions, expected);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
|
||||
assert.deepEqual(readSessionsFromStorage(), [primary]);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
Object.defineProperty(globalThis, 'DOMException', { configurable: true, value: previousDOMException });
|
||||
}
|
||||
});
|
||||
|
||||
for (const scopeType of ['terminal', 'workspace'] as const) {
|
||||
test(`startup orphan cleanup preserves pending disk-only recovery (${scopeType})`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const primary: AISession = {
|
||||
id: 'closed-primary', title: 'Closed primary', agentId: 'catty',
|
||||
scope: { type: scopeType, targetId: 'closed-target' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
const diskOnly: AISession = {
|
||||
...primary, id: 'disk-only', title: 'Disk only', updatedAt: 1,
|
||||
scope: { type: scopeType, targetId: 'another-closed-target' },
|
||||
};
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
__resetBackupMirrorForTests();
|
||||
const backups: string[] = [];
|
||||
let reads = 0;
|
||||
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
aiSessionsBackupReadLatest: () => {
|
||||
reads += 1;
|
||||
return new Promise(resolve => { resolveRead = resolve; });
|
||||
},
|
||||
} });
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
const ai = useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
// TerminalLayerSupport performs this cleanup on mount, while the disk
|
||||
// backup IPC read can still be pending.
|
||||
React.useEffect(() => { ai.cleanupOrphanedSessions(new Set()); }, [ai.cleanupOrphanedSessions]);
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(reads, 1);
|
||||
assert.deepEqual(sessions, [{ ...primary, scopeDangling: true }]);
|
||||
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)!), [diskOnly.id]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups, []);
|
||||
|
||||
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
|
||||
const expected = [primary, diskOnly].map(session => ({ ...session, scopeDangling: true }));
|
||||
assert.deepEqual(sessions, expected);
|
||||
assert.deepEqual(readSessionsFromStorage(), expected);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const mutation of ['rename', 'create', 'delete', 'delete-pending'] as const) {
|
||||
test(`live ${mutation} preserves intended history across pending disk recovery`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const primary: AISession = {
|
||||
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
const diskOnly = { ...primary, id: 'disk-only', updatedAt: 1 };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([diskOnly.id]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, JSON.stringify({ 'global:': diskOnly.id }));
|
||||
setLatestAIActiveSessionMapSnapshot({ 'global:': diskOnly.id });
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
__resetBackupMirrorForTests();
|
||||
const backups: string[] = [];
|
||||
let resolveRead!: (backup: { ok: boolean; json: string }) => void;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
aiSessionsBackupWrite: async ({ json }: { json: string }) => { backups.push(json); return { ok: true }; },
|
||||
aiSessionsBackupReadLatest: () => new Promise(resolve => { resolveRead = resolve; }),
|
||||
} });
|
||||
let ai!: ReturnType<typeof useAIState>;
|
||||
let snapshot!: ReturnType<typeof useAISessionsStore>;
|
||||
function Harness() {
|
||||
ai = useAIState();
|
||||
snapshot = useAISessionsStore();
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(snapshot.activeSessionIdMap['global:'], diskOnly.id);
|
||||
await act(async () => {
|
||||
if (mutation === 'rename') ai.updateSessionTitle(primary.id, 'Renamed before recovery');
|
||||
else if (mutation === 'create') ai.createSession({ type: 'global' }, 'catty');
|
||||
else ai.deleteSession(mutation === 'delete-pending' ? diskOnly.id : primary.id);
|
||||
});
|
||||
const liveBeforeRecovery = [...snapshot.sessions];
|
||||
const expectedActiveId = mutation === 'create' ? liveBeforeRecovery[0].id
|
||||
: mutation === 'delete-pending' ? null : diskOnly.id;
|
||||
assert.equal(snapshot.activeSessionIdMap['global:'], expectedActiveId);
|
||||
assert.deepEqual(readSessionsFromStorage(), liveBeforeRecovery);
|
||||
const recoveryMarker = dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW);
|
||||
assert.deepEqual(recoveryMarker == null ? null : JSON.parse(recoveryMarker), mutation === 'delete-pending' ? null : [diskOnly.id]);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups.map(json => JSON.parse(json)), mutation === 'delete-pending' ? [liveBeforeRecovery] : []);
|
||||
|
||||
await act(async () => { resolveRead({ ok: true, json: JSON.stringify([primary, diskOnly]) }); });
|
||||
const expected = mutation === 'delete-pending' ? liveBeforeRecovery : [...liveBeforeRecovery, diskOnly];
|
||||
assert.deepEqual(snapshot.sessions, expected);
|
||||
assert.deepEqual(readSessionsFromStorage(), expected);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(JSON.parse(backups.at(-1)!), expected);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
setLatestAIActiveSessionMapSnapshot({});
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('count-cap eviction clears a loaded disk-only marker and resumes backup mirroring', () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
const source: AISession[] = Array.from({ length: 51 }, (_, index) => ({
|
||||
id: `chat-${index}`, title: `Chat ${index}`, agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 51 - index, messages: [],
|
||||
}));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify([source[50].id]));
|
||||
const backups: string[] = [];
|
||||
Object.assign(dom.window, { netcatty: { aiSessionsBackupWrite: async ({ json }: { json: string }) => {
|
||||
backups.push(json);
|
||||
return { ok: true };
|
||||
} } });
|
||||
__resetBackupMirrorForTests();
|
||||
try {
|
||||
assert.equal(writeSessionsForStorage(source), true);
|
||||
assert.deepEqual(readSessionsFromStorage(), source.slice(0, 50));
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
flushBackupMirror();
|
||||
assert.deepEqual(backups.map(json => JSON.parse(json)), [source.slice(0, 50)]);
|
||||
} finally {
|
||||
__resetBackupMirrorForTests();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
for (const keepPrimary of [true, false]) {
|
||||
test(`deletion clears disk-only recovery and remains authoritative (keep primary ${keepPrimary})`, async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
|
||||
}
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
const primary: AISession = {
|
||||
id: 'primary', title: 'Primary', agentId: 'catty', scope: { type: 'global' },
|
||||
createdAt: 1, updatedAt: 2, messages: [],
|
||||
};
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([primary]));
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW, JSON.stringify(['deleted-chat']));
|
||||
const remaining = keepPrimary ? [primary] : [];
|
||||
assert.equal(writeSessionsForStorage(remaining, { deletedSessionIds: ['deleted-chat'] }), true);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW), null);
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
let reads = 0;
|
||||
Object.assign(dom.window, { netcatty: { aiSessionsBackupReadLatest: async () => {
|
||||
reads += 1;
|
||||
return { ok: true, json: JSON.stringify([primary, { ...primary, id: 'deleted-chat' }]) };
|
||||
} } });
|
||||
let sessions: readonly AISession[] = [];
|
||||
function Harness() {
|
||||
useAIState();
|
||||
sessions = useAISessionsStore().sessions;
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await act(async () => { root.render(React.createElement(Harness)); });
|
||||
assert.equal(reads, 0);
|
||||
assert.deepEqual(sessions, remaining);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
__resetBackupMirrorForTests();
|
||||
setLatestAISessionsSnapshot([]);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
+71
-253
@@ -66,23 +66,18 @@ import {
|
||||
bumpDraftUploadGeneration,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
flushBackupMirror,
|
||||
getAIBridge,
|
||||
getDraftUploadGeneration,
|
||||
getPersistedAiSessionsSnapshot,
|
||||
isAiSessionsHydrationComplete,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAIActiveTargetIdsSnapshot,
|
||||
getAIBridge,
|
||||
flushBackupMirror,
|
||||
getDraftUploadGeneration,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAIDraftsByScopeSnapshot,
|
||||
latestAIPanelViewByScopeSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
markAiSessionsHydrationComplete,
|
||||
prepareRestoredSessions,
|
||||
readAiSessionMessageCap,
|
||||
rebindActiveSessionMap,
|
||||
readSessionsFromStorage,
|
||||
scheduleBackupMirror,
|
||||
seedLastPersistedSessions,
|
||||
setAiSessionsBackupRecoveryPending,
|
||||
rebindActiveSessionMap,
|
||||
writeSessionsForStorage,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAIDraftsByScopeSnapshot,
|
||||
@@ -91,25 +86,11 @@ import {
|
||||
type DraftsByScope,
|
||||
type PanelViewByScope,
|
||||
} from './aiStateSnapshots';
|
||||
import {
|
||||
AI_SESSIONS_IDB_VERSION,
|
||||
AI_SESSIONS_META_LAST_WRITE_KEY,
|
||||
AI_SESSIONS_META_SCHEMA_VERSION_KEY,
|
||||
idbGetAllSessions,
|
||||
idbPutMeta,
|
||||
idbPutSessions,
|
||||
loadOrMigrateAiSessions,
|
||||
mirrorActiveSessionMapToIdb,
|
||||
openAiSessionsIdb,
|
||||
publishAiSessionsIdbChange,
|
||||
readActiveSessionMapFromIdb,
|
||||
subscribeAiSessionsIdbChange,
|
||||
} from './aiSessionIdbStorage';
|
||||
import {
|
||||
isMissingOrCorruptSessionsStore,
|
||||
parseSessionsStore,
|
||||
mergeImportedSessions,
|
||||
parseBackupSessions,
|
||||
readLiveSessionsArray,
|
||||
} from './aiSessionsBackup';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
import {
|
||||
@@ -200,165 +181,53 @@ export function useAIState() {
|
||||
);
|
||||
|
||||
// ── Sessions ──
|
||||
// Memory-only initial state: the primary store is IndexedDB and loads
|
||||
// asynchronously in the boot effect below (issue #4123). The legacy
|
||||
// localStorage key is read-only fallback and must not pre-seed stale data.
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() => latestAISessionsSnapshot ?? []);
|
||||
// Issue #4125 review P2②: flips true when the boot load settles. The
|
||||
// active-pointer validation effect waits for it — the initially-empty
|
||||
// in-memory session array must never null the persisted pointers.
|
||||
const [sessionsHydrated, setSessionsHydrated] = useState(() => isAiSessionsHydrationComplete());
|
||||
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
|
||||
latestAISessionsSnapshot
|
||||
?? readSessionsFromStorage()
|
||||
);
|
||||
// An explicit empty array is authoritative, even if the backup is stale.
|
||||
// Only a missing or corrupt store can be restored from disk.
|
||||
// Disk-only overflow IDs also permit recovery, without reviving deleted chats.
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushBackupMirror);
|
||||
return () => window.removeEventListener('beforeunload', flushBackupMirror);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
|
||||
const liveStore = parseSessionsStore(liveRaw);
|
||||
const liveSessions = readSessionsFromStorage();
|
||||
const diskOnlyRecovery = (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0;
|
||||
if (!diskOnlyRecovery && (liveStore.valid || liveSessions.length > 0)) {
|
||||
if (liveSessions.length > 0) scheduleBackupMirror(JSON.stringify(liveSessions));
|
||||
return;
|
||||
}
|
||||
if (!diskOnlyRecovery && latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupReadLatest) return;
|
||||
let cancelled = false;
|
||||
let cleanupAfterHydration = false;
|
||||
const canRestore = () => diskOnlyRecovery
|
||||
? (localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW)?.length ?? 0) > 0
|
||||
: isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
|
||||
&& readSessionsFromStorage().length === 0
|
||||
&& (latestAISessionsSnapshot?.length ?? 0) === 0
|
||||
&& sessionsRef.current.length === 0;
|
||||
void (async () => {
|
||||
try {
|
||||
// 1. IndexedDB (primary) → one-time legacy localStorage migration.
|
||||
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
|
||||
setAiSessionsBackupRecoveryPending(diskOnlyIds.size > 0);
|
||||
const boot = await loadOrMigrateAiSessions(
|
||||
async () => {
|
||||
// Legacy retention split history between the primary key, JSON
|
||||
// chunks, and explicitly marked disk-only records. Resolve all
|
||||
// slices before the one-shot migration records completion.
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; ; index += 1) {
|
||||
const key = index === 0 ? STORAGE_KEY_AI_SESSIONS_OVERFLOW : `${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_${index + 1}`;
|
||||
const chunk = localStorageAdapter.readString(key);
|
||||
if (chunk == null) break;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let legacy = mergeImportedSessions(
|
||||
readLiveSessionsArray(chunks.join('')),
|
||||
readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS)),
|
||||
);
|
||||
if (diskOnlyIds.size > 0) {
|
||||
try {
|
||||
const backup = await getAIBridge()?.aiSessionsBackupReadLatest?.();
|
||||
if (backup?.ok) {
|
||||
legacy = mergeImportedSessions(
|
||||
(parseBackupSessions(backup.json) ?? []).filter(session => diskOnlyIds.has(session.id)),
|
||||
legacy,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AIState] Disk-only session recovery failed; migration will retry on the next boot:', error);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(legacy);
|
||||
},
|
||||
[...diskOnlyIds],
|
||||
);
|
||||
setAiSessionsBackupRecoveryPending(boot.migrationPending);
|
||||
if (cancelled) return;
|
||||
if (boot.idbFailed && boot.sessions.length > 0) {
|
||||
// Issue #4125 review P2①: the legacy set is not in IndexedDB yet.
|
||||
// Persist it best-effort (one re-open retry) BEFORE seeding the
|
||||
// diff baseline; if the store is still unreachable, leave the
|
||||
// baseline unset so the next edit performs a full write instead of
|
||||
// a diff that would drop the un-persisted sessions after IndexedDB
|
||||
// recovers.
|
||||
try {
|
||||
const db = await openAiSessionsIdb();
|
||||
if (!cancelled) {
|
||||
await idbPutSessions(db, boot.sessions);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_SCHEMA_VERSION_KEY, AI_SESSIONS_IDB_VERSION);
|
||||
await idbPutMeta(db, AI_SESSIONS_META_LAST_WRITE_KEY, Date.now());
|
||||
publishAiSessionsIdbChange();
|
||||
seedLastPersistedSessions(boot.sessions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[AIState] Could not persist the legacy sessions after an IndexedDB failure;'
|
||||
+ ' the diff baseline stays unset so the next edit rewrites every record:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
seedLastPersistedSessions(boot.sessions);
|
||||
}
|
||||
|
||||
// Restore the mirrored active-pointer map when the localStorage key
|
||||
// is missing (fresh profile restored from IndexedDB).
|
||||
if (localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) == null) {
|
||||
const mirrored = await readActiveSessionMapFromIdb();
|
||||
if (cancelled) return;
|
||||
if (mirrored && Object.keys(mirrored).length > 0) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, mirrored);
|
||||
setLatestAIActiveSessionMapSnapshot(mirrored);
|
||||
setActiveSessionIdMapRaw(mirrored);
|
||||
}
|
||||
}
|
||||
|
||||
const bootSessions = boot.sessions;
|
||||
if (bootSessions.length > 0) {
|
||||
const live = latestAISessionsSnapshot ?? sessionsRef.current;
|
||||
if (live.length > 0) {
|
||||
// Sessions were created before the async load finished: merge
|
||||
// (live records win by id) instead of clobbering either side.
|
||||
const byId = new Map(bootSessions.map(session => [session.id, session]));
|
||||
for (const session of live) byId.set(session.id, session);
|
||||
const merged = Array.from(byId.values())
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
setLatestAISessionsSnapshot(merged);
|
||||
sessionsRef.current = merged;
|
||||
setSessionsRaw(merged);
|
||||
writeSessionsForStorage(merged);
|
||||
return;
|
||||
}
|
||||
setLatestAISessionsSnapshot(bootSessions);
|
||||
sessionsRef.current = bootSessions;
|
||||
setSessionsRaw(bootSessions);
|
||||
scheduleBackupMirror(JSON.stringify(bootSessions));
|
||||
cleanupAfterHydration = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// A previously initialized empty IDB is a committed deletion. An
|
||||
// incomplete migration must retry only its marked disk-only records.
|
||||
if (boot.authoritativeEmpty || boot.migrationPending) return;
|
||||
|
||||
// 2. Uninitialized store (and no legacy data to migrate): the #4018 disk
|
||||
// backup remains the last restore step. Only when the legacy store
|
||||
// is also missing or corrupt — a valid empty legacy array is an
|
||||
// intentional wipe and stays authoritative.
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiSessionsBackupReadLatest) return;
|
||||
if (!canRestore()) return;
|
||||
const backup = await bridge.aiSessionsBackupReadLatest?.();
|
||||
if (cancelled || !backup?.ok) return;
|
||||
// Re-check after the async gap: a session may have been created
|
||||
// while the backup was being read; never restore over live state.
|
||||
// Cover persisted storage and in-memory snapshot/React state —
|
||||
// createSession updates the snapshot before the debounced disk write.
|
||||
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
|
||||
if (sessionsRef.current.length > 0) return;
|
||||
// Issue #4125 review P2③: one open retry already happened inside
|
||||
// the boot load. If the store is still unopenable, do not let the
|
||||
// recheck rejection reach the outer catch — the disk backup below
|
||||
// is the recovery source.
|
||||
try {
|
||||
if ((await idbGetAllSessions()).length > 0) return;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[AIState] IndexedDB recheck failed during the disk-backup restore;'
|
||||
+ ' applying the newest backup generation as the recovery source:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
|
||||
return;
|
||||
}
|
||||
// Re-check after the async gap so live mutations and deletions win.
|
||||
if (!canRestore()) return;
|
||||
const backupSessions = parseBackupSessions(backup.json);
|
||||
if (!backupSessions) return;
|
||||
const diskOnlyIds = new Set(localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []);
|
||||
const recovered = diskOnlyRecovery
|
||||
? mergeImportedSessions(
|
||||
backupSessions.filter(session => diskOnlyIds.has(session.id)),
|
||||
latestAISessionsSnapshot ?? readSessionsFromStorage(),
|
||||
)
|
||||
: backupSessions;
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
|
||||
const restored = prepareRestoredSessions(recovered, syncEnabled);
|
||||
if (restored.length === 0) return;
|
||||
// Apply restore synchronously (not inside a setState updater). A
|
||||
// deferred updater would leave didRestore false and skip dangling
|
||||
@@ -366,7 +235,11 @@ export function useAIState() {
|
||||
setLatestAISessionsSnapshot(restored);
|
||||
writeSessionsForStorage(restored);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
cleanupAfterHydration = true;
|
||||
// Restore does not change live terminals/workspaces, so the orphan-
|
||||
// cleanup effect will not run. Reuse the last known live target set
|
||||
// (empty is valid: nothing live) so chats bound to missing scopes
|
||||
// become dangling and /chats can offer /rebind.
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions = latestAISessionsSnapshot ?? restored;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
@@ -376,25 +249,10 @@ export function useAIState() {
|
||||
?? {},
|
||||
);
|
||||
console.info(
|
||||
`[AIState] Restored ${nextSessions.length} AI session(s) from the disk backup.`,
|
||||
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Boot load must never break startup — but it must not vanish
|
||||
// silently either (issue #4125 review P2③).
|
||||
console.warn('[AIState] AI session boot load failed:', error);
|
||||
} finally {
|
||||
markAiSessionsHydrationComplete();
|
||||
if (!cancelled) {
|
||||
// Maintenance may have mounted before hydration with unchanged live
|
||||
// targets. Replay it now against the authoritative boot history.
|
||||
if (cleanupAfterHydration) {
|
||||
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
|
||||
const nextSessions = latestAISessionsSnapshot ?? sessionsRef.current;
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
}
|
||||
setSessionsHydrated(true);
|
||||
}
|
||||
} catch {
|
||||
// Backup restore must never break startup.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -463,9 +321,6 @@ export function useAIState() {
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||
// Issue #4123: mirror the active-pointer map into the IndexedDB meta
|
||||
// store (localStorage stays written at the mutation sites as before).
|
||||
void mirrorActiveSessionMapToIdb(activeSessionIdMap);
|
||||
}, [activeSessionIdMap]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -477,12 +332,12 @@ export function useAIState() {
|
||||
}, [panelViewByScope]);
|
||||
|
||||
useEffect(() => {
|
||||
// Issue #4125 review P2②: before boot hydration settles, `sessions` is
|
||||
// the initially-empty in-memory array; validating pointers against it
|
||||
// would null every pointer and break workspace restore. No pointer
|
||||
// writes before hydration completes.
|
||||
if (!sessionsHydrated) return;
|
||||
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
|
||||
// Disk-only chats may not have arrived from the startup backup read yet.
|
||||
// Keep their active pointers and panel views until recovery or deletion.
|
||||
for (const id of localStorageAdapter.read<string[]>(STORAGE_KEY_AI_SESSIONS_DISK_OVERFLOW) ?? []) {
|
||||
validSessionIds.add(id);
|
||||
}
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
@@ -510,7 +365,7 @@ export function useAIState() {
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
}, [sessions, activeSessionIdMap, sessionsHydrated]);
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
@@ -702,7 +557,8 @@ export function useAIState() {
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
try {
|
||||
const key = e.key;
|
||||
const key = e.key === STORAGE_KEY_AI_SESSIONS_OVERFLOW || e.key?.startsWith(`${STORAGE_KEY_AI_SESSIONS_OVERFLOW}_`)
|
||||
? STORAGE_KEY_AI_SESSIONS : e.key;
|
||||
switch (key) {
|
||||
case STORAGE_KEY_AI_PROVIDERS: {
|
||||
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
|
||||
@@ -795,10 +651,7 @@ export function useAIState() {
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_SESSIONS: {
|
||||
// Session writes no longer land in localStorage (issue #4123);
|
||||
// the cross-window signal is the IndexedDB change channel and the
|
||||
// storage event only fires as a legacy fallback.
|
||||
const nextSessions = getPersistedAiSessionsSnapshot();
|
||||
const nextSessions = readSessionsFromStorage();
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
setSessionsRaw(nextSessions);
|
||||
break;
|
||||
@@ -840,7 +693,7 @@ export function useAIState() {
|
||||
case STORAGE_KEY_AI_SESSIONS:
|
||||
setSessionsRaw(
|
||||
latestAISessionsSnapshot
|
||||
?? getPersistedAiSessionsSnapshot(),
|
||||
?? readSessionsFromStorage(),
|
||||
);
|
||||
return;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
||||
@@ -867,37 +720,6 @@ export function useAIState() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cross-window AI session sync: session writes land in IndexedDB now, so
|
||||
// the localStorage `storage` event no longer fires for them. Other windows
|
||||
// announce their writes on a BroadcastChannel; the receiver reloads the
|
||||
// store from IndexedDB.
|
||||
useEffect(() => subscribeAiSessionsIdbChange(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await idbGetAllSessions();
|
||||
if (next.length === 0) {
|
||||
if (!isAiSessionsHydrationComplete()) return;
|
||||
// Issue #4125 review P2④: after hydration an empty store is an
|
||||
// intentional cross-window deletion — adopt it instead of keeping
|
||||
// the deleted session alive locally (its next edit would
|
||||
// resurrect it). The pointer-validation effect clears the
|
||||
// now-stale pointers. Only the pre-hydration window keeps the
|
||||
// early-return: the boot load owns the store view until it
|
||||
// settles.
|
||||
setLatestAISessionsSnapshot([]);
|
||||
seedLastPersistedSessions([]);
|
||||
setSessionsRaw([]);
|
||||
return;
|
||||
}
|
||||
setLatestAISessionsSnapshot(next);
|
||||
seedLastPersistedSessions(next);
|
||||
setSessionsRaw(next);
|
||||
} catch {
|
||||
// Cross-window refresh is best-effort.
|
||||
}
|
||||
})();
|
||||
}), []);
|
||||
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
useEffect(() => {
|
||||
const bridge = getAIBridge();
|
||||
@@ -1209,6 +1031,8 @@ export function useAIState() {
|
||||
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
|
||||
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
|
||||
if (!writeSessionsForStorage(next)) return 0;
|
||||
// Local readback can omit disk-only overflow chats. Keep the complete
|
||||
// prepared history live so cleanup and later writes retain those chats.
|
||||
const importedIds = new Set(imported.map(session => session.id));
|
||||
sessionsRef.current = next;
|
||||
setLatestAISessionsSnapshot(next);
|
||||
@@ -1245,27 +1069,22 @@ export function useAIState() {
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
// Local-only retention. Synced history must match the full-history storage
|
||||
// policy. The in-memory bound follows the user-configurable per-session cap
|
||||
// (see aiStateSnapshots.readAiSessionMessageCap) but never below this floor,
|
||||
// so the default behavior is unchanged and RAM stays bounded when the user
|
||||
// lowers the persisted cap.
|
||||
const MIN_IN_MEMORY_MESSAGES_PER_SESSION = 500;
|
||||
// Local-only retention. Synced history must match the full-history storage policy.
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
|
||||
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
|
||||
const inMemoryCap = Math.max(readAiSessionMessageCap(), MIN_IN_MEMORY_MESSAGES_PER_SESSION);
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => {
|
||||
if (s.id !== sessionId) return s;
|
||||
let msgs = [...s.messages, message];
|
||||
// Dropping synced prefixes makes the next cloud merge add them back.
|
||||
if (!syncEnabled && msgs.length > inMemoryCap) {
|
||||
if (!syncEnabled && msgs.length > MAX_MESSAGES_PER_SESSION) {
|
||||
const systemMsgs = msgs.filter(m => m.role === 'system');
|
||||
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
|
||||
const dropped = nonSystemMsgs.length - (inMemoryCap - systemMsgs.length);
|
||||
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${inMemoryCap} limit`);
|
||||
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-inMemoryCap + systemMsgs.length)];
|
||||
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
|
||||
console.warn(`[useAIState] Session ${sessionId}: trimmed ${dropped} oldest non-system message(s) to stay within ${MAX_MESSAGES_PER_SESSION} limit`);
|
||||
msgs = [...systemMsgs, ...nonSystemMsgs.slice(-MAX_MESSAGES_PER_SESSION + systemMsgs.length)];
|
||||
}
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
@@ -1503,11 +1322,10 @@ export function useAIState() {
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
cleanupOrphanedAISessions(activeTargetIds);
|
||||
if (!isAiSessionsHydrationComplete()) return;
|
||||
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? getPersistedAiSessionsSnapshot();
|
||||
?? readSessionsFromStorage();
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
@@ -1559,7 +1377,7 @@ export function useAIState() {
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? getPersistedAiSessionsSnapshot();
|
||||
?? readSessionsFromStorage();
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: currentMap,
|
||||
@@ -1604,7 +1422,7 @@ export function useAIState() {
|
||||
?? {};
|
||||
const currentSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? getPersistedAiSessionsSnapshot();
|
||||
?? readSessionsFromStorage();
|
||||
const result = retargetWorkspaceActiveChatAfterMemberLoss({
|
||||
activeSessionIdMap: currentMap,
|
||||
sessions: currentSessions,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRunt
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { getSessionScopeMatchRank } from '../../components/ai/sessionScopeMatch.ts';
|
||||
import { getPersistedAiSessionsSnapshot, setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SCOPE_KEY = 'terminal:terminal-a';
|
||||
|
||||
@@ -175,21 +175,17 @@ for (const newerCount of [49, 50]) {
|
||||
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
|
||||
});
|
||||
assert.ok(branchId);
|
||||
// Issue #4123: the persisted set lives in IndexedDB (count cap 1000, so
|
||||
// nothing is evicted at these sizes) — not in a 50-capped localStorage blob.
|
||||
const saved: AISession[] = getPersistedAiSessionsSnapshot();
|
||||
assert.equal(saved.length, newerCount + 2);
|
||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.equal(saved.length, 50);
|
||||
const original = saved.find(item => item.id === SOURCE_SESSION.id);
|
||||
assert.ok(original, 'the original must survive updatedAt-based history pruning');
|
||||
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
// Seed the read-only legacy store with the persisted set (the stand-in
|
||||
// for what the migration persists on a profile without IndexedDB).
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(saved));
|
||||
dom.window.close();
|
||||
container.remove(); dom.window.close();
|
||||
|
||||
// Remount the actual hook from the persisted records, as on app reload.
|
||||
const reloaded = await setupAiState(saved);
|
||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
@@ -227,7 +223,7 @@ for (const viewingScope of [
|
||||
), 3);
|
||||
const original = capture.sessions.find(item => item.id === source.id)!;
|
||||
assert.deepEqual({ ...original, updatedAt: sourceBefore.updatedAt }, sourceBefore);
|
||||
const saved = getPersistedAiSessionsSnapshot();
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!) as AISession[];
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.scope, branch.scope);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
|
||||
@@ -156,139 +156,6 @@ for (const preference of ["system", "light", "dark"] as const) {
|
||||
}
|
||||
}
|
||||
|
||||
// A hanging jsdom/React render must fail this test quickly instead of
|
||||
// consuming the whole CI job budget.
|
||||
test("terminal chrome never replaces the application native theme policy", { timeout: 15000 }, async () => {
|
||||
const { JSDOM } = await import("jsdom");
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalSnapshot = getSettingsChromeSnapshot();
|
||||
const themeSources: string[] = [];
|
||||
const backgrounds: string[] = [];
|
||||
const testWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
testWindow.netcatty = {
|
||||
setTheme: async (source: string) => { themeSources.push(source); return true; },
|
||||
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
|
||||
} as unknown as NetcattyBridge;
|
||||
globalThis.window = testWindow;
|
||||
globalThis.document = testWindow.document;
|
||||
try {
|
||||
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
|
||||
for (const type of ['light', 'dark'] as const) {
|
||||
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
|
||||
assert.ok(theme);
|
||||
syncActiveChromeTheme(theme, () => {});
|
||||
assert.equal(document.documentElement.classList.contains(type), true);
|
||||
assert.equal(backgrounds.at(-1), theme.colors.background);
|
||||
}
|
||||
assert.deepEqual(themeSources, ['system', 'system'], 'terminal colors must not pin nativeTheme.themeSource to light/dark');
|
||||
} finally {
|
||||
publishSettingsChromeSnapshot(originalSnapshot);
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("system preference refresh reapplies an unchanged independent terminal chrome", { timeout: 15000 }, async () => {
|
||||
const { JSDOM } = await import("jsdom");
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body><div data-top-tabs-root></div></body></html>');
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalSnapshot = getSettingsChromeSnapshot();
|
||||
const backgrounds: string[] = [];
|
||||
const testWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
testWindow.netcatty = {
|
||||
setTheme: (source: string) => {
|
||||
assert.equal(source, 'system', 'chrome refresh must preserve native system theme policy');
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
setBackgroundColor: async (background: string) => { backgrounds.push(background); return true; },
|
||||
} as unknown as NetcattyBridge;
|
||||
globalThis.window = testWindow;
|
||||
globalThis.document = testWindow.document;
|
||||
try {
|
||||
publishSettingsChromeSnapshot({ ...originalSnapshot, theme: "system" });
|
||||
for (const type of ['light', 'dark'] as const) {
|
||||
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === type);
|
||||
assert.ok(theme);
|
||||
syncActiveChromeTheme(theme, () => {});
|
||||
// The application settings effect runs first on an OS preference change.
|
||||
document.documentElement.className = type === 'dark' ? 'light' : 'dark';
|
||||
backgrounds.push('application-background');
|
||||
syncActiveChromeTheme(theme, () => {}, { refreshSurfaces: true });
|
||||
assert.equal(document.documentElement.classList.contains(type), true);
|
||||
assert.equal(backgrounds.at(-1), theme.colors.background);
|
||||
}
|
||||
syncActiveChromeTheme(null, () => {
|
||||
document.documentElement.className = 'light';
|
||||
backgrounds.push('application-background');
|
||||
});
|
||||
assert.equal(document.documentElement.className, 'light');
|
||||
assert.equal(document.documentElement.dataset.activeChromeTheme, undefined);
|
||||
assert.equal(document.getElementById('netcatty-active-chrome-theme'), null);
|
||||
assert.equal(backgrounds.at(-1), 'application-background');
|
||||
} finally {
|
||||
publishSettingsChromeSnapshot(originalSnapshot);
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("chrome hook reacts to OS preference changes with stable terminal props", { timeout: 15000 }, async () => {
|
||||
const { JSDOM } = await import('jsdom');
|
||||
const { act, createElement } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { useActiveChromeTheme } = await import('./useActiveChromeTheme');
|
||||
const { getSettingsChromeSnapshot, publishSettingsChromeSnapshot } = await import('./settingsChromeStore');
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>');
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const originalSnapshot = getSettingsChromeSnapshot();
|
||||
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const originalActEnvironment = environment.IS_REACT_ACT_ENVIRONMENT;
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const backgrounds: string[] = [];
|
||||
const testWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.window = testWindow;
|
||||
globalThis.document = dom.window.document;
|
||||
testWindow.netcatty = {
|
||||
setBackgroundColor: async (color: string) => { backgrounds.push(color); return true; },
|
||||
} as unknown as NetcattyBridge;
|
||||
const theme = TERMINAL_THEMES.find((candidate) => candidate.type === 'dark');
|
||||
assert.ok(theme);
|
||||
const applyAppTheme = () => {};
|
||||
function Harness() {
|
||||
useActiveChromeTheme({ activeTheme: theme!, applyAppTheme });
|
||||
return null;
|
||||
}
|
||||
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'dark' });
|
||||
let renderer: ReturnType<typeof createRoot> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = createRoot(document.createElement('div'));
|
||||
renderer.render(createElement(Harness));
|
||||
});
|
||||
assert.equal(document.documentElement.classList.contains('dark'), true);
|
||||
document.documentElement.className = 'light';
|
||||
backgrounds.push('application-background');
|
||||
await act(async () => {
|
||||
publishSettingsChromeSnapshot({ ...originalSnapshot, resolvedTheme: 'light' });
|
||||
});
|
||||
assert.equal(document.documentElement.classList.contains('dark'), true);
|
||||
assert.equal(backgrounds.at(-1), theme.colors.background);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
environment.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment;
|
||||
publishSettingsChromeSnapshot(originalSnapshot);
|
||||
globalThis.window = originalWindow;
|
||||
globalThis.document = originalDocument;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("active chrome theme foreground uses contrast calculation", () => {
|
||||
const source = readFileSync(new URL("./useActiveChromeTheme.ts", import.meta.url), "utf8");
|
||||
|
||||
|
||||
@@ -237,9 +237,9 @@ function applyActiveChromeTheme(theme: TerminalTheme) {
|
||||
}
|
||||
|
||||
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
|
||||
const targetClass = theme.type === "dark" ? "dark" : "light";
|
||||
if (typeof window !== "undefined") {
|
||||
// Preserve OS preference events instead of locking nativeTheme to the chrome type.
|
||||
const targetClass = theme.type === "dark" ? "dark" : "light";
|
||||
const themeSource = getSettingsChromeSnapshot().theme === "system" ? "system" : targetClass;
|
||||
netcattyBridge.get()?.setTheme?.(themeSource);
|
||||
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
|
||||
@@ -250,11 +250,10 @@ function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
|
||||
export function syncActiveChromeTheme(
|
||||
activeTheme: TerminalTheme | null,
|
||||
applyAppTheme: () => void,
|
||||
{ refreshSurfaces = false }: { refreshSurfaces?: boolean } = {},
|
||||
): void {
|
||||
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
|
||||
const appliedFingerprint = getAppliedChromeFingerprint();
|
||||
if (nextFingerprint === appliedFingerprint && !(activeTheme && refreshSurfaces)) {
|
||||
if (nextFingerprint === appliedFingerprint) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,11 +283,8 @@ export function useActiveChromeTheme({
|
||||
applyAppThemeRef.current = applyAppTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Settings may have just applied the OS theme over a fixed terminal theme.
|
||||
// Reassert its visual surfaces even when the terminal fingerprint is stable;
|
||||
// tab-switch callers retain the default fingerprint fast path.
|
||||
syncActiveChromeTheme(effectiveTheme, applyAppTheme, { refreshSurfaces: true });
|
||||
}, [effectiveTheme, applyAppTheme, pluginThemeRevision, resolvedTheme]);
|
||||
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
|
||||
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback } from 'react';
|
||||
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
|
||||
import { useStoredNumber } from './useStoredNumber';
|
||||
|
||||
export const COMPOSE_BAR_HEIGHT_DEFAULT = 144;
|
||||
export const COMPOSE_BAR_HEIGHT_MIN = 104;
|
||||
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
|
||||
export const COMPOSE_BAR_HEIGHT_MIN = 72;
|
||||
export const COMPOSE_BAR_HEIGHT_MAX = 360;
|
||||
|
||||
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { useComposeDirectoryPicker } from './useComposeDirectoryPicker';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { refreshObservedTerminalDirectory, registerTerminalDirectory } from './terminalDirectoryRegistry';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import { DirectoryPickerError } from '../../domain/directoryPicker';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
test('opening loads children once; search is local; unmount stops chip refresh demand', async () => {
|
||||
let reads = 0; let probes = 0;
|
||||
const id = 'hook-directory';
|
||||
const controller = createTerminalDirectoryController({ sessionId: id,
|
||||
io: { cwd: async () => { probes++; return '/base'; }, realpath: async path => path, write() {},
|
||||
list: async () => { reads++; return { home: '/base', entries: [{ name: 'child', path: '/base/child', hidden: false }] }; } },
|
||||
isCurrent: () => true, isReady: () => true,
|
||||
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
const cleanup = registerTerminalDirectory(id, controller);
|
||||
let model!: ReturnType<typeof useComposeDirectoryPicker>;
|
||||
function Harness() { model = useComposeDirectoryPicker(id); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
assert.equal(model.cwd, '/base');
|
||||
await act(async () => { model.setOpen(true); });
|
||||
assert.equal(reads, 1);
|
||||
await act(async () => { model.setQuery('CH'); });
|
||||
assert.equal(model.entries.length, 1);
|
||||
assert.equal(reads, 1);
|
||||
await act(async () => { root.unmount(); });
|
||||
const before = probes;
|
||||
refreshObservedTerminalDirectory(id);
|
||||
await Promise.resolve();
|
||||
assert.equal(probes, before);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('switching terminal cancels an old directory read and closes its popover', async () => {
|
||||
let finish!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
|
||||
const cleanups = ['hook-a', 'hook-b'].map(id => registerTerminalDirectory(id, createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => '/' + id, realpath: async path => path, write() {},
|
||||
list: () => new Promise(resolve => { finish = resolve; }) },
|
||||
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
})));
|
||||
let model!: ReturnType<typeof useComposeDirectoryPicker>;
|
||||
function Harness({ id }: { id: string }) { model = useComposeDirectoryPicker(id); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness, { id: 'hook-a' })); });
|
||||
await act(async () => { model.setOpen(true); });
|
||||
await act(async () => { root.update(React.createElement(Harness, { id: 'hook-b' })); });
|
||||
await act(async () => { finish({ entries: [{ name: 'old', path: '/hook-a/old', hidden: false }], home: null }); });
|
||||
assert.equal(model.open, false);
|
||||
assert.equal(model.cwd, '/hook-b');
|
||||
assert.deepEqual(model.entries, []);
|
||||
await act(async () => { root.unmount(); });
|
||||
cleanups.forEach(cleanup => cleanup());
|
||||
});
|
||||
|
||||
test('reconnect after an aborted listing allows manual recovery from a failed cwd probe', async t => {
|
||||
const id = 'hook-directory-reconnect';
|
||||
let finishOld!: (value: { entries: { name: string; path: string; hidden: boolean }[]; home: null }) => void;
|
||||
const oldController = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => '/old', realpath: async value => value, write() {},
|
||||
list: () => new Promise(resolve => { finishOld = resolve; }) },
|
||||
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
const unregisterOld = registerTerminalDirectory(id, oldController);
|
||||
let model!: ReturnType<typeof useComposeDirectoryPicker>;
|
||||
function Harness() { model = useComposeDirectoryPicker(id); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
let unregisterNew = () => {};
|
||||
t.after(async () => {
|
||||
await act(async () => { root.unmount(); });
|
||||
unregisterOld(); unregisterNew();
|
||||
});
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
await act(async () => { model.setOpen(true); });
|
||||
assert.equal(model.loading, true);
|
||||
// Close while the request is pending, so the read is already aborted when
|
||||
// the disconnect disposes its controller and settles the old promise.
|
||||
await act(async () => { model.setOpen(false); });
|
||||
await act(async () => { unregisterOld(); terminalCwdStore.setCwd(id, null); });
|
||||
let failProbe = true;
|
||||
const nextController = createTerminalDirectoryController({
|
||||
sessionId: id, isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => { if (failProbe) throw new DirectoryPickerError('unknown'); return '/recovered'; },
|
||||
realpath: async value => value, write() {},
|
||||
list: async () => ({ entries: [{ name: 'new', path: '/recovered/new', hidden: false }], home: null }) },
|
||||
publish: cwd => { terminalCwdStore.setCwd(id, cwd, 'backend-strict'); },
|
||||
});
|
||||
await act(async () => { unregisterNew = registerTerminalDirectory(id, nextController); });
|
||||
assert.equal(model.cwd, null);
|
||||
assert.equal(model.error, 'unknown');
|
||||
assert.equal(model.loading, false, 'cancelled reads must not leave the manual Refresh action disabled');
|
||||
await act(async () => { failProbe = false; await model.refresh(); model.setOpen(true); });
|
||||
assert.equal(model.cwd, '/recovered');
|
||||
assert.equal(model.loading, false);
|
||||
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
|
||||
await act(async () => { finishOld({ entries: [{ name: 'stale', path: '/old/stale', hidden: false }], home: null }); });
|
||||
assert.deepEqual(model.entries.map(entry => entry.name), ['new']);
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { DirectoryPickerError, directoryParent, filterDirectories, type DirectoryEntry, type DirectoryPickerErrorCode } from '../../domain/directoryPicker';
|
||||
import { terminalCwdStore } from './terminalCwdStore';
|
||||
import { getTerminalDirectory, observeTerminalDirectory, subscribeTerminalDirectories } from './terminalDirectoryRegistry';
|
||||
|
||||
export function useComposeDirectoryPicker(sessionId: string) {
|
||||
const controller = useSyncExternalStore(subscribeTerminalDirectories, () => getTerminalDirectory(sessionId), () => null);
|
||||
useSyncExternalStore(terminalCwdStore.subscribe, terminalCwdStore.getVersion, terminalCwdStore.getVersion);
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const cwd = controller && (confirmed || controller.hasConfirmedCwd() || terminalCwdStore.getSource(sessionId) === 'osc7')
|
||||
? terminalCwdStore.getCwd(sessionId) : null;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
|
||||
const [home, setHome] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const listingBase = useRef<string | null>(null);
|
||||
const [error, setError] = useState<DirectoryPickerErrorCode | null>(null);
|
||||
const generation = useRef(0);
|
||||
const lifetime = useRef(new AbortController());
|
||||
const read = useRef<AbortController | null>(null);
|
||||
const sending = useRef(false);
|
||||
const report = useCallback((failure: unknown, fallback: DirectoryPickerErrorCode) => {
|
||||
setError(failure instanceof DirectoryPickerError ? failure.code : fallback);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = ++generation.current;
|
||||
const stopObserving = observeTerminalDirectory(sessionId);
|
||||
lifetime.current = new AbortController();
|
||||
sending.current = false;
|
||||
setConfirmed(false); setOpen(false); setPending(false); setLoading(false); setHome(null); setError(null); setEntries([]);
|
||||
listingBase.current = null;
|
||||
if (controller) void Promise.all([controller.refresh(), controller.home()]).then(([, nextHome]) => {
|
||||
if (generation.current === id) { setConfirmed(true); setHome(nextHome); }
|
||||
}).catch(failure => { if (generation.current === id) report(failure, 'unknown'); });
|
||||
return () => {
|
||||
generation.current = id + 1;
|
||||
lifetime.current.abort(); read.current?.abort(); stopObserving();
|
||||
};
|
||||
}, [sessionId, controller, report]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!controller) { setError('unavailable'); return; }
|
||||
const id = generation.current;
|
||||
setError(null);
|
||||
try { await controller.refresh(); if (generation.current === id) setConfirmed(true); }
|
||||
catch (failure) { if (generation.current === id) report(failure, 'unknown'); }
|
||||
}, [controller, report]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!controller || !cwd) return;
|
||||
read.current?.abort();
|
||||
const task = new AbortController(); read.current = task;
|
||||
const id = generation.current;
|
||||
setLoading(true); setEntries([]); setError(null);
|
||||
try {
|
||||
const result = await controller.list(cwd, task.signal);
|
||||
if (!task.signal.aborted && id === generation.current) {
|
||||
listingBase.current = cwd; setEntries(result.entries); setHome(result.home);
|
||||
}
|
||||
} catch (failure) {
|
||||
if (!task.signal.aborted && id === generation.current) report(failure, 'readFailed');
|
||||
} finally { if (!task.signal.aborted && id === generation.current) setLoading(false); }
|
||||
}, [controller, cwd, report]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setQuery(''); void load(); }
|
||||
return () => { read.current?.abort(); };
|
||||
}, [open, load]);
|
||||
|
||||
const change = useCallback(async (path: string) => {
|
||||
if (!controller) { setError('unavailable'); return; }
|
||||
if (sending.current) return;
|
||||
if (!cwd || terminalCwdStore.getCwd(sessionId) !== cwd
|
||||
|| (path !== directoryParent(cwd) && listingBase.current !== cwd)) { setError('unavailable'); return; }
|
||||
sending.current = true; setPending(true); setError(null);
|
||||
const id = generation.current;
|
||||
try {
|
||||
await controller.change(path, lifetime.current.signal, () => {
|
||||
if (id === generation.current) setOpen(false);
|
||||
}, cwd ?? undefined);
|
||||
} catch (failure) { if (id === generation.current) report(failure, 'changeFailed'); }
|
||||
finally {
|
||||
if (id === generation.current) { sending.current = false; setPending(false); }
|
||||
}
|
||||
}, [controller, report, cwd, sessionId]);
|
||||
|
||||
const filtered = useMemo(() => filterDirectories(entries, query, showHidden), [entries, query, showHidden]);
|
||||
return { cwd, home, open, setOpen, query, setQuery, showHidden, setShowHidden, entries: filtered,
|
||||
parent: cwd ? directoryParent(cwd) : null, loading, pending, error, available: !!controller, refresh, reload: load, change };
|
||||
}
|
||||
@@ -1231,15 +1231,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Listen for system theme changes from main process (fallback when matchMedia doesn't fire)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSystemThemeChanged) return;
|
||||
return bridge.onSystemThemeChanged((payload) => {
|
||||
setSystemPreference(payload.isDark ? 'dark' : 'light');
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
document.documentElement.lang = uiLanguage;
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host } from '../../domain/models';
|
||||
import { getTerminalDirectory, observeTerminalDirectory } from './terminalDirectoryRegistry';
|
||||
import { useTerminalDirectorySession } from './useTerminalDirectorySession';
|
||||
import { useComposeDirectoryPicker } from './useComposeDirectoryPicker';
|
||||
|
||||
type SessionProps = Parameters<typeof useTerminalDirectorySession>[0];
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
function installBridge(t: TestContext, getAppInfo?: NetcattyBridge['getAppInfo']) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
let probes = 0; let lists = 0;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
|
||||
getAppInfo,
|
||||
getSessionPwd: async () => { probes++; return { success: true, cwd: '/actual' }; },
|
||||
listLocalDir: async () => { lists++; return []; },
|
||||
} } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
return { counts: () => ({ probes, lists }) };
|
||||
}
|
||||
|
||||
const options = (id: string, protocol = 'local', shellType = 'posix'): SessionProps => ({
|
||||
sessionId: id, status: 'connected',
|
||||
host: { id, label: 'Test', hostname: 'host', username: 'me', protocol } as Host,
|
||||
shellType, getBackendId: () => id, isReady: () => true, publish() {},
|
||||
});
|
||||
|
||||
for (const wrapper of ['wsl-Ubuntu', 'git-bash', 'cygwin']) {
|
||||
test(`Windows ${wrapper} reports directory navigation unavailable and performs no reads`, async t => {
|
||||
const bridge = installBridge(t, async () => ({ name: 'Netcatty', version: 'test', platform: 'win32' }));
|
||||
const id = 'windows-' + wrapper;
|
||||
let model!: ReturnType<typeof useComposeDirectoryPicker>;
|
||||
function Harness() {
|
||||
const props = options(id);
|
||||
props.host = { ...props.host, localShell: wrapper };
|
||||
useTerminalDirectorySession(props);
|
||||
model = useComposeDirectoryPicker(id);
|
||||
return null;
|
||||
}
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
t.after(async () => { await act(async () => { root.unmount(); }); });
|
||||
assert.equal(getTerminalDirectory(id), null);
|
||||
assert.equal(model.available, false);
|
||||
await act(async () => { model.setOpen(true); await model.refresh(); });
|
||||
assert.equal(model.error, 'unavailable');
|
||||
assert.deepEqual(bridge.counts(), { probes: 0, lists: 0 });
|
||||
});
|
||||
}
|
||||
|
||||
for (const platform of ['linux', 'darwin']) {
|
||||
test(`local POSIX directory controller registers on ${platform}`, async t => {
|
||||
installBridge(t, async () => ({ name: 'Netcatty', version: 'test', platform }));
|
||||
const id = 'local-' + platform;
|
||||
function Harness() { useTerminalDirectorySession(options(id)); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
t.after(async () => { await act(async () => { root.unmount(); }); });
|
||||
assert.ok(getTerminalDirectory(id));
|
||||
});
|
||||
}
|
||||
|
||||
test('Windows SSH sessions do not inherit the local cwd platform limitation', async t => {
|
||||
let platformQueries = 0;
|
||||
installBridge(t, async () => { platformQueries++; return { name: 'Netcatty', version: 'test', platform: 'win32' }; });
|
||||
const id = 'windows-ssh';
|
||||
function Harness() { useTerminalDirectorySession(options(id, 'ssh')); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness)); });
|
||||
t.after(async () => { await act(async () => { root.unmount(); }); });
|
||||
assert.ok(getTerminalDirectory(id));
|
||||
assert.equal(platformQueries, 0);
|
||||
});
|
||||
|
||||
test('a late platform query cannot register a disconnected terminal', async t => {
|
||||
let finish!: (info: { name: string; version: string; platform: string }) => void;
|
||||
installBridge(t, () => new Promise(resolve => { finish = resolve; }));
|
||||
const id = 'disconnected-during-platform-query';
|
||||
const stopObserving = observeTerminalDirectory(id);
|
||||
t.after(stopObserving);
|
||||
function Harness({ status }: { status: string }) { useTerminalDirectorySession({ ...options(id), status }); return null; }
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(React.createElement(Harness, { status: 'connected' })); });
|
||||
await act(async () => { root.update(React.createElement(Harness, { status: 'disconnected' })); });
|
||||
await act(async () => { finish({ name: 'Netcatty', version: 'test', platform: 'linux' }); });
|
||||
assert.equal(getTerminalDirectory(id), null);
|
||||
await act(async () => { root.unmount(); });
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Host } from '../../domain/models';
|
||||
import { classifyDistroId, hostRestrictsExtraSshChannels } from '../../domain/host';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { createTerminalDirectoryIO } from '../../infrastructure/services/terminalDirectory';
|
||||
import { createTerminalDirectoryController } from './terminalDirectoryController';
|
||||
import { registerTerminalDirectory } from './terminalDirectoryRegistry';
|
||||
|
||||
export function useTerminalDirectorySession(options: {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
host: Host;
|
||||
shellType?: string;
|
||||
getBackendId: () => string | null;
|
||||
isReady: () => boolean;
|
||||
publish: (cwd: string) => void;
|
||||
}) {
|
||||
const live = useRef(options);
|
||||
live.current = options;
|
||||
const { sessionId, status, shellType, host } = options;
|
||||
const local = host.protocol === 'local';
|
||||
const supported = (local || ((host.protocol ?? 'ssh') === 'ssh' && !host.moshEnabled && !host.etEnabled))
|
||||
&& shellType !== 'powershell' && shellType !== 'cmd'
|
||||
&& host.deviceType !== 'network' && classifyDistroId(host.distro) !== 'network-device'
|
||||
&& !hostRestrictsExtraSshChannels(host);
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const backendId = live.current.getBackendId();
|
||||
if (!bridge || !backendId || status !== 'connected' || !supported) return;
|
||||
let active = true;
|
||||
const isCurrent = () => active && live.current.status === 'connected' && live.current.getBackendId() === backendId;
|
||||
let unregister = () => {};
|
||||
const register = () => {
|
||||
if (!isCurrent()) return;
|
||||
const controller = createTerminalDirectoryController({
|
||||
sessionId,
|
||||
io: createTerminalDirectoryIO(bridge, backendId, {
|
||||
local, hostname: host.hostname, username: host.username || 'root', port: host.port,
|
||||
encoding: host.sftpEncoding,
|
||||
}, isCurrent),
|
||||
isCurrent,
|
||||
isReady: () => live.current.isReady(),
|
||||
publish: cwd => live.current.publish(cwd),
|
||||
});
|
||||
unregister = registerTerminalDirectory(sessionId, controller);
|
||||
};
|
||||
if (local) {
|
||||
// POSIX wrappers on Windows still use win32 PTYs. Neither their cwd nor
|
||||
// their path namespace is supported by readLocalSessionCwd/local FS.
|
||||
void bridge.getAppInfo?.().then(info => {
|
||||
if (info.platform === 'darwin' || info.platform === 'linux') register();
|
||||
}).catch(() => {});
|
||||
} else register();
|
||||
return () => { active = false; unregister(); };
|
||||
}, [sessionId, status, supported, local, host.hostname, host.username, host.port, host.sftpEncoding, shellType]);
|
||||
}
|
||||
@@ -8,8 +8,6 @@ import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { getAgentRuntime } from '../infrastructure/ai/harness/globalAgentRuntime';
|
||||
import { useAIChatStreaming, type UseAIChatStreamingReturn } from './state/useAIChatStreaming';
|
||||
import { installFakeIndexedDb } from './state/testSupport/fakeIndexedDb';
|
||||
import { idbPutSessions, openAiSessionsIdb } from './state/aiSessionIdbStorage';
|
||||
|
||||
installBrowserLockFixture();
|
||||
|
||||
@@ -45,47 +43,7 @@ function save(sessions: AISession[], enabled = true) {
|
||||
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify(sessions));
|
||||
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
|
||||
}
|
||||
test.beforeEach(() => {
|
||||
storage.clear();
|
||||
save([], false);
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
});
|
||||
test.afterEach(() => {
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
});
|
||||
|
||||
// Issue #4125 review P1①: a sync round that runs before the boot hydration
|
||||
// has settled must not collect (and persist) the stale legacy store — it
|
||||
// would overwrite newer IndexedDB records with pre-migration data.
|
||||
test('a sync round before boot hydration skips chat collection and never overwrites IndexedDB with stale legacy data', async () => {
|
||||
const idb = installFakeIndexedDb();
|
||||
try {
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
snapshots.__resetAiSessionsHydrationForTests(); // boot load still pending
|
||||
const newer = { ...session(), title: 'Newer IndexedDB copy', updatedAt: 9 };
|
||||
await idbPutSessions(await openAiSessionsIdb(), [newer]);
|
||||
// Stale pre-migration copy left in the read-only legacy key.
|
||||
storage.set(keys.STORAGE_KEY_AI_SESSIONS, JSON.stringify([{ ...session(), title: 'Stale legacy copy', updatedAt: 1 }]));
|
||||
storage.set(keys.STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled: true }));
|
||||
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
// Pre-hydration collection skips the round entirely.
|
||||
assert.equal(collectAiSessionsForSync(), undefined);
|
||||
await snapshots.__flushIdbWritesForTests();
|
||||
assert.equal(
|
||||
idb.db.dumpStore('sessions').find((s) => (s as AISession).id === 'chat')?.title,
|
||||
'Newer IndexedDB copy',
|
||||
);
|
||||
// After hydration the collector returns the live snapshot again.
|
||||
snapshots.markAiSessionsHydrationComplete();
|
||||
const hydrated = [newer];
|
||||
snapshots.seedLastPersistedSessions(hydrated);
|
||||
snapshots.setLatestAISessionsSnapshot(hydrated);
|
||||
assert.equal(collectAiSessionsForSync()?.[0].title, 'Newer IndexedDB copy');
|
||||
} finally {
|
||||
idb.restore();
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
}
|
||||
});
|
||||
test.beforeEach(() => { storage.clear(); save([], false); });
|
||||
|
||||
test('actual manual upload and auto collector honor consent and explicit empty', async () => {
|
||||
save([session()], false);
|
||||
@@ -116,16 +74,23 @@ test('sync persistence survives restart beyond the local-only history limits', (
|
||||
assert.equal(afterRestart[0].messages.length, 201);
|
||||
save(many, false);
|
||||
assert.equal(snapshots.writeSessionsForStorage(many), true);
|
||||
// Issue #4123: the persisted set now lives in IndexedDB (per-record
|
||||
// writes); the legacy localStorage key is a read-only fallback. The
|
||||
// session-count cap rose to 1000, so all 51 chats survive retention —
|
||||
// only the per-session message cap (200) still trims.
|
||||
const localOnly = snapshots.getPersistedAiSessionsSnapshot();
|
||||
assert.equal(localOnly.length, 51);
|
||||
const localOnly = JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!);
|
||||
assert.equal(localOnly.length, 50);
|
||||
assert.equal(localOnly[0].messages.length, 200);
|
||||
});
|
||||
|
||||
test('legacy localStorage quota failures no longer block sync upload', async () => {
|
||||
test('sync collection after restart includes overflow before clearing it', () => {
|
||||
const primary = session();
|
||||
const overflow = { ...session(), id: 'overflow-chat', updatedAt: 1 };
|
||||
save([primary], true);
|
||||
storage.set(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW, JSON.stringify([overflow]));
|
||||
snapshots.setLatestAISessionsSnapshot(null as unknown as AISession[]);
|
||||
assert.deepEqual(collectAiSessionsForSync()?.map(value => value.id), [primary.id, overflow.id]);
|
||||
assert.equal(storage.has(keys.STORAGE_KEY_AI_SESSIONS_OVERFLOW), false);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!).map((value: AISession) => value.id), [primary.id, overflow.id]);
|
||||
});
|
||||
|
||||
test('failed history persistence refuses upload without pruning the cloud representation', async () => {
|
||||
save([session()]);
|
||||
const original = globalThis.localStorage.setItem;
|
||||
globalThis.localStorage.setItem = (key, value) => {
|
||||
@@ -134,12 +99,8 @@ test('legacy localStorage quota failures no longer block sync upload', async ()
|
||||
};
|
||||
snapshots.setLatestAISessionsSnapshot([{ ...session(), title: 'Unsaved change' }]);
|
||||
try {
|
||||
// Persistence moved to IndexedDB (issue #4123), so the legacy key's
|
||||
// quota can no longer fail the collect step; the in-memory snapshot is
|
||||
// the source of truth for the upload.
|
||||
const cloud = await buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined });
|
||||
assert.equal(cloud.aiSessions?.[0].title, 'Unsaved change');
|
||||
assert.equal(JSON.stringify(cloud).includes('cHJpdmF0ZQ=='), false);
|
||||
await assert.rejects(buildCloudSyncPayload(vault, [], { collectPluginSidecars: () => undefined }), /persist|save/i);
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].title, 'Chat');
|
||||
const backup = buildLocalVaultPayload(vault);
|
||||
assert.equal(backup.aiSessions?.[0].title, 'Unsaved change');
|
||||
assert.equal(backup.aiSessions?.[0].messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
|
||||
@@ -203,7 +164,7 @@ test('download publishes immediately and keeps local attachment bytes on a newer
|
||||
assert.equal(current.messages[0].content, 'Updated');
|
||||
assert.equal(current.messages[0].attachments?.[0].base64Data, 'cHJpdmF0ZQ==');
|
||||
assert.equal(snapshots.latestAISessionsSnapshot?.[0], current);
|
||||
assert.equal(snapshots.getPersistedAiSessionsSnapshot()[0].messages[0].content, 'Updated');
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].messages[0].content, 'Updated');
|
||||
});
|
||||
|
||||
test('restoring an old backup without chat leaves current chats alone', async () => {
|
||||
@@ -213,7 +174,7 @@ test('restoring an old backup without chat leaves current chats alone', async ()
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
assert.deepEqual(snapshots.latestAISessionsSnapshot, [local]);
|
||||
assert.deepEqual(snapshots.getPersistedAiSessionsSnapshot(), [local]);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), [local]);
|
||||
});
|
||||
|
||||
test('authoritative cloud session removal stays removed in the next upload', async () => {
|
||||
@@ -241,9 +202,6 @@ test('replacement stops active turns and waits for finalizers before persisting
|
||||
const local = { ...session(), messages: [...session().messages,
|
||||
{ id: 'reply', role: 'assistant' as const, content: 'Partial', timestamp: 2, executionStatus: 'running' as const }] };
|
||||
save([local]);
|
||||
// Seed the per-record diff baseline so the only 'persist' marker in
|
||||
// this scenario is the final replacement write (issue #4123).
|
||||
snapshots.seedLastPersistedSessions([local]);
|
||||
hook!.abortControllersRef.current.set('chat', controller);
|
||||
await act(async () => { hook!.setStreamingForScope('chat', true); });
|
||||
Object.assign(window, { netcatty: {
|
||||
@@ -258,24 +216,15 @@ test('replacement stops active turns and waits for finalizers before persisting
|
||||
{ ...local.messages[1], content: 'Settled', executionStatus: 'cancelled' }] }]);
|
||||
order.push('settled');
|
||||
});
|
||||
// Issue #4123: session persistence is an IndexedDB commit now; the
|
||||
// 'persist' marker fires when the record lands in the store.
|
||||
globalThis.localStorage.setItem = original;
|
||||
const idb = installFakeIndexedDb({ onOperation: (store, _op) => {
|
||||
// Both puts and deletes are persistence commits for the sessions store.
|
||||
if (store === 'sessions') order.push('persist');
|
||||
} });
|
||||
globalThis.localStorage.setItem = (key, value) => { if (key === keys.STORAGE_KEY_AI_SESSIONS) order.push('persist'); original(key, value); };
|
||||
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
|
||||
{ id: 'remote', role: 'user' as const, content: 'Remote', timestamp: 3 }] };
|
||||
await applyLocalVaultPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined }, {
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
await snapshots.__flushIdbWritesForTests();
|
||||
idb.restore();
|
||||
snapshots.__resetIdbPersistenceForTests();
|
||||
assert.ok(order.indexOf('cancel') < order.indexOf('settled'));
|
||||
assert.ok(order.indexOf('settled') < order.indexOf('persist'));
|
||||
assert.ok(order.includes('cleanup'));
|
||||
assert.ok(order.indexOf('persist') < order.indexOf('cleanup'));
|
||||
assert.equal(hook!.abortControllersRef.current.has('chat'), false);
|
||||
if (remove) assert.deepEqual(getAISessionsSnapshot().sessions, []);
|
||||
else assert.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Remote');
|
||||
@@ -319,7 +268,7 @@ test('cloud sync preserves an active trimmed conversation and its native thread'
|
||||
assert.equal(controller.signal.aborted, false);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(getAISessionsSnapshot().sessions, [local]);
|
||||
assert.equal(snapshots.getPersistedAiSessionsSnapshot()[0].externalSessionId, 'native-thread');
|
||||
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].externalSessionId, 'native-thread');
|
||||
}
|
||||
} finally {
|
||||
hook!.abortControllersRef.current.delete('chat');
|
||||
@@ -350,7 +299,7 @@ test('cloud downloads respect local chat opt-in while still applying vault data'
|
||||
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
|
||||
assert.equal(vaultImports, 1);
|
||||
assert.deepEqual(snapshots.latestAISessionsSnapshot, []);
|
||||
assert.deepEqual(snapshots.getPersistedAiSessionsSnapshot(), []);
|
||||
assert.deepEqual(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!), []);
|
||||
save([], true);
|
||||
await applySyncPayload(downloaded, { importVaultData: () => { vaultImports++; } });
|
||||
assert.equal(vaultImports, 2);
|
||||
|
||||
@@ -50,9 +50,8 @@ import { decryptField, encryptField } from '../infrastructure/persistence/secure
|
||||
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
|
||||
import { emitAIStateChanged } from './state/aiStateEvents';
|
||||
import {
|
||||
isAiSessionsHydrationComplete,
|
||||
latestAISessionsSnapshot,
|
||||
getPersistedAiSessionsSnapshot,
|
||||
readSessionsFromStorage,
|
||||
setLatestAISessionsSnapshot,
|
||||
writeSessionsForStorage,
|
||||
cleanupSdkAgentSessions,
|
||||
@@ -1066,12 +1065,6 @@ function pruneOrphanPerAgentBindings(): void {
|
||||
export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
|
||||
const enabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS);
|
||||
if (enabled?.enabled !== true) return undefined;
|
||||
// Issue #4125 review P1①: never collect before the boot hydration has
|
||||
// settled. A pre-hydration collect falls back to the stale legacy
|
||||
// localStorage store and the writeSessionsForStorage() call below would
|
||||
// overwrite newer IndexedDB records with pre-migration data. Skip this
|
||||
// sync round; the next auto-sync picks up the hydrated store.
|
||||
if (!isAiSessionsHydrationComplete()) return undefined;
|
||||
const sessions = collectLocalAiSessions();
|
||||
if (!writeSessionsForStorage(sessions)) {
|
||||
throw new Error('Unable to persist chat history for cloud sync');
|
||||
@@ -1081,7 +1074,7 @@ export function collectAiSessionsForSync(): SyncPayload['aiSessions'] {
|
||||
|
||||
function collectLocalAiSessions(): NonNullable<SyncPayload['aiSessions']> {
|
||||
return latestAISessionsSnapshot
|
||||
?? getPersistedAiSessionsSnapshot();
|
||||
?? readSessionsFromStorage();
|
||||
}
|
||||
|
||||
/** Build the portable payload; device-local data is added by the backup builder. */
|
||||
|
||||
@@ -247,8 +247,6 @@ const SettingsAITabContainer: React.FC = () => {
|
||||
setResponseIdleTimeout={aiState.setResponseIdleTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
aiSessionMessageCap={aiState.aiSessionMessageCap}
|
||||
setAiSessionMessageCap={aiState.setAiSessionMessageCap}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
quickMessages={aiState.quickMessages}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { terminalCwdStore } from '../application/state/terminalCwdStore';
|
||||
import { useTerminalDirectorySession } from '../application/state/useTerminalDirectorySession';
|
||||
import { refreshObservedTerminalDirectory, scheduleObservedTerminalDirectoryRefresh } from '../application/state/terminalDirectoryRegistry';
|
||||
import { publishTerminalSelectionTarget } from "../infrastructure/plugins/terminalSelectionRegistry";
|
||||
import { usePluginTerminalSelection } from "../application/state/usePluginTerminalSelection";
|
||||
import { clearTerminalBroadcastUserInput, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
|
||||
@@ -1249,7 +1246,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
|
||||
terminalCwdTracker.clearRendererCwd();
|
||||
terminalCwdStore.setCwd(sessionId, null);
|
||||
knownCwdRef.current = undefined;
|
||||
if (options?.persistRestoreMetadata === false) return;
|
||||
onTerminalCwdChange?.(sessionId, null);
|
||||
@@ -2594,7 +2590,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const cwdAwareOnCommandSubmitted = useCallback((
|
||||
...args: Parameters<NonNullable<typeof onCommandSubmitted>>
|
||||
) => {
|
||||
scheduleObservedTerminalDirectoryRefresh(sessionId);
|
||||
if (!shouldPreserveTerminalCwdAcrossCommand(hostRestrictsExtraSshChannels(host))) {
|
||||
invalidateTerminalCwdAfterCommand(
|
||||
terminalCwdTracker,
|
||||
@@ -2608,7 +2603,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, [host, onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
refreshObservedTerminalDirectory(sessionId);
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
// Hibernate wake builds its runtime from this callback. Same local
|
||||
// prompt-return refresh as useTerminalEffects (#1952).
|
||||
@@ -2621,7 +2615,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
cwd: string | null,
|
||||
meta?: TerminalCwdChangeMeta,
|
||||
) => {
|
||||
terminalCwdStore.setCwd(changedSessionId, cwd, meta?.source);
|
||||
pluginTerminalLifecycle.onCwdChanged(cwd);
|
||||
onTerminalCwdChange?.(changedSessionId, cwd, meta);
|
||||
}, [onTerminalCwdChange, pluginTerminalLifecycle]);
|
||||
@@ -2637,13 +2630,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pluginAwareOnTerminalCwdChange(sessionId, normalizedCwd ?? null, meta);
|
||||
void refreshProviderOutputs('cwd-changed');
|
||||
}, [pluginAwareOnTerminalCwdChange, refreshProviderOutputs, sessionId, terminalCwdTracker]);
|
||||
useTerminalDirectorySession({
|
||||
sessionId, status, host, shellType,
|
||||
getBackendId: () => sessionRef.current,
|
||||
isReady: () => isTerminalReadyForCommandInjection(sessionId),
|
||||
publish: cwd => pluginAwareOnRuntimeCwdChange(cwd, { source: 'backend-strict' }),
|
||||
});
|
||||
|
||||
const pluginAwareOnTerminalTitleChange = useCallback((changedSessionId: string, title: string | null) => {
|
||||
terminalTitleRef.current = title || undefined;
|
||||
pluginTerminalLifecycle.onTitleChanged(title);
|
||||
@@ -2785,7 +2771,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onTerminalDataCapture: handleTerminalDataCaptureOnce,
|
||||
onTerminalOutput: (chunk: string, meta?: TerminalSessionDataMeta) => {
|
||||
observeTerminalInputPrompt(chunk, meta);
|
||||
scheduleObservedTerminalDirectoryRefresh(sessionId);
|
||||
appendOutputTriggerOutputRef.current(chunk, meta);
|
||||
if (onTerminalOutput) {
|
||||
onTerminalOutput(sessionId, chunk);
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ChatMessage, ChatMessageAttachment, ToolCall, ToolResult } from ".
|
||||
import { isTerminalSelectionAttachment } from "../../application/state/terminalSelectionAttachment";
|
||||
import { formatVaultNoteReferences, isVaultNoteAttachment } from "../../application/state/vaultNoteAttachment";
|
||||
import { redactSecretsForModel } from "../../infrastructure/ai/harness/modelSecretRedaction";
|
||||
import { sliceHead, sliceTail } from "../../infrastructure/ai/textBoundaries";
|
||||
|
||||
const MAX_ATTACHMENT_PLACEHOLDER_DETAIL_CHARS = 120;
|
||||
const MAX_TOOL_COMMAND_CHARS = 220;
|
||||
@@ -12,7 +11,7 @@ const HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS = 2_800;
|
||||
function truncateInline(value: string, maxChars: number): string {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= maxChars) return normalized;
|
||||
return `${sliceHead(normalized, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function describeAttachmentSize(attachment: ChatMessageAttachment): string {
|
||||
@@ -85,7 +84,7 @@ function fitHistoricalTerminalOutput(content: string): string {
|
||||
0,
|
||||
MAX_HISTORICAL_TERMINAL_OUTPUT_CHARS - HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS - marker.length,
|
||||
);
|
||||
return `${sliceHead(safeContent, HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS)}${marker}${sliceTail(safeContent, tailChars)}`;
|
||||
return `${safeContent.slice(0, HISTORICAL_TERMINAL_OUTPUT_HEAD_CHARS)}${marker}${safeContent.slice(-tailChars)}`;
|
||||
}
|
||||
|
||||
function findOutputHandleId(content: string): string | undefined {
|
||||
|
||||
@@ -55,10 +55,6 @@ import { AIPrivacySection } from "./ai/AIPrivacySection";
|
||||
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
|
||||
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
|
||||
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
|
||||
import {
|
||||
MAX_AI_SESSION_MESSAGE_CAP,
|
||||
MIN_AI_SESSION_MESSAGE_CAP,
|
||||
} from "../../../domain/aiSessionMessageCap";
|
||||
import { WebSearchSettings } from "./ai/WebSearchSettings";
|
||||
import { QuickMessagesSettings } from "./ai/QuickMessagesSettings";
|
||||
import type { AIQuickMessage } from "../../../infrastructure/ai/quickMessages";
|
||||
@@ -163,8 +159,6 @@ interface SettingsAITabProps {
|
||||
setResponseIdleTimeout: (value: number) => void;
|
||||
maxIterations: number;
|
||||
setMaxIterations: (value: number) => void;
|
||||
aiSessionMessageCap: number;
|
||||
setAiSessionMessageCap: (value: number) => void;
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
quickMessages: AIQuickMessage[];
|
||||
@@ -202,8 +196,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setResponseIdleTimeout,
|
||||
maxIterations,
|
||||
setMaxIterations,
|
||||
aiSessionMessageCap,
|
||||
setAiSessionMessageCap,
|
||||
webSearchConfig,
|
||||
setWebSearchConfig,
|
||||
quickMessages,
|
||||
@@ -1312,35 +1304,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-chat-history"
|
||||
title={t('ai.chatHistory.title')}
|
||||
>
|
||||
<SettingCard>
|
||||
<SettingRow
|
||||
anchorId="ai-chat-history-message-cap"
|
||||
label={t('ai.chatHistory.messageCap')}
|
||||
description={t('ai.chatHistory.messageCap.description')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t('ai.chatHistory.messageCap')}
|
||||
value={aiSessionMessageCap}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) setAiSessionMessageCap(val);
|
||||
}}
|
||||
min={MIN_AI_SESSION_MESSAGE_CAP}
|
||||
max={MAX_AI_SESSION_MESSAGE_CAP}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{t('ai.chatHistory.messageCap.unit')}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
anchorId="ai-user-skills"
|
||||
title={t('ai.userSkills.title')}
|
||||
|
||||
@@ -35,17 +35,6 @@ test("terminal behavior settings expose disabled auto-close", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('Context Chips default on for old settings and preserve an explicit off preference', () => {
|
||||
assert.equal(normalizeTerminalSettings({ scrollback: 5000 }).contextChipsEnabled, true);
|
||||
const disabled = normalizeTerminalSettings({ contextChipsEnabled: false });
|
||||
assert.equal(normalizeTerminalSettings(JSON.parse(JSON.stringify(disabled))).contextChipsEnabled, false);
|
||||
const markup = renderToStaticMarkup(React.createElement(TerminalBehaviorSettings, {
|
||||
t: (key: string) => key, terminalSettings: disabled, updateTerminalSetting: () => {},
|
||||
}));
|
||||
assert.match(markup, /settings-anchor-terminal-context-chips/);
|
||||
assert.match(markup, /settings\.terminal\.behavior\.contextChips[\s\S]*?role="switch" aria-checked="false"/);
|
||||
});
|
||||
|
||||
test("terminal behavior settings expose OSC desktop notification mode", () => {
|
||||
const markup = renderSettings(true);
|
||||
assert.match(markup, /settings-anchor-terminal-osc-notifications/);
|
||||
|
||||
@@ -48,14 +48,6 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
|
||||
<>
|
||||
<SectionHeader title={t("settings.terminal.section.behavior")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
anchorId="terminal-context-chips"
|
||||
label={t("settings.terminal.behavior.contextChips")}
|
||||
description={t("settings.terminal.behavior.contextChips.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.contextChipsEnabled !== false} onChange={(v) => updateTerminalSetting("contextChipsEnabled", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
anchorId="terminal-auto-close-on-exit"
|
||||
label={t("settings.terminal.behavior.autoCloseOnExit")}
|
||||
|
||||
@@ -6,10 +6,6 @@ import type { ProviderConfig, ProviderAdvancedParams, OpenAIApiFormat, ProviderS
|
||||
import { PROVIDER_PRESETS, resolveOpenAIApi, resolveProviderStyle } from "../../../../infrastructure/ai/types";
|
||||
import { normalizeOllamaSdkBaseURL } from "../../../../infrastructure/ai/ollamaCompatBaseUrl";
|
||||
import { sanitizeContextWindow } from "../../../../infrastructure/ai/contextCompaction";
|
||||
import {
|
||||
isProviderReasoningEffort,
|
||||
PROVIDER_REASONING_EFFORT_OPTIONS,
|
||||
} from "../../../../infrastructure/ai/cattyReasoning";
|
||||
import {
|
||||
probeProviderConnection,
|
||||
validateProviderProbeInputs,
|
||||
@@ -61,17 +57,8 @@ async function compressIconFileToDataUrl(file: File): Promise<string> {
|
||||
|
||||
const STYLE_OPTIONS: ReadonlyArray<ProviderStyle> = ["anthropic", "openai", "google"];
|
||||
const OPENAI_API_OPTIONS: ReadonlyArray<OpenAIApiFormat> = ["chat", "responses"];
|
||||
/**
|
||||
* How each reasoning level is shown in the select. Raw wire values stay the
|
||||
* option values so a saved config keeps round-tripping unchanged.
|
||||
*/
|
||||
const REASONING_EFFORT_LABELS: Record<string, string> = {
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "xhigh (extra high)",
|
||||
max: "max",
|
||||
};
|
||||
/** Selectable thinking-depth levels; empty string means "provider default" (field not sent). */
|
||||
const REASONING_EFFORT_OPTIONS = ["low", "medium", "high"] as const;
|
||||
|
||||
/** Same box as the h-8 fields above. Transparent border keeps primary aligned with outline. */
|
||||
const PROVIDER_ACTION_CLASS = "box-border h-8 px-3 gap-1.5 text-sm font-medium leading-none";
|
||||
@@ -376,7 +363,7 @@ export const ProviderConfigForm: React.FC<{
|
||||
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
|
||||
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
|
||||
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
|
||||
if (isProviderReasoningEffort(ap.reasoningEffort)) {
|
||||
if (ap.reasoningEffort && (REASONING_EFFORT_OPTIONS as readonly string[]).includes(ap.reasoningEffort)) {
|
||||
cleanedParams.reasoningEffort = ap.reasoningEffort;
|
||||
}
|
||||
|
||||
@@ -787,13 +774,10 @@ export const ProviderConfigForm: React.FC<{
|
||||
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="">{t('ai.providers.advancedParams.default')}</option>
|
||||
{PROVIDER_REASONING_EFFORT_OPTIONS.map((level) => (
|
||||
<option key={level} value={level}>{REASONING_EFFORT_LABELS[level] ?? level}</option>
|
||||
{REASONING_EFFORT_OPTIONS.map((level) => (
|
||||
<option key={level} value={level}>{level}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
{t('ai.providers.advancedParams.reasoningEffort.help')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { ArrowUp, ChevronDown, Folder, LoaderCircle, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useComposeDirectoryPicker } from '../../application/state/useComposeDirectoryPicker';
|
||||
import { directoryDisplayName, directoryLabel } from '../../domain/directoryPicker';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import type { ComposeBarTheme } from './TerminalComposeBar';
|
||||
|
||||
export function ComposeDirectoryPicker({ sessionId, theme, onReturnFocus }: {
|
||||
sessionId: string; theme: ComposeBarTheme; onReturnFocus: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const model = useComposeDirectoryPicker(sessionId);
|
||||
const [active, setActive] = useState(0);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const search = useRef<HTMLInputElement>(null);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const composing = useRef(false);
|
||||
const outside = useRef(false);
|
||||
const id = useId();
|
||||
const hasParent = model.parent !== null;
|
||||
const count = model.entries.length + Number(hasParent);
|
||||
const activeEntry = active === 0 && hasParent ? model.parent : model.entries[active - Number(hasParent)]?.path;
|
||||
const errorText = model.error ? t('terminal.directory.' + model.error) : null;
|
||||
useEffect(() => {
|
||||
setActive(model.entries.length ? Number(hasParent) : -1);
|
||||
setScrollTop(0);
|
||||
if (viewport.current) viewport.current.scrollTop = 0;
|
||||
}, [model.entries, hasParent, model.open]);
|
||||
|
||||
const virtual = model.entries.length > 200;
|
||||
const start = virtual ? Math.max(0, Math.floor(scrollTop / 32) - 4) : 0;
|
||||
const visible = virtual ? model.entries.slice(start, start + 16) : model.entries;
|
||||
const row = (name: string, path: string, index: number, parent = false) => (
|
||||
<div
|
||||
key={path} id={`${id}-${index}`} role="option" aria-selected={active === index}
|
||||
title={directoryDisplayName(path)}
|
||||
className="h-8 flex items-center gap-2 px-3 text-xs cursor-pointer min-w-0"
|
||||
style={{ backgroundColor: active === index ? theme.chipHoverBg : undefined,
|
||||
color: active === index ? theme.resolvedFg : theme.mutedFg }}
|
||||
onPointerMove={() => setActive(index)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => { if (!model.pending) void model.change(path); }}
|
||||
>
|
||||
{parent ? <ArrowUp size={13} className="shrink-0" /> : <Folder size={13} className="shrink-0" />}
|
||||
<span className="truncate">{directoryDisplayName(name)}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="shrink-0 flex items-center gap-2 px-3 h-7 min-w-0" role="group" aria-label="Context Chips">
|
||||
<Popover open={model.open} onOpenChange={value => { if (value) outside.current = false; model.setOpen(value); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" disabled={model.pending}
|
||||
aria-label={t('terminal.directory.open', { path: model.cwd ? directoryLabel(model.cwd, model.home) : t('terminal.directory.unknown') })}
|
||||
title={model.cwd ? directoryDisplayName(model.cwd) : t('terminal.directory.unknown')}
|
||||
className="h-6 min-w-0 max-w-full flex items-center gap-1.5 rounded border px-2 text-[11px] font-mono focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-60"
|
||||
style={{ backgroundColor: theme.chipBg, borderColor: theme.borderColor, color: theme.resolvedFg }}
|
||||
>
|
||||
{model.pending ? <LoaderCircle size={12} className="shrink-0 animate-spin" /> : <Folder size={12} className="shrink-0" />}
|
||||
<span className="truncate">{model.cwd ? directoryDisplayName(directoryLabel(model.cwd, model.home)) : t('terminal.directory.unknown')}</span>
|
||||
<ChevronDown size={10} className="shrink-0 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" sideOffset={6}
|
||||
className="p-0 overflow-hidden w-[360px] max-w-[calc(100vw-16px)]"
|
||||
style={{ backgroundColor: theme.resolvedBg, borderColor: theme.borderColor, color: theme.resolvedFg }}
|
||||
onOpenAutoFocus={event => { event.preventDefault(); search.current?.focus(); }}
|
||||
onInteractOutside={() => { outside.current = true; }}
|
||||
onCloseAutoFocus={event => { event.preventDefault(); if (!outside.current) onReturnFocus(); }}
|
||||
onKeyDown={event => {
|
||||
event.stopPropagation();
|
||||
if (composing.current || event.nativeEvent.isComposing) return;
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
const next = active < 0 ? (event.key === 'ArrowDown' ? 0 : count - 1)
|
||||
: Math.max(0, Math.min(count - 1, active + (event.key === 'ArrowDown' ? 1 : -1)));
|
||||
setActive(next);
|
||||
const child = next - Number(hasParent);
|
||||
if (viewport.current && child >= 0) {
|
||||
const el = viewport.current;
|
||||
if (child * 32 < el.scrollTop) el.scrollTop = child * 32;
|
||||
else if ((child + 1) * 32 > el.scrollTop + el.clientHeight) el.scrollTop = (child + 1) * 32 - el.clientHeight;
|
||||
}
|
||||
} else if (event.key === 'Enter' && event.target === search.current) {
|
||||
event.preventDefault(); if (activeEntry && !model.pending) void model.change(activeEntry);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="px-3 py-2 border-b" style={{ borderColor: theme.borderColor }}>
|
||||
<input ref={search} value={model.query} onChange={event => model.setQuery(event.target.value)}
|
||||
aria-label={t('terminal.directory.search')} aria-controls={`${id}-list`}
|
||||
aria-activedescendant={activeEntry ? `${id}-${active}` : undefined}
|
||||
placeholder={t('terminal.directory.search')}
|
||||
className="w-full bg-transparent text-xs outline-none placeholder:opacity-60"
|
||||
onCompositionStart={() => { composing.current = true; }}
|
||||
onCompositionEnd={() => { composing.current = false; }}
|
||||
/>
|
||||
</div>
|
||||
<div role="listbox" aria-label={t('terminal.directory.directories')} id={`${id}-list`} aria-busy={model.loading}>
|
||||
{model.parent && row(t('terminal.directory.parent'), model.parent, 0, true)}
|
||||
<div ref={viewport} className="max-h-56 overflow-y-auto overscroll-contain" onScroll={event => setScrollTop(event.currentTarget.scrollTop)}>
|
||||
<div style={virtual ? { height: model.entries.length * 32, position: 'relative' } : undefined}>
|
||||
<div style={virtual ? { position: 'absolute', top: start * 32, left: 0, right: 0 } : undefined}>
|
||||
{visible.map((entry, index) => row(entry.name, entry.path, start + index + Number(hasParent)))}
|
||||
</div>
|
||||
</div>
|
||||
{model.loading && <p className="px-3 py-4 text-xs" role="status">{t('terminal.directory.loading')}</p>}
|
||||
{!model.loading && !errorText && model.cwd && !model.entries.length && (
|
||||
<p className="px-3 py-4 text-xs" style={{ color: theme.mutedFg }}>{t(model.query ? 'terminal.directory.noMatches' : 'terminal.directory.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(errorText || !model.available || !model.cwd) && <p className="px-3 py-2 text-xs" role="status">
|
||||
{errorText ?? t(model.available ? 'terminal.directory.unknown' : 'terminal.directory.unavailable')}
|
||||
</p>}
|
||||
<div className="flex items-center justify-between border-t px-3 py-2 text-[11px]" style={{ borderColor: theme.borderColor, color: theme.mutedFg }}>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={model.showHidden} onChange={event => model.setShowHidden(event.target.checked)} />{t('terminal.directory.hidden')}</label>
|
||||
<button type="button" disabled={model.loading || model.pending || !model.available}
|
||||
className="inline-flex items-center gap-1 rounded px-1 disabled:opacity-50"
|
||||
onClick={() => { if (model.cwd) void model.reload(); else void model.refresh(); }}>
|
||||
<RefreshCw size={11} />{t('terminal.directory.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!model.open && errorText && <span role="status" title={errorText} className="truncate text-[10px]" style={{ color: theme.mutedFg }}>{errorText}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,51 +8,6 @@ import { TerminalComposeBar } from './TerminalComposeBar';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
|
||||
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
|
||||
import { createTerminalDirectoryController } from '../../application/state/terminalDirectoryController';
|
||||
import { registerTerminalDirectory, refreshObservedTerminalDirectory } from '../../application/state/terminalDirectoryRegistry';
|
||||
import { terminalCwdStore } from '../../application/state/terminalCwdStore';
|
||||
|
||||
test('disabling Context Chips preserves the compose draft and stops extra cwd refreshes', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const originals = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const name of ['window', 'document', 'localStorage']) {
|
||||
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
|
||||
}
|
||||
t.after(() => {
|
||||
dom.window.close();
|
||||
for (const [name, descriptor] of originals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
});
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
let probes = 0;
|
||||
const cleanup = registerTerminalDirectory('toggle-test', createTerminalDirectoryController({
|
||||
sessionId: 'toggle-test', isCurrent: () => true, isReady: () => true,
|
||||
io: { cwd: async () => { probes++; return '/project'; }, list: async () => ({ entries: [], home: null }),
|
||||
realpath: async path => path, write() {} },
|
||||
publish: cwd => { terminalCwdStore.setCwd('toggle-test', cwd, 'backend-strict'); },
|
||||
}));
|
||||
const textarea = { value: '', selectionStart: 0, selectionEnd: 0, focus() {},
|
||||
setSelectionRange(start: number, end: number) { this.selectionStart = start; this.selectionEnd = end; } };
|
||||
const render = (enabled: boolean) => <TooltipProvider><TerminalComposeBar sessionId="toggle-test"
|
||||
contextChipsEnabled={enabled} onSend={() => true} onClose={() => {}} /></TooltipProvider>;
|
||||
let root!: ReactTestRenderer;
|
||||
await act(async () => { root = create(render(true), { createNodeMock: el => el.type === 'textarea' ? textarea : null }); });
|
||||
assert.equal(probes, 1);
|
||||
textarea.value = 'keep this draft'; textarea.selectionStart = 2; textarea.selectionEnd = 8;
|
||||
await act(async () => { root.update(render(false)); });
|
||||
assert.equal(root.root.findAllByProps({ 'aria-label': 'Context Chips' }).length, 0);
|
||||
const before = probes;
|
||||
refreshObservedTerminalDirectory('toggle-test');
|
||||
await Promise.resolve();
|
||||
assert.equal(probes, before);
|
||||
assert.equal(textarea.value, 'keep this draft');
|
||||
assert.deepEqual([textarea.selectionStart, textarea.selectionEnd], [2, 8]);
|
||||
await act(async () => { root.unmount(); });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ComposeDirectoryPicker } from './ComposeDirectoryPicker';
|
||||
/**
|
||||
* Terminal Compose Bar
|
||||
* An immersive prompt bar below the terminal with a quick-snippet strip,
|
||||
@@ -30,7 +29,7 @@ import {
|
||||
const SNIPPET_STRIP_HEIGHT = 30;
|
||||
const RESIZE_HANDLE_HEIGHT = 6;
|
||||
|
||||
export type ComposeBarTheme = {
|
||||
type ComposeBarTheme = {
|
||||
resolvedBg: string;
|
||||
resolvedFg: string;
|
||||
borderColor: string;
|
||||
@@ -274,7 +273,6 @@ const ComposeBarSnippetManagePopover = memo(function ComposeBarSnippetManagePopo
|
||||
});
|
||||
|
||||
export interface TerminalComposeBarProps {
|
||||
contextChipsEnabled?: boolean;
|
||||
// Return false for rejected or sensitive input so it is not recalled.
|
||||
onSend: (text: string) => boolean | void | Promise<boolean | void>;
|
||||
sessionId: string;
|
||||
@@ -293,7 +291,6 @@ export interface TerminalComposeBarProps {
|
||||
|
||||
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
onSend,
|
||||
contextChipsEnabled = true,
|
||||
sessionId,
|
||||
onClose,
|
||||
onSnippetClick,
|
||||
@@ -614,9 +611,6 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{contextChipsEnabled && <ComposeDirectoryPicker key={sessionId} sessionId={sessionId} theme={theme}
|
||||
onReturnFocus={() => textareaRef.current?.focus({ preventScroll: true })} />}
|
||||
|
||||
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
|
||||
<div className="flex flex-1 min-h-0 items-start gap-1.5">
|
||||
{(onToggleComposeSendTarget || sendToAll) && (
|
||||
|
||||
@@ -1432,7 +1432,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
{isComposeBarOpen && !inWorkspace && (
|
||||
<TerminalComposeBar
|
||||
contextChipsEnabled={terminalSettings?.contextChipsEnabled !== false}
|
||||
key={sessionId}
|
||||
sessionId={sessionId}
|
||||
onSend={async (text) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { refreshObservedTerminalDirectory } from '../../application/state/terminalDirectoryRegistry';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
|
||||
import { useRef } from 'react';
|
||||
import { publishTerminalSelectionTarget } from '../../infrastructure/plugins/terminalSelectionRegistry';
|
||||
@@ -238,7 +237,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
onCommandSettledRef.current = ctx.onCommandSettled;
|
||||
const pluginAwareOnCommandCompleted = () => {
|
||||
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
|
||||
refreshObservedTerminalDirectory(sessionId);
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
// Stock local shells update cwd when the command finishes, which can be
|
||||
// long after the 150ms post-submit probe. Prompt return (and OSC 133)
|
||||
|
||||
@@ -350,7 +350,6 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
|
||||
{activeWorkspace && focusedSessionId && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
sessionId={focusedSessionId}
|
||||
contextChipsEnabled={terminalSettings?.contextChipsEnabled !== false}
|
||||
onSend={handleComposeSend}
|
||||
onSnippetClick={(snippet) => void handleSnippetFromPanel(snippet)}
|
||||
snippets={snippets}
|
||||
|
||||
@@ -15,11 +15,9 @@ Context Chips: [目录] [分支] [变更统计] [Node] [PR]
|
||||
|
||||
芯片显示上下文,点击就近操作。首期只显示已实现的目录芯片,其余按终端能力与上下文出现。共享终端主题、紧凑图标和文字、悬停说明及键盘焦点;不额外显示占据高度的行标题。窄分屏优先保留目录,长路径/分支名截断显示,完整值可查看。
|
||||
|
||||
“设置 → 终端 → Context Chips”控制整行显示,默认开启;关闭时保留命令草稿,并停止芯片额外发起的目录读取与命令后刷新。
|
||||
|
||||
| 功能 | 开发目标 | 调研与交互 |
|
||||
| --- | --- | --- |
|
||||
| Directory Picker | 显示当前位置;搜索子目录、返回父目录、选择后立即切换并关闭菜单 | [功能规格](directory-picker.md) · [交互示意](directory-picker-demo.html) · [跨平台研究](directory-picker-cross-platform.md) · [后续计划](directory-picker-follow-up.md) |
|
||||
| Directory Picker | 显示当前位置;搜索子目录、返回父目录、选择后立即切换并关闭菜单 | [directory-picker.md](directory-picker.md) · [交互示意](directory-picker-demo.html) |
|
||||
| Git Branch | 显示当前分支,搜索并切换其他分支 | [git-branch.md](git-branch.md) |
|
||||
| Git Changes | 显示改动文件数及增删行;预留变更文件面板入口 | [git-changes.md](git-changes.md) |
|
||||
| Node Version | 显示当前终端 Node 版本;支持的管理器可提供版本切换 | [node-version.md](node-version.md) |
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# Directory Picker:跨平台能力与 shell 集成研究
|
||||
|
||||
调研日期:2026-10-30。Netcatty 源码基线:`7f8f8d34687a1dbb94b16f314952bf25aac0d88d`。本文研究后续能力,**不表示当前 PR 已支持 Windows 本地目录导航**;实施顺序见 [后续计划](directory-picker-follow-up.md)。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
保留绝对路径作为操作目标;依 shell 生成命令,依文件系统环境读取目录,依当前交互会话确认结果。跨平台的难点是这三项能力的组合,不是把 `cd --` 换成 `cd`。
|
||||
|
||||
建议优先补齐 Windows PowerShell 5.1/PowerShell 7,再推进 CMD。macOS 的 zsh/bash 和主流 Linux 的 bash/ash/zsh 保留已有进程 cwd 回退,并逐步增加可协商的 shell 上报。fish 独立处理引用与事件。WSL、Git Bash、Cygwin 按自己的路径空间接入,不因被分类为 `posix` 就复用 Windows 本地文件系统。
|
||||
|
||||
这里的“默认终端支持”指 **Netcatty 创建的交互 shell**。Windows Terminal、Terminal.app、GNOME Terminal 等窗口应用本身不是命令解释器;不能只按 OS 或发行版名称选择语法。
|
||||
|
||||
## 2. 当前实现与缺口
|
||||
|
||||
| 位置 | 已有能力 | 后续约束 |
|
||||
| --- | --- | --- |
|
||||
| `application/state/useTerminalDirectorySession.ts` | macOS/Linux 本地与符合条件的 SSH;Windows 本地主动禁用 | 当前平台门槛不能直接删除,必须先证明 cwd、路径、执行三者都适配 |
|
||||
| `electron/bridges/localSessionCwd.cjs` | Linux 读 `/proc/<pid>/cwd`;macOS 用 lsof | Windows 未实现;读到启动 PTY 的进程不等于能识别所有嵌套 shell |
|
||||
| `infrastructure/services/terminalDirectory.ts` | 本地 FS 或 exact-session SFTP,严格 cwd 查询 | 远程不新建登录、不借用其他 session;SFTP 根与 shell 根未必相同 |
|
||||
| `domain/directoryPicker.ts` | `/` 开头的路径、POSIX 父路径和拼接 | 不支持盘符、UNC、WSL 及 MSYS/Cygwin 路径转换 |
|
||||
| `domain/sessionRestore.ts` | `resolveInteractiveTerminalCdIntent` 的 POSIX 引用、控制字节拒绝 | 扩展 Directory Picker 不应破坏已有恢复和 SFTP 定位调用者 |
|
||||
| `terminalDirectoryController.ts`/`terminalCwdStore.ts` | 每会话 revision、严格结果确认、旧连接失效 | 加入 shell 报告也要保留这些顺序与身份约束 |
|
||||
| `terminalDirectoryRegistry.ts`/`components/Terminal.tsx` | 芯片可见时按完成/输出事件刷新,不恒定轮询 | 集成有效时可以减少辅助探测;关闭 chips 要停止其额外需求 |
|
||||
| `components/terminal/runtime/createXTermRuntime.ts` | OSC 7 上报路径;OSC 133 的 D 完成事件 | 当前 `URL.pathname` 结果仍按 POSIX 使用;不能把 `/C:/...` 当 POSIX 路径直接读盘 |
|
||||
| `components/terminal/osc7Setup.ts` | 已有 bash/zsh/fish 目录跟踪配置及确认流程 | 会写 rc 文件并可能 reload;不能改成新功能的静默自动安装器 |
|
||||
| `electron/bridges/ai/sessionShellKind.cjs`/`liveShellProbe.cjs` | shell 分类、远端登录 hint、当前 PTY 探测 | 登录 shell hint 不证明当前 shell;现有主动探测有输入和副作用,不能每次点菜单就执行 |
|
||||
| `electron/bridges/ai/ptyExecHelpers.cjs` | POSIX/fish/PowerShell 各自的字符串引用 | 可提取纯引用逻辑;整个 AI exec 包装器不适合目录操作,有子进程、eval、环境及历史处理语义 |
|
||||
|
||||
## 3. 绝对路径与 `--`
|
||||
|
||||
菜单查询、点击、异步 realpath、写入终端之间存在时间差。即使 chip 自动跟随,列表也只代表查询时的目录。相对路径不能单独消除旧列表风险:仍要校验 session、连接代次、shell/文件系统身份及 cwd revision。
|
||||
|
||||
POSIX `cd` 的相对目录还可能受 `CDPATH` 影响;`./child` 能避开这一搜索规则,但仍依赖执行时 cwd。POSIX 文档规定 `cd` 改变的是当前 shell 的执行环境,因此独立 exec 的 cd 不能代替对用户交互 shell 的切换。[S1]
|
||||
|
||||
本机隔离目录实验,bash 3.2.57、zsh 5.9 和系统 dash:
|
||||
|
||||
| 实验 | 结果 |
|
||||
| --- | --- |
|
||||
| `cd -- '/absolute/path'` 与 `cd '/absolute/path'` | 三种 shell 均成功 |
|
||||
| 从 A 列出 `src`,执行前切到 B,再 `cd './src'` | 三种 shell 均进入 B/src |
|
||||
| 同样条件下使用 A/src 绝对路径 | 三种 shell 均进入指定 A/src |
|
||||
| 设置 `CDPATH` 后执行 `cd 'src'` | bash/dash 进入搜索路径中的 src;本次 zsh 优先进入本地 src,不能泛化为各 shell 完全相同 |
|
||||
|
||||
这些实验只证明上述本机版本。`--` 与引号作用不同:前者结束选项,后者保持参数边界。对于当前 `/` 开头的绝对路径,`--` 不是必需;不应把删掉它当成 Windows 兼容方案。引用策略必须按 shell 测试,不能把 POSIX 单引号拼接直接推广到 fish/CMD。[S1、S4、N1]
|
||||
|
||||
## 4. 支持矩阵与数据来源
|
||||
|
||||
| 环境 | cwd 首选/回退 | 目录读取 | 切换策略与主要缺口 |
|
||||
| --- | --- | --- | --- |
|
||||
| macOS zsh,兼容 bash 3.2 | 已有 lsof;新增 prompt/chpwd 上报 | 本地 FS | 当前 shell 的 cd;保留原有 login/rc/PATH 行为 |
|
||||
| Linux bash/zsh/dash/ash | 已有 `/proc`;可用时接 shell 报告 | 本地 FS | 独立引用测试;dash/ash 不保证有 bash 的 hook 能力 |
|
||||
| Linux/macOS fish | prompt/PWD 事件,必要时进程回退 | 本地 FS | fish 引用与事件;不能运行 POSIX 初始化脚本 |
|
||||
| Windows PowerShell 5.1/7 | **当前 runspace 的位置报告** | FileSystem provider 的本地 FS | `Set-Location -LiteralPath`;盘符、UNC、provider、策略限制 |
|
||||
| Windows CMD | 会话 PROMPT 中的位置/边界报告 | Windows 本地 FS | `cd /d`;百分号、感叹号、命令扩展、UNC 另定 |
|
||||
| Windows WSL | 所选 distro 内 shell 上报/受控 helper | 同一 distro 的 Linux FS 适配 | Linux 语法;不能把 `/home` 传给宿主 Windows FS |
|
||||
| Windows Git Bash/Cygwin | 该环境的 shell 上报 | 已验证的命名空间映射/helper | MSYS/Cygwin 的挂载规则,不是 WSL 的路径转换 |
|
||||
| 普通 SSH 到 Unix | 已有严格活动 shell 探测;可选远端集成 | 同一连接的 SFTP;先证明路径对应 | 嵌套 shell、sudo、chroot 不能退回登录 home 冒充 cwd |
|
||||
| SSH 到 Windows | 远端活动 shell 报告;不靠新 exec 的 Get-Location | 远端 SFTP 路径映射/受控能力 | Win32-OpenSSH hint 不等于活动 shell;独立阶段 |
|
||||
| 单通道跳板、网络设备、mosh/ET、插件终端 | 按 provider/传输能力分别证明 | 按该传输证明 | 不因支持 cwd 上报就自动支持目录枚举或安全切换 |
|
||||
|
||||
Ubuntu/Debian、Fedora/Rocky/Alma、Arch、Alpine 等应作为验收样本,不能把发行版与用户当前 shell 硬绑定。Windows 也不能只根据 PowerShell/CMD 的提示符外观推断可写状态。
|
||||
|
||||
## 5. 会话级 shell 集成的可行路径
|
||||
|
||||
### 5.1 参考实现与可复用部分
|
||||
|
||||
VS Code 将工作目录与命令边界由 shell 脚本报告,启动时通过参数/环境注入;其用户文档也明确普通 SSH、子 shell、特殊启动参数会使自动注入失效。[S2] 固定源码快照 N1 中:
|
||||
|
||||
- bash 使用 init-file 路径,并兼容用户 rc/login 启动;fish 使用 init-command。
|
||||
- zsh 使用临时 ZDOTDIR 中的启动文件代理,不只是加载 `.zshrc` 一步。
|
||||
- PowerShell 在原 prompt 前后报告位置和边界,只为 FileSystem provider 报告文件路径;受限语言模式会退出集成。
|
||||
- Windows Terminal 另给出 PowerShell/CMD 的 prompt 集成例子,使用 OSC 133 与 OSC 9;9;这与现有 OSC 7 接收逻辑不是同一协议。[S3、N1]
|
||||
|
||||
这些代码证明路径可行,不等于 Netcatty 已支持,也不应整套复制。尤其官方页面和最新主分支的 shell 支持范围可能不同,以自己测试过的版本为准。
|
||||
|
||||
### 5.2 推荐启动方式
|
||||
|
||||
本地在 `startLocalSession` 的可执行文件、参数和环境解析完成后,按已识别 shell 生成**会话级启动方案**。支持的普通交互参数才注入;用户 `-Command`、`-File`、`--norc`、自定义 `--rcfile`、受限模式或包装脚本不能悄悄改写。集成失败时照常打开终端,声明能力不足,保留当前支持的回退。
|
||||
|
||||
临时脚本通过 `tempDirBridge.getTempFilePath()` 放在 Netcatty 管理目录,检查权限、包内资源路径与清理生命周期;不能依赖源码目录在打包后存在。外部 shell 不能把 Electron 的 asar 虚拟路径当普通脚本文件使用,需显式部署为真实资源或复制到受管目录后加载。[S15] 首期不改用户 rc、注册表 AutoRun 或全局环境,也不默认重载整个配置文件。
|
||||
|
||||
shell hook 要保留原 prompt、主题插件、退出状态及 PATH;防止重复安装和递归。bash 区分标量/数组 `PROMPT_COMMAND` 及旧版本;zsh 保留 precmd/chpwd 链;fish 保留已有函数和事件。PowerShell 包装原 prompt 并恢复其结果,不能使用 PowerShell 6 才加入的反引号 e 作为 5.1 唯一 ESC 写法,应采用如 `[char]27` 的兼容构造。[S5–S8]
|
||||
|
||||
### 5.3 上报协议与可信度
|
||||
|
||||
推荐先保留 OSC 7 的可互操作 cwd 展示,再为 host-owned 集成增加可协商、带明确版本及编码的状态报告;是否复用 OSC 633,需在实现前确定。不要随意占用新的 OSC 编号,也不要为完整命令录制扩张本功能。
|
||||
|
||||
最低需要报告:当前 cwd/provider、路径类型、shell kind、prompt-ready/running,以及 shell 实例或集成代次。接收器必须处理 BEL/ST、分片、多字节 UTF-8、转义、长度上限与 URI authority。不能把终端原样输出拼成命令。
|
||||
|
||||
报告范围只覆盖目录与操作所需状态,不默认收集命令全文、环境变量或凭据。新增序列需经过已有输出管线,避免污染日志、回放、输出触发器与侧栏上下文;不能只在可见 xterm 中生效,忽略 worker/hibernate 路径。
|
||||
|
||||
**OSC 7 和普通边界序列是内容,不是认证。** 任意程序都可能打印它们,cwd 报告不能单独解除密码/MFA、alternate screen 或半行输入的阻止条件。VS Code 的 nonce 用于部分命令报告验证,不代表所有 Cwd 属性都已认证。[S2、N1] 候选方案可以使用会话 nonce 降低误识别,但 shell 内可执行代码仍可能读取它,不能宣传为隔离恶意进程的安全边界。
|
||||
|
||||
实现时在 worker/host 绑定 session、连接及 shell 代次,验证消息后再发 typed event 到应用状态。保留每会话 revision,使另一个终端的更新不能盖过本终端严格读取;旧连接、退出、hibernate/恢复、外部 `su`、嵌套 shell 均需失效规则。
|
||||
|
||||
## 6. Windows 原生 shell
|
||||
|
||||
### PowerShell
|
||||
|
||||
PowerShell 每个 runspace 的当前位置不同于 `[Environment]::CurrentDirectory`;另起 `powershell -Command Get-Location` 也读不到原交互 runspace 的状态。[S4] 应在目标 prompt 内获取 `$PWD`/`SessionState.Path.CurrentLocation`,只在 provider 为 FileSystem 时取 ProviderPath。进入 `HKLM:`、Env: 或自定义 provider 时显示“非文件系统位置”,不继续展示上一次目录为当前值。[S4、N1]
|
||||
|
||||
切换示例为 `Microsoft.PowerShell.Management\Set-Location -LiteralPath 'D:\Project [test]\O''Brien'`:单引号内部的单引号按 PowerShell 规则加倍;`-LiteralPath` 防止方括号等被当通配符。按当前 runspace 的 FileSystem home 做 `~` 展示;不要使用启动 Electron 的用户 home 代替。不能利用 Invoke-Expression 处理用户路径,也不以子进程的成功退出替代原 runspace 确认。[S4、S5]
|
||||
|
||||
PowerShell 5.1 与 7 分别验证 PSReadLine、有/无 profile、oh-my-posh/starship、UNC、PSDrive、ConstrainedLanguage/JEA。遵守执行策略;不能自动 `Set-ExecutionPolicy`、强制 Bypass 或绕过组策略。策略阻止集成时声明不可用,并给出可理解原因。[S9、N1]
|
||||
|
||||
### CMD
|
||||
|
||||
`cd /d "D:\Project"` 才会同时切换盘符和目录;单引号不是 CMD 的路径引用语法。[S10] `PROMPT` 的 `$P`、`$E` 可提供位置和序列构造,Windows Terminal 提供了会话 PROMPT 的组合示例。[S3、S11] 这只证明可上报,不自动解决命令前后边界、当前半行输入、编码或错误码保留。
|
||||
|
||||
`%NAME%` 的环境展开、`/V:ON` 的 `!NAME!`、交互输入与批处理中的 `%` 规则不同。不能照抄嵌套 `cmd /c` 包装器的转义:子 CMD 切目录不影响父 CMD;`setlocal` 包装也可能改变退出后的目录/状态。[S12、N1 的对照边界]
|
||||
|
||||
第一版可先明确拒绝未经验证的 `%`/`!` 等路径,而非声明支持任意目录。不能修改用户 delayed expansion/AutoRun 来让自己的命令能运行。UNC 也不能默认用 `pushd` 兜底:它会创建临时盘符并改变目录栈;必须另行设计可见语义、清理和失败恢复。[S10–S12]
|
||||
|
||||
## 7. WSL、路径模型和远程
|
||||
|
||||
路径至少区分显示值、执行值、文件系统地址、逻辑/物理路径及 authority。例如同一 `/home/me` 在 distro A、distro B、SSH 或容器里不是同一目标;`C:foo` 是盘符相对路径,不等于 `C:\foo`;UNC 根也不能用 POSIX 字符串截断计算父级。[S4、S10、S13]
|
||||
|
||||
WSL 优先使用所选 distro 的 helper 枚举并返回结构化目录,或经过验证的 Windows UNC 访问;不把 `$PWD` 直接交给 `listLocalDir`。Git Bash/Cygwin 则要验证挂载点、`/c`/`/cygdrive`/`/usr`、链接和编码,不能只替换斜杠。helper 不经 `ls` 文本拆行,支持中英文、空格与严格取消;必要时仍拒绝无法安全执行的控制字符。
|
||||
|
||||
PowerShell 的 drive/provider、Windows 大小写、Linux 的大小写敏感与链接语义应交给对应适配器,不对所有路径全局 lowercase/trim/decode。WSL 的 Linux FS 与 Windows FS 在大小写和访问性能上有区别。[S13]
|
||||
|
||||
本地集成不会随普通 ssh、tmux、容器或 sudo 自动迁移到目标 shell。远端可选择已有的显式 OSC 7 配置流程,或后续受控地安装当前 shell 的临时集成;必须说明配置是否持久化。不能因菜单打开而私自修改远端 rc。单通道、mosh/ET、Windows OpenSSH、受限 SFTP 需各自证明 cwd、list、change 三种能力,不能借另一个登录“猜目录”。[S2、项目现有实现]
|
||||
|
||||
## 8. 实验范围与参考资料
|
||||
|
||||
本轮完成源码检查与本机 bash/zsh/dash 命令实验。PowerShell、CMD、fish、WSL 未在本机安装,Windows 和多发行版运行结果**尚未验证**。现有 PR 的本地 PTY/SSH fixture 证明的是当前 Unix 路径,不证明 Windows 已可用。
|
||||
|
||||
官方资料均查阅于 2026-10-30:
|
||||
|
||||
- S1:[POSIX cd](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/cd.html),当前 shell、CDPATH、逻辑/物理路径。
|
||||
- S2:[VS Code shell integration](https://code.visualstudio.com/docs/terminal/shell-integration),注入、支持范围、SSH 限制、OSC 633 与 nonce。
|
||||
- S3:[Windows Terminal shell integration](https://learn.microsoft.com/en-us/windows/terminal/tutorials/shell-integration),PowerShell/CMD 的 OSC 133 和位置报告。
|
||||
- S4:[PowerShell locations](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_locations?view=powershell-7.5) 与 [Set-Location](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/set-location?view=powershell-7.5),runspace、provider、LiteralPath。
|
||||
- S5:[PowerShell quoting rules](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules?view=powershell-7.5) 与 [special characters](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_special_characters?view=powershell-7.5),引用及 5.1/6+ 语法区别。
|
||||
- S6:[Bash prompt](https://www.gnu.org/software/bash/manual/html_node/Controlling-the-Prompt.html) 与 [Bash startup files](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html)。
|
||||
- S7:[zsh functions](https://zsh.sourceforge.io/Doc/Release/Functions.html),precmd/chpwd 及 hook 链。
|
||||
- S8:[fish language](https://fishshell.com/docs/current/language.html),PWD、函数作用域与事件。
|
||||
- S9:[PowerShell execution policies](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.5)。
|
||||
- S10:[CMD cd](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cd),盘符与 `/d`。
|
||||
- S11:[CMD prompt](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/prompt),位置、转义及目录栈符号。
|
||||
- S12:[CMD](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/cmd) 与 [pushd](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/pushd),展开、AutoRun、UNC/临时盘符。
|
||||
- S13:[WSL filesystems](https://learn.microsoft.com/en-us/windows/wsl/filesystems),路径空间、大小写与访问方式。
|
||||
- S14:[WezTerm shell integration](https://wezterm.org/shell-integration.html),OSC 7 Windows/POSIX 构造对照。
|
||||
- S15:[Electron ASAR archives](https://www.electronjs.org/docs/latest/tutorial/asar-archives),虚拟路径、外部程序与 unpack 限制。
|
||||
|
||||
N1:VS Code 官方仓库固定快照 `ce233327f26ef7791b7fff0143c24e81a8126bd2`,用于验证实现机制,不表示其支持承诺等同于 Netcatty:
|
||||
|
||||
- [terminalEnvironment.ts](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/platform/terminal/node/terminalEnvironment.ts):按 shell 注入参数、ZDOTDIR 与 unsupported-args 分支。
|
||||
- [shellIntegration.ps1](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1):provider、原 prompt、状态、语言模式与协议字段。
|
||||
- [shellIntegration-bash.sh](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh)、[shellIntegration-rc.zsh](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh)、[shellIntegration.fish](https://github.com/microsoft/vscode/blob/ce233327f26ef7791b7fff0143c24e81a8126bd2/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish):各 shell 初始化及事件处理。
|
||||
@@ -1,97 +0,0 @@
|
||||
# Directory Picker:后续开发计划
|
||||
|
||||
日期:2026-10-30。目标是在现有目录芯片上补齐 Windows 原生终端与常用 shell 支持,同时保持 macOS/Linux/普通 SSH 的既有行为。技术依据、实验与官方来源见 [跨平台研究](directory-picker-cross-platform.md);当前功能规格见 [directory-picker.md](directory-picker.md)。
|
||||
|
||||
## 1. 当前交付与后续范围
|
||||
|
||||
当前 PR 实现目录菜单、绝对路径切换、真实 cwd 确认、手动 cd 联动及默认开启的 Context Chips 设置。Windows 本地、WSL/Git Bash/Cygwin 仍为不可用,不应先撤销门槛再寻找兼容方案。
|
||||
|
||||
后续按**路径与能力 → Windows PowerShell → CMD → WSL/兼容层 → 扩展远程**推进。macOS/Linux 的会话集成可与 PowerShell 阶段复用协议基础,但不同 shell 独立验收,不要求为增加一个平台一次性重写全部终端。
|
||||
|
||||
## 2. 统一能力与状态
|
||||
|
||||
不能只有一个 `supported`:至少分别描述 `readCwd`、`listDirectories`、`changeDirectory`、`confirmChange`。只有目标环境和这些能力一致,菜单才可执行切换。例如收到 OSC cwd 但没有正确的 WSL 枚举能力时,只能展示位置;没有可用的安全执行/确认时不能给出可操作假象。
|
||||
|
||||
候选能力快照应包含下列语义;这是规划,不是当前新增的公共 RPC 协议:
|
||||
|
||||
| 字段 | 用途 |
|
||||
| --- | --- |
|
||||
| session/connection/shell generation | 绑定实际目标,重连与嵌套 shell 变化废弃旧请求 |
|
||||
| shell kind/version/confidence | 区分 spawn 信息、登录 hint 与当前活动 shell;未知时拒绝危险猜测 |
|
||||
| filesystem authority/path flavor | native-win32、posix、wsl:distro、msys、cygwin、ssh 等环境身份 |
|
||||
| provider/cwd/home/source/revision | 表示 FileSystem、非文件系统、未知与过期;保留单会话排序 |
|
||||
| integration status/capabilities | active、fallback、unsupported、policy-blocked 等可解释状态 |
|
||||
|
||||
UI 显示值可以缩写和截断;执行目标保留对应命名空间的绝对路径。调用前重新校验 cwd revision/身份,结果按真实上报确认,不能只因命令已写入就宣布成功。关闭工具栏停止其额外工作,但不能拆掉其他功能仍在消费的共享集成。
|
||||
|
||||
## 3. 阶段 A:路径、能力及确认边界
|
||||
|
||||
**交付**:抽出按 path flavor 的解析、父目录、拼接、比较和显示逻辑,扩展目录控制器所需的 typed capability;保持现有 UI 与 POSIX 默认行为。
|
||||
|
||||
调整位置:`domain/directoryPicker.ts`、`terminalDirectoryController.ts`、`useTerminalDirectorySession.ts`、`infrastructure/services/terminalDirectory.ts` 及对应 bridge 类型。将变更目录的意图构造注入控制器或放入明确 shell 适配,不让控制器固定引用唯一 POSIX builder。
|
||||
|
||||
优先复用 `resolveInteractiveTerminalCdIntent` 的 POSIX 路径与控制字节规则。PowerShell/CMD 新策略不得直接改变该函数现有恢复目录/SFTP 调用者的契约。现有 AI helper 仅提取经过验证的纯引用函数,避免复用含子 shell/eval 的执行包装。
|
||||
|
||||
**验收门槛**:POSIX `/`、Windows `C:\`、UNC `\\server\share\`、相对盘符 `C:foo`、大小写和 symlink 不混淆;文件系统 authority 不同的同名路径永不互用。旧的目录、广播、草稿、会话失效、关闭 chips 和 SFTP 测试保持通过。
|
||||
|
||||
## 4. 阶段 B:shell 报告基础与 PowerShell 5.1/7
|
||||
|
||||
**交付**:本地 PowerShell FileSystem 目录展示、子目录/父目录导航、手动切换联动、真实结果确认。目标包括项目支持 Windows 版本上的系统 Windows PowerShell 5.1 和用户安装的 PowerShell 7;具体 Windows build 按当时 Electron/ConPTY 支持矩阵验证,不能用“包能打出来”代替实机支持。
|
||||
|
||||
实施点:
|
||||
|
||||
1. 在 `electron/bridges/terminalBridge.cjs` 的 spawn 参数解析后加入可测试的会话启动方案;脚本由 `tempDirBridge` 管理。保留 profile、PSReadLine、用户启动参数和原 prompt。未知参数/策略阻止时不注入,不改执行策略。
|
||||
2. 位置取自该 runspace 的 FileSystem ProviderPath;`HKLM:` 等报告为非文件系统。切换使用 LiteralPath 和 PowerShell 引用,在原 shell 生效。
|
||||
3. worker 接收并归一化 shell 状态,随后通过既有 session 事件/preload 传递给应用层。协议版本、字段转义、最大长度、旧代次与来源必须可验证;是否复用 OSC 633 在此阶段明确。
|
||||
4. cwd 报告与就绪证明分开;敏感输入、半行草稿和 TUI 仍会阻止注入。不能把可以打印任意 OSC 的进程当成 host authority。
|
||||
5. 使用本地 FS 读取原生 Windows 路径;新进入的盘符/UNC 要重新校验,FileSystem home 才可显示为 `~`。
|
||||
|
||||
**验收门槛**:Windows 实际 ConPTY 中验证切换盘符、空格、中文、单引号、方括号、`$`、反引号、只读目录、删除目录、policy-blocked、`-NoProfile`、自定义 prompt。registry/Env provider 不冒充本地目录;PowerShell 5.1 不使用仅 6+ 可用的 ESC 语法。启动失败不能损坏用户 profile,关闭后无持久配置变更。
|
||||
|
||||
同一基础在 macOS/Linux 新会话内按 shell 启用:bash 兼容 3.2 的 cwd hook,zsh 的 precmd/chpwd,fish 的事件。无法集成的 dash/ash 保留现有进程回退。完整命令生命周期在不同旧版本上未必可用,不能为 cwd 功能强制要求全部实现 preexec。
|
||||
|
||||
## 5. 阶段 C:CMD 原生路径导航
|
||||
|
||||
**交付**:会话级 cwd 上报、盘符切换、普通路径菜单、清楚的受限路径提示。
|
||||
|
||||
PROMPT 包装保留用户原值,`$P` 提供位置。`cd /d` 必须在原 CMD 中执行,不经另一个 `cmd /c`。独立实现并验证交互/batch 转义、命令扩展、`%`/`!`、编码与 ERRORLEVEL;不要通过临时改变 delayed expansion/AutoRun 绕过问题。
|
||||
|
||||
**范围选择**:可先支持经过验证的盘符绝对目录,对 `%`/`!` 和 UNC 明确不可切换。UNC 如果以后采用 pushd/popd,要先定义临时盘符、目录栈、退出及失败清理,不把这个动作伪装成无副作用的 cd。
|
||||
|
||||
**验收门槛**:命令扩展开/关、delayed expansion 开/关、默认/自定义 PROMPT、空格/中文/`&`/`^`/`%`/`!`、从 C 到 D、访问被拒、共享不可达。UI 不能把拒绝/失败当 `± 0` 类的空成功;不执行多余命令、不改变父 CMD 之外的进程来冒充结果。
|
||||
|
||||
## 6. 阶段 D:WSL、Git Bash 与 Cygwin
|
||||
|
||||
**交付**:明确目标 distro/兼容层的 cwd 与目录读取适配。Windows 上只是发现了 bash.exe/wsl.exe,不足以启用 Directory Picker。
|
||||
|
||||
- WSL 在所选 distro 内获取 shell 上报和结构化目录数据,验证 distro 身份及 `wslpath`/UNC 映射策略;同一路径不能跨 distro 复用缓存。WSL1/2 的支持范围按实测决定。
|
||||
- Git Bash/Cygwin 各自验证 mount/drive/symlink 映射,不能把 POSIX 路径字符串直接交给 Windows `fs`。
|
||||
- helper 协议保留原始名称、文件类型和目录链接;支持有限输出、取消、权限失败。不得使用 `ls` 的展示文本做机器目录清单。
|
||||
|
||||
**验收门槛**:Linux home、Windows 挂载盘、两个 distro 相同路径、大小写敏感目录、链接、编码、启动参数和 unavailable provider。不支持的映射有具体提示;测试不得通过修改用户 distro 配置来获得成功。
|
||||
|
||||
## 7. 阶段 E:远程与嵌套 shell
|
||||
|
||||
普通 Unix SSH 保留当前严格探测和 exact-session SFTP 约束。远程集成是目标 shell 的独立能力,不随本机启动脚本自动生效。已有显式 OSC 7 配置写 rc,与后续无持久化注入必须在 UI 和权限上区分。
|
||||
|
||||
逐项处理 SSH 到 Windows、su/sudo、嵌套 shell、tmux/screen、容器、mosh/ET、SFTP chroot 和单通道跳板。一个 cwd 报告不能证明登录用户的 SFTP 有相同目录视图;无法证明映射或当前活动 shell 身份时,保持不可用,不新建另一个登录。
|
||||
|
||||
**验收门槛**:连接及 shell 代次变更、主机不同的同名 cwd、权限跨用户、拒绝额外通道、工作目录已删除、重连/hibernate/附加窗口。自动安装远端脚本不纳入当前计划的默认行为;必须经独立范围确认。
|
||||
|
||||
## 8. 必须保留的回归与完成标准
|
||||
|
||||
| 类别 | 验收内容 |
|
||||
| --- | --- |
|
||||
| 命令与路径 | 每个 shell 的实际输入结果;绝对路径不受 CDPATH/异步 cwd 改变影响;特殊字符不执行额外代码 |
|
||||
| shell 生命周期 | prompt、原状态码、PATH、历史与主题不被破坏;login、无 profile、特殊启动参数有明确行为 |
|
||||
| 协议 | 分片/合并、BEL/ST、编码、Windows URI、大小上限、伪造/旧报告、重复集成和 nonce 限制 |
|
||||
| 正确性 | 手动 cd、函数 cd、延迟 cd、失败 cd、逻辑/物理路径、provider 切换、目录列表与真实 cwd 一致 |
|
||||
| 安全与目标 | 密码/MFA/sudo、TUI、半行输入、广播开启、切焦点/重连:没有错误目标写入 |
|
||||
| 平台 | Windows 原生 ConPTY 的 5.1/7/CMD;macOS 系统 bash 3.2 与 zsh;Linux bash/dash/ash/fish/zsh |
|
||||
| 发行版 | Ubuntu/Debian、Fedora/Rocky 或 Alma、Arch、Alpine 作为后端 shell 样本;不把同一 shell 的语法复制测试替代真实环境 |
|
||||
| 性能 | 有效集成优先事件更新,回退探测合并/单飞/有超时;空闲不额外轮询,关闭 chips 不产生其额外读写 |
|
||||
| 打包 | 外部 shell 使用真实脚本资源或受管副本,不依赖 asar 虚拟路径;native helper 如有新增,验证各架构与签名;资源变更通过 `npm run pack:dir` |
|
||||
|
||||
现有 domain、controller、registry、hook、真实 PTY、SSH fixture 与 Windows CI 是起点。后续 Windows 测试必须使用真实 Windows shell/ConPTY 验证 runspace 状态,不能仅靠 Linux mock 或 Windows 编译成功。是否开启某个平台,由这些验收结果决定,不以删除平台 gate 作为完成标准。
|
||||
|
||||
当前 PR 只追加研究与规划;阶段 A–E 的产品实现、脚本安装及协议扩展由后续独立改动完成。
|
||||
@@ -6,10 +6,6 @@
|
||||
|
||||
本地模拟见 [directory-picker-demo.html](directory-picker-demo.html),不访问文件系统或真实终端。
|
||||
|
||||
Context Chips 在“设置 → 终端”中默认开启,可关闭整行。该偏好随现有终端设置保存,旧配置默认开启;关闭不会发送或清空草稿,也不会继续订阅芯片的额外 cwd 刷新。
|
||||
|
||||
Windows 原生 shell、跨平台路径与 shell 集成的后续范围见 [跨平台研究](directory-picker-cross-platform.md) 和 [开发计划](directory-picker-follow-up.md),不改变本 PR 的产品支持范围。
|
||||
|
||||
## 1. 目标与首期边界
|
||||
|
||||
用户在撰写命令前,能看到**当前聚焦终端的真实工作目录**,并通过一个小菜单搜索当前层子目录、返回父目录、选择目录,使接下来执行的命令落在目标位置。
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DEFAULT_AI_SESSION_MESSAGE_CAP,
|
||||
MAX_AI_SESSION_MESSAGE_CAP,
|
||||
MIN_AI_SESSION_MESSAGE_CAP,
|
||||
normalizeAiSessionMessageCap,
|
||||
} from './aiSessionMessageCap';
|
||||
|
||||
test('normalizeAiSessionMessageCap keeps in-range integers untouched', () => {
|
||||
assert.equal(normalizeAiSessionMessageCap(200), 200);
|
||||
assert.equal(normalizeAiSessionMessageCap(MIN_AI_SESSION_MESSAGE_CAP), MIN_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(MAX_AI_SESSION_MESSAGE_CAP), MAX_AI_SESSION_MESSAGE_CAP);
|
||||
});
|
||||
|
||||
test('normalizeAiSessionMessageCap clamps below the minimum and above the maximum', () => {
|
||||
assert.equal(normalizeAiSessionMessageCap(10), MIN_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(49.4), MIN_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(5000), MAX_AI_SESSION_MESSAGE_CAP);
|
||||
});
|
||||
|
||||
test('normalizeAiSessionMessageCap rounds fractional values and rejects non-finite input', () => {
|
||||
assert.equal(normalizeAiSessionMessageCap(120.6), 121);
|
||||
assert.equal(normalizeAiSessionMessageCap(Number.NaN), DEFAULT_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(Number.POSITIVE_INFINITY), DEFAULT_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap(undefined), DEFAULT_AI_SESSION_MESSAGE_CAP);
|
||||
assert.equal(normalizeAiSessionMessageCap('not-a-number'), DEFAULT_AI_SESSION_MESSAGE_CAP);
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* User-configurable per-session AI chat message cap.
|
||||
*
|
||||
* Storage moved to IndexedDB (issue #4123), so the tight cap that defended the
|
||||
* shared 2 MB localStorage budget no longer drives retention. The cap now only
|
||||
* bounds per-record size, cloud-sync payload and UI rendering, so users may
|
||||
* raise it; it still stays bounded to keep those costs predictable.
|
||||
*/
|
||||
|
||||
export const DEFAULT_AI_SESSION_MESSAGE_CAP = 200;
|
||||
export const MIN_AI_SESSION_MESSAGE_CAP = 50;
|
||||
export const MAX_AI_SESSION_MESSAGE_CAP = 2000;
|
||||
|
||||
export function normalizeAiSessionMessageCap(value: unknown): number {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numeric)) return DEFAULT_AI_SESSION_MESSAGE_CAP;
|
||||
return Math.min(
|
||||
MAX_AI_SESSION_MESSAGE_CAP,
|
||||
Math.max(MIN_AI_SESSION_MESSAGE_CAP, Math.round(numeric)),
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { directoryDisplayName, directoryEntries, directoryLabel, directoryParent, filterDirectories, isDirectoryPath } from './directoryPicker';
|
||||
|
||||
test('directory paths preserve names, enforce home boundaries, and stop at root', () => {
|
||||
assert.equal(directoryLabel('/home/me/a ', '/home/me'), '~/a ');
|
||||
assert.equal(directoryLabel('/home/metal/a', '/home/me'), '/home/metal/a');
|
||||
assert.equal(directoryParent('/'), null);
|
||||
assert.equal(directoryParent('/one/'), '/');
|
||||
assert.equal(directoryParent('/one/two '), '/one');
|
||||
assert.ok(isDirectoryPath("/a '$()`中文"));
|
||||
for (const value of ['~/a', 'C:\\a', '/a\n', '/a\x1b', '/a\0']) assert.equal(isDirectoryPath(value), false);
|
||||
assert.equal(directoryDisplayName('a\n\x1b'), 'a\\x0a\\x1b');
|
||||
});
|
||||
|
||||
test('directory results include only directories and directory symlinks; filtering is local and natural', () => {
|
||||
const entries = directoryEntries('/base', [
|
||||
{ name: 'app10', type: 'directory', size: '', lastModified: '' },
|
||||
{ name: 'app2', type: 'symlink', linkTarget: 'directory', size: '', lastModified: '' },
|
||||
{ name: '.config', type: 'directory', size: '', lastModified: '' },
|
||||
{ name: 'broken', type: 'symlink', size: '', lastModified: '' },
|
||||
{ name: 'file', type: 'file', size: '', lastModified: '' },
|
||||
{ name: '..', type: 'directory', size: '', lastModified: '' },
|
||||
]);
|
||||
assert.deepEqual(filterDirectories(entries, 'APP', false).map(entry => entry.path), ['/base/app2', '/base/app10']);
|
||||
assert.equal(filterDirectories(entries, '', false).length, 2);
|
||||
assert.equal(filterDirectories(entries, '', true).length, 3);
|
||||
assert.deepEqual(filterDirectories(entries, 'missing', true), []);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { RemoteFile } from './models';
|
||||
import { resolveInteractiveTerminalCdIntent } from './sessionRestore';
|
||||
|
||||
export type DirectoryEntry = { name: string; path: string; hidden: boolean };
|
||||
export type DirectoryPickerErrorCode = 'unavailable' | 'unknown' | 'busy' | 'readFailed' | 'changeFailed' | 'unsupportedPath';
|
||||
|
||||
export class DirectoryPickerError extends Error {
|
||||
constructor(public readonly code: DirectoryPickerErrorCode) { super(code); }
|
||||
}
|
||||
|
||||
export function isDirectoryPath(path: string | null | undefined): path is string {
|
||||
return typeof path === 'string' && path.startsWith('/') && !!resolveInteractiveTerminalCdIntent(path);
|
||||
}
|
||||
|
||||
export function directoryParent(path: string): string | null {
|
||||
const trimmed = path.replace(/\/+$/, '') || '/';
|
||||
return trimmed === '/' ? null : trimmed.slice(0, trimmed.lastIndexOf('/')) || '/';
|
||||
}
|
||||
|
||||
export function directoryLabel(path: string, home?: string | null): string {
|
||||
const prefix = home?.replace(/\/+$/, '');
|
||||
if (prefix && (path === prefix || path.startsWith(prefix + '/'))) return '~' + path.slice(prefix.length);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function directoryEntries(path: string, files: readonly RemoteFile[]): DirectoryEntry[] {
|
||||
return files.filter(file => file.type === 'directory' || (file.type === 'symlink' && file.linkTarget === 'directory'))
|
||||
.filter(file => file.name !== '.' && file.name !== '..' && !file.name.includes('/') && file.name.length > 0)
|
||||
.map(file => ({ name: file.name, path: (path === '/' ? '' : path.replace(/\/+$/, '')) + '/' + file.name,
|
||||
hidden: file.hidden === true || file.name.startsWith('.') }));
|
||||
}
|
||||
|
||||
const names = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
export function filterDirectories(entries: readonly DirectoryEntry[], query: string, showHidden: boolean): DirectoryEntry[] {
|
||||
const needle = query.toLocaleLowerCase();
|
||||
return entries.filter(entry => (showHidden || !entry.hidden) && entry.name.toLocaleLowerCase().includes(needle))
|
||||
.sort((a, b) => names.compare(a.name, b.name) || a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** Control characters remain visible as data rather than affecting menu layout. */
|
||||
export function directoryDisplayName(name: string): string {
|
||||
return Array.from(name, char => char.charCodeAt(0) <= 31 || char.charCodeAt(0) === 127
|
||||
? '\\x' + char.charCodeAt(0).toString(16).padStart(2, '0') : char).join('');
|
||||
}
|
||||
@@ -71,8 +71,6 @@ export interface KeywordHighlightRule {
|
||||
export type TerminalTabDoubleClickBehavior = 'duplicate' | 'copy' | 'disabled';
|
||||
|
||||
export interface TerminalSettings {
|
||||
/** Context chips below quick commands in the compose bar. */
|
||||
contextChipsEnabled?: boolean;
|
||||
// Rendering
|
||||
scrollback: number; // Number of lines kept in buffer
|
||||
drawBoldInBrightColors: boolean; // Draw bold text in bright colors
|
||||
@@ -440,7 +438,6 @@ export const normalizeTerminalSettings = (
|
||||
const mergedSettings = {
|
||||
...DEFAULT_TERMINAL_SETTINGS,
|
||||
...(settings ?? {}),
|
||||
contextChipsEnabled: settings?.contextChipsEnabled !== false,
|
||||
middleClickBehavior,
|
||||
middleClickPaste: middleClickBehavior === 'paste',
|
||||
wordSeparators,
|
||||
@@ -534,7 +531,6 @@ export function isLegacyDefaultShiftEnterNewlineText(value: unknown): boolean {
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
contextChipsEnabled: true,
|
||||
scrollback: DEFAULT_TERMINAL_SCROLLBACK,
|
||||
drawBoldInBrightColors: true,
|
||||
terminalEmulationType: 'xterm-256color',
|
||||
|
||||
@@ -429,13 +429,6 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.terminal.behavior.shiftEnterForceText.desc",
|
||||
sectionKey: "settings.terminal.section.behavior",
|
||||
},
|
||||
{
|
||||
id: "terminal-context-chips",
|
||||
tab: "terminal",
|
||||
labelKey: "settings.terminal.behavior.contextChips",
|
||||
descriptionKey: "settings.terminal.behavior.contextChips.desc",
|
||||
sectionKey: "settings.terminal.section.behavior",
|
||||
},
|
||||
{
|
||||
id: "terminal-clear-wipes-scrollback",
|
||||
tab: "terminal",
|
||||
|
||||
@@ -393,46 +393,3 @@ test('real pending PowerShell input is still not sent', async (t) => {
|
||||
assert.match(result.error, /no command was sent/);
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
|
||||
test('OSC 133 A marker readies exec for a colored P10k prompt the whitelist rejects', async () => {
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
// Colored P10k-style prompt after a prompt-start marker; the SGR codes and
|
||||
// ❯ shape are irrelevant — the marker proves the shell accepts input.
|
||||
trackSessionIdlePrompt(
|
||||
session,
|
||||
'\x1b]133;A\x07\x1b[38;5;39m~/src\x1b[0m \x1b[38;5;213m❯\x1b[0m ',
|
||||
);
|
||||
assert.equal(readyPrompt(session), '~/src ❯ ');
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 200,
|
||||
timeoutMs: 1000,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(pty.writes.length, 1);
|
||||
});
|
||||
|
||||
test('OSC 133 C/D markers keep exec gated even when output echoes a known prompt', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
|
||||
const pty = new TestPty();
|
||||
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
|
||||
trackSessionIdlePrompt(session, '\x1b]133;A\x07PS C:\\Users\\alice> ');
|
||||
assert.equal(readyPrompt(session), 'PS C:\\Users\\alice> ');
|
||||
// Command in flight; the program echoes prompt-shaped text. Text says
|
||||
// ready, the last marker says otherwise — markers override.
|
||||
trackSessionIdlePrompt(session, '\x1b]133;C\x07build running...\r\nPS C:\\Users\\alice> ');
|
||||
assert.equal(readyPrompt(session), '');
|
||||
const job = startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'powershell',
|
||||
getReadyPrompt: () => readyPrompt(session),
|
||||
promptWaitTimeoutMs: 50,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
t.mock.timers.tick(50);
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.match(result.error, /no command was sent/);
|
||||
assert.deepEqual(pty.writes, []);
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { trackSessionIdlePrompt, getFreshIdlePrompt, getVisibleIdlePrompt, computeOsc133ReadyState } = require('./shellUtils.cjs');
|
||||
const { startPtyJob, resolveEffectiveShellKind } = require('./ptyExec.cjs');
|
||||
|
||||
const A = '\x1b]133;A\x07';
|
||||
const B = '\x1b]133;B\x07';
|
||||
const C = '\x1b]133;C\x07';
|
||||
const D = '\x1b]133;D;0\x1b\\';
|
||||
const ready = session => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session);
|
||||
|
||||
class TestPty extends EventEmitter {
|
||||
writes = [];
|
||||
write(data) {
|
||||
this.writes.push(data);
|
||||
const marker = String(data).match(/(__NCMCP_[a-z0-9]+_[0-9a-f]+__)/i)?.[1];
|
||||
if (marker) queueMicrotask(() => this.emit('data', `${marker}_S\nOK\n${marker}_E:0\n`));
|
||||
}
|
||||
}
|
||||
|
||||
test('OSC prompts reject pending input and continuations without writing any bytes', async () => {
|
||||
for (const prompt of ['review@local:~$ ', 'custom λ ', '> ']) {
|
||||
for (const pending of ['echo half-typed', ' ', 'echo "unfinished\r\n> ']) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + prompt + B);
|
||||
assert.equal(ready(session), prompt);
|
||||
trackSessionIdlePrompt(session, pending);
|
||||
assert.equal(ready(session), '', JSON.stringify({ prompt, pending }));
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('OSC prompt start alone does not revive a stale cached prompt', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, 'PS C:\\Users\\review> ');
|
||||
trackSessionIdlePrompt(session, A);
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, '❯ ' + B);
|
||||
assert.equal(getFreshIdlePrompt(session), '');
|
||||
assert.equal(ready(session), '❯ ');
|
||||
assert.equal(resolveEffectiveShellKind('', ready(session), { loginShellHint: 'posix' }), 'posix');
|
||||
});
|
||||
|
||||
test('OSC custom prompts need prompt-end B before arbitrary text can qualify', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'custom λ ');
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, B);
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
});
|
||||
|
||||
test('OSC busy and finished states survive 4KB tail eviction until a new prompt', () => {
|
||||
for (const marker of [C, D]) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'review@local:~$ ' + B + marker);
|
||||
trackSessionIdlePrompt(session, 'x'.repeat(4200) + '\r\nreview@local:~$ ');
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + 'custom λ ' + B);
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
}
|
||||
});
|
||||
|
||||
test('OSC busy marker is retained even when it arrives inside one oversized chunk', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, C + 'x'.repeat(4200) + '\r\nreview@local:~$ ');
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(ready(session), '');
|
||||
});
|
||||
|
||||
test('OSC parser accepts complete BEL/ST parameterized markers but rejects prefixes and invalid opcodes', () => {
|
||||
for (const tail of ['\x1b]133;A', '\x1b]133;Aardvark\x07', '\x1b]133;C-more\x07']) {
|
||||
assert.equal(computeOsc133ReadyState(tail), null, JSON.stringify(tail));
|
||||
}
|
||||
assert.equal(computeOsc133ReadyState('\x1b]133;A;cl=m\x1b\\'), true);
|
||||
assert.equal(computeOsc133ReadyState(C + '\x1b]133;A'), false);
|
||||
});
|
||||
|
||||
test('OSC secondary prompt markers never make unfinished input ready', () => {
|
||||
for (const prompt of ['> ', 'review@local:~$ ']) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'custom λ ' + B + 'echo "\r\n');
|
||||
trackSessionIdlePrompt(session, '\x1b]133;A;k=s\x07' + prompt + B);
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(ready(session), '');
|
||||
assert.equal(computeOsc133ReadyState('\x1b]133;A;k=s\x07'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('OSC markers and custom prompts survive every one-byte PTY split', () => {
|
||||
const session = {};
|
||||
for (const byte of '\x1b]133;A;cl=m\x1b\\custom λ \x1b]133;B\x1b\\') {
|
||||
trackSessionIdlePrompt(session, byte);
|
||||
}
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
for (const byte of C) trackSessionIdlePrompt(session, byte);
|
||||
assert.equal(ready(session), '');
|
||||
});
|
||||
|
||||
test('OSC prompt recovery after erased input accepts only the original empty prompt', () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + 'custom λ ' + B + 'partial');
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, '\r\x1b[2Kcustom λ ');
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
trackSessionIdlePrompt(session, 'echo "\r\n> ');
|
||||
trackSessionIdlePrompt(session, '\r\x1b[2Kcustom λ ');
|
||||
assert.equal(ready(session), '', 'continuation requires a new prompt cycle');
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + 'custom λ ' + B);
|
||||
assert.equal(ready(session), '', 'newline and A/B do not prove cancellation');
|
||||
trackSessionIdlePrompt(session, C + D + A + 'custom λ ' + B);
|
||||
assert.equal(ready(session), 'custom λ ');
|
||||
});
|
||||
|
||||
test('ambiguous backspace-space-backspace output never authorizes command injection', async () => {
|
||||
for (const prompt of ['review@local:~$ ', 'custom \u03bb ']) {
|
||||
for (const erased of ['x\b \b', 'xy\b \b\b \b',
|
||||
'hello' + '\b'.repeat(5) + ' '.repeat(5) + '\b'.repeat(5)]) {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, A + prompt + B);
|
||||
trackSessionIdlePrompt(session, erased);
|
||||
assert.equal(ready(session), '', JSON.stringify({ prompt, erased }));
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
trackSessionIdlePrompt(session, ' ');
|
||||
assert.equal(ready(session), '', 'a new space advances the input cursor');
|
||||
trackSessionIdlePrompt(session, '\b');
|
||||
assert.equal(ready(session), '', 'moving left must not erase a genuinely pending space');
|
||||
trackSessionIdlePrompt(session, ' \b');
|
||||
assert.equal(ready(session), '', 'moving right and left across a blank must remain pending');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real Bash line editing keeps ambiguous deletion and pending blanks gated', (t) => {
|
||||
if (process.platform === 'win32') return t.skip('requires a POSIX Bash PTY');
|
||||
const probe = spawnSync('python3', ['-c', 'import pty']);
|
||||
if (probe.error?.code === 'ENOENT') return t.skip('python3 is unavailable');
|
||||
assert.equal(probe.status, 0, probe.stderr?.toString());
|
||||
for (const args of [[], ['--wide-prompt']]) {
|
||||
const result = spawnSync('python3', [path.resolve(__dirname, '../../../scripts/fixtures/osc133-erase-input.py'), ...args],
|
||||
{ encoding: 'utf8', timeout: 10_000 });
|
||||
if (result.status === 77) return t.skip('a UTF-8 locale is unavailable');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const scenarios = JSON.parse(result.stdout);
|
||||
assert.equal(scenarios.length, 27);
|
||||
const word = scenarios.find(({ name }) => name === 'Ctrl-W whole word');
|
||||
assert.ok(word.states.at(-1).raw.includes('\b'.repeat(5) + ' '.repeat(5) + '\b'.repeat(5)));
|
||||
const forward = scenarios.find(({ name }) => name === 'mixed forward delete');
|
||||
const kill = scenarios.find(({ name }) => name === 'indistinguishable mixed line kill');
|
||||
// The first leaves a genuine blank; the second empties input. Identical
|
||||
// output cannot certify which happened, so both must stay conservatively gated.
|
||||
assert.equal(forward.states.slice(1).map(({ raw }) => raw).join(''),
|
||||
kill.states.slice(1).map(({ raw }) => raw).join(''));
|
||||
for (const scenario of scenarios) {
|
||||
const session = {}, splitSession = {};
|
||||
for (const { step, raw, ready: expected } of scenario.states) {
|
||||
trackSessionIdlePrompt(session, raw);
|
||||
for (const ch of raw) {
|
||||
trackSessionIdlePrompt(splitSession, ch);
|
||||
if (!expected) assert.equal(Boolean(ready(splitSession)), false,
|
||||
`intermediate byte of ${scenario.name}: ${step}`);
|
||||
}
|
||||
assert.equal(Boolean(ready(session)), expected, `${scenario.name}: ${step}`);
|
||||
assert.equal(Boolean(ready(splitSession)), expected, `byte-split ${scenario.name}: ${step}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('real Bash vi replacement and multiline Ctrl-L remain unready through every packet boundary', async (t) => {
|
||||
if (process.platform === 'win32') return t.skip('requires a POSIX Bash PTY');
|
||||
const probe = spawnSync('python3', ['-c', 'import pty']);
|
||||
if (probe.error?.code === 'ENOENT') return t.skip('python3 is unavailable');
|
||||
assert.equal(probe.status, 0, probe.stderr?.toString());
|
||||
const result = spawnSync('python3', [path.resolve(__dirname, '../../../scripts/fixtures/osc133-erase-input.py'), '--risk-probes'],
|
||||
{ encoding: 'utf8', timeout: 10_000 });
|
||||
if (result.status === 77) return t.skip('a UTF-8 locale is unavailable');
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const scenarios = JSON.parse(result.stdout);
|
||||
assert.equal(scenarios.length, 2);
|
||||
assert.equal(scenarios[0].readline_line, ' ', 'bind -x confirms vi replacement leaves real input');
|
||||
assert.equal(scenarios[0].states.slice(1).map(({ raw }) => raw).join(''), 'x\b \b');
|
||||
assert.equal(scenarios[1].readline_line, 'echo pending');
|
||||
const redraw = scenarios[1].states.at(-1).raw;
|
||||
assert.ok(redraw.includes(A + 'header\r\r\n'));
|
||||
assert.ok(redraw.includes(B), 'Ctrl-L redraw includes the input boundary before re-echo');
|
||||
for (const scenario of scenarios) {
|
||||
const session = {};
|
||||
for (const { step, raw, ready: expected } of scenario.states) {
|
||||
for (const ch of raw) {
|
||||
trackSessionIdlePrompt(session, ch);
|
||||
if (!expected) assert.equal(ready(session), '', `${scenario.name}: ${step}, intermediate packet`);
|
||||
}
|
||||
assert.equal(Boolean(ready(session)), expected, `${scenario.name}: ${step}`);
|
||||
}
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.code, 'SHELL_NOT_READY');
|
||||
assert.deepEqual(pty.writes, []);
|
||||
// Repeated redraws, their own newlines, and secondary prompts cannot reset
|
||||
// uncertain input. Only a completed command lifecycle permits recovery.
|
||||
for (const text of ['\r\n' + A + 'header\r\nreview@local:~$ ' + B,
|
||||
'\x1b]133;A;k=s\x07> ' + B + A + 'custom ' + B]) {
|
||||
for (const ch of text) {
|
||||
trackSessionIdlePrompt(session, ch);
|
||||
assert.equal(ready(session), '');
|
||||
}
|
||||
}
|
||||
trackSessionIdlePrompt(session, C + D + A + 'custom ' + B);
|
||||
assert.equal(ready(session), 'custom ');
|
||||
}
|
||||
});
|
||||
|
||||
test('same-row OSC redraw stays gated until a fresh prompt lifecycle', () => {
|
||||
const session = {}, prompt = 'review@local:~$ ';
|
||||
trackSessionIdlePrompt(session, A + prompt + B + 'x ');
|
||||
for (const ch of '\r' + A + prompt + B) {
|
||||
trackSessionIdlePrompt(session, ch);
|
||||
assert.equal(ready(session), '');
|
||||
}
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + prompt + B);
|
||||
assert.equal(ready(session), '', 'a newline cannot clear the redraw latch');
|
||||
trackSessionIdlePrompt(session, 'x' + '\r' + A + prompt + B);
|
||||
assert.equal(ready(session), '');
|
||||
trackSessionIdlePrompt(session, C + D + A + prompt + B);
|
||||
assert.equal(ready(session), prompt);
|
||||
});
|
||||
|
||||
test('OSC repeats complete execution and prompt cycles without stale prompt state', async () => {
|
||||
const session = {};
|
||||
for (const prompt of ['custom λ ', 'other ❯ ', 'review@local:~$ ']) {
|
||||
trackSessionIdlePrompt(session, '\r\n' + A + prompt + B);
|
||||
const pty = new TestPty();
|
||||
const result = await startPtyJob(pty, 'echo OK', {
|
||||
shellKind: 'posix', getReadyPrompt: () => ready(session), promptWaitTimeoutMs: 5, timeoutMs: 200,
|
||||
}).resultPromise;
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(pty.writes.length, 1);
|
||||
trackSessionIdlePrompt(session, C + 'output' + D);
|
||||
assert.equal(ready(session), '');
|
||||
}
|
||||
});
|
||||
@@ -224,109 +224,11 @@ function looksLikeIdleAutoLogout(outputTail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── OSC 133 (shell-integration) readiness ──
|
||||
//
|
||||
// A starts a prompt; B ends its rendering and starts command input; C starts
|
||||
// execution/output; D finishes execution. A alone does not prove empty input.
|
||||
// Keep lifecycle state beyond rolling-tail eviction, and accept custom prompts
|
||||
// only while the B-marked cursor line is unchanged and no continuation arrived.
|
||||
// A-only integrations retain the existing whitelist checks.
|
||||
const OSC133_MARKER_PATTERN = /\u001B\]133;([ABCD])(?:;([^\u0007\u001B]*))?(?:\u0007|\u001B\\)/g;
|
||||
|
||||
function isOsc133PrimaryPrompt(marker) {
|
||||
// Kitty's k=s marks PS2, which is unfinished command input, not a fresh PS1.
|
||||
return marker[1] === "A" && !marker[2]?.split(";").includes("k=s");
|
||||
}
|
||||
|
||||
function computeOsc133ReadyState(tail) {
|
||||
if (typeof tail !== "string" || !tail) return null;
|
||||
let ready = null;
|
||||
for (const marker of tail.matchAll(OSC133_MARKER_PATTERN)) {
|
||||
if (marker[1] !== "B") ready = isOsc133PrimaryPrompt(marker);
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
function promptTrackingView(session) {
|
||||
const tail = session?._promptTrackTail;
|
||||
if (typeof tail !== "string" || !tail) return { tail: "", alternateScreenActive: false };
|
||||
if (session.osc133Ready === true) {
|
||||
const start = session._osc133PromptStart;
|
||||
return {
|
||||
// Never revive text from before A, or a prompt whose start was evicted.
|
||||
tail: typeof start === "number" && start >= 0 ? tail.slice(start) : "",
|
||||
alternateScreenActive: session._osc133PromptAltScreenAtStart === true,
|
||||
};
|
||||
}
|
||||
return { tail, alternateScreenActive: session._promptTrackAltScreenAtTailStart === true };
|
||||
}
|
||||
|
||||
function matchesOsc133EmptyPrompt(session, view) {
|
||||
if (session._osc133PromptRedrawPending) return false;
|
||||
if (session._osc133PromptLine == null) return true;
|
||||
if (session._osc133PromptContinued) return false;
|
||||
const { terminalCursorState } = require("../terminalLogSanitizer.cjs");
|
||||
const cursor = terminalCursorState(view.tail, view);
|
||||
// Readline deletion and vi replacement can emit identical BS-space-BS
|
||||
// bytes. Preserve every displayed blank; output cannot certify its removal.
|
||||
return cursor.column === session._osc133PromptColumn
|
||||
&& cursor.line === session._osc133PromptLine;
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
const previousTail = session._promptTrackTail || "";
|
||||
const combined = `${previousTail}${chunk}`;
|
||||
session.osc133Ready ??= null;
|
||||
for (const marker of combined.matchAll(OSC133_MARKER_PATTERN)) {
|
||||
const end = marker.index + marker[0].length;
|
||||
// Replaying old markers would erase pending-input state on every chunk.
|
||||
// A marker split across chunks is consumed when its terminator arrives.
|
||||
if (end <= previousTail.length) continue;
|
||||
if (isOsc133PrimaryPrompt(marker)) {
|
||||
const { alternateScreenActiveAfter } = require("../terminalLogSanitizer.cjs");
|
||||
const beforeMarker = combined.slice(0, marker.index);
|
||||
const priorSession = { ...session, _promptTrackTail: beforeMarker };
|
||||
// A/B also occur during redraw, including multiline PS1 and packets
|
||||
// ending before pending input is re-echoed. Neither A nor a newline
|
||||
// proves cancellation. Keep uncertain input gated until C/D establishes
|
||||
// a new command lifecycle; a redraw cannot clear this latch.
|
||||
session._osc133PromptRedrawPending ||= session.osc133Ready === true
|
||||
&& session._osc133PromptLine != null
|
||||
&& !matchesOsc133EmptyPrompt(priorSession, promptTrackingView(priorSession));
|
||||
session.osc133Ready = true;
|
||||
session._osc133PromptStart = end;
|
||||
session._osc133PromptAltScreenAtStart = alternateScreenActiveAfter(
|
||||
combined.slice(0, end), session._promptTrackAltScreenAtTailStart === true,
|
||||
);
|
||||
session._osc133PromptLine = null;
|
||||
session._osc133PromptEnd = -1;
|
||||
session._osc133PromptContinued = false;
|
||||
} else if (marker[1] === "B") {
|
||||
if (session.osc133Ready === true && session._osc133PromptStart >= 0) {
|
||||
const { terminalCursorState } = require("../terminalLogSanitizer.cjs");
|
||||
const cursor = terminalCursorState(
|
||||
combined.slice(session._osc133PromptStart, end),
|
||||
{ alternateScreenActive: session._osc133PromptAltScreenAtStart === true },
|
||||
);
|
||||
session._osc133PromptLine = cursor.line;
|
||||
session._osc133PromptColumn = cursor.column;
|
||||
session._osc133PromptEnd = end;
|
||||
}
|
||||
} else {
|
||||
// Secondary prompts continue input; only C/D establish a new lifecycle.
|
||||
session._osc133PromptRedrawPending = marker[1] === "A";
|
||||
session.osc133Ready = false;
|
||||
session._osc133PromptLine = null;
|
||||
session._osc133PromptStart = -1;
|
||||
session._osc133PromptEnd = -1;
|
||||
}
|
||||
}
|
||||
if (session._osc133PromptLine != null) {
|
||||
const inputStart = Math.max(previousTail.length, session._osc133PromptEnd);
|
||||
if (combined.slice(inputStart).includes("\n")) session._osc133PromptContinued = true;
|
||||
}
|
||||
const overflow = combined.length - MAX_PROMPT_TRACK_TAIL;
|
||||
if (overflow > 0) {
|
||||
// The kept tail no longer starts where the previous tail started. Replay
|
||||
@@ -337,14 +239,11 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
combined.slice(0, overflow),
|
||||
session._promptTrackAltScreenAtTailStart === true,
|
||||
);
|
||||
for (const key of ["_osc133PromptStart", "_osc133PromptEnd"]) {
|
||||
if (typeof session[key] === "number") session[key] = Math.max(-1, session[key] - overflow);
|
||||
}
|
||||
}
|
||||
const nextTail = overflow > 0 ? combined.slice(overflow) : combined;
|
||||
session._promptTrackTail = nextTail;
|
||||
|
||||
const prompt = extractTrailingIdlePrompt(promptTrackingView(session).tail);
|
||||
const prompt = extractTrailingIdlePrompt(nextTail);
|
||||
if (prompt) {
|
||||
session.lastIdlePrompt = prompt;
|
||||
session.lastIdlePromptAt = Date.now();
|
||||
@@ -370,18 +269,11 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
// back to `shellKind` alone.
|
||||
function getFreshIdlePrompt(session) {
|
||||
if (!session) return "";
|
||||
|
||||
// C/D gate text-shaped output. A never bypasses cache freshness or input
|
||||
// checks: an earlier prompt remains marked A while a user edits a command.
|
||||
if (session.osc133Ready === false) return "";
|
||||
|
||||
const cached = session.lastIdlePrompt;
|
||||
if (!cached) return "";
|
||||
|
||||
const view = promptTrackingView(session);
|
||||
const tail = view.tail;
|
||||
const tail = session._promptTrackTail;
|
||||
if (typeof tail !== "string" || !tail) return "";
|
||||
if (!matchesOsc133EmptyPrompt(session, view)) return "";
|
||||
|
||||
const normalizedTail = stripAnsi(tail).replace(/\r/g, "\n");
|
||||
const normalizedCached = stripAnsi(cached).replace(/\r/g, "\n");
|
||||
@@ -397,23 +289,15 @@ function getFreshIdlePrompt(session) {
|
||||
// screen is still an idle default prompt (#3927). Collapse those cursor and
|
||||
// erase operations, then accept only the line the cursor is actually on.
|
||||
function getVisibleIdlePrompt(session) {
|
||||
const view = promptTrackingView(session);
|
||||
const tail = view.tail;
|
||||
const tail = session?._promptTrackTail;
|
||||
if (typeof tail !== "string" || !tail) return "";
|
||||
|
||||
// Mirror getFreshIdlePrompt: C/D gate output, B confirms an empty input line.
|
||||
if (session.osc133Ready === false) return "";
|
||||
if (!matchesOsc133EmptyPrompt(session, view)) return "";
|
||||
|
||||
const { terminalCursorLine } = require("../terminalLogSanitizer.cjs");
|
||||
const cursorLine = terminalCursorLine(tail, view);
|
||||
const cursorLine = terminalCursorLine(tail, {
|
||||
alternateScreenActive: session._promptTrackAltScreenAtTailStart === true,
|
||||
});
|
||||
const rightTrimmed = cursorLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
if (session.osc133Ready === true && session._osc133PromptLine != null) {
|
||||
// A complete B gives the custom prompt boundary; its unchanged cursor line
|
||||
// and lack of continuation are checked above before bypassing shape checks.
|
||||
return /\s$/.test(cursorLine) ? `${rightTrimmed} ` : rightTrimmed;
|
||||
}
|
||||
if (
|
||||
!isDefaultPowerShellPromptLine(rightTrimmed)
|
||||
&& !isDefaultPosixPromptLine(rightTrimmed)
|
||||
@@ -1367,7 +1251,6 @@ module.exports = {
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
getVisibleIdlePrompt,
|
||||
computeOsc133ReadyState,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isDefaultCmdPromptLine,
|
||||
isDefaultPosixPromptLine,
|
||||
|
||||
@@ -25,7 +25,6 @@ const {
|
||||
readWindowsRegistryPath,
|
||||
createWindowsLivePathResolver,
|
||||
trackSessionIdlePrompt,
|
||||
computeOsc133ReadyState,
|
||||
} = require("./shellUtils.cjs");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
@@ -1379,98 +1378,3 @@ test("a failed machine hive does not promote user directories ahead of inherited
|
||||
platform: "win32", basePath: "C:\\Windows\\System32", env: {}, knownCliDirs: () => [],
|
||||
}), "C:\\Windows\\System32;C:\\user-tools");
|
||||
});
|
||||
|
||||
// ── OSC 133 shell-integration readiness ──
|
||||
|
||||
test("computeOsc133ReadyState derives readiness from the last marker in the raw tail", () => {
|
||||
assert.equal(computeOsc133ReadyState(""), null);
|
||||
assert.equal(computeOsc133ReadyState("plain output with no markers"), null);
|
||||
// BEL terminator, A last → ready.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;A\x07user@host ~ % "), true);
|
||||
// ST terminator on D; full cycle A→B→C→D ends not-at-prompt.
|
||||
assert.equal(
|
||||
computeOsc133ReadyState("\x1b]133;A\x07\x1b]133;B\x07ls\x07\x1b]133;C\x07files\x1b]133;D\x1b\\"),
|
||||
false,
|
||||
);
|
||||
// Parameterized D form (e.g. `133;D;0`) still matches the marker prefix.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;A\x07\x1b]133;D;0\x07"), false);
|
||||
// Output start (C) after the last prompt start means a command is running.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;A\x07\x1b]133;C\x07build output..."), false);
|
||||
// A fresh prompt start after a finished command is ready again.
|
||||
assert.equal(computeOsc133ReadyState("\x1b]133;D\x07\x1b]133;A\x07~ ❯ "), true);
|
||||
});
|
||||
|
||||
test("trackSessionIdlePrompt stores OSC 133 state alongside the whitelist prompt", () => {
|
||||
// No markers → null (non-integrated shell, whitelist fallback unchanged).
|
||||
const plain = {};
|
||||
trackSessionIdlePrompt(plain, "Last login...\r\nuser@host ~ % ");
|
||||
assert.equal(plain.osc133Ready, null);
|
||||
assert.equal(getFreshIdlePrompt(plain), "user@host ~ % ");
|
||||
});
|
||||
|
||||
test("OSC 133 keeps whitelisted prompts and readies custom prompts after B", () => {
|
||||
// Colored Powerlevel10k-style prompt: theme SGR codes + ❯, after an A marker.
|
||||
const p10k = {};
|
||||
trackSessionIdlePrompt(
|
||||
p10k,
|
||||
"\x1b]133;A\x07\x1b]0;title\x07\x1b[38;5;39m~/src\x1b[0m \x1b[38;5;213m❯\x1b[0m ",
|
||||
);
|
||||
assert.equal(p10k.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(p10k), "~/src ❯ ");
|
||||
assert.equal(getVisibleIdlePrompt(p10k), "~/src ❯ ");
|
||||
|
||||
// B marks the custom prompt boundary; A alone cannot prove empty input.
|
||||
const custom = {};
|
||||
trackSessionIdlePrompt(custom, "\x1b]133;A\x07myprompt λ ");
|
||||
assert.equal(custom.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(custom), "");
|
||||
assert.equal(getVisibleIdlePrompt(custom), "");
|
||||
trackSessionIdlePrompt(custom, "\x1b]133;B\x07");
|
||||
assert.equal(getVisibleIdlePrompt(custom), "myprompt λ ");
|
||||
});
|
||||
|
||||
test("OSC 133 C/D markers override whitelisted-looking prompt text (not ready)", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "\x1b]133;A\x07PS C:\\Users\\alice> ");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(session), "PS C:\\Users\\alice> ");
|
||||
|
||||
// Command in flight; a running program then echoes prompt-shaped text —
|
||||
// the exact case where the whitelist lies (#4048 false-ready).
|
||||
trackSessionIdlePrompt(session, "\x1b]133;B\x07dir\x07\x1b]133;C\x07running...\r\nPS C:\\Users\\alice> ");
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), "");
|
||||
|
||||
// D (finished) without a new A marker stays not-ready even with
|
||||
// prompt-shaped text after it.
|
||||
trackSessionIdlePrompt(session, "done\x1b]133;D;0\x1b\\\r\nuser@host ~ % ");
|
||||
assert.equal(session.osc133Ready, false);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), "");
|
||||
|
||||
// A new prompt start restores readiness.
|
||||
trackSessionIdlePrompt(session, "\x1b]133;A\x07user@host ~ % ");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(session), "user@host ~ % ");
|
||||
});
|
||||
|
||||
test("OSC 133 state survives markers split across PTY chunks via the rolling tail", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "welcome\r\n\x1b]133;");
|
||||
assert.equal(session.osc133Ready, null);
|
||||
trackSessionIdlePrompt(session, "A\x07user@host ~ % ");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getVisibleIdlePrompt(session), "user@host ~ % ");
|
||||
});
|
||||
|
||||
test("OSC 133 lifecycle state survives rolling-tail eviction without reviving old prompts", () => {
|
||||
const session = {};
|
||||
trackSessionIdlePrompt(session, "\x1b]133;A\x07");
|
||||
assert.equal(session.osc133Ready, true);
|
||||
// Retain lifecycle state, but fail closed when the prompt itself was evicted.
|
||||
trackSessionIdlePrompt(session, "x".repeat(4200));
|
||||
assert.equal(session.osc133Ready, true);
|
||||
assert.equal(getFreshIdlePrompt(session), "");
|
||||
assert.equal(getVisibleIdlePrompt(session), "");
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
classifyProcessError,
|
||||
createDiagnosticLogger,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
isNonFatalNetworkError,
|
||||
@@ -253,137 +251,3 @@ test("controller suppresses ssh-style errors with a level property", () => {
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
assert.equal(result.action, "suppress");
|
||||
});
|
||||
|
||||
test("diagnostic logger drops lines once the sink reports an error", () => {
|
||||
const sink = new EventEmitter();
|
||||
const written = [];
|
||||
const log = createDiagnosticLogger(
|
||||
(...args) => written.push(args),
|
||||
{ streams: [sink] },
|
||||
);
|
||||
|
||||
log("first");
|
||||
sink.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
log("second");
|
||||
|
||||
assert.deepEqual(written, [["first"]]);
|
||||
});
|
||||
|
||||
test("diagnostic logger forwards only the first sink error to onError", () => {
|
||||
const sink = new EventEmitter();
|
||||
const reports = [];
|
||||
createDiagnosticLogger(
|
||||
() => {},
|
||||
{ streams: [sink], onError: (err) => reports.push(err) },
|
||||
);
|
||||
|
||||
const first = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
sink.emit("error", first);
|
||||
sink.emit("error", Object.assign(new Error("write EPIPE again"), { code: "EPIPE" }));
|
||||
|
||||
assert.deepEqual(reports, [first]);
|
||||
});
|
||||
|
||||
test("diagnostic logger latches before forwarding the sink error", () => {
|
||||
const sink = new EventEmitter();
|
||||
let attempts = 0;
|
||||
let log = null;
|
||||
log = createDiagnosticLogger(
|
||||
() => {
|
||||
attempts += 1;
|
||||
},
|
||||
{ streams: [sink], onError: () => log("logged from the reporter") },
|
||||
);
|
||||
|
||||
sink.emit("error", Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
|
||||
assert.equal(attempts, 0, "the reporter must not write through the failed sink");
|
||||
});
|
||||
|
||||
test("diagnostic logger keeps a throwing onError from escaping", () => {
|
||||
const sink = new EventEmitter();
|
||||
const written = [];
|
||||
const log = createDiagnosticLogger(
|
||||
(...args) => written.push(args),
|
||||
{
|
||||
streams: [sink],
|
||||
onError() {
|
||||
throw new Error("reporter is dead");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() => sink.emit("error", new Error("write EPIPE")));
|
||||
log("after failure");
|
||||
assert.deepEqual(written, []);
|
||||
});
|
||||
|
||||
test("a closed stdout pipe is reported once so the failure reaches the crash log", async () => {
|
||||
const child = spawn(process.execPath, ["-e", `
|
||||
const { createDiagnosticLogger } = require(${JSON.stringify(require.resolve("./processErrorGuards.cjs"))});
|
||||
let reports = 0;
|
||||
const log = createDiagnosticLogger(
|
||||
(...args) => console.error(...args),
|
||||
{
|
||||
streams: [process.stdout, process.stderr],
|
||||
onError() { reports += 1; },
|
||||
},
|
||||
);
|
||||
setTimeout(() => {
|
||||
log("diagnostic after the pipe closed");
|
||||
log("second diagnostic after the pipe closed");
|
||||
setTimeout(() => process.exit(reports), 200);
|
||||
}, 200);
|
||||
setTimeout(() => process.exit(reports), 3000);
|
||||
`], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
child.stdout.destroy();
|
||||
child.stderr.destroy();
|
||||
const exitCode = await new Promise((resolve) => child.once("exit", resolve));
|
||||
|
||||
assert.equal(exitCode, 1, "exactly one report for the first sink failure");
|
||||
});
|
||||
|
||||
test("diagnostic logger latches closed when the sink throws synchronously", () => {
|
||||
let attempts = 0;
|
||||
const log = createDiagnosticLogger(() => {
|
||||
attempts += 1;
|
||||
throw Object.assign(new Error("write EIO"), { code: "EIO" });
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => log("boom"));
|
||||
assert.doesNotThrow(() => log("boom again"));
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("diagnostic logger dispose releases the sink listeners", () => {
|
||||
const sink = new EventEmitter();
|
||||
const log = createDiagnosticLogger(() => {}, { streams: [sink] });
|
||||
|
||||
assert.equal(sink.listenerCount("error"), 1);
|
||||
log.dispose();
|
||||
assert.equal(sink.listenerCount("error"), 0);
|
||||
});
|
||||
|
||||
test("a broken diagnostic sink still lets runtime errors be captured", () => {
|
||||
const captured = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
throw err;
|
||||
},
|
||||
logError: createDiagnosticLogger(() => {
|
||||
throw Object.assign(new Error("write EIO"), { code: "EIO" });
|
||||
}),
|
||||
logWarn: createDiagnosticLogger(() => {
|
||||
throw Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
}),
|
||||
});
|
||||
controller.beginMainWindowStartup();
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
|
||||
assert.doesNotThrow(() => controller.handleUncaughtException(new Error("runtime boom")));
|
||||
assert.deepEqual(captured, [["uncaughtException", "runtime boom"]]);
|
||||
});
|
||||
@@ -75,62 +75,6 @@ function classifyProcessError(err, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the console sink used for diagnostics so a broken sink cannot feed
|
||||
* itself.
|
||||
*
|
||||
* The only lines these guards cannot safely write are the ones about the sink
|
||||
* they write through: a torn-down stdio pipe reports EPIPE asynchronously and a
|
||||
* closed writer throws EIO synchronously, and both are delivered straight back
|
||||
* to the process-level handlers, which log again — an unbounded feedback loop
|
||||
* that pins a core, buffers every failed write in memory, and leaves the
|
||||
* process unable to answer anything else. Once the sink reports a failure its
|
||||
* diagnostics are dropped; because this listener also consumes the stream
|
||||
* `error` event before the process guards see it, the first failure is handed
|
||||
* to `options.onError` so the caller (`captureError` in the main process,
|
||||
* `report` in the terminal worker) can still persist it.
|
||||
*/
|
||||
function createDiagnosticLogger(log, options = {}) {
|
||||
const streams = (options.streams || []).filter(
|
||||
(stream) => typeof stream?.on === "function"
|
||||
&& typeof stream?.removeListener === "function",
|
||||
);
|
||||
const onError = typeof options.onError === "function" ? options.onError : null;
|
||||
let sinkFailed = false;
|
||||
let sinkErrorReported = false;
|
||||
const onSinkError = (err) => {
|
||||
// Latch before reporting: anything the reporter logs is already unwritable.
|
||||
sinkFailed = true;
|
||||
if (sinkErrorReported) return;
|
||||
sinkErrorReported = true;
|
||||
if (!onError) return;
|
||||
try {
|
||||
onError(err);
|
||||
} catch {
|
||||
// A failing reporter must not escape the stream's error listener.
|
||||
}
|
||||
};
|
||||
for (const stream of streams) {
|
||||
// Holding an `error` listener also keeps a failed write on the diagnostics
|
||||
// stream from being rethrown as an uncaught exception, which is how the
|
||||
// loop used to restart.
|
||||
stream.on("error", onSinkError);
|
||||
}
|
||||
|
||||
const guarded = (...args) => {
|
||||
if (sinkFailed) return;
|
||||
try {
|
||||
log(...args);
|
||||
} catch {
|
||||
sinkFailed = true;
|
||||
}
|
||||
};
|
||||
guarded.dispose = () => {
|
||||
for (const stream of streams) stream.removeListener("error", onSinkError);
|
||||
};
|
||||
return guarded;
|
||||
}
|
||||
|
||||
function createProcessErrorController(options = {}) {
|
||||
const captureError = typeof options.captureError === "function" ? options.captureError : () => {};
|
||||
const onFatalError = typeof options.onFatalError === "function"
|
||||
@@ -242,7 +186,6 @@ function installProcessErrorHandlers(processObject, controller) {
|
||||
|
||||
module.exports = {
|
||||
classifyProcessError,
|
||||
createDiagnosticLogger,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
isBenignStreamError,
|
||||
|
||||
@@ -3,7 +3,6 @@ const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const EventEmitter = require("node:events");
|
||||
|
||||
const { createBridgeRegistrar } = require("../main/registerBridges.cjs");
|
||||
const ptyProcessTree = require("./ptyProcessTree.cjs");
|
||||
@@ -40,7 +39,6 @@ function createMainHandler(t, { mode = "worker" } = {}) {
|
||||
ipcMain,
|
||||
safeStorage: { isEncryptionAvailable: () => false },
|
||||
dialog: {},
|
||||
nativeTheme: new EventEmitter(),
|
||||
...(mode === "unavailable" ? {} : {
|
||||
utilityProcess: { fork() { throw new Error("test must not start a real worker"); } },
|
||||
}),
|
||||
|
||||
@@ -44,7 +44,6 @@ function createBridgeRegistrarForTest({
|
||||
sshBridge = null,
|
||||
terminalBridge = null,
|
||||
electronModuleOverrides = {},
|
||||
windows = [],
|
||||
}) {
|
||||
const noopBridge = createNoopBridge();
|
||||
const tempDirBridge = {
|
||||
@@ -62,7 +61,6 @@ function createBridgeRegistrarForTest({
|
||||
isEncryptionAvailable: () => false,
|
||||
},
|
||||
dialog: {},
|
||||
nativeTheme: new EventEmitter(),
|
||||
...electronModuleOverrides,
|
||||
},
|
||||
app: {
|
||||
@@ -70,7 +68,7 @@ function createBridgeRegistrarForTest({
|
||||
getVersion: () => "0.0.0",
|
||||
getName: () => "Netcatty",
|
||||
},
|
||||
BrowserWindow: { getAllWindows: () => windows },
|
||||
BrowserWindow: { getAllWindows: () => [] },
|
||||
shell: { openExternal() {}, openPath() {} },
|
||||
clipboard: { readText: () => "", writeText() {} },
|
||||
path,
|
||||
@@ -114,36 +112,6 @@ function createBridgeRegistrarForTest({
|
||||
});
|
||||
}
|
||||
|
||||
test("registrar broadcasts system theme updates when plugin host initialization fails", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-theme-registrar-test-"));
|
||||
t.after(() => fs.promises.rm(tempDir, { recursive: true, force: true }));
|
||||
const nativeTheme = new EventEmitter();
|
||||
const messages = [];
|
||||
const registerBridges = createBridgeRegistrarForTest({
|
||||
ipcMain: createIpcMainStub(),
|
||||
tempDir,
|
||||
electronModuleOverrides: { nativeTheme },
|
||||
windows: [{
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => messages.push({ channel, payload }),
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
// This fixture intentionally lacks app.getAppPath, so the optional plugin
|
||||
// host fails to initialize. Native theme delivery must remain independent.
|
||||
registerBridges({});
|
||||
assert.equal(nativeTheme.listenerCount("updated"), 1);
|
||||
nativeTheme.shouldUseDarkColors = true;
|
||||
nativeTheme.emit("updated");
|
||||
assert.deepEqual(messages, [{
|
||||
channel: "netcatty:system-theme-changed",
|
||||
payload: { isDark: true },
|
||||
}]);
|
||||
});
|
||||
|
||||
test("the single temp-download entry routes direct downloads through the shared transfer bridge", async (t) => {
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-download-temp-test-"));
|
||||
t.after(async () => {
|
||||
|
||||
@@ -1985,9 +1985,7 @@ async function openSftpForSession(_event, payload) {
|
||||
throwIfAborted(payload?.abortSignal);
|
||||
let sourceSessionId = sessionId;
|
||||
let source;
|
||||
if (payload?.reuseExactSessionOnly === true) {
|
||||
source = { sessionId, ...ensureRemoteSftpSupport(sessionId) };
|
||||
} else if (payload?.expectedEndpoint) {
|
||||
if (payload?.expectedEndpoint) {
|
||||
const expectedEndpoint = buildConnectionReuseEndpoint(payload.expectedEndpoint);
|
||||
source = findRemoteSftpSourceByEndpoint(
|
||||
sessionId,
|
||||
@@ -2008,7 +2006,7 @@ async function openSftpForSession(_event, payload) {
|
||||
session.singleChannelSsh
|
||||
|| remoteSoftwareRequiresSingleChannel(session.remoteSshVersion || sshClient?._remoteVer)
|
||||
) {
|
||||
if (payload?.reuseExactSessionOnly !== true && typeof session._openDedicatedSftp === "function") {
|
||||
if (typeof session._openDedicatedSftp === "function") {
|
||||
const opened = await session._openDedicatedSftp(payload);
|
||||
const client = sftpClients?.get(opened.sftpId);
|
||||
if (client) client.__netcattySourceSessionId = sourceSessionId;
|
||||
|
||||
@@ -5,45 +5,6 @@ const assert = require("node:assert/strict");
|
||||
const bridge = require("./sftpBridge.cjs");
|
||||
const { buildConnectionReuseEndpoint, normalizeEndpoint } = require("./sshConnectionPool.cjs");
|
||||
|
||||
test('exact-session-only browse refuses the dedicated-login path on single-channel hosts', async () => {
|
||||
let dialed = false;
|
||||
bridge.init({ sessions: new Map([['terminal', {
|
||||
singleChannelSsh: true, conn: { sftp() { throw new Error('must not borrow'); } },
|
||||
async _openDedicatedSftp() { dialed = true; return { sftpId: 'wrong' }; },
|
||||
}]]), sftpClients: new Map(), electronModule: {} });
|
||||
await assert.rejects(bridge.openSftpForSession(null, { sessionId: 'terminal', reuseExactSessionOnly: true }),
|
||||
{ code: 'ERR_SFTP_SINGLE_CHANNEL_BASTION' });
|
||||
assert.equal(dialed, false);
|
||||
});
|
||||
|
||||
test('exact-session-only browse never borrows a different live session', async () => {
|
||||
let borrowed = false;
|
||||
bridge.init({ sessions: new Map([['other', { conn: { sftp() { borrowed = true; } } }]]),
|
||||
sftpClients: new Map(), electronModule: {} });
|
||||
await assert.rejects(bridge.openSftpForSession(null, { sessionId: 'missing', reuseExactSessionOnly: true }), /not found/);
|
||||
assert.equal(borrowed, false);
|
||||
});
|
||||
|
||||
test('exact-session-only browse uses authenticated password/proxy identity without reconstructing credentials', async () => {
|
||||
const options = { hostname: 'host', username: 'me', password: 'test-only-password',
|
||||
authMethod: 'password', proxy: { type: 'socks5', host: 'proxy', port: 1080 } };
|
||||
let opened = 0; let ended = false;
|
||||
const channel = { readdir() {}, stat() {}, mkdir() {}, unlink() {}, end() { ended = true; } };
|
||||
const conn = { sftp(callback) { opened++; callback(null, channel); } };
|
||||
const clients = new Map();
|
||||
bridge.init({ sessions: new Map([['terminal', {
|
||||
conn, _reuseEndpoint: normalizeEndpoint(buildConnectionReuseEndpoint(options)),
|
||||
}]]), sftpClients: clients, electronModule: {} });
|
||||
const result = await bridge.openSftpForSession(null, {
|
||||
sessionId: 'terminal', reuseExactSessionOnly: true, fileProtocol: 'sftp',
|
||||
expectedEndpoint: { hostname: 'host', username: 'me' },
|
||||
});
|
||||
assert.equal(result.sourceSessionId, 'terminal');
|
||||
assert.equal(opened, 1);
|
||||
await bridge.closeSftp(null, { sftpId: result.sftpId });
|
||||
assert.equal(ended, true);
|
||||
});
|
||||
|
||||
test("GUI vault reconnect finds the dedicated terminal dial without a source-session hint", async (t) => {
|
||||
t.mock.method(require("node:net").Socket.prototype, "connect", () => {
|
||||
throw new Error("Unexpected fresh vault dial instead of the terminal profile");
|
||||
|
||||
@@ -142,7 +142,6 @@ function createSessionOpsApi(ctx) {
|
||||
return {
|
||||
success: true,
|
||||
remoteSshVersion: session.remoteSshVersion || '',
|
||||
singleChannelSsh: session.singleChannelSsh === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,16 +12,6 @@ function quoteShellArg(value) {
|
||||
return "'" + String(value).replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
test('session metadata exposes runtime single-channel restrictions without opening exec', async () => {
|
||||
let execCount = 0;
|
||||
const api = makeApi({ singleChannelSsh: true, remoteSshVersion: 'OpenSSH_9.6',
|
||||
conn: { exec() { execCount++; } } });
|
||||
assert.deepEqual(await api.getSessionRemoteInfo(null, { sessionId: 'session-1' }), {
|
||||
success: true, singleChannelSsh: true, remoteSshVersion: 'OpenSSH_9.6',
|
||||
});
|
||||
assert.equal(execCount, 0);
|
||||
});
|
||||
|
||||
function makePwdStream(cwd, loginPid) {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// Keep system-theme delivery independent of optional plugin menu contributions.
|
||||
function registerSystemThemeListener({ nativeTheme, getWindows }) {
|
||||
const onUpdated = () => {
|
||||
const payload = { isDark: nativeTheme.shouldUseDarkColors === true };
|
||||
for (const win of getWindows()) {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
|
||||
win.webContents.send("netcatty:system-theme-changed", payload);
|
||||
}
|
||||
};
|
||||
nativeTheme.on("updated", onUpdated);
|
||||
return () => nativeTheme.removeListener("updated", onUpdated);
|
||||
}
|
||||
|
||||
module.exports = { registerSystemThemeListener };
|
||||
@@ -1,41 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const modulePath = path.join(__dirname, 'systemTheme.cjs');
|
||||
|
||||
test('native theme updates reach every live window without a plugin host', () => {
|
||||
assert.ok(fs.existsSync(modulePath), 'native system theme broadcaster must exist independently of plugins');
|
||||
const { registerSystemThemeListener } = require('./systemTheme.cjs');
|
||||
const nativeTheme = new EventEmitter();
|
||||
nativeTheme.shouldUseDarkColors = false;
|
||||
const calls = [];
|
||||
const live = (id) => ({
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => calls.push({ id, channel, payload }),
|
||||
},
|
||||
});
|
||||
const windows = [live('main'), live('settings'),
|
||||
{ isDestroyed: () => true },
|
||||
{ isDestroyed: () => false, webContents: { isDestroyed: () => true } },
|
||||
];
|
||||
const cleanup = registerSystemThemeListener({ nativeTheme, getWindows: () => windows });
|
||||
nativeTheme.shouldUseDarkColors = true;
|
||||
nativeTheme.emit('updated');
|
||||
nativeTheme.shouldUseDarkColors = false;
|
||||
nativeTheme.emit('updated');
|
||||
assert.deepEqual(calls, [
|
||||
{ id: 'main', channel: 'netcatty:system-theme-changed', payload: { isDark: true } },
|
||||
{ id: 'settings', channel: 'netcatty:system-theme-changed', payload: { isDark: true } },
|
||||
{ id: 'main', channel: 'netcatty:system-theme-changed', payload: { isDark: false } },
|
||||
{ id: 'settings', channel: 'netcatty:system-theme-changed', payload: { isDark: false } },
|
||||
]);
|
||||
cleanup();
|
||||
nativeTheme.emit('updated');
|
||||
assert.equal(calls.length, 4);
|
||||
assert.equal(nativeTheme.listenerCount('updated'), 0);
|
||||
});
|
||||
@@ -11,22 +11,7 @@
|
||||
* logs keep shell history instead of tilde rows and status lines.
|
||||
*/
|
||||
|
||||
const { fullwidthRanges, wideRanges } = require("./terminalWidthData.cjs");
|
||||
const ZERO_WIDTH_MARK_RE = /[\p{Mn}\p{Me}]/u;
|
||||
const ZERO_WIDTH_FORMAT_RE = /(?=\p{Cf})\p{Default_Ignorable_Code_Point}/u;
|
||||
const CSI_FINAL_RE = /[@-~]/;
|
||||
|
||||
function inWidthRanges(codePoint, ranges) {
|
||||
if (codePoint < ranges[0] || codePoint > ranges.at(-1)) return false;
|
||||
let low = 0, high = ranges.length / 2 - 1;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2), index = middle * 2;
|
||||
if (codePoint < ranges[index]) high = middle - 1;
|
||||
else if (codePoint > ranges[index + 1]) low = middle + 1;
|
||||
else return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// ANSI palette colors are emitted as CSS variables (with hex fallbacks) so the
|
||||
// wrapping HTML page can provide light and dark palettes. Indexes 0-7 are the
|
||||
// basic colors, 8-15 the bright colors.
|
||||
@@ -79,7 +64,9 @@ class TerminalTextRenderer {
|
||||
if (!input) return;
|
||||
this.timestamp = timestamp;
|
||||
|
||||
for (const ch of input) this.#consume(ch);
|
||||
for (const ch of input) {
|
||||
this.#consume(ch);
|
||||
}
|
||||
}
|
||||
|
||||
finish() {
|
||||
@@ -90,13 +77,14 @@ class TerminalTextRenderer {
|
||||
}
|
||||
|
||||
getCursorLine() {
|
||||
return (this.lines[this.row] || []).map(cellText).join("");
|
||||
const line = this.lines[this.row] || [];
|
||||
return line.map((cell) => cell?.ch || " ").join("");
|
||||
}
|
||||
|
||||
toString({ includePendingClearedScreen = false, formatLine = (line) => line } = {}) {
|
||||
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
|
||||
return lines
|
||||
.map((line) => formatLine(line.map(cellText).join("").replace(/[ \t]+$/g, ""), line.timestamp))
|
||||
.map((line) => formatLine(line.map((cell) => cell?.ch || " ").join("").replace(/[ \t]+$/g, ""), line.timestamp))
|
||||
.join("\n")
|
||||
.replace(/\n+$/g, "");
|
||||
}
|
||||
@@ -371,46 +359,13 @@ class TerminalTextRenderer {
|
||||
if (this.timestamp !== undefined) line.timestamp = this.timestamp;
|
||||
while (line.length < this.col) line.push(createCell(" ", createDefaultStyle()));
|
||||
for (const ch of text) {
|
||||
const codePoint = ch.codePointAt(0);
|
||||
// Readline counts code points, including spacing marks and emoji
|
||||
// modifiers, rather than collapsing an entire grapheme into one cell.
|
||||
const zeroWidth = ZERO_WIDTH_MARK_RE.test(ch)
|
||||
|| (codePoint !== 0x00ad && ZERO_WIDTH_FORMAT_RE.test(ch))
|
||||
|| (codePoint >= 0x1160 && codePoint <= 0x11ff)
|
||||
|| (codePoint >= 0xd7b0 && codePoint <= 0xd7ff);
|
||||
const width = zeroWidth ? 0
|
||||
: inWidthRanges(codePoint, fullwidthRanges) || inWidthRanges(codePoint, wideRanges) ? 2 : 1;
|
||||
if (width === 0) {
|
||||
let previous = this.col - 1;
|
||||
if (line[previous]?.continuation) previous -= 1;
|
||||
if (previous >= 0 && line[previous]) {
|
||||
line[previous] = { ...line[previous], ch: line[previous].ch + ch };
|
||||
} else {
|
||||
line[this.col] = createCell((line[this.col]?.width === 0 ? line[this.col].ch : "") + ch,
|
||||
this.style, { width: 0 });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const prefix = line[this.col]?.width === 0 ? line[this.col].ch : "";
|
||||
for (let offset = 0; offset < width; offset += 1) this.#clearWideCellAt(line, this.col + offset);
|
||||
line[this.col] = createCell(prefix + ch, this.style, { width });
|
||||
if (width === 2) line[this.col + 1] = createCell("", this.style, { continuation: true });
|
||||
this.col += width;
|
||||
line[this.col] = createCell(ch, this.style);
|
||||
this.col += 1;
|
||||
}
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.justStartedLogScreen = false;
|
||||
}
|
||||
|
||||
#clearWideCellAt(line, column) {
|
||||
const start = line[column]?.continuation ? column - 1 : column;
|
||||
if (line[start]?.width !== 2) return;
|
||||
// Overwriting either half removes the glyph. Preserve evidence that both
|
||||
// blank cells came from text, so a following readline erase can certify it.
|
||||
for (let i = start; i <= start + 1; i += 1) {
|
||||
line[i] = createCell(" ", createDefaultStyle());
|
||||
}
|
||||
}
|
||||
|
||||
#eraseLine(mode) {
|
||||
this.#ensureLine();
|
||||
const line = this.lines[this.row];
|
||||
@@ -422,7 +377,6 @@ class TerminalTextRenderer {
|
||||
if (changesContent && this.timestamp !== undefined) line.timestamp = this.timestamp;
|
||||
if (mode === 1) {
|
||||
for (let i = 0; i <= this.col && i < line.length; i += 1) {
|
||||
this.#clearWideCellAt(line, i);
|
||||
line[i] = createCell(" ", createDefaultStyle());
|
||||
}
|
||||
return;
|
||||
@@ -431,7 +385,6 @@ class TerminalTextRenderer {
|
||||
this.lines[this.row] = [];
|
||||
return;
|
||||
}
|
||||
if (line[this.col]?.continuation) this.#clearWideCellAt(line, this.col);
|
||||
line.length = Math.min(line.length, this.col);
|
||||
}
|
||||
|
||||
@@ -568,10 +521,6 @@ function terminalDataToPlainText(terminalData) {
|
||||
}
|
||||
|
||||
function terminalCursorLine(terminalData, options) {
|
||||
return terminalCursorState(terminalData, options).line;
|
||||
}
|
||||
|
||||
function terminalCursorState(terminalData, options) {
|
||||
// Prompt tracking keeps a rolling tail, so the alternate-screen entry may
|
||||
// already have scrolled off. Seed the mode that was active at the first
|
||||
// byte still in that tail; a fresh renderer would otherwise treat TUI paint
|
||||
@@ -583,11 +532,8 @@ function terminalCursorState(terminalData, options) {
|
||||
renderer.finish();
|
||||
// A full-screen program owns the display. The main buffer may still hold
|
||||
// the shell prompt from before the alternate screen was entered.
|
||||
if (renderer.alternateScreenActive) return { line: "", column: 0 };
|
||||
return {
|
||||
line: renderer.getCursorLine(),
|
||||
column: renderer.col,
|
||||
};
|
||||
if (renderer.alternateScreenActive) return "";
|
||||
return renderer.getCursorLine();
|
||||
}
|
||||
|
||||
// Alternate-screen transitions from TerminalTextRenderer, without a text
|
||||
@@ -674,7 +620,6 @@ module.exports = {
|
||||
terminalDataToHtmlContent,
|
||||
terminalDataToPlainText,
|
||||
terminalCursorLine,
|
||||
terminalCursorState,
|
||||
alternateScreenActiveAfter,
|
||||
};
|
||||
|
||||
@@ -689,21 +634,16 @@ function createDefaultStyle() {
|
||||
};
|
||||
}
|
||||
|
||||
function createCell(ch, style, options = {}) {
|
||||
function createCell(ch, style) {
|
||||
return {
|
||||
ch,
|
||||
style,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
function cellText(cell) {
|
||||
return cell?.continuation ? "" : cell?.ch || " ";
|
||||
}
|
||||
|
||||
function cloneLines(lines) {
|
||||
return lines.map((line) => {
|
||||
const clone = line.map((cell) => (cell ? { ...cell } : cell));
|
||||
const clone = line.map((cell) => (cell ? createCell(cell.ch, cell.style) : cell));
|
||||
if (line.timestamp !== undefined) clone.timestamp = line.timestamp;
|
||||
return clone;
|
||||
});
|
||||
@@ -733,7 +673,6 @@ function renderLineHtml(line) {
|
||||
const trimmedLength = getTrimmedLineLength(line);
|
||||
for (let i = 0; i < trimmedLength; i += 1) {
|
||||
const cell = line[i] || createCell(" ", createDefaultStyle());
|
||||
if (cell.continuation) continue;
|
||||
if (!runStyle || !stylesEqual(runStyle, cell.style)) {
|
||||
flush();
|
||||
runStyle = cell.style;
|
||||
@@ -748,7 +687,6 @@ function getTrimmedLineLength(line) {
|
||||
let length = line.length;
|
||||
while (length > 0) {
|
||||
const cell = line[length - 1];
|
||||
if (cell?.continuation) break;
|
||||
const ch = cell?.ch || " ";
|
||||
if (ch !== " " && ch !== "\t") break;
|
||||
if (styleToCss(cell?.style)) break;
|
||||
|
||||
@@ -3,8 +3,6 @@ const assert = require("node:assert/strict");
|
||||
const {
|
||||
createTerminalTextRenderer,
|
||||
terminalDataToPlainText,
|
||||
terminalCursorLine,
|
||||
terminalCursorState,
|
||||
} = require("./terminalLogSanitizer.cjs");
|
||||
const { terminalDataToHtml } = require("./sessionLogsBridge.cjs");
|
||||
|
||||
@@ -12,62 +10,6 @@ test("plain text rendering applies backspace edits", () => {
|
||||
assert.equal(terminalDataToPlainText("hellp\bo\n"), "hello");
|
||||
});
|
||||
|
||||
test("cursor state preserves ambiguous blank replacement output", () => {
|
||||
const prompt = "prompt> ";
|
||||
for (const width of [1, 2, 5, 11]) {
|
||||
const output = prompt + "x".repeat(width) + "\b".repeat(width)
|
||||
+ " ".repeat(width) + "\b".repeat(width);
|
||||
const expected = { line: prompt + " ".repeat(width), column: prompt.length };
|
||||
assert.deepEqual(terminalCursorState(output), expected);
|
||||
const renderer = createTerminalTextRenderer();
|
||||
for (const ch of output) renderer.feed(ch);
|
||||
assert.equal(renderer.getCursorLine(), expected.line);
|
||||
}
|
||||
assert.deepEqual(terminalCursorState(`${prompt}\x1b[?1049halt`), { line: "", column: 0 });
|
||||
});
|
||||
|
||||
test("cursor columns use display widths without inserting padding into log text", () => {
|
||||
const text = "\u754c\u{1f600}e\u0301";
|
||||
assert.deepEqual(terminalCursorState(text), { line: text, column: 5 });
|
||||
assert.equal(terminalDataToPlainText(text), text);
|
||||
const renderer = createTerminalTextRenderer();
|
||||
for (const ch of text) renderer.feed(ch);
|
||||
assert.equal(renderer.getCursorLine(), text);
|
||||
assert.equal(renderer.toHtmlContent().replace(/<[^>]*>/g, ""), text);
|
||||
});
|
||||
|
||||
test("wide-cell overwrites, erases and preserved screens render complete glyphs", () => {
|
||||
assert.equal(terminalDataToPlainText("\u754cA\rX"), "X A");
|
||||
assert.equal(terminalDataToPlainText("\u754cA\r\x1b[CX"), " XA");
|
||||
assert.equal(terminalDataToPlainText("A\u754cB\r\x1b[2CX"), "A XB");
|
||||
assert.equal(terminalDataToPlainText("\u754cA\r\x1b[C\x1b[K"), "");
|
||||
assert.equal(terminalDataToPlainText("\u754c\x1b[2J\x1b[H\u65b0"), "\u754c\n\u65b0");
|
||||
});
|
||||
|
||||
test("wide and combining erasures preserve displayed blanks", () => {
|
||||
const prompt = "\u754c> ";
|
||||
for (const text of ["\u754c", "\u{1f600}"]) {
|
||||
const erased = prompt + text + "\b\b \b\b";
|
||||
assert.deepEqual(terminalCursorState(erased), {
|
||||
line: prompt + " ", column: 4,
|
||||
});
|
||||
}
|
||||
assert.equal(terminalCursorState(prompt + "e\u0301\b \b").line, prompt + " ");
|
||||
const partial = terminalCursorState(prompt + "\u{1f44d}\u{1f3fd}\b\b \b\b");
|
||||
assert.equal(partial.column, 6);
|
||||
assert.equal(partial.line, prompt + "\u{1f44d} ");
|
||||
});
|
||||
|
||||
test("spacing marks and soft hyphens remain separate input cells", () => {
|
||||
for (const suffix of ["\u093e", "\u00ad"]) {
|
||||
assert.equal(terminalCursorState("x" + suffix).column, 2);
|
||||
const afterDelete = terminalCursorState("x" + suffix + "\b \b");
|
||||
assert.equal(afterDelete.column, 1);
|
||||
assert.equal(afterDelete.line, "x ");
|
||||
}
|
||||
assert.equal(terminalCursorState("\u1100\u1161\u11a8").column, 2);
|
||||
});
|
||||
|
||||
test("plain text rendering applies carriage-return overwrites", () => {
|
||||
assert.equal(terminalDataToPlainText("progress 10%\rprogress 100%\n"), "progress 100%");
|
||||
});
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// Generated by node scripts/generate-terminal-width-data.cjs
|
||||
// Unicode 17.0.0 W/F data from get-east-asian-width 1.5.0:
|
||||
// https://github.com/sindresorhus/get-east-asian-width/tree/v1.5.0
|
||||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
const fullwidthRanges = [
|
||||
12288, 12288, 65281, 65376, 65504, 65510,
|
||||
];
|
||||
|
||||
const wideRanges = [
|
||||
4352, 4447, 8986, 8987, 9001, 9002, 9193, 9196, 9200, 9200, 9203, 9203,
|
||||
9725, 9726, 9748, 9749, 9776, 9783, 9800, 9811, 9855, 9855, 9866, 9871,
|
||||
9875, 9875, 9889, 9889, 9898, 9899, 9917, 9918, 9924, 9925, 9934, 9934,
|
||||
9940, 9940, 9962, 9962, 9970, 9971, 9973, 9973, 9978, 9978, 9981, 9981,
|
||||
9989, 9989, 9994, 9995, 10024, 10024, 10060, 10060, 10062, 10062, 10067, 10069,
|
||||
10071, 10071, 10133, 10135, 10160, 10160, 10175, 10175, 11035, 11036, 11088, 11088,
|
||||
11093, 11093, 11904, 11929, 11931, 12019, 12032, 12245, 12272, 12287, 12289, 12350,
|
||||
12353, 12438, 12441, 12543, 12549, 12591, 12593, 12686, 12688, 12773, 12783, 12830,
|
||||
12832, 12871, 12880, 42124, 42128, 42182, 43360, 43388, 44032, 55203, 63744, 64255,
|
||||
65040, 65049, 65072, 65106, 65108, 65126, 65128, 65131, 94176, 94180, 94192, 94198,
|
||||
94208, 101589, 101631, 101662, 101760, 101874, 110576, 110579, 110581, 110587, 110589, 110590,
|
||||
110592, 110882, 110898, 110898, 110928, 110930, 110933, 110933, 110948, 110951, 110960, 111355,
|
||||
119552, 119638, 119648, 119670, 126980, 126980, 127183, 127183, 127374, 127374, 127377, 127386,
|
||||
127488, 127490, 127504, 127547, 127552, 127560, 127568, 127569, 127584, 127589, 127744, 127776,
|
||||
127789, 127797, 127799, 127868, 127870, 127891, 127904, 127946, 127951, 127955, 127968, 127984,
|
||||
127988, 127988, 127992, 128062, 128064, 128064, 128066, 128252, 128255, 128317, 128331, 128334,
|
||||
128336, 128359, 128378, 128378, 128405, 128406, 128420, 128420, 128507, 128591, 128640, 128709,
|
||||
128716, 128716, 128720, 128722, 128725, 128728, 128732, 128735, 128747, 128748, 128756, 128764,
|
||||
128992, 129003, 129008, 129008, 129292, 129338, 129340, 129349, 129351, 129535, 129648, 129660,
|
||||
129664, 129674, 129678, 129734, 129736, 129736, 129741, 129756, 129759, 129770, 129775, 129784,
|
||||
131072, 196605, 196608, 262141,
|
||||
];
|
||||
|
||||
module.exports = { fullwidthRanges, wideRanges };
|
||||
+4
-25
@@ -25,30 +25,9 @@ perfTrace.mark("main:entry");
|
||||
// Load crash log bridge early so process-level error handlers can use it
|
||||
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
|
||||
const {
|
||||
createDiagnosticLogger,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
} = require("./bridges/processErrorGuards.cjs");
|
||||
// Diagnostics must stay writable-safe: logging a broken stdout/stderr delivers
|
||||
// a new process-level error straight back into these handlers (see
|
||||
// createDiagnosticLogger). Errors themselves are still captured above.
|
||||
const logProcessDiagnostic = createDiagnosticLogger(
|
||||
(...args) => console.error(...args),
|
||||
{
|
||||
streams: [process.stdout, process.stderr],
|
||||
// The logger consumes the stream `error` event, so forward the first sink
|
||||
// failure explicitly or it would never reach the crash log. Only this
|
||||
// logger reports: both share the same streams, so reporting in both would
|
||||
// record every failure twice.
|
||||
onError(err) {
|
||||
try { crashLogBridge.captureError('stdio', err); } catch {}
|
||||
},
|
||||
},
|
||||
);
|
||||
const logProcessWarning = createDiagnosticLogger(
|
||||
(...args) => console.warn(...args),
|
||||
{ streams: [process.stdout, process.stderr] },
|
||||
);
|
||||
const processErrorController = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
try { crashLogBridge.captureError(source, err); } catch {}
|
||||
@@ -56,17 +35,17 @@ const processErrorController = createProcessErrorController({
|
||||
onFatalError(err, context) {
|
||||
uninstallProcessErrorHandlers();
|
||||
if (context?.origin === 'unhandledRejection') {
|
||||
logProcessDiagnostic('Unhandled rejection:', context.reason);
|
||||
console.error('Unhandled rejection:', context.reason);
|
||||
} else {
|
||||
logProcessDiagnostic('Uncaught exception:', err);
|
||||
console.error('Uncaught exception:', err);
|
||||
}
|
||||
throw err;
|
||||
},
|
||||
logError(...args) {
|
||||
logProcessDiagnostic(...args);
|
||||
console.error(...args);
|
||||
},
|
||||
logWarn(...args) {
|
||||
logProcessWarning(...args);
|
||||
console.warn(...args);
|
||||
},
|
||||
});
|
||||
let uninstallProcessErrorHandlers = installProcessErrorHandlers(process, processErrorController);
|
||||
|
||||
@@ -335,11 +335,6 @@ function createBridgeRegistrar(context) {
|
||||
windowManager.setPluginApplicationMenuProvider?.(applicationMenuProvider);
|
||||
});
|
||||
}
|
||||
const { registerSystemThemeListener } = require("../bridges/systemTheme.cjs");
|
||||
registerSystemThemeListener({
|
||||
nativeTheme: electronModule.nativeTheme,
|
||||
getWindows: () => BrowserWindow.getAllWindows(),
|
||||
});
|
||||
|
||||
const getCloudSyncPasswordPath = () => {
|
||||
try {
|
||||
|
||||
@@ -170,13 +170,6 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.on("netcatty:plugins:themes-changed", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:themes-changed", listener);
|
||||
},
|
||||
onSystemThemeChanged: (callback) => {
|
||||
const listener = (_event, payload) => {
|
||||
if (typeof payload?.isDark === "boolean") callback({ isDark: payload.isDark });
|
||||
};
|
||||
ipcRenderer.on("netcatty:system-theme-changed", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:system-theme-changed", listener);
|
||||
},
|
||||
getPluginContributions: (options) => ipcRenderer.invoke("netcatty:plugins:contributions", options ?? {}),
|
||||
getPluginBrandIcon: (pluginId) => ipcRenderer.invoke("netcatty:plugins:brand-icon", { pluginId }),
|
||||
getPluginContributionIcon: (pluginId, icon) => ipcRenderer.invoke("netcatty:plugins:contribution-icon", { pluginId, icon }),
|
||||
@@ -945,10 +938,10 @@ function createPreloadApi(ctx) {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:openForSession", {
|
||||
...(options || {}),
|
||||
sessionId,
|
||||
expectedEndpoint: options?.reuseExactSessionOnly === true ? undefined : options,
|
||||
expectedEndpoint: options,
|
||||
});
|
||||
if (
|
||||
(options?.requireExactSourceSession === true || options?.reuseExactSessionOnly === true)
|
||||
options?.requireExactSourceSession === true
|
||||
&& result.sourceSessionId !== sessionId
|
||||
) {
|
||||
if (result.sftpId) {
|
||||
|
||||
@@ -220,19 +220,6 @@ test("openSftpForSession keeps the SSH source session id when options include an
|
||||
assert.equal(invoked[0].payload.expectedEndpoint.sessionId, "sftp-left-browse-session");
|
||||
});
|
||||
|
||||
test('exact-session-only browse does not fabricate a credential profile', async () => {
|
||||
const calls = [];
|
||||
const api = createPreloadApi({ webUtils: {}, ipcRenderer: {
|
||||
on() {}, removeListener() {},
|
||||
async invoke(channel, payload) { calls.push({ channel, payload }); return { sftpId: 'channel', sourceSessionId: 'terminal' }; },
|
||||
} });
|
||||
assert.equal(await api.openSftpForSession('terminal', {
|
||||
hostname: 'host', username: 'me', reuseExactSessionOnly: true, fileProtocol: 'sftp',
|
||||
}), 'channel');
|
||||
assert.equal(calls[0].payload.expectedEndpoint, undefined);
|
||||
assert.equal(calls[0].payload.reuseExactSessionOnly, true);
|
||||
});
|
||||
|
||||
test("strict openSftpForSession closes and rejects a mismatched source result", async () => {
|
||||
const invoked = [];
|
||||
const api = createPreloadApi({
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createPreloadApi } = require('./api.cjs');
|
||||
|
||||
const channel = 'netcatty:system-theme-changed';
|
||||
|
||||
test('system theme subscription forwards light/dark without exposing the IPC event and unsubscribes', () => {
|
||||
const ipcRenderer = new EventEmitter();
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
const calls = [];
|
||||
const unsubscribe = api.onSystemThemeChanged((...args) => calls.push(args));
|
||||
ipcRenderer.emit(channel, { sender: 'private' }, { isDark: true });
|
||||
ipcRenderer.emit(channel, { sender: 'private' }, { isDark: false });
|
||||
assert.deepEqual(calls, [[{ isDark: true }], [{ isDark: false }]]);
|
||||
unsubscribe();
|
||||
ipcRenderer.emit(channel, {}, { isDark: true });
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(ipcRenderer.listenerCount(channel), 0);
|
||||
});
|
||||
|
||||
test('system theme subscription ignores malformed payloads', () => {
|
||||
const ipcRenderer = new EventEmitter();
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
const calls = [];
|
||||
const unsubscribe = api.onSystemThemeChanged((payload) => calls.push(payload));
|
||||
for (const payload of [undefined, null, {}, { isDark: 'false' }, { isDark: 0 }]) {
|
||||
ipcRenderer.emit(channel, {}, payload);
|
||||
}
|
||||
unsubscribe();
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
const {
|
||||
classifyProcessError,
|
||||
createDiagnosticLogger,
|
||||
} = require("../bridges/processErrorGuards.cjs");
|
||||
|
||||
/**
|
||||
@@ -26,9 +25,8 @@ const {
|
||||
* Benign stream teardown (EPIPE / ERR_STREAM_DESTROYED) is classified as
|
||||
* `ignore` and must NOT call `logError`/`console.error`: writing diagnostics
|
||||
* through an already-broken console pipe re-enters this handler and busy-
|
||||
* loops (see #4136). A shared diagnostic logger also latches the default
|
||||
* console.error path closed after stdout/stderr emit `error`, forwarding the
|
||||
* first failure to `report` so it still reaches the crash log.
|
||||
* loops (see #4136). A `consoleBroken` latch also no-ops the default
|
||||
* console.error path after stdout/stderr emit `error`.
|
||||
*/
|
||||
function installTerminalWorkerErrorGuards(options = {}) {
|
||||
const processObject = options.processObject || process;
|
||||
@@ -36,29 +34,27 @@ function installTerminalWorkerErrorGuards(options = {}) {
|
||||
throw new Error("A process-like EventEmitter is required");
|
||||
}
|
||||
const report = typeof options.report === "function" ? options.report : () => {};
|
||||
// Holding an `error` listener keeps a failed write on stdout/stderr from
|
||||
// being rethrown as an uncaught exception, which is how the loop used to
|
||||
// restart. The first failure is forwarded to `report` because this listener
|
||||
// consumes the event before the process guards see it.
|
||||
const consoleLogger = createDiagnosticLogger(
|
||||
(...args) => console.error(...args),
|
||||
{
|
||||
streams: [processObject.stdout, processObject.stderr],
|
||||
onError(err) {
|
||||
try {
|
||||
report("stdio", err, {
|
||||
action: "ignore",
|
||||
reason: "diagnostics sink failure",
|
||||
});
|
||||
} catch {
|
||||
// Error reporting must never be able to escalate into a worker crash.
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
let consoleBroken = false;
|
||||
const latchConsoleBroken = () => {
|
||||
consoleBroken = true;
|
||||
};
|
||||
for (const stream of [processObject.stdout, processObject.stderr]) {
|
||||
if (stream && typeof stream.on === "function") {
|
||||
stream.on("error", latchConsoleBroken);
|
||||
}
|
||||
}
|
||||
const logError = typeof options.logError === "function"
|
||||
? options.logError
|
||||
: consoleLogger;
|
||||
: (...args) => {
|
||||
if (consoleBroken) return;
|
||||
try {
|
||||
console.error(...args);
|
||||
} catch {
|
||||
// Sync write failures must not escalate; async EPIPE arrives via
|
||||
// stream `error` and is covered by the consoleBroken latch above.
|
||||
consoleBroken = true;
|
||||
}
|
||||
};
|
||||
// Default to runtime semantics only when a caller provides no startup
|
||||
// signal; process.cjs always passes one.
|
||||
const isRuntimeStarted = typeof options.isRuntimeStarted === "function"
|
||||
@@ -127,7 +123,11 @@ function installTerminalWorkerErrorGuards(options = {}) {
|
||||
return () => {
|
||||
processObject.removeListener("uncaughtException", handleUncaughtException);
|
||||
processObject.removeListener("unhandledRejection", handleUnhandledRejection);
|
||||
consoleLogger.dispose();
|
||||
for (const stream of [processObject.stdout, processObject.stderr]) {
|
||||
if (stream && typeof stream.removeListener === "function") {
|
||||
stream.removeListener("error", latchConsoleBroken);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -167,40 +167,6 @@ test("consoleBroken latch also arms from stdout error", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("worker guards report the first sink error exactly once", () => {
|
||||
const fakeProcess = createFakeProcess();
|
||||
const reports = [];
|
||||
installTerminalWorkerErrorGuards({
|
||||
processObject: fakeProcess,
|
||||
report: (origin, err, decision) => reports.push({ origin, err, decision }),
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
const first = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
fakeProcess.stderr.emit("error", first);
|
||||
fakeProcess.stdout.emit("error", Object.assign(new Error("write EPIPE again"), { code: "EPIPE" }));
|
||||
|
||||
assert.equal(reports.length, 1);
|
||||
assert.equal(reports[0].origin, "stdio");
|
||||
assert.equal(reports[0].err, first);
|
||||
});
|
||||
|
||||
test("worker guards absorb report failures caused by a sink error", () => {
|
||||
const fakeProcess = createFakeProcess();
|
||||
installTerminalWorkerErrorGuards({
|
||||
processObject: fakeProcess,
|
||||
report: () => {
|
||||
throw new Error("report route is dead");
|
||||
},
|
||||
logError: () => {},
|
||||
});
|
||||
|
||||
assert.doesNotThrow(() => fakeProcess.stderr.emit(
|
||||
"error",
|
||||
Object.assign(new Error("write EPIPE"), { code: "EPIPE" }),
|
||||
));
|
||||
});
|
||||
|
||||
test("worker guards suppress unhandled rejections with non-Error reasons", () => {
|
||||
const fakeProcess = createFakeProcess();
|
||||
const reports = [];
|
||||
|
||||
Vendored
-2
@@ -182,8 +182,6 @@ declare global {
|
||||
sourceSessionId?: string;
|
||||
/** Require openSftpForSession to use exactly the requested live session. */
|
||||
requireExactSourceSession?: boolean;
|
||||
/** Use the named live terminal only; never open a dedicated/fresh login. */
|
||||
reuseExactSessionOnly?: boolean;
|
||||
// Skip POSIX process discovery when copying a network-device session.
|
||||
skipShellPidDiscovery?: boolean;
|
||||
/**
|
||||
|
||||
@@ -7,11 +7,8 @@ import {
|
||||
buildCattyReasoningProviderOptions,
|
||||
cattyReasoningLevelsForSelection,
|
||||
estimateReasoningOutputReserve,
|
||||
isProviderReasoningEffort,
|
||||
openaiModelLikelySupportsReasoning,
|
||||
openaiModelSupportsExtraHighReasoning,
|
||||
openaiModelSupportsNoneReasoning,
|
||||
PROVIDER_REASONING_EFFORT_OPTIONS,
|
||||
resolveEffectiveCattyReasoningEffort,
|
||||
resolveVisibleCattyThinkingLevel,
|
||||
} from './cattyReasoning';
|
||||
@@ -98,9 +95,9 @@ test('cattyReasoningLevelsForSelection hides the chip unless the model can take
|
||||
assert.deepEqual(cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-4o'), []);
|
||||
assert.ok(cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5.5').includes('high'));
|
||||
assert.ok(cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5.5').includes('off'));
|
||||
assert.deepEqual(
|
||||
assert.equal(
|
||||
cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5.5'),
|
||||
['off', 'low', 'medium', 'high', 'xhigh'],
|
||||
cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5.6'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5'),
|
||||
@@ -127,10 +124,9 @@ test('cattyReasoningLevelsForSelection hides the chip unless the model can take
|
||||
);
|
||||
assert.ok(cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-opus-4-6').includes('high'));
|
||||
assert.ok(cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-sonnet-5').includes('high'));
|
||||
// Fable 5 supports all five effort levels but is always-on, so no `off`.
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-fable-5'),
|
||||
['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
['low', 'medium', 'high'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-3-haiku-20240307'),
|
||||
@@ -364,228 +360,10 @@ test('applyResponsesApiStatelessStoreOption always requests encrypted reasoning
|
||||
|
||||
test('explicit provider effort exposes controls for a custom OpenAI model', () => {
|
||||
const provider = { providerId: 'custom' as const, style: 'openai' as const, advancedParams: { reasoningEffort: 'low' } };
|
||||
// An explicit default is the user's assertion that this unclassifiable
|
||||
// alias takes reasoning_effort, so the full ladder is offered.
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection(provider, 'relay-model'),
|
||||
['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
);
|
||||
assert.deepEqual(cattyReasoningLevelsForSelection(provider, 'relay-model'), ['low', 'medium', 'high']);
|
||||
assert.deepEqual(cattyReasoningLevelsForSelection({ ...provider, advancedParams: {} }, 'relay-model'), []);
|
||||
});
|
||||
|
||||
test('the provider default select accepts every documented effort level', () => {
|
||||
assert.deepEqual(PROVIDER_REASONING_EFFORT_OPTIONS, ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
for (const level of ['low', 'medium', 'high', 'xhigh', 'max']) {
|
||||
assert.equal(isProviderReasoningEffort(level), true, level);
|
||||
}
|
||||
// Persisted values that predate the graded scale must still be honored.
|
||||
for (const level of ['default', '', 'MAX', 'ultra', null, undefined]) {
|
||||
assert.equal(isProviderReasoningEffort(level), false, String(level));
|
||||
}
|
||||
});
|
||||
|
||||
test('DeepSeek V4 grades reasoning effort up to max', () => {
|
||||
const provider = { providerId: 'deepseek' as const };
|
||||
for (const model of ['deepseek-v4.1-flash', 'deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4.1-pro']) {
|
||||
assert.equal(openaiModelLikelySupportsReasoning(model), true, model);
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection(provider, model),
|
||||
['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
model,
|
||||
);
|
||||
for (const level of ['high', 'xhigh', 'max']) {
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions(provider, level, model),
|
||||
{ openai: { reasoningEffort: level } },
|
||||
`${model} ${level}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('DeepSeek keeps sending max from a provider default on a custom alias', () => {
|
||||
const provider = {
|
||||
providerId: 'custom' as const,
|
||||
style: 'openai' as const,
|
||||
advancedParams: { reasoningEffort: 'max' },
|
||||
};
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions(provider, resolveEffectiveCattyReasoningEffort(undefined, 'max'), 'deepseek-v4.1-flash'),
|
||||
{ openai: { reasoningEffort: 'max' } },
|
||||
);
|
||||
});
|
||||
|
||||
test('custom relay aliases keep the extended tier the provider default asserts', () => {
|
||||
// `relay-model` matches no capability pattern, so the explicit provider
|
||||
// default is the only signal that it accepts reasoning_effort. Advertising
|
||||
// just low/medium/high would downgrade a configured xhigh/max to high.
|
||||
for (const level of ['low', 'medium', 'high', 'xhigh', 'max'] as const) {
|
||||
const provider = {
|
||||
providerId: 'custom' as const,
|
||||
style: 'openai' as const,
|
||||
advancedParams: { reasoningEffort: level },
|
||||
};
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection(provider, 'relay-model'),
|
||||
['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
level,
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions(
|
||||
provider,
|
||||
resolveEffectiveCattyReasoningEffort(undefined, level),
|
||||
'relay-model',
|
||||
),
|
||||
{ openai: { reasoningEffort: level } },
|
||||
level,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('OpenAI models that stop at high clamp xhigh and max instead of dropping them', () => {
|
||||
for (const model of ['gpt-5', 'gpt-5.1', 'o3', 'o4-mini']) {
|
||||
for (const level of ['xhigh', 'max']) {
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions({ providerId: 'openai' }, level, model),
|
||||
{ openai: { reasoningEffort: 'high' } },
|
||||
`${model} ${level}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection({ providerId: 'openai' }, 'gpt-5.1'),
|
||||
['off', 'low', 'medium', 'high'],
|
||||
);
|
||||
});
|
||||
|
||||
test('GPT-5.5 and Codex Max advertise xhigh while older and chat models keep their limits', () => {
|
||||
assert.equal(openaiModelSupportsExtraHighReasoning('gpt-5.1-codex-max'), true);
|
||||
for (const model of ['gpt-5.5', 'gpt-5.5-2026-04-23', 'openai/gpt-5.5', ' GPT-5.5 ']) {
|
||||
assert.equal(openaiModelSupportsExtraHighReasoning(model), true, model);
|
||||
const levels = cattyReasoningLevelsForSelection({ providerId: 'openai' }, model);
|
||||
assert.deepEqual(levels, ['off', 'low', 'medium', 'high', 'xhigh']);
|
||||
for (const effort of ['xhigh', 'max']) {
|
||||
assert.equal(resolveVisibleCattyThinkingLevel(levels, effort), 'xhigh');
|
||||
assert.deepEqual(buildCattyReasoningProviderOptions({ providerId: 'openai' }, effort, model),
|
||||
{ openai: { reasoningEffort: 'xhigh' } }, `${model} ${effort}`);
|
||||
}
|
||||
}
|
||||
for (const model of ['gpt-5', 'gpt-5.1', 'gpt-5.5-chat-latest', 'gpt-5.5-mini', 'gpt-5.50', 'o3', 'o4-mini']) {
|
||||
assert.equal(openaiModelSupportsExtraHighReasoning(model), false, model);
|
||||
}
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions({ providerId: 'openai' }, 'xhigh', 'gpt-5.1-codex-max'),
|
||||
{ openai: { reasoningEffort: 'xhigh' } },
|
||||
);
|
||||
});
|
||||
|
||||
test('GPT-5.5 xhigh survives SDK serialization for Chat and Responses, including max clamping', async () => {
|
||||
const { createOpenAI } = await import('@ai-sdk/openai');
|
||||
for (const api of ['chat', 'responses'] as const) {
|
||||
for (const effort of ['xhigh', 'max']) {
|
||||
let body: Record<string, unknown> | undefined;
|
||||
const openai = createOpenAI({ apiKey: 'test', fetch: async (_url, init) => {
|
||||
body = JSON.parse(String(init?.body));
|
||||
throw new Error('captured request');
|
||||
} });
|
||||
const provider = { providerId: 'openai' as const, openaiApi: api, advancedParams: { reasoningEffort: effort } };
|
||||
const levels = cattyReasoningLevelsForSelection(provider, 'gpt-5.5');
|
||||
const chip = resolveVisibleCattyThinkingLevel(levels, effort);
|
||||
const options = buildCattyReasoningProviderOptions(provider, chip, 'gpt-5.5');
|
||||
await assert.rejects(async () => await openai[api]('gpt-5.5').doGenerate({
|
||||
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
|
||||
providerOptions: options as SharedV4ProviderOptions,
|
||||
}), /captured request/);
|
||||
assert.ok(body);
|
||||
assert.equal(api === 'chat' ? body.reasoning_effort : (body.reasoning as { effort?: string } | undefined)?.effort, 'xhigh');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('DeepSeek V4 and custom aliases serialize max through the installed Chat and Responses SDK', async () => {
|
||||
const { createOpenAI } = await import('@ai-sdk/openai');
|
||||
for (const api of ['chat', 'responses'] as const) {
|
||||
for (const [providerId, model] of [['deepseek', 'deepseek-v4-flash'], ['custom', 'relay-model']] as const) {
|
||||
for (const chip of ['max', undefined]) {
|
||||
let body: Record<string, unknown> | undefined;
|
||||
let fetchCalls = 0;
|
||||
const openai = createOpenAI({ apiKey: 'test', fetch: async (_url, init) => {
|
||||
fetchCalls += 1;
|
||||
body = JSON.parse(String(init?.body));
|
||||
throw new Error('captured max request');
|
||||
} });
|
||||
const provider = { providerId, openaiApi: api, advancedParams: { reasoningEffort: 'max' } };
|
||||
const effort = resolveEffectiveCattyReasoningEffort(chip, provider.advancedParams.reasoningEffort);
|
||||
const options = buildCattyReasoningProviderOptions(provider, effort, model);
|
||||
await assert.rejects(async () => await openai[api](model).doGenerate({
|
||||
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }],
|
||||
providerOptions: options as SharedV4ProviderOptions,
|
||||
}), /captured max request/);
|
||||
assert.equal(fetchCalls, 1, `${api} ${model} ${chip ?? 'provider default'}`);
|
||||
assert.ok(body);
|
||||
assert.equal(api === 'chat' ? body.reasoning_effort : (body.reasoning as { effort?: string } | undefined)?.effort, 'max');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Anthropic adaptive thinking exposes max, and xhigh from 4.7 onward', () => {
|
||||
// 4.6 documents low|medium|high|max.
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-opus-4-6'),
|
||||
['off', 'low', 'medium', 'high', 'max'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'max', 'claude-opus-4-6'),
|
||||
{ anthropic: { thinking: { type: 'adaptive' }, effort: 'max' } },
|
||||
);
|
||||
// 4.6 has no `xhigh`, so it lands on its ceiling rather than being dropped.
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'xhigh', 'claude-opus-4-6'),
|
||||
{ anthropic: { thinking: { type: 'adaptive' }, effort: 'max' } },
|
||||
);
|
||||
// 4.7 and 5.x accept xhigh.
|
||||
for (const model of ['claude-opus-4-7', 'claude-sonnet-5']) {
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, model),
|
||||
['off', 'low', 'medium', 'high', 'xhigh', 'max'],
|
||||
model,
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'xhigh', model),
|
||||
{ anthropic: { thinking: { type: 'adaptive' }, effort: 'xhigh' } },
|
||||
model,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('Anthropic always-on families never advertise off alongside xhigh and max', () => {
|
||||
assert.deepEqual(
|
||||
cattyReasoningLevelsForSelection({ providerId: 'anthropic' }, 'claude-fable-5'),
|
||||
['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
);
|
||||
// Legacy budgeted thinking has no tier above high.
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions({ providerId: 'anthropic' }, 'max', 'claude-sonnet-4-5'),
|
||||
{ anthropic: { thinking: { type: 'enabled', budgetTokens: 20_000 } } },
|
||||
);
|
||||
});
|
||||
|
||||
test('Gemini clamps xhigh and max onto its highest thinking level', () => {
|
||||
for (const level of ['xhigh', 'max'] as const) {
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions({ providerId: 'google' }, level, 'gemini-3-flash'),
|
||||
{ google: { thinkingConfig: { thinkingLevel: 'high', includeThoughts: true } } },
|
||||
`gemini-3-flash ${level}`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildCattyReasoningProviderOptions({ providerId: 'google' }, level, 'gemini-2.5-pro'),
|
||||
{ google: { thinkingConfig: { thinkingBudget: 16_384, includeThoughts: true } } },
|
||||
`gemini-2.5-pro ${level}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('custom model requests send explicit effort for Chat and Responses and omit it by default', async () => {
|
||||
const { createOpenAI } = await import('@ai-sdk/openai');
|
||||
for (const api of ['chat', 'responses'] as const) {
|
||||
|
||||
@@ -14,38 +14,11 @@ const GEMINI_25_THINKING_BUDGET: Record<'low' | 'medium' | 'high', number> = {
|
||||
high: 16_384,
|
||||
};
|
||||
|
||||
const REASONING_RANK = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
|
||||
const REASONING_RANK = ['off', 'minimal', 'low', 'medium', 'high'] as const;
|
||||
const LEVELS_LOW_MEDIUM_HIGH = ['low', 'medium', 'high'] as const;
|
||||
const LEVELS_LOW_HIGH = ['low', 'high'] as const;
|
||||
const LEVELS_MINIMAL_LOW_MEDIUM_HIGH = ['minimal', 'low', 'medium', 'high'] as const;
|
||||
const LEVELS_MINIMAL_HIGH = ['minimal', 'high'] as const;
|
||||
const LEVELS_LOW_MEDIUM_HIGH_MAX = ['low', 'medium', 'high', 'max'] as const;
|
||||
const LEVELS_LOW_MEDIUM_HIGH_XHIGH = ['low', 'medium', 'high', 'xhigh'] as const;
|
||||
const LEVELS_LOW_MEDIUM_HIGH_XHIGH_MAX = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
|
||||
const LEVELS_OFF_LOW_MEDIUM_HIGH_MAX = ['off', 'low', 'medium', 'high', 'max'] as const;
|
||||
const LEVELS_OFF_LOW_MEDIUM_HIGH_XHIGH_MAX = ['off', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
|
||||
const LEVELS_OFF_LOW_MEDIUM_HIGH_XHIGH = ['off', 'low', 'medium', 'high', 'xhigh'] as const;
|
||||
|
||||
/**
|
||||
* Levels the provider-level default may take. Shared with the settings form so
|
||||
* the select and the save guard never drift apart.
|
||||
*
|
||||
* `xhigh` and `max` are real wire values, not UI sugar: DeepSeek V4 grades
|
||||
* `reasoning_effort` continuously up to `max`, Anthropic maps
|
||||
* `output_config.effort` (4.6 reaches `max`, 4.7+/5+ also `xhigh`), and OpenAI
|
||||
* documents `xhigh` for the Codex Max line. A model whose vocabulary stops
|
||||
* earlier clamps these down to its own ceiling instead of emitting a value the
|
||||
* provider would reject.
|
||||
*/
|
||||
export const PROVIDER_REASONING_EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'] as const;
|
||||
|
||||
export function isProviderReasoningEffort(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string'
|
||||
&& (PROVIDER_REASONING_EFFORT_OPTIONS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Highest level every reasoning-capable vendor understands. */
|
||||
const HIGHEST_UNIVERSAL_EFFORT = 'high';
|
||||
|
||||
export type CattyReasoningProviderOptions = Record<string, Record<string, unknown>>;
|
||||
|
||||
@@ -148,7 +121,7 @@ export function buildCattyReasoningProviderOptions(
|
||||
|
||||
if (style === 'openai') {
|
||||
if (modelId && openaiModelKnownUnsupportedReasoning(modelId)) return undefined;
|
||||
const explicitDefault = isProviderReasoningEffort(provider.advancedParams?.reasoningEffort);
|
||||
const explicitDefault = ['low', 'medium', 'high'].includes(provider.advancedParams?.reasoningEffort ?? '');
|
||||
const unrecognizedModel = !!modelId && !openaiModelLikelySupportsReasoning(modelId);
|
||||
if (unrecognizedModel && !(provider.providerId === 'custom' && explicitDefault)) return undefined;
|
||||
if (resolved === 'off') {
|
||||
@@ -157,12 +130,7 @@ export function buildCattyReasoningProviderOptions(
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
openai: {
|
||||
reasoningEffort: resolved,
|
||||
...(explicitDefault && resolveOpenAIApi(provider) === 'responses' ? { forceReasoning: true } : {}),
|
||||
},
|
||||
};
|
||||
return { openai: { reasoningEffort: resolved, ...(explicitDefault && resolveOpenAIApi(provider) === 'responses' ? { forceReasoning: true } : {}) } };
|
||||
}
|
||||
|
||||
if (style === 'anthropic') {
|
||||
@@ -173,19 +141,12 @@ export function buildCattyReasoningProviderOptions(
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
// Claude 4.6+ maps `output_config.effort` directly and accepts up to `max`
|
||||
// (4.7+/5+ also `xhigh`). Older models only have the fixed token budgets, so
|
||||
// a level above `high` must clamp to `high` rather than drop the setting.
|
||||
const adaptiveThinking = !!modelId && anthropicUsesAdaptiveThinking(modelId);
|
||||
const anthropicEffort = adaptiveThinking
|
||||
? resolveAnthropicAdaptiveEffort(modelId!, resolved)
|
||||
: clampToUniversalEffort(resolved);
|
||||
if (!anthropicEffort) return undefined;
|
||||
if (adaptiveThinking) {
|
||||
if (resolved !== 'low' && resolved !== 'medium' && resolved !== 'high') return undefined;
|
||||
if (modelId && anthropicUsesAdaptiveThinking(modelId)) {
|
||||
return {
|
||||
anthropic: {
|
||||
thinking: { type: 'adaptive' },
|
||||
effort: anthropicEffort,
|
||||
effort: resolved,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -193,7 +154,7 @@ export function buildCattyReasoningProviderOptions(
|
||||
anthropic: {
|
||||
thinking: {
|
||||
type: 'enabled',
|
||||
budgetTokens: ANTHROPIC_THINKING_BUDGET[anthropicEffort as keyof typeof ANTHROPIC_THINKING_BUDGET],
|
||||
budgetTokens: ANTHROPIC_THINKING_BUDGET[resolved],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -201,19 +162,15 @@ export function buildCattyReasoningProviderOptions(
|
||||
|
||||
if (style === 'google') {
|
||||
if (!modelId || !googleModelLikelySupportsThinking(modelId)) return undefined;
|
||||
// Gemini 3 `thinkingLevel` stops at `high`, and Gemini 2.5 only has numeric
|
||||
// budgets, so anything above `high` has to clamp instead of being sent raw.
|
||||
const googleResolved = clampToUniversalEffort(resolved);
|
||||
if (!googleResolved) return undefined;
|
||||
if (isGemini3Model(modelId)) {
|
||||
if (googleResolved !== 'minimal' && googleResolved !== 'low' && googleResolved !== 'medium' && googleResolved !== 'high') {
|
||||
if (resolved !== 'minimal' && resolved !== 'low' && resolved !== 'medium' && resolved !== 'high') {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
google: {
|
||||
thinkingConfig: {
|
||||
thinkingLevel: googleResolved,
|
||||
includeThoughts: googleResolved !== 'minimal',
|
||||
thinkingLevel: resolved,
|
||||
includeThoughts: resolved !== 'minimal',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -229,11 +186,11 @@ export function buildCattyReasoningProviderOptions(
|
||||
},
|
||||
};
|
||||
}
|
||||
if (googleResolved !== 'low' && googleResolved !== 'medium' && googleResolved !== 'high') return undefined;
|
||||
if (resolved !== 'low' && resolved !== 'medium' && resolved !== 'high') return undefined;
|
||||
return {
|
||||
google: {
|
||||
thinkingConfig: {
|
||||
thinkingBudget: GEMINI_25_THINKING_BUDGET[googleResolved],
|
||||
thinkingBudget: GEMINI_25_THINKING_BUDGET[resolved],
|
||||
includeThoughts: true,
|
||||
},
|
||||
},
|
||||
@@ -242,35 +199,6 @@ export function buildCattyReasoningProviderOptions(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse `xhigh` / `max` onto `high` for providers whose vocabulary stops
|
||||
* there. Dropping the setting entirely would silently ignore an explicit user
|
||||
* choice, so the maximum supported depth is used instead.
|
||||
*/
|
||||
function clampToUniversalEffort(level: string): string | undefined {
|
||||
if (level === 'xhigh' || level === 'max') return HIGHEST_UNIVERSAL_EFFORT;
|
||||
return level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude `output_config.effort` vocabulary per adaptive-thinking generation.
|
||||
* 4.6 exposes `max`; 4.7 and later (including 5.x) also expose `xhigh`.
|
||||
*/
|
||||
function resolveAnthropicAdaptiveEffort(modelId: string, level: string): string | undefined {
|
||||
if (level !== 'xhigh' && level !== 'max') {
|
||||
return level === 'minimal' ? 'low' : level;
|
||||
}
|
||||
return anthropicSupportsExtraHighEffort(modelId) ? level : 'max';
|
||||
}
|
||||
|
||||
/** Claude 4.7+ / 5+ list `xhigh`; 4.6 stops at `max`. */
|
||||
function anthropicSupportsExtraHighEffort(modelId: string): boolean {
|
||||
const parsed = parseClaudeModel(modelId);
|
||||
if (!parsed) return false;
|
||||
if (parsed.major >= 5) return true;
|
||||
return parsed.major === 4 && parsed.minor >= 7;
|
||||
}
|
||||
|
||||
/** Extended thinking is Claude 3.7+ / 4+ / 5+; original Claude 3 Haiku/Sonnet reject it. */
|
||||
export function anthropicModelLikelySupportsThinking(modelId: string): boolean {
|
||||
const parsed = parseClaudeModel(modelId);
|
||||
@@ -303,28 +231,6 @@ export function openaiModelSupportsMinimalReasoning(modelId: string): boolean {
|
||||
return /gpt-5/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Models whose `reasoning_effort` is a continuous dial rather than an OpenAI
|
||||
* tier list. DeepSeek V4 documents `low | high | max` natively and maps
|
||||
* everything in between, so the full graded scale is meaningful there.
|
||||
*/
|
||||
export function openaiModelSupportsGradedReasoningEffort(modelId: string): boolean {
|
||||
const id = modelId.trim().toLowerCase();
|
||||
if (!id) return false;
|
||||
return /deepseek-(?:v?4|v4\.1)/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly supported OpenAI `xhigh` families. GPT-5.5 accepts xhigh (not
|
||||
* max); keep older families and unverified variants at their existing ceiling.
|
||||
*/
|
||||
export function openaiModelSupportsExtraHighReasoning(modelId: string): boolean {
|
||||
const id = modelId.trim().toLowerCase();
|
||||
if (!id || openaiModelIsChatSnapshot(id)) return false;
|
||||
return /gpt-5(?:\.\d+)?-codex-max/.test(id)
|
||||
|| /(?:^|\/)gpt-5\.5(?:-\d{4}-\d{2}-\d{2})?$/.test(id);
|
||||
}
|
||||
|
||||
/** OpenAI-compat models that accept `reasoning_effort` (o-series, GPT-5, reasoners). */
|
||||
export function openaiModelLikelySupportsReasoning(modelId: string): boolean {
|
||||
const id = modelId.trim().toLowerCase();
|
||||
@@ -336,7 +242,6 @@ export function openaiModelLikelySupportsReasoning(modelId: string): boolean {
|
||||
|| /gpt-oss/.test(id)
|
||||
|| /reasoner|reasoning/.test(id)
|
||||
|| /deepseek-r1/.test(id)
|
||||
|| openaiModelSupportsGradedReasoningEffort(id)
|
||||
|| /grok-4/.test(id)
|
||||
);
|
||||
}
|
||||
@@ -350,7 +255,8 @@ export function cattyReasoningLevelsForSelection(
|
||||
const style = resolveProviderStyle(provider);
|
||||
if (style === 'anthropic') {
|
||||
if (!modelId || !anthropicModelLikelySupportsThinking(modelId)) return [];
|
||||
return anthropicAdvertisedLevels(modelId);
|
||||
if (!anthropicAllowsDisabledThinking(modelId)) return LEVELS_LOW_MEDIUM_HIGH;
|
||||
return CATTY_REASONING_LEVELS;
|
||||
}
|
||||
if (style === 'google') {
|
||||
if (!modelId || !googleModelLikelySupportsThinking(modelId)) return [];
|
||||
@@ -361,23 +267,10 @@ export function cattyReasoningLevelsForSelection(
|
||||
if (style === 'openai') {
|
||||
if (!modelId || openaiModelKnownUnsupportedReasoning(modelId)) return [];
|
||||
if (!openaiModelLikelySupportsReasoning(modelId)) {
|
||||
// A custom provider with an explicit default is the escape hatch for
|
||||
// relay aliases we cannot classify. Advertising only the legacy three
|
||||
// levels would let resolveVisibleCattyThinkingLevel downgrade a
|
||||
// configured `xhigh`/`max` to `high`, so the user's own assertion about
|
||||
// the alias becomes the capability signal instead.
|
||||
return provider.providerId === 'custom' && isProviderReasoningEffort(provider.advancedParams?.reasoningEffort)
|
||||
? LEVELS_LOW_MEDIUM_HIGH_XHIGH_MAX
|
||||
return provider.providerId === 'custom' && ['low', 'medium', 'high'].includes(provider.advancedParams?.reasoningEffort ?? '')
|
||||
? LEVELS_LOW_MEDIUM_HIGH
|
||||
: [];
|
||||
}
|
||||
// DeepSeek grades `reasoning_effort` continuously and `none` is not part of
|
||||
// that scale, so the chip starts at `low` and ends at `max`.
|
||||
if (openaiModelSupportsGradedReasoningEffort(modelId)) return LEVELS_LOW_MEDIUM_HIGH_XHIGH_MAX;
|
||||
if (openaiModelSupportsExtraHighReasoning(modelId)) {
|
||||
return openaiModelSupportsNoneReasoning(modelId)
|
||||
? LEVELS_OFF_LOW_MEDIUM_HIGH_XHIGH
|
||||
: LEVELS_LOW_MEDIUM_HIGH_XHIGH;
|
||||
}
|
||||
if (openaiModelSupportsNoneReasoning(modelId)) return CATTY_REASONING_LEVELS;
|
||||
if (openaiModelSupportsMinimalReasoning(modelId)) return LEVELS_MINIMAL_LOW_MEDIUM_HIGH;
|
||||
return LEVELS_LOW_MEDIUM_HIGH;
|
||||
@@ -385,23 +278,6 @@ export function cattyReasoningLevelsForSelection(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic advertises `off` plus the effort tiers the model documents.
|
||||
* Adaptive thinking (4.6+) adds `max`; 4.7+/5+ also add `xhigh`. Always-on
|
||||
* families (Fable/Mythos 5) cannot switch thinking off at all.
|
||||
*/
|
||||
function anthropicAdvertisedLevels(modelId: string): readonly string[] {
|
||||
const allowsDisabled = anthropicAllowsDisabledThinking(modelId);
|
||||
if (!anthropicUsesAdaptiveThinking(modelId)) {
|
||||
return allowsDisabled ? CATTY_REASONING_LEVELS : LEVELS_LOW_MEDIUM_HIGH;
|
||||
}
|
||||
const supportsExtraHigh = anthropicSupportsExtraHighEffort(modelId);
|
||||
if (!allowsDisabled) {
|
||||
return supportsExtraHigh ? LEVELS_LOW_MEDIUM_HIGH_XHIGH_MAX : LEVELS_LOW_MEDIUM_HIGH_MAX;
|
||||
}
|
||||
return supportsExtraHigh ? LEVELS_OFF_LOW_MEDIUM_HIGH_XHIGH_MAX : LEVELS_OFF_LOW_MEDIUM_HIGH_MAX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a level the current model actually advertises; never keep a stale chip value.
|
||||
* Missing mid-levels prefer the next higher advertised value (Gemini 3 Pro medium → high)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type { ChatMessage, AIPermissionMode } from '../types';
|
||||
import { sliceHead } from '../textBoundaries';
|
||||
import {
|
||||
DEFAULT_CONTEXT_WINDOW_TOKENS,
|
||||
DEFAULT_PROTECT_RECENT_MESSAGES,
|
||||
@@ -332,7 +331,7 @@ export function extractLatestUserGoal(messages: ModelMessage[] | ChatMessage[]):
|
||||
const content = typeof message.content === 'string'
|
||||
? message.content.trim()
|
||||
: '';
|
||||
if (content && !content.startsWith('[Netcatty session context')) return sliceHead(content, 500);
|
||||
if (content && !content.startsWith('[Netcatty session context')) return content.slice(0, 500);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ChatMessage } from '../types';
|
||||
import type { ExternalBridgeHistoryMessage } from './types';
|
||||
import { sliceHead } from '../textBoundaries';
|
||||
import { buildHistoricalToolResultReplayText, buildHistoricalUserReplayContent } from '../../../components/ai/cattyHistoryReplay';
|
||||
import { formatVaultNoteReferences, isVaultNoteAttachment } from '../../../application/state/vaultNoteAttachment';
|
||||
|
||||
@@ -48,7 +47,7 @@ const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${sliceHead(value, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ToolOutputStore } from './toolOutputStore';
|
||||
import { sliceHead, sliceTail } from '../textBoundaries';
|
||||
|
||||
const LARGE_USER_INPUT_THRESHOLD_CHARS = 25_000;
|
||||
const LARGE_USER_INPUT_HEAD_CHARS = 12_000;
|
||||
@@ -44,8 +43,8 @@ export function fitLargeUserInputForModel(
|
||||
if (input.length <= LARGE_USER_INPUT_THRESHOLD_CHARS) return input;
|
||||
const handleId = getStableHandleId(input, chatSessionId, toolOutputStore);
|
||||
return [
|
||||
sliceHead(input, LARGE_USER_INPUT_HEAD_CHARS),
|
||||
input.slice(0, LARGE_USER_INPUT_HEAD_CHARS),
|
||||
`\n\n[... large user input moved to saved output: ${input.length} chars, handleId=${handleId}. Use tool_output_read with range or search for omitted details ...]\n\n`,
|
||||
sliceTail(input, LARGE_USER_INPUT_TAIL_CHARS),
|
||||
input.slice(-LARGE_USER_INPUT_TAIL_CHARS),
|
||||
].join('');
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { redactSecretsForModel } from './modelSecretRedaction';
|
||||
import { sliceHead } from '../textBoundaries';
|
||||
|
||||
const MAX_DECISIONS = 15;
|
||||
const MAX_BLOCKERS = 10;
|
||||
@@ -81,7 +80,7 @@ export class SessionStateStore {
|
||||
mergeFromUserGoal(chatSessionId: string, goal: string | undefined): void {
|
||||
if (!goal?.trim()) return;
|
||||
const state = { ...this.get(chatSessionId) };
|
||||
state.userGoal = sliceHead(goal.trim(), 500);
|
||||
state.userGoal = goal.trim().slice(0, 500);
|
||||
state.updatedAt = Date.now();
|
||||
this.bySession.set(chatSessionId, state);
|
||||
}
|
||||
@@ -119,7 +118,7 @@ export class SessionStateStore {
|
||||
mergePlan(chatSessionId: string, items: Array<{ text: string; completed: boolean }>): void {
|
||||
const state = { ...this.get(chatSessionId) };
|
||||
state.planItems = items.slice(-30).map(item => ({
|
||||
text: sliceHead(item.text, 300),
|
||||
text: item.text.slice(0, 300),
|
||||
completed: item.completed,
|
||||
}));
|
||||
state.updatedAt = Date.now();
|
||||
@@ -242,7 +241,7 @@ export class SessionStateStore {
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
const preview = sliceHead(resultText, 160).replace(/\s+/g, ' ').trim();
|
||||
const preview = resultText.slice(0, 160).replace(/\s+/g, ' ').trim();
|
||||
if (preview) {
|
||||
state.blockers = pushUnique(state.blockers, `${toolName}: ${preview}`, MAX_BLOCKERS);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
import { redactSecretsForModel, redactSecretsInValueForModel } from './modelSecretRedaction';
|
||||
import { sliceHead, sliceTail } from '../textBoundaries';
|
||||
|
||||
const SUPERSEDED_READ_PREFIX = '[superseded read:';
|
||||
const EARLIER_TERMINAL_PREFIX = '[earlier terminal output omitted:';
|
||||
@@ -273,7 +272,7 @@ export function pruneStaleToolContext(
|
||||
return `[older tool result omitted: tool=${toolName || 'unknown'}, chars=${text.length}]`;
|
||||
}
|
||||
if (age >= 3 && text.length > 4_000) {
|
||||
return `${sliceHead(text, 1_500)}\n\n[... tool result shortened: ${text.length - 3_000} chars omitted ...]\n\n${sliceTail(text, 1_500)}`;
|
||||
return `${text.slice(0, 1_500)}\n\n[... tool result shortened: ${text.length - 3_000} chars omitted ...]\n\n${text.slice(-1_500)}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { compressVerboseText } from '../requestPayloadCompression';
|
||||
import { sliceHead } from '../textBoundaries';
|
||||
|
||||
const MONITOR_LINE_MAX_CHARS = 500;
|
||||
const MONITOR_BATCH_MAX_CHARS = 3_000;
|
||||
@@ -195,13 +194,13 @@ function fitMonitorBatch(output: string): { content: string; sourceTruncated: bo
|
||||
const lines = normalized.split('\n').map(line => {
|
||||
if (line.length <= MONITOR_LINE_MAX_CHARS) return line;
|
||||
sourceTruncated = true;
|
||||
return `${sliceHead(line, MONITOR_LINE_MAX_CHARS - 28)}[... line shortened ...]`;
|
||||
return `${line.slice(0, MONITOR_LINE_MAX_CHARS - 28)}[... line shortened ...]`;
|
||||
});
|
||||
const content = lines.join('\n');
|
||||
if (content.length <= MONITOR_BATCH_MAX_CHARS) return { content, sourceTruncated };
|
||||
const marker = '\n[... monitor batch shortened ...]';
|
||||
return {
|
||||
content: `${sliceHead(content, MONITOR_BATCH_MAX_CHARS - marker.length)}${marker}`,
|
||||
content: `${content.slice(0, MONITOR_BATCH_MAX_CHARS - marker.length)}${marker}`,
|
||||
sourceTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { safeSliceBounds, sliceHead, sliceTail } from '../textBoundaries';
|
||||
|
||||
export interface ToolOutputHandle {
|
||||
id: string;
|
||||
chatSessionId: string;
|
||||
@@ -144,6 +142,26 @@ function nextHandleId(): string {
|
||||
return `tool-output-${randomId}`;
|
||||
}
|
||||
|
||||
function isHighSurrogate(value: number): boolean {
|
||||
return value >= 0xd800 && value <= 0xdbff;
|
||||
}
|
||||
|
||||
function isLowSurrogate(value: number): boolean {
|
||||
return value >= 0xdc00 && value <= 0xdfff;
|
||||
}
|
||||
|
||||
function safeSliceBounds(content: string, requestedStart: number, requestedEnd: number): [number, number] {
|
||||
let start = Math.min(content.length, Math.max(0, requestedStart));
|
||||
let end = Math.min(content.length, Math.max(start, requestedEnd));
|
||||
if (start > 0 && start < content.length && isLowSurrogate(content.charCodeAt(start))) {
|
||||
start -= 1;
|
||||
}
|
||||
if (end > start && end < content.length && isHighSurrogate(content.charCodeAt(end - 1))) {
|
||||
end -= 1;
|
||||
}
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
export class ToolOutputStore {
|
||||
private readonly bySession = new Map<string, Map<string, ToolOutputHandle>>();
|
||||
private readonly maxHandleChars: number;
|
||||
@@ -239,7 +257,7 @@ export class ToolOutputStore {
|
||||
totalChars: input.content.length,
|
||||
storedChars: retainedContent.length,
|
||||
sourceTruncated: retainedContent.length < input.content.length,
|
||||
preview: sliceHead(retainedContent, previewChars),
|
||||
preview: retainedContent.slice(0, previewChars),
|
||||
storedAt: now,
|
||||
accessedAt: now,
|
||||
fullContent: retainedContent,
|
||||
@@ -732,11 +750,11 @@ function isValidPersistedRecord(
|
||||
function retainBoundedContent(content: string, maxChars: number): string {
|
||||
if (content.length <= maxChars) return content;
|
||||
const marker = `\n\n[... source output exceeded local handle limit; ${content.length - maxChars} chars omitted ...]\n\n`;
|
||||
if (marker.length >= maxChars) return sliceHead(content, maxChars);
|
||||
if (marker.length >= maxChars) return content.slice(0, maxChars);
|
||||
const budget = Math.max(0, maxChars - marker.length);
|
||||
const head = Math.floor(budget / 2);
|
||||
const tail = budget - head;
|
||||
return `${sliceHead(content, head)}${marker}${sliceTail(content, tail)}`;
|
||||
return `${content.slice(0, head)}${marker}${content.slice(-tail)}`;
|
||||
}
|
||||
|
||||
function buildReadResult(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { sliceHead } from '../textBoundaries';
|
||||
|
||||
export interface ToolResultDedupEntry {
|
||||
fingerprint: string;
|
||||
toolName: string;
|
||||
@@ -90,7 +88,7 @@ export class ToolResultDedup {
|
||||
fingerprint,
|
||||
toolName,
|
||||
turnNumber: this.turnNumber,
|
||||
preview: sliceHead(preview, 160),
|
||||
preview: preview.slice(0, 160),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -114,11 +112,11 @@ export function buildTerminalWriteFingerprint(
|
||||
}
|
||||
|
||||
export function previewToolResult(result: unknown): string {
|
||||
if (typeof result === 'string') return sliceHead(result, 160);
|
||||
if (typeof result === 'string') return result.slice(0, 160);
|
||||
try {
|
||||
return sliceHead(JSON.stringify(result), 160);
|
||||
return JSON.stringify(result).slice(0, 160);
|
||||
} catch {
|
||||
return sliceHead(String(result), 160);
|
||||
return String(result).slice(0, 160);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1346,82 +1346,3 @@ test('non-streaming DeepSeek usage reaches the SDK as cached input', async t =>
|
||||
assert.equal(result.usage.inputTokenDetails.cacheReadTokens, 900);
|
||||
assert.equal(result.usage.inputTokenDetails.noCacheTokens, 100);
|
||||
});
|
||||
|
||||
test('replaces lone surrogates before the provider request body is serialized', async (t) => {
|
||||
const originalWindow = (globalThis as typeof globalThis & { window?: unknown }).window;
|
||||
t.after(() => {
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = originalWindow;
|
||||
});
|
||||
|
||||
const dataHandlers = new Map<string, (data: string) => void>();
|
||||
const endHandlers = new Map<string, () => void>();
|
||||
const rawBodies: string[] = [];
|
||||
|
||||
(globalThis as typeof globalThis & { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
aiFetch: async () => ({ ok: true, status: 200, data: '{}' }),
|
||||
aiChatCancel: async () => true,
|
||||
onAiStreamData: (requestId: string, cb: (data: string) => void) => {
|
||||
dataHandlers.set(requestId, cb);
|
||||
return () => dataHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamEnd: (requestId: string, cb: () => void) => {
|
||||
endHandlers.set(requestId, cb);
|
||||
return () => endHandlers.delete(requestId);
|
||||
},
|
||||
onAiStreamError: () => () => undefined,
|
||||
aiChatStream: async (requestId: string, _url: string, _headers: Record<string, string>, body: string) => {
|
||||
rawBodies.push(body);
|
||||
setTimeout(() => {
|
||||
dataHandlers.get(requestId)?.(JSON.stringify({
|
||||
id: 'chatcmpl-test',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1777600000,
|
||||
model: 'deepseek-v4-flash',
|
||||
choices: [{ index: 0, delta: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
}));
|
||||
endHandlers.get(requestId)?.();
|
||||
}, 0);
|
||||
return { ok: true, statusCode: 200, statusText: 'OK' };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const model = createModelFromConfig({
|
||||
id: 'deepseek-custom',
|
||||
providerId: 'custom',
|
||||
name: 'DeepSeek',
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
defaultModel: 'deepseek-v4-flash',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'user', content: 'read the log' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: 'call_1', toolName: 'terminal_exec', input: {} }],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call_1',
|
||||
toolName: 'terminal_exec',
|
||||
// A head/tail cut that split 🏆 leaves its low half behind.
|
||||
output: { type: 'text', value: '[... long line shortened ...]\udfc6[SOCKS-5006]' },
|
||||
}],
|
||||
},
|
||||
],
|
||||
});
|
||||
for await (const _chunk of result.stream) {
|
||||
// Drain the stream so the request is sent.
|
||||
}
|
||||
|
||||
assert.equal(rawBodies.length, 1);
|
||||
assert.doesNotMatch(rawBodies[0], /\\ud[89a-f][0-9a-f]{2}/i);
|
||||
assert.match(JSON.parse(rawBodies[0]).messages.at(-1).content, /\]�\[SOCKS-5006\]/);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ModelMessage } from "ai";
|
||||
import { sliceHead, sliceTail } from "./textBoundaries";
|
||||
|
||||
const RETRY_MAX_TOOL_RESULT_CHARS = 4_000;
|
||||
const RETRY_MAX_MESSAGE_TEXT_CHARS = 8_000;
|
||||
@@ -82,7 +81,7 @@ function collapseCarriageReturnFrames(value: string): string {
|
||||
function shortenVerboseLine(line: string): string {
|
||||
if (line.length <= MAX_VERBOSE_LINE_CHARS) return line;
|
||||
const edge = Math.floor((MAX_VERBOSE_LINE_CHARS - 80) / 2);
|
||||
return `${sliceHead(line, edge)}[... long line shortened: ${line.length} chars ...]${sliceTail(line, edge)}`;
|
||||
return `${line.slice(0, edge)}[... long line shortened: ${line.length} chars ...]${line.slice(-edge)}`;
|
||||
}
|
||||
|
||||
export function truncateTextWithHeadAndTail(
|
||||
@@ -100,7 +99,7 @@ export function truncateTextWithHeadAndTail(
|
||||
): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
if (maxChars <= marker.length + 16) {
|
||||
return sliceHead(value, maxChars);
|
||||
return value.slice(0, maxChars);
|
||||
}
|
||||
|
||||
const budget = maxChars - marker.length;
|
||||
@@ -110,13 +109,13 @@ export function truncateTextWithHeadAndTail(
|
||||
tail = Math.max(0, budget - head);
|
||||
}
|
||||
if (head + tail >= value.length) {
|
||||
return sliceHead(value, maxChars);
|
||||
return value.slice(0, maxChars);
|
||||
}
|
||||
if (head + tail <= 0) {
|
||||
return sliceHead(value, maxChars);
|
||||
return value.slice(0, maxChars);
|
||||
}
|
||||
|
||||
return `${sliceHead(value, head).trimEnd()}${marker}${sliceTail(value, tail).trimStart()}`;
|
||||
return `${value.slice(0, head).trimEnd()}${marker}${value.slice(-tail).trimStart()}`;
|
||||
}
|
||||
|
||||
export function compressMessagesForRequestTooLargeRetry(
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createGoogle } from '@ai-sdk/google';
|
||||
import { wrapLanguageModel, type LanguageModelMiddleware } from 'ai';
|
||||
import type { ProviderConfig, ProviderStyle } from '../types';
|
||||
import { resolveOpenAIApi, resolveProviderStyle } from '../types';
|
||||
import { normalizeAnthropicSdkBaseURL } from '../anthropicCompatBaseUrl';
|
||||
import { buildSdkRequestHeaders } from '../providerRequestHeaders';
|
||||
import { normalizeOllamaSdkBaseURL } from '../ollamaCompatBaseUrl';
|
||||
import { normalizeOpenAIChatCacheUsage } from '../openAIChatCacheUsage';
|
||||
import { toWellFormedValue } from '../textBoundaries';
|
||||
|
||||
export { normalizeOllamaSdkBaseURL };
|
||||
import {
|
||||
@@ -723,33 +721,9 @@ export function resolveProviderEndpoint(
|
||||
return { baseURL, apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Last line of defence before serialization: a lone UTF-16 surrogate left by
|
||||
* any upstream cut becomes `\udXXX` in the JSON body, which strict providers
|
||||
* reject as an unparseable request. Replace it so one bad character cannot
|
||||
* wedge every later turn of the conversation.
|
||||
*/
|
||||
const wellFormedPromptMiddleware: LanguageModelMiddleware = {
|
||||
specificationVersion: 'v4',
|
||||
transformParams: async ({ params }) => {
|
||||
const prompt = toWellFormedValue(params.prompt);
|
||||
return prompt === params.prompt ? params : { ...params, prompt };
|
||||
},
|
||||
};
|
||||
|
||||
export function createModelFromConfig(
|
||||
config: ProviderConfig,
|
||||
requestContext?: ProviderRequestContext,
|
||||
) {
|
||||
return wrapLanguageModel({
|
||||
model: createProviderModel(config, requestContext),
|
||||
middleware: wellFormedPromptMiddleware,
|
||||
});
|
||||
}
|
||||
|
||||
function createProviderModel(
|
||||
config: ProviderConfig,
|
||||
requestContext?: ProviderRequestContext,
|
||||
) {
|
||||
// Use placeholder API key — the main process will inject the real key
|
||||
const safeApiKey = config.apiKey ? API_KEY_PLACEHOLDER : undefined;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { ModelMessage } from "ai";
|
||||
|
||||
import {
|
||||
safeSliceBounds,
|
||||
sliceHead,
|
||||
sliceTail,
|
||||
toWellFormedText,
|
||||
toWellFormedValue,
|
||||
} from "./textBoundaries.ts";
|
||||
import {
|
||||
compressMessagesForRequestTooLargeRetry,
|
||||
compressVerboseText,
|
||||
truncateTextWithHeadAndTail,
|
||||
} from "./requestPayloadCompression.ts";
|
||||
|
||||
const TROPHY = "\u{1F3C6}";
|
||||
|
||||
test("sliceHead never ends inside a surrogate pair", () => {
|
||||
const value = `ab${TROPHY}cd`;
|
||||
assert.equal(sliceHead(value, 3), "ab");
|
||||
assert.equal(sliceHead(value, 4), `ab${TROPHY}`);
|
||||
assert.equal(sliceHead(value, 0), "");
|
||||
assert.equal(sliceHead(value, 99), value);
|
||||
});
|
||||
|
||||
test("sliceTail never starts inside a surrogate pair", () => {
|
||||
const value = `ab${TROPHY}cd`;
|
||||
assert.equal(sliceTail(value, 3), "cd");
|
||||
assert.equal(sliceTail(value, 4), `${TROPHY}cd`);
|
||||
assert.equal(sliceTail(value, 0), "");
|
||||
assert.equal(sliceTail(value, 99), value);
|
||||
});
|
||||
|
||||
test("safeSliceBounds moves both edges off a split pair", () => {
|
||||
const value = `a${TROPHY}b${TROPHY}c`;
|
||||
const [start, end] = safeSliceBounds(value, 2, 5);
|
||||
assert.ok(value.slice(start, end).isWellFormed());
|
||||
});
|
||||
|
||||
test("toWellFormedText replaces only lone surrogates", () => {
|
||||
assert.equal(toWellFormedText(`ok ${TROPHY}`), `ok ${TROPHY}`);
|
||||
assert.equal(toWellFormedText("a\udfc6b\ud83c"), "a�b�");
|
||||
});
|
||||
|
||||
test("toWellFormedValue rewrites nested strings and keeps clean subtrees", () => {
|
||||
const clean = { text: TROPHY };
|
||||
const input = { clean, parts: [{ value: "\udfc6x" }], data: new Uint8Array([1]) };
|
||||
const output = toWellFormedValue(input);
|
||||
assert.equal(output.clean, clean);
|
||||
assert.equal(output.data, input.data);
|
||||
assert.equal(output.parts[0].value, "�x");
|
||||
assert.equal(toWellFormedValue(clean), clean);
|
||||
});
|
||||
|
||||
test("toWellFormedValue keeps an own __proto__ key as data", () => {
|
||||
const input = JSON.parse('{"__proto__":{"polluted":true},"text":"\\udfc6x"}') as Record<string, unknown>;
|
||||
const output = toWellFormedValue(input);
|
||||
assert.notEqual(output, input);
|
||||
assert.equal(output.text, "�x");
|
||||
assert.equal(Object.getPrototypeOf(output), Object.prototype);
|
||||
assert.ok(Object.hasOwn(output, "__proto__"));
|
||||
assert.deepEqual(Object.getOwnPropertyDescriptor(output, "__proto__")?.value, { polluted: true });
|
||||
assert.equal(JSON.stringify(output), '{"__proto__":{"polluted":true},"text":"�x"}');
|
||||
|
||||
const bare = Object.assign(Object.create(null), { text: "\ud83c" });
|
||||
assert.equal(Object.getPrototypeOf(toWellFormedValue(bare)), null);
|
||||
});
|
||||
|
||||
// Regression: a mihomo log tail with 🏆 on every line was JSON-stringified
|
||||
// into one long line; shortening it cut the emoji in half and DeepSeek
|
||||
// rejected every later request with "lone leading surrogate in hex escape".
|
||||
test("compressing an emoji-heavy JSON tool result keeps the request well-formed", () => {
|
||||
const line = `level=info msg="[TCP] 127.0.0.1:38178 --> raw.githubusercontent.com:443 using ⚡ ${TROPHY}[SOCKS-5006]"`;
|
||||
const stdout = Array.from({ length: 40 }, () => line).join("\n");
|
||||
// Sweep one full line period so the tail cut lands on every offset, the emoji included.
|
||||
for (let pad = 0; pad <= line.length; pad += 1) {
|
||||
const value = { stdout, stderr: "x".repeat(pad), exitCode: 0 };
|
||||
assert.ok(compressVerboseText(JSON.stringify(value)).isWellFormed(), `pad=${pad}`);
|
||||
const messages = [{
|
||||
role: "tool",
|
||||
content: [{ type: "tool-result", toolCallId: "c", toolName: "t", output: { type: "json", value } }],
|
||||
}] as ModelMessage[];
|
||||
const { messages: compressed } = compressMessagesForRequestTooLargeRetry(messages, { preserveAttachments: true });
|
||||
const serialized = JSON.stringify(compressed);
|
||||
assert.doesNotMatch(serialized, /\\ud[89a-f][0-9a-f]{2}/i, `pad=${pad}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("truncateTextWithHeadAndTail keeps surrogate pairs whole at every cut", () => {
|
||||
const value = TROPHY.repeat(3_000);
|
||||
for (const maxChars of [10, 101, 1_001, 4_001]) {
|
||||
assert.ok(truncateTextWithHeadAndTail(value, maxChars).isWellFormed(), `maxChars=${maxChars}`);
|
||||
assert.ok(
|
||||
truncateTextWithHeadAndTail(value, maxChars, { headChars: 7, tailChars: 9 }).isWellFormed(),
|
||||
`maxChars=${maxChars} odd edges`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user