Skip to content
Merged
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 } });
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 @@ -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,
Comment thread
chengluyu marked this conversation as resolved.
});
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,
Comment thread
chengluyu marked this conversation as resolved.
Comment thread
chengluyu marked this conversation as resolved.
});
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(),
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
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