From da5e8fbce3e4cb547b9d49f64915d2eb69054086 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:56:47 +0200 Subject: [PATCH] fix(codex): record goal commands as user edits Codex requires explicit user provenance before goal mutations enter model context. Mark every user /goal mutation accordingly and cover both emitted RPCs and native model input. Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com> --- .../providers/codex-app-server-agent.test.ts | 1 + .../agent/providers/codex-app-server-agent.ts | 4 + .../codex-goal-provenance.local.e2e.test.ts | 128 ++++++++++++++++++ .../providers/codex-goal-provenance.test.ts | 79 +++++++++++ 4 files changed, 212 insertions(+) create mode 100644 packages/server/src/server/agent/providers/codex-goal-provenance.local.e2e.test.ts create mode 100644 packages/server/src/server/agent/providers/codex-goal-provenance.test.ts diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index c8269722a7..7980234621 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -5487,6 +5487,7 @@ describe("Codex app-server provider", () => { method: "thread/goal/set", params: { threadId: "test-thread", + origin: "user", objective: "ship feature", status: "active", }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 36267592f8..9660814d5c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -5182,6 +5182,7 @@ export class CodexAppServerAgentSession implements AgentSession { case "set": { await this.client.request("thread/goal/set", { threadId: this.currentThreadId, + origin: "user", objective: subcommand.objective, status: "active", }); @@ -5190,6 +5191,7 @@ export class CodexAppServerAgentSession implements AgentSession { case "pause": { await this.client.request("thread/goal/set", { threadId: this.currentThreadId, + origin: "user", status: "paused", }); return "Goal paused."; @@ -5197,6 +5199,7 @@ export class CodexAppServerAgentSession implements AgentSession { case "resume": { await this.client.request("thread/goal/set", { threadId: this.currentThreadId, + origin: "user", status: "active", }); return "Goal resumed."; @@ -5204,6 +5207,7 @@ export class CodexAppServerAgentSession implements AgentSession { case "clear": { await this.client.request("thread/goal/clear", { threadId: this.currentThreadId, + origin: "user", }); return "Goal cleared."; } diff --git a/packages/server/src/server/agent/providers/codex-goal-provenance.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-goal-provenance.local.e2e.test.ts new file mode 100644 index 0000000000..c4f427a371 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-goal-provenance.local.e2e.test.ts @@ -0,0 +1,128 @@ +import { once } from "node:events"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; + +// Native Codex owns goals; the fixture supplies inference responses only. +test("records all four /goal commands as user edits in the next model request", async () => { + const root = mkdtempSync(path.resolve(".dev-goal-provenance-")); + const requests: string[] = []; + function handleRequest(req: IncomingMessage, res: ServerResponse): void { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + if (req.method !== "POST" || req.url !== "/v1/responses") { + res.writeHead(404).end(); + return; + } + requests.push(Buffer.concat(chunks).toString("utf8")); + res.setHeader("content-type", "text/event-stream"); + const id = `resp-${requests.length}`; + const events = [ + { type: "response.created", response: { id } }, + { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id: `msg-${requests.length}`, + content: [{ type: "output_text", text: "LOCAL_FIXTURE_RESPONSE" }], + }, + }, + { + type: "response.completed", + response: { + id, + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, + ]; + res.end(events.map((e) => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join("")); + }); + } + const server = createServer(handleRequest); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("missing fixture address"); + writeFileSync( + path.join(root, "config.toml"), + ` +model = "gpt-6.1-sol" +model_reasoning_effort = "low" +model_provider = "fixture" +approval_policy = "never" +sandbox_mode = "read-only" +[features] +enable_request_compression = false +[model_providers.fixture] +name = "Local response fixture" +base_url = "http://127.0.0.1:${address.port}/v1" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +request_max_retries = 0 +stream_max_retries = 0 +`, + ); + const client = new CodexAppServerAgentClient(createTestLogger(), { env: { CODEX_HOME: root } }); + const session = await client.createSession({ + provider: "codex", + cwd: root, + model: "gpt-6.1-sol", + thinkingOptionId: "low", + }); + let active = false; + const unsubscribe = session.subscribe((event) => { + if (event.type === "turn_started") active = true; + if (["turn_completed", "turn_canceled", "turn_failed"].includes(event.type)) active = false; + }); + try { + expect((await session.listCommands?.())?.some((command) => command.name === "goal")).toBe(true); + for (const command of [ + "/goal AUDIT_GOAL: summarize local evidence", + "/goal pause", + "/goal resume", + "/goal clear", + ]) { + const handler = session.tryHandleOutOfBand?.(command); + expect(handler).toBeTruthy(); + const events: AgentStreamEvent[] = []; + await handler!.run({ emit: (event) => events.push(event) }); + expect(events).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + item: expect.objectContaining({ + text: expect.stringContaining("Failed to update goal"), + }), + }), + ]), + ); + } + await expect.poll(() => active, { timeout: 30_000 }).toBe(false); + expect((await session.run("PROBE_AFTER_GOAL_CLEAR")).finalText).toBe("LOCAL_FIXTURE_RESPONSE"); + const body = JSON.parse(requests.at(-1)!); + const goalContext = body.input + .filter((item: { role: string }) => item.role === "user") + .flatMap((item: { content: Array<{ text?: string }> }) => + item.content.map((part) => part.text ?? ""), + ) + .filter((text: string) => text.includes('')) + .join("\n"); + expect(goalContext).toContain('User set the goal: "AUDIT_GOAL: summarize local evidence"'); + expect(goalContext).toContain('User set goal status: "paused".'); + expect(goalContext).toContain('User set goal status: "active".'); + expect(goalContext).toContain("User cleared the goal."); + } finally { + unsubscribe(); + await session.close(); + server.closeAllConnections(); + server.close(); + await once(server, "close"); + rmSync(root, { recursive: true, force: true }); + } +}, 60_000); diff --git a/packages/server/src/server/agent/providers/codex-goal-provenance.test.ts b/packages/server/src/server/agent/providers/codex-goal-provenance.test.ts new file mode 100644 index 0000000000..2faa8f3705 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-goal-provenance.test.ts @@ -0,0 +1,79 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { expect, test } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; + +// A protocol peer exercises the public session interface without a Codex install. +test("marks set, pause, resume and clear as user goal mutations", async () => { + const root = mkdtempSync(path.resolve(".dev-goal-protocol-")); + const script = path.join(root, "codex.cjs"); + const capture = path.join(root, "requests.jsonl"); + writeFileSync( + script, + ` +const fs = require("node:fs"); +const readline = require("node:readline"); +if (process.argv.includes("--version")) { + console.log("codex-cli 0.162.1"); + process.exit(0); +} +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + fs.appendFileSync(process.env.CAPTURE, JSON.stringify(request) + "\\n"); + let result = {}; + if (request.method === "initialize") result = { userAgent: "codex-cli/0.162.1" }; + if (request.method === "thread/start") result = { thread: { id: "goal-thread" } }; + if (request.method === "thread/loaded/list") result = { data: ["goal-thread"] }; + process.stdout.write(JSON.stringify({ id: request.id, result }) + "\\n"); +}); +`, + ); + const client = new CodexAppServerAgentClient(createTestLogger(), { + command: { mode: "replace", argv: [process.execPath, script] }, + env: { CODEX_HOME: root, CAPTURE: capture }, + }); + const session = await client.createSession({ + provider: "codex", + cwd: root, + model: "test-model", + thinkingOptionId: "low", + }); + try { + for (const command of ["/goal ship feature", "/goal pause", "/goal resume", "/goal clear"]) { + const handler = session.tryHandleOutOfBand?.(command); + if (!handler) throw new Error(`Missing handler for ${command}`); + await handler.run({ emit: () => {} }); + } + const requests = readFileSync(capture, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const mutations = requests.filter((request) => request.method.startsWith("thread/goal/")); + expect(mutations.map(({ method, params }) => ({ method, params }))).toEqual([ + { + method: "thread/goal/set", + params: { + threadId: "goal-thread", + origin: "user", + objective: "ship feature", + status: "active", + }, + }, + { + method: "thread/goal/set", + params: { threadId: "goal-thread", origin: "user", status: "paused" }, + }, + { + method: "thread/goal/set", + params: { threadId: "goal-thread", origin: "user", status: "active" }, + }, + { method: "thread/goal/clear", params: { threadId: "goal-thread", origin: "user" } }, + ]); + } finally { + await session.close(); + rmSync(root, { recursive: true, force: true }); + } +});