diff --git a/.changeset/web-resume-media-tool.md b/.changeset/web-resume-media-tool.md new file mode 100644 index 0000000000..a18aea7b5e --- /dev/null +++ b/.changeset/web-resume-media-tool.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix ReadMediaFile results rendering as plain tool cards instead of images after resuming or reloading a session. diff --git a/apps/kimi-web/test/turn-logic.test.ts b/apps/kimi-web/test/turn-logic.test.ts index 62498e2b33..6b379bdfe2 100644 --- a/apps/kimi-web/test/turn-logic.test.ts +++ b/apps/kimi-web/test/turn-logic.test.ts @@ -47,6 +47,46 @@ describe('messagesToTurns', () => { ]); }); + it('surfaces a ReadMediaFile snapshot result as media', () => { + // After a reload the daemon snapshot delivers a ReadMediaFile result as + // raw content parts (the same shape the live tool.result stream carries), + // so a resumed session must render the image card, not a generic tool card. + const turns = messagesToTurns( + [ + message('a1', 'assistant', [ + { type: 'toolUse', toolCallId: 'tool-9', toolName: 'ReadMediaFile', input: { path: 'shot.png' } }, + ]), + message('t1', 'tool', [ + { + type: 'toolResult', + toolCallId: 'tool-9', + output: [ + { type: 'text', text: '' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,QUJD' } }, + { type: 'text', text: '' }, + ], + }, + ]), + ], + [], + undefined, + false, + ); + + expect(turns[0]?.tools).toMatchObject([ + { + id: 'tool-9', + status: 'ok', + media: { + kind: 'image', + url: 'data:image/png;base64,QUJD', + path: '/tmp/shot.png', + mimeType: 'image/png', + }, + }, + ]); + }); + it('splits assistant turns when prompt ids differ', () => { const turns = messagesToTurns( [ diff --git a/packages/agent-core/src/services/message/message.ts b/packages/agent-core/src/services/message/message.ts index 07bfe98779..3b6ffb197c 100644 --- a/packages/agent-core/src/services/message/message.ts +++ b/packages/agent-core/src/services/message/message.ts @@ -184,9 +184,13 @@ function mapContentPart(part: ContextMessage['content'][number]): MessageContent * Build the protocol-shaped `Message.content[]` for one ContextMessage. * * Order: - * 1. For `tool` role: emit a SINGLE `tool_result` part. The output is the - * flattened text of the kosong message's content parts (most tool - * messages emit a single text). `is_error` is taken from `ContextMessage.isError`. + * 1. For `tool` role: emit a SINGLE `tool_result` part. Plain-text results + * keep the historical flattened-text output (most tool messages emit a + * single text); a result that carries media parts (image/video/audio — + * e.g. ReadMediaFile) passes the raw kosong content-part array through + * instead, the same shape the live `tool.result` event stream carries, + * so REST consumers can still render the media. `is_error` is taken + * from `ContextMessage.isError`. * 2. For other roles: emit each content part mapped per `mapContentPart`, * THEN append one `tool_use` part per `ToolCall` (assistant only). */ @@ -197,20 +201,23 @@ function buildProtocolContent(msg: ContextMessage): MessageContent[] { // fall back to text passthrough so we don't lose user-visible content. return msg.content.map((p) => mapContentPart(p)); } - const flattenedOutput = msg.content - .map((p) => (p.type === 'text' ? p.text : '')) - .join(''); + const hasMediaPart = msg.content.some( + (p) => p.type === 'image_url' || p.type === 'video_url' || p.type === 'audio_url', + ); + const output: unknown = hasMediaPart + ? msg.content + : msg.content.map((p) => (p.type === 'text' ? p.text : '')).join(''); const part: MessageContent = msg.isError === true ? { type: 'tool_result', tool_call_id: msg.toolCallId, - output: flattenedOutput, + output, is_error: true, } : { type: 'tool_result', tool_call_id: msg.toolCallId, - output: flattenedOutput, + output, }; return [part]; } diff --git a/packages/agent-core/test/services/message-service.test.ts b/packages/agent-core/test/services/message-service.test.ts index 3a1c460779..747b9a200c 100644 --- a/packages/agent-core/test/services/message-service.test.ts +++ b/packages/agent-core/test/services/message-service.test.ts @@ -362,10 +362,12 @@ describe('MessageService', () => { describe('toProtocolMessage tool-result output passthrough', () => { - it('flattens tool result text verbatim — no stripping, tool metadata rides `note`', () => { - // Tool metadata no longer travels inside `output` (producers put it on - // the result's `note` side channel), so the protocol mapper must not eat - // content that merely contains a literal tag. + it('passes media tool results through as raw content parts instead of flattening', () => { + // A ReadMediaFile-style result carries an image_url part next to the + // wrapper text tags; flattening to text would drop the media bytes, so + // the mapper passes the raw part array through (same shape the live + // tool.result event carries). Literal text — even system/image-looking + // markup from a user file — rides along verbatim. const toolMessage: ContextMessage = { role: 'tool', toolCallId: 'call_1', @@ -379,8 +381,26 @@ describe('toProtocolMessage tool-result output passthrough', () => { }; const [part] = toProtocolMessage(SESSION_ID, 0, toolMessage, SESSION_CREATED_AT).content; expect(part?.type).toBe('tool_result'); - const output = (part as { output: string }).output; - expect(output).toContain('literal text from a user file'); - expect(output).toContain(''); + expect((part as { output: unknown }).output).toEqual([ + { type: 'text', text: 'literal text from a user file' }, + { type: 'text', text: '' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,A' } }, + { type: 'text', text: '' }, + ]); + }); + + it('flattens text-only tool results to a single output string', () => { + const toolMessage: ContextMessage = { + role: 'tool', + toolCallId: 'call_1', + content: [ + { type: 'text', text: 'line one\n' }, + { type: 'text', text: 'line two' }, + ], + toolCalls: [], + }; + const [part] = toProtocolMessage(SESSION_ID, 0, toolMessage, SESSION_CREATED_AT).content; + expect(part?.type).toBe('tool_result'); + expect((part as { output: unknown }).output).toBe('line one\nline two'); }); });