Add some types and a basic test to the SDK (#4472)

This commit is contained in:
pakrym-oai
2025-09-29 19:40:08 -07:00
committed by GitHub
parent 079303091f
commit 52e591ce60
19 changed files with 3072 additions and 783 deletions

View File

@@ -1,9 +0,0 @@
import { describe, expect, it } from "vitest";
import { Codex } from "../src/index.js";
describe("Codex", () => {
it("exposes the placeholder API", () => {
const client = new Codex();
});
});

View File

@@ -0,0 +1,173 @@
import http from "node:http";
const DEFAULT_RESPONSE_ID = "resp_mock";
const DEFAULT_MESSAGE_ID = "msg_mock";
type SseEvent = {
type: string;
[key: string]: unknown;
};
type SseResponseBody = {
kind: "sse";
events: SseEvent[];
};
export type ResponsesProxyOptions = {
responseBodies: SseResponseBody[];
statusCode?: number;
};
export type ResponsesProxy = {
url: string;
close: () => Promise<void>;
requests: RecordedRequest[];
};
export type ResponsesApiRequest = {
input: Array<{
role: string;
content?: Array<{ type: string; text: string }>;
}>;
};
export type RecordedRequest = {
body: string;
json: ResponsesApiRequest;
};
function formatSseEvent(event: SseEvent): string {
return `event: ${event.type}\n` + `data: ${JSON.stringify(event)}\n\n`;
}
export async function startResponsesTestProxy(
options: ResponsesProxyOptions,
): Promise<ResponsesProxy> {
const responseBodies = options.responseBodies;
if (responseBodies.length === 0) {
throw new Error("responseBodies is required");
}
const requests: RecordedRequest[] = [];
function readRequestBody(req: http.IncomingMessage): Promise<string> {
return new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
});
req.on("end", () => {
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", reject);
});
}
let responseIndex = 0;
const server = http.createServer((req, res) => {
async function handle(): Promise<void> {
if (req.method === "POST" && req.url === "/responses") {
const body = await readRequestBody(req);
const json = JSON.parse(body);
requests.push({ body, json });
const status = options.statusCode ?? 200;
res.statusCode = status;
res.setHeader("content-type", "text/event-stream");
const responseBody = responseBodies[Math.min(responseIndex, responseBodies.length - 1)]!;
responseIndex += 1;
for (const event of responseBody.events) {
res.write(formatSseEvent(event));
}
res.end();
return;
}
res.statusCode = 404;
res.end();
}
handle().catch(() => {
res.statusCode = 500;
res.end();
});
});
const url = await new Promise<string>((resolve, reject) => {
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Unable to determine proxy address"));
return;
}
server.off("error", reject);
const info = address;
resolve(`http://${info.address}:${info.port}`);
});
server.once("error", reject);
});
async function close(): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
return { url, close, requests };
}
export function sse(...events: SseEvent[]): SseResponseBody {
return {
kind: "sse",
events,
};
}
export function responseStarted(responseId: string = DEFAULT_RESPONSE_ID): SseEvent {
return {
type: "response.created",
response: {
id: responseId,
},
};
}
export function assistantMessage(text: string, itemId: string = DEFAULT_MESSAGE_ID): SseEvent {
return {
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id: itemId,
content: [
{
type: "output_text",
text,
},
],
},
};
}
export function responseCompleted(responseId: string = DEFAULT_RESPONSE_ID): SseEvent {
return {
type: "response.completed",
response: {
id: responseId,
usage: {
input_tokens: 0,
input_tokens_details: null,
output_tokens: 0,
output_tokens_details: null,
total_tokens: 0,
},
},
};
}

View File

@@ -0,0 +1,133 @@
import path from "path";
import { describe, expect, it } from "@jest/globals";
import { Codex } from "../src/codex";
import {
assistantMessage,
responseCompleted,
responseStarted,
sse,
startResponsesTestProxy,
} from "./responsesProxy";
const codexExecPath = path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex");
describe("Codex", () => {
it("returns session events", async () => {
const { url, close } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread();
const result = await thread.run("Hello, world!");
const expectedItems = [
{
id: expect.any(String),
item_type: "assistant_message",
text: "Hi!",
},
];
expect(result.items).toEqual(expectedItems);
expect(thread.id).toEqual(expect.any(String));
} finally {
await close();
}
});
it("sends previous items when run is called twice", async () => {
const { url, close, requests } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("First response", "item_1"),
responseCompleted("response_1"),
),
sse(
responseStarted("response_2"),
assistantMessage("Second response", "item_2"),
responseCompleted("response_2"),
),
],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread();
await thread.run("first input");
await thread.run("second input");
// Check second request continues conversation
expect(requests.length).toBeGreaterThanOrEqual(2);
const secondRequest = requests[1];
expect(secondRequest).toBeDefined();
const payload = secondRequest!.json;
const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant",
);
expect(assistantEntry).toBeDefined();
const assistantText = assistantEntry?.content?.find(
(item: { type: string; text: string }) => item.type === "output_text",
)?.text;
expect(assistantText).toBe("First response");
} finally {
await close();
}
});
it("resumes thread by id", async () => {
const { url, close, requests } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("First response", "item_1"),
responseCompleted("response_1"),
),
sse(
responseStarted("response_2"),
assistantMessage("Second response", "item_2"),
responseCompleted("response_2"),
),
],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const originalThread = client.startThread();
await originalThread.run("first input");
const resumedThread = client.resumeThread(originalThread.id!);
const result = await resumedThread.run("second input");
expect(resumedThread.id).toBe(originalThread.id);
expect(result.finalResponse).toBe("Second response");
expect(requests.length).toBeGreaterThanOrEqual(2);
const secondRequest = requests[1];
expect(secondRequest).toBeDefined();
const payload = secondRequest!.json;
const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant",
);
expect(assistantEntry).toBeDefined();
const assistantText = assistantEntry?.content?.find(
(item: { type: string; text: string }) => item.type === "output_text",
)?.text;
expect(assistantText).toBe("First response");
} finally {
await close();
}
});
});

View File

@@ -0,0 +1,167 @@
import path from "path";
import { describe, expect, it } from "@jest/globals";
import { Codex } from "../src/codex";
import { ConversationEvent } from "../src/index";
import {
assistantMessage,
responseCompleted,
responseStarted,
sse,
startResponsesTestProxy,
} from "./responsesProxy";
const codexExecPath = path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex");
describe("Codex", () => {
it("returns session events", async () => {
const { url, close } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread();
const result = await thread.runStreamed("Hello, world!");
const events: ConversationEvent[] = [];
for await (const event of result.events) {
events.push(event);
}
expect(events).toEqual([
{
type: "session.created",
session_id: expect.any(String),
},
{
type: "turn.started",
},
{
type: "item.completed",
item: {
id: "item_0",
item_type: "assistant_message",
text: "Hi!",
},
},
{
type: "turn.completed",
usage: {
cached_input_tokens: 0,
input_tokens: 0,
output_tokens: 0,
},
},
]);
expect(thread.id).toEqual(expect.any(String));
} finally {
await close();
}
});
it("sends previous items when runStreamed is called twice", async () => {
const { url, close, requests } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("First response", "item_1"),
responseCompleted("response_1"),
),
sse(
responseStarted("response_2"),
assistantMessage("Second response", "item_2"),
responseCompleted("response_2"),
),
],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread();
const first = await thread.runStreamed("first input");
await drainEvents(first.events);
const second = await thread.runStreamed("second input");
await drainEvents(second.events);
// Check second request continues conversation
expect(requests.length).toBeGreaterThanOrEqual(2);
const secondRequest = requests[1];
expect(secondRequest).toBeDefined();
const payload = secondRequest!.json;
const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant",
);
expect(assistantEntry).toBeDefined();
const assistantText = assistantEntry?.content?.find(
(item: { type: string; text: string }) => item.type === "output_text",
)?.text;
expect(assistantText).toBe("First response");
} finally {
await close();
}
});
it("resumes thread by id when streaming", async () => {
const { url, close, requests } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("First response", "item_1"),
responseCompleted("response_1"),
),
sse(
responseStarted("response_2"),
assistantMessage("Second response", "item_2"),
responseCompleted("response_2"),
),
],
});
try {
const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" });
const originalThread = client.startThread();
const first = await originalThread.runStreamed("first input");
await drainEvents(first.events);
const resumedThread = client.resumeThread(originalThread.id!);
const second = await resumedThread.runStreamed("second input");
await drainEvents(second.events);
expect(resumedThread.id).toBe(originalThread.id);
expect(requests.length).toBeGreaterThanOrEqual(2);
const secondRequest = requests[1];
expect(secondRequest).toBeDefined();
const payload = secondRequest!.json;
const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant",
);
expect(assistantEntry).toBeDefined();
const assistantText = assistantEntry?.content?.find(
(item: { type: string; text: string }) => item.type === "output_text",
)?.text;
expect(assistantText).toBe("First response");
} finally {
await close();
}
});
});
async function drainEvents(events: AsyncGenerator<ConversationEvent>): Promise<void> {
let done = false;
do {
done = (await events.next()).done ?? false;
} while (!done);
}