From dde203c5c3e3f95177ba36fb38ed287c85753f22 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sat, 29 Aug 2026 10:53:31 +0900 Subject: [PATCH] fix(cursor): preserve reserved-prefix call ids --- src/adapters/cursor/call-id.ts | 10 +++++--- tests/cursor-call-id.test.ts | 47 ++++++++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/adapters/cursor/call-id.ts b/src/adapters/cursor/call-id.ts index a76707d00a..c74de39054 100644 --- a/src/adapters/cursor/call-id.ts +++ b/src/adapters/cursor/call-id.ts @@ -5,9 +5,10 @@ * literal newline ("call--\nfc__"). OpenCodex forwards ids * verbatim, so that newline leaked into Responses-visible `call_id` values, * where line-oriented clients (logging, splitting, validation) break. The codec - * encodes only ids containing CR/LF into a versioned single-line form and - * decodes both that form and legacy raw multi-line ids back to the exact - * upstream bytes before anything is serialized toward Cursor. + * encodes ids containing CR/LF into a versioned single-line form. It also + * escapes ids already in that form's reserved namespace so encoding remains + * injective. Both forms decode back to the exact upstream bytes before + * anything is serialized toward Cursor. */ const CALL_ID_PREFIX = "ocxc1_"; @@ -19,7 +20,7 @@ function needsEncoding(id: string): boolean { /** Encode a Cursor wire call id into a single-line Responses-safe id. */ export function encodeCursorCallId(id: string): string { - if (!needsEncoding(id)) return id; + if (!needsEncoding(id) && !id.startsWith(CALL_ID_PREFIX)) return id; return CALL_ID_PREFIX + Buffer.from(id, "utf8").toString("base64url"); } @@ -37,6 +38,7 @@ export function decodeCursorCallId(id: string): string { const decoded = Buffer.from(payload, "base64url").toString("utf8"); // Round-trip guard: only trust payloads our encoder could have produced. if (Buffer.from(decoded, "utf8").toString("base64url") !== payload) return id; + if (!needsEncoding(decoded) && !decoded.startsWith(CALL_ID_PREFIX)) return id; return decoded; } catch { return id; diff --git a/tests/cursor-call-id.test.ts b/tests/cursor-call-id.test.ts index def2ece14c..068f324359 100644 --- a/tests/cursor-call-id.test.ts +++ b/tests/cursor-call-id.test.ts @@ -17,6 +17,30 @@ describe("cursor call-id codec", () => { expect(decodeCursorCallId("call_abc123")).toBe("call_abc123"); }); + test("reserved-prefix ids are escaped and round-trip", () => { + for (const id of ["ocxc1_", "ocxc1_Y2FsbF8x", "ocxc1_!!not-base64url!!", "ocxc1_raw\nwire"]) { + const encoded = encodeCursorCallId(id); + expect(encoded).not.toBe(id); + expect(encoded.startsWith("ocxc1_")).toBe(true); + expect(encoded).not.toContain("\n"); + expect(encoded).not.toContain("\r"); + expect(decodeCursorCallId(encoded)).toBe(id); + } + }); + + test("reserved-prefix ids resembling legacy newline encodings stay opaque", () => { + const id = "ocxc1_YQpi"; + const encoded = encodeCursorCallId(id); + expect(encoded).not.toBe(id); + expect(decodeCursorCallId(encoded)).toBe(id); + }); + + test("legacy encoded line breaks remain decodable", () => { + expect(decodeCursorCallId("ocxc1_YQpi")).toBe("a\nb"); + expect(decodeCursorCallId("ocxc1_DQ")).toBe("\r"); + expect(decodeCursorCallId("ocxc1_DQo")).toBe("\r\n"); + }); + test("newline composite id round-trips through a single-line form", () => { const encoded = encodeCursorCallId(COMPOSITE); expect(encoded).not.toContain("\n"); @@ -34,15 +58,18 @@ describe("cursor call-id codec", () => { expect(decodeCursorCallId("ocxc1_!!not-base64url!!")).toBe("ocxc1_!!not-base64url!!"); }); - test("tool_call_start ids are single-line at the adapter boundary", () => { - const events = mapCursorServerMessage( - { type: "tool_call_start", id: COMPOSITE, name: "get_weather" }, - mapperState(), - ); - expect(events).toHaveLength(1); - const event = events[0]!; - if (event.type !== "tool_call_start") throw new Error("expected tool_call_start"); - expect(event.id).not.toContain("\n"); - expect(decodeCursorCallId(event.id)).toBe(COMPOSITE); + test("tool_call_start ids are reversible and single-line at the adapter boundary", () => { + for (const id of [COMPOSITE, "ocxc1_YQpi"]) { + const events = mapCursorServerMessage( + { type: "tool_call_start", id, name: "get_weather" }, + mapperState(), + ); + expect(events).toHaveLength(1); + const event = events[0]!; + if (event.type !== "tool_call_start") throw new Error("expected tool_call_start"); + expect(event.id).not.toContain("\n"); + expect(event.id).not.toContain("\r"); + expect(decodeCursorCallId(event.id)).toBe(id); + } }); });