Add some types and a basic test to the SDK (#4472)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
173
sdk/typescript/tests/responsesProxy.ts
Normal file
173
sdk/typescript/tests/responsesProxy.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
133
sdk/typescript/tests/run.test.ts
Normal file
133
sdk/typescript/tests/run.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
167
sdk/typescript/tests/runStreamed.test.ts
Normal file
167
sdk/typescript/tests/runStreamed.test.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user