mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-19 13:45:28 +00:00
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:
parent
59dde734f3
commit
ee55c4d523
9 changed files with 708 additions and 143 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue