Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 060d521340 test(codex): clean goal fixtures after setup failures 2026-10-13 11:03:17 +02:00
paseo-bot[bot]andBae Seongmin da5e8fbce3 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>
2026-10-13 10:56:47 +02:00
4 changed files with 233 additions and 0 deletions
@@ -5487,6 +5487,7 @@ describe("Codex app-server provider", () => {
method: "thread/goal/set",
params: {
threadId: "test-thread",
origin: "user",
objective: "ship feature",
status: "active",
},
@@ -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.";
}
@@ -0,0 +1,144 @@
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 { AgentSession, 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);
let session: AgentSession | null = null;
try {
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 } });
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('<codex_internal_context source="user_goal">'))
.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();
}
} finally {
try {
await session?.close();
} finally {
try {
server.closeAllConnections();
if (server.listening) {
server.close();
await once(server, "close");
}
} finally {
rmSync(root, { recursive: true, force: true });
}
}
}
}, 60_000);
@@ -0,0 +1,84 @@
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";
import type { AgentSession } from "../agent-sdk-types.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 },
});
let session: AgentSession | null = null;
try {
session = await client.createSession({
provider: "codex",
cwd: root,
model: "test-model",
thinkingOptionId: "low",
});
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 {
try {
await session?.close();
} finally {
rmSync(root, { recursive: true, force: true });
}
}
});