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/web-resume-media-tool.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions apps/kimi-web/test/turn-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<image path="/tmp/shot.png">' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,QUJD' } },
{ type: 'text', text: '</image>' },
],
},
]),
],
[],
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(
[
Expand Down
23 changes: 15 additions & 8 deletions packages/agent-core/src/services/message/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand All @@ -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];
}
Expand Down
34 changes: 27 additions & 7 deletions packages/agent-core/test/services/message-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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('<system>literal text from a user file</system>');
expect(output).toContain('<image path="/tmp/x.png">');
expect((part as { output: unknown }).output).toEqual([
{ type: 'text', text: '<system>literal text from a user file</system>' },
{ type: 'text', text: '<image path="/tmp/x.png">' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,A' } },
{ type: 'text', text: '</image>' },
]);
});

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