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/fix-server-hello-heartbeat-optional.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/fix-v2-snapshot-media-passthrough.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion apps/kimi-web/src/api/daemon/wire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']),
Expand Down
14 changes: 14 additions & 0 deletions packages/protocol/src/__tests__/ws-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 6 additions & 1 deletion packages/protocol/src/ws-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ export const wsAckEnvelopeSchema = <T extends z.ZodTypeAny>(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(),
Expand Down
3 changes: 2 additions & 1 deletion packages/server-e2e/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ServerHelloMessage['payload']> {
if (this._serverHello) return this._serverHello;
Expand Down
3 changes: 2 additions & 1 deletion packages/server-e2e/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading