fix(kimi-code): persist pasted-image originals into the session dir at dispatch (#2993)

* fix(kimi-code): persist pasted-image originals into the session dir at dispatch

Paste-time original persistence ran before the session existed on a
fresh TUI, so the compression caption baked a shared temp-dir path the
OS can reap. Keep the pre-compression bytes on the attachment in memory
and let dispatch-time caption resolution (sendMessageInternal,
steerMessage, runInlineSkillActivations) write them into the session's
media-originals dir — owned by the session, cleaned up with it, immune
to OS temp reaping.

* fix(kimi-code): harden pasted-image original lifecycle

Address review feedback:

- carry the pre-compression original in the resend snapshot so a
  cache-hint "new session" resend still persists it into the new
  session's originals dir and authors the compression caption
- release the in-memory original bytes once persistence succeeds,
  keeping only the metadata the caption needs
- apply the same 1 GiB mtime-bounded eviction to the sync originals
  store as the engine's async twin

* fix(kimi-code): keep compression captions consistent with the sent image

Address review feedback:

- author a caption only when the image part still matches the
  attachment's current state, so a paste whose ingestion landed after
  extraction (inline pre-compression fallback) is not described as
  downsampled
- leave the original's path unset when persistence fails so a later
  dispatch retries the write instead of dropping the original for good

* fix(kimi-code): keep staged media across lazy session creation

setSession() released ALL staging leases on the assumption that they
belong to the session being replaced. On the lazy first-creation path
there is no previous session: the outstanding lease belongs to the new
session's first prompt, whose dispatch continues right after. The
premature release deleted a pasted image's daemon upload before the
engine's intake could read it, so the model only received
'[image omitted: the uploaded file is no longer available]'.

Gate the release on actually replacing a live session; shutdown and
explicit close keep their own releaseAll().

* Delete .changeset/lazy-session-staging-lease.md

Signed-off-by: 7Sageer <sag77r@hotmail.com>

* Delete .changeset/pasty-image-originals-session-dir.md

Signed-off-by: 7Sageer <sag77r@hotmail.com>

---------

Signed-off-by: 7Sageer <sag77r@hotmail.com>
This commit is contained in:
7Sageer 2026-08-17 15:04:52 +08:00 committed by GitHub
parent 59dde734f3
commit ee55c4d523
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 708 additions and 143 deletions

View file

@ -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<void> {
// 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);

View file

@ -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();
}

View file

@ -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<void> {
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);

View file

@ -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);
}

View file

@ -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://<id>` 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://<id>` 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<PromptPart, { type: 'image_url' }> {
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<PromptPart, { type: 'image_url' }>,
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 = '<system>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');
}
/**

View file

@ -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],
};
}

View file

@ -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 () => {

View file

@ -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();

View file

@ -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;