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. 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/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/agent-core-v2/src/agent/contextMemory/messageProjection.ts b/packages/agent-core-v2/src/agent/contextMemory/messageProjection.ts index 85aa1181d3..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'; @@ -58,21 +64,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']), 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');