From 8a1a86279ad3b85286d3984b1663fd30d5a0a969 Mon Sep 17 00:00:00 2001 From: Kagura Date: Mon, 11 May 2026 00:07:42 +0800 Subject: [PATCH] fix(codex): normalize thread id/sessionId cross-fill before schema validation (#80137) Merged via squash. Prepared head SHA: b2c20dd5d6349713acba7e1f1b2712bbb278b2a0 Co-authored-by: kagura-agent <268167063+kagura-agent@users.noreply.github.com> Co-authored-by: omarshahine <10343873+omarshahine@users.noreply.github.com> Reviewed-by: @omarshahine --- CHANGELOG.md | 1 + .../app-server/protocol-validators.test.ts | 71 +++++++++++++++++++ .../src/app-server/protocol-validators.ts | 29 +++++++- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 extensions/codex/src/app-server/protocol-validators.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9df5f9efc80..d00de304fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -153,6 +153,7 @@ Docs: https://docs.openclaw.ai - Volcengine/Kimi: strip provider-unsupported tool schema length and item constraint keywords for direct and coding-plan models so hosted Kimi runs do not reject message tools with `minLength`. Fixes #38817. - DeepSeek: backfill V4 `reasoning_content` replay fields for unowned OpenAI-compatible proxy providers, preventing follow-up request failures outside the bundled DeepSeek and OpenRouter routes. Fixes #79608. - iMessage: emit a WARN log when an action is blocked because the imsg private API bridge is not attached, so operators see the silent-drop in `~/.openclaw/logs/openclaw.log` instead of having to read per-session trajectory JSONL `tool.result` payloads. Common after a gateway restart un-injects the dylib from Messages.app. (#80035) Thanks @omarshahine. +- Codex: cross-fill missing `thread.id` and `thread.sessionId` before schema validation so live Codex app-server responses that omit `sessionId` no longer fail `thread/start` or `thread/resume`. Fixes #80124. (#80137) Thanks @kagura-agent. ## 2026.5.9 diff --git a/extensions/codex/src/app-server/protocol-validators.test.ts b/extensions/codex/src/app-server/protocol-validators.test.ts new file mode 100644 index 00000000000..6909114e1e4 --- /dev/null +++ b/extensions/codex/src/app-server/protocol-validators.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + assertCodexThreadStartResponse, + assertCodexThreadResumeResponse, +} from "./protocol-validators.js"; + +function makeMinimalThread(overrides: Record = {}) { + return { + id: "thread-1", + sessionId: "session-1", + cliVersion: "0.129.0", + createdAt: 1715299200, + updatedAt: 1715299200, + cwd: "/tmp", + ephemeral: false, + modelProvider: "openai", + preview: "test thread", + source: "appServer", + status: { type: "notLoaded" }, + turns: [], + ...overrides, + }; +} + +function makeMinimalResponse(threadOverrides: Record = {}) { + return { + approvalPolicy: "never", + approvalsReviewer: "user", + cwd: "/tmp", + model: "gpt-5.4", + modelProvider: "openai", + sandbox: { type: "dangerFullAccess" }, + thread: makeMinimalThread(threadOverrides), + }; +} + +describe("assertCodexThreadStartResponse", () => { + it("accepts response with both id and sessionId", () => { + const response = makeMinimalResponse(); + const result = assertCodexThreadStartResponse(response); + expect(result.thread).toMatchObject({ id: "thread-1", sessionId: "session-1" }); + }); + + it("normalizes missing sessionId from id", () => { + const response = makeMinimalResponse({ sessionId: undefined }); + // Remove the sessionId key entirely + delete (response.thread as Record).sessionId; + const result = assertCodexThreadStartResponse(response); + expect(result.thread).toMatchObject({ id: "thread-1", sessionId: "thread-1" }); + }); + + it("normalizes missing id from sessionId", () => { + const response = makeMinimalResponse({ id: undefined, sessionId: "session-1" }); + delete (response.thread as Record).id; + const result = assertCodexThreadStartResponse(response); + expect(result.thread).toMatchObject({ id: "session-1", sessionId: "session-1" }); + }); + + it("throws on invalid response", () => { + expect(() => assertCodexThreadStartResponse({})).toThrow("Invalid Codex app-server"); + }); +}); + +describe("assertCodexThreadResumeResponse", () => { + it("normalizes missing sessionId from id", () => { + const response = makeMinimalResponse({ sessionId: undefined }); + delete (response.thread as Record).sessionId; + const result = assertCodexThreadResumeResponse(response); + expect(result.thread).toMatchObject({ id: "thread-1", sessionId: "thread-1" }); + }); +}); diff --git a/extensions/codex/src/app-server/protocol-validators.ts b/extensions/codex/src/app-server/protocol-validators.ts index bbcb2c92d9b..114e6e04bec 100644 --- a/extensions/codex/src/app-server/protocol-validators.ts +++ b/extensions/codex/src/app-server/protocol-validators.ts @@ -43,11 +43,19 @@ const validateTurnCompletedNotification = ajv.compile(turnStartResponseSchema); export function assertCodexThreadStartResponse(value: unknown): CodexThreadStartResponse { - return assertCodexShape(validateThreadStartResponse, value, "thread/start response"); + return assertCodexShape( + validateThreadStartResponse, + normalizeThreadResponse(value), + "thread/start response", + ); } export function assertCodexThreadResumeResponse(value: unknown): CodexThreadResumeResponse { - return assertCodexShape(validateThreadResumeResponse, value, "thread/resume response"); + return assertCodexShape( + validateThreadResumeResponse, + normalizeThreadResponse(value), + "thread/resume response", + ); } export function assertCodexTurnStartResponse(value: unknown): CodexTurnStartResponse { @@ -140,6 +148,23 @@ function normalizeThreadItem(value: unknown): unknown { } } +function normalizeThreadResponse(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value) || !("thread" in value)) { + return value; + } + const thread = (value as { thread?: unknown }).thread; + if (thread && typeof thread === "object" && !Array.isArray(thread)) { + const t = thread as { id?: string; sessionId?: string }; + if (typeof t.id === "string" && typeof t.sessionId !== "string") { + return { ...value, thread: { ...thread, sessionId: t.id } }; + } + if (typeof t.sessionId === "string" && typeof t.id !== "string") { + return { ...value, thread: { ...thread, id: t.sessionId } }; + } + } + return value; +} + function normalizeTurnStartResponse(value: unknown): unknown { if (!value || typeof value !== "object" || Array.isArray(value) || !("turn" in value)) { return value;