Skip to content

Commit e51cb33

Browse files
authored
fix(agent): preserve Codex goals across cloud resumes
Generated-By: PostHog Code Task-Id: f8939190-58a1-493b-904c-457c7859b3d6
1 parent fcf2620 commit e51cb33

8 files changed

Lines changed: 396 additions & 13 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 CodexGoalState = {
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: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ describe("CodexAppServerAgent", () => {
306306
response: { goal: null },
307307
expectedParams: { threadId: "thr_1" },
308308
expectedText: "No goal set. Usage: `/goal <objective>`",
309+
expectedGoal: undefined,
309310
},
310311
{
311312
label: "reads an active goal",
@@ -314,6 +315,7 @@ describe("CodexAppServerAgent", () => {
314315
response: { goal: { objective: "Ship the fix", status: "active" } },
315316
expectedParams: { threadId: "thr_1" },
316317
expectedText: "Goal active: Ship the fix",
318+
expectedGoal: undefined,
317319
},
318320
{
319321
label: "sets a goal",
@@ -322,6 +324,7 @@ describe("CodexAppServerAgent", () => {
322324
response: { goal: { objective: "Ship the fix", status: "active" } },
323325
expectedParams: { threadId: "thr_1", objective: "Ship the fix" },
324326
expectedText: "Goal set: Ship the fix",
327+
expectedGoal: { objective: "Ship the fix", status: "active" },
325328
},
326329
{
327330
label: "clears a goal",
@@ -330,6 +333,7 @@ describe("CodexAppServerAgent", () => {
330333
response: { cleared: true },
331334
expectedParams: { threadId: "thr_1" },
332335
expectedText: "Goal cleared.",
336+
expectedGoal: null,
333337
},
334338
{
335339
label: "pauses a goal",
@@ -338,6 +342,7 @@ describe("CodexAppServerAgent", () => {
338342
response: { goal: { objective: "Ship the fix", status: "paused" } },
339343
expectedParams: { threadId: "thr_1", status: "paused" },
340344
expectedText: "Goal paused: Ship the fix",
345+
expectedGoal: { objective: "Ship the fix", status: "paused" },
341346
},
342347
{
343348
label: "resumes a goal",
@@ -346,13 +351,14 @@ describe("CodexAppServerAgent", () => {
346351
response: { goal: { objective: "Ship the fix", status: "active" } },
347352
expectedParams: { threadId: "thr_1", status: "active" },
348353
expectedText: "Goal resumed: Ship the fix",
354+
expectedGoal: { objective: "Ship the fix", status: "active" },
349355
},
350356
])("$label without starting a model turn", async (testCase) => {
351357
const stub = makeStubRpc({
352358
"thread/start": { thread: { id: "thr_1" } },
353359
[testCase.method]: testCase.response,
354360
});
355-
const { client, sessionUpdates } = makeFakeClient();
361+
const { client, sessionUpdates, extNotifications } = makeFakeClient();
356362
const agent = new CodexAppServerAgent(client, {
357363
processOptions: { binaryPath: "/bundle/codex" },
358364
rpcFactory: stub.factory,
@@ -379,6 +385,113 @@ describe("CodexAppServerAgent", () => {
379385
content: { type: "text", text: testCase.expectedText },
380386
},
381387
});
388+
if (testCase.expectedGoal !== undefined) {
389+
expect(extNotifications).toContainEqual({
390+
method: "_posthog/codex_goal",
391+
params: { goal: testCase.expectedGoal },
392+
});
393+
}
394+
});
395+
396+
it("handles a goal command wrapped in hidden cold-resume context", async () => {
397+
const stub = makeStubRpc({
398+
"thread/start": { thread: { id: "thr_1" } },
399+
"thread/goal/get": {
400+
goal: { objective: "Ship the fix", status: "paused" },
401+
},
402+
});
403+
const { client, sessionUpdates } = makeFakeClient();
404+
const agent = new CodexAppServerAgent(client, {
405+
processOptions: { binaryPath: "/bundle/codex" },
406+
rpcFactory: stub.factory,
407+
});
408+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
409+
410+
await agent.prompt({
411+
sessionId: "thr_1",
412+
prompt: [
413+
{
414+
type: "text",
415+
text: "Previous conversation context",
416+
_meta: { ui: { hidden: true } },
417+
},
418+
{ type: "text", text: "/goal" },
419+
{
420+
type: "text",
421+
text: "Respond to the user above",
422+
_meta: { ui: { hidden: true } },
423+
},
424+
],
425+
} as unknown as PromptRequest);
426+
427+
expect(stub.requests).toContainEqual({
428+
method: "thread/goal/get",
429+
params: { threadId: "thr_1" },
430+
});
431+
expect(
432+
stub.requests.some((request) => request.method === "turn/start"),
433+
).toBe(false);
434+
expect(sessionUpdates).toContainEqual({
435+
sessionId: "thr_1",
436+
update: {
437+
sessionUpdate: "user_message_chunk",
438+
content: { type: "text", text: "/goal" },
439+
},
440+
});
441+
});
442+
443+
it("restores a persisted goal when starting a replacement thread", async () => {
444+
const restoredGoal = { objective: "Ship the fix", status: "paused" };
445+
const stub = makeStubRpc({
446+
"thread/start": { thread: { id: "thr_1" } },
447+
"thread/goal/set": { goal: restoredGoal },
448+
});
449+
const { client, extNotifications } = makeFakeClient();
450+
const agent = new CodexAppServerAgent(client, {
451+
processOptions: { binaryPath: "/bundle/codex" },
452+
rpcFactory: stub.factory,
453+
});
454+
455+
await agent.newSession({
456+
cwd: "/repo",
457+
_meta: { codexGoal: restoredGoal },
458+
} as unknown as NewSessionRequest);
459+
460+
expect(stub.requests).toContainEqual({
461+
method: "thread/goal/set",
462+
params: { threadId: "thr_1", ...restoredGoal },
463+
});
464+
expect(extNotifications).toContainEqual({
465+
method: "_posthog/codex_goal",
466+
params: { goal: restoredGoal },
467+
});
468+
});
469+
470+
it("interrupts a native goal turn that was already queued when paused", async () => {
471+
const stub = makeStubRpc({
472+
"thread/start": { thread: { id: "thr_1" } },
473+
"thread/goal/set": {
474+
goal: { objective: "Ship the fix", status: "paused" },
475+
},
476+
});
477+
const { client } = makeFakeClient();
478+
const agent = new CodexAppServerAgent(client, {
479+
processOptions: { binaryPath: "/bundle/codex" },
480+
rpcFactory: stub.factory,
481+
});
482+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
483+
484+
await agent.prompt({
485+
sessionId: "thr_1",
486+
prompt: [{ type: "text", text: "/goal pause" }],
487+
} as unknown as PromptRequest);
488+
stub.emit("turn/started", { turn: { id: "goal_tick_1" } });
489+
await Promise.resolve();
490+
491+
expect(stub.requests).toContainEqual({
492+
method: "turn/interrupt",
493+
params: { threadId: "thr_1", turnId: "goal_tick_1" },
494+
});
382495
});
383496

384497
it("includes buffered command output when completion omits aggregatedOutput", async () => {

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

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import type {
2020
StopReason,
2121
} from "@agentclientprotocol/sdk";
2222
import { mcpToolKey, posthogToolMeta } from "@posthog/shared";
23-
import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions";
23+
import {
24+
type CodexGoalState,
25+
POSTHOG_NOTIFICATIONS,
26+
} from "../../acp-extensions";
2427
import { DEFAULT_CODEX_MODEL } from "../../gateway-models";
2528
import type { ProcessSpawnedCallback } from "../../types";
2629
import { ALLOW_BYPASS } from "../../utils/common";
@@ -87,6 +90,7 @@ type AppServerSessionMeta = {
8790
channelMode?: boolean;
8891
spokenNarration?: boolean;
8992
baseBranch?: string;
93+
codexGoal?: CodexGoalState;
9094
};
9195

9296
/** The subset of codex's `Thread` the adapter reads: id + persisted `turns` for history replay. */
@@ -97,7 +101,7 @@ type AppServerThread = {
97101

98102
type ThreadGoal = {
99103
objective: string;
100-
status: string;
104+
status: CodexGoalState["status"];
101105
};
102106

103107
type GoalCommand =
@@ -119,9 +123,21 @@ const GOAL_COMMAND = {
119123
input: { hint: "[<objective>|clear|pause|resume]" },
120124
};
121125

126+
function isHiddenPromptBlock(block: PromptRequest["prompt"][number]): boolean {
127+
const meta = block._meta as { ui?: { hidden?: boolean } } | undefined;
128+
return meta?.ui?.hidden === true;
129+
}
130+
131+
function visiblePromptBlocks(
132+
prompt: PromptRequest["prompt"],
133+
): PromptRequest["prompt"] {
134+
return prompt.filter((block) => !isHiddenPromptBlock(block));
135+
}
136+
122137
function parseGoalCommand(prompt: PromptRequest["prompt"]): GoalCommand | null {
123-
if (prompt.some((block) => block.type !== "text")) return null;
124-
const text = prompt
138+
const visible = visiblePromptBlocks(prompt);
139+
if (visible.some((block) => block.type !== "text")) return null;
140+
const text = visible
125141
.map((block) => (block.type === "text" ? block.text : ""))
126142
.join("\n")
127143
.trim();
@@ -204,6 +220,8 @@ export class CodexAppServerAgent extends BaseAcpAgent {
204220
private readonly mcp = new McpManager();
205221
private readonly turns = new TurnController();
206222
private readonly usage = new UsageTracker();
223+
/** Pause/clear can race a goal continuation already queued by app-server. */
224+
private cancelNextGoalTurn = false;
207225

208226
constructor(
209227
client: AgentSideConnection,
@@ -464,6 +482,9 @@ export class CodexAppServerAgent extends BaseAcpAgent {
464482
}
465483
this.threadId = threadId;
466484
this.sessionId = threadId;
485+
if (method === APP_SERVER_METHODS.THREAD_START && params.meta?.codexGoal) {
486+
await this.restoreGoal(params.meta.codexGoal);
487+
}
467488
await this.loadModelConfig();
468489
this.emitConfigOptions();
469490
await this.emitAvailableCommands();
@@ -608,10 +629,11 @@ export class CodexAppServerAgent extends BaseAcpAgent {
608629
}
609630
const goalCommand = parseGoalCommand(params.prompt);
610631
if (goalCommand) {
611-
this.broadcastUserInput(params.prompt);
632+
this.broadcastUserInput(visiblePromptBlocks(params.prompt));
612633
await this.handleGoalCommand(goalCommand);
613634
return { stopReason: "end_turn" };
614635
}
636+
this.cancelNextGoalTurn = false;
615637
// Reopen the notification gate (a prior interrupt may have left session.cancelled set).
616638
this.session.cancelled = false;
617639
// A new prompt while the plan handoff awaits approval implicitly declines it:
@@ -710,10 +732,13 @@ export class CodexAppServerAgent extends BaseAcpAgent {
710732
private async handleGoalCommand(command: GoalCommand): Promise<void> {
711733
if (!this.threadId) return;
712734
if (command.kind === "clear") {
735+
this.cancelNextGoalTurn = true;
713736
const result = await this.rpc.request<{ cleared?: boolean }>(
714737
APP_SERVER_METHODS.THREAD_GOAL_CLEAR,
715738
{ threadId: this.threadId },
716739
);
740+
await this.emitGoalState(null);
741+
await this.cancelRunningGoalTurn();
717742
this.broadcastAgentText(
718743
result.cleared ? "Goal cleared." : "No goal was set.",
719744
);
@@ -733,6 +758,9 @@ export class CodexAppServerAgent extends BaseAcpAgent {
733758
return;
734759
}
735760

761+
if (command.kind === "pause") {
762+
this.cancelNextGoalTurn = true;
763+
}
736764
const params =
737765
command.kind === "set"
738766
? { threadId: this.threadId, objective: command.objective }
@@ -744,6 +772,10 @@ export class CodexAppServerAgent extends BaseAcpAgent {
744772
APP_SERVER_METHODS.THREAD_GOAL_SET,
745773
params,
746774
);
775+
await this.emitGoalState(result.goal);
776+
if (command.kind === "pause") {
777+
await this.cancelRunningGoalTurn();
778+
}
747779
const prefix =
748780
command.kind === "set"
749781
? "Goal set"
@@ -753,6 +785,46 @@ export class CodexAppServerAgent extends BaseAcpAgent {
753785
this.broadcastAgentText(`${prefix}: ${result.goal.objective}`);
754786
}
755787

788+
private async restoreGoal(goal: CodexGoalState): Promise<void> {
789+
if (!this.threadId) return;
790+
const result = await this.rpc.request<{ goal: ThreadGoal }>(
791+
APP_SERVER_METHODS.THREAD_GOAL_SET,
792+
{
793+
threadId: this.threadId,
794+
objective: goal.objective,
795+
status: goal.status,
796+
},
797+
);
798+
await this.emitGoalState(result.goal);
799+
}
800+
801+
private async emitGoalState(goal: CodexGoalState | null): Promise<void> {
802+
await this.client
803+
.extNotification(POSTHOG_NOTIFICATIONS.CODEX_GOAL, { goal })
804+
.catch((error) =>
805+
this.logger.warn("Failed to persist Codex goal state", error),
806+
);
807+
}
808+
809+
private async cancelRunningGoalTurn(): Promise<void> {
810+
if (!this.turns.isRunning) return;
811+
this.cancelNextGoalTurn = false;
812+
await this.interrupt();
813+
}
814+
815+
private interruptQueuedGoalTurn(turnId: string | undefined): void {
816+
if (!this.cancelNextGoalTurn || !this.threadId || !turnId) return;
817+
this.cancelNextGoalTurn = false;
818+
void this.rpc
819+
.request(APP_SERVER_METHODS.TURN_INTERRUPT, {
820+
threadId: this.threadId,
821+
turnId,
822+
})
823+
.catch((error) =>
824+
this.logger.warn("Queued goal turn interrupt failed", error),
825+
);
826+
}
827+
756828
/** Start one codex turn and await its completion. */
757829
private async runTurn(input: CodexUserInput[]): Promise<StopReason> {
758830
this.lastAgentMessage = "";
@@ -1081,7 +1153,9 @@ export class CodexAppServerAgent extends BaseAcpAgent {
10811153

10821154
if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) {
10831155
// Capture the active turn id (steer precondition / interrupt target).
1084-
this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id);
1156+
const turnId = (params as { turn?: { id?: string } })?.turn?.id;
1157+
this.turns.onStarted(turnId);
1158+
this.interruptQueuedGoalTurn(turnId);
10851159
}
10861160

10871161
// codex auto-compaction surfaces as a contextCompaction item: item/started → in progress,

packages/agent/src/resume.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717

1818
import type { ContentBlock } from "@agentclientprotocol/sdk";
19+
import type { CodexGoalState } from "./acp-extensions";
1920
import { selectRecentTurns } from "./adapters/claude/session/jsonl-hydration";
2021
import type { PostHogAPIClient } from "./posthog-api";
2122
import { ResumeSaga } from "./sagas/resume-saga";
@@ -29,6 +30,7 @@ export interface ResumeState {
2930
lastDevice?: DeviceInfo;
3031
logEntryCount: number;
3132
sessionId: string | null;
33+
codexGoal?: CodexGoalState | null;
3234
}
3335

3436
export interface ConversationTurn {
@@ -95,6 +97,7 @@ export async function resumeFromLog(
9597
lastDevice: result.data.lastDevice,
9698
logEntryCount: result.data.logEntryCount,
9799
sessionId: result.data.sessionId,
100+
codexGoal: result.data.codexGoal,
98101
};
99102
}
100103

0 commit comments

Comments
 (0)