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');
});
});