From 150dd88702728ce75c7da27826c56084aada55d1 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 22:10:23 +0800 Subject: [PATCH 1/4] fix(protocol): make server_hello heartbeat_ms optional kap-server dropped the server-initiated WS heartbeat and no longer emits heartbeat_ms in server_hello, but the published v1 schema still required it, so spec-compliant clients rejected the handshake before subscribing. - mark heartbeat_ms optional in serverHelloPayloadSchema (advisory only) - add a ws-control test for a server_hello without heartbeat_ms - align kimi-web WireServerHello and the server-e2e handshake assertion --- apps/kimi-web/src/api/daemon/wire.ts | 3 ++- packages/protocol/src/__tests__/ws-control.test.ts | 14 ++++++++++++++ packages/protocol/src/ws-control.ts | 7 ++++++- packages/server-e2e/src/client.ts | 3 ++- packages/server-e2e/test/client.test.ts | 3 ++- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index 2c0978d899..d19cd45e4e 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -514,7 +514,8 @@ export interface WireServerHello { timestamp: string; payload: { server_id: string; - heartbeat_ms: number; + /** Advisory only — kap-server omits this since it sends no heartbeat. */ + heartbeat_ms?: number; max_event_buffer_size: number; capabilities: { event_batching: boolean; diff --git a/packages/protocol/src/__tests__/ws-control.test.ts b/packages/protocol/src/__tests__/ws-control.test.ts index c201a4be35..ad6537cfbc 100644 --- a/packages/protocol/src/__tests__/ws-control.test.ts +++ b/packages/protocol/src/__tests__/ws-control.test.ts @@ -170,6 +170,20 @@ describe('ws-control — §3.1 server_hello', () => { expect(result.success).toBe(true); }); + it('parses a server_hello without heartbeat_ms (no server heartbeat)', () => { + const result = serverHelloMessageSchema.safeParse({ + type: 'server_hello', + timestamp: TS, + payload: { + ws_connection_id: 'conn_local', + protocol_version: 2, + max_event_buffer_size: 1000, + capabilities: { event_batching: false, compression: false }, + }, + }); + expect(result.success).toBe(true); + }); + it('rejects a server_hello missing protocol_version', () => { const result = serverHelloMessageSchema.safeParse({ type: 'server_hello', diff --git a/packages/protocol/src/ws-control.ts b/packages/protocol/src/ws-control.ts index 2098276c12..19807c9f46 100644 --- a/packages/protocol/src/ws-control.ts +++ b/packages/protocol/src/ws-control.ts @@ -74,7 +74,12 @@ export const wsAckEnvelopeSchema = (payload: T) => export const serverHelloPayloadSchema = z.object({ ws_connection_id: z.string(), protocol_version: z.number().int().positive(), - heartbeat_ms: z.number().int().positive(), + /** + * Legacy servers advertise their ping interval here. kap-server dropped the + * server-initiated heartbeat and omits this field — clients must treat it as + * advisory and not require it. + */ + heartbeat_ms: z.number().int().positive().optional(), max_event_buffer_size: z.number().int().positive(), capabilities: z.object({ event_batching: z.boolean(), diff --git a/packages/server-e2e/src/client.ts b/packages/server-e2e/src/client.ts index 0420a16c38..13fcb72c27 100644 --- a/packages/server-e2e/src/client.ts +++ b/packages/server-e2e/src/client.ts @@ -367,7 +367,8 @@ export class DaemonClient { // ── WS lifecycle ──────────────────────────────────────────────────────── /** * Open the WS socket, wait for `server_hello`, send `client_hello`, await - * the ack. Returns the server's hello payload (heartbeat config, etc.). + * the ack. Returns the server's hello payload (buffer sizes, capabilities, + * etc.). */ async connect(): Promise { if (this._serverHello) return this._serverHello; diff --git a/packages/server-e2e/test/client.test.ts b/packages/server-e2e/test/client.test.ts index e2dafada73..feef159b49 100644 --- a/packages/server-e2e/test/client.test.ts +++ b/packages/server-e2e/test/client.test.ts @@ -90,7 +90,8 @@ describeLive('DaemonClient (live server required)', () => { log('connect request', { url: `${BASE_URL.replace(/^http/, 'ws')}/api/v1/ws` }); const hello = await client.connect(); log('server hello', hello); - expect(hello.heartbeat_ms).toBeGreaterThan(0); + // heartbeat_ms is optional — kap-server omits it (no server heartbeat). + expect(hello.heartbeat_ms === undefined || hello.heartbeat_ms > 0).toBe(true); expect(typeof hello.ws_connection_id).toBe('string'); await client.close(); log('closed'); From 00641b465d9bd4106b587cf51b695d7259fca4dc Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 22:47:23 +0800 Subject: [PATCH 2/4] fix(agent-core-v2): pass media parts through tool_result projection - keep raw kosong content-part array for tool results carrying image/video/audio parts instead of flattening to text - restore ReadMediaFile media rendering after session reload/resume --- .../fix-v2-snapshot-media-passthrough.md | 5 +++++ .../agent/contextMemory/messageProjection.ts | 20 +++++++++++++------ .../projector-tool-exchanges.test.ts | 20 +++++++++++++++++++ 3 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-v2-snapshot-media-passthrough.md diff --git a/.changeset/fix-v2-snapshot-media-passthrough.md b/.changeset/fix-v2-snapshot-media-passthrough.md new file mode 100644 index 0000000000..a5b52ee1bc --- /dev/null +++ b/.changeset/fix-v2-snapshot-media-passthrough.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix ReadMediaFile results losing their image rendering after a session reload or resume on the v2 server backend. diff --git a/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts b/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts index 85aa1181d3..9d9c28e24e 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts @@ -49,7 +49,12 @@ function mapContentPart(part: ContextMessage['content'][number]): MessageContent /** * Build the protocol-shaped `Message.content[]` for one history entry: - * 1. `tool` role → a single `tool_result` part. + * 1. `tool` role → a single `tool_result` part. Plain-text results keep the + * historical flattened-text output; 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 after reload/resume. * 2. other roles → each mapped content part, then one `tool_use` part per * `ToolCall` (assistant only). */ @@ -58,21 +63,24 @@ function buildProtocolContent(msg: ContextMessage): MessageContent[] { if (msg.toolCallId === undefined) { 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-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index 56efde5adb..cd0c1bd976 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -327,6 +327,26 @@ describe('projector tool-exchange normalization', () => { ]); }); + it('passes raw media parts through as the tool_result output', () => { + // ReadMediaFile-style result: the live tool.result event carries the raw + // kosong content-part array, so the protocol projection must emit the same + // shape — otherwise media rendering is lost after reload/resume. + const result: ContextMessage = { + role: 'tool', + content: [ + { type: 'text', text: 'image result' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }, + ], + toolCalls: [], + toolCallId: 'call_media', + }; + + const protocol = toProtocolMessage('session_1', 0, result, 0); + expect(protocol.content).toEqual([ + { type: 'tool_result', tool_call_id: 'call_media', output: result.content }, + ]); + }); + it('renders v1 tool-result status at the model projection boundary', () => { const history = [ assistant('', ['call_error', 'call_empty']), From 47c74d68dec46c4c9365265752019842f3877c8e Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 22:48:59 +0800 Subject: [PATCH 3/4] chore: add changeset for optional server_hello heartbeat_ms --- .changeset/fix-server-hello-heartbeat-optional.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-server-hello-heartbeat-optional.md diff --git a/.changeset/fix-server-hello-heartbeat-optional.md b/.changeset/fix-server-hello-heartbeat-optional.md new file mode 100644 index 0000000000..5bbe268858 --- /dev/null +++ b/.changeset/fix-server-hello-heartbeat-optional.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/protocol": patch +--- + +Make the server_hello heartbeat_ms field optional so spec-compliant clients no longer reject handshakes from servers that do not advertise a heartbeat interval. From 431ec7688508e1253b43a2f3a77286bfb50fde05 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 22:57:49 +0800 Subject: [PATCH 4/4] docs(agent-core-v2): move tool_result media rationale to module header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the agent-core-v2 comment conventions, comments live solely in the top-of-file block — move the media-passthrough rationale out of the buildProtocolContent JSDoc into the module header. --- .../src/agent/contextMemory/messageProjection.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts b/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts index 9d9c28e24e..ac4e7a0928 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts @@ -7,6 +7,12 @@ * message objects. Lives in agent-core-v2 (next to the `ContextMessage` data it * projects) so the `sessionLegacy` edge adapter can own the v1 `:undo` response * shape without duplicating the projection in the server layer. + * + * Tool results project to a single `tool_result` part: plain-text results keep + * the historical flattened-text output, while a result carrying media parts + * (image/video/audio — e.g. ReadMediaFile) passes the raw kosong content-part + * array through, the same shape the live `tool.result` event stream carries, + * so REST consumers can still render the media after reload/resume. */ import type { Message, MessageContent, MessageRole, ToolUseContent } from '@moonshot-ai/protocol'; @@ -49,12 +55,7 @@ function mapContentPart(part: ContextMessage['content'][number]): MessageContent /** * Build the protocol-shaped `Message.content[]` for one history entry: - * 1. `tool` role → a single `tool_result` part. Plain-text results keep the - * historical flattened-text output; 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 after reload/resume. + * 1. `tool` role → a single `tool_result` part. * 2. other roles → each mapped content part, then one `tool_use` part per * `ToolCall` (assistant only). */