Skip to content

Commit d743136

Browse files
committed
feat: Add /goal support
1 parent e44497d commit d743136

8 files changed

Lines changed: 180 additions & 1 deletion

src/CodexAcpClient.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import type {
3333
SkillsListResponse,
3434
SandboxPolicy,
3535
Thread,
36+
ThreadGoalStatus,
3637
ThreadSourceKind,
3738
TurnCompletedNotification,
3839
UserInput,
@@ -305,6 +306,25 @@ export class CodexAcpClient {
305306
await this.codexClient.runCompact({threadId: sessionId});
306307
}
307308

309+
async setGoal(sessionId: string, objective: string): Promise<void> {
310+
await this.codexClient.threadGoalSet({
311+
threadId: sessionId,
312+
objective,
313+
status: "active",
314+
});
315+
}
316+
317+
async setGoalStatus(sessionId: string, status: ThreadGoalStatus): Promise<void> {
318+
await this.codexClient.threadGoalSet({
319+
threadId: sessionId,
320+
status,
321+
});
322+
}
323+
324+
async clearGoal(sessionId: string): Promise<void> {
325+
await this.codexClient.threadGoalClear({threadId: sessionId});
326+
}
327+
308328
async awaitMcpServerStartup(serverNames: Array<string>, afterVersion: number): Promise<McpStartupResult> {
309329
return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion);
310330
}

src/CodexAppServerClient.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ import type {
3030
ThreadArchiveResponse,
3131
ThreadCompactStartParams,
3232
ThreadCompactStartResponse,
33+
ThreadGoalClearParams,
34+
ThreadGoalClearResponse,
35+
ThreadGoalSetParams,
36+
ThreadGoalSetResponse,
3337
ThreadLoadedListParams,
3438
ThreadLoadedListResponse,
3539
ThreadListParams,
@@ -314,6 +318,14 @@ export class CodexAppServerClient {
314318
return await this.sendRequest({ method: "thread/compact/start", params: params });
315319
}
316320

321+
async threadGoalSet(params: ThreadGoalSetParams): Promise<ThreadGoalSetResponse> {
322+
return await this.sendRequest({ method: "thread/goal/set", params: params });
323+
}
324+
325+
async threadGoalClear(params: ThreadGoalClearParams): Promise<ThreadGoalClearResponse> {
326+
return await this.sendRequest({ method: "thread/goal/clear", params: params });
327+
}
328+
317329
async listMcpServerStatus(params: ListMcpServerStatusParams): Promise<ListMcpServerStatusResponse> {
318330
return await this.sendRequest({ method: "mcpServerStatus/list", params });
319331
}

src/CodexCommands.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ export class CodexCommands {
112112
description: "Summarize conversation to avoid hitting the context limit.",
113113
input: null
114114
},
115+
{
116+
name: "goal",
117+
description: "Set, pause, resume, or clear a task goal.",
118+
input: { hint: "[<objective>|clear|edit|pause|resume]" }
119+
},
115120
{
116121
name: "logout",
117122
description: "Sign out of Codex. This option is available when you are logged in via ChatGPT.",
@@ -151,6 +156,10 @@ export class CodexCommands {
151156
await this.runWithProcessCheck(() => this.codexAcpClient.runCompact(sessionId));
152157
return { handled: true };
153158
}
159+
case "goal": {
160+
await this.runGoalCommand(sessionId, command.rest);
161+
return { handled: true };
162+
}
154163
case "review": {
155164
const target = this.buildReviewTarget(command.rest);
156165
const turnCompleted = await this.runReviewCommand(sessionState, target);
@@ -251,6 +260,40 @@ export class CodexCommands {
251260
));
252261
}
253262

263+
private async runGoalCommand(sessionId: string, rest: string): Promise<void> {
264+
const argument = rest.trim();
265+
if (argument.length === 0) {
266+
await this.sendCommandUsageMessage("goal", "[<objective>|clear|edit|pause|resume]", sessionId);
267+
return;
268+
}
269+
270+
switch (argument.toLowerCase()) {
271+
case "pause":
272+
await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionId, "paused"));
273+
return;
274+
case "resume":
275+
await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionId, "active"));
276+
return;
277+
case "clear":
278+
await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionId));
279+
return;
280+
}
281+
282+
if (argument.length > 4000) {
283+
const session = new ACPSessionConnection(this.connection, sessionId);
284+
await session.update({
285+
sessionUpdate: "agent_message_chunk",
286+
content: {
287+
type: "text",
288+
text: 'Command "/goal" requires goal text of at most 4000 characters.'
289+
}
290+
});
291+
return;
292+
}
293+
294+
await this.runWithProcessCheck(() => this.codexAcpClient.setGoal(sessionId, argument));
295+
}
296+
254297
private buildReviewTarget(instructions: string): ReviewTarget {
255298
if (instructions.length === 0) {
256299
return { type: "uncommittedChanges" };

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import type {ServerNotification} from "../../app-server";
1414
import type {SessionState} from "../../CodexAcpServer";
1515
import {AgentMode} from "../../AgentMode";
16-
import type {Model, ReviewStartResponse, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2";
16+
import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2";
1717
import type {RateLimitsMap} from "../../RateLimitsMap";
1818
import {ModelId} from "../../ModelId";
1919

@@ -1100,6 +1100,68 @@ describe('ACP server test', { timeout: 40_000 }, () => {
11001100
expect(mockFixture.getAcpConnectionDump([])).toContain("Context compacted");
11011101
});
11021102

1103+
it('handles goal slash commands through Codex app server', async () => {
1104+
const { mockFixture, turnStartSpy } = setupPromptFixture();
1105+
const goalSetSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet")
1106+
.mockResolvedValue({ goal: createThreadGoal() });
1107+
const goalClearSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalClear")
1108+
.mockResolvedValue({ cleared: true });
1109+
1110+
await mockFixture.getCodexAcpAgent().prompt({
1111+
sessionId: "session-id",
1112+
prompt: [{ type: "text", text: "/goal Ship the migration and keep tests green" }],
1113+
});
1114+
await mockFixture.getCodexAcpAgent().prompt({
1115+
sessionId: "session-id",
1116+
prompt: [{ type: "text", text: "/goal pause" }],
1117+
});
1118+
await mockFixture.getCodexAcpAgent().prompt({
1119+
sessionId: "session-id",
1120+
prompt: [{ type: "text", text: "/goal resume" }],
1121+
});
1122+
await mockFixture.getCodexAcpAgent().prompt({
1123+
sessionId: "session-id",
1124+
prompt: [{ type: "text", text: "/goal clear" }],
1125+
});
1126+
1127+
expect(goalSetSpy).toHaveBeenNthCalledWith(1, {
1128+
threadId: "session-id",
1129+
objective: "Ship the migration and keep tests green",
1130+
status: "active",
1131+
});
1132+
expect(goalSetSpy).toHaveBeenNthCalledWith(2, {
1133+
threadId: "session-id",
1134+
status: "paused",
1135+
});
1136+
expect(goalSetSpy).toHaveBeenNthCalledWith(3, {
1137+
threadId: "session-id",
1138+
status: "active",
1139+
});
1140+
expect(goalClearSpy).toHaveBeenCalledWith({ threadId: "session-id" });
1141+
expect(turnStartSpy).not.toHaveBeenCalled();
1142+
});
1143+
1144+
it('reports missing goal slash command input', async () => {
1145+
const { mockFixture } = setupPromptFixture();
1146+
const goalSetSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalSet")
1147+
.mockResolvedValue({ goal: createThreadGoal() });
1148+
const goalClearSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadGoalClear")
1149+
.mockResolvedValue({ cleared: true });
1150+
1151+
await mockFixture.getCodexAcpAgent().prompt({
1152+
sessionId: "session-id",
1153+
prompt: [{ type: "text", text: "/goal" }],
1154+
});
1155+
1156+
expect(goalSetSpy).not.toHaveBeenCalled();
1157+
expect(goalClearSpy).not.toHaveBeenCalled();
1158+
const [event] = mockFixture.getAcpConnectionEvents([]);
1159+
expect(event).toBeDefined();
1160+
expect(event!.args[0].update.content.text).toBe(
1161+
'Command "/goal" requires [<objective>|clear|edit|pause|resume].'
1162+
);
1163+
});
1164+
11031165
it('reports missing review slash command input', async () => {
11041166
const { mockFixture } = setupPromptFixture();
11051167
const reviewStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "reviewStart")
@@ -1335,6 +1397,20 @@ describe('ACP server test', { timeout: 40_000 }, () => {
13351397
};
13361398
}
13371399

1400+
function createThreadGoal(overrides?: Partial<ThreadGoal>): ThreadGoal {
1401+
return {
1402+
threadId: "session-id",
1403+
objective: "Ship the migration and keep tests green",
1404+
status: "active",
1405+
tokenBudget: null,
1406+
tokensUsed: 0,
1407+
timeUsedSeconds: 0,
1408+
createdAt: 1710000000,
1409+
updatedAt: 1710000000,
1410+
...overrides,
1411+
};
1412+
}
1413+
13381414
it ('should disable reasoning.summary if key authorization is used', async () => {
13391415
const { mockFixture, turnStartSpy } = setupPromptFixture({ account: { type: "apiKey" } });
13401416

src/__tests__/CodexACPAgent/data/available-commands-build-in.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@
4747
"description": "Summarize conversation to avoid hitting the context limit.",
4848
"input": null
4949
},
50+
{
51+
"name": "goal",
52+
"description": "Set, pause, resume, or clear a task goal.",
53+
"input": {
54+
"hint": "[<objective>|clear|edit|pause|resume]"
55+
}
56+
},
5057
{
5158
"name": "logout",
5259
"description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.",

src/__tests__/CodexACPAgent/data/available-commands-skills.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@
4747
"description": "Summarize conversation to avoid hitting the context limit.",
4848
"input": null
4949
},
50+
{
51+
"name": "goal",
52+
"description": "Set, pause, resume, or clear a task goal.",
53+
"input": {
54+
"hint": "[<objective>|clear|edit|pause|resume]"
55+
}
56+
},
5057
{
5158
"name": "logout",
5259
"description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.",

src/__tests__/CodexACPAgent/data/load-session-history.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@
4747
"description": "Summarize conversation to avoid hitting the context limit.",
4848
"input": null
4949
},
50+
{
51+
"name": "goal",
52+
"description": "Set, pause, resume, or clear a task goal.",
53+
"input": {
54+
"hint": "[<objective>|clear|edit|pause|resume]"
55+
}
56+
},
5057
{
5158
"name": "logout",
5259
"description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.",

src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@
4747
"description": "Summarize conversation to avoid hitting the context limit.",
4848
"input": null
4949
},
50+
{
51+
"name": "goal",
52+
"description": "Set, pause, resume, or clear a task goal.",
53+
"input": {
54+
"hint": "[<objective>|clear|edit|pause|resume]"
55+
}
56+
},
5057
{
5158
"name": "logout",
5259
"description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.",

0 commit comments

Comments
 (0)