Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/empty-image-session-poisoning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix pasted images intermittently failing to reach the model: a zero-byte image from a failed clipboard read poisoned the session, so every later image was dropped with an ambiguous placeholder and the model could hallucinate having seen it. Empty images are now replaced by a clear notice at ingestion and at send time (already-affected sessions recover automatically), and the placeholder tells the model the attachment was removed and must not be guessed at.
web: Show an error chip instead of uploading a zero-byte attachment (e.g. from a failed clipboard image read).
3 changes: 3 additions & 0 deletions apps/kimi-web/src/components/chat/AttachmentChip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const props = withDefaults(
uploading?: boolean;
/** Composer: upload failed — chip tinted, info icon replaces the badge. */
error?: boolean;
/** Composer: why the attachment failed — appended to the tooltip. */
errorReason?: string;
/** Composer: show a remove button. */
removable?: boolean;
/** Accessible label for the remove button. */
Expand Down Expand Up @@ -74,6 +76,7 @@ function formatSize(size: number): string {
const title = computed(() => {
const parts = [displayName.value];
if (props.size !== undefined) parts.push(formatSize(props.size));
if (props.errorReason !== undefined) parts.push(props.errorReason);
return parts.join(' · ');
});
</script>
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,7 @@ function selectModel(modelId: string): void {
:size="att.size"
:uploading="att.uploading"
:error="att.error"
:error-reason="att.errorReason"
removable
:remove-label="t('composer.removeNamed', { name: att.name })"
@activate="onAttachmentActivate(att)"
Expand Down
34 changes: 34 additions & 0 deletions apps/kimi-web/src/composables/useAttachmentUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { getKimiWebApi } from '../api';
import { i18n } from '../i18n';

export interface Attachment {
/** Unique local id (used as :key) */
Expand All @@ -33,6 +34,8 @@ export interface Attachment {
fileId?: string;
/** True if upload failed */
error?: boolean;
/** Localized reason shown in the chip tooltip when `error` is set. */
errorReason?: string;
}

type UploadImage = (
Expand Down Expand Up @@ -89,6 +92,24 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
for (const file of files) {
const kind = attachmentKind(file.type);
const localId = nextLocalId();
// A zero-byte file means the source (clipboard owner, picker) handed
// over no data — uploading it would attach an empty image that the
// provider later rejects. Mark it failed instead so the user
// re-captures; the chip stays visible but is excluded from submit.
if (file.size === 0) {
const att: Attachment = {
localId,
name: file.name,
kind,
mediaType: file.type || 'application/octet-stream',
size: file.size,
uploading: false,
error: true,
errorReason: i18n.global.t('composer.attachmentEmpty'),
};
setForSession(sid, [...(attachmentsBySession.value[sid] ?? []), att]);
continue;
}
// Only media gets a thumbnail object URL; files render an icon chip.
const previewUrl = kind === 'file' ? undefined : URL.createObjectURL(file);
const att: Attachment = {
Expand Down Expand Up @@ -177,6 +198,19 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) {
const key = `${blob.size}:${blob.type}:${name}`;
if (seenKeys.has(key)) return;
seenKeys.add(key);
// Diagnostics for the transient "empty clipboard image" failure: when
// the source app fails to deliver the selection, the browser hands us a
// named, typed, zero-byte File. Log everything visible at this point so
// the source can be identified the next time it happens.
if (blob.size === 0) {
console.warn('[kimi-web] paste produced a zero-byte file', {
name,
type: blob.type,
clipboardTypes: [...cd.types],
items: Array.from(cd.items).map((item) => ({ kind: item.kind, type: item.type })),
userAgent: navigator.userAgent,
});
}
const ext = blob.type.split('/')[1] ?? 'png';
const safeName = name.includes('.') ? name : `paste-${Date.now()}.${ext}`;
files.push(blob instanceof File ? blob : new File([blob], safeName, { type: blob.type }));
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/en/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default {
attachmentVideo: 'Video',
attachmentFile: 'File',
attachmentOpenUnsupported: 'Can’t open {name} — this file type isn’t supported',
attachmentEmpty: 'Empty file (0 bytes) — not attached. If it came from the clipboard, take the screenshot or copy the image again.',
dropToAttach: 'Drop files to attach',
remove: 'Remove',
removeNamed: 'Remove {name}',
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-web/src/i18n/locales/zh/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default {
attachmentVideo: '视频',
attachmentFile: '文件',
attachmentOpenUnsupported: '无法打开 {name}:暂不支持此文件类型',
attachmentEmpty: '文件为空(0 字节),未添加。如果来自剪贴板,请重新截图或复制后再粘贴。',
dropToAttach: '松开鼠标添加附件',
remove: '移除',
removeNamed: '移除 {name}',
Expand Down
22 changes: 22 additions & 0 deletions apps/kimi-web/test/attachment-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,28 @@ describe('useAttachmentUpload', () => {
expect(att.attachments.value).toHaveLength(0);
});

it('rejects a zero-byte file as a failed chip without uploading (issue #2209)', () => {
// A clipboard failure can hand the browser a named, typed, zero-byte
// File. Uploading it would attach an empty image that the provider later
// rejects — mark it failed instead so the user re-captures.
const uploadImage = vi.fn<UploadImage>();
const att = setup(uploadImage);
att.handleFileInputChange(
inputEvent([{ name: 'image.png', type: 'image/png', size: 0 } as unknown as File]),
);

expect(att.attachments.value).toHaveLength(1);
expect(att.attachments.value[0]).toMatchObject({
name: 'image.png',
kind: 'image',
uploading: false,
error: true,
});
expect(att.attachments.value[0].errorReason).toBeTruthy();
expect(uploadImage).not.toHaveBeenCalled();
expect(createObjectURL).not.toHaveBeenCalled();
});

it('removeAttachment on a file chip has no object URL to revoke', () => {
const uploadImage = vi.fn<UploadImage>().mockResolvedValue(null);
const att = setup(uploadImage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
* rejected, then replaces only that snapshot on later steps so a newly
* generated recovery image remains visible. Both are read-side only — the
* history keeps its media.
*
* Every projection also passes each message's content through `media`'s
* image format gate, so a malformed or undeliverable image already persisted
* in history (e.g. an empty data URL from a failed clipboard read) is
* replaced by a text notice on the wire instead of poisoning every later
* request — likewise read-side only.
*/

import { createHash } from 'node:crypto';
Expand All @@ -35,6 +41,7 @@ import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent';
import { IAgentStateService } from '#/agent/state/agentState';
import { ErrorCodes, Error2 } from '#/errors';
import type { ContentPart, Message } from '#/kosong/contract/message';
import { gateImageFormatParts } from '#/agent/media/image-format-policy';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import {
IAgentContextProjectorService,
Expand Down Expand Up @@ -188,20 +195,20 @@ export const MEDIA_DEGRADE_KEEP_RECENT = 2;

const MEDIA_DEGRADED_PLACEHOLDERS = {
image_url:
'[image omitted: dropped to fit the provider request size limit; re-read the file to view it]',
'[An image attached to an earlier message was removed to fit the provider request size limit. You have NOT seen this image — do not describe or guess its contents. If it matters, ask the user to re-send it or to point you at the file so you can read it with ReadMediaFile.]',
audio_url:
'[audio omitted: dropped to fit the provider request size limit; re-read the file to hear it]',
'[An audio clip attached to an earlier message was removed to fit the provider request size limit. You have NOT heard it — do not describe or guess its contents.]',
video_url:
'[video omitted: dropped to fit the provider request size limit; re-read the file to view it]',
'[A video attached to an earlier message was removed to fit the provider request size limit. You have NOT seen it — do not describe or guess its contents.]',
} as const;

export const MEDIA_STRIPPED_PLACEHOLDERS = {
image_url:
'[image omitted for provider compatibility; re-read the file to view it or get conversion guidance]',
'[An image attached to this message was removed before sending because the provider rejected the request (unsupported or unreadable image data, or the request exceeded its size limit). You have NOT seen this image — do not describe or guess its contents. Tell the user the image failed to reach you and suggest re-sending it as PNG or JPEG, or a smaller copy.]',
audio_url:
'[audio omitted for provider compatibility; re-read the file to hear it]',
'[An audio clip attached to this message was removed before sending because the provider rejected the request. You have NOT heard it — do not describe or guess its contents.]',
video_url:
'[video omitted for provider compatibility; re-read the file to view it]',
'[A video attached to this message was removed before sending because the provider rejected the request. You have NOT seen it — do not describe or guess its contents.]',
} as const;

type MediaPlaceholderSet = typeof MEDIA_DEGRADED_PLACEHOLDERS | typeof MEDIA_STRIPPED_PLACEHOLDERS;
Expand Down Expand Up @@ -540,7 +547,7 @@ function projectedContent(source: ContextMessage, onAnomaly?: OnAnomaly): Conten
note: source.note,
})
: source.content;
return cleanContent(source, content, onAnomaly);
return cleanContent(source, gateImageFormatParts(content), onAnomaly);
}

function cleanContent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ import { completionBudgetParams, resolveCompletionBudget } from '#/kosong/model/
import { resolveThinkingKeep, type ThinkingConfig } from '#/kosong/model/thinking';
import { THINKING_SECTION } from '#/app/kosongConfig/configSection';
import type { Protocol } from '#/kosong/protocol/protocol';
import type { ApiErrorEvent } from '#/app/telemetry/events';
import type { ApiErrorEvent, MediaStrippedEvent } from '#/app/telemetry/events';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IWireService } from '#/wire/wire';
import type { PayloadOf } from '#/wire/types';
Expand Down Expand Up @@ -331,6 +331,37 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
): Promise<AgentLLMRequestFinish> {
const shaped = this.toolSelect.shapeHistory(request.messages);
let mediaStripSnapshot = this.mediaStripSnapshotForTurn(request.source);
const reportMediaStripped = (
message: string,
reason: MediaStrippedEvent['reason'],
raw: unknown,
): void => {
const statusCode = raw instanceof APIStatusError ? raw.statusCode : undefined;
const errorMessage = (raw instanceof Error ? raw.message : String(raw)).slice(0, 300);
this.log.warn(message, {
model: request.model.name,
statusCode,
errorMessage,
...request.logFields,
});
let mediaCount = 0;
for (const msg of shaped) {
for (const part of msg.content) {
if (part.type === 'image_url' || part.type === 'audio_url' || part.type === 'video_url') {
mediaCount += 1;
}
}
}
const properties: MediaStrippedEvent = {
reason,
model: request.model.name,
alias: request.modelAlias,
status_code: statusCode,
turn_id: request.source?.type === 'turn' ? request.source.turnId : undefined,
media_count: mediaCount,
};
this.telemetry.track2('media_stripped', properties);
};
const requestInput = (projection: RequestProjection) => {
return {
systemPrompt: request.systemPrompt,
Expand Down Expand Up @@ -463,12 +494,10 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
this.markRecoveryTurn(this.mediaDegradedTurns, request.source);
projection = 'media-degraded';
} else {
this.log.warn(
reportMediaStripped(
'provider rejected degraded-media request as too large; resending with rejected media stripped',
{
model: request.model.name,
...request.logFields,
},
'degraded_too_large',
raw,
);
mediaStripSnapshot = this.projector.captureMediaStripSnapshot(shaped);
this.markMediaStrippedRecoveryTurn(mediaStripSnapshot, request.source);
Expand All @@ -478,12 +507,10 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
}
if (projection !== 'media-stripped' && isImageFormatError(raw)) {
signal?.throwIfAborted();
this.log.warn(
reportMediaStripped(
'provider rejected an image in the request; resending with rejected media stripped',
{
model: request.model.name,
...request.logFields,
},
'image_format',
raw,
);
mediaStripSnapshot = this.projector.captureMediaStripSnapshot(shaped);
this.markMediaStrippedRecoveryTurn(mediaStripSnapshot, request.source);
Expand Down
50 changes: 3 additions & 47 deletions packages/agent-core-v2/src/agent/media/image-compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,14 @@ import type { ContentPart } from '#/kosong/contract/message';

import { sniffImageDimensions } from './file-type';
import {
buildMalformedImageNotice,
buildUnsupportedImageNotice,
decodeBase64Prefix,
isDataUrl,
isModelAcceptedImageMime,
gateImageFormatParts,
normalizeImageMime,
parseImageDataUrl,
resolveEffectiveImageMime,
unsupportedImageMimeFromUrl,
} from './image-format-policy';
import { decodeWebp, isAnimatedWebp } from './webp-decode';

export { gateImageFormatParts };

export const MAX_IMAGE_EDGE_PX = 2000;

let configuredMaxImageEdgePx: number | undefined;
Expand Down Expand Up @@ -316,46 +312,6 @@ export interface CompressedContentParts {
readonly captions: readonly string[];
}

export function gateImageFormatParts(parts: readonly ContentPart[]): ContentPart[] {
const out: ContentPart[] = [];
for (const part of parts) {
if (part.type === 'image_url') {
const parsed = parseImageDataUrl(part.imageUrl.url);
if (parsed === null) {
if (isDataUrl(part.imageUrl.url)) {
out.push({ type: 'text', text: buildMalformedImageNotice(part.imageUrl.url) });
continue;
}
const extMime = unsupportedImageMimeFromUrl(part.imageUrl.url);
if (extMime !== null) {
out.push({
type: 'text',
text: buildUnsupportedImageNotice(extMime, part.imageUrl.url),
});
continue;
}
out.push(part);
continue;
}
const effectiveMime = resolveEffectiveImageMime(
parsed.mimeType,
decodeBase64Prefix(parsed.base64),
);
if (!isModelAcceptedImageMime(effectiveMime)) {
out.push({ type: 'text', text: buildUnsupportedImageNotice(effectiveMime) });
continue;
}
const canonicalUrl = `data:${normalizeImageMime(effectiveMime)};base64,${parsed.base64}`;
if (part.imageUrl.url !== canonicalUrl) {
out.push({ type: 'image_url', imageUrl: { ...part.imageUrl, url: canonicalUrl } });
continue;
}
}
out.push(part);
}
return out;
}

export async function compressImageContentParts(
parts: readonly ContentPart[],
options: CompressImageOptions & { readonly annotate?: CompressAnnotateOptions } = {},
Expand Down
Loading