Skip to content

Commit 73b695c

Browse files
authored
fix(agent): support /goal in Codex cloud runs (#3467)
1 parent 413f1b5 commit 73b695c

9 files changed

Lines changed: 723 additions & 18 deletions

File tree

packages/agent/src/acp-extensions.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,22 @@ export const POSTHOG_NOTIFICATIONS = {
8686

8787
/** RTK output-compression token savings tallied at the end of a run */
8888
RTK_SAVINGS: "_posthog/rtk_savings",
89+
90+
/** Latest native Codex goal state, persisted so cold cloud resumes can restore it. */
91+
CODEX_GOAL: "_posthog/codex_goal",
8992
} as const;
9093

94+
export type NativeGoalState = {
95+
objective: string;
96+
status:
97+
| "active"
98+
| "paused"
99+
| "blocked"
100+
| "usageLimited"
101+
| "budgetLimited"
102+
| "complete";
103+
};
104+
91105
/**
92106
* Custom request methods for PostHog-specific operations that need a response
93107
* (request/response, not fire-and-forget). Used with

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts

Lines changed: 286 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { sandboxPolicyFor } from "./session-config";
1414

1515
// Required-field invariants the native codex app-server enforces on each request.
1616
const REQUIRED_FIELDS: Record<string, string[]> = {
17+
"thread/goal/clear": ["threadId"],
18+
"thread/goal/get": ["threadId"],
19+
"thread/goal/set": ["threadId"],
1720
"turn/interrupt": ["threadId", "turnId"],
1821
"turn/steer": ["threadId", "input", "expectedTurnId"],
1922
};
@@ -43,7 +46,12 @@ function makeStubRpc(responses: Record<string, unknown>) {
4346
message: `Invalid request: missing field \`${missing}\``,
4447
};
4548
}
46-
return (responses[method] ?? {}) as T;
49+
const response = responses[method];
50+
return (
51+
typeof response === "function"
52+
? await response(params)
53+
: (response ?? {})
54+
) as T;
4755
},
4856
notify() {},
4957
async close() {},
@@ -295,6 +303,274 @@ describe("CodexAppServerAgent", () => {
295303
).toHaveLength(1);
296304
});
297305

306+
it.each([
307+
{
308+
label: "reads an empty goal",
309+
prompt: "/goal",
310+
method: "thread/goal/get",
311+
response: { goal: null },
312+
expectedParams: { threadId: "thr_1" },
313+
expectedText: "No goal set. Usage: `/goal <objective>`",
314+
expectedGoal: undefined,
315+
},
316+
{
317+
label: "reads an active goal",
318+
prompt: "/goal",
319+
method: "thread/goal/get",
320+
response: { goal: { objective: "Ship the fix", status: "active" } },
321+
expectedParams: { threadId: "thr_1" },
322+
expectedText: "Goal active: Ship the fix",
323+
expectedGoal: undefined,
324+
},
325+
{
326+
label: "sets a goal",
327+
prompt: "/goal Ship the fix",
328+
method: "thread/goal/set",
329+
response: { goal: { objective: "Ship the fix", status: "active" } },
330+
expectedParams: { threadId: "thr_1", objective: "Ship the fix" },
331+
expectedText: "Goal set: Ship the fix",
332+
expectedGoal: { objective: "Ship the fix", status: "active" },
333+
},
334+
{
335+
label: "clears a goal",
336+
prompt: "/goal clear",
337+
method: "thread/goal/clear",
338+
response: { cleared: true },
339+
expectedParams: { threadId: "thr_1" },
340+
expectedText: "Goal cleared.",
341+
expectedGoal: null,
342+
},
343+
{
344+
label: "pauses a goal",
345+
prompt: "/goal pause",
346+
method: "thread/goal/set",
347+
response: { goal: { objective: "Ship the fix", status: "paused" } },
348+
expectedParams: { threadId: "thr_1", status: "paused" },
349+
expectedText: "Goal paused: Ship the fix",
350+
expectedGoal: { objective: "Ship the fix", status: "paused" },
351+
},
352+
{
353+
label: "resumes a goal",
354+
prompt: "/goal resume",
355+
method: "thread/goal/set",
356+
response: { goal: { objective: "Ship the fix", status: "active" } },
357+
expectedParams: { threadId: "thr_1", status: "active" },
358+
expectedText: "Goal resumed: Ship the fix",
359+
expectedGoal: { objective: "Ship the fix", status: "active" },
360+
},
361+
])("$label without starting a model turn", async (testCase) => {
362+
const stub = makeStubRpc({
363+
"thread/start": { thread: { id: "thr_1" } },
364+
[testCase.method]: testCase.response,
365+
});
366+
const { client, sessionUpdates, extNotifications } = makeFakeClient();
367+
const agent = new CodexAppServerAgent(client, {
368+
processOptions: { binaryPath: "/bundle/codex" },
369+
rpcFactory: stub.factory,
370+
});
371+
372+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
373+
const result = await agent.prompt({
374+
sessionId: "thr_1",
375+
prompt: [{ type: "text", text: testCase.prompt }],
376+
} as unknown as PromptRequest);
377+
378+
expect(result.stopReason).toBe("end_turn");
379+
expect(stub.requests).toContainEqual({
380+
method: testCase.method,
381+
params: testCase.expectedParams,
382+
});
383+
expect(
384+
stub.requests.some((request) => request.method === "turn/start"),
385+
).toBe(false);
386+
expect(sessionUpdates).toContainEqual({
387+
sessionId: "thr_1",
388+
update: {
389+
sessionUpdate: "agent_message_chunk",
390+
content: { type: "text", text: testCase.expectedText },
391+
},
392+
});
393+
if (testCase.expectedGoal !== undefined) {
394+
expect(extNotifications).toContainEqual({
395+
method: "_posthog/codex_goal",
396+
params: { goal: testCase.expectedGoal },
397+
});
398+
}
399+
});
400+
401+
it("handles a goal command wrapped in hidden cold-resume context", async () => {
402+
const stub = makeStubRpc({
403+
"thread/start": { thread: { id: "thr_1" } },
404+
"thread/goal/get": {
405+
goal: { objective: "Ship the fix", status: "paused" },
406+
},
407+
});
408+
const { client, sessionUpdates } = makeFakeClient();
409+
const agent = new CodexAppServerAgent(client, {
410+
processOptions: { binaryPath: "/bundle/codex" },
411+
rpcFactory: stub.factory,
412+
});
413+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
414+
415+
await agent.prompt({
416+
sessionId: "thr_1",
417+
prompt: [
418+
{
419+
type: "text",
420+
text: "Previous conversation context",
421+
_meta: { ui: { hidden: true } },
422+
},
423+
{ type: "text", text: "/goal" },
424+
{
425+
type: "text",
426+
text: "Respond to the user above",
427+
_meta: { ui: { hidden: true } },
428+
},
429+
],
430+
} as unknown as PromptRequest);
431+
432+
expect(stub.requests).toContainEqual({
433+
method: "thread/goal/get",
434+
params: { threadId: "thr_1" },
435+
});
436+
expect(
437+
stub.requests.some((request) => request.method === "turn/start"),
438+
).toBe(false);
439+
expect(sessionUpdates).toContainEqual({
440+
sessionId: "thr_1",
441+
update: {
442+
sessionUpdate: "user_message_chunk",
443+
content: { type: "text", text: "/goal" },
444+
},
445+
});
446+
});
447+
448+
it("restores a persisted goal when starting a replacement thread", async () => {
449+
const restoredGoal = { objective: "Ship the fix", status: "paused" };
450+
const stub = makeStubRpc({
451+
"thread/start": { thread: { id: "thr_1" } },
452+
"thread/goal/set": { goal: restoredGoal },
453+
});
454+
const { client, extNotifications } = makeFakeClient();
455+
const agent = new CodexAppServerAgent(client, {
456+
processOptions: { binaryPath: "/bundle/codex" },
457+
rpcFactory: stub.factory,
458+
});
459+
460+
await agent.newSession({
461+
cwd: "/repo",
462+
_meta: { nativeGoal: restoredGoal },
463+
} as unknown as NewSessionRequest);
464+
465+
expect(stub.requests).toContainEqual({
466+
method: "thread/goal/set",
467+
params: { threadId: "thr_1", ...restoredGoal },
468+
});
469+
expect(extNotifications).toContainEqual({
470+
method: "_posthog/codex_goal",
471+
params: { goal: restoredGoal },
472+
});
473+
});
474+
475+
it("interrupts a native goal turn that was already queued when paused", async () => {
476+
const stub = makeStubRpc({
477+
"thread/start": { thread: { id: "thr_1" } },
478+
"thread/goal/set": {
479+
goal: { objective: "Ship the fix", status: "paused" },
480+
},
481+
});
482+
const { client } = makeFakeClient();
483+
const agent = new CodexAppServerAgent(client, {
484+
processOptions: { binaryPath: "/bundle/codex" },
485+
rpcFactory: stub.factory,
486+
});
487+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
488+
489+
await agent.prompt({
490+
sessionId: "thr_1",
491+
prompt: [{ type: "text", text: "/goal pause" }],
492+
} as unknown as PromptRequest);
493+
stub.emit("turn/started", { turn: { id: "goal_tick_1" } });
494+
await Promise.resolve();
495+
496+
expect(stub.requests).toContainEqual({
497+
method: "turn/interrupt",
498+
params: { threadId: "thr_1", turnId: "goal_tick_1" },
499+
});
500+
});
501+
502+
it("interrupts a native goal turn that started before it was paused", async () => {
503+
const stub = makeStubRpc({
504+
"thread/start": { thread: { id: "thr_1" } },
505+
"thread/goal/set": {
506+
goal: { objective: "Ship the fix", status: "paused" },
507+
},
508+
});
509+
const { client } = makeFakeClient();
510+
const agent = new CodexAppServerAgent(client, {
511+
processOptions: { binaryPath: "/bundle/codex" },
512+
rpcFactory: stub.factory,
513+
});
514+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
515+
stub.emit("turn/started", { turn: { id: "goal_tick_1" } });
516+
517+
await agent.prompt({
518+
sessionId: "thr_1",
519+
prompt: [{ type: "text", text: "/goal pause" }],
520+
} as unknown as PromptRequest);
521+
522+
expect(stub.requests).toContainEqual({
523+
method: "turn/interrupt",
524+
params: { threadId: "thr_1", turnId: "goal_tick_1" },
525+
});
526+
});
527+
528+
it("retries queued goal cancellation after an interrupt failure", async () => {
529+
let interruptAttempts = 0;
530+
const stub = makeStubRpc({
531+
"thread/start": { thread: { id: "thr_1" } },
532+
"thread/goal/set": {
533+
goal: { objective: "Ship the fix", status: "paused" },
534+
},
535+
"turn/interrupt": () => {
536+
interruptAttempts++;
537+
if (interruptAttempts === 1) {
538+
throw new Error("interrupt failed");
539+
}
540+
return {};
541+
},
542+
});
543+
const { client } = makeFakeClient();
544+
const agent = new CodexAppServerAgent(client, {
545+
processOptions: { binaryPath: "/bundle/codex" },
546+
rpcFactory: stub.factory,
547+
});
548+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
549+
550+
await agent.prompt({
551+
sessionId: "thr_1",
552+
prompt: [{ type: "text", text: "/goal pause" }],
553+
} as unknown as PromptRequest);
554+
stub.emit("turn/started", { turn: { id: "goal_tick_1" } });
555+
await Promise.resolve();
556+
await Promise.resolve();
557+
stub.emit("turn/started", { turn: { id: "goal_tick_2" } });
558+
await Promise.resolve();
559+
560+
expect(
561+
stub.requests.filter((request) => request.method === "turn/interrupt"),
562+
).toEqual([
563+
{
564+
method: "turn/interrupt",
565+
params: { threadId: "thr_1", turnId: "goal_tick_1" },
566+
},
567+
{
568+
method: "turn/interrupt",
569+
params: { threadId: "thr_1", turnId: "goal_tick_2" },
570+
},
571+
]);
572+
});
573+
298574
it("includes buffered command output when completion omits aggregatedOutput", async () => {
299575
const stub = makeStubRpc({
300576
initialize: {},
@@ -1713,6 +1989,7 @@ describe("CodexAppServerAgent", () => {
17131989
{
17141990
skills: [
17151991
{ name: "deploy", description: "Deploy", enabled: true },
1992+
{ name: "goal", description: "Duplicate", enabled: true },
17161993
{ name: "danger", description: "Disabled", enabled: false },
17171994
],
17181995
},
@@ -1731,7 +2008,14 @@ describe("CodexAppServerAgent", () => {
17312008
(u: any) => u.update?.sessionUpdate === "available_commands_update",
17322009
) as any
17332010
)?.update?.availableCommands;
1734-
expect(cmds.map((c: { name: string }) => c.name)).toEqual(["deploy"]);
2011+
expect(cmds).toEqual([
2012+
{
2013+
name: "goal",
2014+
description: "Set or view the goal for a long-running task",
2015+
input: { hint: "[<objective>|clear|pause|resume]" },
2016+
},
2017+
{ name: "deploy", description: "Deploy" },
2018+
]);
17352019
});
17362020

17372021
it("emits _posthog/sdk_session when a taskRunId is present", async () => {

0 commit comments

Comments
 (0)