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
5 changes: 5 additions & 0 deletions .changeset/preserve-media-attachment-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Preserve image and video filenames in session history.
4 changes: 4 additions & 0 deletions packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,7 @@ export interface AgentStateSnapshot {
imageUrl: {
url: string;
id?: string;
name?: string;
};
} | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ {
type: 'audio_url';
Expand All @@ -1071,6 +1072,7 @@ export interface AgentStateSnapshot {
videoUrl: {
url: string;
id?: string;
name?: string;
};
})[];
readonly toolCalls: /* ToolCall — packages/agent-core-v2/src/kosong/contract/message.ts */ {
Expand Down Expand Up @@ -1274,6 +1276,7 @@ export interface AgentStateSnapshot {
imageUrl: {
url: string;
id?: string;
name?: string;
};
} | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ {
type: 'audio_url';
Expand All @@ -1286,6 +1289,7 @@ export interface AgentStateSnapshot {
videoUrl: {
url: string;
id?: string;
name?: string;
};
}>;
// src/agent/media/mediaToolsRegistrar.ts
Expand Down
6 changes: 3 additions & 3 deletions packages/agent-core-v2/src/agent/loop/turnEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export interface TurnPromptAttachmentFile {
}

export type TurnPromptAttachment =
| { readonly kind: 'image' | 'video' | 'audio'; readonly fileId: string }
| { readonly kind: 'image' | 'video' | 'audio'; readonly fileId: string; readonly name?: string }
| TurnPromptAttachmentFile;

export interface TurnStartedPayload {
Expand Down Expand Up @@ -71,10 +71,10 @@ export function turnPromptAttachments(
for (const part of input) {
if (part.type === 'image_url') {
const fileId = promptMediaFileId(part.imageUrl.url, part.imageUrl.id);
if (fileId !== undefined) attachments.push({ kind: 'image', fileId });
if (fileId !== undefined) attachments.push({ kind: 'image', fileId, name: part.imageUrl.name });
} else if (part.type === 'video_url') {
const fileId = promptMediaFileId(part.videoUrl.url, part.videoUrl.id);
if (fileId !== undefined) attachments.push({ kind: 'video', fileId });
if (fileId !== undefined) attachments.push({ kind: 'video', fileId, name: part.videoUrl.name });
} else if (part.type === 'audio_url') {
const fileId = promptMediaFileId(part.audioUrl.url, part.audioUrl.id);
if (fileId !== undefined) attachments.push({ kind: 'audio', fileId });
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/src/kosong/contract/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface ThinkPart {

export interface ImageURLPart {
type: 'image_url';
imageUrl: { url: string; id?: string };
imageUrl: { url: string; id?: string; name?: string };
}

export interface AudioURLPart {
Expand All @@ -25,7 +25,7 @@ export interface AudioURLPart {

export interface VideoURLPart {
type: 'video_url';
videoUrl: { url: string; id?: string | undefined };
videoUrl: { url: string; id?: string; name?: string };
}

export type ContentPart = TextPart | ThinkPart | ImageURLPart | AudioURLPart | VideoURLPart;
Expand Down
8 changes: 4 additions & 4 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -926,8 +926,8 @@ describe('Agent loop', () => {
{
role: 'user',
content: [
{ type: 'image_url', imageUrl: { url: 'kimi-file://file_1', id: 'file_1' } },
{ type: 'video_url', videoUrl: { url: 'kimi-file://file_2', id: 'file_2' } },
{ type: 'image_url', imageUrl: { url: 'kimi-file://file_1', id: 'file_1', name: 'photo.png' } },
{ type: 'video_url', videoUrl: { url: 'kimi-file://file_2', id: 'file_2', name: 'clip.mp4' } },
{ type: 'image_url', imageUrl: { url: 'kimi-file://file_3' } },
{ type: 'image_url', imageUrl: { url: 'kimi-file://file_4', id: 'other' } },
{ type: 'image_url', imageUrl: { url: 'https://example.com/no-id.png' } },
Expand All @@ -946,8 +946,8 @@ describe('Agent loop', () => {

expect(payloads).toEqual([
[
{ kind: 'image', fileId: 'file_1' },
{ kind: 'video', fileId: 'file_2' },
{ kind: 'image', fileId: 'file_1', name: 'photo.png' },
{ kind: 'video', fileId: 'file_2', name: 'clip.mp4' },
{ kind: 'image', fileId: 'file_3' },
],
]);
Expand Down
50 changes: 34 additions & 16 deletions packages/kap-server/src/lib/promptMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,30 +87,42 @@ function isFsError(error: unknown): boolean {
return error instanceof Error && typeof (error as NodeJS.ErrnoException).code === 'string';
}

export async function assertPromptSessionMediaRefs(
export async function resolvePromptSessionMediaRefs(
content: WireContent,
store: ISessionMediaStore,
): Promise<void> {
): Promise<WireContent> {
const resolved: WireContent = [];
let changed = false;
for (const part of content) {
if (
(part.type !== 'image' && part.type !== 'video') ||
part.source.kind !== 'session_media'
) continue;
) {
resolved.push(part);
continue;
}
const file = await store.open(part.source.file_id);
if (file === undefined) throw fileNotFoundError(part.source.file_id);
if (part.name === undefined) {
resolved.push({ ...part, name: file.name });
changed = true;
} else {
resolved.push(part);
}
}
return changed ? resolved : content;
}

export function contentToCoreParts(content: WireContent): ContentPart[] {
const parts: ContentPart[] = [];
for (const part of content) {
if (part.type === 'text') parts.push({ type: 'text', text: part.text });
else if (part.type === 'image' && part.source.kind === 'url') parts.push({ type: 'image_url', imageUrl: { url: part.source.url, id: part.source.id } });
else if (part.type === 'image' && part.source.kind === 'base64') parts.push({ type: 'image_url', imageUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` } });
else if (part.type === 'image' && part.source.kind === 'session_media') parts.push({ type: 'image_url', imageUrl: { url: buildDaemonFileUrl(part.source.file_id), id: part.source.file_id } });
else if (part.type === 'video' && part.source.kind === 'url') parts.push({ type: 'video_url', videoUrl: { url: part.source.url, id: part.source.id } });
else if (part.type === 'video' && part.source.kind === 'base64') parts.push({ type: 'video_url', videoUrl: { url: `data:${part.source.media_type};base64,${part.source.data}` } });
else if (part.type === 'video' && part.source.kind === 'session_media') parts.push({ type: 'video_url', videoUrl: { url: buildDaemonFileUrl(part.source.file_id), id: part.source.file_id } });
else if (part.type === 'image' && part.source.kind === 'url') parts.push({ type: 'image_url', imageUrl: { url: part.source.url, id: part.source.id, name: part.name } });
else if (part.type === 'image' && part.source.kind === 'base64') parts.push({ type: 'image_url', imageUrl: { url: `data:${part.source.media_type};base64,${part.source.data}`, name: part.name } });
else if (part.type === 'image' && part.source.kind === 'session_media') parts.push({ type: 'image_url', imageUrl: { url: buildDaemonFileUrl(part.source.file_id), id: part.source.file_id, name: part.name } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore names for nameless session-media references

When a client reuses an existing session_media attachment without the new optional name field—as existing clients and historical prompt payloads do—this conversion copies undefined even though assertPromptSessionMediaRefs opens a SessionMediaFile whose persisted metadata includes the original name. Consequently the core part, turn.started attachment, and reconstructed transcript remain nameless; enrich session-media parts from the store metadata when the request omits the name, including the analogous video path.

Useful? React with 👍 / 👎.

else if (part.type === 'video' && part.source.kind === 'url') parts.push({ type: 'video_url', videoUrl: { url: part.source.url, id: part.source.id, name: part.name } });
else if (part.type === 'video' && part.source.kind === 'base64') parts.push({ type: 'video_url', videoUrl: { url: `data:${part.source.media_type};base64,${part.source.data}`, name: part.name } });
else if (part.type === 'video' && part.source.kind === 'session_media') parts.push({ type: 'video_url', videoUrl: { url: buildDaemonFileUrl(part.source.file_id), id: part.source.file_id, name: part.name } });
}
return parts;
}
Expand Down Expand Up @@ -172,10 +184,10 @@ export async function resolvePromptMediaFiles(
);
if (!isModelAcceptedImageMime(effectiveMime)) {
const bytes = Buffer.from(part.source.data, 'base64');
const name = `image.${imageExtensionForMime(effectiveMime)}`;
const name = part.name ?? `image.${imageExtensionForMime(effectiveMime)}`;
const persisted = await persistAttachmentBytes(
bytes,
`${createHash('sha256').update(bytes).digest('hex').slice(0, 32)}-${name}`,
`${createHash('sha256').update(bytes).digest('hex').slice(0, 32)}-${sanitizeAttachmentName(name)}`,
await resolveAttachmentsDir(),
);
content.push({
Expand Down Expand Up @@ -223,6 +235,7 @@ export async function resolvePromptMediaFiles(
content.push({
type: 'image',
source: { kind: 'base64', media_type: compressed.mimeType, data: compressed.base64 },
name: part.name,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve names when base64 images are downgraded

When a named base64 image resolves to a MIME type unsupported by the model, the earlier branch returns before reaching this preserved-name path and constructs the resulting file attachment with the generic image.<ext> name. Thus a valid request such as a named TIFF loses its filename in both the response and cold transcript; use the supplied part.name as the attachment name, with the generated name only as a fallback.

Useful? React with 👍 / 👎.

});
changed = true;
} else {
Expand Down Expand Up @@ -288,7 +301,7 @@ export async function resolvePromptMediaFiles(
if (isFsError(error)) throw fileNotFoundError(sourcePath);
throw error;
});
const name = basename(sourcePath);
const name = part.name ?? basename(sourcePath);
const declared = pathMediaMime(sourcePath, data, 'image');
if (!declared.startsWith('image/')) {
throw new Error2('validation.failed', `${sourcePath} is ${declared}, not an image`);
Expand Down Expand Up @@ -337,6 +350,7 @@ export async function resolvePromptMediaFiles(
content.push({
type: 'image',
source: { kind: 'url', url: buildDaemonFileUrl(saved.id) },
name: part.name ?? name,
});
changed = true;
continue;
Expand All @@ -359,6 +373,7 @@ export async function resolvePromptMediaFiles(
content.push({
type: 'video',
source: { kind: 'url', url: buildDaemonFileUrl(saved.id) },
name: part.name ?? basename(sourcePath),
});
changed = true;
continue;
Expand All @@ -376,20 +391,21 @@ export async function resolvePromptMediaFiles(
let mediaType = file.meta.media_type;
mediaType = resolveEffectiveImageMime(mediaType, data);
if (!isModelAcceptedImageMime(mediaType)) {
const name = part.name ?? file.meta.name;
const persisted = await persistAttachmentBytes(
data,
`${file.meta.id}-${sanitizeAttachmentName(file.meta.name)}`,
`${file.meta.id}-${sanitizeAttachmentName(name)}`,
await resolveAttachmentsDir(),
);
content.push({
type: 'text',
text: persisted === null
? buildUnsupportedImageNotice(mediaType, file.meta.name)
: buildAttachedFileNotice(file.meta.name, mediaType, file.meta.size, persisted),
? buildUnsupportedImageNotice(mediaType, name)
: buildAttachedFileNotice(name, mediaType, file.meta.size, persisted),
});
if (persisted !== null) {
attachments.push({
name: file.meta.name,
name,
mediaType,
size: file.meta.size,
path: persisted,
Expand Down Expand Up @@ -438,6 +454,7 @@ export async function resolvePromptMediaFiles(
content.push({
type: 'image',
source: { kind: 'url', url: buildDaemonFileUrl(finalFile.meta.id) },
name: part.name ?? file.meta.name,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the exact prompt response assertions

For file-backed images and videos, this now emits a non-empty name, but the existing integration tests in packages/kap-server/test/prompts.test.ts still compare the complete response against objects without that property. In particular, carries an uncompressed uploaded image... and carries an uploaded video... will fail whenever the kap-server suite runs, so update those expectations to include the preserved filenames rather than reverting the behavior.

AGENTS.md reference: AGENTS.md:L61-L61

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve requested names when uploaded images are downgraded

When a named source.kind === 'file' image has an unsupported model MIME type, such as AVIF, the earlier downgrade branch constructs both the notice and file attachment from file.meta.name and returns before reaching this new part.name fallback. Consequently supported uploaded images preserve the request's filename while unsupported uploaded images silently revert to the upload-store name; apply the same part.name ?? file.meta.name selection in the downgrade branch.

Useful? React with 👍 / 👎.

});
changed = true;
continue;
Expand All @@ -446,6 +463,7 @@ export async function resolvePromptMediaFiles(
content.push({
type: 'video',
source: { kind: 'url', url: buildDaemonFileUrl(file.meta.id) },
name: part.name ?? file.meta.name,
});
changed = true;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/kap-server/src/protocol/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ export type ImageSource = z.infer<typeof imageSourceSchema>;
export const imageContentSchema = z.object({
type: z.literal('image'),
source: imageSourceSchema,
name: z.string().min(1).optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add media names to the shared protocol schemas

When a consumer parses prompt responses, message history, or prompt.submitted/prompt.steered events through @moonshot-ai/protocol, packages/protocol/src/message.ts still omits name from both media schemas, so Zod strips the newly emitted field and the exported ImageContent/VideoContent types cannot expose it. Update the shared image and video schemas alongside this kap-server schema so protocol consumers actually retain the filename.

Useful? React with 👍 / 👎.

});
export type ImageContent = z.infer<typeof imageContentSchema>;

export const videoContentSchema = z.object({
type: z.literal('video'),
source: imageSourceSchema,
name: z.string().min(1).optional(),
});
export type VideoContent = z.infer<typeof videoContentSchema>;

Expand Down
6 changes: 3 additions & 3 deletions packages/kap-server/src/routes/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ import { errEnvelope, okEnvelope } from '../envelope';
import {
assertPromptFileRefs,
assertPromptPathRefs,
assertPromptSessionMediaRefs,
contentHasPathRefs,
contentToCoreParts,
resolvePromptMediaFiles,
resolvePromptSessionMediaRefs,
type PromptMediaPreparation,
} from '../lib/promptMedia';
import { requestLog } from '../lib/requestLog';
Expand Down Expand Up @@ -243,7 +243,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {
req.body.skills,
);
}
await assertPromptSessionMediaRefs(
const resolvedSessionMedia = await resolvePromptSessionMediaRefs(
req.body.content,
session.accessor.get(ISessionMediaStore),
);
Expand All @@ -259,7 +259,7 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void {

const telemetry = core.accessor.get(ITelemetryService).withContext({ session_id });
preparedMedia = await resolvePromptMediaFiles(
req.body.content,
resolvedSessionMedia,
core.accessor.get(IFileService),
core.accessor.get(IBootstrapService).cacheDir,
{
Expand Down
6 changes: 3 additions & 3 deletions packages/kap-server/src/routes/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ import { errEnvelope, okEnvelope } from '../envelope';
import {
assertPromptFileRefs,
assertPromptPathRefs,
assertPromptSessionMediaRefs,
contentHasPathRefs,
contentToCoreParts,
resolvePromptMediaFiles,
resolvePromptSessionMediaRefs,
type PromptMediaPreparation,
} from '../lib/promptMedia';
import { requestLog } from '../lib/requestLog';
Expand Down Expand Up @@ -248,14 +248,14 @@ export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void {
}
await assertPromptFileRefs(attachments, core.accessor.get(IFileService));
await assertPromptPathRefs(attachments);
await assertPromptSessionMediaRefs(
const resolvedSessionMedia = await resolvePromptSessionMediaRefs(
attachments,
resolved.handle.accessor.get(ISessionMediaStore),
);
const telemetry = core.accessor.get(ITelemetryService).withContext({ session_id });
const sessionDir = resolved.handle.accessor.get(ISessionContext).sessionDir;
preparedMedia = await resolvePromptMediaFiles(
attachments,
resolvedSessionMedia,
core.accessor.get(IFileService),
core.accessor.get(IBootstrapService).cacheDir,
{
Expand Down
22 changes: 14 additions & 8 deletions packages/kap-server/src/services/messages/messageProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ function mapContentPart(part: ContextMessage['content'][number]): MessageContent
case 'image_url': {
const ref = parseDaemonFileUrl(part.imageUrl.url);
return ref !== undefined
? { type: 'image', source: { kind: 'session_media', file_id: ref.fileId } }
: { type: 'image', source: { kind: 'url', url: part.imageUrl.url, id: part.imageUrl.id } };
? { type: 'image', source: { kind: 'session_media', file_id: ref.fileId }, name: part.imageUrl.name }
: { type: 'image', source: { kind: 'url', url: part.imageUrl.url, id: part.imageUrl.id }, name: part.imageUrl.name };
}
case 'audio_url':
return { type: 'text', text: `[audio:${part.audioUrl.url}]` };
case 'video_url': {
const ref = parseDaemonFileUrl(part.videoUrl.url);
return ref !== undefined
? { type: 'video', source: { kind: 'session_media', file_id: ref.fileId } }
: { type: 'video', source: { kind: 'url', url: part.videoUrl.url, id: part.videoUrl.id } };
? { type: 'video', source: { kind: 'session_media', file_id: ref.fileId }, name: part.videoUrl.name }
: { type: 'video', source: { kind: 'url', url: part.videoUrl.url, id: part.videoUrl.id }, name: part.videoUrl.name };
}
}
}
Expand Down Expand Up @@ -98,20 +98,26 @@ export function projectPromptContentParts(content: readonly ContentPart[]): Mess
parts.push({
type: daemonRef.kind,
source: { kind: 'session_media', file_id: daemonRef.ref.fileId },
name:
part.type === 'image_url'
? part.imageUrl.name
: part.type === 'video_url'
? part.videoUrl.name
: undefined,
});
continue;
}
if (part.type === 'text') parts.push({ type: 'text', text: part.text });
else if (part.type === 'image_url') {
const match = /^data:([^;]+);base64,(.*)$/.exec(part.imageUrl.url);
parts.push(match === null
? { type: 'image', source: { kind: 'url', url: part.imageUrl.url, id: part.imageUrl.id } }
: { type: 'image', source: { kind: 'base64', media_type: match[1]!, data: match[2]! } });
? { type: 'image', source: { kind: 'url', url: part.imageUrl.url, id: part.imageUrl.id }, name: part.imageUrl.name }
: { type: 'image', source: { kind: 'base64', media_type: match[1]!, data: match[2]! }, name: part.imageUrl.name });
} else if (part.type === 'video_url') {
const match = /^data:([^;]+);base64,(.*)$/.exec(part.videoUrl.url);
parts.push(match === null
? { type: 'video', source: { kind: 'url', url: part.videoUrl.url, id: part.videoUrl.id } }
: { type: 'video', source: { kind: 'base64', media_type: match[1]!, data: match[2]! } });
? { type: 'video', source: { kind: 'url', url: part.videoUrl.url, id: part.videoUrl.id }, name: part.videoUrl.name }
: { type: 'video', source: { kind: 'base64', media_type: match[1]!, data: match[2]! }, name: part.videoUrl.name });
}
}
return parts;
Expand Down
9 changes: 8 additions & 1 deletion packages/kap-server/src/services/transcript/coreEventMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export class AgentTranscriptProjector {
origin: unknown;
prompt?: string;
promptAttachments?: readonly (
| { kind: 'image' | 'video' | 'audio'; fileId: string }
| { kind: 'image' | 'video' | 'audio'; fileId: string; name?: string }
| { kind: 'file'; name: string; mediaType: string; size: number; path: string }
)[];
}): TranscriptOperation[] {
Expand All @@ -386,6 +386,7 @@ export class AgentTranscriptProjector {
: {
attachmentId: `${turnId}.att${attachmentIds.length + 1}`,
mediaType: `${input.kind}/*`,
name: input.name,
source: { kind: 'session_media', fileId: input.fileId },
};
ops.push({ op: 'attachment.upsert', attachment });
Expand Down Expand Up @@ -1486,6 +1487,12 @@ export class AgentTranscriptProjector {
const attachment: TranscriptAttachment = {
attachmentId: `${stepId}.att${++this.attachmentOrdinal}`,
mediaType: `${ref.kind}/*`,
name:
part.type === 'image_url'
? part.imageUrl.name
: part.type === 'video_url'
? part.videoUrl.name
: undefined,
source: { kind: 'session_media', fileId: ref.ref.fileId },
};
ops.push({ op: 'attachment.upsert', attachment });
Expand Down
Loading
Loading