Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.
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
17 changes: 11 additions & 6 deletions packages/kap-server/src/lib/promptMedia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,12 @@ 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 } });
Comment thread
chengluyu marked this conversation as resolved.
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 @@ -223,6 +223,7 @@ export async function resolvePromptMediaFiles(
content.push({
type: 'image',
source: { kind: 'base64', media_type: compressed.mimeType, data: compressed.base64 },
name: part.name,
Comment thread
chengluyu marked this conversation as resolved.
});
changed = true;
} else {
Expand Down Expand Up @@ -337,6 +338,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 +361,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 Down Expand Up @@ -438,6 +441,7 @@ export async function resolvePromptMediaFiles(
content.push({
type: 'image',
source: { kind: 'url', url: buildDaemonFileUrl(finalFile.meta.id) },
name: part.name ?? file.meta.name,
Comment thread
chengluyu marked this conversation as resolved.
Comment thread
chengluyu marked this conversation as resolved.
});
changed = true;
continue;
Expand All @@ -446,6 +450,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(),
Comment thread
chengluyu marked this conversation as resolved.
});
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';

import type { ContextMessage } from '@moonshot-ai/agent-core-v2';

import { toProtocolMessage } from '../../../src/services/messages/messageProjection';
import { projectPromptContentParts, toProtocolMessage } from '../../../src/services/messages/messageProjection';

const SESSION_ID = 'session_1';
const CREATED_AT = 1_700_000_000_000;
Expand Down Expand Up @@ -50,6 +50,21 @@ describe('toProtocolMessage', () => {
]);
});

it('preserves media names in live and prompt projections', () => {
const part = {
type: 'image_url' as const,
imageUrl: { url: 'kimi-file://file_9', id: 'file_9', name: 'photo.png' },
};
const msg: ContextMessage = { role: 'user', content: [part], toolCalls: [] };

expect(toProtocolMessage(SESSION_ID, 0, msg, CREATED_AT).content).toEqual([
{ type: 'image', source: { kind: 'session_media', file_id: 'file_9' }, name: 'photo.png' },
]);
expect(projectPromptContentParts([part])).toEqual([
{ type: 'image', source: { kind: 'session_media', file_id: 'file_9' }, name: 'photo.png' },
]);
});

it('keeps a legacy tag+ref pair as text plus the ref projection', () => {
const msg: ContextMessage = {
role: 'user',
Expand Down
40 changes: 33 additions & 7 deletions packages/kap-server/test/services/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ describe('AgentTranscriptProjector', () => {
turnId: 0,
origin: { kind: 'user' },
prompt: 'what is this?',
promptAttachments: [{ kind: 'image', fileId: 'file_1' }],
promptAttachments: [{ kind: 'image', fileId: 'file_1', name: 'photo.png' }],
}),
);
feed(ev({ type: 'turn.ended', turnId: 0, reason: 'completed' }));
Expand All @@ -233,6 +233,7 @@ describe('AgentTranscriptProjector', () => {
attachment: {
attachmentId: 't0.att1',
mediaType: 'image/*',
name: 'photo.png',
source: { kind: 'session_media', fileId: 'file_1' },
},
},
Expand All @@ -244,6 +245,7 @@ describe('AgentTranscriptProjector', () => {
expect(tx.getAttachment('t0.att1')).toEqual({
attachmentId: 't0.att1',
mediaType: 'image/*',
name: 'photo.png',
source: { kind: 'session_media', fileId: 'file_1' },
});
});
Expand Down Expand Up @@ -2080,14 +2082,20 @@ describe('AgentTranscriptProjector', () => {
type: 'prompt.steered',
activePromptId: 'p1',
promptIds: ['p2'],
content: [{ type: 'text', text: 'steered in' }],
content: [
{ type: 'text', text: 'steered in' },
{ type: 'video_url', videoUrl: { url: 'kimi-file://f_vid2', name: 'queued.mp4' } },
],
steeredAt: '2026-01-01T00:00:02.000Z',
}),
);
feed(
ev({
type: 'turn.steer',
input: [{ type: 'text', text: 'steered in' }],
input: [
{ type: 'text', text: 'steered in' },
{ type: 'video_url', videoUrl: { url: 'kimi-file://f_vid2', name: 'queued.mp4' } },
],
origin: { kind: 'user' },
}),
);
Expand All @@ -2096,13 +2104,21 @@ describe('AgentTranscriptProjector', () => {
feed(ev({ type: 'turn.step.started', turnId: 3, step: 2 }));
const turn = turnOps('t3', tx.getItems());
expect(turn.steps).toHaveLength(2);
expect(turn.steps[1]?.frames[0]).toMatchObject({
const frame = turn.steps[1]?.frames[0];
expect(frame).toMatchObject({
kind: 'text',
role: 'user',
text: 'steered in',
promptIds: ['p2'],
origin: { kind: 'user' },
});
expect(frame?.kind === 'text' ? frame.attachmentIds : undefined).toHaveLength(1);
const attachmentId = frame?.kind === 'text' ? frame.attachmentIds?.[0] : undefined;
expect(attachmentId === undefined ? undefined : tx.getAttachment(attachmentId)).toMatchObject({
mediaType: 'video/*',
name: 'queued.mp4',
source: { kind: 'session_media', fileId: 'f_vid2' },
});
});

it('projects turn.steer into the running step immediately, with daemon media as attachments', () => {
Expand All @@ -2126,7 +2142,10 @@ describe('AgentTranscriptProjector', () => {
{ type: 'text', text: 'look at this' },
{
type: 'image_url',
imageUrl: { url: 'kimi-file://f_img9?path=%2Fabs%2Fsession%2Fmedia%2Ff_img9.png' },
imageUrl: {
url: 'kimi-file://f_img9?path=%2Fabs%2Fsession%2Fmedia%2Ff_img9.png',
name: 'architecture.png',
},
},
],
steeredAt: '2026-01-01T00:00:02.000Z',
Expand All @@ -2141,7 +2160,10 @@ describe('AgentTranscriptProjector', () => {
{ type: 'text', text: 'look at this' },
{
type: 'image_url',
imageUrl: { url: 'kimi-file://f_img9?path=%2Fabs%2Fsession%2Fmedia%2Ff_img9.png' },
imageUrl: {
url: 'kimi-file://f_img9?path=%2Fabs%2Fsession%2Fmedia%2Ff_img9.png',
name: 'architecture.png',
},
},
],
origin: {
Expand All @@ -2162,7 +2184,11 @@ describe('AgentTranscriptProjector', () => {

const attachmentOp = ops.find((op) => op.op === 'attachment.upsert');
expect(attachmentOp).toMatchObject({
attachment: { mediaType: 'image/*', source: { kind: 'session_media', fileId: 'f_img9' } },
attachment: {
mediaType: 'image/*',
name: 'architecture.png',
source: { kind: 'session_media', fileId: 'f_img9' },
},
});
const frame = turnOps('t4', tx.getItems()).steps[0]?.frames[0];
expect(frame).toMatchObject({
Expand Down
11 changes: 6 additions & 5 deletions packages/transcript/src/contract/mediaRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,18 @@ export function parseDaemonFileRefFileId(url: string): string | undefined {
export interface MediaRefPart {
readonly type: string;
readonly text?: string;
readonly imageUrl?: { readonly url?: string };
readonly videoUrl?: { readonly url?: string };
readonly imageUrl?: { readonly url?: string; readonly name?: string };
readonly videoUrl?: { readonly url?: string; readonly name?: string };
}

export function daemonFileRefFromPairingPart(
part: MediaRefPart,
): { readonly kind: 'image' | 'video'; readonly ref: DaemonFileRef } | undefined {
): { readonly kind: 'image' | 'video'; readonly ref: DaemonFileRef; readonly name?: string } | undefined {
if (part.type !== 'image_url' && part.type !== 'video_url') return undefined;
const url = part.type === 'image_url' ? part.imageUrl?.url : part.videoUrl?.url;
const media = part.type === 'image_url' ? part.imageUrl : part.videoUrl;
const url = media?.url;
if (typeof url !== 'string') return undefined;
const ref = parseDaemonFileRef(url);
if (ref === undefined) return undefined;
return { kind: part.type === 'image_url' ? 'image' : 'video', ref };
return { kind: part.type === 'image_url' ? 'image' : 'video', ref, name: media?.name };
}
Loading
Loading