mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6a9590dac | ||
|
|
744f0f8e44 | ||
|
|
e894c9cbf2 | ||
|
|
1b96e27b09 | ||
|
|
df5c033dfa | ||
|
|
b5fee9632e |
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");
|
||||
}
|
||||
});
|
||||
@@ -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]");
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user