diff --git a/packages/agent-client/src/codex-extract.ts b/packages/agent-client/src/codex-extract.ts new file mode 100644 index 0000000..98b665f --- /dev/null +++ b/packages/agent-client/src/codex-extract.ts @@ -0,0 +1,59 @@ +// Small, dependency-free extractors for reading ids/strings out of loosely-typed +// Codex App Server responses. Ported verbatim from PwrSnap/PwrAgent's in-tree +// Codex client so the kit's native methods (steer / compact / review) resolve +// thread/turn ids with the same tolerance for snake_case / nested `turn` shapes. + +export function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +export function pickString( + record: Record, + keys: string[] +): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed) return trimmed; + } + return undefined; +} + +export function extractThreadIdFromValue(value: unknown): string | undefined { + const record = asRecord(value); + if (!record) return undefined; + const threadRecord = asRecord(record.thread) ?? asRecord(record.session); + return ( + pickString(record, ["threadId", "thread_id", "conversationId", "conversation_id"]) ?? + pickString(threadRecord ?? {}, ["id", "threadId", "thread_id", "conversationId"]) + ); +} + +export function extractTurnIdFromValue(value: unknown): string | undefined { + const record = asRecord(value); + if (!record) return undefined; + const turnRecord = asRecord(record.turn); + return ( + pickString(record, ["turnId", "turn_id", "runId", "run_id"]) ?? + pickString(turnRecord ?? {}, ["id", "turnId", "turn_id", "runId", "run_id"]) + ); +} + +export function extractStringProperty( + value: unknown, + ...keys: string[] +): string | undefined { + const record = asRecord(value); + if (!record) return undefined; + for (const key of keys) { + const candidate = record[key]; + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + return undefined; +} diff --git a/packages/agent-client/src/codex-thread-client.ts b/packages/agent-client/src/codex-thread-client.ts index d8131d9..4cb8b44 100644 --- a/packages/agent-client/src/codex-thread-client.ts +++ b/packages/agent-client/src/codex-thread-client.ts @@ -43,7 +43,11 @@ import type { AskForApproval, DynamicToolCallResponse, DynamicToolSpec, + ReviewDelivery, + ReviewStartParams, + ReviewTarget, SandboxMode, + ThreadCompactStartParams, ThreadForkParams, ThreadForkResponse, ThreadResumeParams, @@ -52,6 +56,7 @@ import type { ThreadStartResponse, TurnStartParams, TurnStartResponse, + TurnSteerParams, UserInput } from "@pwrdrvr/codex-app-server-protocol/v2"; import { @@ -59,6 +64,11 @@ import { CODEX_TOOL_CALL_METHOD, normalizeNotification } from "./normalize"; +import { + extractStringProperty, + extractThreadIdFromValue, + extractTurnIdFromValue +} from "./codex-extract"; export type CodexThreadClientTransportFactory = (command: string) => JsonRpcTransport; @@ -398,6 +408,140 @@ export class CodexThreadClient implements AgentBackend { await connection.request("turn/interrupt", { threadId }, this.requestTimeoutMs); } + /** + * Steer the in-flight turn (Codex `turn/steer`): inject new input that the + * model folds into the active turn. `expectedTurnId` is the precondition — + * Codex rejects the call if it isn't the currently active turn. The steered + * turn's output streams through the normal `onEvent` notification path; the + * returned `turnId` is the active turn (falls back to `expectedTurnId`). + */ + async steerTurn(opts: { + threadId: string; + input: UserInput[]; + expectedTurnId: string; + }): Promise<{ threadId: string; turnId: string }> { + await this.resumeThread(opts.threadId).catch(() => undefined); + const connection = await this.getConnection(); + const params: TurnSteerParams = { + threadId: opts.threadId, + input: opts.input, + expectedTurnId: opts.expectedTurnId + }; + const result = await connection.request("turn/steer", params, this.requestTimeoutMs); + return { + threadId: opts.threadId, + turnId: extractTurnIdFromValue(result) ?? opts.expectedTurnId + }; + } + + /** + * Compact the thread's history into a summary (Codex `thread/compact/start`), + * shrinking the context window. Emits a compaction turn whose progress streams + * through `onEvent`. Returns the produced turn (synthesizing a stable id when + * Codex omits one) and the optional summary item id. + */ + async compactThread( + threadId: string + ): Promise<{ threadId: string; turnId: string; itemId?: string }> { + await this.resumeThread(threadId).catch(() => undefined); + const connection = await this.getConnection(); + const params: ThreadCompactStartParams = { threadId }; + const result = await connection.request( + "thread/compact/start", + params, + this.requestTimeoutMs + ); + const resolvedThreadId = extractThreadIdFromValue(result) ?? threadId; + const turnId = extractTurnIdFromValue(result) ?? `compact:${resolvedThreadId}`; + const itemId = extractStringProperty(result, "itemId", "item_id"); + return itemId !== undefined + ? { threadId: resolvedThreadId, turnId, itemId } + : { threadId: resolvedThreadId, turnId }; + } + + /** + * Start a code review (Codex `review/start`) over the given target. Inline + * delivery runs the review on the current thread; detached delivery spins up a + * new review thread (returned as `reviewThreadId`). Review output streams + * through `onEvent`. + */ + async startReview(opts: { + threadId: string; + target: ReviewTarget; + delivery?: ReviewDelivery; + cwd?: string; + }): Promise<{ threadId: string; reviewThreadId: string; turnId: string }> { + await this.resumeThread(opts.threadId).catch(() => undefined); + const connection = await this.getConnection(); + const params: ReviewStartParams = { + threadId: opts.threadId, + target: opts.target, + delivery: opts.delivery ?? "inline" + }; + const result = await connection.request("review/start", params, this.requestTimeoutMs); + const reviewThreadId = + extractStringProperty(result, "reviewThreadId", "review_thread_id") ?? opts.threadId; + const turnId = extractTurnIdFromValue(result) ?? `pending:${reviewThreadId}`; + return { threadId: opts.threadId, reviewThreadId, turnId }; + } + + /** + * Re-apply per-thread permissions / settings by resuming with an overlay + * (Codex `thread/resume`). Used when the host changes model, approval policy, + * sandbox, service tier, or reasoning effort mid-thread. + */ + async setThreadPermissions(opts: { + threadId: string; + cwd?: string; + model?: string; + approvalPolicy?: string; + sandbox?: string; + serviceTier?: string; + reasoningEffort?: string; + }): Promise<{ threadId: string }> { + const connection = await this.getConnection(); + await this.initialize(); + const params: ThreadResumeParams = { + threadId: opts.threadId, + persistExtendedHistory: false + }; + if (opts.cwd !== undefined) params.cwd = opts.cwd; + if (opts.model !== undefined) params.model = opts.model; + if (opts.approvalPolicy !== undefined) { + params.approvalPolicy = opts.approvalPolicy as AskForApproval; + } + if (opts.sandbox !== undefined) params.sandbox = opts.sandbox as SandboxMode; + if (opts.serviceTier !== undefined) params.serviceTier = opts.serviceTier; + // Reasoning effort is a per-TURN setting in Codex (turn/start `effort`), not a + // thread/resume field — matching the in-tree client, resume doesn't carry it. + const result = await connection.request("thread/resume", params, this.requestTimeoutMs); + this.loadedThreadIds.add(opts.threadId); + return { threadId: extractThreadIdFromValue(result) ?? opts.threadId }; + } + + /** + * Mark a project directory trusted in Codex config (`config/value/write`), + * so subsequent threads in that directory skip the trust prompt. + */ + async trustProject(opts: { + projectPath: string; + configPath?: string; + }): Promise<{ projectPath: string; configPath?: string }> { + const projectPath = opts.projectPath.trim(); + if (!projectPath) throw new Error("projectPath is required"); + const connection = await this.getConnection(); + await this.initialize(); + const filePath = opts.configPath?.trim(); + const payload = { + keyPath: "projects", + value: { [projectPath]: { trust_level: "trusted" } }, + mergeStrategy: "upsert", + ...(filePath ? { filePath } : {}) + }; + await connection.request("config/value/write", payload, this.requestTimeoutMs); + return filePath ? { projectPath, configPath: filePath } : { projectPath }; + } + async archiveThread(threadId: string): Promise { const connection = await this.getConnection(); await connection.request("thread/archive", { threadId }, this.requestTimeoutMs); diff --git a/packages/agent-client/test/codex-thread-client-features.test.ts b/packages/agent-client/test/codex-thread-client-features.test.ts new file mode 100644 index 0000000..8f35d69 --- /dev/null +++ b/packages/agent-client/test/codex-thread-client-features.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from "vitest"; +import type { JsonRpcTransport } from "@pwrdrvr/agent-transport"; +import { CodexThreadClient } from "../src/codex-thread-client"; + +/** + * Fake transport answering the feature wire methods (turn/steer, + * thread/compact/start, review/start, config/value/write) on top of the + * lifecycle basics, so the ported native methods can be driven without a live + * Codex. Each feature response carries the id shapes the extractors read. + */ +class FeatureFakeTransport implements JsonRpcTransport { + sent: Array<{ method?: string; id?: unknown; params?: unknown }> = []; + private messageHandler: (message: string) => void = () => undefined; + private closeHandler: (error?: Error) => void = () => undefined; + + async connect(): Promise {} + async close(): Promise { + this.closeHandler(); + } + setMessageHandler(handler: (message: string) => void): void { + this.messageHandler = handler; + } + setCloseHandler(handler: (error?: Error) => void): void { + this.closeHandler = handler; + } + + send(message: string): void { + const env = JSON.parse(message) as { method?: string; id?: unknown; params?: unknown }; + this.sent.push(env); + if (env.id === undefined || env.method === undefined) return; + const result = this.respond(env.method); + queueMicrotask(() => { + this.messageHandler(JSON.stringify({ jsonrpc: "2.0", id: env.id, result })); + }); + } + + private respond(method: string): unknown { + switch (method) { + case "initialize": + return { userAgent: "fake/1.0", capabilities: {} }; + case "thread/start": + return { + thread: { id: "thread-1" }, + model: "gpt-5-codex", + modelProvider: "openai", + serviceTier: "default" + }; + case "thread/resume": + return { thread: { id: "thread-1" } }; + case "turn/start": + return { turn: { id: "turn-1" } }; + case "turn/steer": + return { turn: { id: "turn-7" } }; + case "thread/compact/start": + return { threadId: "thread-1", turn: { id: "turn-compact" }, itemId: "item-9" }; + case "review/start": + return { reviewThreadId: "review-thread-2", turn: { id: "turn-review" } }; + case "config/value/write": + return {}; + default: + return {}; + } + } + + paramsFor(method: string): Record | undefined { + return this.sent.find((e) => e.method === method)?.params as + | Record + | undefined; + } +} + +describe("CodexThreadClient — ported features", () => { + it("steerTurn sends turn/steer with the precondition and resolves the active turn", async () => { + const fake = new FeatureFakeTransport(); + const client = new CodexThreadClient({ transportFactory: () => fake }); + await client.startThread(); + + const result = await client.steerTurn({ + threadId: "thread-1", + input: [{ type: "text", text: "actually, focus on tests", text_elements: [] }], + expectedTurnId: "turn-1" + }); + + expect(result).toEqual({ threadId: "thread-1", turnId: "turn-7" }); + const params = fake.paramsFor("turn/steer"); + expect(params?.threadId).toBe("thread-1"); + expect(params?.expectedTurnId).toBe("turn-1"); + expect(params?.input).toEqual([ + { type: "text", text: "actually, focus on tests", text_elements: [] } + ]); + await client.close(); + }); + + it("steerTurn falls back to expectedTurnId when the response omits a turn id", async () => { + const fake = new FeatureFakeTransport(); + // Override: respond to turn/steer with no id. + (fake as unknown as { respond: (m: string) => unknown }).respond = (m: string) => + m === "initialize" + ? { capabilities: {} } + : m === "thread/start" + ? { thread: { id: "thread-1" }, model: "m", modelProvider: "p", serviceTier: null } + : {}; + const client = new CodexThreadClient({ transportFactory: () => fake }); + await client.startThread(); + const result = await client.steerTurn({ + threadId: "thread-1", + input: [], + expectedTurnId: "turn-42" + }); + expect(result.turnId).toBe("turn-42"); + await client.close(); + }); + + it("compactThread sends thread/compact/start and extracts turn + item ids", async () => { + const fake = new FeatureFakeTransport(); + const client = new CodexThreadClient({ transportFactory: () => fake }); + await client.startThread(); + + const result = await client.compactThread("thread-1"); + expect(result).toEqual({ + threadId: "thread-1", + turnId: "turn-compact", + itemId: "item-9" + }); + expect(fake.paramsFor("thread/compact/start")?.threadId).toBe("thread-1"); + await client.close(); + }); + + it("startReview sends review/start and extracts the review thread + turn", async () => { + const fake = new FeatureFakeTransport(); + const client = new CodexThreadClient({ transportFactory: () => fake }); + await client.startThread(); + + const result = await client.startReview({ + threadId: "thread-1", + target: { type: "uncommittedChanges" } as never + }); + expect(result).toEqual({ + threadId: "thread-1", + reviewThreadId: "review-thread-2", + turnId: "turn-review" + }); + const params = fake.paramsFor("review/start"); + expect(params?.threadId).toBe("thread-1"); + expect(params?.delivery).toBe("inline"); + await client.close(); + }); + + it("trustProject writes the projects config overlay", async () => { + const fake = new FeatureFakeTransport(); + const client = new CodexThreadClient({ transportFactory: () => fake }); + await client.startThread(); + + const result = await client.trustProject({ projectPath: "/work/repo" }); + expect(result).toEqual({ projectPath: "/work/repo" }); + const params = fake.paramsFor("config/value/write"); + expect(params?.keyPath).toBe("projects"); + expect(params?.mergeStrategy).toBe("upsert"); + expect(params?.value).toEqual({ "/work/repo": { trust_level: "trusted" } }); + await client.close(); + }); + + it("setThreadPermissions resumes with the permission overlay", async () => { + const fake = new FeatureFakeTransport(); + const client = new CodexThreadClient({ transportFactory: () => fake }); + await client.startThread(); + + const result = await client.setThreadPermissions({ + threadId: "thread-1", + model: "gpt-5-codex", + approvalPolicy: "never", + sandbox: "workspace-write" + }); + expect(result).toEqual({ threadId: "thread-1" }); + const params = fake.paramsFor("thread/resume"); + expect(params?.model).toBe("gpt-5-codex"); + expect(params?.approvalPolicy).toBe("never"); + expect(params?.sandbox).toBe("workspace-write"); + await client.close(); + }); +});