import { describe, expect, it } from "vitest"; import { readCodexModelListResponse, readCodexTurn, 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.id).toBe("thread-1"); expect(result.thread.sessionId).toBe("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.id).toBe("thread-1"); expect(result.thread.sessionId).toBe("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.id).toBe("session-1"); expect(result.thread.sessionId).toBe("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.id).toBe("thread-1"); expect(result.thread.sessionId).toBe("thread-1"); }); }); describe("readCodexModelListResponse", () => { it("applies defaults from generated schemas behind local refs", () => { const response = readCodexModelListResponse({ data: [ { id: "gpt-test", model: "gpt-test", displayName: "GPT Test", description: "test model", hidden: false, isDefault: false, defaultReasoningEffort: "medium", supportedReasoningEfforts: [], }, ], }); const model = response?.data[0] as | (NonNullable>["data"][number] & { serviceTiers?: unknown; supportsPersonality?: unknown; }) | undefined; expect(model?.inputModalities).toEqual(["text", "image"]); expect(model?.serviceTiers).toEqual([]); expect(model?.supportsPersonality).toBe(false); }); }); describe("readCodexTurn", () => { it("does not merge defaults from unrelated thread item union branches", () => { const turn = readCodexTurn({ id: "turn-1", status: "completed", items: [{ id: "item-1", type: "plan", text: "ship it" }], }); expect(turn?.items[0]).toEqual({ id: "item-1", type: "plan", text: "ship it" }); }); it("accepts nullable arrays in generated dynamic tool call items", () => { const turn = readCodexTurn({ id: "turn-1", status: "completed", items: [ { arguments: {}, contentItems: null, id: "item-1", status: "completed", tool: "render", type: "dynamicToolCall", }, ], }); expect(turn?.items[0]).toMatchObject({ contentItems: null, id: "item-1", type: "dynamicToolCall", }); }); });