From 9de7862a29c4a7ea2434fc05f0c7c86251c0cd6e Mon Sep 17 00:00:00 2001 From: Bonobo Date: Mon, 10 Aug 2026 04:08:41 +0200 Subject: [PATCH] perf(rpc): reuse serialized output frames Why: RpcFrameEncoder serializes normal frames once for protocol routing and again for output, while v2 snapshots serialize message payloads separately. Changes: - Feed the existing JSON into frame-size enforcement. - Build v2 message snapshots from the serialized frame. Evidence: - End-to-end paired medians improved 10.72% for v1 and 15.72% for v2 across 25 pairs, with identical output hashes. Refs #8118 --- packages/coding-agent/CHANGELOG.md | 4 ++++ .../coding-agent/src/modes/rpc/rpc-frame.ts | 18 +++++++++++++----- packages/coding-agent/test/rpc-frame.test.ts | 19 ++++++++++++++++++- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..e82de1608 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed RPC `message_end` frames being serialized more than once before output while preserving v1 and v2 wire bytes ([#8118](https://github.com/can1357/oh-my-pi/issues/8118)). + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/src/modes/rpc/rpc-frame.ts b/packages/coding-agent/src/modes/rpc/rpc-frame.ts index 862b654c8..6163806d3 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-frame.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-frame.ts @@ -239,9 +239,12 @@ function overflowFrame(frame: object): object { }; } -/** Serialize a complete JSONL frame while enforcing the transport byte ceiling. */ -export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamedMessages?: readonly unknown[]): string { - let json = JSON.stringify(frame); +function encodeRpcFrameFromJson( + frame: object, + json: string, + streamedMessageCount: number, + streamedMessages?: readonly unknown[], +): string { if (serializedFrameBytes(json) <= MAX_RPC_FRAME_BYTES) return `${json}\n`; if (isRecord(frame) && frame.type === "response") { return `${JSON.stringify(overflowFrame(frame))}\n`; @@ -259,6 +262,11 @@ export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamed return `${JSON.stringify(overflowFrame(compacted))}\n`; } +/** Serialize a complete JSONL frame while enforcing the transport byte ceiling. */ +export function encodeRpcFrame(frame: object, streamedMessageCount = 0, streamedMessages?: readonly unknown[]): string { + return encodeRpcFrameFromJson(frame, JSON.stringify(frame), streamedMessageCount, streamedMessages); +} + /** Stateful encoder that tracks which messages a client has already received. */ export class RpcFrameEncoder { #streamedMessages: unknown[] = []; @@ -292,14 +300,14 @@ export class RpcFrameEncoder { frames = [singleFrame]; } } else { - singleFrame = encodeRpcFrame(frame, this.#streamedMessages.length, this.#streamedMessages); + singleFrame = encodeRpcFrameFromJson(frame, json, this.#streamedMessages.length, this.#streamedMessages); frames = [singleFrame]; } if (!isRecord(frame)) return frames; if (frame.type === "message_end") { const snapshot = this.#protocolVersion === 2 && Object.hasOwn(frame, "message") - ? { message: jsonSnapshot(frame.message) } + ? (encodedMessageSnapshot(json) ?? { message: jsonSnapshot(frame.message) }) : singleFrame !== undefined ? encodedMessageSnapshot(singleFrame) : undefined; diff --git a/packages/coding-agent/test/rpc-frame.test.ts b/packages/coding-agent/test/rpc-frame.test.ts index 2117054ba..8babaeccf 100644 --- a/packages/coding-agent/test/rpc-frame.test.ts +++ b/packages/coding-agent/test/rpc-frame.test.ts @@ -20,9 +20,26 @@ function oversizedMessageHistory(prefix: string) { } describe("RPC frame encoding", () => { - it("preserves frames that already fit", () => { + it("preserves fitting frames and serializes stateful message frames once", () => { const frame = { id: "request-1", type: "response", command: "get_state", success: true, data: { ok: true } }; expect(encodeRpcFrame(frame)).toBe(`${JSON.stringify(frame)}\n`); + + for (const version of [1, 2] as const) { + let messageReads = 0; + const message = { role: "assistant", content: [{ type: "text", text: "done" }] }; + const event = { + type: "message_end", + get message() { + messageReads++; + return message; + }, + }; + const encoder = new RpcFrameEncoder(); + encoder.setProtocolVersion(version); + + expect(decode(encoder.encode(event))).toEqual({ type: "message_end", message }); + expect(messageReads).toBe(1); + } }); it("compacts agent_end after message events have streamed", () => {