fix(codex): normalize thread id/sessionId cross-fill before schema validation (#80137)

Merged via squash.

Prepared head SHA: b2c20dd5d6
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
This commit is contained in:
Kagura
2026-05-11 00:07:42 +08:00
committed by GitHub
parent f5cbf9358a
commit 8a1a86279a
3 changed files with 99 additions and 2 deletions

View File

@@ -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

View File

@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import {
assertCodexThreadStartResponse,
assertCodexThreadResumeResponse,
} from "./protocol-validators.js";
function makeMinimalThread(overrides: Record<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown>).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<string, unknown>).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<string, unknown>).sessionId;
const result = assertCodexThreadResumeResponse(response);
expect(result.thread).toMatchObject({ id: "thread-1", sessionId: "thread-1" });
});
});

View File

@@ -43,11 +43,19 @@ const validateTurnCompletedNotification = ajv.compile<CodexTurnCompletedNotifica
const validateTurnStartResponse = ajv.compile<CodexTurnStartResponse>(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;