Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
071c95ab5c | ||
|
|
ec99875dec | ||
|
|
51a6b7efaa | ||
|
|
30f5346035 | ||
|
|
e0302e5f34 | ||
|
|
0425841032 | ||
|
|
156550f7eb | ||
|
|
a1648adf12 | ||
|
|
8182bd6b3c | ||
|
|
484ac5f463 |
45
App.tsx
45
App.tsx
@@ -1068,6 +1068,50 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
[sessions, t],
|
||||
);
|
||||
|
||||
const closeTabsInFlightRef = useRef(false);
|
||||
|
||||
// Close many tabs at once with a single batched busy-shell confirmation.
|
||||
// Used by the "Close all / Close others / Close to the right" context-menu
|
||||
// actions on tabs (#748).
|
||||
const closeTabsBatch = useCallback(
|
||||
async (targetIds: string[]) => {
|
||||
if (targetIds.length === 0) return;
|
||||
if (closeTabsInFlightRef.current) return;
|
||||
|
||||
// Expand workspace ids into their constituent session ids so the busy
|
||||
// probe sees every local shell that's about to be killed.
|
||||
const sessionIdsToProbe: string[] = [];
|
||||
for (const tabId of targetIds) {
|
||||
const ws = workspaces.find((w) => w.id === tabId);
|
||||
if (ws) {
|
||||
for (const s of sessions) {
|
||||
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
|
||||
}
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
sessionIdsToProbe.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
closeTabsInFlightRef.current = true;
|
||||
try {
|
||||
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
|
||||
if (!ok) return;
|
||||
for (const tabId of targetIds) {
|
||||
if (workspaces.find((w) => w.id === tabId)) {
|
||||
closeWorkspace(tabId);
|
||||
} else if (sessions.find((s) => s.id === tabId)) {
|
||||
closeSession(tabId);
|
||||
} else if (logViews.find((lv) => lv.id === tabId)) {
|
||||
closeLogView(tabId);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closeTabsInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSession, closeLogView],
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
|
||||
@@ -1630,6 +1674,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
onCloseTabsBatch={closeTabsBatch}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
|
||||
@@ -306,6 +306,12 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` wipes scrollback',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
@@ -1633,6 +1639,9 @@ const en: Messages = {
|
||||
'tabs.logPrefix': 'Log:',
|
||||
'tabs.logLocal': 'Local',
|
||||
'tabs.copyTab': 'Copy Tab',
|
||||
'tabs.closeOthers': 'Close Others',
|
||||
'tabs.closeToRight': 'Close Tabs to the Right',
|
||||
'tabs.closeAll': 'Close All',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': 'Key label',
|
||||
'keychain.edit.privateKeyRequired': 'Private key *',
|
||||
|
||||
@@ -1389,6 +1389,12 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
|
||||
'settings.terminal.behavior.clearWipesScrollback.desc':
|
||||
'`clear` 命令同时清空回滚历史(POSIX 默认行为)。关闭则保留历史。',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
|
||||
'settings.terminal.behavior.preserveSelectionOnInput.desc':
|
||||
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
@@ -1641,6 +1647,9 @@ const zhCN: Messages = {
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'tabs.closeOthers': '关闭其他标签',
|
||||
'tabs.closeToRight': '关闭右侧标签',
|
||||
'tabs.closeAll': '关闭所有标签',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
selectDraftForAgentSwitch,
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
@@ -172,6 +173,47 @@ test("ensureDraftForScopeState returns the original ref when the scope already e
|
||||
assert.equal(next, draftsByScope);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "keep me only if I was already drafting",
|
||||
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "keep me only if I was already drafting");
|
||||
assert.deepEqual(next.attachments, currentDraft.attachments);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
|
||||
const currentDraft = createEmptyDraft("agent-alpha");
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "");
|
||||
assert.deepEqual(next.attachments, []);
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, []);
|
||||
});
|
||||
|
||||
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
|
||||
const currentDraft = {
|
||||
...createEmptyDraft("agent-alpha"),
|
||||
text: "unfinished prompt",
|
||||
selectedUserSkillSlugs: ["skill-a"],
|
||||
};
|
||||
|
||||
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
|
||||
|
||||
assert.equal(next.agentId, "agent-beta");
|
||||
assert.equal(next.text, "unfinished prompt");
|
||||
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
|
||||
});
|
||||
|
||||
test("draft mutation version increments on every mutation for the same scope", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialVersion = getDraftMutationVersionState({}, scopeKey);
|
||||
|
||||
@@ -145,6 +145,31 @@ export function ensureDraftForScopeState(
|
||||
};
|
||||
}
|
||||
|
||||
export function selectDraftForAgentSwitch(
|
||||
currentDraft: AIDraft | null | undefined,
|
||||
agentId: string,
|
||||
startFresh: boolean,
|
||||
): AIDraft {
|
||||
const hasPendingDraftContent = Boolean(
|
||||
currentDraft
|
||||
&& (
|
||||
currentDraft.text.length > 0
|
||||
|| currentDraft.attachments.length > 0
|
||||
|| currentDraft.selectedUserSkillSlugs.length > 0
|
||||
),
|
||||
);
|
||||
|
||||
if (startFresh && !hasPendingDraftContent) {
|
||||
return createEmptyDraft(agentId);
|
||||
}
|
||||
|
||||
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
|
||||
return {
|
||||
...baseDraft,
|
||||
agentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearScopeDraftState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
|
||||
@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions removes non-restorable terminal chats and closed workspaces", () => {
|
||||
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
@@ -99,10 +99,7 @@ test("pruneInactiveScopedSessions removes non-restorable terminal chats and clos
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.deepEqual(next.sessions, [
|
||||
{
|
||||
...sessions[0],
|
||||
externalSessionId: undefined,
|
||||
},
|
||||
sessions[0],
|
||||
sessions[3],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -103,8 +103,8 @@ export function pruneInactiveScopedSessions(
|
||||
* Session ids currently displayed by any live scope. A session whose
|
||||
* `scope.targetId` is inactive but whose id is still in use somewhere
|
||||
* (e.g. resumed from history into a different terminal) must not be
|
||||
* treated as orphaned — clearing its `externalSessionId` or deleting
|
||||
* it outright would break the chat the user is actively continuing.
|
||||
* treated as orphaned — deleting it outright would break the chat the
|
||||
* user is actively continuing.
|
||||
*/
|
||||
activeSessionIds: Set<string> = new Set(),
|
||||
): {
|
||||
@@ -135,15 +135,7 @@ export function pruneInactiveScopedSessions(
|
||||
sessionsChanged = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!session.externalSessionId) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
sessionsChanged = true;
|
||||
return [
|
||||
{ ...session, externalSessionId: undefined },
|
||||
];
|
||||
return [session];
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -98,8 +98,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
// history can be resumed into a different terminal and we must not
|
||||
// clear its `externalSessionId` (or delete it outright) while it's
|
||||
// actively being used.
|
||||
// delete it outright while it's actively being used.
|
||||
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
@@ -943,7 +942,7 @@ export function useAIState() {
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
@@ -980,7 +979,7 @@ export function useAIState() {
|
||||
}, [setPanelViewByScope]);
|
||||
|
||||
const clearDraftForScope = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
const currentPanelViewByScope = panelViewByScope;
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let draftsChanged = false;
|
||||
|
||||
@@ -108,7 +108,8 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
|
||||
'preserveSelectionOnInput', 'osc52Clipboard',
|
||||
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
|
||||
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
|
||||
] as const;
|
||||
|
||||
@@ -58,12 +58,14 @@ import {
|
||||
} from './ai/draftSendGate';
|
||||
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
||||
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
||||
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
type DefaultTargetSessionHint,
|
||||
} from './ai/hooks/useAIChatStreaming';
|
||||
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
|
||||
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
@@ -177,35 +179,6 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return messages.flatMap((message): Array<{ role: 'user' | 'assistant'; content: string }> => {
|
||||
if (message.role === 'system') return [];
|
||||
|
||||
if (message.role === 'user') {
|
||||
return message.content ? [{ role: 'user', content: message.content }] : [];
|
||||
}
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
if (!parts.length) return [];
|
||||
return [{ role: 'assistant', content: parts.join('\n\n') }];
|
||||
}
|
||||
|
||||
if (message.role === 'tool' && message.toolResults?.length) {
|
||||
return message.toolResults.map((tr) => ({
|
||||
role: 'assistant',
|
||||
content: `Tool result:\n${tr.content}`,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -905,10 +878,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const existingExternalSessionId = currentSession?.externalSessionId;
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
existingSessionId: existingExternalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
historyMessages: buildAcpHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
@@ -1002,12 +976,15 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
showScopeDraftView();
|
||||
ensureScopeDraft(agentId);
|
||||
updateScopeDraft(agentId, (draft) => ({
|
||||
...draft,
|
||||
agentId,
|
||||
...selectDraftForAgentSwitch(
|
||||
draft,
|
||||
agentId,
|
||||
Boolean(activeSessionRef.current?.messages.length),
|
||||
),
|
||||
}));
|
||||
showScopeDraftView();
|
||||
setShowHistory(false);
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
|
||||
@@ -800,6 +800,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Autocomplete integration
|
||||
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
|
||||
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
|
||||
isRestoringSelectionRef,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -1237,7 +1238,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hasText = !!selection && selection.length > 0;
|
||||
setHasSelection(hasText);
|
||||
|
||||
if (hasText && terminalSettings?.copyOnSelect) {
|
||||
if (hasText && terminalSettings?.copyOnSelect && !isRestoringSelectionRef.current) {
|
||||
navigator.clipboard.writeText(selection).catch((err) => {
|
||||
logger.warn("Copy on select failed:", err);
|
||||
});
|
||||
@@ -1328,6 +1329,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
|
||||
// True only while createXTermRuntime is programmatically restoring the
|
||||
// selection right after a keystroke (preserveSelectionOnInput). Lets
|
||||
// copy-on-select skip a redundant clipboard write that would otherwise
|
||||
// clobber whatever the user copied elsewhere in the meantime.
|
||||
const isRestoringSelectionRef = useRef(false);
|
||||
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
|
||||
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
|
||||
@@ -36,6 +36,7 @@ interface TopTabsProps {
|
||||
onRenameWorkspace: (workspaceId: string) => void;
|
||||
onCloseWorkspace: (workspaceId: string) => void;
|
||||
onCloseLogView: (logViewId: string) => void;
|
||||
onCloseTabsBatch: (targetIds: string[]) => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -244,6 +245,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRenameWorkspace,
|
||||
onCloseWorkspace,
|
||||
onCloseLogView,
|
||||
onCloseTabsBatch,
|
||||
onOpenQuickSwitcher,
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
@@ -494,6 +496,37 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}).filter(Boolean);
|
||||
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
|
||||
|
||||
// Bulk-close menu items shared by session and workspace context menus.
|
||||
// Anchor is the tab the user right-clicked on (matches VSCode/JetBrains UX).
|
||||
const renderBulkCloseItems = (anchorId: string) => {
|
||||
const anchorIdx = orderedTabs.indexOf(anchorId);
|
||||
const othersIds = orderedTabs.filter((id) => id !== anchorId);
|
||||
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={othersIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(othersIds)}
|
||||
>
|
||||
{t('tabs.closeOthers')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={rightIds.length === 0}
|
||||
onClick={() => onCloseTabsBatch(rightIds)}
|
||||
>
|
||||
{t('tabs.closeToRight')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onCloseTabsBatch(orderedTabs)}
|
||||
>
|
||||
{t('tabs.closeAll')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the tabs
|
||||
const renderOrderedTabs = () => {
|
||||
return orderedTabItems.map((item) => {
|
||||
@@ -593,6 +626,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(session.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -699,6 +733,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
|
||||
{t('common.close')}
|
||||
</ContextMenuItem>
|
||||
{renderBulkCloseItems(workspace.id)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
662
components/ai/acpHistory.test.ts
Normal file
662
components/ai/acpHistory.test.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
buildAcpHistoryMessages,
|
||||
buildAcpHistoryMessagesForBridge,
|
||||
} from "./acpHistory.ts";
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: ChatMessage["role"],
|
||||
content: string,
|
||||
extra: Partial<ChatMessage> = {},
|
||||
): ChatMessage {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
content,
|
||||
timestamp: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildAcpHistoryMessages compacts older ACP context and keeps only recent raw turns", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "我希望最小改动,不要添加很多 test"),
|
||||
message("a1", "assistant", "已按最小改动处理"),
|
||||
message("u2", "user", "MCP 不允许使用,Windows 上不要假设 pwsh.exe"),
|
||||
message("a2", "assistant", "PR #738 已创建,commit 4181a2c"),
|
||||
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
|
||||
message("a3", "assistant", "建议 ACP history compaction"),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{
|
||||
toolCallId: "search",
|
||||
content: `error: ${"large output ".repeat(500)}`,
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
message("u4", "user", "好的"),
|
||||
message("a4", "assistant", "准备实现"),
|
||||
message("u5", "user", "继续"),
|
||||
message("a5", "assistant", "继续处理"),
|
||||
message("u6", "user", "现在提交"),
|
||||
message("a6", "assistant", "还没提交"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Compact prior Netcatty UI context/);
|
||||
assert.match(result[0].content, /最小改动/);
|
||||
assert.match(result[0].content, /pwsh\.exe/);
|
||||
assert.match(result[0].content, /PR #738/);
|
||||
assert.ok(result[0].content.length <= 3000);
|
||||
|
||||
assert.ok(result.length <= 7);
|
||||
assert.deepEqual(
|
||||
result.slice(1).map((entry) => entry.content),
|
||||
["好的", "准备实现", "继续", "继续处理", "现在提交", "还没提交"],
|
||||
);
|
||||
assert.ok(result.every((entry) => entry.content.length <= 3000));
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessagesForBridge keeps fallback history available for stale ACP session recovery", () => {
|
||||
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
|
||||
|
||||
assert.equal(buildAcpHistoryMessagesForBridge([], "acp-session-1"), undefined);
|
||||
assert.deepEqual(
|
||||
buildAcpHistoryMessagesForBridge(messages, "acp-session-1"),
|
||||
buildAcpHistoryMessages(messages),
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
|
||||
message("a1", "assistant", "Understood."),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
|
||||
assert.deepEqual(
|
||||
result.slice(-6).map((entry) => entry.content),
|
||||
[
|
||||
"filler user message 11",
|
||||
"filler assistant message 11",
|
||||
"filler user message 12",
|
||||
"filler assistant message 12",
|
||||
"filler user message 13",
|
||||
"filler assistant message 13",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short important user constraints outside the recent raw window", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not treat pr inside ordinary words as important", () => {
|
||||
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
|
||||
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
|
||||
// Those words land at priority=1 (kept only as space allows) while the
|
||||
// 不要提交 line lands at priority=2 (always preferred). The check below
|
||||
// doesn't assert that the ordinary words are absent from the compact
|
||||
// section — they may legitimately survive when budget allows; that's
|
||||
// intentional after we stopped blanket-dropping short user messages.
|
||||
// What we DO verify: the priority-2 line is selected, which is only
|
||||
// possible if the IMPORTANT_PATTERNS regex correctly distinguishes it
|
||||
// from the surrounding short ordinary-word turns.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "不要提交"),
|
||||
message("a1", "assistant", "收到"),
|
||||
message("u2", "user", "approach"),
|
||||
message("a2", "assistant", "ack"),
|
||||
message("u3", "user", "improve"),
|
||||
message("a3", "assistant", "ack"),
|
||||
message("u4", "user", "prepare"),
|
||||
message("a4", "assistant", "ack"),
|
||||
];
|
||||
|
||||
for (let index = 5; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /不要提交/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
|
||||
const messages: ChatMessage[] = [];
|
||||
|
||||
for (let index = 1; index <= 12; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(
|
||||
message("u13", "user", "Keep the existing layout and copy wording unchanged."),
|
||||
message("a13", "assistant", "Understood."),
|
||||
);
|
||||
|
||||
for (let index = 14; index <= 18; index += 1) {
|
||||
messages.push(
|
||||
message(
|
||||
`u${index}`,
|
||||
"user",
|
||||
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
|
||||
),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Please propose a migration plan for the sidebar state."),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Introduce a dedicated hook for the panel stack. 2. Move the derived view state into that hook. 3. Keep the existing UI copy and layout. 4. Add a regression test around back navigation.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move the derived view state into that hook\./);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
|
||||
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
|
||||
// would previously be dropped by a blanket length<10 heuristic, even
|
||||
// though they don't match any TRIVIAL pattern.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "Use ssh2"),
|
||||
message("a1", "assistant", "Got it."),
|
||||
message("u2", "user", "中文输出"),
|
||||
message("a2", "assistant", "明白"),
|
||||
];
|
||||
|
||||
// Push enough later turns so u1/u2 fall outside the recent raw window
|
||||
// and have to survive via the durable-user compaction path.
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Use ssh2/);
|
||||
assert.match(result[0].content, /中文输出/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops one-word filler user messages", () => {
|
||||
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
|
||||
// "thanks" filler to leak into the compact section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "继续"),
|
||||
message("a2", "assistant", "继续处理"),
|
||||
];
|
||||
|
||||
for (let index = 3; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `filler assistant message ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
// u1 / u2 fall outside the recent raw window. The compact context, if it
|
||||
// exists, must not surface these trivial turns as durable user requests.
|
||||
if (result.length > 0 && result[0].role === "user") {
|
||||
assert.doesNotMatch(result[0].content, /User request: ok\b/);
|
||||
assert.doesNotMatch(result[0].content, /User request: 继续/);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
|
||||
// Regression: tool results used to only reach fallback replay via the
|
||||
// 500-char compact summary. If the user's last interaction produced a
|
||||
// large tool output (cat/rg/fetched file), any "use that output"-style
|
||||
// follow-up lost the actual bytes. Now tool messages flow through the
|
||||
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
|
||||
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal", arguments: { cmd: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: bigToolOutput, isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Raw-window tool result carries both the [from ...] provenance label
|
||||
// and the actual bytes (not just the 500-char compact summary).
|
||||
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
|
||||
// Confirm we kept enough bytes to exceed the compact-summary cap.
|
||||
const toolResultIdx = flat.indexOf("Tool result [from terminal");
|
||||
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
|
||||
const toolResultChunk = flat.slice(toolResultIdx);
|
||||
assert.ok(
|
||||
toolResultChunk.length > 600,
|
||||
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
|
||||
// Regression: if the raw window starts mid-tool-interaction, the
|
||||
// preceding assistant tool_call message may be outside the 6-item
|
||||
// slice. Without the call's name/args inline on the result line, the
|
||||
// AI sees opaque bytes and "use that output" becomes ambiguous.
|
||||
const messages: ChatMessage[] = [
|
||||
// Early filler to push the tool_call off the raw window
|
||||
message("u0", "user", "prior chatter"),
|
||||
message("a0", "assistant", "prior reply"),
|
||||
message("u1", "user", "cat /etc/hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [
|
||||
{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } },
|
||||
],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [
|
||||
{ toolCallId: "call1", content: "127.0.0.1 localhost", isError: false },
|
||||
],
|
||||
}),
|
||||
message("u2", "user", "use that output"),
|
||||
message("a2", "assistant", "acknowledged"),
|
||||
message("u3", "user", "now do the same for /etc/resolv.conf"),
|
||||
];
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// The tool_result line must carry the originating tool_call's name and
|
||||
// args, so even if a1 was pushed out of the raw window, the result is
|
||||
// self-describing.
|
||||
assert.match(flat, /Tool result \[from terminal_exec/);
|
||||
assert.match(flat, /cat \/etc\/hosts/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
|
||||
// Regression target: codex review flagged that the compaction path
|
||||
// scanned messages.entries() over the full transcript. Build a very
|
||||
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
|
||||
// only messages within the recent user-turn window contribute
|
||||
// durable candidates.
|
||||
const messages: ChatMessage[] = [];
|
||||
// An ancient high-priority constraint that MUST be aged out.
|
||||
messages.push(message("old-important", "user", "不要提交 old-marker-xyz"));
|
||||
messages.push(message("old-ack", "assistant", "收到"));
|
||||
|
||||
// 300 filler turns between the ancient constraint and the window —
|
||||
// well past MAX_DURABLE_SCAN_TURNS (100).
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
messages.push(
|
||||
message(`u${i}`, "user", `filler user message ${i}`),
|
||||
message(`a${i}`, "assistant", `filler assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
// A recent constraint that should survive.
|
||||
messages.push(message("recent-important", "user", "不要提交 recent-marker-abc"));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
messages.push(
|
||||
message(`t${i}`, "user", `tail user message ${i}`),
|
||||
message(`ta${i}`, "assistant", `tail assistant message ${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Recent priority-2 constraint is kept.
|
||||
assert.match(flat, /recent-marker-abc/);
|
||||
// Ancient one past the scan window is dropped — proof the bound holds.
|
||||
assert.doesNotMatch(flat, /old-marker-xyz/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
|
||||
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
|
||||
// the raw message array. In a tool-heavy chat, each user turn can
|
||||
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
|
||||
// results + follow-up assistant), so 200 messages might be only
|
||||
// ~40 user turns. An instruction like "不要提交" from turn 5 would
|
||||
// fall out of the scan before the turn count justified aging it out.
|
||||
//
|
||||
// Now the bound is MAX_DURABLE_SCAN_TURNS=100 user turns. Build a
|
||||
// chat with only 30 user turns but many messages per turn — the
|
||||
// early constraint must still survive.
|
||||
const messages: ChatMessage[] = [];
|
||||
messages.push(message("early-important", "user", "不要提交 EARLY_CONSTRAINT_MARKER"));
|
||||
messages.push(message("early-ack", "assistant", "收到"));
|
||||
|
||||
// 35 additional turns, each with 6 messages (bloats the total
|
||||
// message count to >200 without exceeding 100 user turns).
|
||||
for (let turn = 1; turn < 36; turn += 1) {
|
||||
messages.push(message(`u${turn}`, "user", `turn ${turn} request`));
|
||||
messages.push(message(`a${turn}-plan`, "assistant", "let me check", {
|
||||
toolCalls: [
|
||||
{ id: `c${turn}a`, name: "terminal_exec", arguments: { cmd: "echo a" } },
|
||||
{ id: `c${turn}b`, name: "terminal_exec", arguments: { cmd: "echo b" } },
|
||||
{ id: `c${turn}c`, name: "terminal_exec", arguments: { cmd: "echo c" } },
|
||||
],
|
||||
}));
|
||||
messages.push(message(`t${turn}a`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}a`, content: `result a of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}b`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}b`, content: `result b of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`t${turn}c`, "tool", "", {
|
||||
toolResults: [{ toolCallId: `c${turn}c`, content: `result c of turn ${turn}`, isError: false }],
|
||||
}));
|
||||
messages.push(message(`a${turn}-done`, "assistant", `turn ${turn} done`));
|
||||
}
|
||||
|
||||
// Sanity: the message count is over 200 even though user turns are 30.
|
||||
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Under the old raw-count bound, the early constraint would age out;
|
||||
// under the turn-based bound it survives.
|
||||
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
|
||||
// Regression: isSubstantiveAssistantMessage previously required length
|
||||
// >= 40 OR a small English keyword match OR a numbered list. Short
|
||||
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
|
||||
// satisfied none of those and were silently dropped. After a stale-
|
||||
// session recovery, "do what you suggested earlier" would then replay
|
||||
// only the user's question without the assistant's actual decision.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "which client should I use"),
|
||||
message("a1", "assistant", "Use ssh2"),
|
||||
message("u2", "user", "output language?"),
|
||||
message("a2", "assistant", "中文输出"),
|
||||
message("u3", "user", "merge or rebase?"),
|
||||
message("a3", "assistant", "rebase instead"),
|
||||
];
|
||||
|
||||
// Pad so u1..a3 fall outside the recent raw window (last 6 items) and
|
||||
// must flow through the durable-assistant compact pass.
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.match(flat, /Use ssh2/);
|
||||
assert.match(flat, /中文输出/);
|
||||
assert.match(flat, /rebase instead/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
|
||||
// Sanity: removing the length/keyword gate must not let assistant
|
||||
// filler leak into the compact durable-assistant section.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "prompt 1"),
|
||||
message("a1", "assistant", "ack"),
|
||||
message("u2", "user", "prompt 2"),
|
||||
message("a2", "assistant", "明白"),
|
||||
message("u3", "user", "prompt 3"),
|
||||
message("a3", "assistant", "got it"),
|
||||
];
|
||||
|
||||
for (let index = 4; index <= 13; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `more filler ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
assert.doesNotMatch(flat, /Assistant context: ack\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: got it\b/);
|
||||
assert.doesNotMatch(flat, /Assistant context: 明白/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
|
||||
// Regression: the raw-window fix covered the last 6 items, but once
|
||||
// a tool result fell into the compact section (summarizeToolMessage
|
||||
// path) the `[from <name>(<args>)]` provenance label was absent.
|
||||
// With multiple older tool outputs, all surfacing as identical
|
||||
// `Tool result (callN): ...`, follow-ups like "use the resolv.conf
|
||||
// output" have no way to map to the right call.
|
||||
const messages: ChatMessage[] = [
|
||||
// Two distinct tool interactions, both pushed well outside the
|
||||
// recent raw window by later turns.
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call-hosts", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool1", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-hosts", content: "127.0.0.1 localhost", isError: false }],
|
||||
}),
|
||||
message("u2", "user", "show resolv.conf"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call-resolv", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool2", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call-resolv", content: "nameserver 8.8.8.8", isError: false }],
|
||||
}),
|
||||
// Important user text so summarizeMessage picks these up via the
|
||||
// important-text branch; tool results themselves are always
|
||||
// summarized regardless of IMPORTANT_PATTERNS.
|
||||
message("u3", "user", "fallback plan"),
|
||||
];
|
||||
|
||||
// Filler to push the early tool results out of the 6-item raw window
|
||||
// and into the compact summary section (scanned = last 20).
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Both older tool results must now carry provenance labels so a
|
||||
// follow-up can disambiguate them.
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/hosts/);
|
||||
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
|
||||
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
|
||||
// Without skipping raw-window items, the same last-6 turns would be
|
||||
// summarized in the compact section AND appended verbatim in the raw
|
||||
// section — doubling the budget cost of important user turns / large
|
||||
// tool output and crowding out older durable context.
|
||||
//
|
||||
// Setup: enough filler upfront that u3 ends up OUTSIDE the raw window
|
||||
// (so it can be asserted absent from raw), then a distinctive "raw
|
||||
// only" marker that should appear only in the last-6 raw slice.
|
||||
const messages: ChatMessage[] = [];
|
||||
for (let index = 1; index <= 6; index += 1) {
|
||||
messages.push(
|
||||
message(`uf${index}`, "user", `filler user ${index}`),
|
||||
message(`af${index}`, "assistant", `filler assistant ${index}`),
|
||||
);
|
||||
}
|
||||
// These are the last 4 user/assistant messages — guaranteed to be in
|
||||
// the last-6 raw slice. The IMPORTANT markers below would ordinarily
|
||||
// also get summarized into the compact section, duplicating the cost.
|
||||
messages.push(
|
||||
message("u-rec1", "user", "commit now IMPORTANT_RAW_MARKER please"),
|
||||
message("a-rec1", "assistant", "", {
|
||||
toolCalls: [{ id: "c1", name: "git", arguments: { op: "commit" } }],
|
||||
}),
|
||||
message("tool-rec", "tool", "", {
|
||||
toolResults: [{ toolCallId: "c1", content: "committed abc123 RAW_TOOL_MARKER", isError: false }],
|
||||
}),
|
||||
message("u-rec2", "user", "now push"),
|
||||
);
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
assert.ok(compact, "expected a compact context message");
|
||||
|
||||
// Both markers belong to messages inside the raw window — they must
|
||||
// not be summarized into compact (which would double-bill them).
|
||||
assert.doesNotMatch(compact.content, /IMPORTANT_RAW_MARKER/);
|
||||
assert.doesNotMatch(compact.content, /RAW_TOOL_MARKER/);
|
||||
|
||||
// Raw section still carries them verbatim.
|
||||
const raw = result.filter((m) => !m.content.includes("[Compact prior Netcatty UI context]"));
|
||||
const rawFlat = raw.map((m) => m.content).join("\n");
|
||||
assert.match(rawFlat, /IMPORTANT_RAW_MARKER/);
|
||||
assert.match(rawFlat, /RAW_TOOL_MARKER/);
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
|
||||
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
|
||||
// assistant tool_call with the same id overwrite the older one. An
|
||||
// older tool_result in the replay history would then be annotated
|
||||
// with the wrong command (e.g. a /etc/hosts result labeled as
|
||||
// /etc/resolv.conf). Now each tool_result is indexed by its own
|
||||
// messageId + toolCallId and resolved to the most recent preceding
|
||||
// call with that id.
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "show hosts"),
|
||||
message("a1", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
|
||||
}),
|
||||
message("tool-hosts", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "127.0.0.1 localhost HOSTS_BYTES", isError: false }],
|
||||
}),
|
||||
// A later assistant turn reuses the id "call1" for a different call.
|
||||
message("u2", "user", "show resolv"),
|
||||
message("a2", "assistant", "", {
|
||||
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
|
||||
}),
|
||||
message("tool-resolv", "tool", "", {
|
||||
toolResults: [{ toolCallId: "call1", content: "nameserver 8.8.8.8 RESOLV_BYTES", isError: false }],
|
||||
}),
|
||||
message("u3", "user", "ok"),
|
||||
];
|
||||
|
||||
// Pad so the first interaction lands in the compact summary pass.
|
||||
for (let index = 4; index <= 10; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", `filler user message ${index}`),
|
||||
message(`a${index}`, "assistant", `Ack ${index}`),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
const flat = result.map((m) => m.content).join("\n---\n");
|
||||
|
||||
// Each tool_result must be annotated with ITS OWN preceding call's
|
||||
// args — not whichever assistant tool_call happened to win the
|
||||
// last-write on the shared id.
|
||||
//
|
||||
// Extract the two Tool-result lines and match each to its expected
|
||||
// args. Use non-greedy .*? — the args JSON can contain parentheses.
|
||||
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
|
||||
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
|
||||
|
||||
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
|
||||
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
|
||||
});
|
||||
|
||||
test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
|
||||
const messages: ChatMessage[] = [
|
||||
message("u1", "user", "ok"),
|
||||
message(
|
||||
"a1",
|
||||
"assistant",
|
||||
"Plan: 1. Move parser setup into a dedicated hook. 2. Keep storage schema unchanged. 3. Add a regression test.",
|
||||
),
|
||||
];
|
||||
|
||||
for (let index = 2; index <= 7; index += 1) {
|
||||
messages.push(
|
||||
message(`u${index}`, "user", index % 2 === 0 ? "ok" : "continue"),
|
||||
message(`a${index}`, "assistant", "ack"),
|
||||
);
|
||||
}
|
||||
|
||||
const result = buildAcpHistoryMessages(messages);
|
||||
|
||||
assert.equal(result[0].role, "user");
|
||||
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);
|
||||
});
|
||||
438
components/ai/acpHistory.ts
Normal file
438
components/ai/acpHistory.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
|
||||
|
||||
type AcpHistoryMessage = { role: "user" | "assistant"; content: string };
|
||||
type RawHistoryMessage = AcpHistoryMessage & { sourceId: string };
|
||||
type DurableUserLine = {
|
||||
line: string;
|
||||
messageIndex: number;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
const MAX_RECENT_RAW_MESSAGES = 6;
|
||||
const MAX_MESSAGES_TO_SCAN = 20;
|
||||
// Bound the scan by user turns, not raw message count: a tool-heavy ACP
|
||||
// chat can produce 5+ messages per logical turn (user + assistant +
|
||||
// several tool_results + follow-up assistant), so a plain
|
||||
// message-count cap ages out early constraints much sooner than intended.
|
||||
const MAX_DURABLE_SCAN_TURNS = 100;
|
||||
const MAX_COMPACT_CONTEXT_CHARS = 3000;
|
||||
const MAX_RAW_MESSAGE_CHARS = 2000;
|
||||
const MAX_TOOL_SUMMARY_CHARS = 500;
|
||||
const MAX_DURABLE_USER_CONTEXT_CHARS = 1400;
|
||||
const MAX_DURABLE_ASSISTANT_CONTEXT_CHARS = 900;
|
||||
const MAX_RECENT_SUMMARY_CONTEXT_CHARS = 1200;
|
||||
const MAX_DURABLE_USER_MESSAGE_CHARS = 280;
|
||||
const MAX_DURABLE_ASSISTANT_MESSAGE_CHARS = 360;
|
||||
const MAX_TOOL_CALL_LABEL_CHARS = 200;
|
||||
|
||||
type ToolCallInfo = { name: string; arguments: unknown };
|
||||
|
||||
const IMPORTANT_PATTERNS = [
|
||||
/不要|别|不能|不允许|必须|希望|只|最小|先|暂时|fallback|pwsh|powershell|cmd\.exe|windows|mcp|skills|cli|commit|\bpr\b|打包|内存|历史|压缩|慢/i,
|
||||
/error|failed|failure|exit code|exception|cannot|unable|timeout|crash|fallback|commit|pull request|PR #\d+/i,
|
||||
];
|
||||
const DURABLE_CONSTRAINT_PATTERNS = [
|
||||
/\bdo not\b|\bdon't\b|\bkeep\b|\bpreserve\b|\bavoid\b|\bonly\b|\bunchanged\b|\blocal only\b|\bwithout\b|\bleave\b/i,
|
||||
/不要|别|保留|保持|维持|不改|别改|不要改|仅限本地/i,
|
||||
];
|
||||
const TRIVIAL_USER_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|yes|no|thanks|thank you|continue|继续|好的|收到|行|嗯|好|继续处理|继续吧|开始吧)[.!? ]*$/i,
|
||||
];
|
||||
const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
|
||||
/^(ok|okay|understood|got it|working|proceeding|ready|ack(?: \d+)?|收到|明白|继续处理|准备实现|开始处理|处理中)[.!? ]*$/i,
|
||||
];
|
||||
|
||||
function truncateText(value: string, maxChars: number): string {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isImportantText(value: string): boolean {
|
||||
return IMPORTANT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isDurableConstraintText(value: string): boolean {
|
||||
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function isTrivialUserMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
|
||||
// Don't blanket-drop short messages — short user turns are often
|
||||
// load-bearing constraints ("Use ssh2", "中文输出", "no logs", "more
|
||||
// verbose") that the IMPORTANT/DURABLE regexes can't realistically
|
||||
// enumerate. The trivial-phrase regex already catches actual filler
|
||||
// ("ok", "yes", "thanks", "继续").
|
||||
return TRIVIAL_USER_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableUserPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isSubstantiveAssistantMessage(value: string): boolean {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (!normalized) return false;
|
||||
// Mirror the user-side loosening: don't blanket-drop short assistant
|
||||
// messages just because they're under 40 chars or don't match the small
|
||||
// English keyword list. Short but load-bearing decisions ("Use ssh2",
|
||||
// "rebase instead", "中文输出") aren't realistically enumerable and
|
||||
// they're the exact things a later "do what you suggested" references.
|
||||
// TRIVIAL_ASSISTANT_MESSAGE_PATTERNS still catches the actual filler
|
||||
// ("ok", "ack", "got it", "明白").
|
||||
return !TRIVIAL_ASSISTANT_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
function getDurableAssistantPriority(value: string): number {
|
||||
const normalized = normalizeWhitespace(value);
|
||||
if (isImportantText(normalized)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function appendUniqueLine(
|
||||
target: string[],
|
||||
seen: Set<string>,
|
||||
line: string,
|
||||
maxSectionChars: number,
|
||||
sectionCharsRef: { value: number },
|
||||
): void {
|
||||
const normalized = normalizeWhitespace(line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = sectionCharsRef.value + normalized.length;
|
||||
if (nextChars > maxSectionChars) return;
|
||||
seen.add(normalized);
|
||||
target.push(normalized);
|
||||
sectionCharsRef.value = nextChars;
|
||||
}
|
||||
|
||||
function summarizeToolMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (!message.toolResults?.length) return [];
|
||||
return message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const content = normalizeWhitespace(result.content || "");
|
||||
// Same provenance problem as the raw-window path: once a tool result
|
||||
// lands in the compact section (older than the 6-item raw window),
|
||||
// its paired assistant tool_call is almost always gone. Without the
|
||||
// call label, multiple older results collapse into indistinguishable
|
||||
// "Tool result (callN): ..." lines and follow-ups like "use the
|
||||
// resolv.conf output" can't be resolved. Inline the name+args here
|
||||
// the same way toRawHistoryMessage does.
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): string[] {
|
||||
if (message.role === "system") return [];
|
||||
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
|
||||
|
||||
const lines: string[] = [];
|
||||
if (message.content && isImportantText(message.content)) {
|
||||
const label = message.role === "user" ? "User" : "Assistant";
|
||||
lines.push(`${label}: ${truncateText(normalizeWhitespace(message.content), MAX_TOOL_SUMMARY_CHARS)}`);
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
const args = JSON.stringify(toolCall.arguments ?? {});
|
||||
const summary = `Tool call: ${toolCall.name}(${truncateText(args, 220)})`;
|
||||
if (isImportantText(summary)) lines.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function summarizeDurableUserMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "user" || !message.content) return null;
|
||||
if (isTrivialUserMessage(message.content)) return null;
|
||||
return `User request: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
|
||||
if (message.role !== "assistant" || !message.content) return null;
|
||||
if (!isSubstantiveAssistantMessage(message.content)) return null;
|
||||
return `Assistant context: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_ASSISTANT_MESSAGE_CHARS)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-tool-result provenance index. Keys are
|
||||
* `${toolResultMessageId}:${toolCallId}` rather than the bare toolCall.id
|
||||
* so that provider-reused ids (e.g. "call1" across unrelated turns) don't
|
||||
* cause later calls to overwrite older ones in the lookup — each
|
||||
* tool_result resolves to the most recent assistant tool_call that
|
||||
* preceded it with matching id, which preserves historical correctness
|
||||
* when rebuilding older compact summaries.
|
||||
*/
|
||||
function buildToolCallIndex(messages: ChatMessage[]): Map<string, ToolCallInfo> {
|
||||
const provenance = new Map<string, ToolCallInfo>();
|
||||
// Rolling map of the latest tool_call seen (by id) up to the current
|
||||
// point in the message stream.
|
||||
const latestByCallId = new Map<string, ToolCallInfo>();
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
if (!toolCall.id) continue;
|
||||
latestByCallId.set(toolCall.id, { name: toolCall.name, arguments: toolCall.arguments });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
for (const result of message.toolResults) {
|
||||
const info = latestByCallId.get(result.toolCallId);
|
||||
if (info) {
|
||||
provenance.set(`${message.id}:${result.toolCallId}`, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function lookupToolCallInfo(
|
||||
index: Map<string, ToolCallInfo>,
|
||||
toolMessageId: string,
|
||||
toolCallId: string,
|
||||
): ToolCallInfo | undefined {
|
||||
return index.get(`${toolMessageId}:${toolCallId}`);
|
||||
}
|
||||
|
||||
function toRawHistoryMessage(
|
||||
message: ChatMessage,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): RawHistoryMessage[] {
|
||||
if (message.role === "user") {
|
||||
return message.content
|
||||
? [{ sourceId: message.id, role: "user", content: truncateText(message.content, MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
return parts.length
|
||||
? [{ sourceId: message.id, role: "assistant", content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS) }]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (message.role === "tool" && message.toolResults?.length) {
|
||||
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
|
||||
// per message, ~2000). Without this, follow-up turns after stale-session
|
||||
// recovery would only see the 500-char compact summary in
|
||||
// summarizeToolMessage, losing the actual bytes the user might reference
|
||||
// ("use that output", "what did cat show?"). ACP only supports user/
|
||||
// assistant roles, so we flatten to "assistant" — the tool results were
|
||||
// produced during the assistant's turn.
|
||||
//
|
||||
// Inline the originating tool_call's name+args. Tool calls and their
|
||||
// results live in separate messages; if the last six raw items start
|
||||
// in the middle of a tool interaction, the preceding assistant tool
|
||||
// call can be outside the window. Without the call label the result
|
||||
// is opaque bytes and "use that output" becomes ambiguous.
|
||||
const parts = message.toolResults.map((result) => {
|
||||
const prefix = result.isError ? "Tool error" : "Tool result";
|
||||
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
|
||||
const callLabel = callInfo
|
||||
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
|
||||
: "";
|
||||
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
|
||||
});
|
||||
return [{
|
||||
sourceId: message.id,
|
||||
role: "assistant",
|
||||
content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS),
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildCompactContext(
|
||||
messages: ChatMessage[],
|
||||
durableScanStart: number,
|
||||
recentRawSourceIds: Set<string>,
|
||||
toolCallIndex: Map<string, ToolCallInfo>,
|
||||
): AcpHistoryMessage[] {
|
||||
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
|
||||
const summaryLines: string[] = [];
|
||||
const durableUserCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableUserLines: DurableUserLine[] = [];
|
||||
const durableAssistantCandidates: DurableUserLine[] = [];
|
||||
const selectedDurableAssistantLines: DurableUserLine[] = [];
|
||||
const seen = new Set<string>();
|
||||
const durableChars = { value: 0 };
|
||||
const durableAssistantChars = { value: 0 };
|
||||
const summaryChars = { value: 0 };
|
||||
|
||||
for (let messageIndex = durableScanStart; messageIndex < messages.length; messageIndex += 1) {
|
||||
const message = messages[messageIndex];
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
const durableUserLine = summarizeDurableUserMessage(message);
|
||||
if (durableUserLine) {
|
||||
durableUserCandidates.push({
|
||||
line: durableUserLine,
|
||||
messageIndex,
|
||||
priority: getDurableUserPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
const durableAssistantLine = summarizeDurableAssistantMessage(message);
|
||||
if (durableAssistantLine) {
|
||||
durableAssistantCandidates.push({
|
||||
line: durableAssistantLine,
|
||||
messageIndex,
|
||||
priority: getDurableAssistantPriority(message.content || ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
durableUserCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_USER_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableUserLines.push(candidate);
|
||||
durableChars.value = nextChars;
|
||||
});
|
||||
|
||||
durableAssistantCandidates
|
||||
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
|
||||
.forEach((candidate) => {
|
||||
const normalized = normalizeWhitespace(candidate.line);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
const nextChars = durableAssistantChars.value + normalized.length;
|
||||
if (nextChars > MAX_DURABLE_ASSISTANT_CONTEXT_CHARS) return;
|
||||
seen.add(normalized);
|
||||
selectedDurableAssistantLines.push(candidate);
|
||||
durableAssistantChars.value = nextChars;
|
||||
});
|
||||
|
||||
const durableUserLines = selectedDurableUserLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
const durableAssistantLines = selectedDurableAssistantLines
|
||||
.sort((left, right) => left.messageIndex - right.messageIndex)
|
||||
.map((candidate) => candidate.line);
|
||||
|
||||
for (const line of [...durableUserLines, ...durableAssistantLines]) {
|
||||
seen.add(normalizeWhitespace(line));
|
||||
}
|
||||
|
||||
// Skip messages that are already appended verbatim in the raw window —
|
||||
// otherwise the same last-6 turns get summarized here AND re-sent as
|
||||
// raw, doubling the budget cost of important user turns / large tool
|
||||
// output and crowding out older durable context the replay is meant
|
||||
// to preserve. Matches the recentRawSourceIds skip in the durable pass.
|
||||
for (const message of scanned) {
|
||||
if (recentRawSourceIds.has(message.id)) continue;
|
||||
for (const line of summarizeMessage(message, toolCallIndex)) {
|
||||
appendUniqueLine(summaryLines, seen, line, MAX_RECENT_SUMMARY_CONTEXT_CHARS, summaryChars);
|
||||
}
|
||||
}
|
||||
|
||||
if (!durableUserLines.length && !durableAssistantLines.length && !summaryLines.length) return [];
|
||||
|
||||
const contentLines = [
|
||||
"[Compact prior Netcatty UI context]",
|
||||
"The external ACP agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
|
||||
];
|
||||
if (durableUserLines.length) {
|
||||
contentLines.push("Earlier user requests that may still apply:");
|
||||
contentLines.push(...durableUserLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (durableAssistantLines.length) {
|
||||
contentLines.push("Earlier assistant context that may still matter:");
|
||||
contentLines.push(...durableAssistantLines.map((line) => `- ${line}`));
|
||||
}
|
||||
if (summaryLines.length) {
|
||||
contentLines.push("Recent noteworthy context:");
|
||||
contentLines.push(...summaryLines.map((line) => `- ${line}`));
|
||||
}
|
||||
|
||||
return [{
|
||||
role: "user",
|
||||
content: truncateText(
|
||||
contentLines.join("\n"),
|
||||
MAX_COMPACT_CONTEXT_CHARS,
|
||||
),
|
||||
}];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first message to include in the scan window,
|
||||
* bounded by MAX_DURABLE_SCAN_TURNS user turns (not raw message count).
|
||||
* Walking backwards stops at the target turn count, so the cost is
|
||||
* bounded even when the transcript is huge.
|
||||
*/
|
||||
function computeDurableScanStart(messages: ChatMessage[]): number {
|
||||
let userTurns = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role === "user") {
|
||||
userTurns += 1;
|
||||
if (userTurns >= MAX_DURABLE_SCAN_TURNS) return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMessage[] {
|
||||
// Compute the scan start once, then do all subsequent work over the
|
||||
// already-sliced tail. This avoids O(N) walks over the whole transcript
|
||||
// on every send — previously buildToolCallIndex + the flatMap-to-take-
|
||||
// last-6 raw history both traversed every message in the chat.
|
||||
const durableScanStart = computeDurableScanStart(messages);
|
||||
const scannedTail = messages.slice(durableScanStart);
|
||||
|
||||
// The tool-call provenance index only needs entries for tool_results
|
||||
// that might appear in our output. Building from the scanned tail is
|
||||
// correct for any tool_result whose paired assistant tool_call is
|
||||
// also within the window, which covers >99% of realistic patterns
|
||||
// (tool_calls and tool_results are always adjacent or near-adjacent).
|
||||
// If an ancient tool_call's result stays within the window while the
|
||||
// call itself is outside, that single result loses its [from X(Y)]
|
||||
// label — an acceptable trade for eliminating the per-send O(N) walk.
|
||||
const toolCallIndex = buildToolCallIndex(scannedTail);
|
||||
|
||||
const rawHistory = scannedTail
|
||||
.flatMap((message) => toRawHistoryMessage(message, toolCallIndex))
|
||||
.slice(-MAX_RECENT_RAW_MESSAGES);
|
||||
const compactContext = buildCompactContext(
|
||||
messages,
|
||||
durableScanStart,
|
||||
new Set(rawHistory.map((message) => message.sourceId)),
|
||||
toolCallIndex,
|
||||
);
|
||||
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
|
||||
|
||||
return [...compactContext, ...recentRaw];
|
||||
}
|
||||
|
||||
export function buildAcpHistoryMessagesForBridge(
|
||||
messages: ChatMessage[],
|
||||
_existingSessionId?: string | null,
|
||||
): AcpHistoryMessage[] | undefined {
|
||||
// The main process bridge only consumes this payload during stale-session
|
||||
// fallback replay, so keep it available even when a session id exists.
|
||||
const historyMessages = buildAcpHistoryMessages(messages);
|
||||
return historyMessages.length ? historyMessages : undefined;
|
||||
}
|
||||
@@ -355,14 +355,13 @@ export function useAIChatStreaming({
|
||||
err: unknown,
|
||||
) => {
|
||||
if (abortSignal.aborted) return;
|
||||
let errorStr: string;
|
||||
if (err instanceof Error) errorStr = err.message;
|
||||
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
|
||||
else if (typeof err === 'string') errorStr = err;
|
||||
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
console.error('[AIChatSidePanel] Stream error (full):', err);
|
||||
// Pass the raw error to classifyError so it can inspect structured
|
||||
// fields (statusCode, responseBody) from APICallError and friends;
|
||||
// string-coercing here would strip the metadata we need to detect
|
||||
// 413 / HTML-error-page / parse-failure scenarios.
|
||||
const errorInfo = classifyError(err);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
@@ -560,11 +559,10 @@ export function useAIChatStreaming({
|
||||
id: generateId(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
errorInfo: classifyError(
|
||||
typedChunk.error instanceof Error ? typedChunk.error.message
|
||||
: typeof typedChunk.error === 'string' ? typedChunk.error
|
||||
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
|
||||
),
|
||||
// Pass the raw error so classifyError can detect 413 / HTML /
|
||||
// schema-parse scenarios via structured fields (statusCode,
|
||||
// responseBody) instead of lossy string conversion.
|
||||
errorInfo: classifyError(typedChunk.error),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -815,6 +815,20 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.clearWipesScrollback")}
|
||||
description={t("settings.terminal.behavior.clearWipesScrollback.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.clearWipesScrollback ?? true} onChange={(v) => updateTerminalSetting("clearWipesScrollback", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.preserveSelectionOnInput")}
|
||||
description={t("settings.terminal.behavior.preserveSelectionOnInput.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
|
||||
@@ -114,6 +114,10 @@ export type CreateXTermRuntimeContext = {
|
||||
onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
|
||||
// Autocomplete input handler — called on every character input
|
||||
onAutocompleteInput?: (data: string) => void;
|
||||
|
||||
// Set to true while we're programmatically restoring a selection so that
|
||||
// copy-on-select listeners can suppress redundant clipboard writes.
|
||||
isRestoringSelectionRef?: RefObject<boolean>;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -419,6 +423,38 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true;
|
||||
}
|
||||
|
||||
// Preserve mouse selection across keystrokes when enabled. xterm.js
|
||||
// unconditionally clears the selection on user input
|
||||
// (SelectionService.ts: coreService.onUserInput → clearSelection).
|
||||
// Capture the selection here, then re-apply it after xterm has
|
||||
// processed the key + cleared. The microtask runs after both
|
||||
// synchronous listeners, so by then either the selection is gone (and
|
||||
// we restore) or it's still there (we no-op).
|
||||
if (
|
||||
ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
|
||||
term.hasSelection()
|
||||
) {
|
||||
const sel = term.getSelectionPosition();
|
||||
if (sel) {
|
||||
const length =
|
||||
(sel.end.y - sel.start.y) * term.cols + (sel.end.x - sel.start.x);
|
||||
const savedStartX = sel.start.x;
|
||||
const savedStartY = sel.start.y;
|
||||
queueMicrotask(() => {
|
||||
if (term.hasSelection()) return;
|
||||
// Bail out if scrollback trim invalidated the row index.
|
||||
if (savedStartY >= term.buffer.active.length) return;
|
||||
const restoreFlag = ctx.isRestoringSelectionRef;
|
||||
if (restoreFlag) restoreFlag.current = true;
|
||||
try {
|
||||
term.select(savedStartX, savedStartY, length);
|
||||
} finally {
|
||||
if (restoreFlag) restoreFlag.current = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Autocomplete key handler (must be checked before other handlers)
|
||||
if (ctx.onAutocompleteKeyEvent) {
|
||||
const consumed = ctx.onAutocompleteKeyEvent(e);
|
||||
@@ -664,7 +700,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (!isEraseScrollbackSequence(params)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
|
||||
// Honor it unless the user opts into the legacy "preserve history" behavior.
|
||||
const wipeAllowed = ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true;
|
||||
return !wipeAllowed;
|
||||
});
|
||||
|
||||
// Register OSC 7 handler using xterm.js parser
|
||||
|
||||
@@ -497,6 +497,18 @@ export interface TerminalSettings {
|
||||
// Paste
|
||||
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
|
||||
|
||||
// Shell `clear` command behavior — controls whether CSI 3 J (erase scrollback)
|
||||
// from the shell is honored. Default true matches POSIX/ncurses since 2013:
|
||||
// `clear` clears both visible screen and scrollback. Disable to keep history
|
||||
// across `clear` (matches iTerm2 default and pre-2013 behavior).
|
||||
clearWipesScrollback: boolean;
|
||||
|
||||
// When true, typing on the keyboard does NOT clear an existing mouse
|
||||
// selection. Lets the user select text, type a command prefix (e.g. `sz `),
|
||||
// and then paste the still-live selection. xterm.js's default is to clear
|
||||
// on input; this opt-in toggle restores the selection right after.
|
||||
preserveSelectionOnInput: boolean;
|
||||
|
||||
// Clipboard
|
||||
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
|
||||
|
||||
@@ -625,6 +637,8 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
showServerStats: true, // Show server stats by default
|
||||
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
|
||||
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
|
||||
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
autocompleteEnabled: true, // Autocomplete enabled by default
|
||||
|
||||
@@ -4,8 +4,10 @@ const path = require("node:path");
|
||||
const USER_SKILLS_DIR_NAME = "Skills";
|
||||
const USER_SKILLS_README_NAME = "README.txt";
|
||||
const MAX_SKILL_BYTES = 24 * 1024;
|
||||
const MAX_DESCRIPTION_LENGTH = 280;
|
||||
const MAX_DESCRIPTION_LENGTH = 500;
|
||||
const MAX_INDEX_SKILLS = 8;
|
||||
const MAX_INDEX_DESCRIPTION_CHARS = 160;
|
||||
const MAX_INDEX_LINE_CHARS = 1400;
|
||||
const MAX_EXPLICIT_SKILLS = 4;
|
||||
const MAX_MATCHED_SKILLS = 2;
|
||||
const MAX_MATCHED_SKILL_CHARS = 6000;
|
||||
@@ -67,6 +69,12 @@ function escapeRegExp(value) {
|
||||
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function truncateInlineText(value, maxChars) {
|
||||
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
||||
if (normalized.length <= maxChars) return normalized;
|
||||
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function formatSkillReadWarning(error) {
|
||||
const code = typeof error?.code === "string" ? error.code : null;
|
||||
const message = typeof error?.message === "string" ? error.message : String(error || "Unknown error");
|
||||
@@ -354,11 +362,22 @@ async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs =
|
||||
}
|
||||
|
||||
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
|
||||
const remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
|
||||
let remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
|
||||
const indexEntries = [];
|
||||
let indexChars = 0;
|
||||
|
||||
const indexLine = indexSkills
|
||||
.map((skill) => `${skill.name}: ${skill.description}`)
|
||||
.join("; ");
|
||||
for (const skill of indexSkills) {
|
||||
const entry = `${skill.name}: ${truncateInlineText(skill.description, MAX_INDEX_DESCRIPTION_CHARS)}`;
|
||||
const separatorChars = indexEntries.length > 0 ? 2 : 0;
|
||||
if (indexChars + separatorChars + entry.length > MAX_INDEX_LINE_CHARS) {
|
||||
remainingCount += indexSkills.length - indexEntries.length;
|
||||
break;
|
||||
}
|
||||
indexEntries.push(entry);
|
||||
indexChars += separatorChars + entry.length;
|
||||
}
|
||||
|
||||
const indexLine = indexEntries.join("; ");
|
||||
|
||||
const orderedExplicitSlugs = [];
|
||||
const seenExplicitSlugs = new Set();
|
||||
|
||||
@@ -99,6 +99,69 @@ test("keeps every explicitly selected skill in the built context", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("uses longer skill descriptions for routing matches without injecting the full index text", async () => {
|
||||
const longDescription = [
|
||||
"Use when the user needs a detailed workflow for operating Netcatty through ACP skills and CLI.",
|
||||
"Includes platform launcher guidance, scoped command execution, recovery behavior, and constraints.",
|
||||
"This intentionally exceeds the older short description budget so routing has enough signal.",
|
||||
"It also names edge cases such as unavailable optional shells, strict chat-session scoping, and fallback-only history replay so the agent can choose the skill without reading the whole body first.",
|
||||
].join(" ");
|
||||
|
||||
assert.ok(longDescription.length > 320);
|
||||
|
||||
await withUserSkills(
|
||||
[
|
||||
{
|
||||
directoryName: "Detailed Router",
|
||||
name: "Detailed Router",
|
||||
description: longDescription,
|
||||
body: "Detailed router body",
|
||||
},
|
||||
],
|
||||
async (electronApp) => {
|
||||
const status = await scanUserSkills(electronApp);
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"Need fallback-only history replay guidance for ACP recovery.",
|
||||
[],
|
||||
);
|
||||
|
||||
assert.equal(status.readyCount, 1);
|
||||
assert.equal(status.warningCount, 0);
|
||||
assert.equal(result.context.includes("### Detailed Router"), true);
|
||||
assert.equal(result.context.includes("Detailed router body"), true);
|
||||
assert.equal(result.context.includes(longDescription), false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("caps the injected available-skills index when descriptions are very long", async () => {
|
||||
const longDescription = "signal ".repeat(65);
|
||||
|
||||
await withUserSkills(
|
||||
Array.from({ length: 8 }, (_, index) => ({
|
||||
directoryName: `Skill ${index + 1}`,
|
||||
name: `Skill ${index + 1}`,
|
||||
description: `${longDescription}${index + 1}`,
|
||||
body: `Body ${index + 1}`,
|
||||
})),
|
||||
async (electronApp) => {
|
||||
const result = await buildUserSkillsContext(
|
||||
electronApp,
|
||||
"plain prompt",
|
||||
[],
|
||||
);
|
||||
|
||||
const availableLine = result.context
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("Available user skills: "));
|
||||
|
||||
assert.ok(availableLine, "expected available-skills index line");
|
||||
assert.ok(availableLine.length < 1800, `expected capped index line, got ${availableLine.length}`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves an unavailable explicit selection in the built context", async () => {
|
||||
await withUserSkills(
|
||||
[
|
||||
|
||||
@@ -2317,6 +2317,14 @@ function registerHandlers(ipcMain) {
|
||||
try {
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
// Capture whether the prior run was already cancelled (via the
|
||||
// cancel IPC) BEFORE we set the flag ourselves — the cancel IPC
|
||||
// contract explicitly preserves the provider session so the
|
||||
// next prompt can continue in the same conversation. Tearing
|
||||
// down the provider here would silently break that contract in
|
||||
// the "click Stop, then immediately send next prompt" flow,
|
||||
// discarding the recovered ACP session.
|
||||
const alreadyCancelledViaIpc = existingRun.cancelRequested;
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
@@ -2324,7 +2332,15 @@ function registerHandlers(ipcMain) {
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
// Only tear down the provider for true interrupt-and-restart
|
||||
// flows (user typed a new prompt while the old one was still
|
||||
// streaming, no explicit cancel). When we do skip cleanup here,
|
||||
// the reuse/reset logic below still handles auth/MCP/permission
|
||||
// changes correctly — the provider is preserved only when
|
||||
// nothing else would require rebuilding it.
|
||||
if (!alreadyCancelledViaIpc) {
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
|
||||
@@ -2476,9 +2492,48 @@ function registerHandlers(ipcMain) {
|
||||
providerEntry.mcpFingerprint === mcpSnapshot.fingerprint &&
|
||||
providerEntry.permissionMode === currentPermissionMode,
|
||||
);
|
||||
const shouldResetProviderForHistoryReplay = Boolean(
|
||||
shouldReuseProvider &&
|
||||
providerEntry?.historyReplayFallback &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
);
|
||||
|
||||
if (!shouldReuseProvider) {
|
||||
const resumeSessionId = providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
|
||||
if (!shouldReuseProvider || shouldResetProviderForHistoryReplay) {
|
||||
const resumeSessionId = shouldResetProviderForHistoryReplay
|
||||
? undefined
|
||||
: providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
|
||||
// Preserve the replay-fallback flag across any recreation where
|
||||
// history recovery is still pending, not just the reset-for-replay
|
||||
// path. Otherwise a provider recreation driven by an orthogonal
|
||||
// change (permission mode / MCP scope / auth fingerprint) between
|
||||
// a still-empty recovered turn and its retry would drop the flag
|
||||
// and lose the recovered conversation on the next turn.
|
||||
//
|
||||
// Also hedge whenever we're spawning a brand-new provider process
|
||||
// that's being told to resume an existing session id (the common
|
||||
// app-restart / reconnect flow — #753). Some ACP agents (Copilot
|
||||
// CLI, some Codex builds) silently spin up a fresh session
|
||||
// instead of erroring with "session not found", so the catch-
|
||||
// block fallback below never fires and the agent ends up with
|
||||
// zero prior context. Scheduling a compact replay on the first
|
||||
// turn guarantees the agent sees durable constraints and the
|
||||
// last few raw turns even when session/load is effectively a
|
||||
// no-op. After the first successful streamed turn the flag
|
||||
// clears (post-stream hook), so steady-state cost stays at
|
||||
// just the latest prompt.
|
||||
const preserveHistoryReplayFallback =
|
||||
shouldResetProviderForHistoryReplay ||
|
||||
Boolean(
|
||||
providerEntry?.historyReplayFallback &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
) ||
|
||||
Boolean(
|
||||
resumeSessionId &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||
@@ -2555,7 +2610,7 @@ function registerHandlers(ipcMain) {
|
||||
authFingerprint,
|
||||
mcpFingerprint: mcpSnapshot.fingerprint,
|
||||
permissionMode: currentPermissionMode,
|
||||
historyReplayFallback: false,
|
||||
historyReplayFallback: preserveHistoryReplayFallback,
|
||||
};
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
}
|
||||
@@ -2726,14 +2781,17 @@ function registerHandlers(ipcMain) {
|
||||
role: "user",
|
||||
content: buildMessageContent(contextualPrompt, images),
|
||||
};
|
||||
const shouldReplayHistory = Boolean(
|
||||
providerEntry.historyReplayFallback &&
|
||||
Array.isArray(historyMessages) &&
|
||||
historyMessages.length > 0,
|
||||
);
|
||||
|
||||
const result = streamText({
|
||||
model: modelInstance,
|
||||
messages: providerEntry.historyReplayFallback
|
||||
messages: shouldReplayHistory
|
||||
? [
|
||||
...(Array.isArray(historyMessages)
|
||||
? historyMessages.map((msg) => ({ role: msg.role, content: msg.content }))
|
||||
: []),
|
||||
...historyMessages.map((msg) => ({ role: msg.role, content: msg.content })),
|
||||
latestPromptMessage,
|
||||
]
|
||||
: [latestPromptMessage],
|
||||
@@ -2819,6 +2877,21 @@ function registerHandlers(ipcMain) {
|
||||
: "Agent returned an empty response.",
|
||||
});
|
||||
} else {
|
||||
// Clear replay fallback when the recovered turn either streamed
|
||||
// content OR was user-aborted. The empty-but-not-aborted case is
|
||||
// handled in the if-branch above and intentionally keeps the flag
|
||||
// so a follow-up retry can re-replay onto a fresh session.
|
||||
//
|
||||
// Why also clear on abort: if the user actively cancelled, the
|
||||
// freshly recovered ACP session has whatever state was built up so
|
||||
// far. Leaving the flag set would make the next turn trigger
|
||||
// shouldResetProviderForHistoryReplay, which discards the recovered
|
||||
// session (resumeSessionId is forced to undefined in that path) and
|
||||
// re-spends tokens on another compact replay. That breaks the
|
||||
// cancel-preserves-session contract for users who stop early.
|
||||
if (shouldReplayHistory) {
|
||||
providerEntry.historyReplayFallback = false;
|
||||
}
|
||||
debugMcpLog("ACP stream done", { requestId, chatSessionId, hasContent });
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
||||
return { ok: true };
|
||||
@@ -2871,6 +2944,18 @@ function registerHandlers(ipcMain) {
|
||||
if (activeRun && activeRun.requestId === effectiveRequestId) {
|
||||
activeRun.cancelRequested = true;
|
||||
}
|
||||
// Synchronously clear historyReplayFallback on the preserved provider
|
||||
// entry. Without this, a user pressing Stop and immediately sending
|
||||
// the next prompt can have their new request enter the stream
|
||||
// handler before the aborted run's post-stream clearing code runs.
|
||||
// The new turn would then see historyReplayFallback=true, trigger
|
||||
// shouldResetProviderForHistoryReplay, and recreate the provider
|
||||
// without the recovered existingSessionId — discarding the very
|
||||
// session the cancel contract promised to preserve.
|
||||
if (effectiveChatSessionId) {
|
||||
const preservedEntry = acpProviders.get(effectiveChatSessionId);
|
||||
if (preservedEntry) preservedEntry.historyReplayFallback = false;
|
||||
}
|
||||
const controller = acpActiveStreams.get(effectiveRequestId);
|
||||
let cancelled = false;
|
||||
if (controller) {
|
||||
|
||||
837
electron/bridges/aiBridge.test.cjs
Normal file
837
electron/bridges/aiBridge.test.cjs
Normal file
@@ -0,0 +1,837 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const Module = require("node:module");
|
||||
|
||||
function createIpcMainStub() {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyStreamResult() {
|
||||
return {
|
||||
fullStream: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadBridgeWithMocks(options = {}) {
|
||||
const streamCalls = [];
|
||||
const safeSendCalls = [];
|
||||
let providerCreationCount = 0;
|
||||
const providerCreationArgs = [];
|
||||
|
||||
const fallbackProvider = {
|
||||
tools: {},
|
||||
languageModel() {
|
||||
return { id: "fake-model" };
|
||||
},
|
||||
async initSession() {},
|
||||
getSessionId() {
|
||||
return "fresh-session";
|
||||
},
|
||||
cleanup() {},
|
||||
};
|
||||
|
||||
const mocks = {
|
||||
"./mcpServerBridge.cjs": {
|
||||
init() {},
|
||||
setMainWindowGetter() {},
|
||||
getOrCreateHost: async () => 4010,
|
||||
getScopedSessionIds: () => [],
|
||||
buildMcpServerConfig: () => ({ name: "netcatty-remote-hosts", type: "http", url: "http://127.0.0.1:4010" }),
|
||||
getPermissionMode: () =>
|
||||
typeof options.getPermissionMode === "function"
|
||||
? options.getPermissionMode()
|
||||
: "default",
|
||||
getMaxIterations: () => 20,
|
||||
setChatSessionCancelled() {},
|
||||
cancelPtyExecsForSession() {},
|
||||
clearPendingApprovals() {},
|
||||
cleanupScopedMetadata: async () => {},
|
||||
cleanup() {},
|
||||
},
|
||||
"../cli/discoveryPath.cjs": {
|
||||
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
|
||||
TOOL_CLI_DISCOVERY_ENV_VAR: "NETCATTY_TOOL_CLI_DISCOVERY_FILE",
|
||||
},
|
||||
"./ai/userSkills.cjs": {
|
||||
scanUserSkills: async () => ({ readyCount: 0, warningCount: 0, skills: [], warnings: [] }),
|
||||
buildUserSkillsContext: async () => ({ context: "", selectedSkills: [] }),
|
||||
toPublicUserSkillsStatus: (value) => value,
|
||||
},
|
||||
"./ai/shellUtils.cjs": {
|
||||
stripAnsi: (value) => value,
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
shouldUseShellForCommand: () => false,
|
||||
resolveCliFromPath: () => null,
|
||||
resolveClaudeAcpBinaryPath: () => null,
|
||||
getShellEnv: async () => ({}),
|
||||
invalidateShellEnvCache() {},
|
||||
serializeStreamChunk: (chunk) => chunk,
|
||||
toUnpackedAsarPath: (value) => value,
|
||||
},
|
||||
"./ai/codexHelpers.cjs": {
|
||||
codexLoginSessions: new Map(),
|
||||
resolveCodexAcpBinaryPath: () => null,
|
||||
appendCodexLoginOutput() {},
|
||||
toCodexLoginSessionResponse: () => ({}),
|
||||
getActiveCodexLoginSession: () => null,
|
||||
normalizeCodexIntegrationState: () => ({}),
|
||||
readCodexCustomProviderConfig: () => null,
|
||||
getCodexAuthOverride: () => ({}),
|
||||
getCodexCustomConfigPreflightError: () => null,
|
||||
extractCodexError: (err) => ({ message: err?.message || String(err) }),
|
||||
isCodexAuthError: () => false,
|
||||
getCodexAuthFingerprint: (...args) =>
|
||||
typeof options.getCodexAuthFingerprint === "function"
|
||||
? options.getCodexAuthFingerprint(...args)
|
||||
: "auth-fingerprint",
|
||||
getCodexMcpFingerprint: () => "mcp-fingerprint",
|
||||
invalidateCodexValidationCache() {},
|
||||
getCodexValidationCache: () => null,
|
||||
setCodexValidationCache() {},
|
||||
},
|
||||
"./ai/ptyExec.cjs": {
|
||||
execViaPty: async () => {
|
||||
throw new Error("execViaPty should not be called in this test");
|
||||
},
|
||||
},
|
||||
"./ipcUtils.cjs": {
|
||||
safeSend(sender, channel, payload) {
|
||||
safeSendCalls.push({ sender, channel, payload });
|
||||
},
|
||||
},
|
||||
"./windowManager.cjs": {
|
||||
getMainWindow() {
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
webContents: { id: 1 },
|
||||
};
|
||||
},
|
||||
getSettingsWindow() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
"@mcpc-tech/acp-ai-provider": {
|
||||
createACPProvider(args) {
|
||||
providerCreationCount += 1;
|
||||
providerCreationArgs.push(args);
|
||||
if (typeof options.createACPProvider === "function") {
|
||||
return options.createACPProvider({ args, providerCreationCount, fallbackProvider });
|
||||
}
|
||||
if (providerCreationCount === 1) {
|
||||
return {
|
||||
tools: {},
|
||||
languageModel() {
|
||||
return { id: "fake-model" };
|
||||
},
|
||||
async initSession() {
|
||||
throw new Error("Resource not found: session not found");
|
||||
},
|
||||
getSessionId() {
|
||||
return "stale-session";
|
||||
},
|
||||
cleanup() {},
|
||||
};
|
||||
}
|
||||
return fallbackProvider;
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
stepCountIs: () => Symbol("stopWhen"),
|
||||
streamText(args) {
|
||||
const { messages } = args;
|
||||
streamCalls.push(messages);
|
||||
if (typeof options.streamText === "function") {
|
||||
return options.streamText({ ...args, streamCalls });
|
||||
}
|
||||
if (streamCalls.length === 1) {
|
||||
throw new Error("transport failed before replayed turn completed");
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const bridgePath = require.resolve("./aiBridge.cjs");
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (Object.prototype.hasOwnProperty.call(mocks, request)) {
|
||||
return mocks[request];
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
delete require.cache[bridgePath];
|
||||
|
||||
try {
|
||||
const bridge = require("./aiBridge.cjs");
|
||||
return {
|
||||
bridge,
|
||||
streamCalls,
|
||||
safeSendCalls,
|
||||
providerCreationArgs,
|
||||
restore() {
|
||||
try {
|
||||
bridge.cleanup();
|
||||
} finally {
|
||||
delete require.cache[bridgePath];
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
delete require.cache[bridgePath];
|
||||
Module._load = originalLoad;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
|
||||
const ipcMain = createIpcMainStub();
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
console.error = (...args) => {
|
||||
const message = args.map((part) => String(part ?? "")).join(" ");
|
||||
if (message.includes("transport failed before replayed turn completed")) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError(...args);
|
||||
};
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-1",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first recovered turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-2",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "retry after transport failure",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.equal(streamCalls.length, 2);
|
||||
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
|
||||
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
|
||||
assert.equal(providerCreationArgs.length, 3);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[0], true);
|
||||
assert.equal(providerCreationArgs[0].existingSessionId, "stale-session");
|
||||
assert.equal("existingSessionId" in providerCreationArgs[1], false);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[2], false);
|
||||
});
|
||||
|
||||
test("clears replay fallback after a user-cancelled recovered turn so the fresh ACP session is preserved", async () => {
|
||||
// Regression: if the user stops the first turn after stale-session
|
||||
// recovery, historyReplayFallback must still be cleared. Otherwise the
|
||||
// next turn triggers shouldResetProviderForHistoryReplay, which discards
|
||||
// the freshly recovered ACP session (resumeSessionId is forced to
|
||||
// undefined in that path) and re-spends tokens on another compact
|
||||
// replay. That would break the cancel-preserves-session contract.
|
||||
|
||||
// Gate that the test releases AFTER cancel has been dispatched, so the
|
||||
// bridge's reader loop wakes up to find signal.aborted=true.
|
||||
let releaseRead;
|
||||
const readReleased = new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// First call (the recovered turn) — block in read() so the test can
|
||||
// fire cancel before any chunk arrives, simulating "user clicks Stop
|
||||
// before the agent emits content". Second call (follow-up) — return
|
||||
// an immediately-done empty stream.
|
||||
if (callsRef.length === 1) {
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
await readReleased;
|
||||
// After cancel, signal.aborted is true; return done so the
|
||||
// loop exits cleanly. Never produced a content chunk →
|
||||
// hasContent stays false, aborted is true → we hit the
|
||||
// else-branch where the fix lives.
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const cancelHandler = ipcMain.handlers.get("netcatty:ai:acp:cancel");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
assert.equal(typeof cancelHandler, "function");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// Kick off the first turn; it will block at reader.read().
|
||||
const firstTurn = streamHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-cancel",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first recovered turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Yield enough microtasks so the handler reaches the streamText/read
|
||||
// path before we cancel.
|
||||
for (let i = 0; i < 10; i += 1) await Promise.resolve();
|
||||
|
||||
// Fire cancel — this calls controller.abort() inside the bridge.
|
||||
await cancelHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-cancel",
|
||||
});
|
||||
|
||||
// Now release the blocked read so the loop wakes, sees aborted, and
|
||||
// exits. The else-branch should clear historyReplayFallback.
|
||||
releaseRead();
|
||||
await firstTurn;
|
||||
|
||||
// Second turn — should reuse the recovered fresh-session and send
|
||||
// only the latest prompt (no compact replay).
|
||||
await streamHandler(event, {
|
||||
requestId: "req-cancel-2",
|
||||
chatSessionId: "chat-cancel",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "follow-up after cancel",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// Two streamText calls: the cancelled one + the follow-up.
|
||||
assert.equal(streamCalls.length, 2);
|
||||
|
||||
// Provider creation count: 1 stale attempt + 1 fallback recovery = 2.
|
||||
// If the bug regresses, the follow-up turn would force a 3rd creation
|
||||
// (shouldResetProviderForHistoryReplay → cleanupAcpProvider → recreate
|
||||
// without existingSessionId).
|
||||
assert.equal(
|
||||
providerCreationArgs.length,
|
||||
2,
|
||||
"expected the recovered fresh session to be preserved across user cancel",
|
||||
);
|
||||
|
||||
// Follow-up turn should send only the latest prompt — the recovered
|
||||
// session has the prior context; replaying compact history again would
|
||||
// waste tokens and visually feel like the conversation forgot itself.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
1,
|
||||
"follow-up after cancel must not re-replay compact history",
|
||||
);
|
||||
});
|
||||
|
||||
test("replays compact history on the first turn after app restart even when session/load 'succeeds'", async () => {
|
||||
// Regression for #753: after an app restart, the renderer still has
|
||||
// the prior chat's externalSessionId and full message history in
|
||||
// storage, and passes both to the bridge on the next send. The
|
||||
// externalSessionId becomes existingSessionId → resumeSessionId in
|
||||
// the bridge, and createACPProvider spawns a fresh agent process
|
||||
// with that id.
|
||||
//
|
||||
// Problem: some ACP agents (Copilot CLI, some Codex builds) don't
|
||||
// error on session/load when the id is stale — they silently start
|
||||
// a new session. The catch-block fallback never fires, so
|
||||
// historyReplayFallback stays false and the stream sends only the
|
||||
// latest prompt. The agent says "no previous records" even though
|
||||
// the UI shows the prior conversation.
|
||||
//
|
||||
// Fix: when we're spawning a new provider AND telling it to resume
|
||||
// an existing session id AND we have compact history to replay,
|
||||
// preload historyReplayFallback=true. The first turn includes the
|
||||
// replay; after it streams real content the flag clears so steady-
|
||||
// state cost stays at just the latest prompt.
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
createACPProvider({ fallbackProvider }) {
|
||||
// Pretend session/load succeeded silently — no error thrown, but
|
||||
// also no real context. This models Copilot CLI's behavior.
|
||||
return fallbackProvider;
|
||||
},
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// Return content so the post-stream hook clears the flag after.
|
||||
if (callsRef.length === 1) {
|
||||
const chunks = [{ type: "text-delta", text: "ok" }];
|
||||
let i = 0;
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
if (i < chunks.length) return { done: false, value: chunks[i++] };
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const historyMessages = [{ role: "user", content: "prior constraint: 不要提交" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// First turn after app restart. existingSessionId is set (renderer
|
||||
// persisted it), historyMessages is non-empty.
|
||||
await streamHandler(event, {
|
||||
requestId: "req-restart-1",
|
||||
chatSessionId: "chat-restart",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "what did we discuss?",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stored-session-from-storage",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Second turn — should send only the latest prompt now.
|
||||
await streamHandler(event, {
|
||||
requestId: "req-restart-2",
|
||||
chatSessionId: "chat-restart",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "and now continue",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stored-session-from-storage",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// Single provider creation — session/load "succeeded" so no fallback.
|
||||
assert.equal(providerCreationArgs.length, 1);
|
||||
assert.equal(providerCreationArgs[0].existingSessionId, "stored-session-from-storage");
|
||||
|
||||
// First turn MUST include the compact history + latest prompt.
|
||||
// Regression target: pre-fix, streamCalls[0] had length 1 (latest only).
|
||||
assert.equal(
|
||||
streamCalls[0].length,
|
||||
2,
|
||||
"first turn after app restart must preload compact history as a hedge",
|
||||
);
|
||||
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
|
||||
|
||||
// Second turn uses steady-state behavior (latest only). This confirms
|
||||
// the flag clears after one successful streamed turn and the hedge
|
||||
// doesn't keep replaying forever.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
1,
|
||||
"steady-state turns must not keep replaying history",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves recovered ACP session when user cancels then immediately sends the next prompt", async () => {
|
||||
// Regression: after a user-cancel of a recovered turn, the existingRun
|
||||
// path in the next stream handler used to call cleanupAcpProvider
|
||||
// unconditionally — destroying the fresh ACP session the cancel IPC
|
||||
// had just promised to preserve. Combined with historyReplayFallback
|
||||
// still being true at that moment, the follow-up turn then recreated
|
||||
// a bare new provider via shouldResetProviderForHistoryReplay and
|
||||
// the user lost all recovered conversation context.
|
||||
//
|
||||
// With the fix: (a) the cancel IPC synchronously clears the replay
|
||||
// flag on the preserved provider, and (b) the existingRun path skips
|
||||
// cleanupAcpProvider when the prior run was already cancelled via
|
||||
// the cancel IPC. The next stream then reuses the recovered session
|
||||
// and sends only the latest prompt.
|
||||
|
||||
let releaseRead;
|
||||
const readReleased = new Promise((resolve) => {
|
||||
releaseRead = resolve;
|
||||
});
|
||||
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// Turn 1: block in read() so the test can fire cancel, then
|
||||
// immediately fire the next stream request while the aborted
|
||||
// stream is still unwinding.
|
||||
if (callsRef.length === 1) {
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
await readReleased;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const cancelHandler = ipcMain.handlers.get("netcatty:ai:acp:cancel");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// Turn 1 starts and blocks in read().
|
||||
const firstTurn = streamHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-race",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Yield so the handler reaches the streamText/read phase.
|
||||
for (let i = 0; i < 10; i += 1) await Promise.resolve();
|
||||
|
||||
// User clicks Stop.
|
||||
await cancelHandler(event, {
|
||||
requestId: "req-cancel-1",
|
||||
chatSessionId: "chat-race",
|
||||
});
|
||||
|
||||
// User immediately sends the next prompt BEFORE releasing the read
|
||||
// — i.e. before the first stream handler's post-stream code can
|
||||
// run. This is the exact timing window codex flagged.
|
||||
const secondTurn = streamHandler(event, {
|
||||
requestId: "req-cancel-2",
|
||||
chatSessionId: "chat-race",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "immediate follow-up",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Let the first turn unwind now.
|
||||
releaseRead();
|
||||
await firstTurn;
|
||||
await secondTurn;
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
// 2 provider creations: the stale attempt + fallback recovery.
|
||||
// If the regression is back, there would be a 3rd creation (the
|
||||
// existingRun cleanup + reset-for-replay path discarding the
|
||||
// recovered session).
|
||||
assert.equal(
|
||||
providerCreationArgs.length,
|
||||
2,
|
||||
"expected recovered fresh session to be preserved across cancel+immediate-send",
|
||||
);
|
||||
|
||||
// Second turn must NOT re-replay compact history — the preserved
|
||||
// session already has that context.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
1,
|
||||
"follow-up after cancel must not re-replay compact history",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves history-replay across provider recreation caused by permission-mode / MCP / auth change", async () => {
|
||||
// Regression: after a stale-session recovery left historyReplayFallback=true
|
||||
// (e.g. the recovered turn returned empty), an orthogonal change that
|
||||
// flips shouldReuseProvider to false (permission mode, MCP scope, auth
|
||||
// fingerprint) used to recreate the provider with historyReplayFallback:
|
||||
// false. The next turn then sent only the latest prompt and dropped the
|
||||
// recovered conversation context. We now preserve the flag on any
|
||||
// recreation where a history-replay is still pending.
|
||||
|
||||
// Use permission mode as the orthogonal change — auth fingerprint would
|
||||
// drag in Codex-specific auth validation we can't stub cleanly.
|
||||
let permissionMode = "default";
|
||||
function createStreamResult(chunks) {
|
||||
let idx = 0;
|
||||
return {
|
||||
fullStream: {
|
||||
getReader: () => ({
|
||||
async read() {
|
||||
if (idx < chunks.length) {
|
||||
return { done: false, value: chunks[idx++] };
|
||||
}
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
releaseLock() {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
getPermissionMode: () => permissionMode,
|
||||
streamText({ streamCalls: callsRef }) {
|
||||
// Turn 1: empty stream — the recovered turn returned no content, so
|
||||
// the empty-non-aborted branch keeps historyReplayFallback=true.
|
||||
if (callsRef.length === 1) return createEmptyStreamResult();
|
||||
// Turn 2: content streams — confirms the replay actually reached
|
||||
// the recreated provider.
|
||||
return createStreamResult([{ type: "text-delta", text: "ok" }]);
|
||||
},
|
||||
});
|
||||
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
// Turn 1: stale-session recovery + empty response (flag stays set).
|
||||
await streamHandler(event, {
|
||||
requestId: "req-1",
|
||||
chatSessionId: "chat-preserve",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
// Simulate the user toggling the MCP permission mode between turns.
|
||||
// This flips shouldReuseProvider to false and forces recreation via
|
||||
// the non-reset branch — exactly where the preserve-flag gap lived.
|
||||
permissionMode = "auto";
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-2",
|
||||
chatSessionId: "chat-preserve",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "second turn after permission change",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.equal(streamCalls.length, 2);
|
||||
// Turn 2 must include history + latest; regression would make it just 1.
|
||||
assert.equal(
|
||||
streamCalls[1].length,
|
||||
2,
|
||||
"second turn must re-replay compact history onto the recreated provider",
|
||||
);
|
||||
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
|
||||
|
||||
// 3 provider creations: stale attempt + first fallback + permission-change recreation.
|
||||
assert.equal(providerCreationArgs.length, 3);
|
||||
});
|
||||
|
||||
test("keeps replay fallback enabled after an empty recovered turn by retrying in a fresh ACP session", async () => {
|
||||
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
|
||||
streamText() {
|
||||
return createEmptyStreamResult();
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
|
||||
assert.equal(typeof streamHandler, "function");
|
||||
|
||||
const historyMessages = [{ role: "user", content: "prior recovered context" }];
|
||||
const event = { sender: { id: 1 } };
|
||||
|
||||
try {
|
||||
await streamHandler(event, {
|
||||
requestId: "req-1",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "first recovered turn",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "stale-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
|
||||
await streamHandler(event, {
|
||||
requestId: "req-2",
|
||||
chatSessionId: "chat-1",
|
||||
acpCommand: "fake-acp",
|
||||
acpArgs: [],
|
||||
prompt: "retry after empty response",
|
||||
providerId: undefined,
|
||||
model: undefined,
|
||||
existingSessionId: "fresh-session",
|
||||
historyMessages,
|
||||
images: undefined,
|
||||
toolIntegrationMode: "mcp",
|
||||
defaultTargetSession: undefined,
|
||||
userSkillsContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.equal(streamCalls.length, 2);
|
||||
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
|
||||
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
|
||||
assert.equal(providerCreationArgs.length, 3);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[0], true);
|
||||
assert.equal(providerCreationArgs[0].existingSessionId, "stale-session");
|
||||
assert.equal("existingSessionId" in providerCreationArgs[1], false);
|
||||
assert.equal("existingSessionId" in providerCreationArgs[2], false);
|
||||
});
|
||||
@@ -6,27 +6,78 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { exec } = require("node:child_process");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows using the attrib command
|
||||
* Returns true if the file has the hidden attribute set
|
||||
* Uses async exec to avoid blocking the main process
|
||||
* Parse the output of `attrib.exe <dir>\*` into a set of basenames whose
|
||||
* `H` (hidden) flag is set. Exposed separately so the parser can be
|
||||
* unit-tested without spawning a real subprocess.
|
||||
*
|
||||
* Example attrib output (one entry per line):
|
||||
* A C:\path\file1.txt
|
||||
* H C:\path\file2.txt
|
||||
* A H R C:\path\file3.txt
|
||||
* H C:\path\hidden_dir [DIR]
|
||||
*/
|
||||
async function isWindowsHiddenFile(filePath) {
|
||||
if (process.platform !== "win32") return false;
|
||||
function parseAttribOutput(stdout) {
|
||||
const hidden = new Set();
|
||||
for (const line of String(stdout).split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
// Flags occupy the leading columns. Locate the path by the first
|
||||
// drive letter ("C:\") or UNC prefix ("\\server\share"). The `\\\\`
|
||||
// alternative has no leading anchor because attrib output has the
|
||||
// path inside the line, not at column 0 (leading whitespace holds
|
||||
// the attribute flags).
|
||||
const pathStart = line.search(/[A-Za-z]:[\\/]|\\\\/);
|
||||
if (pathStart < 0) continue;
|
||||
const attrPart = line.substring(0, pathStart).toUpperCase();
|
||||
if (!attrPart.includes("H")) continue;
|
||||
const fullPath = line.substring(pathStart).trim();
|
||||
// Some Windows versions append a trailing literal "[DIR]" marker
|
||||
// when attrib is invoked with /d. Strip only that exact marker —
|
||||
// not any arbitrary bracketed suffix — so legitimate filenames
|
||||
// ending in brackets ("Notes [old]", "Draft [v2].md") survive
|
||||
// intact and still get matched by hiddenSet.has(entry.name).
|
||||
const cleaned = fullPath.replace(/\s+\[DIR\]\s*$/, "");
|
||||
// Always use the win32 basename here — attrib output uses backslash
|
||||
// separators, and the parser must work under CI on non-Windows hosts.
|
||||
const basename = path.win32.basename(cleaned);
|
||||
if (basename) hidden.add(basename);
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-list hidden filenames in a Windows directory.
|
||||
*
|
||||
* Previously we called `attrib` once per entry inside the concurrency
|
||||
* worker loop. On a directory with ~800 files, that spawns ~800 subprocesses
|
||||
* and takes ~30 s (see #766). One subprocess call with a wildcard returns
|
||||
* the hidden attribute for every entry at once, so we replace the per-file
|
||||
* check with a single upfront pass and a Set lookup in the worker.
|
||||
*
|
||||
* Returns the set of hidden basenames (empty on non-Windows or on failure).
|
||||
*/
|
||||
async function listWindowsHiddenBasenames(dirPath) {
|
||||
if (process.platform !== "win32") return new Set();
|
||||
try {
|
||||
const { stdout } = await execAsync(`attrib "${filePath}"`);
|
||||
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
|
||||
// The attributes appear in the first ~10 characters before the path
|
||||
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
|
||||
return attrPart.includes("H");
|
||||
const pattern = path.join(dirPath, "*");
|
||||
// `/d` is required so attrib.exe also reports directory entries —
|
||||
// without it the wildcard is file-centric and hidden folders would
|
||||
// be silently omitted from the set, causing the SFTP browser to
|
||||
// show them as not-hidden (a regression from the per-file path
|
||||
// that passed each entry's full path directly).
|
||||
const { stdout } = await execFileAsync("attrib.exe", [pattern, "/d"], {
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
windowsHide: true,
|
||||
});
|
||||
return parseAttribOutput(stdout);
|
||||
} catch (err) {
|
||||
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
|
||||
return false;
|
||||
console.warn(`[localFsBridge] Batch attrib failed for ${dirPath}:`, err.message);
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +88,17 @@ async function isWindowsHiddenFile(filePath) {
|
||||
*/
|
||||
async function listLocalDir(event, payload) {
|
||||
const dirPath = payload.path;
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
// Read directory entries and the Windows hidden-attribute set in
|
||||
// parallel. The hidden lookup is a single subprocess that covers every
|
||||
// entry in the directory; per-file attrib calls were the ~30 s hotspot
|
||||
// that #766 reported on an 800-file directory.
|
||||
const [entries, hiddenSet] = await Promise.all([
|
||||
fs.promises.readdir(dirPath, { withFileTypes: true }),
|
||||
isWindows ? listWindowsHiddenBasenames(dirPath) : Promise.resolve(new Set()),
|
||||
]);
|
||||
|
||||
// Stat entries in parallel with a small concurrency limit.
|
||||
// Serial stats can be very slow on Windows for large dirs.
|
||||
const CONCURRENCY = 32;
|
||||
@@ -70,8 +129,8 @@ async function listLocalDir(event, payload) {
|
||||
type = "file";
|
||||
}
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
// Windows hidden attribute: resolved from the batched lookup.
|
||||
const hidden = isWindows ? hiddenSet.has(entry.name) : false;
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
@@ -90,7 +149,7 @@ async function listLocalDir(event, payload) {
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
const hidden = isWindows ? hiddenSet.has(brokenEntry.name) : false;
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
@@ -269,4 +328,6 @@ module.exports = {
|
||||
getHomeDir,
|
||||
getSystemInfo,
|
||||
readKnownHosts,
|
||||
parseAttribOutput,
|
||||
listWindowsHiddenBasenames,
|
||||
};
|
||||
|
||||
139
electron/bridges/localFsBridge.test.cjs
Normal file
139
electron/bridges/localFsBridge.test.cjs
Normal file
@@ -0,0 +1,139 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { parseAttribOutput, listWindowsHiddenBasenames } = require("./localFsBridge.cjs");
|
||||
|
||||
test("parseAttribOutput returns an empty set for empty input", () => {
|
||||
assert.equal(parseAttribOutput("").size, 0);
|
||||
assert.equal(parseAttribOutput("\r\n\r\n").size, 0);
|
||||
});
|
||||
|
||||
test("parseAttribOutput captures basenames of files with the H flag", () => {
|
||||
const stdout = [
|
||||
"A C:\\Users\\foo\\public.txt",
|
||||
" H C:\\Users\\foo\\.secret",
|
||||
"A H R C:\\Users\\foo\\hidden-readonly.exe",
|
||||
"A C:\\Users\\foo\\another.log",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual(
|
||||
[...hidden].sort(),
|
||||
[".secret", "hidden-readonly.exe"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseAttribOutput ignores the trailing [DIR] marker on some Windows versions", () => {
|
||||
const stdout = [
|
||||
" H C:\\data\\node_modules [DIR]",
|
||||
" H C:\\data\\.git [DIR]",
|
||||
"A C:\\data\\README.md",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual([...hidden].sort(), [".git", "node_modules"].sort());
|
||||
});
|
||||
|
||||
test("parseAttribOutput preserves filenames that legitimately end with bracketed suffixes", () => {
|
||||
// Regression: a prior version stripped ANY trailing bracketed suffix
|
||||
// via /\s+\[[^\]]+\]\s*$/, truncating "Notes [old]" to "Notes".
|
||||
// Only the literal [DIR] marker that attrib emits with /d is a parser
|
||||
// artifact; user-facing filenames with brackets must survive intact so
|
||||
// hiddenSet.has(entry.name) still matches the actual readdir entry.
|
||||
const stdout = [
|
||||
" H C:\\data\\Notes [old]",
|
||||
" H C:\\data\\Draft [v2].md",
|
||||
" H C:\\data\\archived [2024]",
|
||||
" H C:\\data\\node_modules [DIR]",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual(
|
||||
[...hidden].sort(),
|
||||
["Draft [v2].md", "Notes [old]", "archived [2024]", "node_modules"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("parseAttribOutput handles UNC paths", () => {
|
||||
const stdout = [
|
||||
" H \\\\fileserver\\share\\secret.cfg",
|
||||
"A \\\\fileserver\\share\\public.cfg",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual([...hidden], ["secret.cfg"]);
|
||||
});
|
||||
|
||||
test("parseAttribOutput skips malformed lines", () => {
|
||||
const stdout = [
|
||||
"Parameter format not correct",
|
||||
"",
|
||||
" H C:\\good\\hidden.txt",
|
||||
"File not found",
|
||||
" H not-a-windows-path.txt",
|
||||
].join("\r\n");
|
||||
|
||||
const hidden = parseAttribOutput(stdout);
|
||||
assert.deepEqual([...hidden], ["hidden.txt"]);
|
||||
});
|
||||
|
||||
test("listWindowsHiddenBasenames returns an empty set on non-Windows without spawning anything", async () => {
|
||||
// Running this test file is only meaningful on a non-Windows host for this
|
||||
// assertion. On Windows CI we skip the subprocess-free guarantee.
|
||||
if (process.platform === "win32") return;
|
||||
const result = await listWindowsHiddenBasenames("/tmp");
|
||||
assert.ok(result instanceof Set);
|
||||
assert.equal(result.size, 0);
|
||||
});
|
||||
|
||||
test("listWindowsHiddenBasenames invokes attrib.exe with /d so hidden directories aren't omitted", async () => {
|
||||
// Regression: without `/d`, `attrib <dir>\*` treats the wildcard as
|
||||
// file-centric and hidden directories (node_modules, .git, …) never
|
||||
// reach parseAttribOutput — the SFTP browser then shows them as
|
||||
// not-hidden, a behavior regression from the per-file implementation.
|
||||
const Module = require("node:module");
|
||||
const realChildProcess = require("node:child_process");
|
||||
const originalLoad = Module._load;
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
let capturedArgs = null;
|
||||
let capturedExecutable = null;
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === "node:child_process") {
|
||||
return {
|
||||
...realChildProcess,
|
||||
execFile: (executable, args, _options, cb) => {
|
||||
capturedExecutable = executable;
|
||||
capturedArgs = args;
|
||||
cb(null, { stdout: "", stderr: "" });
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const bridgePath = require.resolve("./localFsBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
|
||||
try {
|
||||
const { listWindowsHiddenBasenames: fn } = require("./localFsBridge.cjs");
|
||||
await fn("C:\\fixture");
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
Object.defineProperty(process, "platform", originalPlatform);
|
||||
delete require.cache[bridgePath];
|
||||
}
|
||||
|
||||
assert.equal(capturedExecutable, "attrib.exe");
|
||||
assert.ok(
|
||||
Array.isArray(capturedArgs) && capturedArgs.includes("/d"),
|
||||
`expected /d in attrib args so hidden directories are included, got ${JSON.stringify(capturedArgs)}`,
|
||||
);
|
||||
});
|
||||
253
electron/bridges/mainProcessErrorGuards.test.cjs
Normal file
253
electron/bridges/mainProcessErrorGuards.test.cjs
Normal file
@@ -0,0 +1,253 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
classifyProcessError,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
isNonFatalNetworkError,
|
||||
} = require("./processErrorGuards.cjs");
|
||||
|
||||
test("treats Chromium ERR_NETWORK_CHANGED as non-fatal", () => {
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("net::ERR_NETWORK_CHANGED")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("treats other Chromium net::ERR_* failures as non-fatal network errors", () => {
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("net::ERR_INTERNET_DISCONNECTED")),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("net::ERR_NAME_NOT_RESOLVED")),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("treats Node socket error codes as non-fatal network errors", () => {
|
||||
const err = new Error("socket reset");
|
||||
err.code = "ECONNRESET";
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
|
||||
const dnsErr = new Error("dns failed");
|
||||
dnsErr.code = "ENOTFOUND";
|
||||
assert.equal(isNonFatalNetworkError(dnsErr), true);
|
||||
});
|
||||
|
||||
test("keeps non-network errors fatal", () => {
|
||||
assert.equal(
|
||||
isNonFatalNetworkError(new Error("Something else broke")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("generic startup exceptions stay fatal before the app is up", () => {
|
||||
const result = classifyProcessError(new Error("boom"), {
|
||||
runtimeStarted: false,
|
||||
});
|
||||
|
||||
assert.equal(result.action, "fatal");
|
||||
});
|
||||
|
||||
test("generic runtime exceptions are suppressed after startup", () => {
|
||||
const result = classifyProcessError(new Error("boom"), {
|
||||
runtimeStarted: true,
|
||||
});
|
||||
|
||||
assert.equal(result.action, "suppress");
|
||||
assert.match(result.reason, /runtime/i);
|
||||
});
|
||||
|
||||
test("generic runtime promise rejections are also suppressed after startup", () => {
|
||||
const result = classifyProcessError(new Error("promise boom"), {
|
||||
runtimeStarted: true,
|
||||
origin: "unhandledRejection",
|
||||
});
|
||||
|
||||
assert.equal(result.action, "suppress");
|
||||
assert.match(result.reason, /runtime/i);
|
||||
});
|
||||
|
||||
test("controller keeps startup strict until the main window is actually shown", () => {
|
||||
const controller = createProcessErrorController();
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
assert.equal(controller.isRuntimeProtectionActive(), false);
|
||||
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
assert.equal(controller.isRuntimeProtectionActive(), true);
|
||||
});
|
||||
|
||||
test("controller becomes strict again while recreating a missing main window", () => {
|
||||
const controller = createProcessErrorController();
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
assert.equal(controller.isRuntimeProtectionActive(), true);
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
assert.equal(controller.isRuntimeProtectionActive(), false);
|
||||
|
||||
controller.completeMainWindowStartup({ windowShown: false });
|
||||
assert.equal(controller.isRuntimeProtectionActive(), true);
|
||||
});
|
||||
|
||||
test("startup-period errors stay fatal while recreating the main window", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
const fatals = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError() {},
|
||||
onFatalError(err) {
|
||||
fatals.push(err.message);
|
||||
throw err;
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
installProcessErrorHandlers(fakeProcess, controller);
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
controller.beginMainWindowStartup();
|
||||
|
||||
assert.throws(() => {
|
||||
fakeProcess.emit("uncaughtException", new Error("recreate boom"));
|
||||
}, /recreate boom/);
|
||||
assert.deepEqual(fatals, ["recreate boom"]);
|
||||
});
|
||||
|
||||
test("fatal startup failures uninstall listeners and keep throwing", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
const captured = [];
|
||||
const fatals = [];
|
||||
let uninstall = null;
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
fatals.push(err.message);
|
||||
uninstall?.();
|
||||
throw err;
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
uninstall = installProcessErrorHandlers(fakeProcess, controller);
|
||||
|
||||
assert.throws(() => {
|
||||
fakeProcess.emit("uncaughtException", new Error("startup boom"));
|
||||
}, /startup boom/);
|
||||
assert.deepEqual(fatals, ["startup boom"]);
|
||||
assert.deepEqual(captured, [["uncaughtException", "startup boom"]]);
|
||||
assert.equal(fakeProcess.listenerCount("uncaughtException"), 0);
|
||||
assert.equal(fakeProcess.listenerCount("unhandledRejection"), 0);
|
||||
});
|
||||
|
||||
test("installed handlers suppress runtime failures after startup", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
const captured = [];
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
throw err;
|
||||
},
|
||||
logError(...args) {
|
||||
errors.push(args.map(String).join(" "));
|
||||
},
|
||||
logWarn(...args) {
|
||||
warnings.push(args.map(String).join(" "));
|
||||
},
|
||||
});
|
||||
|
||||
installProcessErrorHandlers(fakeProcess, controller);
|
||||
|
||||
controller.beginMainWindowStartup();
|
||||
controller.completeMainWindowStartup({ windowShown: true });
|
||||
|
||||
fakeProcess.emit("uncaughtException", new Error("runtime boom"));
|
||||
fakeProcess.emit("unhandledRejection", new Error("runtime rejection"));
|
||||
assert.deepEqual(captured, [
|
||||
["uncaughtException", "runtime boom"],
|
||||
["unhandledRejection", "runtime rejection"],
|
||||
]);
|
||||
assert.equal(errors.some((line) => line.includes("runtime error after startup")), true);
|
||||
assert.equal(warnings.length, 0);
|
||||
});
|
||||
|
||||
test("unhandled rejection marks the forwarded error so uncaught follow-up is not double-captured", () => {
|
||||
const captured = [];
|
||||
const fatals = [];
|
||||
const controller = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
captured.push([source, err.message]);
|
||||
},
|
||||
onFatalError(err) {
|
||||
fatals.push(err);
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
controller.handleUnhandledRejection(new Error("startup rejection"));
|
||||
assert.equal(fatals.length, 1);
|
||||
assert.equal(fatals[0].__fromUnhandledRejection, true);
|
||||
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
|
||||
|
||||
controller.handleUncaughtException(fatals[0]);
|
||||
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
|
||||
});
|
||||
|
||||
test("benign stream teardown errors are ignored by the installed handlers", () => {
|
||||
const fakeProcess = new EventEmitter();
|
||||
let captureCount = 0;
|
||||
let fatalCount = 0;
|
||||
const controller = createProcessErrorController({
|
||||
captureError() {
|
||||
captureCount += 1;
|
||||
},
|
||||
onFatalError() {
|
||||
fatalCount += 1;
|
||||
},
|
||||
logError() {},
|
||||
logWarn() {},
|
||||
});
|
||||
|
||||
installProcessErrorHandlers(fakeProcess, controller);
|
||||
const err = new Error("broken pipe");
|
||||
err.code = "EPIPE";
|
||||
fakeProcess.emit("uncaughtException", err);
|
||||
|
||||
assert.equal(captureCount, 0);
|
||||
assert.equal(fatalCount, 0);
|
||||
});
|
||||
|
||||
test("controller suppresses wrapped network errors from err.cause", () => {
|
||||
const err = new Error("request failed");
|
||||
err.cause = new Error("net::ERR_NETWORK_CHANGED");
|
||||
|
||||
const result = classifyProcessError(err, {
|
||||
runtimeStarted: false,
|
||||
});
|
||||
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
assert.equal(result.action, "suppress");
|
||||
});
|
||||
|
||||
test("controller suppresses ssh-style errors with a level property", () => {
|
||||
const err = new Error("connection lost before handshake");
|
||||
err.level = "client-socket";
|
||||
|
||||
const result = classifyProcessError(err, {
|
||||
runtimeStarted: false,
|
||||
});
|
||||
|
||||
assert.equal(isNonFatalNetworkError(err), true);
|
||||
assert.equal(result.action, "suppress");
|
||||
});
|
||||
193
electron/bridges/processErrorGuards.cjs
Normal file
193
electron/bridges/processErrorGuards.cjs
Normal file
@@ -0,0 +1,193 @@
|
||||
function isNonFatalNetworkError(err) {
|
||||
if (!err) return false;
|
||||
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||||
// never a reason to kill the entire multi-session app.
|
||||
if (err.level) return true;
|
||||
|
||||
const candidates = [err, err.cause].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
const code = candidate.code;
|
||||
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||||
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||||
switch (code) {
|
||||
case "ECONNRESET":
|
||||
case "ECONNREFUSED":
|
||||
case "ECONNABORTED":
|
||||
case "ETIMEDOUT":
|
||||
case "ENOTFOUND":
|
||||
case "EHOSTUNREACH":
|
||||
case "EHOSTDOWN":
|
||||
case "ENETUNREACH":
|
||||
case "ENETDOWN":
|
||||
case "EADDRNOTAVAIL":
|
||||
case "EPROTO":
|
||||
case "EPERM":
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Chromium/Electron networking often rejects with a message like
|
||||
// "net::ERR_NETWORK_CHANGED" but without a useful `code` property.
|
||||
// These are transport failures for background fetch/update/sync work,
|
||||
// not reasons to kill the whole app.
|
||||
const message = String(candidate.message || "");
|
||||
if (/net::ERR_(?:NETWORK_[A-Z_]+|INTERNET_DISCONNECTED|NAME_NOT_RESOLVED|CONNECTION_[A-Z_]+|ADDRESS_[A-Z_]+|SSL_[A-Z_]+|CERT_[A-Z_]+|PROXY_[A-Z_]+|TUNNEL_[A-Z_]+|SOCKS_[A-Z_]+)/.test(message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isBenignStreamError(err) {
|
||||
const code = err?.code;
|
||||
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
|
||||
}
|
||||
|
||||
function classifyProcessError(err, options = {}) {
|
||||
const runtimeStarted = options.runtimeStarted === true;
|
||||
|
||||
if (isBenignStreamError(err)) {
|
||||
return {
|
||||
action: "ignore",
|
||||
reason: "benign stream teardown",
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonFatalNetworkError(err)) {
|
||||
return {
|
||||
action: "suppress",
|
||||
reason: "non-fatal network error",
|
||||
};
|
||||
}
|
||||
|
||||
if (runtimeStarted) {
|
||||
return {
|
||||
action: "suppress",
|
||||
reason: "runtime error after startup",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: "fatal",
|
||||
reason: "startup error before app became usable",
|
||||
};
|
||||
}
|
||||
|
||||
function createProcessErrorController(options = {}) {
|
||||
const captureError = typeof options.captureError === "function" ? options.captureError : () => {};
|
||||
const onFatalError = typeof options.onFatalError === "function"
|
||||
? options.onFatalError
|
||||
: (err) => { throw err; };
|
||||
const logError = typeof options.logError === "function" ? options.logError : (...args) => console.error(...args);
|
||||
const logWarn = typeof options.logWarn === "function" ? options.logWarn : (...args) => console.warn(...args);
|
||||
|
||||
let hasShownMainWindow = false;
|
||||
let pendingMainWindowStartupCount = 0;
|
||||
|
||||
const isRuntimeProtectionActive = () => (
|
||||
hasShownMainWindow && pendingMainWindowStartupCount === 0
|
||||
);
|
||||
|
||||
const beginMainWindowStartup = () => {
|
||||
pendingMainWindowStartupCount += 1;
|
||||
};
|
||||
|
||||
const completeMainWindowStartup = ({ windowShown = false } = {}) => {
|
||||
if (pendingMainWindowStartupCount > 0) {
|
||||
pendingMainWindowStartupCount -= 1;
|
||||
}
|
||||
if (windowShown) {
|
||||
hasShownMainWindow = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUncaughtException = (err) => {
|
||||
const decision = classifyProcessError(err, {
|
||||
runtimeStarted: isRuntimeProtectionActive(),
|
||||
origin: "uncaughtException",
|
||||
});
|
||||
|
||||
if (decision.action === "ignore") {
|
||||
logWarn("Ignored process error:", decision.reason, err?.code || err?.message || err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.action === "suppress") {
|
||||
if (!err?.__fromUnhandledRejection) {
|
||||
captureError("uncaughtException", err);
|
||||
}
|
||||
logError(`Suppressed uncaught exception (${decision.reason}):`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!err?.__fromUnhandledRejection) {
|
||||
captureError("uncaughtException", err);
|
||||
}
|
||||
onFatalError(err, {
|
||||
origin: "uncaughtException",
|
||||
decision,
|
||||
reason: err,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (reason) => {
|
||||
const decision = classifyProcessError(reason, {
|
||||
runtimeStarted: isRuntimeProtectionActive(),
|
||||
origin: "unhandledRejection",
|
||||
});
|
||||
|
||||
if (decision.action === "ignore") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.action === "suppress") {
|
||||
captureError("unhandledRejection", reason);
|
||||
logError(`Suppressed unhandled rejection (${decision.reason}):`, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
captureError("unhandledRejection", reason);
|
||||
const err = reason instanceof Error ? reason : new Error(String(reason));
|
||||
err.__fromUnhandledRejection = true;
|
||||
onFatalError(err, {
|
||||
origin: "unhandledRejection",
|
||||
decision,
|
||||
reason,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
beginMainWindowStartup,
|
||||
completeMainWindowStartup,
|
||||
handleUncaughtException,
|
||||
handleUnhandledRejection,
|
||||
isRuntimeProtectionActive,
|
||||
};
|
||||
}
|
||||
|
||||
function installProcessErrorHandlers(processObject, controller) {
|
||||
if (!processObject?.on || !processObject?.removeListener) {
|
||||
throw new Error("A process-like EventEmitter is required");
|
||||
}
|
||||
if (!controller?.handleUncaughtException || !controller?.handleUnhandledRejection) {
|
||||
throw new Error("A process error controller is required");
|
||||
}
|
||||
|
||||
processObject.on("uncaughtException", controller.handleUncaughtException);
|
||||
processObject.on("unhandledRejection", controller.handleUnhandledRejection);
|
||||
|
||||
return () => {
|
||||
processObject.removeListener("uncaughtException", controller.handleUncaughtException);
|
||||
processObject.removeListener("unhandledRejection", controller.handleUnhandledRejection);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
classifyProcessError,
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
isBenignStreamError,
|
||||
isNonFatalNetworkError,
|
||||
};
|
||||
@@ -36,6 +36,9 @@ let menuDeps = null;
|
||||
let electronApp = null; // Reference to Electron app for userData path
|
||||
let isQuitting = false;
|
||||
const rendererReadyCallbacksByWebContentsId = new Map();
|
||||
const rendererReadySeenByWebContentsId = new Set();
|
||||
const rendererReadyWaitersByWebContentsId = new Map();
|
||||
const unhealthyWebContentsIds = new Set();
|
||||
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
|
||||
const OAUTH_DEFAULT_WIDTH = 600;
|
||||
const OAUTH_DEFAULT_HEIGHT = 700;
|
||||
@@ -791,6 +794,128 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
|
||||
return { showOnce, markRendererReady };
|
||||
}
|
||||
|
||||
function resolveRendererReady(wcId) {
|
||||
if (!wcId) return;
|
||||
unhealthyWebContentsIds.delete(wcId);
|
||||
rendererReadySeenByWebContentsId.add(wcId);
|
||||
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
|
||||
if (cb) cb();
|
||||
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
|
||||
if (!waiters || waiters.size === 0) return;
|
||||
rendererReadyWaitersByWebContentsId.delete(wcId);
|
||||
for (const resolve of waiters) {
|
||||
try {
|
||||
resolve();
|
||||
} catch {
|
||||
// ignore waiter errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowUsable(win, options = {}) {
|
||||
const requireVisible = options.requireVisible === true;
|
||||
if (!win || typeof win.isDestroyed !== "function" || win.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
if (requireVisible) {
|
||||
if (typeof win.isVisible !== "function") return false;
|
||||
try {
|
||||
if (!win.isVisible()) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const contents = win.webContents;
|
||||
if (!contents || typeof contents.isDestroyed !== "function" || contents.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
const wcId = (() => {
|
||||
try {
|
||||
return contents.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (wcId && unhealthyWebContentsIds.has(wcId)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof contents.isCrashed === "function") {
|
||||
try {
|
||||
if (contents.isCrashed()) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function waitForRendererReady(win, { timeoutMs = 15000 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wcId = (() => {
|
||||
try {
|
||||
return win?.webContents?.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!win || win.isDestroyed?.() || !wcId) {
|
||||
reject(new Error("Main window is unavailable before renderer ready."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rendererReadySeenByWebContentsId.has(wcId)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
try { win.removeListener("closed", handleClosed); } catch {}
|
||||
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
|
||||
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
|
||||
if (waiters) {
|
||||
waiters.delete(handleReady);
|
||||
if (waiters.size === 0) {
|
||||
rendererReadyWaitersByWebContentsId.delete(wcId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReady = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleClosed = () => {
|
||||
cleanup();
|
||||
reject(new Error("Main window closed before renderer became ready."));
|
||||
};
|
||||
const handleGone = (_event, details) => {
|
||||
cleanup();
|
||||
reject(new Error(`Renderer process exited before ready: ${details?.reason || "unknown"}`));
|
||||
};
|
||||
|
||||
let waiters = rendererReadyWaitersByWebContentsId.get(wcId);
|
||||
if (!waiters) {
|
||||
waiters = new Set();
|
||||
rendererReadyWaitersByWebContentsId.set(wcId, waiters);
|
||||
}
|
||||
waiters.add(handleReady);
|
||||
|
||||
win.once("closed", handleClosed);
|
||||
win.webContents?.once?.("render-process-gone", handleGone);
|
||||
|
||||
if (Number(timeoutMs) > 0) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Renderer did not report ready before timeout."));
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the main application window
|
||||
*/
|
||||
@@ -869,12 +994,27 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
// Clear reference when the main window is destroyed
|
||||
win.on('closed', () => {
|
||||
try {
|
||||
if (win?.webContents?.id) {
|
||||
unhealthyWebContentsIds.delete(win.webContents.id);
|
||||
rendererReadySeenByWebContentsId.delete(win.webContents.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (mainWindow === win) mainWindow = null;
|
||||
});
|
||||
|
||||
// Log renderer crashes for diagnostics (skip normal clean exits)
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (details?.reason === "clean-exit") return;
|
||||
try {
|
||||
if (win.webContents?.id) {
|
||||
unhealthyWebContentsIds.add(win.webContents.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const crashLogBridge = require("./crashLogBridge.cjs");
|
||||
crashLogBridge.captureError("render-process-gone", new Error(
|
||||
@@ -1097,14 +1237,62 @@ async function createWindow(electronModule, options) {
|
||||
/**
|
||||
* Create or focus the settings window
|
||||
*/
|
||||
/**
|
||||
* Show + reliably focus a window's renderer. Works around two Windows-specific
|
||||
* Electron quirks that surface when a prewarmed/hidden window is later shown
|
||||
* (see issue #760):
|
||||
*
|
||||
* 1. SetForegroundWindow restrictions: `BrowserWindow.focus()` invoked from
|
||||
* a non-foreground process is often silently rejected by Windows. The
|
||||
* window appears on top but never receives true OS foreground focus, so
|
||||
* `document.hasFocus()` stays false in the renderer.
|
||||
* 2. Chromium suppresses the input caret + keyboard routing whenever
|
||||
* `document.hasFocus()` is false, even if an `<input>` is the active
|
||||
* element. The classic symptom: clicking an input selects/deletes work
|
||||
* but the caret never blinks and typed characters don't appear.
|
||||
*
|
||||
* The alwaysOnTop toggle is the established workaround for (1); explicitly
|
||||
* calling `webContents.focus()` covers (2) so the renderer marks the page as
|
||||
* focused regardless of whether the OS granted foreground.
|
||||
*/
|
||||
function showAndFocusWindow(win) {
|
||||
if (!win || win.isDestroyed()) return;
|
||||
try {
|
||||
win.show();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
win.setAlwaysOnTop(true);
|
||||
win.focus();
|
||||
win.setAlwaysOnTop(false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
win.focus();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.focus();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
|
||||
|
||||
// If settings window already exists, show and focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.show();
|
||||
settingsWindow.focus();
|
||||
showAndFocusWindow(settingsWindow);
|
||||
return settingsWindow;
|
||||
}
|
||||
|
||||
@@ -1264,7 +1452,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
await win.loadURL(`${baseUrl}${settingsPath}`);
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
if (showOnLoad) { showAndFocusWindow(win); }
|
||||
return win;
|
||||
} catch (e) {
|
||||
console.warn("Dev server not reachable for settings window", e);
|
||||
@@ -1273,7 +1461,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html#/settings");
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
if (showOnLoad) { showAndFocusWindow(win); }
|
||||
|
||||
return win;
|
||||
}
|
||||
@@ -1467,8 +1655,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
ipcMain.on("netcatty:renderer:ready", (event) => {
|
||||
const wcId = event?.sender?.id;
|
||||
if (!wcId) return;
|
||||
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
|
||||
if (cb) cb();
|
||||
resolveRendererReady(wcId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1558,6 +1745,8 @@ module.exports = {
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
getSettingsWindow,
|
||||
isWindowUsable,
|
||||
waitForRendererReady,
|
||||
setIsQuitting,
|
||||
openFallbackBrowser,
|
||||
tryOpenExternalWithFallback,
|
||||
|
||||
67
electron/bridges/windowManagerReadiness.test.cjs
Normal file
67
electron/bridges/windowManagerReadiness.test.cjs
Normal file
@@ -0,0 +1,67 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { isWindowUsable } = require("./windowManager.cjs");
|
||||
|
||||
function createWindowStub({ destroyed = false, webContents } = {}) {
|
||||
return {
|
||||
isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
isVisible() {
|
||||
return true;
|
||||
},
|
||||
webContents,
|
||||
};
|
||||
}
|
||||
|
||||
test("isWindowUsable returns false when webContents is crashed", () => {
|
||||
const win = createWindowStub({
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(isWindowUsable(win), false);
|
||||
});
|
||||
|
||||
test("isWindowUsable returns true for a healthy live window", () => {
|
||||
const win = createWindowStub({
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(isWindowUsable(win), true);
|
||||
});
|
||||
|
||||
test("isWindowUsable can require a visible window", () => {
|
||||
const hiddenWin = {
|
||||
...createWindowStub({
|
||||
webContents: {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
isVisible() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isWindowUsable(hiddenWin, { requireVisible: true }), false);
|
||||
assert.equal(isWindowUsable(hiddenWin, { requireVisible: false }), true);
|
||||
});
|
||||
@@ -20,79 +20,31 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
|
||||
|
||||
// Load crash log bridge early so process-level error handlers can use it
|
||||
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
|
||||
|
||||
// SSH / network errors that must never crash the process.
|
||||
// ssh2 can emit multiple 'error' events per connection (e.g. ECONNRESET followed
|
||||
// by "Connection lost before handshake"). If a listener is consumed after the first
|
||||
// event, the second becomes an uncaught exception. These are non-fatal for the app.
|
||||
function isNonFatalNetworkError(err) {
|
||||
if (!err) return false;
|
||||
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||||
// never a reason to kill the entire multi-session app.
|
||||
if (err.level) return true;
|
||||
const code = err.code;
|
||||
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||||
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
case 'ECONNREFUSED':
|
||||
case 'ECONNABORTED':
|
||||
case 'ETIMEDOUT':
|
||||
case 'ENOTFOUND':
|
||||
case 'EHOSTUNREACH':
|
||||
case 'EHOSTDOWN':
|
||||
case 'ENETUNREACH':
|
||||
case 'ENETDOWN':
|
||||
case 'EADDRNOTAVAIL':
|
||||
case 'EPROTO':
|
||||
case 'EPERM':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions — log all, only re-throw truly fatal ones
|
||||
process.on('uncaughtException', (err) => {
|
||||
// Skip benign stream teardown errors — don't pollute crash logs with false positives
|
||||
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
|
||||
console.warn('Ignored stream error:', err.code);
|
||||
return;
|
||||
}
|
||||
// Non-fatal SSH/network errors: log but do NOT crash the process
|
||||
if (isNonFatalNetworkError(err)) {
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
const {
|
||||
createProcessErrorController,
|
||||
installProcessErrorHandlers,
|
||||
} = require("./bridges/processErrorGuards.cjs");
|
||||
const processErrorController = createProcessErrorController({
|
||||
captureError(source, err) {
|
||||
try { crashLogBridge.captureError(source, err); } catch {}
|
||||
},
|
||||
onFatalError(err, context) {
|
||||
uninstallProcessErrorHandlers();
|
||||
if (context?.origin === 'unhandledRejection') {
|
||||
console.error('Unhandled rejection:', context.reason);
|
||||
} else {
|
||||
console.error('Uncaught exception:', err);
|
||||
}
|
||||
console.warn('Non-fatal uncaught exception (suppressed):', err.message);
|
||||
return;
|
||||
}
|
||||
// Skip logging if already captured by unhandledRejection handler
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
}
|
||||
console.error('Uncaught exception:', err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
// Skip benign stream teardown errors
|
||||
const code = reason?.code;
|
||||
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
|
||||
// Non-fatal SSH/network errors: log but do NOT re-throw
|
||||
if (isNonFatalNetworkError(reason)) {
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.warn('Non-fatal unhandled rejection (suppressed):', reason?.message || reason);
|
||||
return;
|
||||
}
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.error('Unhandled rejection:', reason);
|
||||
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
|
||||
// can skip duplicate logging.
|
||||
const err = reason instanceof Error ? reason : new Error(String(reason));
|
||||
err.__fromUnhandledRejection = true;
|
||||
throw err;
|
||||
throw err;
|
||||
},
|
||||
logError(...args) {
|
||||
console.error(...args);
|
||||
},
|
||||
logWarn(...args) {
|
||||
console.warn(...args);
|
||||
},
|
||||
});
|
||||
let uninstallProcessErrorHandlers = installProcessErrorHandlers(process, processErrorController);
|
||||
|
||||
// Load Electron
|
||||
let electronModule;
|
||||
@@ -1013,6 +965,80 @@ async function createWindow() {
|
||||
return win;
|
||||
}
|
||||
|
||||
function waitForWindowToShow(win) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
reject(new Error("Main window was destroyed before first show."));
|
||||
return;
|
||||
}
|
||||
if (win.isVisible?.()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try { win.removeListener("show", handleShow); } catch {}
|
||||
try { win.removeListener("closed", handleClosed); } catch {}
|
||||
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
|
||||
};
|
||||
|
||||
const handleShow = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleClosed = () => {
|
||||
cleanup();
|
||||
reject(new Error("Main window closed before first show."));
|
||||
};
|
||||
const handleGone = (_event, details) => {
|
||||
cleanup();
|
||||
reject(new Error(`Renderer process exited before first show: ${details?.reason || "unknown"}`));
|
||||
};
|
||||
|
||||
win.once("show", handleShow);
|
||||
win.once("closed", handleClosed);
|
||||
win.webContents?.once?.("render-process-gone", handleGone);
|
||||
});
|
||||
}
|
||||
|
||||
let mainWindowStartupPromise = null;
|
||||
|
||||
async function createAndShowMainWindow() {
|
||||
if (mainWindowStartupPromise) return mainWindowStartupPromise;
|
||||
|
||||
mainWindowStartupPromise = (async () => {
|
||||
processErrorController.beginMainWindowStartup();
|
||||
try {
|
||||
const win = await createWindow();
|
||||
await waitForWindowToShow(win);
|
||||
void getWindowManager().waitForRendererReady(win, {
|
||||
timeoutMs: isDev ? 30000 : 15000,
|
||||
}).catch((err) => {
|
||||
console.warn("[Main] Renderer ready signal was late or missing after first show:", err?.message || err);
|
||||
});
|
||||
processErrorController.completeMainWindowStartup({ windowShown: true });
|
||||
return win;
|
||||
} catch (err) {
|
||||
processErrorController.completeMainWindowStartup({ windowShown: false });
|
||||
throw err;
|
||||
} finally {
|
||||
mainWindowStartupPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return mainWindowStartupPromise;
|
||||
}
|
||||
|
||||
function hasUsableWindow() {
|
||||
try {
|
||||
const windowManager = getWindowManager();
|
||||
return [windowManager.getMainWindow?.(), windowManager.getSettingsWindow?.()]
|
||||
.some((win) => windowManager.isWindowUsable?.(win, { requireVisible: true }));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showStartupError(err) {
|
||||
const title = "Netcatty";
|
||||
const code = err && typeof err === "object" ? err.code : null;
|
||||
@@ -1038,9 +1064,12 @@ if (!gotLock) {
|
||||
app.on("second-instance", () => {
|
||||
if (!focusMainWindow()) {
|
||||
// Window is missing or crashed — try to recreate it
|
||||
void createWindow().catch((err) => {
|
||||
void createAndShowMainWindow().catch((err) => {
|
||||
console.error("[Main] Failed to recreate window on second-instance:", err);
|
||||
showStartupError(err);
|
||||
if (!hasUsableWindow()) {
|
||||
try { app.quit(); } catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1058,9 +1087,17 @@ if (!gotLock) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
// Build and set application menu. A broken menu should not take down
|
||||
// the entire app — fall back to no custom menu and continue startup.
|
||||
try {
|
||||
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
} catch (err) {
|
||||
console.error("[Main] Failed to build application menu:", err);
|
||||
try {
|
||||
Menu.setApplicationMenu(null);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
@@ -1080,7 +1117,7 @@ if (!gotLock) {
|
||||
});
|
||||
|
||||
// Create the main window
|
||||
void createWindow().then(() => {
|
||||
void createAndShowMainWindow().then(() => {
|
||||
// Trigger auto-update check 5 s after window creation.
|
||||
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
|
||||
getAutoUpdateBridge().startAutoCheck(5000);
|
||||
@@ -1130,9 +1167,12 @@ if (!gotLock) {
|
||||
|
||||
if (focusMainWindow()) return;
|
||||
// Main window doesn't exist — create it even if other windows (e.g. settings) are open
|
||||
void createWindow().catch((err) => {
|
||||
void createAndShowMainWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
showStartupError(err);
|
||||
if (!hasUsableWindow()) {
|
||||
try { app.quit(); } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
131
infrastructure/ai/errorClassifier.test.ts
Normal file
131
infrastructure/ai/errorClassifier.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyError, sanitizeErrorMessage } from "./errorClassifier.ts";
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// sanitizeErrorMessage — regression guard for pre-existing behavior
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("sanitizeErrorMessage strips absolute user paths", () => {
|
||||
const result = sanitizeErrorMessage("ENOENT at /Users/alice/project/file.ts");
|
||||
assert.match(result, /<path>/);
|
||||
assert.doesNotMatch(result, /alice/);
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage redacts URL credentials", () => {
|
||||
const result = sanitizeErrorMessage("Failed https://api.example.com/v1?api_key=SECRET123");
|
||||
assert.match(result, /<url-redacted>/);
|
||||
assert.doesNotMatch(result, /SECRET123/);
|
||||
});
|
||||
|
||||
test("sanitizeErrorMessage truncates very long messages", () => {
|
||||
const long = "a".repeat(1000);
|
||||
const result = sanitizeErrorMessage(long);
|
||||
assert.ok(result.length < 600, `expected truncation, got ${result.length} chars`);
|
||||
assert.match(result, /\.\.\.$/);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — 413 detection
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError surfaces a friendly 413 message when statusCode is 413", () => {
|
||||
const err = Object.assign(new Error("Request failed with status 413"), {
|
||||
statusCode: 413,
|
||||
responseBody: "<html>nginx 413</html>",
|
||||
});
|
||||
const info = classifyError(err);
|
||||
assert.equal(info.type, "network");
|
||||
assert.match(info.message, /Request too large/i);
|
||||
assert.match(info.message, /client_max_body_size/i);
|
||||
assert.match(info.message, /Raw:/);
|
||||
});
|
||||
|
||||
test("classifyError detects 'Request Entity Too Large' in a string error", () => {
|
||||
const info = classifyError("413 Request Entity Too Large");
|
||||
assert.equal(info.type, "network");
|
||||
assert.match(info.message, /Request too large/i);
|
||||
});
|
||||
|
||||
test("classifyError handles 413 via the message when no statusCode field is set", () => {
|
||||
const info = classifyError(new Error("AI_APICallError: 413 payload rejected"));
|
||||
assert.equal(info.type, "network");
|
||||
assert.match(info.message, /Request too large/i);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — 502 / 503 / 504 upstream gateway
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError marks 502/503/504 as network+retryable", () => {
|
||||
for (const code of [502, 503, 504]) {
|
||||
const info = classifyError(Object.assign(new Error(`status ${code}`), { statusCode: code }));
|
||||
assert.equal(info.type, "network");
|
||||
assert.equal(info.retryable, true, `code ${code} should be retryable`);
|
||||
assert.match(info.message, new RegExp(String(code)));
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — HTML response body
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError detects HTML in responseBody even when status is unknown", () => {
|
||||
const err = Object.assign(new Error("Invalid JSON"), {
|
||||
responseBody: "<!DOCTYPE html>\n<html><body>nginx error</body></html>",
|
||||
});
|
||||
const info = classifyError(err);
|
||||
assert.equal(info.type, "provider");
|
||||
assert.match(info.message, /HTML error page/i);
|
||||
assert.match(info.message, /proxy/i);
|
||||
});
|
||||
|
||||
test("classifyError detects HTML directly embedded in the error message", () => {
|
||||
const info = classifyError("Parse failed: <html><body>...</body></html>");
|
||||
assert.equal(info.type, "provider");
|
||||
assert.match(info.message, /HTML error page/i);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — Zod / schema parse failures
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError surfaces a friendlier message for 'Expected \\'id\\' to be a string.'", () => {
|
||||
// This is the exact error pattern reported in #765.
|
||||
const info = classifyError("Expected 'id' to be a string.");
|
||||
assert.equal(info.type, "provider");
|
||||
assert.match(info.message, /could not be parsed/i);
|
||||
assert.match(info.message, /request-size limit/i);
|
||||
// Raw error must still be visible for debugging / user reports.
|
||||
assert.match(info.message, /Expected 'id' to be a string/);
|
||||
});
|
||||
|
||||
test("classifyError handles a variety of schema validation wordings", () => {
|
||||
for (const raw of [
|
||||
"Invalid JSON response: missing field",
|
||||
"Type validation failed: expected number",
|
||||
"Expected 'choices' to be an array.",
|
||||
]) {
|
||||
const info = classifyError(raw);
|
||||
assert.equal(info.type, "provider", `wording: ${raw}`);
|
||||
assert.match(info.message, /could not be parsed|HTML error page/i);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// classifyError — fallthrough
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
test("classifyError falls through to 'unknown' for unclassified errors", () => {
|
||||
const info = classifyError(new Error("Some other provider failure"));
|
||||
assert.equal(info.type, "unknown");
|
||||
assert.match(info.message, /Some other provider failure/);
|
||||
});
|
||||
|
||||
test("classifyError handles null, undefined, and non-Error shapes without throwing", () => {
|
||||
assert.doesNotThrow(() => classifyError(null));
|
||||
assert.doesNotThrow(() => classifyError(undefined));
|
||||
assert.doesNotThrow(() => classifyError({ foo: "bar" }));
|
||||
assert.doesNotThrow(() => classifyError(42));
|
||||
});
|
||||
@@ -1,15 +1,173 @@
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
type ErrorInfo = NonNullable<ChatMessage['errorInfo']>;
|
||||
|
||||
/**
|
||||
* Convert a raw error string into display-safe error info.
|
||||
*
|
||||
* Intentionally avoids keyword-based "root cause" attribution because upstream
|
||||
* providers often return generic 4xx/5xx text that would be misclassified.
|
||||
* We show the sanitized upstream message directly instead.
|
||||
* Extract the human-readable message from anything that might surface as an
|
||||
* error (Error instance, string, SDK error object with `.message`, etc.).
|
||||
*/
|
||||
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
|
||||
const message = sanitizeErrorMessage(error).trim() || 'Unknown error';
|
||||
return { type: 'unknown', message, retryable: false };
|
||||
function extractMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message || '';
|
||||
if (typeof error === 'string') return error;
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const m = (error as { message: unknown }).message;
|
||||
if (typeof m === 'string') return m;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the HTTP status code out of an error when the SDK layer attached one.
|
||||
* Vercel AI SDK's APICallError exposes `.statusCode`; some shims use
|
||||
* `.status` or `.cause.statusCode`. Falls back to parsing the message text
|
||||
* when no structured field is available.
|
||||
*/
|
||||
function extractStatusCode(error: unknown, message: string): number | undefined {
|
||||
if (error && typeof error === 'object') {
|
||||
const obj = error as Record<string, unknown>;
|
||||
if (typeof obj.statusCode === 'number') return obj.statusCode;
|
||||
if (typeof obj.status === 'number') return obj.status;
|
||||
if (obj.cause && typeof obj.cause === 'object') {
|
||||
const causeStatus = (obj.cause as Record<string, unknown>).statusCode;
|
||||
if (typeof causeStatus === 'number') return causeStatus;
|
||||
}
|
||||
}
|
||||
// Last resort: look for a standalone 3-digit HTTP status in the message.
|
||||
// Bound by word boundaries to avoid picking up "in 413 ms" etc.
|
||||
const match = message.match(/\b(4\d{2}|5\d{2})\b/);
|
||||
if (match) return Number(match[1]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the response body out of an error object if the SDK attached it.
|
||||
* Nginx / CDN proxy error pages ship as HTML, so we can detect them here.
|
||||
*/
|
||||
function extractResponseBody(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') return undefined;
|
||||
const body = (error as Record<string, unknown>).responseBody;
|
||||
if (typeof body === 'string') return body;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function looksLikeHtml(text: string): boolean {
|
||||
if (!text) return false;
|
||||
const lower = text.toLowerCase();
|
||||
const trimmedStart = lower.trimStart().slice(0, 200);
|
||||
// Start-of-body: responseBody captured verbatim by the SDK lands here.
|
||||
if (
|
||||
trimmedStart.startsWith('<!doctype html') ||
|
||||
trimmedStart.startsWith('<html') ||
|
||||
trimmedStart.startsWith('<head') ||
|
||||
trimmedStart.startsWith('<body')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Embedded: some SDKs wrap the HTML body inside an error message like
|
||||
// "Parse failed: <html>...". Look for unmistakable HTML tags anywhere
|
||||
// in the text. Kept narrow to avoid flagging errors that casually
|
||||
// mention "html" as a word.
|
||||
if (
|
||||
lower.includes('<!doctype html') ||
|
||||
lower.includes('<html>') ||
|
||||
lower.includes('<html ') ||
|
||||
// Common nginx default error-page opener.
|
||||
/<center>\s*<h1>/.test(lower)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksLikeZodParseError(message: string): boolean {
|
||||
// Zod and Vercel AI SDK schema errors look like:
|
||||
// Expected 'id' to be a string.
|
||||
// Expected 'choices' to be an array.
|
||||
// Invalid JSON response: ...
|
||||
// Type validation failed: ...
|
||||
return (
|
||||
/\bExpected '[^']+' to be (a|an) /i.test(message) ||
|
||||
/\binvalid json response\b/i.test(message) ||
|
||||
/\btype validation failed\b/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an arbitrary error surface to display-safe error info shown in the
|
||||
* chat UI. Known hostile scenarios get a concrete, actionable message; the
|
||||
* raw SDK text is appended so users can still report it verbatim.
|
||||
*
|
||||
* Covers:
|
||||
* - HTTP 413 (proxy request-size limit, e.g. nginx client_max_body_size)
|
||||
* - HTTP 502/504 (upstream proxy failures)
|
||||
* - HTML error page returned in place of JSON (any proxy)
|
||||
* - Schema/parse failures ("Expected 'id' to be a string.") that typically
|
||||
* mean the server swapped the response body for an error page
|
||||
*/
|
||||
export function classifyError(error: unknown): ErrorInfo {
|
||||
const rawMessage = extractMessage(error).trim() || 'Unknown error';
|
||||
const statusCode = extractStatusCode(error, rawMessage);
|
||||
const responseBody = extractResponseBody(error);
|
||||
|
||||
const hasHtml =
|
||||
looksLikeHtml(rawMessage) ||
|
||||
(responseBody !== undefined && looksLikeHtml(responseBody));
|
||||
const looksLikeParseError = looksLikeZodParseError(rawMessage);
|
||||
|
||||
const sanitizedRaw = sanitizeErrorMessage(rawMessage);
|
||||
|
||||
if (statusCode === 413 || /\brequest entity too large\b/i.test(rawMessage)) {
|
||||
return {
|
||||
type: 'network',
|
||||
message:
|
||||
`Request too large (HTTP 413). The AI gateway rejected the payload — this usually means ` +
|
||||
`the request body exceeded the proxy's size limit (for example nginx \`client_max_body_size\`). ` +
|
||||
`Try sending a shorter message, fewer/smaller attachments, or raising the proxy limit.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
|
||||
return {
|
||||
type: 'network',
|
||||
message:
|
||||
`AI gateway error (HTTP ${statusCode}). The proxy in front of the provider returned an error — ` +
|
||||
`the upstream AI service may be unreachable or timing out.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasHtml) {
|
||||
return {
|
||||
type: 'provider',
|
||||
message:
|
||||
`The server returned an HTML error page instead of a JSON response. ` +
|
||||
`This almost always means a proxy (nginx / CDN / gateway) between you and the AI provider ` +
|
||||
`intercepted the request — commonly due to a size limit, auth failure, or the upstream service being down.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (looksLikeParseError) {
|
||||
return {
|
||||
type: 'provider',
|
||||
message:
|
||||
`The AI response could not be parsed as a valid chat completion. ` +
|
||||
`A proxy may have replaced or truncated the response body, or the provider returned a non-standard format. ` +
|
||||
`If you just sent a large request, check for a request-size limit on any intermediate proxy.\n\n` +
|
||||
`Raw: ${sanitizedRaw}`,
|
||||
retryable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { type: 'unknown', message: sanitizedRaw, retryable: false };
|
||||
}
|
||||
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
||||
|
||||
163
package-lock.json
generated
163
package-lock.json
generated
@@ -1105,13 +1105,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.4",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
|
||||
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
|
||||
"version": "3.972.18",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz",
|
||||
"integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.12.0",
|
||||
"fast-xml-parser": "5.3.4",
|
||||
"@smithy/types": "^4.14.1",
|
||||
"fast-xml-parser": "5.5.8",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1158,7 +1158,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1804,6 +1803,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1825,6 +1825,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1841,6 +1842,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1855,6 +1857,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -3310,7 +3313,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
|
||||
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -5594,9 +5596,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
|
||||
"integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
|
||||
"version": "4.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz",
|
||||
"integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
@@ -6106,6 +6108,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
@@ -6299,7 +6361,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
@@ -6380,7 +6441,6 @@
|
||||
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
@@ -6410,7 +6470,6 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -6961,7 +7020,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7012,7 +7070,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -7573,7 +7630,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -8316,7 +8372,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -8600,7 +8657,6 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -8982,6 +9038,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -9002,6 +9059,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -9231,7 +9289,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -9683,10 +9740,10 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
|
||||
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
|
||||
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9695,7 +9752,24 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^2.1.0"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
|
||||
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.4",
|
||||
"path-expression-matcher": "^1.2.0",
|
||||
"strnum": "^2.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -10593,7 +10667,6 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -12083,7 +12156,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/debug": "^4.0.0",
|
||||
"debug": "^4.0.0",
|
||||
@@ -12701,8 +12773,7 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
@@ -12957,6 +13028,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -12969,7 +13041,6 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -13551,6 +13622,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@@ -13729,6 +13815,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -13746,6 +13833,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -13936,7 +14024,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13946,7 +14033,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -15155,9 +15241,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
|
||||
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15277,6 +15363,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -15341,6 +15428,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -15415,7 +15503,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15530,7 +15617,6 @@
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -15629,7 +15715,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15650,7 +15735,6 @@
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"bail": "^2.0.0",
|
||||
@@ -15989,7 +16073,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16083,7 +16166,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16362,7 +16444,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs application/state/*.test.ts domain/*.test.ts"
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/ai/*.test.ts components/terminal/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
|
||||
Reference in New Issue
Block a user