diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 6fc34438..550fc9c5 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -28,6 +28,7 @@ import type { GetAccountResponse, ListMcpServerStatusResponse, Model, + ReviewTarget, SkillsListParams, SkillsListResponse, Thread, @@ -274,6 +275,22 @@ export class CodexAcpClient { await this.codexClient.threadArchive({threadId: sessionId}); } + async runReview( + sessionId: string, + target: ReviewTarget, + onTurnStarted?: (turnId: string) => void, + ): Promise { + return await this.codexClient.runReview({ + threadId: sessionId, + target, + delivery: "inline", + }, onTurnStarted); + } + + async runCompact(sessionId: string): Promise { + await this.codexClient.runCompact({threadId: sessionId}); + } + async awaitMcpServerStartup(serverNames: Array, afterVersion: number): Promise { return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 9b92c1fe..99da1dd7 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1259,8 +1259,34 @@ export class CodexAcpServer implements acp.Agent { approvalHandler, elicitationHandler); - if (await this.availableCommands.tryHandleCommand(params.prompt, sessionState)) { + const commandResult = await this.availableCommands.tryHandleCommand(params.prompt, sessionState); + if (commandResult.handled) { logger.log("Prompt handled by a command"); + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + if (commandResult.turnCompleted?.turn.status === "interrupted") { + if (!this.sessionIsClosing(params.sessionId) && this.sessions.has(params.sessionId)) { + await this.connection.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "*Conversation interrupted*" + } + } + }); + } + return { + stopReason: "cancelled", + usage: this.buildPromptUsage(sessionState.lastTokenUsage), + _meta: this.buildQuotaMeta(sessionState), + }; + } + const error = eventHandler.getFailure() + if (error) { + // noinspection ExceptionCaughtLocallyJS + throw error; + } return { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 0bb5e1a4..cf8827a9 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -21,11 +21,15 @@ import type { McpServerStatusUpdatedNotification, ModelListParams, ModelListResponse, + ReviewStartParams, + ReviewStartResponse, SkillsExtraRootsSetParams, SkillsListParams, SkillsListResponse, ThreadArchiveParams, ThreadArchiveResponse, + ThreadCompactStartParams, + ThreadCompactStartResponse, ThreadLoadedListParams, ThreadLoadedListResponse, ThreadListParams, @@ -47,6 +51,7 @@ import type { CommandExecutionRequestApprovalResponse, FileChangeRequestApprovalParams, FileChangeRequestApprovalResponse, + ItemCompletedNotification, } from "./app-server/v2"; export interface ApprovalHandler { @@ -99,6 +104,7 @@ export class CodexAppServerClient { private readonly mcpServerStartupStates = new Map(); private readonly mcpServerStartupResolvers: Array = []; private readonly pendingTurnCompletionResolvers = new Map void>>(); + private readonly pendingCompactionCompletionResolvers = new Map void>>(); private readonly turnCompletionCaptures = new Map void>>(); private readonly staleTurnIds = new Map>(); @@ -118,6 +124,9 @@ export class CodexAppServerClient { if (isTurnCompletedNotification(serverNotification)) { this.recordTurnCompleted(serverNotification.params); } + if (isCompactionCompletedNotification(serverNotification)) { + this.recordCompactionCompleted(serverNotification); + } const routing = extractTurnRouting(serverNotification); const staleTurnNotification = this.isStaleTurn(routing.threadId, routing.turnId); if (staleTurnNotification) { @@ -213,10 +222,40 @@ export class CodexAppServerClient { } } + async runReview(params: ReviewStartParams, onTurnStarted?: (turnId: string) => void): Promise { + const capturedCompletions: Array = []; + const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => { + capturedCompletions.push(event); + }); + + try { + const reviewStarted = await this.reviewStart(params); + onTurnStarted?.(reviewStarted.turn.id); + const earlyCompletion = capturedCompletions.find(event => event.turn.id === reviewStarted.turn.id); + releaseCapture(); + if (earlyCompletion) { + return earlyCompletion; + } + return await this.awaitTurnCompleted(reviewStarted.reviewThreadId, reviewStarted.turn.id); + } finally { + releaseCapture(); + } + } + + async runCompact(params: ThreadCompactStartParams): Promise { + const compactionCompleted = this.awaitCompactionCompleted(params.threadId); + await this.threadCompactStart(params); + return await compactionCompleted; + } + async turnInterrupt(params: TurnInterruptParams): Promise { return await this.sendRequest({ method: "turn/interrupt", params: params }); } + async reviewStart(params: ReviewStartParams): Promise { + return await this.sendRequest({ method: "review/start", params: params }); + } + markTurnStale(threadId: string, turnId: string): void { const threadStaleTurns = this.staleTurnIds.get(threadId) ?? new Set(); threadStaleTurns.add(turnId); @@ -251,6 +290,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/unsubscribe", params: params }); } + async threadCompactStart(params: ThreadCompactStartParams): Promise { + return await this.sendRequest({ method: "thread/compact/start", params: params }); + } + async listMcpServerStatus(params: ListMcpServerStatusParams): Promise { return await this.sendRequest({ method: "mcpServerStatus/list", params }); } @@ -303,6 +346,14 @@ export class CodexAppServerClient { }); } + async awaitCompactionCompleted(threadId: string): Promise { + return await new Promise((resolve) => { + const resolvers = this.pendingCompactionCompletionResolvers.get(threadId) ?? new Set(); + resolvers.add(resolve); + this.pendingCompactionCompletionResolvers.set(threadId, resolvers); + }); + } + resolveTurnInterrupted(threadId: string, turnId: string): void { this.recordTurnCompleted({ threadId, @@ -380,6 +431,21 @@ export class CodexAppServerClient { } } + private recordCompactionCompleted(event: CompactionCompletedNotification): void { + const threadId = extractThreadId(event); + if (threadId === null) { + return; + } + const resolvers = this.pendingCompactionCompletionResolvers.get(threadId); + if (!resolvers) { + return; + } + this.pendingCompactionCompletionResolvers.delete(threadId); + for (const resolve of resolvers) { + resolve(event); + } + } + private isStaleTurn(threadId: string | null, turnId: string | null): boolean { if (threadId === null || turnId === null) { return false; @@ -493,6 +559,10 @@ export type CodexConnectionEvent = | ({ eventType: "response" } & unknown) | ({ eventType: "notification" } & ServerNotification); +export type CompactionCompletedNotification = + | { method: "thread/compacted", params: Extract["params"] } + | { method: "item/completed", params: ItemCompletedNotification & { item: Extract } }; + type CodexRequest = DistributiveOmit type DistributiveOmit = T extends any @@ -525,6 +595,13 @@ function isTurnCompletedNotification(notification: ServerNotification): notifica return notification.method === "turn/completed"; } +function isCompactionCompletedNotification(notification: ServerNotification): notification is CompactionCompletedNotification { + if (notification.method === "thread/compacted") { + return true; + } + return notification.method === "item/completed" && notification.params.item.type === "contextCompaction"; +} + function extractThreadId(notification: ServerNotification): string | null { const params = notification.params as { threadId?: unknown } | undefined; if (params && typeof params.threadId === "string") { diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index a4ca15e4..25b8ac2f 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -2,12 +2,21 @@ import type * as acp from "@agentclientprotocol/sdk"; import type {AgentSideConnection, AvailableCommand} from "@agentclientprotocol/sdk"; import {ACPSessionConnection} from "./ACPSessionConnection"; import type {CodexAcpClient} from "./CodexAcpClient"; -import type {RateLimitSnapshot, SkillsListEntry} from "./app-server/v2"; +import type {RateLimitSnapshot, ReviewTarget, SkillsListEntry, TurnCompletedNotification} from "./app-server/v2"; import type {SessionState} from "./CodexAcpServer"; import type {RateLimitsMap} from "./RateLimitsMap"; import type {TokenCount} from "./TokenCount"; import {logger} from "./Logger"; +type ParsedSlashCommand = { + name: string; + rest: string; +}; + +export type CommandHandleResult = + | { handled: false } + | { handled: true, turnCompleted?: TurnCompletedNotification }; + export class CodexCommands { private readonly connection: AgentSideConnection; private readonly codexAcpClient: CodexAcpClient; @@ -83,6 +92,26 @@ export class CodexCommands { description: "Display session configuration and token usage.", input: null }, + { + name: "review", + description: "Review uncommitted changes, or review with custom instructions.", + input: { hint: "optional review instructions" } + }, + { + name: "review-branch", + description: "Review changes relative to a base branch.", + input: { hint: "branch name" } + }, + { + name: "review-commit", + description: "Review a specific commit.", + input: { hint: "commit sha" } + }, + { + name: "compact", + description: "Summarize conversation to avoid hitting the context limit.", + input: null + }, { name: "logout", description: "Sign out of Codex. This option is available when you are logged in via ChatGPT.", @@ -91,7 +120,7 @@ export class CodexCommands { ]; } - private getCommandName(prompt: acp.ContentBlock[]): string | null { + private parseCommand(prompt: acp.ContentBlock[]): ParsedSlashCommand | null { const firstBlock = prompt[0]; if (!firstBlock || firstBlock.type != "text") return null; @@ -102,16 +131,54 @@ export class CodexCommands { if (commandText.length === 0) return null; const [name] = commandText.split(/\s+/); - return name?.toLowerCase() ?? null; + if (!name) return null; + + return { + name: name.toLowerCase(), + rest: commandText.slice(name.length).trim(), + }; } - async tryHandleCommand(prompt: acp.ContentBlock[], sessionState: SessionState): Promise { - const commandName = this.getCommandName(prompt); - if (commandName === null) return false; - if (commandName.startsWith("$")) return false; + async tryHandleCommand(prompt: acp.ContentBlock[], sessionState: SessionState): Promise { + const command = this.parseCommand(prompt); + if (command === null) return { handled: false }; + const commandName = command.name; + if (commandName.startsWith("$")) return { handled: false }; const sessionId = sessionState.sessionId; switch (commandName) { + case "compact": { + await this.runWithProcessCheck(() => this.codexAcpClient.runCompact(sessionId)); + return { handled: true }; + } + case "review": { + const target = this.buildReviewTarget(command.rest); + const turnCompleted = await this.runReviewCommand(sessionState, target); + return { handled: true, turnCompleted }; + } + case "review-branch": { + if (command.rest.length === 0) { + await this.sendCommandUsageMessage(commandName, "branch name", sessionId); + return { handled: true }; + } + const turnCompleted = await this.runReviewCommand(sessionState, { + type: "baseBranch", + branch: command.rest, + }); + return { handled: true, turnCompleted }; + } + case "review-commit": { + if (command.rest.length === 0) { + await this.sendCommandUsageMessage(commandName, "commit sha", sessionId); + return { handled: true }; + } + const turnCompleted = await this.runReviewCommand(sessionState, { + type: "commit", + sha: command.rest, + title: null, + }); + return { handled: true, turnCompleted }; + } case "status": { const session = new ACPSessionConnection(this.connection, sessionId); const message = this.buildStatusMessage(sessionState); @@ -119,7 +186,7 @@ export class CodexCommands { sessionUpdate: "agent_message_chunk", content: { type: "text", text: message } }); - return true; + return { handled: true }; } case "logout": { await this.runWithProcessCheck(() => this.codexAcpClient.logout()); @@ -128,7 +195,7 @@ export class CodexCommands { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Logged out from Codex account." } }); - return true; + return { handled: true }; } case "skills": { const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills()); @@ -145,7 +212,7 @@ export class CodexCommands { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }); - return true; + return { handled: true }; } case "mcp": { const servers = await this.runWithProcessCheck(() => this.codexAcpClient.listMcpServers()); @@ -166,12 +233,43 @@ export class CodexCommands { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }); - return true; + return { handled: true }; } default: await this.sendUnknownCommandMessage(commandName, sessionId); - return true; + return { handled: true }; + } + } + + private async runReviewCommand(sessionState: SessionState, target: ReviewTarget): Promise { + return await this.runWithProcessCheck(() => this.codexAcpClient.runReview( + sessionState.sessionId, + target, + (turnId) => { + sessionState.currentTurnId = turnId; + }, + )); + } + + private buildReviewTarget(instructions: string): ReviewTarget { + if (instructions.length === 0) { + return { type: "uncommittedChanges" }; } + return { + type: "custom", + instructions, + }; + } + + private async sendCommandUsageMessage(name: string, inputHint: string, sessionId: string): Promise { + const session = new ACPSessionConnection(this.connection, sessionId); + await session.update({ + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `Command "/${name}" requires ${inputHint}.` + } + }); } private async sendUnknownCommandMessage(name: string, sessionId: string): Promise { diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 5b1d520a..642ec40a 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -137,13 +137,7 @@ export class CodexEventHandler { case "item/autoApprovalReview/completed": return this.handleGuardianApprovalReviewCompleted(notification.params); case "thread/compacted": - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "*Context compacted to fit the model's context window.*\n\n" - } - }; + return this.createContextCompactedEvent(); case "model/rerouted": return this.createModelReroutedEvent(notification.params); case "fuzzyFileSearch/sessionUpdated": @@ -362,13 +356,39 @@ export class CodexEventHandler { case "imageView": case "imageGeneration": case "enteredReviewMode": - case "exitedReviewMode": - case "contextCompaction": case "plan": return null; + case "exitedReviewMode": + return this.createExitedReviewModeEvent(event.item); + case "contextCompaction": + return this.createContextCompactedEvent(); } } + private createExitedReviewModeEvent(item: ThreadItem & { type: "exitedReviewMode" }): UpdateSessionEvent | null { + const text = item.review.trim(); + if (text.length === 0) { + return null; + } + return { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text, + } + }; + } + + private createContextCompactedEvent(): UpdateSessionEvent { + return { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "*Context compacted to fit the model's context window.*\n\n" + } + }; + } + private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent { return { sessionUpdate: "tool_call_update", diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 355e330a..485fdc13 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -13,7 +13,7 @@ import { import type {ServerNotification} from "../../app-server"; import type {SessionState} from "../../CodexAcpServer"; import {AgentMode} from "../../AgentMode"; -import type {Model, TurnStartParams} from "../../app-server/v2"; +import type {Model, ReviewStartResponse, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; @@ -815,6 +815,151 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(mockFixture.getAcpConnectionDump([])).toBe(""); }); + it('handles review slash commands through Codex app server', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture(); + const reviewStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "reviewStart") + .mockResolvedValue(createReviewStartResponse()); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/review" }], + }); + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/review focus on API compatibility" }], + }); + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/review-branch main" }], + }); + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/review-commit abc123" }], + }); + + expect(reviewStartSpy).toHaveBeenNthCalledWith(1, { + threadId: "session-id", + target: { type: "uncommittedChanges" }, + delivery: "inline", + }); + expect(reviewStartSpy).toHaveBeenNthCalledWith(2, { + threadId: "session-id", + target: { type: "custom", instructions: "focus on API compatibility" }, + delivery: "inline", + }); + expect(reviewStartSpy).toHaveBeenNthCalledWith(3, { + threadId: "session-id", + target: { type: "baseBranch", branch: "main" }, + delivery: "inline", + }); + expect(reviewStartSpy).toHaveBeenNthCalledWith(4, { + threadId: "session-id", + target: { type: "commit", sha: "abc123", title: null }, + delivery: "inline", + }); + expect(turnStartSpy).not.toHaveBeenCalled(); + }); + + it('waits for review slash command completion', async () => { + const { mockFixture } = setupPromptFixture(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "reviewStart") + .mockResolvedValue(createReviewStartResponse()); + let completeReview: (value: TurnCompletedNotification) => void = () => {}; + const reviewCompletedPromise = new Promise((resolve) => { + completeReview = resolve; + }); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockReturnValue(reviewCompletedPromise); + + let promptResolved = false; + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/review" }], + }).then((response) => { + promptResolved = true; + return response; + }); + + await vi.waitFor(() => { + expect(mockFixture.getCodexAppServerClient().awaitTurnCompleted).toHaveBeenCalledWith( + "session-id", + "review-turn-id", + ); + }); + await Promise.resolve(); + expect(promptResolved).toBe(false); + + completeReview(createReviewCompletedNotification()); + await expect(promptPromise).resolves.toEqual(expect.objectContaining({ + stopReason: "end_turn", + })); + expect(promptResolved).toBe(true); + }); + + it('returns cancelled when review slash command is interrupted', async () => { + const { mockFixture } = setupPromptFixture(); + vi.spyOn(mockFixture.getCodexAppServerClient(), "reviewStart") + .mockResolvedValue(createReviewStartResponse()); + vi.spyOn(mockFixture.getCodexAppServerClient(), "awaitTurnCompleted") + .mockResolvedValue(createReviewCompletedNotification("interrupted")); + + const response = await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/review" }], + }); + + expect(response.stopReason).toBe("cancelled"); + }); + + it('waits for compact slash command completion', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture(); + const compactStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadCompactStart") + .mockResolvedValue({}); + + let promptResolved = false; + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/compact" }], + }).then((response) => { + promptResolved = true; + return response; + }); + + await vi.waitFor(() => { + expect(compactStartSpy).toHaveBeenCalledWith({ threadId: "session-id" }); + }); + await Promise.resolve(); + expect(promptResolved).toBe(false); + + mockFixture.sendServerNotification({ + method: "thread/compacted", + params: { threadId: "session-id", turnId: "compact-turn-id" }, + }); + + await expect(promptPromise).resolves.toEqual(expect.objectContaining({ + stopReason: "end_turn", + })); + expect(promptResolved).toBe(true); + expect(turnStartSpy).not.toHaveBeenCalled(); + expect(mockFixture.getAcpConnectionDump([])).toContain("Context compacted"); + }); + + it('reports missing review slash command input', async () => { + const { mockFixture } = setupPromptFixture(); + const reviewStartSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "reviewStart") + .mockResolvedValue(createReviewStartResponse()); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/review-branch" }], + }); + + expect(reviewStartSpy).not.toHaveBeenCalled(); + const [event] = mockFixture.getAcpConnectionEvents([]); + expect(event).toBeDefined(); + expect(event!.args[0].update.content.text).toBe('Command "/review-branch" requires branch name.'); + }); + it('handles logout command', async () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); @@ -1002,6 +1147,38 @@ describe('ACP server test', { timeout: 40_000 }, () => { return { mockFixture, sessionState, turnStartSpy }; } + function createReviewStartResponse(): ReviewStartResponse { + return { + reviewThreadId: "session-id", + turn: { + id: "review-turn-id", + items: [], + itemsView: "notLoaded", + status: "inProgress", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + } + }; + } + + function createReviewCompletedNotification(status: "completed" | "interrupted" = "completed"): TurnCompletedNotification { + return { + threadId: "session-id", + turn: { + id: "review-turn-id", + items: [], + itemsView: "notLoaded", + status, + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + } + }; + } + it ('should disable resasoning.summary if key authorization is used', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ account: { type: "apiKey" } }); @@ -1140,6 +1317,69 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/thread-compacted.json"); }); + it ('should surface contextCompaction item as user-visible message', async () => { + const sessionId = "test-session-id"; + const { mockFixture } = setupPromptFixture({ sessionId }); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [{ type: "text", text: "test" }], + }); + + mockFixture.clearAcpConnectionDump(); + + mockFixture.sendServerNotification({ + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-id", + completedAtMs: 0, + item: { type: "contextCompaction", id: "context-compaction-id" }, + }, + }); + + await vi.waitFor(() => { + const dump = mockFixture.getAcpConnectionDump([]); + expect(dump.length).toBeGreaterThan(0); + }); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/thread-compacted.json"); + }); + + it ('should surface exitedReviewMode item as user-visible review output', async () => { + const sessionId = "test-session-id"; + const { mockFixture } = setupPromptFixture({ sessionId }); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [{ type: "text", text: "test" }], + }); + + mockFixture.clearAcpConnectionDump(); + + mockFixture.sendServerNotification({ + method: "item/completed", + params: { + threadId: sessionId, + turnId: "turn-id", + completedAtMs: 0, + item: { + type: "exitedReviewMode", + id: "review-output-id", + review: "No findings.", + }, + }, + }); + + await vi.waitFor(() => { + const events = mockFixture.getAcpConnectionEvents([]); + expect(events.length).toBeGreaterThan(0); + }); + + const [event] = mockFixture.getAcpConnectionEvents([]); + expect(event!.args[0].update.content.text).toBe("No findings."); + }); + it ('should accumulate rate limits from multiple notifications', async () => { const sessionId = "test-session-id"; const { mockFixture, sessionState } = setupPromptFixture({ sessionId }); diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index 42999913..d805f6b0 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -21,6 +21,32 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "review", + "description": "Review uncommitted changes, or review with custom instructions.", + "input": { + "hint": "optional review instructions" + } + }, + { + "name": "review-branch", + "description": "Review changes relative to a base branch.", + "input": { + "hint": "branch name" + } + }, + { + "name": "review-commit", + "description": "Review a specific commit.", + "input": { + "hint": "commit sha" + } + }, + { + "name": "compact", + "description": "Summarize conversation to avoid hitting the context limit.", + "input": null + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index cc64ab60..dc1b0101 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -21,6 +21,32 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "review", + "description": "Review uncommitted changes, or review with custom instructions.", + "input": { + "hint": "optional review instructions" + } + }, + { + "name": "review-branch", + "description": "Review changes relative to a base branch.", + "input": { + "hint": "branch name" + } + }, + { + "name": "review-commit", + "description": "Review a specific commit.", + "input": { + "hint": "commit sha" + } + }, + { + "name": "compact", + "description": "Summarize conversation to avoid hitting the context limit.", + "input": null + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 8e962238..44753465 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -21,6 +21,32 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "review", + "description": "Review uncommitted changes, or review with custom instructions.", + "input": { + "hint": "optional review instructions" + } + }, + { + "name": "review-branch", + "description": "Review changes relative to a base branch.", + "input": { + "hint": "branch name" + } + }, + { + "name": "review-commit", + "description": "Review a specific commit.", + "input": { + "hint": "commit sha" + } + }, + { + "name": "compact", + "description": "Summarize conversation to avoid hitting the context limit.", + "input": null + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.",