From b4de63240150f0858c3e60b5765ce7a1dc5076a1 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:07:15 +0800 Subject: [PATCH 1/7] feat(protocol): preserve media attachment names --- .../src/agent/loop/turnEvents.ts | 6 ++--- .../src/kosong/contract/message.ts | 4 ++-- .../test/agent/loop/loop.test.ts | 8 +++---- packages/kap-server/src/lib/promptMedia.ts | 17 +++++++++----- packages/kap-server/src/protocol/message.ts | 2 ++ .../services/messages/messageProjection.ts | 22 ++++++++++++------- .../src/services/transcript/coreEventMap.ts | 3 ++- .../messages/messageProjection.test.ts | 17 +++++++++++++- .../test/services/transcript.test.ts | 4 +++- 9 files changed, 57 insertions(+), 26 deletions(-) diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index d3e9fdebe67..e77f0d9b7a6 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -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 { @@ -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 }); diff --git a/packages/agent-core-v2/src/kosong/contract/message.ts b/packages/agent-core-v2/src/kosong/contract/message.ts index 2743c9e7d69..9c1cca28c0f 100644 --- a/packages/agent-core-v2/src/kosong/contract/message.ts +++ b/packages/agent-core-v2/src/kosong/contract/message.ts @@ -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 { @@ -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; diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index cdd643b906a..320f1f54c49 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -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' } }, @@ -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' }, ], ]); diff --git a/packages/kap-server/src/lib/promptMedia.ts b/packages/kap-server/src/lib/promptMedia.ts index 2f991b7466b..52996289c85 100644 --- a/packages/kap-server/src/lib/promptMedia.ts +++ b/packages/kap-server/src/lib/promptMedia.ts @@ -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 } }); + 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; } @@ -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, }); changed = true; } else { @@ -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; @@ -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; @@ -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, }); changed = true; continue; @@ -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; } diff --git a/packages/kap-server/src/protocol/message.ts b/packages/kap-server/src/protocol/message.ts index 3f048b8e842..7bd4b36116f 100644 --- a/packages/kap-server/src/protocol/message.ts +++ b/packages/kap-server/src/protocol/message.ts @@ -47,12 +47,14 @@ export type ImageSource = z.infer; export const imageContentSchema = z.object({ type: z.literal('image'), source: imageSourceSchema, + name: z.string().min(1).optional(), }); export type ImageContent = z.infer; export const videoContentSchema = z.object({ type: z.literal('video'), source: imageSourceSchema, + name: z.string().min(1).optional(), }); export type VideoContent = z.infer; diff --git a/packages/kap-server/src/services/messages/messageProjection.ts b/packages/kap-server/src/services/messages/messageProjection.ts index 56f15fe6f0c..0fb2e72998a 100644 --- a/packages/kap-server/src/services/messages/messageProjection.ts +++ b/packages/kap-server/src/services/messages/messageProjection.ts @@ -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 }; } } } @@ -98,6 +98,12 @@ 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; } @@ -105,13 +111,13 @@ export function projectPromptContentParts(content: readonly ContentPart[]): Mess 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; diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index ebe2c334f1b..236f060eb69 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -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[] { @@ -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 }); diff --git a/packages/kap-server/test/services/messages/messageProjection.test.ts b/packages/kap-server/test/services/messages/messageProjection.test.ts index 26a3bcfefea..cc80a352042 100644 --- a/packages/kap-server/test/services/messages/messageProjection.test.ts +++ b/packages/kap-server/test/services/messages/messageProjection.test.ts @@ -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; @@ -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', diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index a31cd4a76c2..b8ae7711c87 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -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' })); @@ -233,6 +233,7 @@ describe('AgentTranscriptProjector', () => { attachment: { attachmentId: 't0.att1', mediaType: 'image/*', + name: 'photo.png', source: { kind: 'session_media', fileId: 'file_1' }, }, }, @@ -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' }, }); }); From c2060ab1acd26cea7b073426c6f1fd94e0ace248 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:19:21 +0800 Subject: [PATCH 2/7] fix(transcript): preserve media names in cold rebuild --- packages/transcript/src/contract/mediaRef.ts | 11 +++++----- packages/transcript/src/history/groupTurns.ts | 10 +++++---- packages/transcript/test/layers.test.ts | 22 ++++++++++++++----- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/transcript/src/contract/mediaRef.ts b/packages/transcript/src/contract/mediaRef.ts index 5f1fae290a7..77b1c2c19c5 100644 --- a/packages/transcript/src/contract/mediaRef.ts +++ b/packages/transcript/src/contract/mediaRef.ts @@ -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 }; } diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 6552dbbe1c6..95d0031749c 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -9,12 +9,12 @@ import { projectTranscriptUserOrigin } from '../contract/origin'; export type HistoryMediaSource = | { readonly kind: 'url'; readonly url: string } | { readonly kind: 'base64'; readonly media_type: string; readonly data: string } - | { readonly kind: 'file'; readonly file_id: string }; + | { readonly kind: 'file' | 'session_media'; readonly file_id: string }; export type HistoryContentPart = | { readonly type: 'text'; readonly text: string } | { readonly type: 'think'; readonly think: string } - | { readonly type: 'image' | 'video' | 'audio'; readonly source: HistoryMediaSource } + | { readonly type: 'image' | 'video' | 'audio'; readonly source: HistoryMediaSource; readonly name?: string } | { readonly type: 'file'; readonly file_id: string; @@ -108,11 +108,12 @@ export function groupMessagesIntoSnapshot( attachmentId: `att_${attachments.length + 1}`, mediaType: source.kind === 'base64' ? source.media_type : `${part.type}/*`, + name: part.name, source: source.kind === 'url' ? { kind: 'url', url: source.url } - : source.kind === 'file' - ? { kind: 'file', fileId: source.file_id } + : source.kind === 'file' || source.kind === 'session_media' + ? { kind: source.kind, fileId: source.file_id } : undefined, }; attachments.push(entity); @@ -136,6 +137,7 @@ export function groupMessagesIntoSnapshot( const entity: TranscriptAttachment = { attachmentId: `att_${attachments.length + 1}`, mediaType: `${ref.kind}/*`, + name: ref.name, source: { kind: 'session_media', fileId: ref.ref.fileId }, }; attachments.push(entity); diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index ad8411b2b17..0951dd06def 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -829,8 +829,9 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { role: 'user', content: [ { type: 'text', text: 'what is this? [Image #1]' }, - { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: 'aGVsbG8=' } }, - { type: 'image', source: { kind: 'url', url: 'https://example.com/pic.png' } }, + { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: 'aGVsbG8=' }, name: 'inline.png' }, + { type: 'image', source: { kind: 'url', url: 'https://example.com/pic.png' }, name: 'remote.png' }, + { type: 'image', source: { kind: 'file', file_id: 'file_8' }, name: 'stored.png' }, { type: 'file', file_id: 'file_9', name: 'notes.txt', media_type: 'text/plain', size: 128 }, ], toolCalls: [], @@ -839,25 +840,32 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { role: 'assistant', content: [{ type: 'text', text: 'a screenshot' }], toolCalls: [] }, ]); - expect(snapshot.attachments).toHaveLength(3); + expect(snapshot.attachments).toHaveLength(4); expect(snapshot.attachments[0]).toMatchObject({ attachmentId: 'att_1', mediaType: 'image/png', + name: 'inline.png', source: undefined, }); expect(snapshot.attachments[1]).toMatchObject({ attachmentId: 'att_2', + name: 'remote.png', source: { kind: 'url', url: 'https://example.com/pic.png' }, }); expect(snapshot.attachments[2]).toMatchObject({ attachmentId: 'att_3', + name: 'stored.png', + source: { kind: 'file', fileId: 'file_8' }, + }); + expect(snapshot.attachments[3]).toMatchObject({ + attachmentId: 'att_4', mediaType: 'text/plain', name: 'notes.txt', source: { kind: 'file', fileId: 'file_9' }, }); const firstTurn = snapshot.items[0]; if (firstTurn?.kind !== 'turn') throw new Error('expected turn'); - expect(firstTurn.attachmentIds).toEqual(['att_1', 'att_2', 'att_3']); + expect(firstTurn.attachmentIds).toEqual(['att_1', 'att_2', 'att_3', 'att_4']); }); it('folds origin file attachments on the opening user message into path-sourced entities', () => { @@ -938,7 +946,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { content: [ { type: 'video_url', - videoUrl: { url: 'kimi-file://file_1' }, + videoUrl: { url: 'kimi-file://file_1', name: 'clip.mp4' }, } as HistoryContentPart, ], toolCalls: [], @@ -950,7 +958,7 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { type: 'text', text: 'what is this?' }, { type: 'image_url', - imageUrl: { url: 'kimi-file://file_2?path=%2Fcache%2Fshot.png' }, + imageUrl: { url: 'kimi-file://file_2?path=%2Fcache%2Fshot.png', name: 'shot.png' }, } as HistoryContentPart, ], toolCalls: [], @@ -962,11 +970,13 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { { attachmentId: 'att_1', mediaType: 'video/*', + name: 'clip.mp4', source: { kind: 'session_media', fileId: 'file_1' }, }, { attachmentId: 'att_2', mediaType: 'image/*', + name: 'shot.png', source: { kind: 'session_media', fileId: 'file_2' }, }, ]); From 5a0ddd7e0d0443ac00adfe179fdc0533f6216a98 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:34:20 +0800 Subject: [PATCH 3/7] fix(transcript): preserve steered media names --- .../src/services/transcript/coreEventMap.ts | 6 ++++ .../test/services/transcript.test.ts | 36 +++++++++++++++---- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index 236f060eb69..013ecb86c8f 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -1487,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 }); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index b8ae7711c87..2f01292510a 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -2082,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' }, }), ); @@ -2098,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', () => { @@ -2128,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', @@ -2143,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: { @@ -2164,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({ From fe96535219d06d2fb88f3ea69a50d0e4034a61fa Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:28:03 +0800 Subject: [PATCH 4/7] chore: add media attachment name changeset --- .changeset/preserve-media-attachment-names.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/preserve-media-attachment-names.md diff --git a/.changeset/preserve-media-attachment-names.md b/.changeset/preserve-media-attachment-names.md new file mode 100644 index 00000000000..9b540316a76 --- /dev/null +++ b/.changeset/preserve-media-attachment-names.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Preserve image and video filenames in session history. From 54d002d2ce786831d9a78f09e0947e6e5064857e Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:45:56 +0800 Subject: [PATCH 5/7] fix(kap-server): preserve media names across fallbacks --- packages/kap-server/src/lib/promptMedia.ts | 33 +++++++++++++++------- packages/kap-server/src/routes/prompts.ts | 6 ++-- packages/kap-server/src/routes/skills.ts | 6 ++-- packages/kap-server/test/prompts.test.ts | 18 +++++++----- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/packages/kap-server/src/lib/promptMedia.ts b/packages/kap-server/src/lib/promptMedia.ts index 52996289c85..b82df77dd3f 100644 --- a/packages/kap-server/src/lib/promptMedia.ts +++ b/packages/kap-server/src/lib/promptMedia.ts @@ -87,18 +87,30 @@ 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 { +): Promise { + 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[] { @@ -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({ @@ -289,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`); @@ -379,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, diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index a770aef69ed..50e2ab8a6c4 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -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'; @@ -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), ); @@ -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, { diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 16b37e66641..c7890f2979d 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -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'; @@ -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, { diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index 0bbd934fbac..6ab1ae06858 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -700,6 +700,7 @@ describe('server-v2 /api/v1 prompts', () => { expect(content[1]).toEqual({ type: 'video', source: { kind: 'session_media', file_id: uploaded.data.id }, + name: 'clip.mp4', }); await expectSessionMedia(server!, id, `${uploaded.data.id}.mp4`, videoBytes); @@ -814,7 +815,7 @@ describe('server-v2 /api/v1 prompts', () => { const content = submitted.body.data.content as Array>; expect(content).toEqual([ - { type: 'image', source: { kind: 'session_media', file_id: uploaded.id } }, + { type: 'image', source: { kind: 'session_media', file_id: uploaded.id }, name: 'small.png' }, ]); const mediaPath = await expectSessionMedia(server!, id, `${uploaded.id}.png`, smallPng); @@ -846,7 +847,7 @@ describe('server-v2 /api/v1 prompts', () => { expect(replayed.body.code).toBe(0); expect(replayed.body.data.content).toEqual([ { type: 'text', text: 'replay the stored image' }, - { type: 'image', source: { kind: 'session_media', file_id: uploaded.id } }, + { type: 'image', source: { kind: 'session_media', file_id: uploaded.id }, name: 'small.png' }, ]); const session = getLiveSessionById(server!.core.accessor, id); @@ -868,6 +869,7 @@ describe('server-v2 /api/v1 prompts', () => { imageUrl: { url: `kimi-file://${uploaded.id}`, id: uploaded.id, + name: 'small.png', }, }); }); @@ -899,7 +901,7 @@ describe('server-v2 /api/v1 prompts', () => { expect(message).toBeDefined(); expect(message!.content).toContainEqual({ type: 'image_url', - imageUrl: { url: `kimi-file://${uploaded.id}` }, + imageUrl: { url: `kimi-file://${uploaded.id}`, name: 'small.png' }, }); }); @@ -997,7 +999,7 @@ describe('server-v2 /api/v1 prompts', () => { expect(uploaded.code).toBe(0); const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { - content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.data.id } }], + content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.data.id }, name: 'renamed.avif' }], }); expect(submitted.body.code).toBe(0); @@ -1006,7 +1008,8 @@ describe('server-v2 /api/v1 prompts', () => { const notice = content[0]; if (notice?.type !== 'text') throw new Error('expected a text notice'); expect(notice.text).toContain('image/avif'); - expect(notice.text).toContain('photo.avif'); + expect(notice.text).toContain('renamed.avif'); + expect(notice.text).not.toContain('photo.avif'); }); it('replaces a remote image URL with an unsupported extension with a text notice', async () => { @@ -1111,6 +1114,7 @@ describe('server-v2 /api/v1 prompts', () => { content: [ { type: 'image', + name: 'scan.avif', source: { kind: 'base64', media_type: 'image/avif', @@ -1126,11 +1130,11 @@ describe('server-v2 /api/v1 prompts', () => { const notice = content[0]; expect(notice?.type).toBe('text'); expect(notice?.text).not.toContain('[Image omitted'); - expect(notice?.text).toContain('"image.avif"'); + expect(notice?.text).toContain('"scan.avif"'); expect(notice?.text).toContain('image/avif'); const attachedPath = attachedPathFrom(notice?.text ?? ''); expect(attachedPath).toContain('/attachments/'); - expect(attachedPath.endsWith('-image.avif')).toBe(true); + expect(attachedPath.endsWith('-scan.avif')).toBe(true); expect(await readFile(attachedPath)).toEqual(data); }); From 670f9ba1488496327a8869dfc4726e30c8b02e86 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:56:00 +0800 Subject: [PATCH 6/7] fix(protocol): retain media names in shared schemas --- packages/protocol/src/__tests__/message.test.ts | 4 ++++ packages/protocol/src/message.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/protocol/src/__tests__/message.test.ts b/packages/protocol/src/__tests__/message.test.ts index 0ec6da3e9dc..cf519594903 100644 --- a/packages/protocol/src/__tests__/message.test.ts +++ b/packages/protocol/src/__tests__/message.test.ts @@ -53,8 +53,10 @@ describe('messageContentSchema variants', () => { const parsed = imageContentSchema.parse({ type: 'image', source: { kind: 'url', url: 'https://example.com/a.png' }, + name: 'a.png', }); expect(parsed.source.kind).toBe('url'); + expect(parsed.name).toBe('a.png'); }); it('parses image base64 source', () => { @@ -85,8 +87,10 @@ describe('messageContentSchema variants', () => { const parsed = videoContentSchema.parse({ type: 'video', source: { kind: 'file', file_id: 'file_video_01' }, + name: 'clip.mp4', }); expect(parsed.source.kind).toBe('file'); + expect(parsed.name).toBe('clip.mp4'); }); it('parses session-owned media source from stored history', () => { diff --git a/packages/protocol/src/message.ts b/packages/protocol/src/message.ts index acf640f8242..bbfa3dc2b34 100644 --- a/packages/protocol/src/message.ts +++ b/packages/protocol/src/message.ts @@ -53,6 +53,7 @@ export type ImageSource = z.infer; export const imageContentSchema = z.object({ type: z.literal('image'), source: imageSourceSchema, + name: z.string().min(1).optional(), }); export type ImageContent = z.infer; @@ -60,6 +61,7 @@ export type ImageContent = z.infer; export const videoContentSchema = z.object({ type: z.literal('video'), source: imageSourceSchema, + name: z.string().min(1).optional(), }); export type VideoContent = z.infer; From 0dedb84fdc1cd6e7331df23b4b633fcab2d5b191 Mon Sep 17 00:00:00 2001 From: chengluyu <2239547+chengluyu@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:15:18 +0800 Subject: [PATCH 7/7] docs(agent-core-v2): refresh state manifest --- packages/agent-core-v2/docs/state-manifest.d.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 8846f7bc5f0..d0972cf6eac 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -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'; @@ -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 */ { @@ -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'; @@ -1286,6 +1289,7 @@ export interface AgentStateSnapshot { videoUrl: { url: string; id?: string; + name?: string; }; }>; // src/agent/media/mediaToolsRegistrar.ts