Allow exec callers to classify new threads (#40161)

## What changed

- Add a global `codex exec --thread-source <SOURCE>` option and propagate it to newly created and forked threads.
- Default the source to `user` when the option is omitted.
- Expose the classification as `threadSource` in the TypeScript SDK. It applies when a thread is first created and does not override the source when resuming an existing thread.

## Testing

- Cover CLI parsing and persisted metadata for new, resumed, and forked threads.
- Verify that the TypeScript SDK forwards `threadSource` only for new threads.

GitOrigin-RevId: 67a55a2b1f91b3a88f946c2af1c2a0989abb3130
This commit is contained in:
pakrym-oai
2026-08-23 00:21:53 +00:00
committed by copyberry
parent 8e649e3afa
commit a73485dc76
10 changed files with 89 additions and 12 deletions

View File

@@ -13,6 +13,8 @@ export type CodexExecArgs = {
baseUrl?: string;
apiKey?: string;
threadId?: string | null;
// --thread-source; only applies when creating a new thread
threadSource?: string;
images?: string[];
// --model
model?: string;
@@ -112,6 +114,10 @@ export class CodexExec {
commandArgs.push("--model", args.model);
}
if (args.threadSource !== undefined && !args.threadId) {
commandArgs.push("--thread-source", args.threadSource);
}
if (args.sandboxMode) {
commandArgs.push("--sandbox", args.sandboxMode);
}

View File

@@ -81,6 +81,7 @@ export class Thread {
threadId: this._id,
images,
model: options?.model,
threadSource: options?.threadSource,
sandboxMode: options?.sandboxMode,
workingDirectory: options?.workingDirectory,
skipGitRepoCheck: options?.skipGitRepoCheck,

View File

@@ -15,6 +15,8 @@ export type WebSearchMode = "disabled" | "cached" | "live";
export type ThreadOptions = {
model?: string;
/** Source classification applied when this thread is first created. */
threadSource?: string;
sandboxMode?: SandboxMode;
workingDirectory?: string;
skipGitRepoCheck?: boolean;

View File

@@ -185,6 +185,31 @@ describe("CodexExec", () => {
},
);
it("passes the thread source when starting a new thread", async () => {
const { CodexExec } = await import("../src/exec");
spawnMock.mockClear();
const child = new FakeChildProcess();
spawnMock.mockReturnValue(child as unknown as child_process.ChildProcess);
setImmediate(() => {
child.stdout.end();
child.stderr.end();
child.emit("exit", 0, null);
});
const exec = new CodexExec("codex");
for await (const _ of exec.run({ input: "hi", threadSource: "automated_review" })) {
// no-op
}
expect(spawnMock.mock.calls[0]?.[1]).toEqual([
"exec",
"--experimental-json",
"--thread-source",
"automated_review",
]);
});
it("lets SDK-managed and thread settings override raw configuration when resuming", async () => {
const { CodexExec } = await import("../src/exec");
spawnMock.mockClear();
@@ -205,6 +230,7 @@ describe("CodexExec", () => {
for await (const _ of exec.run({
input: "resume with overrides",
threadId: "thread-id",
threadSource: "should_not_override",
baseUrl: "https://managed.example.test",
approvalPolicy: "on-request",
networkAccessEnabled: false,

View File

@@ -202,6 +202,7 @@ describe("Codex", () => {
try {
const thread = client.startThread({
model: "gpt-test-1",
threadSource: "automated_review",
sandboxMode: "workspace-write",
});
await thread.run("apply options");
@@ -216,6 +217,11 @@ describe("Codex", () => {
expectPair(commandArgs, ["--sandbox", "workspace-write"]);
expectPair(commandArgs, ["--model", "gpt-test-1"]);
expectPair(commandArgs, ["--thread-source", "automated_review"]);
const metadata = JSON.parse(payload!.headers["x-codex-turn-metadata"] as string) as {
thread_source?: string;
};
expect(metadata.thread_source).toBe("automated_review");
} finally {
cleanup();
restore();