diff --git a/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts index 532f291a5..3f74627f4 100644 --- a/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts +++ b/apps/kimi-code/src/tui/controllers/cache-hint-controller.ts @@ -23,6 +23,7 @@ import { evaluateCacheHint } from '../utils/cache-hint'; import { formatErrorMessage } from '../utils/event-payload'; import { makeExtractionResendable, + originalsDirForSession, type ExtractionResult, } from '../utils/image-placeholder'; @@ -370,9 +371,12 @@ export class CacheHintController { } private async releaseToSendPath(stash: StashedSubmit): Promise { + // A session reset cleared the image store: rebuild the extraction from + // its snapshots, persisting compressed pastes' originals into the NEW + // session's originals dir so the compression caption survives the move. const extraction = stash.extraction !== undefined && this.host.state.appState.sessionId !== stash.sessionId - ? makeExtractionResendable(stash.extraction) + ? makeExtractionResendable(stash.extraction, originalsDirForSession(this.host.session)) : stash.extraction; if (stash.inlineSkillActivations !== undefined && stash.inlineSkillActivations.length > 0) { await this.host.sendInlineSkillUserInput(stash.text, stash.inlineSkillActivations, extraction); diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 0e071e3d9..73dabd66c 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -1,7 +1,5 @@ -import { unlink } from 'node:fs/promises'; - import type { FileMeta, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; -import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk'; +import { compressImageForModel } from '@moonshot-ai/kimi-code-sdk'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import { parseImageMeta } from '#/utils/image/image-mime'; @@ -594,10 +592,11 @@ export class EditorKeyboardController { // the stored bytes, the inline thumbnail, the `[image #N (W×H)]` placeholder, // and the submitted image all agree, and the agent core only ever sees an // already-compressed image. Best effort: originals pass through on failure. - // When compression changed the bytes, the original is persisted (into the - // session's media-originals dir when known, else the temp-dir fallback) - // and recorded on the attachment, so submit-time expansion can announce - // the compression and point the model at the full-fidelity copy. + // When compression changed the bytes, the pre-compression original is kept + // on the attachment in memory: the session whose media-originals dir it + // belongs in may not exist yet at paste time, so dispatch-time caption + // resolution (`resolveOriginalCaptions`) persists it and announces the + // compression, pointing the model at the full-fidelity copy. // The edge cap comes from the host harness's [image] config (resolved per // paste so a config reload applies immediately); hosts without a harness // use the env/built-in default. @@ -611,21 +610,13 @@ export class EditorKeyboardController { source: 'tui_paste', }, }); - const sessionDir = this.host.session?.summary?.sessionDir; // Dimensions come from the compression result, not parseImageMeta: the // compressor reports display space (EXIF orientation applied) — the space // the sent image, the caption, and ReadMediaFile region readback share — // while parseImageMeta reads the raw pre-rotation header. - // Persist the original BEFORE minting a daemon upload: when persistence - // fails the whole ingestion is abandoned, and an upload minted earlier - // would be orphaned (never attached, never deleted). const original = compressed.changed ? { - path: await persistOriginalImage( - originalBytes, - originalMime, - sessionDir === undefined ? {} : { dir: sessionMediaOriginalsDir(sessionDir) }, - ), + bytes: originalBytes, width: compressed.originalWidth, height: compressed.originalHeight, byteLength: originalBytes.length, @@ -650,9 +641,6 @@ export class EditorKeyboardController { if (completed === undefined && uploaded !== undefined) { await this.host.harness?.deleteFile(uploaded.id).catch(() => undefined); } - if (completed === undefined && original !== undefined && original.path !== null) { - await unlink(original.path).catch(() => undefined); - } this.host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 21db15b5b..70a34bd8e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -159,8 +159,10 @@ import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments, + originalsDirForSession, pendingImageIngestions, refreshExpiringImageFileRefs, + resolveOriginalCaptions, rewriteMediaPlaceholders, videoAttachmentIdsInText, } from './utils/image-placeholder'; @@ -1486,7 +1488,14 @@ export class KimiTUI { ): Promise { const knownEntryIds = new Set(this.state.transcriptEntries.map((entry) => entry.id)); await session.promptWithSkills( - extraction.hasMedia ? extraction.parts : text, + extraction.hasMedia + ? resolveOriginalCaptions( + extraction.parts, + extraction.imageAttachmentIds, + this.imageStore, + originalsDirForSession(session), + ) + : text, activations.map((activation) => ({ name: activation.skillName, args: activation.args })), ); // The engine bundles the activations into the prompt's own message, and @@ -1767,7 +1776,18 @@ export class KimiTUI { : this.streamingUI.getTurnContext().turnId; this.beginSessionRequest(); - const sdkInput = options?.parts ?? input; + // Compression captions for pasted images are authored here — not at + // extraction — because only now is the session (and its media-originals + // dir) known: extraction runs before a first session exists. + const sdkInput = + options?.parts !== undefined + ? resolveOriginalCaptions( + options.parts, + options.imageAttachmentIds ?? [], + this.imageStore, + originalsDirForSession(session), + ) + : input; const goalActive = this.state.appState.goal?.status === 'active'; // The lease normally arrives pre-created by sendNormalUserInput (carrying // its exact-binding submission id). Queued dispatches and steer batches @@ -1963,7 +1983,22 @@ export class KimiTUI { const stagingLease = this.staging.create(imageAttachmentIds, stagingPaths, 'user'); const currentTurnId = this.streamingUI.getTurnContext().turnId; if (currentTurnId !== undefined) this.staging.bindToTurn(stagingLease, currentTurnId); - this.staging.trackDispatch(stagingLease, session.steer(combineSteerInput(input)), (error) => { + // Same dispatch-time caption resolution as sendMessageInternal — the + // running turn's session owns the persisted originals. + const resolvedInput = input.map((item) => + item.parts === undefined + ? item + : { + ...item, + parts: resolveOriginalCaptions( + item.parts, + item.imageAttachmentIds ?? [], + this.imageStore, + originalsDirForSession(session), + ), + }, + ); + this.staging.trackDispatch(stagingLease, session.steer(combineSteerInput(resolvedInput)), (error) => { this.showError(`Failed to steer: ${formatErrorMessage(error)}`); }); } @@ -2276,7 +2311,12 @@ export class KimiTUI { // A session switch abandons the previous session's in-flight staging // leases and retires its history-owned cache copies. Do this at the // boundary so retired paths cannot accumulate until process shutdown. - this.staging.releaseAll(); + // Only when actually replacing a live session, though: on lazy first + // creation the outstanding lease belongs to the new session's first + // prompt, whose dispatch continues right after this — releasing it here + // would delete the staged media (e.g. a pasted image's daemon upload) + // before the engine's intake can read it. + if (previous !== undefined) this.staging.releaseAll(); this.session = session; this.harness.setTelemetryContext({ sessionId: session.id }); this.registerSessionHandlers(session); diff --git a/apps/kimi-code/src/tui/utils/image-attachment-store.ts b/apps/kimi-code/src/tui/utils/image-attachment-store.ts index ed2a5af4a..a1f520cd2 100644 --- a/apps/kimi-code/src/tui/utils/image-attachment-store.ts +++ b/apps/kimi-code/src/tui/utils/image-attachment-store.ts @@ -6,9 +6,10 @@ * (640×480)]` / `[video #2 sample.mov]`). The placeholder is what the * user sees in the input field; on submit, `extractMediaAttachments` * walks the text and expands image placeholders to image content parts - * (preceded by a compression caption when paste-time compression shrank - * the bytes — see `ImageAttachment.original`) and video placeholders to - * file-path tags for `ReadMediaFile`. + * (dispatch-time caption resolution then precedes them with a compression + * caption when paste-time compression shrank the bytes — see + * `ImageAttachment.original`) and video placeholders to file-path tags + * for `ReadMediaFile`. * * Scope is per-`KimiTUI` instance. Reloads (`/new`, `/clear`, * session switch) call `clear()` so ids restart from 1 and stale @@ -19,14 +20,24 @@ export interface ImageAttachmentOriginal { /** - * Where the pre-compression bytes were persisted for readback - * (ReadMediaFile + region); null when persistence failed. + * Pre-compression bytes, kept in memory until dispatch-time caption + * resolution (`resolveOriginalCaptions`) persists them — the session whose + * media-originals dir they belong in may not exist yet at paste time. + * Released once persistence succeeds; the on-disk copy is the original + * from then on. */ - readonly path: string | null; + bytes?: Uint8Array; readonly width: number; readonly height: number; + /** Pre-compression size, retained for captions after `bytes` is released. */ readonly byteLength: number; readonly mime: string; + /** + * Where the original was persisted for readback (ReadMediaFile + region). + * Undefined until dispatch-time persistence succeeds; failures are retried + * at the next dispatch. + */ + path?: string; } export interface ImageAttachment { @@ -38,8 +49,8 @@ export interface ImageAttachment { readonly height: number; /** * Pre-compression original, recorded when paste-time compression changed - * the bytes. Drives the compression caption emitted on submit so the model - * knows it received a downsampled copy. Absent for untouched pastes. + * the bytes. Drives the compression caption authored on dispatch so the + * model knows it received a downsampled copy. Absent for untouched pastes. */ readonly original?: ImageAttachmentOriginal | undefined; /** @@ -52,9 +63,9 @@ export interface ImageAttachment { /** Epoch milliseconds when the daemon staging upload expires. */ fileExpiresAt?: number; /** - * Background ingestion (compression/original persistence/daemon upload) - * still in flight. The paste callback settles once the placeholder is in - * the editor — typing never waits on this — but submit holds it briefly + * Background ingestion (compression/daemon upload) still in flight. The + * paste callback settles once the placeholder is in the editor — typing + * never waits on this — but submit holds it briefly * (`pendingImageIngestions`) so a fast paste-then-Enter still gets the * compressed/ref form; a slow ingestion submits the inline form instead. * Cleared when ingestion completes. @@ -139,8 +150,8 @@ export class ImageAttachmentStore { /** * Complete an image that was inserted into the editor before its ingestion - * work (compression/original persistence/upload) finished. Returns undefined - * when the attachment was cleared while that work was in flight. + * work (compression/upload) finished. Returns undefined when the attachment + * was cleared while that work was in flight. */ completeImage( attachment: ImageAttachment, @@ -169,6 +180,20 @@ export class ImageAttachmentStore { return attachment; } + /** + * Record where an attachment's pre-compression original was persisted and + * release the in-memory buffer — the on-disk copy is the original from + * then on, and the caption only needs the retained metadata. Dispatch-time + * caption resolution calls this after a successful write; failures leave + * the path unset so a later dispatch retries. + */ + setOriginalPath(id: number, path: string): void { + const attachment = this.byId.get(id); + if (attachment?.kind !== 'image' || attachment.original === undefined) return; + attachment.original.path = path; + attachment.original.bytes = undefined; + } + get(id: number): MediaAttachment | undefined { return this.byId.get(id); } diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts index ab5bef2cd..233f95ae8 100644 --- a/apps/kimi-code/src/tui/utils/image-placeholder.ts +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -3,15 +3,18 @@ * we'll send to the SDK prompt endpoint. * * `extractMediaAttachments` (sync) is the single expansion path for prompts: - * - image placeholders expand to inline image content parts (preceded by a - * compression caption when paste-time compression shrank the bytes — see - * `ImageAttachment.original`). When the paste was uploaded to the daemon - * file store (`ImageAttachment.fileId`, v2 engine only), the placeholder - * instead expands to a bare `kimi-file://` image part — the engine's - * prompt intake materializes the session copy and rewrites the reference - * with its `?path=`, making the part self-contained (no paired tag is - * authored); without a `fileId` the inline base64 form is emitted - * unchanged (the only form the v1 engine accepts); + * - image placeholders expand to inline image content parts. When the paste + * was uploaded to the daemon file store (`ImageAttachment.fileId`, v2 + * engine only), the placeholder instead expands to a bare + * `kimi-file://` image part — the engine's prompt intake materializes + * the session copy and rewrites the reference with its `?path=`, making + * the part self-contained (no paired tag is authored); without a `fileId` + * the inline base64 form is emitted unchanged (the only form the v1 + * engine accepts). Compression captions for paste-time-downsampled images + * are NOT authored here: extraction runs before a first session exists, + * so `resolveOriginalCaptions` adds them at dispatch time, persisting the + * in-memory original (`ImageAttachment.original`) into the session's + * media-originals dir first; * - video placeholders are copied into the shared cache (`getCacheDir()`) * and expand to a `video_url` part pointing at the cache copy with a * `file://` url. The v1 engine resolves that local reference inside the @@ -34,16 +37,18 @@ * noise between two media parts. */ -import { randomUUID } from 'node:crypto'; -import { copyFileSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { createHash, randomUUID } from 'node:crypto'; +import { copyFileSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; +import type { PromptPart, Session } from '@moonshot-ai/kimi-code-sdk'; import { buildDaemonFileUrl, buildImageCompressionCaption, buildMediaPathTag, + sessionMediaOriginalsDir, } from '@moonshot-ai/kimi-code-sdk'; import { getCacheDir } from '#/utils/paths'; @@ -87,6 +92,20 @@ export interface ExtractionResult { export interface ImageResendSnapshot { readonly bytes: Uint8Array; readonly mime: string; + readonly width: number; + readonly height: number; + /** + * Pre-compression original captured at extraction, so a new-session resend + * can still persist it and author the compression caption after the image + * store (and its attachments) was cleared. Absent for untouched pastes and + * for originals already persisted and released. + */ + readonly original?: { + readonly bytes: Uint8Array; + readonly width: number; + readonly height: number; + readonly mime: string; + }; } export function extractMediaAttachments( @@ -122,12 +141,25 @@ export function extractMediaAttachments( parts.push(videoPartForCachePath(cachePath)); videoAttachmentIds.push(id); } else { - imageSnapshots.push({ bytes: attachment.bytes, mime: attachment.mime }); - // Paste-time compression is announced next to the image so the model - // knows it received a downsampled copy and where the original lives. - if (attachment.original !== undefined) { - pushText(parts, captionForCompressedImage(attachment)); - } + const original = attachment.original; + imageSnapshots.push({ + bytes: attachment.bytes, + mime: attachment.mime, + width: attachment.width, + height: attachment.height, + original: + original?.bytes === undefined + ? undefined + : { + bytes: original.bytes, + width: original.width, + height: original.height, + mime: original.mime, + }, + }); + // No compression caption here: `resolveOriginalCaptions` authors it + // at dispatch time, once the session (and its media-originals dir) + // is known. if (attachment.fileId !== undefined) { // The bytes were uploaded to the daemon file store at paste time // (v2): reference them by a bare `kimi-file://` url — the engine's @@ -270,23 +302,61 @@ export function refreshExpiringImageFileRefs( * replaced with the bytes captured during the original extraction. Cache * paths are intentionally preserved: they are carried by the resend's new * staging lease and remain available to any path tag in the prompt. + * + * Snapshots of compressed pastes also carry the pre-compression original: the + * cleared store took the attachment with it, so dispatch-time caption + * resolution can no longer find either. `makeExtractionResendable` persists + * that original into `originalsDir` (the NEW session's media-originals dir; + * temp-dir fallback when undefined) and authors the compression caption + * itself, right before the rebuilt image part. */ -export function makeExtractionResendable(extraction: ExtractionResult): ExtractionResult { +export function makeExtractionResendable( + extraction: ExtractionResult, + originalsDir?: string, +): ExtractionResult { if (extraction.imageSnapshots.length === 0) return extraction; let imageIndex = 0; - const parts = extraction.parts.map((part) => { - if (part.type !== 'image_url') return part; + const parts: PromptPart[] = []; + for (const part of extraction.parts) { + if (part.type !== 'image_url') { + parts.push(part); + continue; + } const snapshot = extraction.imageSnapshots[imageIndex++]; - if (snapshot === undefined || !part.imageUrl.url.startsWith('kimi-file://')) return part; - return { + const original = snapshot?.original; + if (snapshot !== undefined && original !== undefined) { + parts.push({ + type: 'text', + text: buildImageCompressionCaption({ + original: { + width: original.width, + height: original.height, + byteLength: original.bytes.length, + mimeType: original.mime, + }, + final: { + width: snapshot.width, + height: snapshot.height, + byteLength: snapshot.bytes.length, + mimeType: snapshot.mime, + }, + originalPath: persistOriginalImageSync(original.bytes, original.mime, originalsDir), + }), + }); + } + if (snapshot === undefined || !part.imageUrl.url.startsWith('kimi-file://')) { + parts.push(part); + continue; + } + parts.push({ ...part, imageUrl: { ...part.imageUrl, url: `data:${snapshot.mime};base64,${Buffer.from(snapshot.bytes).toString('base64')}`, }, - }; - }); + }); + } return { ...extraction, @@ -411,7 +481,7 @@ function pushText(parts: PromptPart[], segment: string): void { parts.push({ type: 'text', text: segment }); } -function imagePartForAttachment(att: ImageAttachment): PromptPart { +function imagePartForAttachment(att: ImageAttachment): Extract { const base64 = Buffer.from(att.bytes).toString('base64'); return { type: 'image_url', @@ -419,6 +489,23 @@ function imagePartForAttachment(att: ImageAttachment): PromptPart { }; } +/** + * Is this image part still what the attachment holds? Extraction encodes the + * attachment as of extraction time; a paste whose background ingestion + * (compression/daemon upload) landed afterwards mutated it, leaving the part + * carrying the pre-compression form — which no caption may describe. + */ +function imagePartMatchesAttachment( + part: Extract, + attachment: ImageAttachment, +): boolean { + const url = part.imageUrl.url; + if (url.startsWith('kimi-file://')) { + return attachment.fileId !== undefined && url === buildDaemonFileUrl(attachment.fileId); + } + return url === imagePartForAttachment(attachment).imageUrl.url; +} + /** * A `video_url` prompt part pointing at a cache copy by `file://` url. The v1 * engine resolves the local reference in-turn (upload → `ms://`, or degrade to @@ -471,24 +558,158 @@ function materializeImageToCache(att: ImageAttachment): string { return target; } -function captionForCompressedImage(att: ImageAttachment): string { - const original = att.original; - if (original === undefined) return ''; - return buildImageCompressionCaption({ - original: { - width: original.width, - height: original.height, - byteLength: original.byteLength, - mimeType: original.mime, - }, - final: { - width: att.width, - height: att.height, - byteLength: att.bytes.length, - mimeType: att.mime, - }, - originalPath: original.path, - }); +/** Opening every compression caption starts with (see buildImageCompressionCaption). */ +const CAPTION_OPENING = 'Image compressed to fit model limits:'; + +/** + * The session-owned originals store for compression captions, when the + * session's dir is known; undefined falls back to the shared temp dir. + */ +export function originalsDirForSession(session: Session | undefined): string | undefined { + const sessionDir = session?.summary?.sessionDir; + return sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir); +} + +/** + * Author a compression caption before every referenced image whose paste-time + * compression shrank the bytes, persisting not-yet-persisted originals into + * `originalsDir` (the session's media-originals dir; the shared temp-dir + * fallback when undefined) so the caption points at a real readback path. + * + * Extraction deliberately does not do this: it can run before the session + * exists (first submit creates it lazily), and the original belongs with the + * session — owned by it, cleaned up with it, immune to OS temp reaping. The + * dispatch paths call this once the session is known. Synchronous because + * those paths cannot await; the write is a single small file, same as the + * cache copies extraction itself stages. Idempotent: an image already + * preceded by a compression caption gets it refreshed in place, so a + * re-resolved part list never grows a duplicate. + */ +export function resolveOriginalCaptions( + parts: readonly PromptPart[], + imageAttachmentIds: readonly number[], + store: ImageAttachmentStore, + originalsDir: string | undefined, +): PromptPart[] { + let imageIndex = 0; + let changed = false; + const out: PromptPart[] = []; + for (const part of parts) { + if (part.type !== 'image_url') { + out.push(part); + continue; + } + const attachmentId = imageAttachmentIds[imageIndex++]; + const attachment = attachmentId === undefined ? undefined : store.get(attachmentId); + if (attachment?.kind !== 'image' || attachment.original === undefined) { + out.push(part); + continue; + } + // The part was encoded from the attachment at extraction; a paste whose + // background ingestion landed afterwards mutated it (compressed bytes, + // daemon file id), leaving the part carrying the pre-compression form. + // Caption only when the two still agree — otherwise the caption would + // describe an image the model did not receive. + if (!imagePartMatchesAttachment(part, attachment)) { + out.push(part); + continue; + } + const original = attachment.original; + if (original.path === undefined && original.bytes !== undefined) { + // A persistence failure (unwritable dir, full disk) leaves the path + // unset — and the bytes retained — so a later dispatch retries; this + // dispatch captions without a readback path. + const path = persistOriginalImageSync(original.bytes, original.mime, originalsDir); + if (path !== null) store.setOriginalPath(attachment.id, path); + } + const caption = buildImageCompressionCaption({ + original: { + width: original.width, + height: original.height, + byteLength: original.byteLength, + mimeType: original.mime, + }, + final: { + width: attachment.width, + height: attachment.height, + byteLength: attachment.bytes.length, + mimeType: attachment.mime, + }, + originalPath: original.path, + }); + const previous = out.at(-1); + if (previous?.type === 'text' && previous.text.startsWith(CAPTION_OPENING)) { + out[out.length - 1] = { type: 'text', text: caption }; + } else { + out.push({ type: 'text', text: caption }); + } + changed = true; + out.push(part); + } + return changed ? out : [...parts]; +} + +/** + * Synchronous twin of the engine's `persistOriginalImage` — same + * content-addressed naming and the same size-capped eviction: the dispatch + * paths that resolve captions cannot await. Exported for tests; production + * callers go through `resolveOriginalCaptions` / `makeExtractionResendable`. + */ +export function persistOriginalImageSync( + bytes: Uint8Array, + mime: string, + dir: string | undefined, + maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES, +): string | null { + if (bytes.length === 0) return null; + try { + const targetDir = dir ?? originalImageTempDir(); + const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 32); + const target = join(targetDir, `${hash}.${imageExtensionForMime(mime)}`); + mkdirSync(targetDir, { recursive: true }); + const existing = statSync(target, { throwIfNoEntry: false }); + // Content-addressed: an existing entry with the right size IS this image. + if (existing === undefined || existing.size !== bytes.length) { + writeFileSync(target, bytes); + } + sweepCacheSync(targetDir, maxTotalBytes); + // The just-written file may itself have been evicted by the sweep when a + // single original exceeds the cap; report persistence honestly. + return statSync(target, { throwIfNoEntry: false }) === undefined ? null : target; + } catch { + return null; + } +} + +/** Per-store ceiling; mirrors the engine originals store. */ +const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024; // 1 GiB + +/** Evict oldest files (by mtime) until the store fits `maxTotalBytes`. */ +function sweepCacheSync(dir: string, maxTotalBytes: number): void { + const entries: { path: string; size: number; mtimeMs: number }[] = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + const info = statSync(path, { throwIfNoEntry: false }); + if (info === undefined || !info.isFile()) continue; + entries.push({ path, size: info.size, mtimeMs: info.mtimeMs }); + } + let total = entries.reduce((sum, entry) => sum + entry.size, 0); + if (total <= maxTotalBytes) return; + entries.sort((a, b) => a.mtimeMs - b.mtimeMs); + for (const entry of entries) { + if (total <= maxTotalBytes) break; + try { + unlinkSync(entry.path); + total -= entry.size; + } catch { + // Best effort, mirroring the async twin. + } + } +} + +/** Mirrors agent-core's `originalImageCacheDir` (not re-exported through the SDK). */ +function originalImageTempDir(): string { + return join(tmpdir(), 'kimi-code-original-images'); } /** diff --git a/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts b/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts index 2dd5871ba..beb9082f5 100644 --- a/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts +++ b/apps/kimi-code/test/tui/controllers/cache-hint-controller.test.ts @@ -96,7 +96,7 @@ function uploadedExtraction(fileId: string, byte: number): ExtractionResult { hasMedia: true, imageAttachmentIds: [1], videoAttachmentIds: [], - imageSnapshots: [{ bytes: new Uint8Array([byte]), mime: 'image/png' }], + imageSnapshots: [{ bytes: new Uint8Array([byte]), mime: 'image/png', width: 640, height: 480 }], stagingPaths: [path], }; } diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts index 17e363e3e..9e6056f86 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -5,9 +5,10 @@ * - an oversized pasted image is downsampled while building the attachment, * so the stored bytes, the `[image #N (W×H)]` placeholder, and the eventual * submitted image all agree on the compressed size - * - the pre-compression original is persisted and recorded on the - * attachment, so the submitted prompt can announce the compression and - * point the model at the full-fidelity bytes + * - the pre-compression original is recorded on the attachment in memory — + * never persisted at paste time, because the session whose + * media-originals dir it belongs in may not exist yet; dispatch-time + * caption resolution owns persistence (see image-placeholder tests) * - a within-budget paste is stored byte-for-byte (fast path), with no * original recorded * - on the v2 engine the final bytes are uploaded to the daemon file store @@ -15,7 +16,8 @@ * and expiry; an upload failure leaves the paste on the inline fallback */ -import { mkdtemp, readFile, rm, unlink } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -211,7 +213,7 @@ describe('clipboard image paste compression', () => { expect(Math.max(dims!.width, dims!.height)).toBe(800); }); - it('records and persists the pre-compression original for an oversized paste', async () => { + it('records the pre-compression original in memory for an oversized paste', async () => { const big = await solidPng(3600, 1800); readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); @@ -221,19 +223,18 @@ describe('clipboard image paste compression', () => { const att = store.get(1); if (att?.kind !== 'image') throw new Error('expected image attachment'); expect(att.original).toBeDefined(); + expect(att.original?.bytes).toEqual(big); expect(att.original?.width).toBe(3600); expect(att.original?.height).toBe(1800); expect(att.original?.byteLength).toBe(big.length); expect(att.original?.mime).toBe('image/png'); - // The original bytes are readable back from the persisted path. - expect(att.original?.path).not.toBeNull(); - const persisted = await readFile(att.original!.path!); - expect(new Uint8Array(persisted)).toEqual(big); - await unlink(att.original!.path!).catch(() => undefined); + // Nothing is persisted at paste time — dispatch-time caption resolution + // owns that, once the session (and its media-originals dir) is known. + expect(att.original?.path).toBeUndefined(); }); - it('persists the original into the session media-originals dir when the session is known', async () => { + it('does not persist the original at paste time, even with a known session', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-paste-session-')); const big = await solidPng(3600, 1800); readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); @@ -243,10 +244,9 @@ describe('clipboard image paste compression', () => { const att = store.get(1); if (att?.kind !== 'image') throw new Error('expected image attachment'); - expect(att.original?.path).not.toBeNull(); - expect(att.original!.path!.startsWith(join(sessionDir, 'media-originals'))).toBe(true); - const persisted = await readFile(att.original!.path!); - expect(new Uint8Array(persisted)).toEqual(big); + expect(att.original?.bytes).toEqual(big); + expect(att.original?.path).toBeUndefined(); + expect(existsSync(join(sessionDir, 'media-originals'))).toBe(false); await rm(sessionDir, { recursive: true, force: true }); }); @@ -291,7 +291,6 @@ describe('clipboard image paste compression', () => { expect(att.original?.height).toBe(3600); // The compressed attachment itself keeps the portrait aspect. expect(att.width).toBeLessThan(att.height); - await unlink(att.original!.path!).catch(() => undefined); }, 15_000, ); @@ -372,7 +371,6 @@ describe('clipboard image paste compression', () => { const [data] = uploadFile.mock.calls[0]!; expect(data).toBe(att.bytes); expect(att.bytes).not.toBe(big); - await unlink(att.original!.path!).catch(() => undefined); }); it('keeps the paste on the inline fallback when the daemon upload fails (v2)', async () => { diff --git a/apps/kimi-code/test/tui/input/image-placeholder.test.ts b/apps/kimi-code/test/tui/input/image-placeholder.test.ts index 8a52dedaa..2dcdc498e 100644 --- a/apps/kimi-code/test/tui/input/image-placeholder.test.ts +++ b/apps/kimi-code/test/tui/input/image-placeholder.test.ts @@ -3,7 +3,7 @@ * fallback from expiring daemon uploads to bytes retained by the TUI. */ -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -18,7 +18,9 @@ import { extractMediaAttachments, makeExtractionResendable, pendingImageIngestions, + persistOriginalImageSync, refreshExpiringImageFileRefs, + resolveOriginalCaptions, rewriteMediaPlaceholders, } from '#/tui/utils/image-placeholder'; import { getCacheDir } from '#/utils/paths'; @@ -192,45 +194,24 @@ describe('extractMediaAttachments', () => { } }); - it('inserts a compression caption before an image that was compressed at paste time', () => { + it('expands a compressed paste without a caption — captions are authored at dispatch', () => { const store = new ImageAttachmentStore(); const att = store.addImage(new Uint8Array([1, 2, 3]), 'image/png', 2000, 2000, { - path: '/tmp/kimi-code-original-images/abc.png', + bytes: new Uint8Array([9, 8, 7]), width: 2600, height: 2600, - byteLength: 123456, + byteLength: 3, mime: 'image/png', }); const r = extractMediaAttachments(`look ${att.placeholder}`, store); - expect(r.parts).toHaveLength(2); - const caption = r.parts[0]; - if (caption?.type !== 'text') throw new Error('expected leading text part'); - expect(caption.text).toContain('Image compressed'); - expect(caption.text).toContain('2600x2600'); - expect(caption.text).toContain('/tmp/kimi-code-original-images/abc.png'); - expect(r.parts[1]).toEqual({ - type: 'image_url', - imageUrl: { url: 'data:image/png;base64,AQID' }, - }); - }); - - it('notes an unpreserved original when persistence failed at paste time', () => { - const store = new ImageAttachmentStore(); - const att = store.addImage(new Uint8Array([1]), 'image/png', 2000, 2000, { - path: null, - width: 2600, - height: 2600, - byteLength: 123456, - mime: 'image/png', - }); - - const r = extractMediaAttachments(att.placeholder, store); - - const caption = r.parts[0]; - if (caption?.type !== 'text') throw new Error('expected leading text part'); - expect(caption.text).toMatch(/not preserved/i); + // Extraction stays persistence-free: no caption part, no original path. + expect(r.parts).toEqual([ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQID' } }, + ]); + expect(att.original?.path).toBeUndefined(); }); it('adds no caption for an uncompressed image attachment', () => { @@ -313,35 +294,46 @@ describe('extractMediaAttachments', () => { } }); - it('emits the compression caption before the bare kimi-file reference', () => { - const { cleanup } = setupTempCache(); + it('rebuilds a compressed paste with its caption and original for a new-session resend', () => { + const dir = makeTempDir(); try { const store = new ImageAttachmentStore(); const att = store.addImage( new Uint8Array([1, 2, 3]), 'image/png', 2000, - 2000, + 1000, { - path: '/tmp/kimi-code-original-images/abc.png', + bytes: new Uint8Array([9, 8, 7, 6]), width: 2600, height: 2600, - byteLength: 123456, + byteLength: 4, mime: 'image/png', }, - 'file-2', + 'file-1', ); - const r = extractMediaAttachments(att.placeholder, store); - expect(r.parts).toHaveLength(2); - const caption = r.parts[0]; - if (caption?.type !== 'text') throw new Error('expected leading text part'); + // The session reset clears the store, so the snapshot is the only place + // the original survives — the resend must persist it into the NEW + // session's originals dir and author the caption itself. + const extraction = extractMediaAttachments(att.placeholder, store); + + const resend = makeExtractionResendable(extraction, dir); + + expect(resend.imageAttachmentIds).toEqual([]); + expect(resend.parts).toHaveLength(2); + const caption = resend.parts[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); expect(caption.text).toContain('Image compressed'); - expect(r.parts[1]).toEqual({ + expect(caption.text).toContain('2600x2600'); + const files = readdirSync(dir); + expect(files).toHaveLength(1); + expect(caption.text).toContain(join(dir, files[0]!)); + expect(resend.parts[1]).toEqual({ type: 'image_url', - imageUrl: { url: 'kimi-file://file-2' }, + imageUrl: { url: 'data:image/png;base64,AQID' }, }); } finally { - cleanup(); + rmSync(dir, { recursive: true, force: true }); } }); @@ -388,6 +380,271 @@ describe('extractMediaAttachments', () => { }); }); +describe('resolveOriginalCaptions', () => { + function storeWithOriginal( + original?: { + bytes: Uint8Array; + width: number; + height: number; + byteLength: number; + mime: string; + path?: string; + }, + fileId?: string, + ) { + const store = new ImageAttachmentStore(); + const att = store.addImage( + new Uint8Array([1, 2, 3]), + 'image/png', + 2000, + 1000, + original, + fileId, + ); + return { store, att }; + } + + it('persists the original into the given dir and inserts the caption before the image', () => { + const dir = makeTempDir(); + try { + const originalBytes = new Uint8Array([9, 8, 7, 6]); + const { store, att } = storeWithOriginal({ + bytes: originalBytes, + width: 2600, + height: 2600, + byteLength: originalBytes.length, + mime: 'image/png', + }); + const r = extractMediaAttachments(`look ${att.placeholder}`, store); + + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + expect(att.original?.path?.startsWith(dir)).toBe(true); + expect(readFileSync(att.original!.path!)).toEqual(Buffer.from(originalBytes)); + expect(resolved).toHaveLength(3); + const caption = resolved[1]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toContain('Image compressed'); + expect(caption.text).toContain('2600x2600'); + expect(caption.text).toContain(att.original!.path!); + expect(resolved[2]).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,AQID' }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('releases the in-memory original bytes once persistence succeeds', () => { + const dir = makeTempDir(); + try { + const originalBytes = new Uint8Array([9, 8, 7, 6]); + const { store, att } = storeWithOriginal({ + bytes: originalBytes, + width: 2600, + height: 2600, + byteLength: originalBytes.length, + mime: 'image/png', + }); + const r = extractMediaAttachments(att.placeholder, store); + resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + // The on-disk copy is the original from here on; the caption still + // renders the original size from the retained metadata. + expect(att.original?.bytes).toBeUndefined(); + const again = resolveOriginalCaptions( + r.parts, + r.imageAttachmentIds, + store, + dir, + ); + const caption = again[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toContain('2600x2600'); + expect(caption.text).toContain('4 B'); + expect(caption.text).toContain(att.original!.path!); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('authors the caption before the bare kimi-file reference', () => { + const dir = makeTempDir(); + try { + const { store, att } = storeWithOriginal( + { bytes: new Uint8Array([9, 9]), width: 2600, height: 2600, byteLength: 2, mime: 'image/png' }, + 'file-2', + ); + const r = extractMediaAttachments(att.placeholder, store); + + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + expect(resolved).toHaveLength(2); + const caption = resolved[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toContain('Image compressed'); + expect(caption.text).toContain(att.original!.path!); + expect(resolved[1]).toEqual({ + type: 'image_url', + imageUrl: { url: 'kimi-file://file-2' }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refreshes an already-authored caption in place instead of duplicating it', () => { + const dir = makeTempDir(); + try { + const { store, att } = storeWithOriginal({ + bytes: new Uint8Array([9]), + width: 2600, + height: 2600, + byteLength: 1, + mime: 'image/png', + }); + const r = extractMediaAttachments(att.placeholder, store); + const once = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + const twice = resolveOriginalCaptions(once, r.imageAttachmentIds, store, dir); + + expect(twice).toHaveLength(2); + expect(twice[0]?.type).toBe('text'); + expect(twice[1]?.type).toBe('image_url'); + // The content-addressed original was persisted exactly once. + expect(att.original?.path?.startsWith(dir)).toBe(true); + expect(readdirSync(dir)).toHaveLength(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reuses an already-persisted original path without rewriting the file', () => { + const dir = makeTempDir(); + try { + const existing = join(dir, 'already.png'); + writeFileSync(existing, 'orig'); + const { store, att } = storeWithOriginal({ + bytes: new Uint8Array([7, 7, 7]), + width: 2600, + height: 2600, + byteLength: 3, + mime: 'image/png', + path: existing, + }); + const r = extractMediaAttachments(att.placeholder, store); + + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + const caption = resolved[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toContain(existing); + expect(readFileSync(existing, 'utf8')).toBe('orig'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('notes an unpreserved original when persistence fails, then retries at a later dispatch', () => { + const dir = makeTempDir(); + try { + // A file where the target directory must be created breaks persistence. + const occupied = join(dir, 'occupied'); + writeFileSync(occupied, 'x'); + const { store, att } = storeWithOriginal({ + bytes: new Uint8Array([5, 5]), + width: 2600, + height: 2600, + byteLength: 2, + mime: 'image/png', + }); + const r = extractMediaAttachments(att.placeholder, store); + + const failed = resolveOriginalCaptions( + r.parts, + r.imageAttachmentIds, + store, + join(occupied, 'sub'), + ); + + const caption = failed[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toMatch(/not preserved/i); + // The failure is not terminal: the path stays unset and the bytes are + // retained, so a later dispatch retries the write. + expect(att.original?.path).toBeUndefined(); + expect(att.original?.bytes).toBeDefined(); + + const retried = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + expect(att.original?.path?.startsWith(dir)).toBe(true); + const retryCaption = retried[0]; + if (retryCaption?.type !== 'text') throw new Error('expected caption text part'); + expect(retryCaption.text).toContain(att.original!.path!); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('skips the caption when ingestion landed after extraction (stale inline part)', () => { + const dir = makeTempDir(); + try { + const store = new ImageAttachmentStore(); + const rawBytes = new Uint8Array([1, 2, 3, 4]); + // Extraction raced the background ingestion: the part encodes the raw + // paste bytes… + const att = store.addImage(rawBytes, 'image/png', 2600, 2600); + const r = extractMediaAttachments(att.placeholder, store); + // …then ingestion completed, recording the compressed form. Captioning + // now would describe an image the model did not receive. + store.completeImage(att, { + bytes: new Uint8Array([1, 2, 3]), + mime: 'image/png', + width: 2000, + height: 2000, + original: { bytes: rawBytes, width: 2600, height: 2600, byteLength: 4, mime: 'image/png' }, + }); + + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + expect(resolved).toHaveLength(1); + expect(resolved[0]?.type).toBe('image_url'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('leaves images without an original untouched', () => { + const { store, placeholder } = storeWith(new Uint8Array([0xaa])); + const r = extractMediaAttachments(placeholder, store); + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, undefined); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.type).toBe('image_url'); + }); +}); + +describe('persistOriginalImageSync', () => { + it('evicts the oldest originals once the store exceeds the size cap', () => { + const dir = makeTempDir(); + try { + const first = persistOriginalImageSync(new Uint8Array(6).fill(1), 'image/png', dir); + expect(first).not.toBeNull(); + // Pin the first file far into the past so eviction order is deterministic. + const old = new Date(Date.now() - 60_000); + utimesSync(first!, old, old); + + const second = persistOriginalImageSync(new Uint8Array(6).fill(2), 'image/png', dir, 10); + + expect(second).not.toBeNull(); + expect(existsSync(first!)).toBe(false); + expect(existsSync(second!)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('rewriteMediaPlaceholders', () => { it('returns plain text untouched with hasMedia=false', () => { const store = new ImageAttachmentStore(); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index ccbc2fc9c..1634c26bb 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -3324,6 +3324,38 @@ command = "vim" expect(attachment.bytes).toEqual(new Uint8Array([0xaa, 0xbb])); }); + it('keeps an image staging upload across lazy session creation (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-lazy'); + + driver.handleUserInput(attachment.placeholder); + + // The lease is created at extraction, before the session exists: lazy + // creation runs setSession mid-dispatch, and the first prompt's lease + // must survive it — the engine's intake only reads the upload once the + // prompt lands. + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'image_url', imageUrl: { url: 'kimi-file://file-lazy' } }], + { promptId: expect.any(String) }, + ); + }); + expect(harness.deleteFile).not.toHaveBeenCalled(); + emitTurn(driver, 1, () => { + expect(harness.deleteFile).not.toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledWith('file-lazy'); + }); + }); + it('still deletes the staging upload when a cache-hint dismissal precedes the resend', async () => { const { driver, session, harness } = await makeDriver(); const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore;