Compare commits

..
28 changed files with 246 additions and 725 deletions

No files matched your search

@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { enAiMessages } from './en/ai';
import { esAiMessages } from './es/ai';
import { ruAiMessages } from './ru/ai';
import { zhCNAiMessages } from './zh-CN/ai';
import { zhTWAiMessages } from './zh-TW/ai';
for (const [locale, messages] of Object.entries({
en: enAiMessages, es: esAiMessages, ru: ruAiMessages,
'zh-CN': zhCNAiMessages, 'zh-TW': zhTWAiMessages,
})) {
test(`${locale} translates the Catty iteration limit notice and Continue action`, () => {
assert.ok(messages['ai.chat.iterationLimitReached']);
assert.match(messages['ai.chat.iterationLimitReached'], /\{limit\}/);
assert.ok(messages['ai.chat.continue']);
});
}
+2
View File
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const enAiMessages: Messages = {
'ai.chat.iterationLimitReached': 'Tool-loop limit ({limit} iterations) reached. You can continue in this chat. Running terminal jobs are not stopped.',
'ai.chat.continue': 'Continue',
'ai.chat.attachmentContentUnavailable': 'Content not available on this device',
// AI Settings
'ai.agentSettings': 'Agent Settings',
+2
View File
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const esAiMessages: Messages = {
'ai.chat.iterationLimitReached': 'Se alcanzó el límite del bucle de herramientas ({limit} iteraciones). Puedes continuar en este chat. Las tareas de terminal en ejecución no se detienen.',
'ai.chat.continue': 'Continuar',
'ai.chat.attachmentContentUnavailable': 'Contenido no disponible en este dispositivo',
// AI Settings
'ai.agentSettings': 'Configuración del agente',
+2
View File
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const ruAiMessages: Messages = {
'ai.chat.iterationLimitReached': 'Достигнут лимит цикла инструментов ({limit} итераций). Можно продолжить в этом чате. Запущенные задачи терминала не останавливаются.',
'ai.chat.continue': 'Продолжить',
'ai.chat.attachmentContentUnavailable': 'Содержимое недоступно на этом устройстве',
'ai.chat.contextUsage': 'Контекст: {used} / {max} токенов',
// AI Settings
+2
View File
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const zhCNAiMessages: Messages = {
'ai.chat.iterationLimitReached': '已达到工具循环上限({limit} 次迭代)。你可以在此对话中继续,正在运行的终端任务不会停止。',
'ai.chat.continue': '继续',
'ai.chat.attachmentContentUnavailable': '附件内容未保留在此设备',
// AI Settings
'ai.agentSettings': 'Agent 设置',
+2
View File
@@ -1,6 +1,8 @@
import type { Messages } from '../types';
export const zhTWAiMessages: Messages = {
'ai.chat.iterationLimitReached': '已達到工具迴圈上限({limit} 次迭代)。你可以在此對話中繼續,正在執行的終端工作不會停止。',
'ai.chat.continue': '繼續',
'ai.chat.attachmentContentUnavailable': '附件內容未保留在此裝置',
// AI Settings
'ai.agentSettings': 'Agent 設定',
@@ -6,7 +6,6 @@ import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { PREFLIGHT_STAT_CONCURRENCY, PREFLIGHT_STAT_MAX_FILES, useSftpTransfers } from "./useSftpTransfers";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import type { SftpPane } from "./types";
import { buildCacheKey } from "./sharedRemoteHostCache";
for (const direction of ["download", "upload"] as const) {
test(`failed ${direction} connection replaces Waiting with the actual error (#3698)`, async () => {
@@ -1000,86 +999,3 @@ test("no-conflict local copy awaits discovery so a late stat cannot overwrite th
restore();
}
});
for (const scenario of ["folder-cp", "folder-fallback", "file-cp", "different-key", "missing-key", "non-utf8"] as const) {
test(`fresh remote copy selects a consistent strategy: ${scenario} (#4207)`, async () => {
const isDirectory = scenario.startsWith("folder");
const source = makePane("left");
const target = makePane("left");
target.connection = { ...target.connection!, id: "target-conn", currentPath: "/usr/tmp" };
if (scenario === "non-utf8") target.filenameEncoding = "gb18030";
const key = buildCacheKey("host-1", "debian", 22, "ssh", false, "user", "sftp");
const keys = new Map([["remote-conn", key], ["target-conn", key]]);
if (scenario === "different-key") keys.set("target-conn", buildCacheKey("host-2", "debian", 22, "ssh", false, "user", "sftp"));
if (scenario === "missing-key") keys.delete("target-conn");
const ownerId = `same-host-${scenario}-4207`;
const streams: Array<{ resumable: boolean; sameHost?: boolean; parentTaskId?: string }> = [];
const copies: unknown[][] = [];
let listings = 0;
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const restore = installGlobals({
statSftp: async (id: string) => id === "sftp-source"
? { type: "file", size: 3626, lastModified: 1758000001000 }
: null,
mkdirSftp: async () => undefined,
sameHostCopyDirectory: async (...args: unknown[]) => {
copies.push(args);
assert.equal(sftpTransferCenterStore.getTask(args[4] as string)?.resumable, false);
return { success: scenario !== "folder-fallback" };
},
startStreamTransfer: async (options: { transferId: string; resumable: boolean; sameHost?: boolean; parentTaskId?: string }) => {
streams.push(options);
assert.equal(sftpTransferCenterStore.getTask(options.transferId)?.resumable, options.resumable);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed", transferId: options.transferId,
transferred: 3626, totalBytes: 3626, lifecycleEpoch: 0,
});
return {};
},
});
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => side === "left" ? source : target,
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map([["remote-conn", "sftp-source"], ["target-conn", "sftp-target"]]) },
connectionCacheKeyMapRef: { current: keys },
listLocalFiles: async () => [],
listRemoteFiles: async () => { listings++; return source.files; },
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
const results = await ops!.startTransfer(
[{ name: isDirectory ? "bt_mine" : "aops_dns_view_demo.sh", isDirectory }], "left", "right",
);
assert.equal(results[0]?.status, "completed");
});
const eligible = ["folder-cp", "folder-fallback", "file-cp"].includes(scenario);
const rows = sftpTransferCenterStore.getOwnerTasks(ownerId);
assert.ok(rows.length > 0);
assert.ok(rows.every(row => row.resumable === !eligible));
assert.equal(copies.length, isDirectory ? 1 : 0);
if (isDirectory) assert.deepEqual(copies[0]?.slice(0, 4), ["sftp-source", "/opt/bt_mine", "/usr/tmp/bt_mine", "auto"]);
assert.equal(listings, scenario === "folder-fallback" ? 1 : 0);
assert.equal(streams.length, scenario === "folder-cp" ? 0 : 1);
if (streams.length) {
assert.equal(streams[0].resumable, !eligible);
assert.equal(streams[0].sameHost, !["different-key", "missing-key"].includes(scenario) || undefined);
if (isDirectory) assert.ok(streams[0].parentTaskId);
}
} finally {
await act(async () => { renderer?.unmount(); });
for (const row of sftpTransferCenterStore.getOwnerTasks(ownerId)) sftpTransferCenterStore.dismiss(row.id);
restore();
}
});
}
+2 -15
View File
@@ -1348,17 +1348,6 @@ export const useSftpTransfers = ({
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const batchId = crypto.randomUUID();
const usesLegacyScp = sourcePane.connection.fileProtocol === "scp" || targetPane.connection.fileProtocol === "scp";
const sourceCacheKey = connectionCacheKeyMapRef.current.get(sourcePane.connection.id);
const targetCacheKey = connectionCacheKeyMapRef.current.get(targetPane.connection.id);
const encodingSafeForExec = [sourcePane.filenameEncoding, targetPane.filenameEncoding]
.every((encoding) => !encoding || encoding === "utf-8" || encoding === "auto");
// Select the existing non-resumable cp route only for newly created copies.
// Resume/recovery keeps its saved strategy and checkpoints. If cp fails,
// recursive/download+upload fallback also remains non-resumable.
const useSameHostCopy = !usesLegacyScp
&& !sourcePane.connection.isLocal && !targetPane.connection.isLocal
&& !!sourceCacheKey && sourceCacheKey === targetCacheKey
&& encodingSafeForExec;
const newTasks: TransferTask[] = [];
const skippedResults: TransferResult[] = [];
@@ -1593,10 +1582,8 @@ export const useSftpTransfers = ({
sourceLastModified,
preflightStatSkipped: preflightSkippedNames.has(file.name),
origin: "manual",
resumable: !usesLegacyScp && !useSameHostCopy,
pauseUnavailableReason: usesLegacyScp
? "This server uses legacy SCP; cancel and retry from the beginning instead"
: useSameHostCopy ? "Same-host copies cannot be paused; cancel and retry from the beginning instead" : undefined,
resumable: !usesLegacyScp,
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
phase: file.isDirectory ? "scanning" : "transferring",
});
}
+3 -1
View File
@@ -55,7 +55,7 @@ interface AIChatPanelContentProps {
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
inputValue: string;
setInputValue: (value: string) => void;
handleSend: () => void;
handleSend: (textOverride?: string) => void;
handleCompact: () => void;
/** Rename a chat by id (history drawer edit affordance). Panel action, never agent content. */
onRenameSession?: (sessionId: string, title: string) => void;
@@ -211,6 +211,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
parked = false,
sending = false,
}) => {
const handleContinue = React.useCallback(() => handleSend(t('ai.chat.continue')), [handleSend, t]);
const mentionHosts = React.useMemo(
() => terminalSessions.map((session) => ({
sessionId: session.sessionId,
@@ -345,6 +346,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
onContinue={handleContinue}
activeSessionId={activeSessionId}
activeCompaction={activeCompaction}
contextCompaction={activeSession?.contextCompaction}
+10 -10
View File
@@ -1610,14 +1610,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
const handleSend = useCallback(async () => {
const handleSend = useCallback(async (textOverride?: string) => {
const draft = currentDraftRef.current;
const currentPanelView = panelViewRef.current;
const currentSessionView = activeSessionRef.current;
if (!validateNoteMentions(draft?.attachments ?? [])) return;
const trimmed = draft?.text.trim() ?? '';
if (textOverride === undefined && !validateNoteMentions(draft?.attachments ?? [])) return;
const trimmed = (textOverride ?? draft?.text ?? '').trim();
const sendScopeKey = scopeKey;
const attachments = (draft?.attachments ?? []).map((file) => ({
const attachments = (textOverride === undefined ? draft?.attachments ?? [] : []).map((file) => ({
base64Data: file.base64Data,
mediaType: file.mediaType,
filename: file.filename,
@@ -1645,12 +1645,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const selectedSkillSlugs = textOverride === undefined ? draft?.selectedUserSkillSlugs ?? [] : [];
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isInlineTextAttachment(attachment));
const isDraftMode = currentPanelView.mode === 'draft';
flushDraftText();
if (textOverride === undefined) flushDraftText();
const submittedText = draft?.text ?? '';
const keepPendingAfterSend = () => {
const pending = pendingComposerTextRef.current;
@@ -1739,7 +1739,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const createdSession = createSession(scope, sendAgentId);
sessionId = createdSession.id;
currentSession = createdSession;
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
showScopeSessionView(createdSession.id);
setActiveSessionId(createdSession.id);
sessionSendGateKey = sessionId;
@@ -1770,7 +1770,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
showScopeSessionView(sessionId);
}
return;
@@ -1785,7 +1785,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
showScopeSessionView(sessionId);
}
return;
@@ -1798,7 +1798,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
if (textOverride === undefined) clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
showScopeSessionView(sessionId);
setActiveSessionId(sessionId);
setStreamingForScope(sessionId, true);
+62
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
@@ -644,3 +645,64 @@ test('fork is offered only for safe completed replies and hidden while streaming
assert.equal((render(false).match(/Fork from here/g) ?? []).length, 1);
assert.equal((render(true).match(/Fork from here/g) ?? []).length, 0);
});
test('ChatMessageList renders a non-error iteration limit notice with a Continue action', () => {
const messages: ChatMessage[] = [
{ id: 'poll', role: 'tool', content: '', timestamp: 1,
toolResults: [{ toolCallId: 'poll-1', content: '{"jobId":"job-1","status":"running"}' }] },
{ id: 'limit', role: 'assistant', content: '', timestamp: 2, iterationLimit: 20 },
];
const render = (isStreaming: boolean) => renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<ChatMessageList messages={messages} isStreaming={isStreaming} onContinue={() => {}} />
</TooltipProvider>
</I18nProvider>,
);
const markup = render(false);
assert.match(markup, /Tool-loop limit \(20 iterations\) reached/);
assert.match(markup, /<button[^>]*>Continue<\/button>/);
assert.doesNotMatch(markup, /bg-destructive/);
assert.match(render(true), /<button[^>]*disabled=""[^>]*>Continue<\/button>/);
const source = readFileSync(new URL('./ChatMessageList.tsx', import.meta.url), 'utf8');
const panel = readFileSync(new URL('../AIChatPanelContent.tsx', import.meta.url), 'utf8');
const sidePanel = readFileSync(new URL('../AIChatSidePanel.tsx', import.meta.url), 'utf8');
assert.match(source, /onClick=\{onContinue\}/);
assert.match(panel, /onContinue=\{handleContinue\}/);
assert.match(panel, /handleContinue = React.useCallback\(\(\) => handleSend\(t\('ai.chat.continue'\)\)/);
assert.match(sidePanel, /const handleSend = useCallback\(async \(textOverride\?: string\)/);
assert.match(sidePanel, /const trimmed = \(textOverride \?\? draft\?\.text \?\? ''\)\.trim\(\)/);
});
test('iteration limit Continue button invokes the provided send callback', async t => {
const previousWindow = globalThis.window;
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
globalThis.window = { setTimeout, clearTimeout } as unknown as Window & typeof globalThis;
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
t.after(() => {
globalThis.window = previousWindow;
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
let continued = 0;
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<I18nProvider locale="en">
<TooltipProvider>
<ChatMessageList
messages={[{ id: 'limit', role: 'assistant', content: '', timestamp: 1, iterationLimit: 20 }]}
onContinue={() => { continued += 1; }}
/>
</TooltipProvider>
</I18nProvider>,
);
});
const button = renderer!.root.findAllByType('button').find(node => node.children.includes('Continue'));
assert.ok(button);
assert.equal(button.props.disabled, false);
await act(async () => { button.props.onClick(); });
assert.equal(continued, 1);
await act(async () => { renderer!.unmount(); });
});
+16
View File
@@ -90,6 +90,7 @@ import ChatJumpNav from './ChatJumpNav';
interface ChatMessageListProps {
messages: ChatMessage[];
isStreaming?: boolean;
onContinue?: () => void;
/** Active chat session ID — used to filter standalone MCP approval blocks */
activeSessionId?: string | null;
activeCompaction?: ActiveCompactionUi | null;
@@ -185,6 +186,7 @@ export function pruneResolvedApprovals(
const ChatMessageList: React.FC<ChatMessageListProps> = ({
messages,
isStreaming,
onContinue,
activeSessionId,
activeCompaction = null,
contextCompaction,
@@ -813,6 +815,18 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
</div>
)}
{message.iterationLimit != null && (
<div role="status" className="rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-sm">
<p>{t('ai.chat.iterationLimitReached', { limit: message.iterationLimit })}</p>
<button
type="button"
onClick={onContinue}
disabled={!!isStreaming || !onContinue}
className="mt-2 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>{t('ai.chat.continue')}</button>
</div>
)}
{/* Error info */}
{message.errorInfo && (
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
@@ -1079,6 +1093,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
if (prev.isStreaming !== next.isStreaming) return false;
if (prev.onContinue !== next.onContinue) return false;
if (prev.activeSessionId !== next.activeSessionId) return false;
if (prev.notes !== next.notes) return false;
if (prev.hosts !== next.hosts) return false;
@@ -1107,6 +1122,7 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
p.statusText !== n.statusText ||
p.executionStatus !== n.executionStatus ||
p.errorInfo !== n.errorInfo ||
p.iterationLimit !== n.iterationLimit ||
p.toolCalls !== n.toolCalls ||
p.toolResults !== n.toolResults ||
p.agentActivities !== n.agentActivities ||
@@ -9,13 +9,6 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
assert.match(source, /Toggle,\s*\n\s*Select,/);
});
test("folder compression row exposes its settings search anchor", () => {
assert.match(
source,
/<SettingRow\s+anchorId="sftp-compressed-upload"\s+label=\{t\('settings\.sftp\.compressedUpload\.enable'\)\}/,
);
});
function functionBody(functionName: string): string {
const start = source.indexOf(`const ${functionName} = useCallback`);
assert.notEqual(start, -1, `${functionName} should exist`);
@@ -180,7 +180,6 @@ export default function SettingsFileAssociationsTab() {
<SectionHeader title={t('settings.sftp.compressedUpload')} />
<SettingCard>
<SettingRow
anchorId="sftp-compressed-upload"
label={t('settings.sftp.compressedUpload.enable')}
description={t('settings.sftp.compressedUpload.enableDesc')}
>
@@ -232,14 +231,12 @@ export default function SettingsFileAssociationsTab() {
</div>
</SettingRow>
<SettingRow
anchorId="sftp-skip-unchanged"
label={t('settings.sftp.skipUnchanged.enable')}
description={t('settings.sftp.skipUnchanged.enableDesc')}
>
<Toggle checked={sftpSkipUnchanged} onChange={setSftpSkipUnchanged} />
</SettingRow>
<SettingRow
anchorId="sftp-transport-idle-ttl"
label={t('settings.ssh.transportIdleTtl')}
description={t('settings.ssh.transportIdleTtl.desc')}
>
@@ -75,9 +75,6 @@ test('list hook preserves local copy/move routing, remote HTML drag, selection a
await act(async () => root.render(React.createElement(Harness, { local: false })));
await act(async () => right.handlePaneDrop(event()));
assert.equal(actions.at(-1)?.kind, 'copy');
actions.length = 0;
await act(async () => left.handleEntryDrop(entries[2], event()));
assert.deepEqual(actions, [{ kind: 'move', args: [['/files/b.txt'], '/files/folder'] }], 'same-pane remote drag stays a move');
} finally {
await act(async () => root.unmount());
clearLocalFileDrag();
-42
View File
@@ -125,48 +125,6 @@ test("settings search finds App Lock from the zh-CN locale", () => {
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
});
test("settings search finds folder compression from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [t, queries] of [
[tZhReal, ["压缩", "普通传输"]],
[tEnReal, ["compress", "tar"]],
] as const) {
for (const query of queries) {
const hits = filterSettingsSearchCatalog(query, t);
assert.ok(hits.some((hit) => hit.entry.id === "sftp-compressed-upload"), query);
}
}
});
test("settings search finds skip unchanged files from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [t, queries] of [
[tZhReal, ["跳过未改文件", "修改时间"]],
[tEnReal, ["skip unchanged", "rsync"]],
] as const) {
for (const query of queries) {
const hits = filterSettingsSearchCatalog(query, t);
assert.ok(hits.some((hit) => hit.entry.id === "sftp-skip-unchanged"), query);
}
}
});
test("settings search finds SSH connection keep-alive from the real locales", () => {
const tZhReal = (key: string) => zhCN[key] ?? key;
const tEnReal = (key: string) => en[key] ?? key;
for (const [t, queries] of [
[tZhReal, ["保活时间", "重新认证"]],
[tEnReal, ["keep-alive", "re-authentication"]],
] as const) {
for (const query of queries) {
const hits = filterSettingsSearchCatalog(query, t);
assert.ok(hits.some((hit) => hit.entry.id === "sftp-transport-idle-ttl"), query);
}
}
});
test("settings search finds temporary network device mode", () => {
const tEnReal = (key: string) => en[key] ?? key;
const tZhTW = (key: string) => zhTW[key] ?? key;
-21
View File
@@ -671,13 +671,6 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.sftp.autoSync.enableDesc",
sectionKey: "settings.sftp.autoSync",
},
{
id: "sftp-compressed-upload",
tab: "file-associations",
labelKey: "settings.sftp.compressedUpload.enable",
descriptionKey: "settings.sftp.compressedUpload.enableDesc",
sectionKey: "settings.sftp.compressedUpload",
},
{
id: "sftp-follow-terminal-cwd",
tab: "file-associations",
@@ -698,20 +691,6 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
labelKey: "settings.sftp.transferConcurrency",
descriptionKey: "settings.sftp.transferConcurrency.desc",
},
{
id: "sftp-skip-unchanged",
tab: "file-associations",
labelKey: "settings.sftp.skipUnchanged.enable",
descriptionKey: "settings.sftp.skipUnchanged.enableDesc",
sectionKey: "settings.sftp.transferConcurrency",
},
{
id: "sftp-transport-idle-ttl",
tab: "file-associations",
labelKey: "settings.ssh.transportIdleTtl",
descriptionKey: "settings.ssh.transportIdleTtl.desc",
sectionKey: "settings.sftp.transferConcurrency",
},
{
id: "sftp-default-opener",
tab: "file-associations",
@@ -281,26 +281,6 @@ test('wall deadline during authorization reports authorization failure rather th
assert.deepEqual(pty.writes, []);
});
test('idle Enter-PSSession remoting prompt allows the command to be sent', async () => {
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = '[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ';
trackSessionIdlePrompt(session, `PowerShell 7\r\n${prompt}`);
assert.equal(readyPrompt(session), prompt);
const result = await startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
typedInput: true,
}).resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, 'OK');
assert.equal(pty.writes.length, 1);
assert.match(pty.writes[0], /\$__NCMCP_/);
});
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
+3 -4
View File
@@ -50,12 +50,11 @@ function formatSyntheticEcho(command) {
}
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
// `PS /home/alice>`), with optional Enter-PSSession / remoting `[host]: `
// and debug `[DBG]: ` prefixes. Anchored so command output that merely
// starts with `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
// `PS /home/alice>`). Anchored so command output that merely starts with
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
// `"PS >"` (which the default prompt never emits) so a script that prints
// such a line can't trick prompt-driven shell-kind selection.
const POWERSHELL_PROMPT_PATTERN = /^(?:\[[^\]\r\n]+\]: )*PS(?:\s+\S.*)?>$/;
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
// Default cmd.exe prompt (e.g. `C:\>`, `C:\Users\alice>`, `D:\data>`).
// Drive letter + optional path + `>`. Rejects `C: >` (space before `>`) and
-40
View File
@@ -112,46 +112,6 @@ test("isDefaultPowerShellPromptLine matches default shapes and rejects look-alik
assert.equal(isDefaultPowerShellPromptLine(null), false);
});
test("PowerShell prompt helpers recognize remoting and debug prefixes", () => {
for (const prompt of [
"[192.168.1.10]: PS C:\\Users\\Administrator\\Documents>",
"[SERVER01]: PS C:\\>",
"[host.example.com]: PS C:\\Windows\\System32>",
"[fe80::1]: PS C:\\>",
"[DBG]: PS C:\\Users\\alice>",
"[SERVER01]: [DBG]: PS C:\\>",
]) {
assert.equal(isDefaultPowerShellPromptLine(prompt), true, prompt);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt, prompt);
}
});
test("PowerShell prompt helpers reject malformed remoting prefixes and look-alikes", () => {
for (const line of [
"[host] PS C:\\>",
"prefix [host]: PS C:\\>",
"[]: PS C:\\>",
"[ho]st]: PS C:\\>",
"[ho\rst]: PS C:\\>",
"[ho\nst]: PS C:\\>",
"[host]:PS C:\\>",
"[host]: PS >",
"PS >",
"PSO>",
"ZIPS>",
"",
null,
]) {
assert.equal(isDefaultPowerShellPromptLine(line), false, String(line));
assert.equal(extractTrailingIdlePrompt(line), "", String(line));
}
});
test("extractTrailingIdlePrompt preserves trailing space on a remoting prompt", () => {
const prompt = "[192.168.1.10]: PS C:\\Users\\Administrator\\Documents> ";
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${prompt}`), prompt);
});
test("extracts a trailing cmd.exe idle prompt", () => {
// Windows OpenSSH default shell is cmd.exe; without capturing `C:\...>`
// AI exec cannot select the cmd wrapper when shellKind is still unset.
-10
View File
@@ -1913,16 +1913,6 @@ function createSessionBackedSftpClient(sessionId, sshClient, options = {}) {
const attrs = await statAsync(sftp, remotePath);
return statResultFromAttrs(attrs);
},
async lstat(remotePath) {
const sftp = await requireSftpChannel(client);
// Copy preflights must inspect link nodes, never their followed targets.
// lstatAsync falls back to STAT when LSTAT is absent, so reject first.
if (typeof sftp.lstat !== "function") {
throw Object.assign(new Error("Remote server does not support LSTAT"), { code: "ENOTSUP" });
}
const attrs = await lstatAsync(sftp, remotePath);
return statResultFromAttrs(attrs);
},
async realPath(remotePath) {
const sftp = await requireSftpChannel(client);
return await realpathAsync(sftp, remotePath);
@@ -1,94 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { Readable, PassThrough } = require("node:stream");
const sftpBridge = require("./sftpBridge.cjs");
const transferBridge = require("./transferBridge.cjs");
const missing = () => Object.assign(new Error("ENOENT"), { code: 2 });
async function openSessionClient(t, { sourceIsLink = false, targetExists = false } = {}) {
const commands = [];
const channel = new EventEmitter();
channel.readdir = (_path, callback) => callback(null, []);
channel.mkdir = (_path, callback) => callback(null);
channel.unlink = (_path, callback) => callback(null);
channel.stat = (remotePath, callback) => {
if (String(remotePath) === "/target/file" && !targetExists) return callback(missing());
callback(null, { size: 66, mode: 0o100640, mtime: 1 });
};
channel.lstat = (remotePath, callback) => {
if (String(remotePath) === "/target" && !targetExists) return callback(missing());
callback(null, { size: 66, mode: sourceIsLink ? 0o120777 : 0o100640, mtime: 1 });
};
channel.createReadStream = () => Readable.from([Buffer.alloc(66)]);
const connection = {
sftp(callback) { callback(null, channel); },
exec(command, callback) {
commands.push(command);
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
};
const clients = new Map();
sftpBridge.init({
electronModule: { webContents: { fromId: () => null } },
sessions: new Map([["copy-session", { conn: connection }]]),
sftpClients: clients,
});
transferBridge.init({ sftpClients: clients });
const { sftpId } = await sftpBridge.openSftpForSession(null, {
sessionId: "copy-session", fileProtocol: "sftp",
});
t.after(() => sftpBridge.closeSftp(null, { sftpId }));
return { client: clients.get(sftpId), channel, commands, sftpId };
}
test("same-host file copy executes cp using the actual session-backed client (#4207)", async (t) => {
const { commands, sftpId } = await openSessionClient(t);
const result = await transferBridge.startTransfer({ sender: { send() {} } }, {
transferId: "session-backed-file-copy",
sourcePath: "/source/file", targetPath: "/target/file",
sourceType: "sftp", targetType: "sftp",
sourceSftpId: sftpId, targetSftpId: sftpId,
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: 66, sameHost: true, resumable: false,
});
assert.deepEqual(commands, ["cp -a '/source/file' '/target/file'"]);
assert.equal(result.error, undefined);
});
for (const sourceIsLink of [false, true]) {
test(`session-backed LSTAT preserves link classification (${sourceIsLink})`, async (t) => {
const { client } = await openSessionClient(t, { sourceIsLink });
assert.equal((await client.lstat("/source/file")).isSymbolicLink, sourceIsLink);
assert.equal((await client.stat("/source/file")).isSymbolicLink, false);
});
}
for (const unsupported of ["missing-method", "server-error"]) {
test(`session-backed LSTAT never follows STAT on ${unsupported}`, async (t) => {
const { client, channel } = await openSessionClient(t);
let statCalls = 0;
channel.stat = () => { statCalls++; throw new Error("must not follow symlinks"); };
if (unsupported === "missing-method") delete channel.lstat;
else channel.lstat = (_path, callback) => callback(Object.assign(new Error("unsupported"), { code: 8 }));
await assert.rejects(() => client.lstat("/source/file"), error =>
error.code === (unsupported === "missing-method" ? "ENOTSUP" : 8));
assert.equal(statCalls, 0);
});
}
for (const targetExists of [false, true]) {
test(`session-backed directory cp preserves the destination merge guard (${targetExists})`, async (t) => {
const { commands, sftpId } = await openSessionClient(t, { targetExists });
const result = await transferBridge.sameHostCopyDirectory({ sender: { send() {} } }, {
transferId: `session-backed-directory-copy-${targetExists}`,
sftpId, sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
});
assert.equal(result.success, !targetExists);
assert.deepEqual(commands, targetExists ? [] : ["cp -ra '/source/.' '/target/'"]);
});
}
+2 -44
View File
@@ -7174,13 +7174,6 @@ async function startTransferNow(event, payload, onProgress) {
const singleChannelCopy = srcClient.__netcattySingleChannelSsh || sshClient?.__netcattySingleChannelSsh;
if (sshClient && typeof sshClient.exec === 'function' && !singleChannelCopy) {
try {
// cp -a preserves link nodes; the SFTP fallback follows target bytes.
// A failed lstat also falls through to that existing route.
const sourceAttrs = await runCancelablePreflight(() => srcClient.lstat(
encodePathForSession(sourceSftpId, sourcePath, resolvedSourceEnc),
));
if (sourceAttrs.isSymbolicLink) throw new Error("Source is a symbolic link");
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try {
await runTransferAbortableOperation(transfer, (signal) => ensureRemoteDirForSession(
@@ -7193,18 +7186,6 @@ async function startTransferNow(event, payload, onProgress) {
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
}
const targetClient = sftpClients.get(targetSftpId);
let existingRemoteMode = null;
try {
const attrs = await runCancelablePreflight(() => targetClient.stat(
encodePathForSession(targetSftpId, targetPath, resolvedTargetEnc),
));
if (Number.isFinite(attrs?.mode)) existingRemoteMode = attrs.mode & 0o7777;
} catch {
// Missing destinations or failed stats need no permission restore.
if (transfer.cancelled || transfer.signal?.aborted) throw new Error("Transfer cancelled");
}
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
@@ -7213,13 +7194,6 @@ async function startTransferNow(event, payload, onProgress) {
if (result.code === 0) {
sendProgress(fileSize, fileSize);
sameHostDone = true;
await restoreRemoteUploadModeBestEffort(
targetClient,
targetSftpId,
targetPath,
resolvedTargetEnc,
existingRemoteMode,
);
} else if (result.code === 127) {
// Exit 127 = command not found — cache to skip future attempts
cpUnavailableSet.add(srcClient);
@@ -8230,28 +8204,11 @@ async function sameHostCopyDirectory(event, payload) {
}
if (cpUnavailableSet.has(client)) return { success: false };
const resolvedEncoding = resolveEncodingForRequest(sftpId, encoding);
if (resolvedEncoding && resolvedEncoding !== 'utf-8') {
return { success: false };
}
const sshClient = client.client;
if (!sshClient || typeof sshClient.exec !== 'function') {
return { success: false };
}
// Merges must use recursive SFTP transfers to preserve destination child modes.
// Only a clearly missing target is safe for the remote cp optimization.
try {
await runTransferCancelablePreflight(transfer, () => client.lstat(
encodePathForSession(sftpId, targetPath, resolvedEncoding),
));
return { success: false };
} catch (error) {
if (transfer.cancelled) throw new Error("Transfer cancelled");
if (!isMissingRemoteStageError(error)) return { success: false };
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
const lifecyclePayload = {
...payload,
@@ -8276,7 +8233,8 @@ async function sameHostCopyDirectory(event, payload) {
if (transfer.cancelled) throw new Error("Transfer cancelled");
}
// Use "source/." to copy directory *contents* into the newly created target.
// Use "source/." to copy directory *contents* into target, preserving merge
// semantics consistent with the recursive per-file transfer path.
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
@@ -106,7 +106,6 @@ test("same-host cp capability cache follows the live SFTP client, not a recycled
bridge.init({ sftpClients });
const makeClient = (exitCode, calls) => ({
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
client: {
exec(_command, callback) {
calls.count += 1;
+1 -325
View File
@@ -1294,9 +1294,7 @@ test("server-to-server resume cancellation settles while source identity verific
t.after(async () => fs.promises.rm(tempPath, { force: true }));
let markPrefixStarted;
const prefixStarted = new Promise((resolve) => { markPrefixStarted = resolve; });
let execCalls = 0;
const sourceClient = {
client: { exec() { execCalls += 1; throw new Error("checkpointed copies must not exec cp"); } },
sftp: createFastSftp({
createReadStream() {
markPrefixStarted();
@@ -1319,7 +1317,7 @@ test("server-to-server resume cancellation settles while source identity verific
targetSftpId: "target",
totalBytes: 8,
resumable: true,
sameHost: true,
sameHost: false,
resumeStage: "download",
checkpointBytes: 4,
downloadCheckpointBytes: 4,
@@ -1334,7 +1332,6 @@ test("server-to-server resume cancellation settles while source identity verific
]);
assert.match(result.error || "", /cancel/i);
assert.equal(execCalls, 0, "same-host resume must verify its saved prefix instead of running cp");
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:cancelled"), true);
assert.equal(sender.sent.some((entry) => entry.channel === "netcatty:transfer:complete"), false);
});
@@ -13246,119 +13243,6 @@ test("same-host directory copy rejected by a committed close leaves no lease or
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
});
test("same-host directory copy skips cp when auto encoding resolves to non-UTF-8", async (t) => {
const {
sftpTransferSessionLeaseStore,
} = require("./sftpTransferSessionLease.cjs");
sftpTransferSessionLeaseStore.resetForTests();
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
const sftpId = "same-host-directory-auto-gb18030";
// Seed the session's resolved encoding before the copy requests auto.
sftpBridge.resolveEncodingForRequest(sftpId, "gb18030");
let execCalls = 0;
transferBridge.init({
sftpClients: new Map([[sftpId, {
client: {
exec(_command, callback) {
execCalls += 1;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
},
}]]),
});
const sender = createSender();
const result = await transferBridge.sameHostCopyDirectory(
{ sender },
{
transferId: "same-host-directory-auto-gb18030-copy",
sftpId,
sourcePath: "/source",
targetPath: "/\u76ee\u6807",
encoding: "auto",
},
);
assert.equal(execCalls, 0);
assert.equal(result.success, false);
assert.deepEqual(sender.sent, []);
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
});
for (const targetState of ["existing", "missing", "stat-error"]) {
test(`same-host directory copy ${targetState} target uses cp only for a fresh destination`, async (t) => {
const { sftpTransferSessionLeaseStore } = require("./sftpTransferSessionLease.cjs");
sftpTransferSessionLeaseStore.resetForTests();
t.after(() => sftpTransferSessionLeaseStore.resetForTests());
const sftpId = `same-host-directory-${targetState}`;
const commands = [];
const mkdirs = [];
let targetExists = targetState === "existing";
const client = {
async lstat(remotePath) {
assert.equal(String(remotePath), "/target");
assert.deepEqual(mkdirs, [], "check the destination before ensuring it");
if (targetState === "stat-error") {
throw Object.assign(new Error("Permission denied"), { code: 3 });
}
if (!targetExists) throw Object.assign(new Error("ENOENT"), { code: 2 });
return { isDirectory: true };
},
sftp: createFastSftp({
stat(_remotePath, callback) {
if (!targetExists) {
callback(Object.assign(new Error("ENOENT"), { code: 2 }));
return;
}
callback(null, { isDirectory: () => true });
},
mkdir(remotePath, callback) {
mkdirs.push(String(remotePath));
targetExists = true;
callback(null);
},
}),
client: {
exec(command, callback) {
commands.push(command);
assert.equal(targetExists, true, "create the fresh destination before cp");
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
},
};
const sftpClients = new Map([[sftpId, client]]);
transferBridge.init({ sftpClients });
sftpBridge.init({ sftpClients });
const sender = createSender();
const result = await transferBridge.sameHostCopyDirectory({ sender }, {
transferId: `${sftpId}-copy`, sftpId,
sourcePath: "/source", targetPath: "/target", encoding: "utf-8",
});
if (targetState === "missing") {
assert.deepEqual(result, { success: true });
assert.deepEqual(commands, ["cp -ra '/source/.' '/target/'"]);
assert.deepEqual(mkdirs, ["/target"]);
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
} else {
assert.deepEqual(result, { success: false });
assert.deepEqual(commands, []);
assert.deepEqual(mkdirs, []);
assert.deepEqual(sender.sent, []);
}
assert.equal(transferBridge._getActiveTransferCountForTests(), 0);
assert.equal(sftpTransferSessionLeaseStore.getLeaseCount(sftpId), 0);
});
}
test("same-host directory copy cancellation aborts cp and releases its lease", async (t) => {
const {
sftpTransferSessionLeaseStore,
@@ -13375,7 +13259,6 @@ test("same-host directory copy cancellation aborts cp and releases its lease", a
stream.end = () => {};
stream.destroy = () => {};
const client = {
async lstat() { throw Object.assign(new Error("ENOENT"), { code: 2 }); },
client: {
exec(_command, callback) {
callback(null, stream);
@@ -13459,7 +13342,6 @@ test("same-host file cp cancellation releases the shared session lease", async (
stream.end = () => {};
stream.destroy = () => {};
const client = {
lstat: async () => ({ isSymbolicLink: false }),
client: {
exec(_command, callback) {
callback(null, stream);
@@ -15257,209 +15139,3 @@ test("pending channel-open skips the fastPut reopen and uses the shared channel"
assert.ok(sharedWrites >= 1);
assert.deepEqual(remoteFiles.get(targetPath), payload);
});
test("same-host file cp restores existing destination mode", async () => {
const commands = [];
const chmods = [];
let targetMode = 0o100600;
let downloads = 0;
let uploads = 0;
const sourceSftp = createFastSftp({
open() { downloads++; throw new Error("unexpected download"); },
});
const sourceClient = {
sftp: sourceSftp,
lstat: async () => ({ isSymbolicLink: false }),
stat: async () => ({ size: 64, mode: 0o100644 }),
client: {
sftp: (callback) => callback(null, sourceSftp),
exec(command, callback) {
commands.push(command);
targetMode = 0o100644;
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", 0));
},
},
};
const targetClient = {
sftp: createFastSftp({
open() { uploads++; throw new Error("unexpected upload"); },
}),
stat: async () => ({ size: 64, mode: targetMode }),
chmod: async (remotePath, mode) => {
chmods.push([String(remotePath), mode]);
targetMode = mode;
},
};
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId: `same-host-cp-mode-${crypto.randomUUID()}`,
sourcePath: "/source/payload.bin", targetPath: "/target/payload.bin",
sourceType: "sftp", targetType: "sftp",
sourceSftpId: "source", targetSftpId: "target",
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: 64,
sameHost: true, resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
assert.deepEqual(chmods, [["/target/payload.bin", 0o600]]);
assert.equal(targetMode, 0o600);
assert.equal(downloads, 0);
assert.equal(uploads, 0);
});
for (const sourceState of ["symlink", "lstat-error"]) {
test(`same-host file cp skips a source ${sourceState} and downloads/uploads target bytes`, async (t) => {
const transferId = `same-host-cp-fallback-${crypto.randomUUID()}`;
const sourcePath = "/opt/app/config";
const targetPath = "/tmp/app/config";
const payload = Buffer.from("contents of ../shared/config");
const commands = [];
let reads = 0;
let remote = Buffer.alloc(0);
let promoted = false;
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
const originalRead = sourceSftp.read;
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
const sourceClient = {
sftp: sourceSftp,
async lstat(remotePath) {
assert.equal(String(remotePath), sourcePath);
if (sourceState === "lstat-error") throw new Error("lstat unsupported");
return { isSymbolicLink: true, size: "../shared/config".length };
},
stat: async () => ({ size: payload.length }),
client: {
sftp: (callback) => callback(null, sourceSftp),
exec(command, callback) {
commands.push(command);
callback(new Error("unexpected exec"));
},
},
};
const targetSftp = createFastSftp({
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
write(_handle, buffer, offset, length, position, callback) {
if (remote.length < position + length) {
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
}
buffer.copy(remote, position, offset, offset + length);
callback(null);
},
close(_handle, callback) { callback(null); },
});
const targetClient = {
sftp: targetSftp,
client: { sftp: (callback) => callback(null, targetSftp) },
async stat(remotePath) {
if (String(remotePath) === targetPath && remote.length === 0) {
throw Object.assign(new Error("ENOENT"), { code: 2 });
}
return { size: remote.length };
},
rename: async () => { promoted = true; },
delete: async () => {},
};
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "config");
t.after(async () => fs.promises.rm(localStage, { force: true }));
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId, sourcePath, targetPath,
sourceType: "sftp", targetType: "sftp",
sourceSftpId: "source", targetSftpId: "target",
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: payload.length, sameHost: true, resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.deepEqual(commands, [], "source symlinks and lstat failures must bypass cp -a");
assert.ok(reads > 0, "fallback must download the followed source contents");
assert.deepEqual(remote, payload, "fallback must upload target bytes rather than the link node");
assert.equal(promoted, true);
});
}
for (const cpResult of [0, 127, 1, "exec-error"]) {
test(`fresh same-host file cp result ${cpResult} preserves copy/fallback behavior (#4207)`, async (t) => {
const transferId = `same-host-cp-${crypto.randomUUID()}`;
const payload = Buffer.from("same-host ordinary SFTP copy");
const commands = [];
const chmods = [];
let reads = 0;
let remote = Buffer.alloc(0);
let promoted = false;
const { sftp: sourceSftp } = createPipelinedDownloadSftp(payload);
const originalRead = sourceSftp.read;
sourceSftp.read = (...args) => { reads++; originalRead(...args); };
const sourceClient = {
sftp: sourceSftp,
lstat: async () => ({ isSymbolicLink: false }),
stat: async () => ({ size: payload.length }),
client: {
sftp: (callback) => callback(null, sourceSftp),
exec(command, callback) {
commands.push(command);
if (cpResult === "exec-error") { callback(new Error("exec denied")); return; }
const stream = new PassThrough();
stream.stderr = new PassThrough();
callback(null, stream);
setImmediate(() => stream.emit("close", cpResult));
},
},
};
const targetSftp = createFastSftp({
open(_path, _flags, callback) { callback(null, Buffer.from("target-handle")); },
write(_handle, buffer, offset, length, position, callback) {
if (remote.length < position + length) {
remote = Buffer.concat([remote, Buffer.alloc(position + length - remote.length)]);
}
buffer.copy(remote, position, offset, offset + length);
callback(null);
},
close(_handle, callback) { callback(null); },
});
const targetClient = {
sftp: targetSftp,
client: { sftp: (callback) => callback(null, targetSftp) },
stat: async (remotePath) => {
if (String(remotePath) === "/target/payload.bin" && remote.length === 0) {
throw Object.assign(new Error("ENOENT"), { code: 2 });
}
return { size: remote.length };
},
chmod: async (...args) => { chmods.push(args); },
rename: async () => { promoted = true; },
delete: async () => {},
};
transferBridge.init({ sftpClients: new Map([["source", sourceClient], ["target", targetClient]]) });
const localStage = tempDirBridge.getTransferTempFilePath(transferId, "payload.bin");
t.after(async () => fs.promises.rm(localStage, { force: true }));
const sender = createSender();
const result = await transferBridge.startTransfer({ sender }, {
transferId,
sourcePath: "/source/payload.bin",
targetPath: "/target/payload.bin",
sourceType: "sftp", targetType: "sftp",
sourceSftpId: "source", targetSftpId: "target",
sourceEncoding: "utf-8", targetEncoding: "utf-8",
totalBytes: payload.length,
sameHost: true, resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.deepEqual(commands, ["cp -a '/source/payload.bin' '/target/payload.bin'"]);
assert.equal(sender.sent.some(entry => entry.channel === "netcatty:transfer:complete"), true);
if (cpResult === 0) {
assert.deepEqual(chmods, [], "fresh cp must not restore destination permissions");
assert.equal(reads, 0, "successful cp must not download the source");
assert.equal(remote.length, 0, "successful cp must not upload through SFTP");
assert.equal(fs.existsSync(localStage), false);
} else {
assert.ok(reads > 0, "cp failure must fall back to SFTP download");
assert.deepEqual(remote, payload, "fallback must upload the downloaded contents");
assert.equal(promoted, true);
}
});
}
@@ -1,7 +1,9 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { jsonSchema, tool } from 'ai';
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
import { processCattyStream, shouldEmitAgentEventsForStreamChunk } from './turnDrivers/cattyStreamProcessor';
import type { CattyToolsBundle } from './capabilityTools';
import { createInitialCattyRuntimeContext } from './cattyRuntimeContext';
import type { ChatMessage } from '../types';
@@ -111,3 +113,100 @@ describe('processCattyStream reasoning continuation', () => {
assert.match(continuation?.reasoningParts?.map(part => part.text).join('') ?? '', /thinking/);
});
});
describe('processCattyStream iteration limit', () => {
const usage = {
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 1, text: 1, reasoning: undefined },
};
async function run(t: import('node:test').TestContext, replies: ('poll' | 'summary')[], maxIterations: number) {
const originalRaf = globalThis.requestAnimationFrame;
const originalCancelRaf = globalThis.cancelAnimationFrame;
t.after(() => {
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
});
globalThis.requestAnimationFrame = callback => setTimeout(() => callback(0), 0) as unknown as number;
globalThis.cancelAnimationFrame = id => clearTimeout(id);
const messages: ChatMessage[] = [{ id: 'assistant-1', role: 'assistant', content: '', timestamp: 0 }];
let calls = 0;
const model = new MockLanguageModelV4({
doStream: async () => {
const reply = replies[calls++];
assert.ok(reply, 'unexpected extra model step');
return {
stream: simulateReadableStream({ chunks: [
{ type: 'stream-start', warnings: [] },
...(reply === 'poll' ? [{
type: 'tool-call' as const,
toolCallId: `poll-${calls}`,
toolName: 'terminal_poll',
input: '{"jobId":"job-1"}',
}] : [
{ type: 'text-start' as const, id: 'summary' },
{ type: 'text-delta' as const, id: 'summary', delta: 'The job completed.' },
{ type: 'text-end' as const, id: 'summary' },
]),
{ type: 'finish', finishReason: { unified: reply === 'poll' ? 'tool-calls' : 'stop', raw: undefined }, usage },
] }),
};
},
});
await processCattyStream({
streamSessionId: 'session-1', model, systemPrompt: 'test',
toolsBundle: {
tools: {
terminal_poll: tool({
inputSchema: jsonSchema<{ jobId: string }>({
type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'],
}),
execute: async ({ jobId }) => ({ jobId, status: 'running', output: 'buffered output' }),
}),
} as unknown as CattyToolsBundle['tools'],
toolsContext: {},
},
sdkMessages: [{ role: 'user', content: 'Check the job' }],
signal: new AbortController().signal,
currentAssistantMsgId: 'assistant-1', maxIterations,
runtimeContext: createInitialCattyRuntimeContext({
chatSessionId: 'session-1', turnId: 'turn-1', permissionMode: 'auto', scopeType: 'terminal',
}),
ui: {
addMessageToSession: (sessionId, message) => {
assert.equal(sessionId, 'session-1');
messages.push(message);
},
updateMessageById: (_sessionId, id, updater) => {
const index = messages.findIndex(message => message.id === id);
if (index >= 0) messages[index] = updater(messages[index]);
},
},
});
return { messages, calls };
}
it('adds a non-error assistant notice after terminal_poll exhausts the step cap', async t => {
const { messages, calls } = await run(t, ['poll', 'poll'], 2);
assert.equal(calls, 2);
assert.equal(messages.at(-2)?.role, 'tool');
assert.match(messages.at(-2)?.toolResults?.[0].content ?? '', /job-1/);
const notice = messages.at(-1)!;
assert.equal(notice.role, 'assistant');
assert.equal(notice.iterationLimit, 2);
assert.equal(notice.errorInfo, undefined);
});
for (const [name, replies, maxIterations] of [
['natural stop before the cap', ['summary'], 2],
['natural stop on the last allowed step', ['summary'], 1],
['post-tool summary on the last allowed step', ['poll', 'summary'], 2],
] as const) {
it(`does not add a limit notice for ${name}`, async t => {
const { messages } = await run(t, [...replies], maxIterations);
assert.equal(messages.at(-1)?.content, 'The job completed.');
assert.ok(messages.every(message => message.iterationLimit === undefined));
});
}
});
@@ -141,6 +141,8 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
: undefined;
const promptCache = createCattyPromptCache(systemPrompt, model.provider, endpoint);
let reachedIterationLimit = false;
const stopAtIterationLimit = isStepCount(maxIterations);
const result = streamText({
model,
messages: promptCache.prepareMessages(sdkMessages),
@@ -152,7 +154,12 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
permissionMode: runtimeContext.permissionMode,
chatSessionId: runtimeContext.chatSessionId,
}),
stopWhen: isStepCount(maxIterations),
// The SDK evaluates stopWhen only when tool work would allow another
// step. A natural final reply (even on the last allowed step) bypasses it.
stopWhen: async (options) => {
reachedIterationLimit = await stopAtIterationLimit(options);
return reachedIterationLimit;
},
abortSignal: signal,
include: { rawChunks: true },
timeout: buildCattyStreamTimeouts({
@@ -580,6 +587,16 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
const usage = await result.usage;
const finalStep = await result.finalStep;
const performance = finalStep?.performance;
if (reachedIterationLimit && !signal.aborted) {
clearCompactionStatusFromAssistant(activeMsgId);
ui.addMessageToSession(streamSessionId, {
id: generateId(),
role: 'assistant',
content: '',
timestamp: Date.now(),
iterationLimit: maxIterations,
});
}
// onEnd can run before this consumer flushes its final text and creates the
// post-tool assistant message. Attach turn totals only after that flush.
if (usage) {
+2
View File
@@ -171,6 +171,8 @@ export interface ChatMessage {
timestamp: number;
model?: string;
providerId?: AIProviderId;
/** Catty stopped at its tool-loop cap; rendered as a non-error notice. */
iterationLimit?: number;
errorInfo?: {
type: 'network' | 'auth' | 'timeout' | 'provider' | 'agent' | 'unknown';
message: string;