Compare commits

..
Author SHA1 Message Date
bincxz d6a9590dac fix(sftp): persist repaired background transfer hierarchy 2026-10-18 13:24:53 +08:00
bincxz 744f0f8e44 fix(sftp): publish repaired hierarchy before ignoring progress 2026-10-18 13:14:15 +08:00
bincxz e894c9cbf2 fix(sftp): refresh batch eligibility on capability updates 2026-10-18 13:08:03 +08:00
bincxz 1b96e27b09 fix(sftp): keep expanded folder transfer lists responsive 2026-10-18 12:57:20 +08:00
陈大猫 df5c033dfa fix(ai): preserve Catty prompt cache and cache usage (#3714)
* fix(ai): preserve Catty prompt cache and provider usage

* fix(ai): gate explicit caching on the official Anthropic endpoint

* fix(ai): resolve provider defaults before enabling prompt caching
2026-10-18 12:20:18 +08:00
陈大猫 b5fee9632e feat(monitor): enrich and virtualize Vault monitoring views (#3713)
* feat(monitor): enrich and virtualize vault monitoring views

* fix(monitor): preserve outer gutter around sticky terminal actions

* fix(monitor): mask scrolled content behind action column corners

* feat(monitor): customize columns and clarify unavailable hosts

* fix(monitor): collect GPU data without delaying primary samples
2026-10-18 04:19:55 +08:00
23 changed files with 2059 additions and 158 deletions

No files matched your search

@@ -4007,3 +4007,111 @@ test("partial cancel failure resets recovered folder children so the re-walk can
assert.equal(afterFullCancel.find((row) => row.id === id)?.status, "cancelled");
}
});
test("lifecycle hierarchy snapshot stays stable on bytes and changes on actual state transitions", () => {
const store = createSftpTransferCenterStore();
store.publishOwner("panel", [makeTask("hierarchy-live")]);
const hierarchy = store.getLifecycleSnapshot();
store.patchTask("hierarchy-live", { transferredBytes: 5, speed: 3 });
assert.equal(store.getLifecycleSnapshot(), hierarchy);
assert.equal(store.getTask("hierarchy-live")?.transferredBytes, 5);
store.getSnapshot(); // Other progress consumers must not invalidate the hierarchy.
assert.equal(store.getLifecycleSnapshot(), hierarchy);
store.patchTask("hierarchy-live", { status: "failed", error: "fixture failure" });
assert.notEqual(store.getLifecycleSnapshot(), hierarchy);
assert.equal(store.getLifecycleSnapshot().tasks[0].status, "failed");
});
test("progress that admits or changes a task lifecycle also refreshes the hierarchy", () => {
const store = createSftpTransferCenterStore();
const initial = store.getLifecycleSnapshot();
store.ingestBackgroundEvent({ type: "progress", transferId: "late-start", transferred: 1 });
const admitted = store.getLifecycleSnapshot();
assert.notEqual(admitted, initial);
assert.equal(admitted.tasks[0].id, "late-start");
store.publishOwner("panel", [makeTask("queued-progress", "queued")]);
const queued = store.getLifecycleSnapshot();
store.ingestBackgroundEvent({ type: "progress", transferId: "queued-progress", transferred: 3 });
assert.notEqual(store.getLifecycleSnapshot(), queued);
assert.equal(store.getLifecycleSnapshot().tasks.find((task) => task.id === "queued-progress")?.status, "transferring");
const live = store.getLifecycleSnapshot();
store.ingestBackgroundEvent({ type: "progress", transferId: "queued-progress", transferred: 4 });
assert.equal(store.getLifecycleSnapshot(), live);
});
test("admission updates hierarchy state without forcing full owner lifecycle work", () => {
const store = createSftpTransferCenterStore();
const task = makeTask("admitted-hierarchy", "queued");
store.publishOwner("panel", [task]);
const queued = store.getLifecycleSnapshot();
let lifecycleNotifications = 0;
store.subscribe(() => { lifecycleNotifications += 1; });
assert.equal(store.admitTaskRun(task), "ready");
assert.notEqual(store.getLifecycleSnapshot(), queued);
assert.equal(store.getLifecycleSnapshot().tasks[0].status, "transferring");
assert.equal(lifecycleNotifications, 0, "dispatch admission keeps its existing lightweight fanout");
});
test("same-byte and ignored terminal events publish repaired background hierarchy immediately", () => {
for (const { status, type } of [
{ status: "transferring", type: "progress" },
{ status: "completed", type: "progress" },
{ status: "completed", type: "completed" },
{ status: "failed", type: "failed" },
] as const) {
const store = createSftpTransferCenterStore();
store.publishOwner("panel", [{ ...makeTask("folder"), isDirectory: true }]);
store.publishOwner("background-agent", [{ ...makeTask("orphan", status), background: true }]);
const beforeRepair = store.getLifecycleSnapshot();
let lifecycleNotifications = 0;
store.subscribe(() => { lifecycleNotifications += 1; });
store.ingestBackgroundEvent({ type, transferId: "orphan", parentTaskId: "folder", transferred: 2 });
const repaired = store.getLifecycleSnapshot();
assert.notEqual(repaired, beforeRepair, status);
assert.equal(repaired.tasks.find((task) => task.id === "orphan")?.parentTaskId, "folder", status);
assert.equal(repaired.tasks.find((task) => task.id === "orphan")?.background, false, status);
assert.equal(lifecycleNotifications, 0, "hierarchy repair keeps lightweight owner fanout");
store.ingestBackgroundEvent({ type: "progress", transferId: "orphan", parentTaskId: "folder", transferred: 3 });
assert.equal(store.getLifecycleSnapshot(), repaired, "subsequent byte ticks preserve the repaired hierarchy without rebuilding it");
assert.equal(store.getTask("orphan")?.parentTaskId, "folder");
}
});
test("repaired terminal hierarchy survives restart without another lifecycle event", (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: Date.now() });
for (const status of ["completed", "failed", "cancelled"] as const) {
let persisted = "";
let writes = 0;
const store = createSftpTransferCenterStore({ read: () => null, write: (value) => { persisted = value; writes += 1; } });
store.publishOwner("panel", [{ ...makeTask("folder"), isDirectory: true }]);
store.publishOwner("background-agent", [{ ...makeTask("orphan", status), background: true }]);
t.mock.timers.tick(300);
assert.equal(JSON.parse(persisted).tasks.find((task: TransferTask) => task.id === "orphan")?.parentTaskId, undefined);
let lifecycleNotifications = 0;
store.subscribe(() => { lifecycleNotifications += 1; });
const beforeRepairWrites = writes;
store.ingestBackgroundEvent({ type: "progress", transferId: "orphan", parentTaskId: "folder", transferred: 2 });
t.mock.timers.tick(300);
assert.equal(writes, beforeRepairWrites + 1, "structural repair is saved even when the terminal event is ignored");
const savedChild = JSON.parse(persisted).tasks.find((task: TransferTask) => task.id === "orphan");
assert.equal(savedChild?.parentTaskId, "folder", status);
assert.equal(savedChild?.background, false, status);
assert.equal(savedChild?.ownerId, "panel", status);
const restored = createSftpTransferCenterStore({ read: () => persisted, write: () => {} });
const child = restored.getTask("orphan");
if (status === "completed") {
assert.equal(child, undefined, "existing restore compaction retains completed children in their folder checkpoint");
assert.equal(restored.getTask("folder")?.directoryResumeCheckpoint?.completedEntries, 1);
} else {
assert.equal(child?.parentTaskId, "folder", status);
assert.equal(child?.background, false, status);
assert.equal(child?.ownerId, "panel", status);
}
assert.equal(lifecycleNotifications, 0, "repair retains lightweight owner notifications");
const settledWrites = writes;
store.ingestBackgroundEvent({ type: "progress", transferId: "orphan", parentTaskId: "folder", transferred: 3 });
t.mock.timers.tick(300);
assert.equal(writes, settledWrites, "unchanged late progress does not schedule recurring writes");
}
});
+48 -4
View File
@@ -109,6 +109,8 @@ export interface SftpTransferCenterStore {
*/
subscribeProgress(listener: Listener): () => void;
getSnapshot(): SftpTransferCenterSnapshot;
/** Stable task hierarchy between lifecycle changes; live rows read getTask. */
getLifecycleSnapshot(): SftpTransferCenterSnapshot;
/** Stable identity when badge counts are unchanged (avoids TopTabs thrash). */
getBadgeSnapshot(): { count: number; hasAttention: boolean };
/** Single task row — same object identity until that row is patched. */
@@ -213,6 +215,21 @@ function buildBadgeSnapshot(tasks: readonly TransferTask[]): { count: number; ha
};
}
/** Fields used by grouping, ordering and batch-action eligibility. */
function hasTransferListMetadataChange(before: TransferTask | undefined, after: TransferTask | undefined): boolean {
if (!before || !after) return before !== after;
return before.status !== after.status
|| before.parentTaskId !== after.parentTaskId
|| before.background !== after.background
|| before.startTime !== after.startTime
|| before.priority !== after.priority
|| before.ownerId !== after.ownerId
|| before.resumable !== after.resumable
|| before.reconnectRequired !== after.reconnectRequired
|| Boolean(before.conflict) !== Boolean(after.conflict)
|| (before.status === "failed" && ((before.checkpointBytes ?? 0) > 0) !== ((after.checkpointBytes ?? 0) > 0));
}
const TERMINAL_OWNER_STATUSES = new Set<TransferTask["status"]>([
"completed",
"cancelled",
@@ -420,6 +437,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
event: BackgroundTransferEvent;
}>>();
let snapshot = tasks.length > 0 ? buildSnapshot(tasks) : EMPTY_SNAPSHOT;
let lifecycleSnapshot = snapshot;
let badgeSnapshot = buildBadgeSnapshot(tasks);
let snapshotDirty = false;
const listeners = new Set<Listener>();
@@ -695,6 +713,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
}
snapshotDirty = false;
snapshot = buildSnapshot(tasks);
lifecycleSnapshot = snapshot;
refreshBadgeSnapshot();
persist(shouldPersistImmediately);
notifyDedicatedResumeWaiters();
@@ -707,19 +726,24 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
};
/** Progress-only: mutate tasks, defer snapshot rebuild, wake progress UI only. */
const emitProgress = (persistImmediately = false) => {
const emitProgress = (persistImmediately = false, lifecycleChanged = false) => {
if (deferRestoreReplayEmits) {
snapshotDirty = true;
persistenceDirty = persistenceDirty || persistImmediately;
return;
}
snapshotDirty = true;
// Admission/late progress may change a row's lifecycle without the costly
// prune/persist path. Keep the list hierarchy current, coalescing its UI
// notification with progress instead of waking owner lifecycle listeners.
if (lifecycleChanged) lifecycleSnapshot = buildSnapshot(tasks);
// Never JSON.stringify/localStorage on the pure-progress path. Sync storage
// on large directory histories freezes CSS spinners every few seconds.
// Pause / complete / fingerprint / lifecycle still force persist below.
if (persistImmediately) {
snapshotDirty = false;
snapshot = buildSnapshot(tasks);
lifecycleSnapshot = snapshot;
refreshBadgeSnapshot();
persist(true);
notifyDedicatedResumeWaiters();
@@ -1401,6 +1425,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
return () => progressListeners.delete(listener);
},
getSnapshot: () => ensureSnapshot(),
getLifecycleSnapshot: () => lifecycleSnapshot,
getBadgeSnapshot: () => badgeSnapshot,
admitTaskRun(incoming, pausedAtResume, completedAtRestart) {
const rootId = incoming.parentTaskId ?? incoming.id;
@@ -1454,7 +1479,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
};
tasks = next;
captureObservedTask(next[index]);
emitProgress(false);
emitProgress(false, hasTransferListMetadataChange(existing, next[index]));
return "ready";
},
observeTaskSettlement(expected, ignoredCompletion, onIdentityConflict) {
@@ -1773,7 +1798,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (fingerprintChanged) emit(true);
else if (directoryCheckpointChanged) emit(true);
else if (lifecycleChanged) emit();
else emitProgress(false);
else emitProgress(false, hasTransferListMetadataChange(task, nextTask));
}
},
upsertTasks(incoming) {
@@ -2173,6 +2198,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
}
let existingIndex = tasks.findIndex((task) => task.id === event.transferId);
let existing = tasks[existingIndex];
const beforeEvent = existing;
let parentControlsChanged = false;
const eventParent = event.parentTaskId
? tasks.find((task) => task.id === event.parentTaskId)
: undefined;
@@ -2190,7 +2217,16 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
} : task);
existing = tasks[existingIndex];
captureObservedTask(existing);
// Structural recovery must survive restart even if an unchanged or
// already-terminal event returns before the normal persistence path.
persist();
}
// Hierarchy repair has already changed the live row. Even an otherwise
// ignored event must publish that change; ordinary progress still emits
// only once at the end, without a second directory-sized snapshot build.
const publishRepairedHierarchy = () => {
if (existing !== beforeEvent) emitProgress(false, true);
};
// A delayed child progress event can arrive after the renderer compacted
// that completed child into its parent checkpoint. Never resurrect it as
// a live row, especially after the parent itself has finished.
@@ -2216,9 +2252,11 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
// Allow a late explicit cancel to stick; ignore everything else.
if (event.type === "completed" || event.type === "failed") {
// Keep existing terminal state (prefer cancelled over late completed).
publishRepairedHierarchy();
return;
}
if (event.type === "queued" || event.type === "started" || event.type === "progress" || event.type === "pausing" || event.type === "resumed" || event.type === "paused") {
publishRepairedHierarchy();
return;
}
}
@@ -2263,6 +2301,7 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
&& sameOptional(event.resumable, existing.resumable)
&& sameOptional(event.pauseUnavailableReason, existing.pauseUnavailableReason)
) {
publishRepairedHierarchy();
return;
}
}
@@ -2452,6 +2491,8 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
};
});
if (event.resumable === false && existing.parentTaskId) {
const parent = tasks.find((task) => task.id === existing.parentTaskId);
parentControlsChanged = parent !== undefined && parent.resumable !== false;
tasks = tasks.map((task) => task.id === existing.parentTaskId ? {
...task,
resumable: false,
@@ -2508,8 +2549,11 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
if (existingIndex >= 0) captureObservedTask(tasks[existingIndex]);
// Progress-only ticks skip prune + coalesce listener paints; lifecycle
// events still take the full emit path so UI never lags on pause/cancel.
const currentTask = tasks[existingIndex];
const hierarchyOrLifecycleChanged = parentControlsChanged
|| hasTransferListMetadataChange(beforeEvent, currentTask);
if (event.type === "progress" && !persistImmediately) {
emitProgress(false);
emitProgress(false, hierarchyOrLifecycleChanged);
} else {
emit(persistImmediately);
}
@@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
test("expanded folder history bounds mounted files, isolates progress, and reaches the final child", async (t) => {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({
MutationObserver: env.window.MutationObserver,
NodeFilter: env.window.NodeFilter,
HTMLInputElement: env.window.HTMLInputElement,
Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
})) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
Object.defineProperties(env.window.HTMLElement.prototype, {
offsetWidth: { configurable: true, get: () => 460 },
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-child-list" ? 192 : 460; } },
});
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
const { TooltipProvider } = await import("./ui/tooltip.tsx");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
const childCount = 1_000;
const base = {
fileName: "folder", sourcePath: "/source", targetPath: "/target",
sourceConnectionId: "source", targetConnectionId: "local", direction: "download" as const,
totalBytes: 1_000, transferredBytes: 0, speed: 0, startTime: 1,
};
store.publishOwner("children-performance", [
{ ...base, id: "folder", status: "transferring", isDirectory: true, progressMode: "files" },
...Array.from({ length: childCount }, (_, index) => ({
...base, id: `child-${index}`, fileName: `child-${index}.bin`, sourcePath: `/source/${index}`, targetPath: `/target/${index}`,
status: index === 0 ? "transferring" as const : "queued" as const,
startTime: index + 2, isDirectory: false, parentTaskId: "folder",
})),
]);
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
const click = async (selector: string) => {
const element = env.document.querySelector(selector);
assert.ok(element, selector);
await dispatchDomEvent(element, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
};
await click("[data-section=global-sftp-transfer-toggle]");
await click('button[aria-label="Show files"]');
const childList = () => env.document.querySelector<HTMLElement>("[data-section=global-sftp-transfer-child-list]");
const childRows = () => childList()?.querySelectorAll('[data-transfer-status]') ?? [];
assert.ok(childRows().length > 0 && childRows().length < 40, `mounted ${childRows().length} child rows`);
assert.match(childList()?.textContent ?? "", /child-0.bin/);
let orderReads = 0;
for (const task of store.getSnapshot().tasks) {
if (!task.parentTaskId) continue;
const startTime = task.startTime;
Object.defineProperty(task, "startTime", { configurable: true, enumerable: true, get: () => { orderReads += 1; return startTime; } });
}
await runWithAct(async () => {
store.patchTask("child-0", { transferredBytes: 500, speed: 100 });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.ok(orderReads < 20, `one byte update re-sorted the directory (${orderReads} reads)`);
assert.match(childList()?.textContent ?? "", /500 B/);
const pauseAll = () => [...env.document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === "Pause all")!;
assert.equal(pauseAll().disabled, false);
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "child-0", transferred: 600, resumable: false, pauseUnavailableReason: "fixture cannot pause" });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(store.getTask("folder")?.resumable, false, "a child's backend capability also limits its folder");
assert.equal(pauseAll().disabled, true, "batch controls must use the current backend capability");
await runWithAct(async () => {
store.patchTask("folder", { resumable: true });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(pauseAll().disabled, false, "metadata-only patches must refresh eligibility too");
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "child-0", transferred: 650, resumable: false });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(pauseAll().disabled, true, "a parent-only capability change is reflected even when the child already reported false");
await runWithAct(async () => {
store.patchTask("folder", { resumable: false });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(pauseAll().disabled, true);
orderReads = 0;
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "child-0", transferred: 700, resumable: false });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.ok(orderReads < 20, "repeated capability metadata on byte ticks does not re-sort the directory");
await runWithAct(() => store.publishOwner("failed-control", [{
...base, id: "failed-control", status: "failed", isDirectory: false, endTime: Date.now(), resumable: true, checkpointBytes: 0,
}]));
const resumeAll = () => [...env.document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === "Resume all")!;
assert.equal(resumeAll().disabled, true);
await runWithAct(async () => {
store.patchTask("failed-control", { checkpointBytes: 20 });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(resumeAll().disabled, false, "a newly available failed-task checkpoint enables the existing resume action");
const list = childList()!;
list.scrollTop = 24 * childCount - 192;
await dispatchDomEvent(list, new env.window.Event("scroll"));
await flushEffects();
assert.ok(childRows().length > 0 && childRows().length < 40);
assert.match(childList()?.textContent ?? "", /child-999.bin/);
await runWithAct(() => store.patchTask("child-999", { status: "completed", transferredBytes: 1_000 }));
assert.equal(store.getTask("folder")?.directoryResumeCheckpoint?.completedEntries, 1, "completed files remain counted in the existing compact folder checkpoint");
assert.equal(store.getTask("child-999"), undefined, "the existing completion compaction still owns history retention");
assert.equal(store.getTask("child-500")?.status, "queued");
await click('button[aria-label="Hide files"]');
assert.equal(childList(), null);
await click('button[aria-label="Show files"]');
assert.ok(childRows().length > 0 && childRows().length < 40);
assert.equal(store.getSnapshot().tasks.filter((task) => task.parentTaskId === "folder").length, childCount - 1, "virtualization never drops queued files");
await runWithAct(() => {
store.publishOwner("repair-folder", [{ ...base, id: "repair-folder", fileName: "repaired folder", status: "transferring", isDirectory: true }]);
store.publishOwner("repair-background", [{ ...base, id: "repair-child", fileName: "repaired-child.bin", status: "transferring", isDirectory: false, background: true, transferredBytes: 10 }]);
});
const repairedFolderRow = () => [...env.document.querySelectorAll<HTMLElement>('[data-section=global-sftp-transfer-row]')].find((row) => row.textContent?.includes("repaired folder"))!;
assert.equal(repairedFolderRow().querySelector('button[aria-label="Show files"]'), null);
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "repair-child", parentTaskId: "repair-folder", transferred: 10 });
await new Promise((resolve) => setTimeout(resolve, 20));
});
const repairedExpand = repairedFolderRow().querySelector('button[aria-label="Show files"]');
assert.ok(repairedExpand, "same-byte hierarchy repair immediately exposes the folder's child");
await dispatchDomEvent(repairedExpand, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.match(repairedFolderRow().textContent ?? "", /repaired-child.bin/);
const repairedHierarchy = store.getLifecycleSnapshot();
await runWithAct(async () => {
store.ingestBackgroundEvent({ type: "progress", transferId: "repair-child", parentTaskId: "repair-folder", transferred: 11 });
await new Promise((resolve) => setTimeout(resolve, 20));
});
assert.equal(store.getLifecycleSnapshot(), repairedHierarchy);
assert.match(repairedFolderRow().textContent ?? "", /repaired-child.bin/);
await click("[data-section=global-sftp-transfer-toggle]");
});
+80 -76
View File
@@ -24,6 +24,7 @@ import {
sftpTransferCenterStore,
useSftpTransferCenterBadge,
useSftpTransferResuming,
useSftpTransferTask,
type SftpTransferCenterSnapshot,
} from "../application/state/sftpTransferCenterStore";
import { transferRuntime } from "../application/state/sftp/transferRuntime";
@@ -47,28 +48,22 @@ const CLOSED_TRANSFER_CENTER_SNAPSHOT: SftpTransferCenterSnapshot = {
};
/**
* Full transfer-center list only while the popover is open. When closed, the
* getter always returns the same CLOSED snapshot so progress store notifies do
* not re-render this TopTabs child (badge uses a separate stable subscription).
* Read the folder hierarchy only while open and only on lifecycle changes.
* Mounted rows subscribe to their own bytes; progress must not regroup/sort
* every folder or re-render the center and its TopTabs ancestor.
*/
function useSftpTransferCenterWhenOpen(open: boolean): SftpTransferCenterSnapshot {
const openRef = useRef(open);
openRef.current = open;
return useSyncExternalStore(
(onStoreChange) => {
// Lifecycle + progress: when the popover is open the list must move with
// bytes; when closed getSnapshot returns a stable CLOSED constant so
// progress ticks are cheap no-ops for this subscriber.
const unsubLifecycle = sftpTransferCenterStore.subscribe(onStoreChange);
const unsubProgress = sftpTransferCenterStore.subscribeProgress(onStoreChange);
return () => {
unsubLifecycle();
unsubProgress();
};
const unsubscribeLifecycle = sftpTransferCenterStore.subscribe(onStoreChange);
const unsubscribeProgress = sftpTransferCenterStore.subscribeProgress(onStoreChange);
return () => { unsubscribeLifecycle(); unsubscribeProgress(); };
},
() => (
openRef.current
? sftpTransferCenterStore.getSnapshot()
? sftpTransferCenterStore.getLifecycleSnapshot()
: CLOSED_TRANSFER_CENTER_SNAPSHOT
),
() => CLOSED_TRANSFER_CENTER_SNAPSHOT,
@@ -340,8 +335,74 @@ function formatTransferPathLine(task: Pick<TransferTask, "sourcePath" | "targetP
return `${source} → ${target}`;
}
const TransferChildRow = React.memo(function TransferChildRow({ task: snapshotTask, compact = false, frozen = false }: {
task: TransferTask;
compact?: boolean;
frozen?: boolean;
}) {
const liveTask = useSftpTransferTask(snapshotTask.id, snapshotTask);
const task = frozen ? snapshotTask : liveTask;
const { t } = useI18n();
const percent = getGlobalTransferProgressPercent(task);
const detail = formatChildByteProgress(task)
|| (task.totalBytes > 0 ? `${percent.toFixed(0)}%` : t(statusLabelKey(task.status)));
if (!compact) {
return (
<div className="flex h-6 items-center gap-2 rounded px-1 text-[10px] hover:bg-muted/40" data-transfer-status={task.status}>
<TruncatedTextWithTooltip text={task.fileName} tooltip={task.targetPath || task.fileName} className="flex-1 text-muted-foreground" />
<span className="shrink-0 font-mono text-muted-foreground">{detail}</span>
{task.status === "transferring" && <Loader2 size={10} className="shrink-0 animate-spin text-primary" />}
</div>
);
}
const childFrozen = frozen || task.status === "pausing" || task.status === "paused";
return (
<div className="min-w-0">
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<TruncatedTextWithTooltip text={task.fileName} className="flex-1 text-muted-foreground" />
<span className="shrink-0 font-mono">{detail}</span>
</div>
{task.totalBytes > 0 && (
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-secondary">
<div className={cn("h-full rounded-full", task.status === "completed" ? "bg-emerald-500/80" : childFrozen ? "bg-amber-500/80" : "bg-primary/80")}
style={{ width: `${task.status === "completed" ? 100 : percent}%` }} />
</div>
)}
</div>
);
});
const TransferChildrenList = React.memo(function TransferChildrenList({ tasks }: { tasks: readonly TransferTask[] }) {
const scrollRef = useRef<HTMLDivElement>(null);
const virtual = tasks.length > 20;
const virtualizer = useVirtualizer({
count: tasks.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 24,
getItemKey: (index) => tasks[index].id,
initialRect: { width: 0, height: 192 },
overscan: 4,
enabled: virtual,
});
return (
<div className="mt-2 border-t border-border/40 pt-2">
<div ref={scrollRef} className="max-h-48 overflow-y-auto" style={{ height: Math.min(tasks.length, 8) * 24 }} data-section="global-sftp-transfer-child-list">
{!virtual ? tasks.map((task) => <TransferChildRow key={task.id} task={task} />) : (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((row) => (
<div key={row.key} className="absolute left-0 top-0 w-full" style={{ transform: `translateY(${row.start}px)` }}>
<TransferChildRow task={tasks[row.index]} />
</div>
))}
</div>
)}
</div>
</div>
);
});
function TransferRow({
task,
task: snapshotTask,
childTasks = [],
expanded,
onToggleExpanded,
@@ -353,6 +414,7 @@ function TransferRow({
onToggleExpanded: () => void;
isLast: boolean;
}) {
const task = useSftpTransferTask(snapshotTask.id, snapshotTask);
const { t } = useI18n();
const folderReplaceWarningId = React.useId();
// Optimistic spinner from click until store status moves off paused/interrupted.
@@ -605,72 +667,14 @@ function TransferRow({
)}
data-section="global-sftp-transfer-active-children"
>
{displayChildren.map((child) => {
const childPercent = getGlobalTransferProgressPercent(child);
const childDetail = formatChildByteProgress(child);
const childFrozen = isPausing
|| task.status === "paused"
|| child.status === "pausing"
|| child.status === "paused";
return (
<div key={child.id} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-[10px] text-muted-foreground">
<TruncatedTextWithTooltip text={child.fileName} className="flex-1 text-muted-foreground" />
<span className="shrink-0 font-mono">
{childDetail || (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
</span>
</div>
{child.totalBytes > 0 && (
<div className="mt-0.5 h-1 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full rounded-full",
child.status === "completed"
? "bg-emerald-500/80"
: childFrozen
? "bg-amber-500/80"
: "bg-primary/80",
)}
style={{ width: `${child.status === "completed" ? 100 : childPercent}%` }}
/>
</div>
)}
</div>
);
})}
{displayChildren.map((child) => (
<TransferChildRow key={child.id} task={child} compact frozen={isPausing || task.status === "paused"} />
))}
</div>
)}
{isDirParent && expanded && childTasks.length > 0 && (
<div
className="mt-2 max-h-48 space-y-1 overflow-y-auto border-t border-border/40 pt-2"
data-section="global-sftp-transfer-child-list"
>
{childTasks.map((child) => {
const childPercent = getGlobalTransferProgressPercent(child);
const childDetail = formatChildByteProgress(child);
return (
<div
key={child.id}
className="flex items-center gap-2 rounded px-1 py-1 text-[10px] hover:bg-muted/40"
data-transfer-status={child.status}
>
<TruncatedTextWithTooltip
text={child.fileName}
tooltip={child.targetPath || child.fileName}
className="flex-1 text-muted-foreground"
/>
<span className="shrink-0 font-mono text-muted-foreground">
{childDetail
|| (child.totalBytes > 0 ? `${childPercent.toFixed(0)}%` : t(statusLabelKey(child.status)))}
</span>
{child.status === "transferring" && (
<Loader2 size={10} className="shrink-0 animate-spin text-primary" />
)}
</div>
);
})}
</div>
<TransferChildrenList tasks={childTasks} />
)}
{task.status === "attention" && task.conflict && canControl && (() => {
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
test("large transfer histories mount only visible rows and can scroll to the last file", async (t) => {
const env = installDomEnvironment();
@@ -64,6 +64,33 @@ test("large transfer histories mount only visible rows and can scroll to the las
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
await runWithAct(() => {
store.patchTask("virtual-file-999", { isDirectory: true, progressMode: "files" });
store.upsertTasks(Array.from({ length: 100 }, (_, index) => ({
id: `nested-file-${index}`, fileName: `nested-${index}.bin`,
sourcePath: `/source/999/${index}`, targetPath: `/target/999/${index}`,
sourceConnectionId: "source", targetConnectionId: "local", direction: "download" as const,
status: "queued" as const, totalBytes: 1_000, transferredBytes: 0, speed: 0,
startTime: index + 2_000, isDirectory: false, parentTaskId: "virtual-file-999",
})));
});
scroll.scrollTop = 0;
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
await flushEffects();
const expand = env.document.querySelector('button[aria-label="Show files"]');
assert.ok(expand);
await dispatchDomEvent(expand, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
assert.ok(env.document.querySelector('[data-section="global-sftp-transfer-child-list"]'));
scroll.scrollTop = 112 * 1_000 - 460;
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
await flushEffects();
scroll.scrollTop = 0;
await dispatchDomEvent(scroll, new env.window.Event("scroll"));
await flushEffects();
assert.ok(env.document.querySelector('button[aria-label="Hide files"]'), "folder expansion survives top-level row recycling");
assert.ok(env.document.querySelector('[data-section="global-sftp-transfer-child-list"]'));
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
await flushEffects();
});
@@ -0,0 +1,63 @@
# Catty prompt cache repair and verification
## Scope and issue history
- Original report: [#3545](https://github.com/binaricat/Netcatty/issues/3545), duplicate [#3546](https://github.com/binaricat/Netcatty/issues/3546).
- [#3547](https://github.com/binaricat/Netcatty/pull/3547) moved static guidelines before scope/session details. That repair remains valid, but its closing comment did not claim a measured DeepSeek result.
- This change starts from main `b5fee9632ea012e5c15fbdae454dec2aa7d32572` and repairs additional cache invalidation and usage-reporting paths.
## Confirmed defects and changes
1. Completed terminal results were rewritten when the next user turn rebuilt history. Keep the already bounded result, with secret redaction, and let context budgeting own necessary reductions.
2. Live SDK results use JSON while saved history uses text. Apply the same bounded compression to both before sending them, avoiding a second, different representation on the next turn.
3. Routine output cleanup inserted the latest goal and pending handles at the start of otherwise intact history. Reserve that reinjection for actual budget pressure or summarization.
4. Large-output handle notices were replaced at the front on every step. Results already containing a handle need no duplicate notice. Announce missing handles once at the tail and preserve prior notices; continue to reserve their budget if older results are pruned.
5. DeepSeek's documented `prompt_cache_hit_tokens` was lost when the OpenAI-compatible SDK parsed responses. Normalize it to the standard cached-token field before parsing, preserving an existing standard value, including zero. Cover streaming and non-streaming responses.
6. Catty omitted cache counts from its usage events and saved assistant messages. Preserve provider counts and attach aggregate usage after the final assistant message has been created.
7. Native Anthropic requests lacked cache breakpoints. Cache the static system block and a bounded conversation tail; leave implicit-cache OpenAI/Google requests untouched and preserve signed reasoning content. Enable these vendor fields only for the official HTTPS Anthropic endpoint (including the SDK's default endpoint), not third-party Messages-compatible gateways. Use the shared resolved SDK address, including OpenRouter/Ollama fallbacks when a stored URL is empty. Review follow-ups add endpoint gating and full-driver gateway/fallback regressions; they do not change the measured DeepSeek path.
## Comparison with mature agents and official guidance
- [Codex prompt-caching tests](https://github.com/openai/codex/blob/8c076942cb70fba8e5c4732c23f1faabc4aed484/codex-rs/core/tests/suite/prompt_caching.rs): exercise stable tools and append-only request prefixes. The new tests compare the actual serialized requests produced by Catty's driver, SDK and bridge adapter across consecutive turns.
- [OpenCode cache policy](https://github.com/anomalyco/opencode/blob/c38d83c0c4f92466ff9c8e12b8a741698e811012/packages/ai/src/cache-policy.ts): use provider-specific explicit cache boundaries where required and leave implicit-cache protocols alone.
- [DeepSeek context caching](https://api-docs.deepseek.com/guides/kv_cache/): exact shared prefixes, asynchronous/best-effort cache persistence and provider-reported hit/miss counts. Live probes wait between turns and use the official endpoint.
- [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching): explicit boundaries, tool/system/message ordering and a limit of four breakpoints. Catty uses at most three automatic markers.
- [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching): stable shared prefixes. No additional explicit cache-control fields are sent on this path.
## Live verification
The final fixed Catty driver was bundled from the isolated worktree and executed inside the running Electron renderer, using the app's existing IPC and configured `https://api.deepseek.com` service, model `deepseek-v4-flash`. The API key stayed inside the app's credential handling.
Each run contains six consecutive turns for each of three scenarios: plain chat, one command per turn, and a command producing 14,000 synthetic log lines followed by a bounded saved-output read. Model requests and usage are real. Tool execution uses an in-memory fixture; no command is sent to the user's connected SSH hosts. The prompts contain only synthetic session/host information.
The results file records every provider-reported input/cache/output count, per-step counts and errors. The rate is total cached input divided by total input, including all model calls in a turn. It is not an average of rounded percentages.
Two complete runs of the final source produced 36 turns / 72 real model requests, with zero errors:
| Scenario | Turns | Weighted cache hit rate | Per-turn range |
| --- | ---: | ---: | ---: |
| Plain chat | 12 | 98.55% | 98.19%-98.92% |
| Command execution | 12 | 98.59% | 98.12%-99.03% |
| Large command output + saved-output read | 12 | 97.80% | 97.30%-98.32% |
Raw per-turn and per-step counters are in [the JSON evidence file](./catty-prompt-cache-live-2026-10-18.json). These are warm-cache measurements; an earlier probe's first request correctly reported zero cached input before subsequent requests reached about 98%. The earlier probe is excluded from the final-source totals.
The production chat-message component was rendered with the live conversation in Electron. Its usage footer showed input, output, cache and reasoning counts; opening the command entry showed the expected fixture result. No product UI layout was changed.
Limits: live provider evidence covers the configured DeepSeek model. Native Anthropic request formatting and usage preservation are verified with the real SDK and a protocol fixture, not a paid Claude session. Cache hits remain provider-managed; a new prefix, necessary compaction or expired cache can legitimately miss.
## Automated verification
- Full AI/history/UI-related suite after the endpoint review follow-ups: 600 tests passed.
- New regression coverage includes six consecutive plain-chat turns, six command turns, six large-file turns, six large-terminal-output turns, long-output handles, streaming/non-streaming DeepSeek usage, aggregate multi-step usage and native Anthropic boundaries.
- Production build passed.
- Full lint passed with eight existing warnings; final changed-file lint passed without warnings.
- Type checking matches the untouched main baseline: 758 existing errors, zero additional errors after normalizing checkout paths and line numbers.
Full-repository verification also found setup and unrelated timing limits:
- The first run passed 13,968 tests and skipped 36, with one failed file caused by the fresh worktree missing the nested `plugin-cli/node_modules/yauzl` dependency. Linking the existing dependency directory repaired the setup; all 29 CLI tests then passed.
- A repeat full run hit the unchanged `large directory progress coalesces synchronous persistence work` timing assertion (2,638,624 characters persisted against a 588,644-character snapshot). That assertion passed in the prior full run. All 102 tests in its file passed on isolated rerun; the same assertion also passed on untouched main. This is an observed full-run timing fluctuation, not a claimed baseline reproduction or an SFTP repair.
- The Homebrew Node 22 binary is broken locally (missing shared library); verification uses an isolated official npm Node 22 runtime. `TMPDIR=/tmp` selects Netcatty's shorter private per-user temporary directory for Unix-socket tests. The default macOS path exceeded the FIDO test socket limit; no product source was changed to accommodate this environment.
The full suite is therefore not reported as unconditionally green. No Catty/cache test failed in the final implementation.
@@ -0,0 +1,955 @@
{
"date": "2026-10-18",
"endpoint": "https://api.deepseek.com",
"model": "deepseek-v4-flash",
"input": "synthetic prompts and in-memory tool output",
"totalTurns": 36,
"totalModelCalls": 72,
"summary": [
{
"kind": "chat",
"turns": 12,
"inputTokens": 162870,
"cachedTokens": 160512,
"weightedHitRate": 98.55,
"minTurnRate": 98.19,
"maxTurnRate": 98.92
},
{
"kind": "command",
"turns": 12,
"inputTokens": 341856,
"cachedTokens": 337024,
"weightedHitRate": 98.59,
"minTurnRate": 98.12,
"maxTurnRate": 99.03
},
{
"kind": "large-output",
"turns": 12,
"inputTokens": 598639,
"cachedTokens": 585472,
"weightedHitRate": 97.8,
"minTurnRate": 97.3,
"maxTurnRate": 98.32
}
],
"runs": [
{
"model": "deepseek-v4-flash",
"rows": [
{
"kind": "chat",
"round": 1,
"inputTokens": 13500,
"cachedTokens": 13312,
"hitRate": 98.61,
"outputTokens": 21,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 690
},
{
"kind": "chat",
"round": 2,
"inputTokens": 13529,
"cachedTokens": 13312,
"hitRate": 98.4,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 898
},
{
"kind": "chat",
"round": 3,
"inputTokens": 13558,
"cachedTokens": 13312,
"hitRate": 98.19,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 658
},
{
"kind": "chat",
"round": 4,
"inputTokens": 13587,
"cachedTokens": 13440,
"hitRate": 98.92,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 982
},
{
"kind": "chat",
"round": 5,
"inputTokens": 13616,
"cachedTokens": 13440,
"hitRate": 98.71,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 611
},
{
"kind": "chat",
"round": 6,
"inputTokens": 13645,
"cachedTokens": 13440,
"hitRate": 98.5,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 676
},
{
"kind": "command",
"round": 1,
"inputTokens": 27473,
"cachedTokens": 27008,
"hitRate": 98.31,
"outputTokens": 384,
"modelCalls": 2,
"toolResults": 1,
"milliseconds": 5711
},
{
"kind": "command",
"round": 2,
"inputTokens": 28156,
"cachedTokens": 27776,
"hitRate": 98.65,
"outputTokens": 93,
"modelCalls": 2,
"toolResults": 2,
"milliseconds": 4606
},
{
"kind": "command",
"round": 3,
"inputTokens": 28538,
"cachedTokens": 28160,
"hitRate": 98.68,
"outputTokens": 83,
"modelCalls": 2,
"toolResults": 3,
"milliseconds": 4738
},
{
"kind": "command",
"round": 4,
"inputTokens": 28940,
"cachedTokens": 28544,
"hitRate": 98.63,
"outputTokens": 113,
"modelCalls": 2,
"toolResults": 4,
"milliseconds": 5000
},
{
"kind": "command",
"round": 5,
"inputTokens": 29342,
"cachedTokens": 29056,
"hitRate": 99.03,
"outputTokens": 83,
"modelCalls": 2,
"toolResults": 5,
"milliseconds": 5011
},
{
"kind": "command",
"round": 6,
"inputTokens": 29714,
"cachedTokens": 29312,
"hitRate": 98.65,
"outputTokens": 83,
"modelCalls": 2,
"toolResults": 6,
"milliseconds": 4517
},
{
"kind": "large-output",
"round": 1,
"inputTokens": 42570,
"cachedTokens": 41472,
"hitRate": 97.42,
"outputTokens": 344,
"modelCalls": 3,
"toolResults": 2,
"milliseconds": 6895
},
{
"kind": "large-output",
"round": 2,
"inputTokens": 45647,
"cachedTokens": 44416,
"hitRate": 97.3,
"outputTokens": 163,
"modelCalls": 3,
"toolResults": 4,
"milliseconds": 5644
},
{
"kind": "large-output",
"round": 3,
"inputTokens": 48547,
"cachedTokens": 47488,
"hitRate": 97.82,
"outputTokens": 165,
"modelCalls": 3,
"toolResults": 6,
"milliseconds": 5271
},
{
"kind": "large-output",
"round": 4,
"inputTokens": 51449,
"cachedTokens": 50304,
"hitRate": 97.77,
"outputTokens": 163,
"modelCalls": 3,
"toolResults": 8,
"milliseconds": 5826
},
{
"kind": "large-output",
"round": 5,
"inputTokens": 54353,
"cachedTokens": 53248,
"hitRate": 97.97,
"outputTokens": 166,
"modelCalls": 3,
"toolResults": 10,
"milliseconds": 6235
},
{
"kind": "large-output",
"round": 6,
"inputTokens": 57280,
"cachedTokens": 56320,
"hitRate": 98.32,
"outputTokens": 168,
"modelCalls": 3,
"toolResults": 12,
"milliseconds": 5692
}
],
"steps": [
{
"kind": "chat",
"round": 1,
"promptTokens": 13500,
"cachedTokens": 13312,
"completionTokens": 21
},
{
"kind": "chat",
"round": 2,
"promptTokens": 13529,
"cachedTokens": 13312,
"completionTokens": 4
},
{
"kind": "chat",
"round": 3,
"promptTokens": 13558,
"cachedTokens": 13312,
"completionTokens": 4
},
{
"kind": "chat",
"round": 4,
"promptTokens": 13587,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "chat",
"round": 5,
"promptTokens": 13616,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "chat",
"round": 6,
"promptTokens": 13645,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "command",
"round": 1,
"promptTokens": 13531,
"cachedTokens": 13312,
"completionTokens": 364
},
{
"kind": "command",
"round": 1,
"promptTokens": 13942,
"cachedTokens": 13696,
"completionTokens": 20
},
{
"kind": "command",
"round": 2,
"promptTokens": 14018,
"cachedTokens": 13824,
"completionTokens": 73
},
{
"kind": "command",
"round": 2,
"promptTokens": 14138,
"cachedTokens": 13952,
"completionTokens": 20
},
{
"kind": "command",
"round": 3,
"promptTokens": 14214,
"cachedTokens": 14080,
"completionTokens": 63
},
{
"kind": "command",
"round": 3,
"promptTokens": 14324,
"cachedTokens": 14080,
"completionTokens": 20
},
{
"kind": "command",
"round": 4,
"promptTokens": 14400,
"cachedTokens": 14208,
"completionTokens": 93
},
{
"kind": "command",
"round": 4,
"promptTokens": 14540,
"cachedTokens": 14336,
"completionTokens": 20
},
{
"kind": "command",
"round": 5,
"promptTokens": 14616,
"cachedTokens": 14464,
"completionTokens": 63
},
{
"kind": "command",
"round": 5,
"promptTokens": 14726,
"cachedTokens": 14592,
"completionTokens": 20
},
{
"kind": "command",
"round": 6,
"promptTokens": 14802,
"cachedTokens": 14592,
"completionTokens": 63
},
{
"kind": "command",
"round": 6,
"promptTokens": 14912,
"cachedTokens": 14720,
"completionTokens": 20
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 13558,
"cachedTokens": 13312,
"completionTokens": 243
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 14394,
"cachedTokens": 13696,
"completionTokens": 97
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 14618,
"cachedTokens": 14464,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 14705,
"cachedTokens": 14464,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 15360,
"cachedTokens": 14720,
"completionTokens": 96
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 15582,
"cachedTokens": 15232,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 15669,
"cachedTokens": 15488,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 16326,
"cachedTokens": 15616,
"completionTokens": 98
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 16552,
"cachedTokens": 16384,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 16639,
"cachedTokens": 16384,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 17294,
"cachedTokens": 16640,
"completionTokens": 96
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 17516,
"cachedTokens": 17280,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 17603,
"cachedTokens": 17408,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 18261,
"cachedTokens": 17536,
"completionTokens": 99
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 18489,
"cachedTokens": 18304,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 18576,
"cachedTokens": 18432,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 19236,
"cachedTokens": 18560,
"completionTokens": 101
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 19468,
"cachedTokens": 19328,
"completionTokens": 4
}
],
"errors": []
},
{
"model": "deepseek-v4-flash",
"rows": [
{
"kind": "chat",
"round": 1,
"inputTokens": 13500,
"cachedTokens": 13312,
"hitRate": 98.61,
"outputTokens": 57,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 1278
},
{
"kind": "chat",
"round": 2,
"inputTokens": 13529,
"cachedTokens": 13312,
"hitRate": 98.4,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 658
},
{
"kind": "chat",
"round": 3,
"inputTokens": 13558,
"cachedTokens": 13312,
"hitRate": 98.19,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 809
},
{
"kind": "chat",
"round": 4,
"inputTokens": 13587,
"cachedTokens": 13440,
"hitRate": 98.92,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 958
},
{
"kind": "chat",
"round": 5,
"inputTokens": 13616,
"cachedTokens": 13440,
"hitRate": 98.71,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 857
},
{
"kind": "chat",
"round": 6,
"inputTokens": 13645,
"cachedTokens": 13440,
"hitRate": 98.5,
"outputTokens": 4,
"modelCalls": 1,
"toolResults": 0,
"milliseconds": 1061
},
{
"kind": "command",
"round": 1,
"inputTokens": 27300,
"cachedTokens": 26880,
"hitRate": 98.46,
"outputTokens": 195,
"modelCalls": 2,
"toolResults": 1,
"milliseconds": 4773
},
{
"kind": "command",
"round": 2,
"inputTokens": 27785,
"cachedTokens": 27264,
"hitRate": 98.12,
"outputTokens": 84,
"modelCalls": 2,
"toolResults": 2,
"milliseconds": 4529
},
{
"kind": "command",
"round": 3,
"inputTokens": 28142,
"cachedTokens": 27776,
"hitRate": 98.7,
"outputTokens": 67,
"modelCalls": 2,
"toolResults": 3,
"milliseconds": 4584
},
{
"kind": "command",
"round": 4,
"inputTokens": 28482,
"cachedTokens": 28032,
"hitRate": 98.42,
"outputTokens": 67,
"modelCalls": 2,
"toolResults": 4,
"milliseconds": 4706
},
{
"kind": "command",
"round": 5,
"inputTokens": 28822,
"cachedTokens": 28416,
"hitRate": 98.59,
"outputTokens": 67,
"modelCalls": 2,
"toolResults": 5,
"milliseconds": 4558
},
{
"kind": "command",
"round": 6,
"inputTokens": 29162,
"cachedTokens": 28800,
"hitRate": 98.76,
"outputTokens": 67,
"modelCalls": 2,
"toolResults": 6,
"milliseconds": 4496
},
{
"kind": "large-output",
"round": 1,
"inputTokens": 42428,
"cachedTokens": 41344,
"hitRate": 97.45,
"outputTokens": 270,
"modelCalls": 3,
"toolResults": 2,
"milliseconds": 5945
},
{
"kind": "large-output",
"round": 2,
"inputTokens": 45447,
"cachedTokens": 44416,
"hitRate": 97.73,
"outputTokens": 164,
"modelCalls": 3,
"toolResults": 4,
"milliseconds": 5125
},
{
"kind": "large-output",
"round": 3,
"inputTokens": 48356,
"cachedTokens": 47232,
"hitRate": 97.68,
"outputTokens": 166,
"modelCalls": 3,
"toolResults": 6,
"milliseconds": 5238
},
{
"kind": "large-output",
"round": 4,
"inputTokens": 51279,
"cachedTokens": 50176,
"hitRate": 97.85,
"outputTokens": 167,
"modelCalls": 3,
"toolResults": 8,
"milliseconds": 5535
},
{
"kind": "large-output",
"round": 5,
"inputTokens": 54195,
"cachedTokens": 53120,
"hitRate": 98.02,
"outputTokens": 164,
"modelCalls": 3,
"toolResults": 10,
"milliseconds": 5977
},
{
"kind": "large-output",
"round": 6,
"inputTokens": 57088,
"cachedTokens": 55936,
"hitRate": 97.98,
"outputTokens": 162,
"modelCalls": 3,
"toolResults": 12,
"milliseconds": 5391
}
],
"steps": [
{
"kind": "chat",
"round": 1,
"promptTokens": 13500,
"cachedTokens": 13312,
"completionTokens": 57
},
{
"kind": "chat",
"round": 2,
"promptTokens": 13529,
"cachedTokens": 13312,
"completionTokens": 4
},
{
"kind": "chat",
"round": 3,
"promptTokens": 13558,
"cachedTokens": 13312,
"completionTokens": 4
},
{
"kind": "chat",
"round": 4,
"promptTokens": 13587,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "chat",
"round": 5,
"promptTokens": 13616,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "chat",
"round": 6,
"promptTokens": 13645,
"cachedTokens": 13440,
"completionTokens": 4
},
{
"kind": "command",
"round": 1,
"promptTokens": 13531,
"cachedTokens": 13312,
"completionTokens": 191
},
{
"kind": "command",
"round": 1,
"promptTokens": 13769,
"cachedTokens": 13568,
"completionTokens": 4
},
{
"kind": "command",
"round": 2,
"promptTokens": 13829,
"cachedTokens": 13568,
"completionTokens": 80
},
{
"kind": "command",
"round": 2,
"promptTokens": 13956,
"cachedTokens": 13696,
"completionTokens": 4
},
{
"kind": "command",
"round": 3,
"promptTokens": 14016,
"cachedTokens": 13824,
"completionTokens": 63
},
{
"kind": "command",
"round": 3,
"promptTokens": 14126,
"cachedTokens": 13952,
"completionTokens": 4
},
{
"kind": "command",
"round": 4,
"promptTokens": 14186,
"cachedTokens": 13952,
"completionTokens": 63
},
{
"kind": "command",
"round": 4,
"promptTokens": 14296,
"cachedTokens": 14080,
"completionTokens": 4
},
{
"kind": "command",
"round": 5,
"promptTokens": 14356,
"cachedTokens": 14208,
"completionTokens": 63
},
{
"kind": "command",
"round": 5,
"promptTokens": 14466,
"cachedTokens": 14208,
"completionTokens": 4
},
{
"kind": "command",
"round": 6,
"promptTokens": 14526,
"cachedTokens": 14336,
"completionTokens": 63
},
{
"kind": "command",
"round": 6,
"promptTokens": 14636,
"cachedTokens": 14464,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 13558,
"cachedTokens": 13312,
"completionTokens": 166
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 14320,
"cachedTokens": 13696,
"completionTokens": 100
},
{
"kind": "large-output",
"round": 1,
"promptTokens": 14550,
"cachedTokens": 14336,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 14637,
"cachedTokens": 14464,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 15293,
"cachedTokens": 14592,
"completionTokens": 97
},
{
"kind": "large-output",
"round": 2,
"promptTokens": 15517,
"cachedTokens": 15360,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 15604,
"cachedTokens": 15360,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 16262,
"cachedTokens": 15616,
"completionTokens": 99
},
{
"kind": "large-output",
"round": 3,
"promptTokens": 16490,
"cachedTokens": 16256,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 16577,
"cachedTokens": 16384,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 17236,
"cachedTokens": 16512,
"completionTokens": 100
},
{
"kind": "large-output",
"round": 4,
"promptTokens": 17466,
"cachedTokens": 17280,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 17553,
"cachedTokens": 17408,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 18209,
"cachedTokens": 17536,
"completionTokens": 97
},
{
"kind": "large-output",
"round": 5,
"promptTokens": 18433,
"cachedTokens": 18176,
"completionTokens": 4
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 18520,
"cachedTokens": 18304,
"completionTokens": 63
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 19174,
"cachedTokens": 18432,
"completionTokens": 95
},
{
"kind": "large-output",
"round": 6,
"promptTokens": 19394,
"cachedTokens": 19200,
"completionTokens": 4
}
],
"errors": []
}
]
}
@@ -0,0 +1,96 @@
# Expanded SFTP folder-list responsiveness
The global transfer center already limited mounted top-level rows, but expanding
a folder rendered every retained child file. Each progress update also rebuilt
all folder groups and sorted their children. A folder with thousands of queued
files therefore stalled the renderer even when only one file's bytes changed.
The child list now mounts its visible range with a small overscan. Its existing
scroll area still reaches every retained file. The transfer center reads a stable
hierarchy snapshot between lifecycle changes; mounted parent/child rows subscribe
to their own live progress. Progress that introduces a row or changes its status
still refreshes the hierarchy. Completion retention and compact checkpoints are
unchanged.
## Controlled measurements
A separate Electron window mounted the actual before/after transfer center with
two synthetic directory transfers. No SSH connection, real file or user transfer
was involved. The same expand action, ten updates to one child and a scroll to
the final file were exercised in each version.
| Children | Work | Before | After |
| ---: | --- | ---: | ---: |
| 1,000 | Expand to next rendered frame | 298.8 ms | 10.94 ms |
| 1,000 | One child's progress to next frame | 112.6–125.6 ms | 1.18–18.18 ms |
| 5,000 | Expand to next rendered frame | 1,216.1 ms | 14.36 ms |
| 5,000 | One child's progress to next frame | 500.5–538.4 ms | 1.31–6.03 ms |
| 1,000 / 5,000 | Mounted child rows | 1,000 / 5,000 | 12 / 12 |
Both repaired lists reached their final child and retained all 1,000 / 5,000
queued records. Native scrolling, collapse/reopen and live byte display were
also checked. These are development-fixture measurements on one machine, not a
cross-device frame-rate guarantee.
The actual-component regression fails on the original implementation because
it mounts 1,000 children. It verifies bounded mounted rows, live progress without
re-sorting the folder, final-child access, completion accounting and collapse /
reopen. The existing top-level recycling test also expands a folder, scrolls it
out of view and returns to its retained expansion state.
## Issue relationship
Related to #3155. Its Windows report and subsequent comments cover multiple
possible large-transfer failures; they do not establish that this exact expanded
folder-list workload caused the reporter's freeze. This repair is independently
reproduced UI work and must not close that issue. Open #3694 and #3696 modify
file metadata / transfer connection acquisition, not this list behavior.
## Control availability follow-up
Independent review found that the initial stable snapshot did not refresh when
progress reported a transfer could no longer pause, including a child's change
propagated to its folder. Metadata-only patches and a newly available failed
transfer checkpoint could similarly leave batch buttons stale. The list now
refreshes its lightweight snapshot only when grouping/order/control eligibility
fields change; repeated identical capability metadata on byte ticks keeps the
hierarchy stable.
The actual-component regression first failed with Pause all incorrectly enabled
after the backend reported the folder could not pause. It now covers that child /
parent update, direct metadata changes, repeated unchanged metadata without a
full sort, and Resume all becoming available when a failed task gains a checkpoint.
An Electron comparison against initial repair 1b96e27b0 confirmed stale batch
buttons before and correct disabled/enabled states afterward. The final 1,000 /
5,000 file runs still mounted 12 rows and reached the final file: expand-to-frame
12.6 / 12.86 ms, child progress 1.32–15.42 / 1.06–17.90 ms.
## Hierarchy repair follow-up
A same-byte background event can repair a formerly top-level child before the
progress de-duplication guard returns. The repaired hierarchy must be published
even when the remaining event is ignored, including late events for terminal
rows. Those early exits now publish only when repair actually replaced the row;
normal progress still builds at most one hierarchy snapshot and ordinary byte
ticks keep its reference stable.
Both the store and actual-center regressions failed before this repair. The
Electron comparison against e894c9cb confirmed the old folder had no Show files
action after the same-byte event or its next byte tick. The repaired folder
immediately exposes its child, and the next byte tick keeps that grouping
without rebuilding it. A fresh 5,000-file run mounted 12 rows, reached the final
file and retained all 5,000 records: expansion 14.12 ms, progress 1.28-18.58 ms.
The repaired hierarchy also uses the existing coalesced persistence scheduler
at the structural mutation, so an ignored terminal event cannot leave the old
orphan on disk indefinitely. A restart regression first failed with zero writes
for the repair; completed, failed and cancelled cases now each save the change
once, restore under the folder (completed children through the existing compact
checkpoint), and schedule no further writes on unchanged late progress. Owner
lifecycle notifications stay at zero. An isolated actual Electron renderer
compared 744f0f8e against the repair and confirmed all three old orphan restores
and all three repaired restores. This run used synthetic in-memory persistence;
manual window input was unavailable because macOS was locked. Earlier native
folder scrolling and appearance evidence remains separate.
@@ -30,7 +30,7 @@ export interface CattyStreamChunk {
stepNumber?: number;
}
const STEP_HANDLE_NOTICE_RE = /^\[step \d+\] Tool output handles available:/;
const STEP_HANDLE_NOTICE_RE = /^(?:\[step \d+\] Tool output handles available:|\[Tool output handles available:)/;
function isStepHandleNoticeMessage(content: unknown): boolean {
return typeof content === 'string' && STEP_HANDLE_NOTICE_RE.test(content);
@@ -0,0 +1,251 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { CattyTurnDriver } from './turnDrivers/cattyTurnDriver';
import { ToolOutputStore } from './toolOutputStore';
import { ToolResultDedup } from './toolResultDedup';
import { SessionStateStore } from './sessionState';
import type { CattyTurnInput, TurnDriverContext } from './turnDrivers/types';
import type { AgentEvent } from './types';
import type { AISession, ChatMessage, ProviderConfig } from '../types';
interface WireMessage {
role: string;
content: unknown;
}
interface WireRequest {
messages: WireMessage[];
tools?: unknown[];
system?: Array<{ type: string; text: string; cache_control?: unknown }>;
}
type Reply = { tool: 'terminal_execute' | 'sftp_read_file' } | { text: string };
function fixture(t: test.TestContext, replies: Reply[], style: 'openai' | 'anthropic' = 'openai', terminalOutput = 'small output') {
const originalWindow = globalThis.window;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancelRaf = globalThis.cancelAnimationFrame;
t.after(() => {
globalThis.window = originalWindow;
globalThis.requestAnimationFrame = originalRaf;
globalThis.cancelAnimationFrame = originalCancelRaf;
});
globalThis.requestAnimationFrame = callback => setTimeout(() => callback(0), 0) as unknown as number;
globalThis.cancelAnimationFrame = id => clearTimeout(id);
const requests: WireRequest[] = [];
const urls: string[] = [];
const events: AgentEvent[] = [];
const messages: ChatMessage[] = [];
const data = new Map<string, (value: string) => void>();
const end = new Map<string, () => void>();
const bridge = {
aiSetChatSessionCancelled: async () => ({ ok: true }),
aiMcpUpdateSessions: async () => undefined,
aiExec: async () => ({ ok: true, stdout: terminalOutput, stderr: '', exitCode: 0 }),
aiCapability: async (_method: string, params: Record<string, unknown>) => ({
ok: true,
path: params.path,
content: Array.from({ length: 2000 }, (_, i) => `log ${i}: synthetic ${String(params.path)}`).join('\n'),
}),
onAiStreamData: (id: string, callback: (value: string) => void) => {
data.set(id, callback);
return () => { data.delete(id); };
},
onAiStreamEnd: (id: string, callback: () => void) => {
end.set(id, callback);
return () => { end.delete(id); };
},
onAiStreamError: () => () => undefined,
aiChatCancel: async () => true,
aiChatStream: async (id: string, url: string, _headers: Record<string, string>, body: string) => {
urls.push(url);
requests.push(JSON.parse(body) as WireRequest);
const reply = replies[requests.length - 1];
assert.ok(reply, 'unexpected model request');
const send = (chunk: unknown) => data.get(id)?.(JSON.stringify(chunk));
if (style === 'anthropic') {
send({ type: 'message_start', message: {
id: `response-${requests.length}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
content: [], stop_reason: null, stop_sequence: null,
usage: { input_tokens: 100, output_tokens: 0, cache_read_input_tokens: 900, cache_creation_input_tokens: 0 },
} });
send({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
send({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'OK' } });
send({ type: 'content_block_stop', index: 0 });
send({ type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } });
send({ type: 'message_stop' });
} else {
const toolReply = 'tool' in reply;
const delta = toolReply ? {
role: 'assistant',
tool_calls: [{ index: 0, id: `call-${requests.length}`, type: 'function', function: {
name: reply.tool,
arguments: JSON.stringify(reply.tool === 'terminal_execute'
? { sessionId: 'terminal-1', command: `printf audit-${requests.length}` }
: { sessionId: 'terminal-1', path: `/fixture/log-${requests.length}` }),
} }],
} : { role: 'assistant', content: reply.text };
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model',
choices: [{ index: 0, delta, finish_reason: null }] });
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model',
choices: [{ index: 0, delta: {}, finish_reason: toolReply ? 'tool_calls' : 'stop' }] });
send({ id: 'response', object: 'chat.completion.chunk', created: 0, model: 'test-model', choices: [],
usage: { prompt_tokens: 1000, completion_tokens: 1, total_tokens: 1001,
prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100 } });
data.get(id)?.('[DONE]');
}
end.get(id)?.();
return { ok: true, statusCode: 200 };
},
};
globalThis.window = { netcatty: bridge } as unknown as Window & typeof globalThis;
const store = new ToolOutputStore();
const dedup = new ToolResultDedup();
const state = new SessionStateStore();
const provider: ProviderConfig = {
id: 'fixture', providerId: 'custom', name: 'Fixture', enabled: true, style,
defaultModel: style === 'anthropic' ? 'claude-sonnet-4-5' : 'test-model',
baseURL: style === 'anthropic' ? 'https://api.anthropic.com' : 'https://provider.example.test/v1', apiKey: 'not-a-real-key',
};
let turnNumber = 0;
return {
requests, urls, events, messages, provider,
async turn(maxIterations = 1) {
turnNumber += 1;
const history = structuredClone(messages);
const userText = `Audit round ${turnNumber}`;
const assistantId = `assistant-${turnNumber}`;
messages.push({ id: `user-${turnNumber}`, role: 'user', content: userText, timestamp: turnNumber },
{ id: assistantId, role: 'assistant', content: '', timestamp: turnNumber });
const currentSession: AISession = {
id: 'chat-1', title: 'Fixture', agentId: 'catty', scope: { type: 'terminal', targetId: 'terminal-1' },
createdAt: 0, updatedAt: turnNumber, messages: history,
};
const signal = new AbortController().signal;
const input: CattyTurnInput = {
backend: 'catty', chatSessionId: 'chat-1', sendScopeKey: 'chat-1', userText, signal,
currentSession, assistantMsgId: assistantId, maxIterations, bridge,
context: {
activeProvider: provider, activeModelId: provider.defaultModel!, scopeType: 'terminal', scopeLabel: 'Fixture',
globalPermissionMode: 'auto', commandBlocklist: [], autoTitleSession: () => undefined,
terminalSessions: [{ sessionId: 'terminal-1', hostname: 'fixture.example.test', label: 'Fixture', connected: true }],
},
ui: {
addMessageToSession: (_session, message) => { messages.push(message); },
updateMessageById: (_session, id, update) => {
const index = messages.findIndex(message => message.id === id);
if (index >= 0) messages[index] = update(messages[index]);
},
updateLastMessage: () => undefined,
reportStreamError: (_session, _signal, error) => { throw error; },
setStreamingForScope: () => undefined,
},
};
const context: TurnDriverContext = {
turnId: `turn-${turnNumber}`, chatSessionId: 'chat-1', sessionId: 'chat-1', backend: 'catty', signal,
emit: event => { events.push({
...event, sessionId: 'chat-1', chatSessionId: 'chat-1', backend: 'catty', timestamp: Date.now(),
} as AgentEvent); }, toolOutputStore: store, toolResultDedup: dedup, sessionStateStore: state,
};
dedup.beginTurn();
await new CattyTurnDriver().run(input, context);
},
};
}
function assertRequestPrefix(previous: WireRequest, next: WireRequest) {
assert.deepEqual(next.messages.slice(0, previous.messages.length), previous.messages,
'already-sent conversation must remain an exact prefix');
assert.deepEqual(next.tools, previous.tools);
}
test('Catty preserves the request prefix across six ordinary turns', async t => {
const f = fixture(t, Array.from({ length: 6 }, () => ({ text: 'OK' })));
for (let i = 0; i < 6; i += 1) await f.turn();
assert.equal(f.requests.length, 6);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
});
test('Catty replays completed small commands without rewriting their results', async t => {
const f = fixture(t, Array.from({ length: 6 }, () => [
{ tool: 'terminal_execute' as const }, { text: 'OK' },
]).flat());
for (let i = 0; i < 6; i += 1) await f.turn(2);
assert.equal(f.requests.length, 12);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 1800, 'usage includes both model steps');
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/, 'OpenAI-compatible requests use implicit caching');
});
test('Catty keeps large-output handles without replacing the conversation prefix', async t => {
const f = fixture(t, [{ tool: 'sftp_read_file' }, { tool: 'sftp_read_file' }, { text: 'OK' }]);
await f.turn(3);
assert.equal(f.requests.length, 3);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
assert.match(JSON.stringify(f.requests.at(-1)), /tool output handle:/);
assert.doesNotMatch(JSON.stringify(f.requests.at(-1)), /Tool output handles available/);
});
test('Catty preserves large-output history across consecutive user turns', async t => {
const f = fixture(t, Array.from({ length: 6 }, () => [
{ tool: 'sftp_read_file' as const }, { text: 'OK' },
]).flat());
for (let i = 0; i < 6; i += 1) await f.turn(2);
assert.equal(f.requests.length, 12);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
});
test('Catty preserves large terminal results across consecutive user turns', async t => {
const f = fixture(t, Array.from({ length: 6 }, () => [
{ tool: 'terminal_execute' as const }, { text: 'OK' },
]).flat(), 'openai', Array.from({ length: 14000 }, (_, i) => `synthetic log record ${i}`).join('\n'));
for (let i = 0; i < 6; i += 1) await f.turn(2);
assert.equal(f.requests.length, 12);
for (let i = 1; i < f.requests.length; i += 1) assertRequestPrefix(f.requests[i - 1], f.requests[i]);
});
test('Catty keeps DeepSeek cache reads in trace and persisted assistant usage', async t => {
const f = fixture(t, [{ text: 'OK' }]);
await f.turn();
const event = f.events.find(event => event.type === 'usage');
assert.ok(event?.type === 'usage');
assert.equal(event.cachedPromptTokens, 900);
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 900);
});
test('Catty marks a stable Claude system block and the conversation tail for caching', async t => {
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
await f.turn();
const system = f.requests[0].system!;
assert.ok(system.length >= 2, 'static and dynamic system context need separate boundaries');
assert.deepEqual(system[0].cache_control, { type: 'ephemeral' });
assert.doesNotMatch(system[0].text, /## Current Scope/);
assert.match(system[1].text, /## Current Scope/);
assert.match(JSON.stringify(f.requests[0].messages.at(-1)), /cache_control/);
const event = f.events.find(event => event.type === 'usage');
assert.ok(event?.type === 'usage');
assert.equal(event.cachedPromptTokens, 900);
assert.equal(f.messages.at(-1)?.usage?.cachedInputTokens, 900);
});
test('Catty does not send vendor cache extensions to an Anthropic-compatible gateway', async t => {
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
f.provider.baseURL = 'https://gateway.example.test/anthropic';
await f.turn();
assert.equal(f.requests.length, 1);
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/);
assert.equal(f.messages.at(-1)?.content, 'OK');
});
for (const providerId of ['openrouter', 'ollama'] as const) {
test(`Catty respects the ${providerId} endpoint fallback with Anthropic format`, async t => {
const f = fixture(t, [{ text: 'OK' }], 'anthropic');
f.provider.providerId = providerId;
f.provider.baseURL = '';
await f.turn();
assert.match(f.urls[0], providerId === 'openrouter' ? /^https:\/\/openrouter\.ai\// : /^http:\/\/localhost:11434\//);
assert.doesNotMatch(JSON.stringify(f.requests), /cache_control/);
assert.equal(f.messages.at(-1)?.content, 'OK');
});
}
@@ -0,0 +1,56 @@
import type { ModelMessage, SystemModelMessage } from 'ai';
import type { ProviderConfig } from '../types';
const CACHE_CONTROL = { type: 'ephemeral' } as const;
/** Native Anthropic needs explicit breakpoints; implicit-cache protocols stay untouched. */
export function createCattyPromptCache(
systemPrompt: string,
provider: string,
endpoint?: Pick<ProviderConfig, 'baseURL'>,
) {
let officialEndpoint = false;
if (endpoint) {
try {
// This must be the resolved SDK address, including vendor fallbacks.
// Only an unresolved URL after that step uses the SDK's native default.
officialEndpoint = new URL(endpoint.baseURL || 'https://api.anthropic.com').origin === 'https://api.anthropic.com';
} catch { /* An unknown endpoint must not receive vendor extensions. */ }
}
const enabled = /^anthropic(?:\.|$)/.test(provider) && officialEndpoint;
const boundary = systemPrompt.indexOf('\n\n## Current Scope');
const instructions: string | SystemModelMessage[] = enabled
? [{
role: 'system',
content: boundary < 0 ? systemPrompt : systemPrompt.slice(0, boundary),
providerOptions: { anthropic: { cacheControl: CACHE_CONTROL } },
}, ...(boundary < 0 ? [] : [{
role: 'system' as const,
content: systemPrompt.slice(boundary),
}])]
: systemPrompt;
return {
enabled,
instructions,
prepareMessages(messages: ModelMessage[]): ModelMessage[] {
if (!enabled) return messages;
// Keep at most two moving conversation breakpoints, plus the static
// system boundary. Remove our prior message-level markers as v7 carries
// prepared messages forward; never alter signed reasoning content.
const targets = new Set<number>();
for (let index = messages.length - 1; index >= 0 && targets.size < 2; index -= 1) {
if (messages[index].role === 'user' || messages[index].role === 'tool') targets.add(index);
}
return messages.map((message, index) => {
const existing = message.providerOptions?.anthropic;
if (!targets.has(index) && !existing?.cacheControl && !existing?.cache_control) return message;
const anthropic = { ...existing };
delete anthropic.cacheControl;
delete anthropic.cache_control;
if (targets.has(index)) anthropic.cacheControl = CACHE_CONTROL;
return { ...message, providerOptions: { ...message.providerOptions, anthropic } };
});
},
};
}
@@ -0,0 +1,42 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ModelMessage } from 'ai';
import { createCattyPromptCache } from './cattyPromptCache';
test('leaves implicit-cache protocols unchanged', () => {
for (const provider of ['openai.chat', 'openai.responses', 'google.generative-ai']) {
const cache = createCattyPromptCache('instructions', provider);
const messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
assert.equal(cache.instructions, 'instructions');
assert.equal(cache.prepareMessages(messages), messages);
}
});
test('Claude markers stay bounded across tool steps without changing signed reasoning', () => {
const cache = createCattyPromptCache('static\n\n## Current Scope\nvolatile', 'anthropic.messages', { baseURL: 'https://api.anthropic.com' });
const reasoning = { type: 'reasoning' as const, text: 'thinking',
providerOptions: { anthropic: { signature: 'signed-value' } } };
let messages: ModelMessage[] = [{ role: 'user', content: 'hello' }];
for (let step = 0; step < 30; step += 1) {
messages = cache.prepareMessages(messages);
const marked = messages.filter(message => message.providerOptions?.anthropic?.cacheControl);
assert.ok(marked.length <= 2);
messages.push({ role: 'assistant', content: [reasoning, { type: 'text', text: 'result' }] });
messages.push({ role: 'user', content: `next-${step}` });
}
for (const message of messages) {
if (message.role === 'assistant') assert.equal(message.content[0], reasoning);
}
assert.ok(Array.isArray(cache.instructions));
assert.deepEqual(cache.instructions[0].providerOptions?.anthropic?.cacheControl, { type: 'ephemeral' });
});
test('only the official HTTPS Anthropic endpoint enables vendor cache markers', () => {
for (const baseURL of ['https://proxy.example.test/v1', 'http://api.anthropic.com', 'https://api.anthropic.com.example.test', 'invalid']) {
const cache = createCattyPromptCache('instructions', 'anthropic.messages', { baseURL });
assert.equal(cache.enabled, false);
}
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages').enabled, false);
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages', {}).enabled, true);
assert.equal(createCattyPromptCache('instructions', 'anthropic.messages', { baseURL: 'https://api.anthropic.com/v1' }).enabled, true);
});
@@ -78,7 +78,7 @@ test('prepareTurnContext applies typed compression before LLM summarize threshol
assert.ok(prepared.messages.length >= messages.length);
const serialized = JSON.stringify(prepared.messages);
assert.match(serialized, /Fix only the upstream timeout issue/);
assert.match(serialized, /Permission mode: confirm/);
assert.doesNotMatch(serialized, /Netcatty session context/);
assert.ok(serialized.length < JSON.stringify(messages).length);
});
@@ -163,7 +163,7 @@ test('TraceStore bounds compaction history independently from events', () => {
assert.equal(store.getCompactions('chat-bounded-compactions').length, 3);
});
test('prepareStepContext replaces prior step handle notices under v7 carry-forward semantics', async () => {
test('prepareStepContext preserves prior notices and appends only newly available handles', async () => {
const store = new ToolOutputStore();
store.store({
chatSessionId: 'chat-4',
@@ -194,9 +194,22 @@ test('prepareStepContext replaces prior step handle notices under v7 carry-forwa
&& typeof message.content === 'string'
&& message.content.includes('Tool output handles available'),
);
assert.equal(notices.length, 1);
assert.match(String(notices[0]?.content), /\[step 2\]/);
assert.doesNotMatch(String(notices[0]?.content), /tool-output-old/);
assert.equal(notices.length, 2);
assert.deepEqual(prepared.messages.slice(0, 2), [priorNotice, { role: 'user', content: 'continue' }]);
assert.equal(notices[0], priorNotice);
assert.match(String(notices[1]?.content), /^\[Tool output handles available:/);
assert.doesNotMatch(String(notices[1]?.content), /tool-output-old|\[step/);
assert.equal(prepared.messages.at(-1), notices[1]);
const next = await prepareStepContext({
messages: prepared.messages,
stepNumber: 3,
sessionId: 'chat-4',
chatSessionId: 'chat-4',
toolOutputStore: store,
runtimeContext,
});
assert.deepEqual(next.messages, prepared.messages, 'unchanged handles need no per-step rewrite');
});
test('prepareStepContext emits step compaction trace when over budget', async () => {
@@ -266,7 +279,8 @@ test('prepareStepContext retains handle notice after step budget guard', async (
&& message.content.includes('Tool output handles available'),
);
assert.equal(notices.length, 1);
assert.match(String(notices[0]?.content), /\[step 2\]/);
assert.match(String(notices[0]?.content), /^\[Tool output handles available:/);
assert.equal(prepared.messages.at(-1), notices[0]);
});
test('prepareStepContext never leaves orphan results from a parallel tool batch', async () => {
+30 -39
View File
@@ -1,6 +1,5 @@
import type { ModelMessage } from 'ai';
import type { ChatMessage, AIPermissionMode } from '../types';
import { isStepHandleNoticeMessage } from './agentEventAdapter';
import {
DEFAULT_CONTEXT_WINDOW_TOKENS,
DEFAULT_PROTECT_RECENT_MESSAGES,
@@ -265,7 +264,9 @@ export async function prepareTurnContext(
}
const reinjection = buildReinjectionMessages(input.reinjection);
if (reinjection.length > 0 && didAdjust) {
// Routine output cleanup does not discard the conversation or its goals.
// Reinserting the latest goal at the front would invalidate every turn.
if (reinjection.length > 0 && didAdjust && (underBudgetPressure || didLlmSummarize)) {
const reinjectionTokens = estimateModelMessagesTokensWithKind({
messages: reinjection,
providerId: input.providerId,
@@ -414,22 +415,6 @@ export async function prepareStepContext(
const typed = compressMessagesForRequestTooLargeRetry(working);
working = typed.messages;
const pendingHandles = input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? [];
let didHandleNotice = false;
if (pendingHandles.length > 0 && input.stepNumber > 0) {
working = working.filter((message) => {
if (message.role !== 'user') return true;
const content = typeof message.content === 'string' ? message.content : '';
return !isStepHandleNoticeMessage(content);
});
const notice: ModelMessage = {
role: 'user',
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
};
working = [notice, ...working];
didHandleNotice = true;
}
const budgetGuard = applyStepBudgetGuard(working, {
contextWindow,
reservedTokens: input.reservedTokens ?? 0,
@@ -438,36 +423,42 @@ export async function prepareStepContext(
protectRecentMessages: protectRecent,
});
working = budgetGuard.messages;
if (didHandleNotice && pendingHandles.length > 0) {
const hasHandleNotice = working.some(
(message) => message.role === 'user' && isStepHandleNoticeMessage(message.content),
);
if (!hasHandleNotice) {
working = [{
role: 'user',
content: `[step ${input.stepNumber}] Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}`,
}, ...working];
}
}
if (didHandleNotice) {
const notices = working.filter(
message => message.role === 'user' && isStepHandleNoticeMessage(message.content),
);
const body = working.filter(
message => !(message.role === 'user' && isStepHandleNoticeMessage(message.content)),
);
const pendingHandles = input.stepNumber > 0
? input.toolOutputStore?.listPendingHandles(input.chatSessionId ?? input.sessionId) ?? []
: [];
const unannouncedHandles = () => {
// Bounded tool results already carry their read handles. Announce only
// handles whose result was pruned, so normal history needs no transient
// user message that would disappear when the next turn is rebuilt.
const visibleContent = working.map(message => typeof message.content === 'string'
? message.content : JSON.stringify(message.content));
return pendingHandles.filter(handle => !visibleContent.some(content => content.includes(handle.id)));
};
let didHandleNotice = false;
if (unannouncedHandles().length > 0) {
// Reserve the complete list: the guard may remove an older announcement.
// New notices belong at the tail; replacing a step-numbered prefix would
// invalidate the entire cached conversation on every model call.
const noticeTokens = estimateModelMessagesTokensWithKind({
messages: notices,
messages: [{ role: 'user', content: `[Tool output handles available: ${pendingHandles.map(h => h.id).join(', ')}]` }],
providerId: input.providerId,
}).tokens;
const finalGuard = applyStepBudgetGuard(body, {
const finalGuard = applyStepBudgetGuard(working, {
contextWindow,
reservedTokens: (input.reservedTokens ?? 0) + noticeTokens,
maxOutputTokens,
providerId: input.providerId,
protectRecentMessages: protectRecent,
});
working = [...notices, ...finalGuard.messages];
working = finalGuard.messages;
const handles = unannouncedHandles();
if (handles.length > 0) {
working = [...working, {
role: 'user',
content: `[Tool output handles available: ${handles.map(h => h.id).join(', ')}]`,
}];
didHandleNotice = true;
}
budgetGuard.didAdjust = budgetGuard.didAdjust || finalGuard.didAdjust;
}
const didBudgetAdjust = integrity.didAdjust || stale.didAdjust || typed.didAdjust || budgetGuard.didAdjust;
@@ -27,7 +27,7 @@ import {
type AssistantContentPart,
type CattyProviderContinuationContext,
} from '../../aiChatStreamingSupport';
import { redactSecretsInValueForModel } from '../modelSecretRedaction';
import { redactSecretsForModel, redactSecretsInValueForModel } from '../modelSecretRedaction';
import { fitLargeUserInputForModel } from '../largeUserInput';
import type { ToolOutputStore } from '../toolOutputStore';
@@ -198,7 +198,6 @@ export interface BuildCattySdkMessagesInput {
trimmed: string;
attachments?: ChatMessageAttachment[];
continuationContext: CattyProviderContinuationContext;
preserveTerminalToolResults?: ReadonlySet<ToolResult>;
chatSessionId: string;
toolOutputStore: ToolOutputStore;
fieldsByMessage: Map<ModelMessage, OpenAIChatAssistantFields | undefined>;
@@ -212,7 +211,6 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
trimmed,
attachments,
continuationContext,
preserveTerminalToolResults = new Set<ToolResult>(),
chatSessionId,
toolOutputStore,
fieldsByMessage,
@@ -394,9 +392,12 @@ export function buildCattySdkMessages(input: BuildCattySdkMessagesInput): ModelM
toolName: toolCall?.name ?? 'unknown',
output: {
type: 'text' as const,
value: buildHistoricalToolResultReplayText(tr, toolCall, {
preserveTerminalOutput: preserveTerminalToolResults.has(tr),
}),
// Live results are already bounded. Reformatting them merely
// because another user turn arrived breaks the cached prefix.
// Context-budget pruning still owns necessary reductions.
value: redactSecretsForModel(buildHistoricalToolResultReplayText(tr, toolCall, {
preserveTerminalOutput: true,
})),
},
};
}),
@@ -8,13 +8,14 @@ import {
} from '../../cattyRequestTooLargeRetry';
import { mapCattyStreamChunkToAgentEvents } from '../agentEventAdapter';
import type { AgentEvent } from '../types';
import type { ProviderAdvancedParams } from '../../types';
import { resolveProviderStyle, type ProviderAdvancedParams } from '../../types';
import type { CattyReasoningProviderOptions } from '../../cattyReasoning';
import { createModelFromConfig } from '../../sdk/providers';
import { createModelFromConfig, resolveProviderEndpoint } from '../../sdk/providers';
import type { CattyToolsBundle } from '../capabilityTools';
import { buildCattyToolApproval } from '../cattyToolApproval';
import type { CattyRuntimeContext } from '../cattyRuntimeContext';
import { buildCattyStreamTimeouts } from '../streamTimeouts';
import { createCattyPromptCache } from '../cattyPromptCache';
import {
extractProviderContinuationFromRawChunk,
mergeProviderContinuation,
@@ -39,7 +40,7 @@ import {
type ToolOutputDeniedChunk,
type ToolResultChunk,
} from '../../aiChatStreamingSupport';
import type { ChatMessage } from '../../types';
import type { ChatMessage, ProviderConfig } from '../../types';
export type CattyModel = ReturnType<typeof createModelFromConfig>;
@@ -51,6 +52,7 @@ export interface CattyStreamUiSink {
export interface ProcessCattyStreamInput {
streamSessionId: string;
model: CattyModel;
providerConfig?: ProviderConfig;
systemPrompt: string;
toolsBundle: CattyToolsBundle;
sdkMessages: ModelMessage[];
@@ -76,6 +78,7 @@ export interface ProcessCattyStreamInput {
export interface ProcessCattyStreamResult {
usage?: {
promptTokens?: number;
cachedPromptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
@@ -129,11 +132,15 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
let runtimeContext = initialRuntimeContext;
const { tools, toolsContext } = toolsBundle;
const endpoint = input.providerConfig
? resolveProviderEndpoint(input.providerConfig, resolveProviderStyle(input.providerConfig), undefined)
: undefined;
const promptCache = createCattyPromptCache(systemPrompt, model.provider, endpoint);
const result = streamText({
model,
messages: sdkMessages,
instructions: systemPrompt,
messages: promptCache.prepareMessages(sdkMessages),
instructions: promptCache.instructions,
tools,
toolsContext,
runtimeContext,
@@ -186,6 +193,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
modelId: step.model.modelId,
finishReason: step.finishReason,
promptTokens: usage.inputTokens ?? 0,
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
completionTokens: usage.outputTokens ?? 0,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
} as AgentEvent);
@@ -201,15 +209,17 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
timestamp: Date.now(),
turnId,
promptTokens: usage.inputTokens ?? 0,
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
completionTokens: usage.outputTokens ?? 0,
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
estimated: false,
} as AgentEvent);
}
},
...(prepareStep ? {
...(prepareStep || promptCache.enabled ? {
prepareStep: async ({ stepNumber, messages, runtimeContext: stepRuntimeContext }) => {
const prepared = await prepareStep({
const prepared = await prepareStep?.({
stepNumber,
messages,
runtimeContext: stepRuntimeContext as CattyRuntimeContext,
@@ -217,7 +227,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
if (prepared?.runtimeContext) {
runtimeContext = prepared.runtimeContext;
}
return prepared ?? { messages };
return { ...prepared, messages: promptCache.prepareMessages(prepared?.messages ?? messages) };
},
} : {}),
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
@@ -552,6 +562,21 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
const usage = await result.usage;
const finalStep = await result.finalStep;
const performance = finalStep?.performance;
// 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) {
ui.updateMessageById(streamSessionId, activeMsgId, message => ({
...message,
usage: {
inputTokens: usage.inputTokens ?? 0,
cachedInputTokens: usage.inputTokenDetails?.cacheReadTokens,
outputTokens: usage.outputTokens ?? 0,
reasoningTokens: usage.outputTokenDetails?.reasoningTokens,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
estimated: false,
},
}));
}
if (performance) {
onAgentEvent?.({
@@ -571,6 +596,7 @@ export async function processCattyStream(input: ProcessCattyStreamInput): Promis
return {
usage: usage ? {
promptTokens: usage.inputTokens,
cachedPromptTokens: usage.inputTokenDetails?.cacheReadTokens,
completionTokens: usage.outputTokens,
totalTokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
} : undefined,
@@ -37,7 +37,6 @@ import {
buildCattySdkMessages,
collectOpenAIChatAssistantFieldsForMessages,
collectPreservedTerminalWriteFingerprints,
collectToolResultsAfterMessage,
createContinuationContext,
} from './cattyMessageBuilder';
import { hadToolProgressBeforeRequestTooLarge, processCattyStream } from './cattyStreamProcessor';
@@ -218,7 +217,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
const buildSdkMessages = (
allMessages: import('../../types').ChatMessage[],
includeCurrentUserMessage: boolean,
options: { preserveTerminalToolResults?: ReadonlySet<import('../../types').ToolResult> } = {},
sessionContextCompaction = currentSession?.contextCompaction,
) => buildCattySdkMessages({
allMessages,
@@ -227,7 +225,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
trimmed: modelUserText,
attachments: includeCurrentUserMessage ? attachments : undefined,
continuationContext,
preserveTerminalToolResults: options.preserveTerminalToolResults,
chatSessionId: sessionId,
toolOutputStore: ctx.toolOutputStore,
fieldsByMessage: openAIChatAssistantFieldsByMessage,
@@ -399,7 +396,6 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
const headSdkMessages = buildSdkMessages(
uiMessages.slice(0, compactedMessageCount),
false,
{},
undefined,
);
const compacted = await compactMessages(headSdkMessages, {
@@ -414,7 +410,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
};
ui.persistContextCompaction?.(sessionId, nextCompaction);
emitContextSnapshot(prepareMessagesForStream(
buildSdkMessages(uiMessages, false, {}, nextCompaction),
buildSdkMessages(uiMessages, false, nextCompaction),
));
} else {
// Non-durable outcome: keep the meter honest (full current context).
@@ -452,6 +448,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
await processCattyStream({
streamSessionId: sessionId,
model,
providerConfig: context.activeProvider,
systemPrompt,
toolsBundle,
sdkMessages: streamMessages,
@@ -514,12 +511,7 @@ async function runCattyTurn(input: CattyTurnInput, ctx: TurnDriverContext): Prom
assistantMsgId,
sessionId,
);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, {
preserveTerminalToolResults: collectToolResultsAfterMessage(
latestSession.messages,
assistantMsgId,
),
}, latestSession.contextCompaction);
retryBaseMessages = buildSdkMessages(latestSession.messages, false, latestSession.contextCompaction);
}
retryAssistantMsgId = generateId();
ui.addMessageToSession(sessionId, {
+1
View File
@@ -180,6 +180,7 @@ export interface StepEndEvent extends AgentEventBase {
modelId?: string;
finishReason?: string;
promptTokens: number;
cachedPromptTokens?: number;
completionTokens: number;
totalTokens: number;
}
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeOpenAIChatCacheUsage } from './openAIChatCacheUsage';
test('adapts documented DeepSeek cache usage while preserving the rest of the response', () => {
const response = { choices: [], usage: {
prompt_tokens: 1000, prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100,
prompt_tokens_details: { other: 1 }, completion_tokens: 5,
} };
const parsed = JSON.parse(normalizeOpenAIChatCacheUsage(JSON.stringify(response)));
assert.deepEqual(parsed, {
...response, usage: { ...response.usage, prompt_tokens_details: { other: 1, cached_tokens: 900 } },
});
});
test('preserves standard usage and rejects invalid cache counts without breaking stream data', () => {
for (const hit of [-1, 1.5, '900', 1001, null]) {
const data = JSON.stringify({ usage: { prompt_tokens: 1000, prompt_cache_hit_tokens: hit } });
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
}
for (const data of ['[DONE]', 'not JSON', '{"usage":null}', '{"type":"message_start"}']) {
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
}
const data = JSON.stringify({ usage: {
prompt_tokens: 1000, prompt_cache_hit_tokens: 900, prompt_tokens_details: { cached_tokens: 0 },
} });
assert.equal(normalizeOpenAIChatCacheUsage(data), data);
});
+17
View File
@@ -0,0 +1,17 @@
/** Adapt DeepSeek's documented usage fields before the OpenAI SDK validates them. */
export function normalizeOpenAIChatCacheUsage(data: string): string {
if (!data.includes('prompt_cache_hit_tokens')) return data;
try {
const body = JSON.parse(data);
const usage = body?.usage;
const hit = usage?.prompt_cache_hit_tokens;
if (!Number.isSafeInteger(hit) || hit < 0) return data;
if (Number.isFinite(usage.prompt_tokens) && hit > usage.prompt_tokens) return data;
// Preserve a provider's standard field, including an explicit zero.
if (usage.prompt_tokens_details?.cached_tokens != null) return data;
usage.prompt_tokens_details = { ...usage.prompt_tokens_details, cached_tokens: hit };
return JSON.stringify(body);
} catch {
return data;
}
}
+23 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isStepCount, streamText, tool } from 'ai';
import { generateText, isStepCount, streamText, tool } from 'ai';
import { z } from 'zod';
import { createBridgeFetchForSDK, createModelFromConfig } from './sdk/providers';
import type { OpenAIChatAssistantFields } from './providerContinuation';
@@ -1305,3 +1305,25 @@ for (const indexes of [[0], [1], [1, 4], [4, 1]]) {
indexes.map(index => `call_${index}`).sort());
});
}
test('non-streaming DeepSeek usage reaches the SDK as cached input', async t => {
const originalWindow = globalThis.window;
t.after(() => { globalThis.window = originalWindow; });
globalThis.window = { netcatty: {
aiFetch: async () => ({ ok: true, status: 200, data: JSON.stringify({
id: 'response', object: 'chat.completion', created: 0, model: 'deepseek-chat',
choices: [{ index: 0, message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1000, completion_tokens: 1, total_tokens: 1001,
prompt_cache_hit_tokens: 900, prompt_cache_miss_tokens: 100 },
}) }),
} } as unknown as Window & typeof globalThis;
const result = await generateText({
model: createModelFromConfig({
id: 'fixture', providerId: 'custom', name: 'Fixture', enabled: true,
baseURL: 'https://provider.example.test/v1', apiKey: 'fixture', defaultModel: 'deepseek-chat',
}),
prompt: 'hello',
});
assert.equal(result.usage.inputTokenDetails.cacheReadTokens, 900);
assert.equal(result.usage.inputTokenDetails.noCacheTokens, 100);
});
@@ -158,13 +158,19 @@ function compressContentPartForRequestRetry(part: unknown): unknown {
const output = record.output;
if (output && typeof output === "object") {
const outputRecord = output as Record<string, unknown>;
if (outputRecord.type === "text" && typeof outputRecord.value === "string") {
const value = compressAndTruncateText(outputRecord.value, RETRY_MAX_TOOL_RESULT_CHARS);
if (value === outputRecord.value) return part;
const text = outputRecord.type === "text" && typeof outputRecord.value === "string"
? outputRecord.value
: outputRecord.type === "json" ? JSON.stringify(outputRecord.value) : undefined;
if (text !== undefined) {
// Live SDK tool results are JSON, while persisted results replay as
// text. Apply the same reduction before their first model request.
const value = compressAndTruncateText(text, RETRY_MAX_TOOL_RESULT_CHARS);
if (value === text) return part;
return {
...record,
output: {
...outputRecord,
type: "text",
value,
},
};
+3 -2
View File
@@ -6,6 +6,7 @@ import { resolveOpenAIApi, resolveProviderStyle } from '../types';
import { normalizeAnthropicSdkBaseURL } from '../anthropicCompatBaseUrl';
import { buildSdkRequestHeaders } from '../providerRequestHeaders';
import { normalizeOllamaSdkBaseURL } from '../ollamaCompatBaseUrl';
import { normalizeOpenAIChatCacheUsage } from '../openAIChatCacheUsage';
export { normalizeOllamaSdkBaseURL };
import {
@@ -529,7 +530,7 @@ export function createBridgeFetchForSDK(
};
const unsubData = bridge.onAiStreamData(requestId, (data: string) => {
const normalizedData = normalizeOpenAIChatToolCalls(data);
const normalizedData = normalizeOpenAIChatCacheUsage(normalizeOpenAIChatToolCalls(data));
captureOpenAIChatFields(normalizedData);
// Re-wrap as SSE so the SDK can parse it
enqueueChunk(encoder.encode(`data: ${normalizedData}\n\n`));
@@ -627,7 +628,7 @@ export function createBridgeFetchForSDK(
// Non-streaming path
const result = await bridge.aiFetch(url, method, headers, requestBody, providerId);
return new Response(result.data, {
return new Response(normalizeOpenAIChatCacheUsage(result.data), {
status: result.status,
statusText: result.ok ? 'OK' : 'Error',
headers: { 'content-type': 'application/json' },