From 7623b8b3829c41c50e7f313a6af1b36fa6997b4d Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Thu, 14 May 2026 22:58:56 -0700 Subject: [PATCH 1/3] fix: sanitize empty Read pages arguments --- CHANGELOG.md | 2 + src/translation/codex-to-anthropic.ts | 48 ++++++++- .../codex-to-anthropic-read-pages.test.ts | 100 ++++++++++++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 tests/unit/translation/codex-to-anthropic-read-pages.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 54346859..581e6cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ ### Fixed +- Claude Code 的 `Read` 工具参数里如果 `pages` 传成空字符串或空白字符串,会在 Codex → Anthropic 转换时被自动剔除,避免 GPT-5.5 反复触发 `Read tool validation error: Invalid pages parameter: ""` 并重试隔离工作树;对应单测覆盖流式与非流式两条路径,以及非空 PDF 页码范围保留(`src/translation/codex-to-anthropic.ts`、`tests/unit/translation/codex-to-anthropic-read-pages.test.ts`)。 + - Release bump workflows now require runtime file changes in addition to meaningful commit subjects before tagging a beta or stable build. This prevents squash-promotion history divergence from re-counting old dev commits, and prevents workflow/docs/test-only fixes from producing empty Electron releases (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`). - Release bump workflows now skip the release-notes workflow hotfix subject itself, so promoting the stable-notes CI fix to `master` does not create an empty desktop release on the next scheduled bump (`.github/workflows/bump-electron.yml`, `.github/workflows/bump-electron-beta.yml`, `tests/unit/ci/package-boundary.test.ts`). - 修复 stable release notes 在手动 squash promotion 后只写 `fix: promote dev release fixes to master`、漏掉 dev 原始 PR 的问题:`release.yml` 改为调用 `.github/scripts/generate-release-notes.sh`,stable tag 若只有 promotion 内容且运行时代码树与 `origin/dev` 一致(忽略 README/package 版本文件),会回退使用 dev history 生成说明;新增单测覆盖正常 stable tag 与 squash promotion 两条路径(`.github/workflows/release.yml`、`.github/scripts/generate-release-notes.sh`、`tests/unit/ci/release-notes-script.test.ts`)。 diff --git a/src/translation/codex-to-anthropic.ts b/src/translation/codex-to-anthropic.ts index cc8a1038..d226c186 100644 --- a/src/translation/codex-to-anthropic.ts +++ b/src/translation/codex-to-anthropic.ts @@ -28,6 +28,31 @@ interface ResponseMetadata { functionCallIds?: string[]; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sanitizeToolInput(toolName: string, input: Record): Record { + if (toolName !== "Read") return input; + if (typeof input.pages !== "string" || input.pages.trim() !== "") return input; + + const sanitized: Record = { ...input }; + delete sanitized.pages; + return sanitized; +} + +function sanitizeFunctionCallArguments(toolName: string, argumentsJson: string): string { + try { + const parsed: unknown = JSON.parse(argumentsJson); + if (!isRecord(parsed)) return argumentsJson; + + const sanitized = sanitizeToolInput(toolName, parsed); + return sanitized === parsed ? argumentsJson : JSON.stringify(sanitized); + } catch { + return argumentsJson; + } +} + function resolveCacheUsage( inputTokens: number, cachedTokens: number | undefined, @@ -79,7 +104,8 @@ export async function* streamCodexToAnthropic( let textBlockStarted = false; let thinkingBlockStarted = false; const functionCallIds = new Set(); - const callIdsWithDeltas = new Set(); + const callIdsWithForwardedDeltas = new Set(); + const functionCallNames = new Map(); const publishFunctionCallId = (callId: string): void => { if (functionCallIds.has(callId)) return; @@ -172,6 +198,7 @@ export async function* streamCodexToAnthropic( hasToolCalls = true; hasContent = true; publishFunctionCallId(evt.functionCallStart.callId); + functionCallNames.set(evt.functionCallStart.callId, evt.functionCallStart.name); yield* closeThinkingIfOpen(); yield* closeTextIfOpen(); @@ -191,7 +218,11 @@ export async function* streamCodexToAnthropic( } if (evt.functionCallDelta) { - callIdsWithDeltas.add(evt.functionCallDelta.callId); + if (functionCallNames.get(evt.functionCallDelta.callId) === "Read") { + continue; + } + + callIdsWithForwardedDeltas.add(evt.functionCallDelta.callId); yield formatSSE("content_block_delta", { type: "content_block_delta", index: contentIndex, @@ -203,11 +234,17 @@ export async function* streamCodexToAnthropic( if (evt.functionCallDone) { publishFunctionCallId(evt.functionCallDone.callId); // Emit full arguments if no deltas were streamed - if (!callIdsWithDeltas.has(evt.functionCallDone.callId)) { + if (!callIdsWithForwardedDeltas.has(evt.functionCallDone.callId)) { yield formatSSE("content_block_delta", { type: "content_block_delta", index: contentIndex, - delta: { type: "input_json_delta", partial_json: evt.functionCallDone.arguments }, + delta: { + type: "input_json_delta", + partial_json: sanitizeFunctionCallArguments( + evt.functionCallDone.name, + evt.functionCallDone.arguments, + ), + }, }); } // Close this tool_use block @@ -333,7 +370,8 @@ export async function collectCodexToAnthropicResponse( functionCallIds.add(evt.functionCallDone.callId); let parsedInput: Record = {}; try { - parsedInput = JSON.parse(evt.functionCallDone.arguments) as Record; + const parsed: unknown = JSON.parse(evt.functionCallDone.arguments); + parsedInput = isRecord(parsed) ? sanitizeToolInput(evt.functionCallDone.name, parsed) : {}; } catch { /* use empty object */ } toolUseBlocks.push({ type: "tool_use", diff --git a/tests/unit/translation/codex-to-anthropic-read-pages.test.ts b/tests/unit/translation/codex-to-anthropic-read-pages.test.ts new file mode 100644 index 00000000..5e507f9c --- /dev/null +++ b/tests/unit/translation/codex-to-anthropic-read-pages.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ExtractedEvent } from "@src/translation/codex-event-extractor.js"; +import { + createCompleted, + createCreated, + createFunctionCallDelta, + createFunctionCallDone, + createFunctionCallStart, + createInProgress, +} from "@helpers/events.js"; + +let mockEvents: ExtractedEvent[] = []; + +vi.mock("@src/translation/codex-event-extractor.js", async (importOriginal) => { + const original = await importOriginal() as Record; + return { + ...original, + iterateCodexEvents: vi.fn(async function* () { + for (const evt of mockEvents) { + yield evt; + } + }), + }; +}); + +import { collectCodexToAnthropicResponse, streamCodexToAnthropic } from "@src/translation/codex-to-anthropic.js"; +import type { CodexApi } from "@src/proxy/codex-api.js"; + +const fakeCodexApi = {} as CodexApi; +const fakeResponse = new Response(null); + +function readToolStream(args: string, deltas: string[] = []): ExtractedEvent[] { + return [ + createCreated("resp_read"), + createInProgress("resp_read"), + createFunctionCallStart("call_read", "Read"), + ...deltas.map((delta) => createFunctionCallDelta("call_read", delta)), + createFunctionCallDone("call_read", "Read", args), + createCompleted("resp_read", { input_tokens: 10, output_tokens: 5 }), + ]; +} + +async function collectStreamInput(events: ExtractedEvent[]): Promise> { + mockEvents = events; + const chunks: string[] = []; + for await (const chunk of streamCodexToAnthropic(fakeCodexApi, fakeResponse, "gpt-5.5")) { + chunks.push(chunk); + } + + const partialJson = chunks + .map((chunk) => { + const dataLine = chunk.trim().split("\n").find((line) => line.startsWith("data: ")); + if (!dataLine) return ""; + const data = JSON.parse(dataLine.slice(6)) as Record; + const delta = data.delta; + if (typeof delta !== "object" || delta === null || Array.isArray(delta)) return ""; + const partial = (delta as Record).partial_json; + return typeof partial === "string" ? partial : ""; + }) + .join(""); + + return JSON.parse(partialJson) as Record; +} + +describe("Codex to Anthropic Read.pages sanitization", () => { + it("omits empty Read.pages from streamed tool arguments", async () => { + const input = await collectStreamInput(readToolStream( + '{"file_path":"package.json","pages":""}', + )); + + expect(input).toEqual({ file_path: "package.json" }); + }); + + it("omits empty Read.pages when arguments arrive as streaming deltas", async () => { + const input = await collectStreamInput(readToolStream( + '{"file_path":"package.json","pages":""}', + ['{"file_path":"package.json"', ',"pages":""}'], + )); + + expect(input).toEqual({ file_path: "package.json" }); + }); + + it("omits empty Read.pages from collected tool input", async () => { + mockEvents = readToolStream('{"file_path":"package.json","pages":" "}'); + + const { response } = await collectCodexToAnthropicResponse(fakeCodexApi, fakeResponse, "gpt-5.5"); + const toolBlock = response.content.find((block) => block.type === "tool_use"); + + expect(toolBlock?.input).toEqual({ file_path: "package.json" }); + }); + + it("keeps non-empty Read.pages ranges", async () => { + mockEvents = readToolStream('{"file_path":"manual.pdf","pages":"1-2"}'); + + const { response } = await collectCodexToAnthropicResponse(fakeCodexApi, fakeResponse, "gpt-5.5"); + const toolBlock = response.content.find((block) => block.type === "tool_use"); + + expect(toolBlock?.input).toEqual({ file_path: "manual.pdf", pages: "1-2" }); + }); +}); From f3d2c9d1d1742a7d1af981b67c2f59ea8f76d702 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Sun, 17 May 2026 15:46:42 -0700 Subject: [PATCH 2/3] docs: explain why Read deltas are dropped mid-stream --- src/translation/codex-to-anthropic.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/translation/codex-to-anthropic.ts b/src/translation/codex-to-anthropic.ts index d226c186..9133578d 100644 --- a/src/translation/codex-to-anthropic.ts +++ b/src/translation/codex-to-anthropic.ts @@ -218,6 +218,9 @@ export async function* streamCodexToAnthropic( } if (evt.functionCallDelta) { + // Drop Read deltas and buffer until functionCallDone, where the full + // arguments can be sanitized atomically (e.g. stripping empty `pages`). + // Partial JSON cannot be safely rewritten mid-stream. if (functionCallNames.get(evt.functionCallDelta.callId) === "Read") { continue; } From ea8a263b61c4dc686bafc556b41aad1c6c111d85 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Sun, 17 May 2026 16:45:25 -0700 Subject: [PATCH 3/3] chore: retrigger CI