import { describe, expect, it, vi } from "vitest";import { ProviderSessionId } from "@t3tools/contracts"; import { CodexAppServerManager, classifyCodexStderrLine, isRecoverableThreadResumeError, normalizeCodexModelSlug,} from "./codexAppServerManager"; const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); function createSendTurnHarness() { const manager = new CodexAppServerManager(); const context = { session: { sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, }; const requireSession = vi .spyOn( manager as unknown as { requireSession: (sessionId: string) => unknown }, "requireSession", ) .mockReturnValue(context); const sendRequest = vi .spyOn( manager as unknown as { sendRequest: (...args: unknown[]) => Promise<unknown> }, "sendRequest", ) .mockResolvedValue({ turn: { id: "turn_1", }, }); const updateSession = vi .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") .mockImplementation(() => {}); return { manager, context, requireSession, sendRequest, updateSession };} function createThreadControlHarness() { const manager = new CodexAppServerManager(); const context = { session: { sessionId: "sess_1", provider: "codex", status: "ready", threadId: "thread_1", createdAt: "2026-02-10T00:00:00.000Z", updatedAt: "2026-02-10T00:00:00.000Z", }, }; const requireSession = vi .spyOn( manager as unknown as { requireSession: (sessionId: string) => unknown }, "requireSession", ) .mockReturnValue(context); const sendRequest = vi.spyOn( manager as unknown as { sendRequest: (...args: unknown[]) => Promise<unknown> }, "sendRequest", ); const updateSession = vi .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") .mockImplementation(() => {}); return { manager, context, requireSession, sendRequest, updateSession };} describe("classifyCodexStderrLine", () => { it("ignores empty lines", () => { expect(classifyCodexStderrLine(" ")).toBeNull(); }); it("ignores non-error structured codex logs", () => { const line = "2026-02-08T04:24:19.241256Z WARN codex_core::features: unknown feature key in config: skills"; expect(classifyCodexStderrLine(line)).toBeNull(); }); it("ignores known benign rollout path errors", () => { const line = "\u001b[2m2026-02-08T04:24:20.085687Z\u001b[0m \u001b[31mERROR\u001b[0m \u001b[2mcodex_core::rollout::list\u001b[0m: state db missing rollout path for thread 019c3b6c-46b8-7b70-ad23-82f824d161fb"; expect(classifyCodexStderrLine(line)).toBeNull(); }); it("keeps unknown structured errors", () => { const line = "2026-02-08T04:24:20.085687Z ERROR codex_core::runtime: unrecoverable failure"; expect(classifyCodexStderrLine(line)).toEqual({ message: line, }); }); it("keeps plain stderr messages", () => { const line = "fatal: permission denied"; expect(classifyCodexStderrLine(line)).toEqual({ message: line, }); });}); describe("normalizeCodexModelSlug", () => { it("maps 5.3 aliases to gpt-5.3-codex", () => { expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); }); it("prefers codex id when model differs", () => { expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe("gpt-5.3-codex"); }); it("keeps non-aliased models as-is", () => { expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2"); });}); describe("isRecoverableThreadResumeError", () => { it("matches not-found resume errors", () => { expect( isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")), ).toBe(true); }); it("ignores non-resume errors", () => { expect( isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")), ).toBe(false); }); it("ignores non-recoverable resume errors", () => { expect( isRecoverableThreadResumeError( new Error("thread/resume failed: timed out waiting for server"), ), ).toBe(false); });}); describe("startSession", () => { it("emits session/startFailed when resolving cwd throws before process launch", async () => { const manager = new CodexAppServerManager(); const events: Array<{ method: string; kind: string; message?: string }> = []; manager.on("event", (event) => { events.push({ method: event.method, kind: event.kind, ...(event.message ? { message: event.message } : {}), }); }); const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { throw new Error("cwd missing"); }); try { await expect( manager.startSession({ provider: "codex", }), ).rejects.toThrow("cwd missing"); expect(events).toHaveLength(1); expect(events[0]).toEqual({ method: "session/startFailed", kind: "error", message: "cwd missing", }); } finally { processCwd.mockRestore(); manager.stopAll(); } });}); describe("sendTurn", () => { it("sends text and image user input items to turn/start", async () => { const { manager, context, requireSession, sendRequest, updateSession } = createSendTurnHarness(); const result = await manager.sendTurn({ sessionId: asSessionId("sess_1"), input: "Inspect this image", attachments: [ { type: "image", name: "error.png", mimeType: "image/png", sizeBytes: 1_024, dataUrl: "data:image/png;base64,AAAA", }, ], model: "gpt-5.3", effort: "high", }); expect(result).toEqual({ threadId: "thread_1", turnId: "turn_1", resumeCursor: { threadId: "thread_1", }, }); expect(requireSession).toHaveBeenCalledWith("sess_1"); expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { threadId: "thread_1", input: [ { type: "text", text: "Inspect this image", text_elements: [], }, { type: "image", url: "data:image/png;base64,AAAA", }, ], model: "gpt-5.3-codex", effort: "high", }); expect(updateSession).toHaveBeenCalledWith(context, { status: "running", activeTurnId: "turn_1", resumeCursor: { threadId: "thread_1", }, }); }); it("supports image-only turns", async () => { const { manager, context, sendRequest } = createSendTurnHarness(); await manager.sendTurn({ sessionId: asSessionId("sess_1"), attachments: [ { type: "image", name: "diagram.png", mimeType: "image/png", sizeBytes: 256, dataUrl: "data:image/png;base64,BBBB", }, ], }); expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", { threadId: "thread_1", input: [ { type: "image", url: "data:image/png;base64,BBBB", }, ], }); }); it("rejects empty turn input", async () => { const { manager } = createSendTurnHarness(); await expect( manager.sendTurn({ sessionId: asSessionId("sess_1"), }), ).rejects.toThrow("Turn input must include text or attachments."); });}); describe("thread checkpoint control", () => { it("reads thread turns from thread/read", async () => { const { manager, context, requireSession, sendRequest } = createThreadControlHarness(); sendRequest.mockResolvedValue({ thread: { id: "thread_1", turns: [ { id: "turn_1", items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], }, ], }, }); const result = await manager.readThread(asSessionId("sess_1")); expect(requireSession).toHaveBeenCalledWith("sess_1"); expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { threadId: "thread_1", includeTurns: true, }); expect(result).toEqual({ threadId: "thread_1", turns: [ { id: "turn_1", items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], }, ], }); }); it("reads thread turns from flat thread/read responses", async () => { const { manager, context, sendRequest } = createThreadControlHarness(); sendRequest.mockResolvedValue({ threadId: "thread_1", turns: [ { id: "turn_1", items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], }, ], }); const result = await manager.readThread(asSessionId("sess_1")); expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { threadId: "thread_1", includeTurns: true, }); expect(result).toEqual({ threadId: "thread_1", turns: [ { id: "turn_1", items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], }, ], }); }); it("rolls back turns via thread/rollback and resets session running state", async () => { const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); sendRequest.mockResolvedValue({ thread: { id: "thread_1", turns: [], }, }); const result = await manager.rollbackThread(asSessionId("sess_1"), 2); expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { threadId: "thread_1", numTurns: 2, }); expect(updateSession).toHaveBeenCalledWith(context, { status: "ready", activeTurnId: undefined, }); expect(result).toEqual({ threadId: "thread_1", turns: [], }); });});
import { describe, expect, it, vi } from "vitest";
import { ProviderSessionId } from "@t3tools/contracts";
import {
CodexAppServerManager,
classifyCodexStderrLine,
isRecoverableThreadResumeError,
normalizeCodexModelSlug,
} from "./codexAppServerManager";
const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value);
function createSendTurnHarness() {
const manager = new CodexAppServerManager();
const context = {
session: {
sessionId: "sess_1",
provider: "codex",
status: "ready",
threadId: "thread_1",
createdAt: "2026-02-10T00:00:00.000Z",
updatedAt: "2026-02-10T00:00:00.000Z",
},
};
const requireSession = vi
.spyOn(
manager as unknown as { requireSession: (sessionId: string) => unknown },
"requireSession",
)
.mockReturnValue(context);
const sendRequest = vi
.spyOn(
manager as unknown as { sendRequest: (...args: unknown[]) => Promise<unknown> },
"sendRequest",
)
.mockResolvedValue({
turn: {
id: "turn_1",
},
});
const updateSession = vi
.spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession")
.mockImplementation(() => {});
return { manager, context, requireSession, sendRequest, updateSession };
}
function createThreadControlHarness() {
const manager = new CodexAppServerManager();
const context = {
session: {
sessionId: "sess_1",
provider: "codex",
status: "ready",
threadId: "thread_1",
createdAt: "2026-02-10T00:00:00.000Z",
updatedAt: "2026-02-10T00:00:00.000Z",
},
};
const requireSession = vi
.spyOn(
manager as unknown as { requireSession: (sessionId: string) => unknown },
"requireSession",
)
.mockReturnValue(context);
const sendRequest = vi.spyOn(
manager as unknown as { sendRequest: (...args: unknown[]) => Promise<unknown> },
"sendRequest",
);
const updateSession = vi
.spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession")
.mockImplementation(() => {});
return { manager, context, requireSession, sendRequest, updateSession };
}
describe("classifyCodexStderrLine", () => {
it("ignores empty lines", () => {
expect(classifyCodexStderrLine(" ")).toBeNull();
});
it("ignores non-error structured codex logs", () => {
const line =
"2026-02-08T04:24:19.241256Z WARN codex_core::features: unknown feature key in config: skills";
expect(classifyCodexStderrLine(line)).toBeNull();
});
it("ignores known benign rollout path errors", () => {
const line =
"\u001b[2m2026-02-08T04:24:20.085687Z\u001b[0m \u001b[31mERROR\u001b[0m \u001b[2mcodex_core::rollout::list\u001b[0m: state db missing rollout path for thread 019c3b6c-46b8-7b70-ad23-82f824d161fb";
expect(classifyCodexStderrLine(line)).toBeNull();
});
it("keeps unknown structured errors", () => {
const line = "2026-02-08T04:24:20.085687Z ERROR codex_core::runtime: unrecoverable failure";
expect(classifyCodexStderrLine(line)).toEqual({
message: line,
});
});
it("keeps plain stderr messages", () => {
const line = "fatal: permission denied";
expect(classifyCodexStderrLine(line)).toEqual({
message: line,
});
});
});
describe("normalizeCodexModelSlug", () => {
it("maps 5.3 aliases to gpt-5.3-codex", () => {
expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex");
expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex");
});
it("prefers codex id when model differs", () => {
expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe("gpt-5.3-codex");
});
it("keeps non-aliased models as-is", () => {
expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2");
});
});
describe("isRecoverableThreadResumeError", () => {
it("matches not-found resume errors", () => {
expect(
isRecoverableThreadResumeError(new Error("thread/resume failed: thread not found")),
).toBe(true);
});
it("ignores non-resume errors", () => {
expect(
isRecoverableThreadResumeError(new Error("thread/start failed: permission denied")),
).toBe(false);
});
it("ignores non-recoverable resume errors", () => {
expect(
isRecoverableThreadResumeError(
new Error("thread/resume failed: timed out waiting for server"),
),
).toBe(false);
});
});
describe("startSession", () => {
it("emits session/startFailed when resolving cwd throws before process launch", async () => {
const manager = new CodexAppServerManager();
const events: Array<{ method: string; kind: string; message?: string }> = [];
manager.on("event", (event) => {
events.push({
method: event.method,
kind: event.kind,
...(event.message ? { message: event.message } : {}),
});
});
const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => {
throw new Error("cwd missing");
});
try {
await expect(
manager.startSession({
provider: "codex",
}),
).rejects.toThrow("cwd missing");
expect(events).toHaveLength(1);
expect(events[0]).toEqual({
method: "session/startFailed",
kind: "error",
message: "cwd missing",
});
} finally {
processCwd.mockRestore();
manager.stopAll();
}
});
});
describe("sendTurn", () => {
it("sends text and image user input items to turn/start", async () => {
const { manager, context, requireSession, sendRequest, updateSession } =
createSendTurnHarness();
const result = await manager.sendTurn({
sessionId: asSessionId("sess_1"),
input: "Inspect this image",
attachments: [
{
type: "image",
name: "error.png",
mimeType: "image/png",
sizeBytes: 1_024,
dataUrl: "data:image/png;base64,AAAA",
},
],
model: "gpt-5.3",
effort: "high",
});
expect(result).toEqual({
threadId: "thread_1",
turnId: "turn_1",
resumeCursor: {
threadId: "thread_1",
},
});
expect(requireSession).toHaveBeenCalledWith("sess_1");
expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
threadId: "thread_1",
input: [
{
type: "text",
text: "Inspect this image",
text_elements: [],
},
{
type: "image",
url: "data:image/png;base64,AAAA",
},
],
model: "gpt-5.3-codex",
effort: "high",
});
expect(updateSession).toHaveBeenCalledWith(context, {
status: "running",
activeTurnId: "turn_1",
resumeCursor: {
threadId: "thread_1",
},
});
});
it("supports image-only turns", async () => {
const { manager, context, sendRequest } = createSendTurnHarness();
await manager.sendTurn({
sessionId: asSessionId("sess_1"),
attachments: [
{
type: "image",
name: "diagram.png",
mimeType: "image/png",
sizeBytes: 256,
dataUrl: "data:image/png;base64,BBBB",
},
],
});
expect(sendRequest).toHaveBeenCalledWith(context, "turn/start", {
threadId: "thread_1",
input: [
{
type: "image",
url: "data:image/png;base64,BBBB",
},
],
});
});
it("rejects empty turn input", async () => {
const { manager } = createSendTurnHarness();
await expect(
manager.sendTurn({
sessionId: asSessionId("sess_1"),
}),
).rejects.toThrow("Turn input must include text or attachments.");
});
});
describe("thread checkpoint control", () => {
it("reads thread turns from thread/read", async () => {
const { manager, context, requireSession, sendRequest } = createThreadControlHarness();
sendRequest.mockResolvedValue({
thread: {
id: "thread_1",
turns: [
{
id: "turn_1",
items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
},
],
},
});
const result = await manager.readThread(asSessionId("sess_1"));
expect(requireSession).toHaveBeenCalledWith("sess_1");
expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
threadId: "thread_1",
includeTurns: true,
});
expect(result).toEqual({
threadId: "thread_1",
turns: [
{
id: "turn_1",
items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
},
],
});
});
it("reads thread turns from flat thread/read responses", async () => {
const { manager, context, sendRequest } = createThreadControlHarness();
sendRequest.mockResolvedValue({
threadId: "thread_1",
turns: [
{
id: "turn_1",
items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
},
],
});
const result = await manager.readThread(asSessionId("sess_1"));
expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", {
threadId: "thread_1",
includeTurns: true,
});
expect(result).toEqual({
threadId: "thread_1",
turns: [
{
id: "turn_1",
items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }],
},
],
});
});
it("rolls back turns via thread/rollback and resets session running state", async () => {
const { manager, context, sendRequest, updateSession } = createThreadControlHarness();
sendRequest.mockResolvedValue({
thread: {
id: "thread_1",
turns: [],
},
});
const result = await manager.rollbackThread(asSessionId("sess_1"), 2);
expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", {
threadId: "thread_1",
numTurns: 2,
});
expect(updateSession).toHaveBeenCalledWith(context, {
status: "ready",
activeTurnId: undefined,
});
expect(result).toEqual({
threadId: "thread_1",
turns: [],
});
});
});