e4a87fa3ce
- Added Matplotlib figure PNG rendering and display tracking in Python runner to emit PNG output immediately when figures are displayed via display(fig). - Extended session persistence to externalize oversized image payloads in both content and details.images, enabling tool result images to survive session reload. - Enhanced session loader to resolve image data payloads and blob references across content and details.images during session reconstruction. - Added image cache invalidation in TUI image component when image protocol, cell dimensions, or Kitty Unicode placeholder mode changes. - Added comprehensive test coverage for Matplotlib display, image persistence across reload, and TUI image rendering with protocol and dimension changes.
148 lines
5.2 KiB
TypeScript
148 lines
5.2 KiB
TypeScript
import {
|
|
type BlobStore,
|
|
externalizeImageDataSync,
|
|
externalizeImageDataUrlSync,
|
|
isBlobRef,
|
|
isImageDataUrl,
|
|
} from "./blob-store";
|
|
import type { FileEntry } from "./session-entries";
|
|
|
|
const MAX_PERSIST_CHARS = 500_000;
|
|
const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
|
|
/** Minimum base64 length to externalize to blob store (skip tiny inline images) */
|
|
const BLOB_EXTERNALIZE_THRESHOLD = 1024;
|
|
const TEXT_CONTENT_KEY = "content";
|
|
|
|
function truncateString(value: string, maxLength: number): string {
|
|
if (value.length <= maxLength) return value;
|
|
let truncated = value.slice(0, maxLength);
|
|
if (truncated.length > 0) {
|
|
const last = truncated.charCodeAt(truncated.length - 1);
|
|
if (last >= 0xd800 && last <= 0xdbff) {
|
|
truncated = truncated.slice(0, -1);
|
|
}
|
|
}
|
|
return truncated;
|
|
}
|
|
|
|
export function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"type" in value &&
|
|
(value as { type?: string }).type === "image" &&
|
|
"data" in value &&
|
|
typeof (value as { data?: string }).data === "string"
|
|
);
|
|
}
|
|
|
|
function isImageMimeType(value: unknown): value is string {
|
|
return typeof value === "string" && value.toLowerCase().startsWith("image/");
|
|
}
|
|
|
|
export function isImageDataPayload(value: unknown): value is { data: string; mimeType?: string } {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"data" in value &&
|
|
typeof (value as { data?: string }).data === "string" &&
|
|
(isImageBlock(value) || ("mimeType" in value && isImageMimeType((value as { mimeType?: unknown }).mimeType)))
|
|
);
|
|
}
|
|
|
|
function shouldExternalizeImagePayload(
|
|
value: unknown,
|
|
key: string | undefined,
|
|
): value is { data: string; mimeType?: string } {
|
|
if (!isImageDataPayload(value)) return false;
|
|
if (isBlobRef(value.data) || value.data.length < BLOB_EXTERNALIZE_THRESHOLD) return false;
|
|
return (key === TEXT_CONTENT_KEY && isImageBlock(value)) || key === "images";
|
|
}
|
|
|
|
/**
|
|
* Recursively truncate large strings in an object for session persistence.
|
|
* - Truncates any oversized string fields (key-agnostic)
|
|
* - Externalizes oversized image payloads to blob refs
|
|
* - Updates lineCount when content is truncated
|
|
* - Returns original object if no changes needed (structural sharing)
|
|
*
|
|
* Runs in one synchronous tick so an OOM/SIGKILL landing right after a persist
|
|
* call returns cannot lose the entry. Image externalization happens via the
|
|
* synchronous blob-store path (`fs.writeFileSync`), so blob bytes are in the
|
|
* kernel page cache before the JSONL line referencing them is written.
|
|
*/
|
|
function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): unknown {
|
|
if (obj === null || obj === undefined) return obj;
|
|
if (shouldExternalizeImagePayload(obj, key)) {
|
|
return { ...obj, data: externalizeImageDataSync(blobStore, obj.data, obj.mimeType) };
|
|
}
|
|
|
|
if (typeof obj === "string") {
|
|
if (key === "image_url" && isImageDataUrl(obj)) {
|
|
return externalizeImageDataUrlSync(blobStore, obj);
|
|
}
|
|
if (obj.length > MAX_PERSIST_CHARS) {
|
|
// Cryptographic signatures must be preserved exactly or cleared entirely — never truncated.
|
|
// Truncation would produce an invalid signature that the API rejects.
|
|
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
|
|
return "";
|
|
}
|
|
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
|
|
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
if (Array.isArray(obj)) {
|
|
let changed = false;
|
|
const result: unknown[] = new Array(obj.length);
|
|
for (let i = 0; i < obj.length; i++) {
|
|
const item = obj[i];
|
|
const newItem = truncateForPersistence(item, blobStore, key);
|
|
if (newItem !== item) changed = true;
|
|
result[i] = newItem;
|
|
}
|
|
return changed ? result : obj;
|
|
}
|
|
|
|
if (typeof obj === "object") {
|
|
let changed = false;
|
|
const entries: Array<readonly [string, unknown]> = [];
|
|
for (const [childKey, value] of Object.entries(obj)) {
|
|
// Strip transient/redundant properties that shouldn't be persisted.
|
|
// - partialJson: streaming accumulator for tool call JSON parsing
|
|
// - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
|
|
if (childKey === "partialJson" || childKey === "jsonlEvents") {
|
|
changed = true;
|
|
continue;
|
|
}
|
|
const newValue = truncateForPersistence(value, blobStore, childKey);
|
|
if (newValue !== value) changed = true;
|
|
entries.push([childKey, newValue]);
|
|
}
|
|
if (!changed) return obj;
|
|
|
|
const contentEntry = entries.find(([childKey]) => childKey === "content");
|
|
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
|
|
if (
|
|
contentEntry &&
|
|
typeof contentEntry[1] === "string" &&
|
|
lineCountEntry &&
|
|
typeof lineCountEntry[1] === "number"
|
|
) {
|
|
const content = contentEntry[1];
|
|
const updatedEntries = entries.map(([childKey, value]) =>
|
|
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
|
|
);
|
|
return Object.fromEntries(updatedEntries);
|
|
}
|
|
return Object.fromEntries(entries);
|
|
}
|
|
|
|
return obj;
|
|
}
|
|
|
|
export function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): FileEntry {
|
|
return truncateForPersistence(entry, blobStore) as FileEntry;
|
|
}
|