Store settings on the thread instead of turn (#4579)

It's much more common to keep the same settings for the entire
conversation, we can add per-turn overrides later.
This commit is contained in:
pakrym-oai
2025-10-01 17:31:13 -07:00
committed by GitHub
parent e899ae7d8a
commit c07fb71186
8 changed files with 56 additions and 32 deletions

View File

@@ -51,3 +51,21 @@ const result = await thread.run("Implement the fix");
console.log(result); console.log(result);
``` ```
### Working directory
By default, Codex will run in the current working directory. You can change the working directory by passing the `workingDirectory` option to the when creating a thread.
```typescript
const thread = codex.startThread({
workingDirectory: "/path/to/working/directory",
});
```
To avoid unrecoverable errors, Codex requires the working directory to be a git repository. You can skip the git repository check by passing the `skipGitRepoCheck` option to the when creating a thread.
```typescript
const thread = codex.startThread({
skipGitRepoCheck: true,
});
```

View File

@@ -1,6 +1,7 @@
import { CodexOptions } from "./codexOptions"; import { CodexOptions } from "./codexOptions";
import { CodexExec } from "./exec"; import { CodexExec } from "./exec";
import { Thread } from "./thread"; import { Thread } from "./thread";
import { ThreadOptions } from "./threadOptions";
/** /**
* Codex is the main class for interacting with the Codex agent. * Codex is the main class for interacting with the Codex agent.
@@ -20,8 +21,8 @@ export class Codex {
* Starts a new conversation with an agent. * Starts a new conversation with an agent.
* @returns A new thread instance. * @returns A new thread instance.
*/ */
startThread(): Thread { startThread(options: ThreadOptions = {}): Thread {
return new Thread(this.exec, this.options); return new Thread(this.exec, this.options, options);
} }
/** /**
@@ -31,7 +32,7 @@ export class Codex {
* @param id The id of the thread to resume. * @param id The id of the thread to resume.
* @returns A new thread instance. * @returns A new thread instance.
*/ */
resumeThread(id: string): Thread { resumeThread(id: string, options: ThreadOptions = {}): Thread {
return new Thread(this.exec, this.options, id); return new Thread(this.exec, this.options, options, id);
} }
} }

View File

@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import readline from "node:readline"; import readline from "node:readline";
import { SandboxMode } from "./turnOptions"; import { SandboxMode } from "./threadOptions";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";

View File

@@ -29,4 +29,4 @@ export { Codex } from "./codex";
export type { CodexOptions } from "./codexOptions"; export type { CodexOptions } from "./codexOptions";
export type { TurnOptions, ApprovalMode, SandboxMode } from "./turnOptions"; export type { ThreadOptions as TheadOptions, ApprovalMode, SandboxMode } from "./threadOptions";

View File

@@ -2,7 +2,7 @@ import { CodexOptions } from "./codexOptions";
import { ThreadEvent } from "./events"; import { ThreadEvent } from "./events";
import { CodexExec } from "./exec"; import { CodexExec } from "./exec";
import { ThreadItem } from "./items"; import { ThreadItem } from "./items";
import { TurnOptions } from "./turnOptions"; import { ThreadOptions } from "./threadOptions";
/** Completed turn. */ /** Completed turn. */
export type Turn = { export type Turn = {
@@ -29,27 +29,33 @@ export class Thread {
private _exec: CodexExec; private _exec: CodexExec;
private _options: CodexOptions; private _options: CodexOptions;
private _id: string | null; private _id: string | null;
private _threadOptions: ThreadOptions;
/** Returns the ID of the thread. Populated after the first turn starts. */ /** Returns the ID of the thread. Populated after the first turn starts. */
public get id(): string | null { public get id(): string | null {
return this._id; return this._id;
} }
constructor(exec: CodexExec, options: CodexOptions, id: string | null = null) { /* @internal */
constructor(
exec: CodexExec,
options: CodexOptions,
threadOptions: ThreadOptions,
id: string | null = null,
) {
this._exec = exec; this._exec = exec;
this._options = options; this._options = options;
this._id = id; this._id = id;
this._threadOptions = threadOptions;
} }
/** Provides the input to the agent and streams events as they are produced during the turn. */ /** Provides the input to the agent and streams events as they are produced during the turn. */
async runStreamed(input: string, options?: TurnOptions): Promise<StreamedTurn> { async runStreamed(input: string): Promise<StreamedTurn> {
return { events: this.runStreamedInternal(input, options) }; return { events: this.runStreamedInternal(input) };
} }
private async *runStreamedInternal( private async *runStreamedInternal(input: string): AsyncGenerator<ThreadEvent> {
input: string, const options = this._threadOptions;
options?: TurnOptions,
): AsyncGenerator<ThreadEvent> {
const generator = this._exec.run({ const generator = this._exec.run({
input, input,
baseUrl: this._options.baseUrl, baseUrl: this._options.baseUrl,
@@ -75,8 +81,8 @@ export class Thread {
} }
/** Provides the input to the agent and returns the completed turn. */ /** Provides the input to the agent and returns the completed turn. */
async run(input: string, options?: TurnOptions): Promise<Turn> { async run(input: string): Promise<Turn> {
const generator = this.runStreamedInternal(input, options); const generator = this.runStreamedInternal(input);
const items: ThreadItem[] = []; const items: ThreadItem[] = [];
let finalResponse: string = ""; let finalResponse: string = "";
for await (const event of generator) { for await (const event of generator) {

View File

@@ -2,7 +2,7 @@ export type ApprovalMode = "never" | "on-request" | "on-failure" | "untrusted";
export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"; export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
export type TurnOptions = { export type ThreadOptions = {
model?: string; model?: string;
sandboxMode?: SandboxMode; sandboxMode?: SandboxMode;
workingDirectory?: string; workingDirectory?: string;

View File

@@ -5,7 +5,8 @@ jest.mock("node:child_process", () => {
return { ...actual, spawn: jest.fn(actual.spawn) }; return { ...actual, spawn: jest.fn(actual.spawn) };
}); });
const actualChildProcess = jest.requireActual<typeof import("node:child_process")>("node:child_process"); const actualChildProcess =
jest.requireActual<typeof import("node:child_process")>("node:child_process");
const spawnMock = child_process.spawn as jest.MockedFunction<typeof actualChildProcess.spawn>; const spawnMock = child_process.spawn as jest.MockedFunction<typeof actualChildProcess.spawn>;
export function codexExecSpy(): { args: string[][]; restore: () => void } { export function codexExecSpy(): { args: string[][]; restore: () => void } {

View File

@@ -109,9 +109,7 @@ describe("Codex", () => {
const thread = client.startThread(); const thread = client.startThread();
await thread.run("first input"); await thread.run("first input");
await thread.run("second input", { await thread.run("second input");
model: "gpt-test-1",
});
// Check second request continues the same thread // Check second request continues the same thread
expect(requests.length).toBeGreaterThanOrEqual(2); expect(requests.length).toBeGreaterThanOrEqual(2);
@@ -119,7 +117,7 @@ describe("Codex", () => {
expect(secondRequest).toBeDefined(); expect(secondRequest).toBeDefined();
const payload = secondRequest!.json; const payload = secondRequest!.json;
expect(payload.model).toBe("gpt-test-1"); expect(payload.input.at(-1)!.content![0]!.text).toBe("second input");
const assistantEntry = payload.input.find( const assistantEntry = payload.input.find(
(entry: { role: string }) => entry.role === "assistant", (entry: { role: string }) => entry.role === "assistant",
); );
@@ -197,11 +195,11 @@ describe("Codex", () => {
try { try {
const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" });
const thread = client.startThread(); const thread = client.startThread({
await thread.run("apply options", {
model: "gpt-test-1", model: "gpt-test-1",
sandboxMode: "workspace-write", sandboxMode: "workspace-write",
}); });
await thread.run("apply options");
const payload = requests[0]; const payload = requests[0];
expect(payload).toBeDefined(); expect(payload).toBeDefined();
@@ -240,11 +238,11 @@ describe("Codex", () => {
apiKey: "test", apiKey: "test",
}); });
const thread = client.startThread(); const thread = client.startThread({
await thread.run("use custom working directory", {
workingDirectory, workingDirectory,
skipGitRepoCheck: true, skipGitRepoCheck: true,
}); });
await thread.run("use custom working directory");
const commandArgs = spawnArgs[0]; const commandArgs = spawnArgs[0];
expectPair(commandArgs, ["--cd", workingDirectory]); expectPair(commandArgs, ["--cd", workingDirectory]);
@@ -274,12 +272,12 @@ describe("Codex", () => {
apiKey: "test", apiKey: "test",
}); });
const thread = client.startThread(); const thread = client.startThread({
await expect( workingDirectory,
thread.run("use custom working directory", { });
workingDirectory, await expect(thread.run("use custom working directory")).rejects.toThrow(
}), /Not inside a trusted directory/,
).rejects.toThrow(/Not inside a trusted directory/); );
} finally { } finally {
await close(); await close();
} }