diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 0369f867..010f723f 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -1,12 +1,16 @@ import { spawn } from "child_process"; import readline from "node:readline"; +import { SandboxMode } from "./turnOptions"; + export type CodexExecArgs = { input: string; baseUrl?: string; apiKey?: string; threadId?: string | null; + model?: string; + sandboxMode?: SandboxMode; }; export class CodexExec { @@ -17,6 +21,15 @@ export class CodexExec { async *run(args: CodexExecArgs): AsyncGenerator { const commandArgs: string[] = ["exec", "--experimental-json"]; + + if (args.model) { + commandArgs.push("--model", args.model); + } + + if (args.sandboxMode) { + commandArgs.push("--sandbox", args.sandboxMode); + } + if (args.threadId) { commandArgs.push("resume", args.threadId, args.input); } else { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 02bbfa1b..93557fb6 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -27,3 +27,5 @@ export type { Thread, RunResult, RunStreamedResult, Input } from "./thread"; export type { Codex } from "./codex"; export type { CodexOptions } from "./codexOptions"; + +export type { TurnOptions, ApprovalMode, SandboxMode } from "./turnOptions"; diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index cd19a942..3e380e0a 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -2,6 +2,7 @@ import { CodexOptions } from "./codexOptions"; import { ThreadEvent } from "./events"; import { CodexExec } from "./exec"; import { ThreadItem } from "./items"; +import { TurnOptions } from "./turnOptions"; export type RunResult = { items: ThreadItem[]; @@ -25,16 +26,21 @@ export class Thread { this.id = id; } - async runStreamed(input: string): Promise { - return { events: this.runStreamedInternal(input) }; + async runStreamed(input: string, options?: TurnOptions): Promise { + return { events: this.runStreamedInternal(input, options) }; } - private async *runStreamedInternal(input: string): AsyncGenerator { + private async *runStreamedInternal( + input: string, + options?: TurnOptions, + ): AsyncGenerator { const generator = this.exec.run({ input, baseUrl: this.options.baseUrl, apiKey: this.options.apiKey, threadId: this.id, + model: options?.model, + sandboxMode: options?.sandboxMode, }); for await (const item of generator) { const parsed = JSON.parse(item) as ThreadEvent; @@ -45,8 +51,8 @@ export class Thread { } } - async run(input: string): Promise { - const generator = this.runStreamedInternal(input); + async run(input: string, options?: TurnOptions): Promise { + const generator = this.runStreamedInternal(input, options); const items: ThreadItem[] = []; let finalResponse: string = ""; for await (const event of generator) { diff --git a/sdk/typescript/src/turnOptions.ts b/sdk/typescript/src/turnOptions.ts new file mode 100644 index 00000000..c414334d --- /dev/null +++ b/sdk/typescript/src/turnOptions.ts @@ -0,0 +1,8 @@ +export type ApprovalMode = "never" | "on-request" | "on-failure" | "untrusted"; + +export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"; + +export type TurnOptions = { + model?: string; + sandboxMode?: SandboxMode; +}; diff --git a/sdk/typescript/tests/codexExecSpy.ts b/sdk/typescript/tests/codexExecSpy.ts new file mode 100644 index 00000000..9028b6e6 --- /dev/null +++ b/sdk/typescript/tests/codexExecSpy.ts @@ -0,0 +1,29 @@ +import * as child_process from "child_process"; + +jest.mock("child_process", () => { + const actual = jest.requireActual("child_process"); + return { ...actual, spawn: jest.fn(actual.spawn) }; +}); + +const actualChildProcess = jest.requireActual("child_process"); +const spawnMock = child_process.spawn as jest.MockedFunction; + +export function codexExecSpy(): { args: string[][]; restore: () => void } { + const previousImplementation = + spawnMock.getMockImplementation() ?? actualChildProcess.spawn; + const args: string[][] = []; + + spawnMock.mockImplementation(((...spawnArgs: Parameters) => { + const commandArgs = spawnArgs[1]; + args.push(Array.isArray(commandArgs) ? [...commandArgs] : []); + return previousImplementation(...spawnArgs); + }) as typeof actualChildProcess.spawn); + + return { + args, + restore: () => { + spawnMock.mockClear(); + spawnMock.mockImplementation(previousImplementation); + }, + }; +} diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index 7f6c57c2..093adee4 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -1,5 +1,6 @@ import path from "path"; +import { codexExecSpy } from "./codexExecSpy"; import { describe, expect, it } from "@jest/globals"; import { Codex } from "../src/codex"; @@ -130,4 +131,56 @@ describe("Codex", () => { await close(); } }); + + it("passes turn options to exec", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Turn options applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run("apply options", { + model: "gpt-test-1", + sandboxMode: "workspace-write", + }); + + const payload = requests[0]; + expect(payload).toBeDefined(); + const json = payload!.json as { model?: string } | undefined; + + expect(json?.model).toBe("gpt-test-1"); + expect(spawnArgs.length).toBeGreaterThan(0); + const commandArgs = spawnArgs[0]; + + expectPair(commandArgs, ["--sandbox", "workspace-write"]); + expectPair(commandArgs, ["--model", "gpt-test-1"]); + + } finally { + restore(); + await close(); + } + }); }); + + +function expectPair(args: string[] | undefined, pair: [string, string]) { + if (!args) { + throw new Error("Args is undefined"); + } + const index = args.indexOf(pair[0]); + if (index === -1) { + throw new Error(`Pair ${pair[0]} not found in args`); + } + expect(args[index + 1]).toBe(pair[1]); +}