diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 39ed8998..d62f4bdd 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -102,6 +102,9 @@ export class CodexAcpClient { if (!gatewaySettings) throw RequestError.invalidRequest(); const baseUrl = gatewaySettings.baseUrl; + const providerName = typeof gatewaySettings.providerName === "string" && gatewaySettings.providerName.trim().length > 0 + ? gatewaySettings.providerName + : "User-provided gateway"; const headers: Record = { "X-Client-Feature-ID": "codex", ...gatewaySettings.headers @@ -110,7 +113,7 @@ export class CodexAcpClient { this.gatewayConfig = { modelProvider: "custom-gateway", config: { - name: "User-provided gateway", + name: providerName, base_url: baseUrl, http_headers: headers, wire_api: "responses" @@ -359,7 +362,7 @@ export class CodexAcpClient { async subscribeToSessionEvents( sessionId: string, - eventHandler: (result: ServerNotification) => void, + eventHandler: (result: ServerNotification) => void | Promise, approvalHandler: ApprovalHandler ) { this.codexClient.onServerNotification(sessionId, eventHandler); @@ -393,7 +396,9 @@ export class CodexAcpClient { // Wait for turn completion // If turnInterrupt() was called, Codex will send turn/completed event with status "interrupted" - return await this.codexClient.awaitTurnCompleted(); + const turnCompleted = await this.codexClient.awaitTurnCompleted(); + await this.codexClient.flushServerNotifications(request.sessionId); + return turnCompleted; } async listSkills(params?: SkillsListParams): Promise { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index e96576d5..01632644 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -35,6 +35,7 @@ import { createFileChangeUpdate, createMcpToolCallUpdate, } from "./CodexToolCallMapper"; +import { createApprovalContextStore } from "./CodexApprovalContext"; export interface SessionState { sessionId: string, @@ -705,8 +706,9 @@ export class CodexAcpServer implements acp.Agent { sessionState.lastTokenUsage = null; try { - const eventHandler = new CodexEventHandler(this.connection, sessionState); - const approvalHandler = new CodexApprovalHandler(this.connection, sessionState); + const approvalContext = createApprovalContextStore(); + const eventHandler = new CodexEventHandler(this.connection, sessionState, approvalContext); + const approvalHandler = new CodexApprovalHandler(this.connection, sessionState, approvalContext); await this.codexAcpClient.subscribeToSessionEvents(params.sessionId, (event) => eventHandler.handleNotification(event), approvalHandler); diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 8fb6ce71..8471b78f 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -34,11 +34,15 @@ import type { SkillsListResponse, ListMcpServerStatusParams, ListMcpServerStatusResponse, ConfigReadParams, ConfigReadResponse, + McpServerElicitationRequestParams, + McpServerElicitationRequestResponse, } from "./app-server/v2"; +import { logger } from "./Logger"; export interface ApprovalHandler { handleCommandExecution(params: CommandExecutionRequestApprovalParams): Promise; handleFileChange(params: FileChangeRequestApprovalParams): Promise; + handleMcpServerElicitation(params: McpServerElicitationRequestParams): Promise; } const CommandExecutionApprovalRequest = new RequestType< @@ -53,6 +57,12 @@ const FileChangeApprovalRequest = new RequestType< void >('item/fileChange/requestApproval'); +const McpServerElicitationRequest = new RequestType< + McpServerElicitationRequestParams, + McpServerElicitationRequestResponse, + void +>('mcpServer/elicitation/request'); + /** * A type-safe client over the Codex App Server's JSON-RPC API. * Maps each request to its expected response and exposes clear, typed methods for supported JSON-RPC operations. @@ -60,6 +70,8 @@ const FileChangeApprovalRequest = new RequestType< export class CodexAppServerClient { readonly connection: MessageConnection; private approvalHandlers = new Map(); + private readonly notificationHandlers = new Map void | Promise>(); + private readonly notificationQueues = new Map | null>(); private mcpStartupCompleteVersion = 0; private lastMcpStartupComplete: McpStartupCompleteEvent | null = null; private readonly mcpStartupCompleteResolvers: Array> = []; @@ -98,6 +110,14 @@ export class CodexAppServerClient { } return await handler.handleFileChange(params); }); + + this.connection.onRequest(McpServerElicitationRequest, async (params) => { + const handler = this.approvalHandlers.get(params.threadId); + if (!handler) { + return { action: "cancel", content: null, _meta: null }; + } + return await handler.handleMcpServerElicitation(params); + }); } onApprovalRequest(threadId: string, handler: ApprovalHandler): void { @@ -190,8 +210,9 @@ export class CodexAppServerClient { * Registers a notification handler for a specific session. * Replaces any existing handler for the same session, preventing handler accumulation. */ - onServerNotification(sessionId: string, callback: (event: ServerNotification) => void) { + onServerNotification(sessionId: string, callback: (event: ServerNotification) => void | Promise) { this.notificationHandlers.set(sessionId, callback); + this.notificationQueues.set(sessionId, null); } private codexEventHandlers: Array<(event: CodexConnectionEvent) => void> = []; @@ -199,13 +220,46 @@ export class CodexAppServerClient { this.codexEventHandlers.push(callback); } - private notificationHandlers = new Map void>(); private notify(notification: ServerNotification) { - for (const notificationHandler of this.notificationHandlers.values()) { - notificationHandler(notification); + for (const [sessionId, notificationHandler] of this.notificationHandlers.entries()) { + const queue = this.notificationQueues.get(sessionId); + if (queue) { + const next = queue + .then(() => notificationHandler(notification)) + .catch((error) => { + logger.error("Error handling server notification", error); + }); + this.notificationQueues.set(sessionId, this.trackNotificationQueue(sessionId, next)); + continue; + } + + try { + const result = notificationHandler(notification); + if (result instanceof Promise) { + const next = result.catch((error) => { + logger.error("Error handling server notification", error); + }); + this.notificationQueues.set(sessionId, this.trackNotificationQueue(sessionId, next)); + } + } catch (error) { + logger.error("Error handling server notification", error); + } } } + async flushServerNotifications(sessionId: string): Promise { + await (this.notificationQueues.get(sessionId) ?? Promise.resolve()); + } + + private trackNotificationQueue(sessionId: string, queue: Promise): Promise { + const trackedQueue = queue.finally(() => { + if (this.notificationQueues.get(sessionId) === trackedQueue) { + this.notificationQueues.set(sessionId, null); + } + }); + return trackedQueue; + } + private resolveSignal( event: T, version: number, diff --git a/src/CodexApprovalContext.ts b/src/CodexApprovalContext.ts new file mode 100644 index 00000000..551d605f --- /dev/null +++ b/src/CodexApprovalContext.ts @@ -0,0 +1,13 @@ +import type { ThreadItem } from "./app-server/v2"; + +export interface ApprovalContextStore { + readonly fileChangesByItemId: Map; + readonly turnDiffsByTurnId: Map; +} + +export function createApprovalContextStore(): ApprovalContextStore { + return { + fileChangesByItemId: new Map(), + turnDiffsByTurnId: new Map(), + }; +} diff --git a/src/CodexApprovalHandler.ts b/src/CodexApprovalHandler.ts index 3b0643c0..a7f16a2e 100644 --- a/src/CodexApprovalHandler.ts +++ b/src/CodexApprovalHandler.ts @@ -5,11 +5,21 @@ import type { CommandExecutionRequestApprovalParams, CommandExecutionRequestApprovalResponse, FileChangeRequestApprovalParams, - FileChangeRequestApprovalResponse + FileChangeRequestApprovalResponse, + McpServerElicitationRequestParams, + McpServerElicitationRequestResponse } from "./app-server/v2"; +import type {JsonValue} from "./app-server/serde_json/JsonValue"; import type {ToolCallContent} from "@agentclientprotocol/sdk/dist/schema/types.gen"; import {logger} from "./Logger"; import {stripShellPrefix} from "./CodexEventHandler"; +import type {ApprovalContextStore} from "./CodexApprovalContext"; +import { + createFileChangeContents, + createFileChangeLocations, + createRawFileChangeInput, + parseUnifiedDiffChanges, +} from "./CodexToolCallMapper"; const APPROVAL_OPTIONS: acp.PermissionOption[] = [ { optionId: "allow_once", name: "Allow Once", kind: "allow_once" }, @@ -17,16 +27,27 @@ const APPROVAL_OPTIONS: acp.PermissionOption[] = [ { optionId: "reject_once", name: "Reject", kind: "reject_once" }, ]; +const ELICITATION_ALLOW_ONCE_OPTION_ID = "allow_once"; +const ELICITATION_ALLOW_SESSION_OPTION_ID = "allow_for_session"; +const ELICITATION_ALLOW_ALWAYS_OPTION_ID = "allow_always"; +const ELICITATION_DENY_ONCE_OPTION_ID = "deny_once"; + +type ElicitationPersistOption = "session" | "always"; +type JsonObject = { [key: string]: JsonValue }; + export class CodexApprovalHandler implements ApprovalHandler { private readonly connection: acp.AgentSideConnection; private readonly sessionState: SessionState; + private readonly approvalContext: ApprovalContextStore; constructor( connection: acp.AgentSideConnection, - sessionState: SessionState + sessionState: SessionState, + approvalContext: ApprovalContextStore, ) { this.connection = connection; this.sessionState = sessionState; + this.approvalContext = approvalContext; } async handleCommandExecution( @@ -48,7 +69,7 @@ export class CodexApprovalHandler implements ApprovalHandler { ): Promise { try { const sessionId = this.sessionState.sessionId; - const acpRequest = this.buildFileChangePermissionRequest(sessionId, params); + const acpRequest = await this.buildFileChangePermissionRequest(sessionId, params); const response = await this.connection.requestPermission(acpRequest); return this.convertFileChangeResponse(response); } catch (error) { @@ -57,11 +78,25 @@ export class CodexApprovalHandler implements ApprovalHandler { } } + async handleMcpServerElicitation( + params: McpServerElicitationRequestParams + ): Promise { + try { + const sessionId = this.sessionState.sessionId; + const acpRequest = this.buildMcpServerElicitationPermissionRequest(sessionId, params); + const response = await this.connection.requestPermission(acpRequest); + return this.convertMcpServerElicitationResponse(params, response); + } catch (error) { + logger.error("Error requesting MCP server elicitation approval", error); + return this.createCancelledMcpServerElicitationResponse(); + } + } + private buildCommandPermissionRequest( sessionId: string, params: CommandExecutionRequestApprovalParams ): acp.RequestPermissionRequest { - const reasonContent = this.createContentFromReason(params.reason ?? null); + const reasonContent = this.createTextContent(params.reason ?? null); return { sessionId, toolCall: { @@ -75,36 +110,171 @@ export class CodexApprovalHandler implements ApprovalHandler { }; } - private createContentFromReason(reason: string | null): ToolCallContent | null { - if (reason === null || reason === "") { + private createTextContent(text: string | null): ToolCallContent | null { + if (text === null || text === "") { return null; } return { type: "content", content: { type: "text", - text: reason + text } } } - private buildFileChangePermissionRequest( + private async buildFileChangePermissionRequest( sessionId: string, params: FileChangeRequestApprovalParams + ): Promise { + const reasonContent = this.createTextContent(params.reason ?? null); + const fileChange = this.approvalContext.fileChangesByItemId.get(params.itemId); + const content: ToolCallContent[] = reasonContent ? [reasonContent] : []; + const toolCall: acp.ToolCallUpdate = { + toolCallId: params.itemId, + kind: "edit", + status: "pending", + }; + if (fileChange) { + content.push(...await createFileChangeContents(fileChange.changes)); + toolCall.locations = createFileChangeLocations(fileChange.changes); + toolCall.rawInput = createRawFileChangeInput(fileChange.changes); + } else { + const turnDiff = this.approvalContext.turnDiffsByTurnId.get(params.turnId); + if (turnDiff) { + const parsedChanges = parseUnifiedDiffChanges(turnDiff); + content.push(...await createFileChangeContents(parsedChanges)); + const locations = createFileChangeLocations(parsedChanges); + if (locations.length > 0) { + toolCall.locations = locations; + } + toolCall.rawInput = parsedChanges.length > 0 + ? { unifiedDiff: turnDiff, ...createRawFileChangeInput(parsedChanges) } + : { unifiedDiff: turnDiff }; + } + } + if (content.length > 0) { + toolCall.content = content; + } + return { + sessionId, + toolCall, + options: APPROVAL_OPTIONS, + }; + } + + private buildMcpServerElicitationPermissionRequest( + sessionId: string, + params: McpServerElicitationRequestParams ): acp.RequestPermissionRequest { - const reasonContent = this.createContentFromReason(params.reason ?? null); + const meta = this.asRecord(params._meta); + const persist = meta?.["persist"]; + const persistOptions = (Array.isArray(persist) ? persist : [persist]).filter( + (value): value is ElicitationPersistOption => value === "session" || value === "always" + ); + const toolDescription = typeof meta?.["tool_description"] === "string" + ? meta["tool_description"] + : null; + const toolDescriptionContent = this.createTextContent(toolDescription); + const rawInput = this.tryToJsonValue(meta?.["tool_params"]) ?? null; + return { sessionId, toolCall: { - toolCallId: params.itemId, - kind: "edit", + toolCallId: this.buildMcpServerElicitationToolCallId(params), + title: params.message !== "" ? params.message : "MCP permission request", + kind: "other", status: "pending", - content: reasonContent ? [reasonContent] : null, + content: toolDescriptionContent ? [toolDescriptionContent] : null, + rawInput, }, - options: APPROVAL_OPTIONS, + options: this.buildMcpServerElicitationOptions(persistOptions), }; } + private buildMcpServerElicitationToolCallId( + params: McpServerElicitationRequestParams + ): string { + return `mcp-elicitation:${params.serverName}:${params.turnId ?? params.threadId}:${params.mode}`; + } + + private buildMcpServerElicitationOptions( + persistOptions: Array + ): Array { + const options: Array = [ + { optionId: ELICITATION_ALLOW_ONCE_OPTION_ID, name: "Allow Once", kind: "allow_once" }, + ] + + if (persistOptions.includes("session")) { + options.push({ + optionId: ELICITATION_ALLOW_SESSION_OPTION_ID, + name: "Allow for Session", + kind: "allow_always", + }) + } + + if (persistOptions.includes("always")) { + options.push({ + optionId: ELICITATION_ALLOW_ALWAYS_OPTION_ID, + name: "Always Allow", + kind: "allow_always", + }) + } + + options.push({ + optionId: ELICITATION_DENY_ONCE_OPTION_ID, + name: "Deny Once", + kind: "reject_once", + }) + + return options + } + + private asRecord(value: unknown): Record | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null + } + return value as Record + } + + private tryToJsonValue(value: unknown): JsonValue | undefined { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value + } + + if (Array.isArray(value)) { + const convertedValues: Array = [] + for (const item of value) { + const convertedItem = this.tryToJsonValue(item) + if (convertedItem === undefined) { + return undefined + } + convertedValues.push(convertedItem) + } + return convertedValues + } + + if (typeof value === "object") { + const record = value as Record + const jsonObject: JsonObject = {} + for (const [key, nestedValue] of Object.entries(record)) { + const convertedValue = this.tryToJsonValue(nestedValue) + if (convertedValue === undefined) { + return undefined + } + jsonObject[key] = convertedValue + } + return jsonObject + } + + return undefined + } + private convertCommandResponse( response: acp.RequestPermissionResponse ): CommandExecutionRequestApprovalResponse { @@ -138,4 +308,42 @@ export class CodexApprovalHandler implements ApprovalHandler { return { decision: "cancel" }; } } + + private convertMcpServerElicitationResponse( + params: McpServerElicitationRequestParams, + response: acp.RequestPermissionResponse + ): McpServerElicitationRequestResponse { + if (response.outcome.outcome === "cancelled") { + return this.createCancelledMcpServerElicitationResponse(); + } + + switch (response.outcome.optionId) { + case ELICITATION_ALLOW_ONCE_OPTION_ID: + return this.createAcceptedMcpServerElicitationResponse(params, null); + case ELICITATION_ALLOW_SESSION_OPTION_ID: + return this.createAcceptedMcpServerElicitationResponse(params, { persist: "session" }); + case ELICITATION_ALLOW_ALWAYS_OPTION_ID: + return this.createAcceptedMcpServerElicitationResponse(params, { persist: "always" }); + case ELICITATION_DENY_ONCE_OPTION_ID: + case "reject_once": + return { action: "decline", content: null, _meta: null }; + default: + return this.createCancelledMcpServerElicitationResponse(); + } + } + + private createAcceptedMcpServerElicitationResponse( + params: McpServerElicitationRequestParams, + meta: JsonObject | null + ): McpServerElicitationRequestResponse { + return { + action: "accept", + content: params.mode === "form" ? {} : null, + _meta: meta, + } + } + + private createCancelledMcpServerElicitationResponse(): McpServerElicitationRequestResponse { + return { action: "cancel", content: null, _meta: null } + } } diff --git a/src/CodexAuthMethod.ts b/src/CodexAuthMethod.ts index 4e5fe2c0..e80f7ff7 100644 --- a/src/CodexAuthMethod.ts +++ b/src/CodexAuthMethod.ts @@ -49,6 +49,7 @@ export interface GatewayAuthRequest extends AuthenticateRequest { "gateway": { baseUrl: string; headers: Record; + providerName?: string; } }; } @@ -59,4 +60,4 @@ export type CodexAuthRequest = ApiKeyAuthRequest | ChatGPTAuthRequest | GatewayA export function isCodexAuthRequest(request: AuthenticateRequest): request is CodexAuthRequest { return request.methodId === "api-key" || request.methodId === "chat-gpt" || request.methodId === "gateway"; -} \ No newline at end of file +} diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index f771660b..81f4e1cd 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -25,6 +25,7 @@ import {toTokenCount} from "./TokenCount"; import { createCommandExecutionUpdate, createDynamicToolCallUpdate, + createFileChangeCompletionUpdate, createFileChangeUpdate, createMcpRawInput, createMcpRawOutput, @@ -34,6 +35,7 @@ import { fuzzyFileSearchToolCallId, } from "./CodexToolCallMapper"; import { stripShellPrefix } from "./CommandUtils"; +import type { ApprovalContextStore } from "./CodexApprovalContext"; export { stripShellPrefix }; @@ -41,12 +43,14 @@ export class CodexEventHandler { private readonly connection: acp.AgentSideConnection; private readonly sessionState: SessionState; + private readonly approvalContext: ApprovalContextStore; private failure: RequestError | null = null; private readonly activeFuzzyFileSearchSessions = new Set(); - constructor(connection: acp.AgentSideConnection, sessionState: SessionState) { + constructor(connection: acp.AgentSideConnection, sessionState: SessionState, approvalContext: ApprovalContextStore) { this.connection = connection; this.sessionState = sessionState; + this.approvalContext = approvalContext; } getFailure(): RequestError | null { @@ -100,7 +104,6 @@ export class CodexEventHandler { case "item/reasoning/summaryPartAdded": //skipped events case "item/reasoning/textDelta": //for raw output - case "turn/diff/updated": case "item/commandExecution/terminalInteraction": case "item/fileChange/outputDelta": case "serverRequest/resolved": @@ -108,6 +111,9 @@ export class CodexEventHandler { case "fs/changed": case "mcpServer/startupStatus/updated": return null; + case "turn/diff/updated": + this.approvalContext.turnDiffsByTurnId.set(notification.params.turnId, notification.params.diff); + return null; case "item/mcpToolCall/progress": return this.createMcpToolProgressEvent(notification.params); case "account/rateLimits/updated": @@ -189,6 +195,7 @@ export class CodexEventHandler { private async createItemEvent(event: ItemStartedNotification): Promise { switch (event.item.type) { case "fileChange": + this.approvalContext.fileChangesByItemId.set(event.item.id, event.item); return await createFileChangeUpdate(event.item); case "commandExecution": return await createCommandExecutionUpdate(event.item); @@ -215,6 +222,8 @@ export class CodexEventHandler { private async completeItemEvent(event: ItemCompletedNotification): Promise { switch (event.item.type) { case "fileChange": + this.approvalContext.fileChangesByItemId.set(event.item.id, event.item); + return createFileChangeCompletionUpdate(event.item); case "dynamicToolCall": return { sessionUpdate: "tool_call_update", diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index ab818662..95e412a1 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -39,19 +39,98 @@ function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus { export async function createFileChangeUpdate( item: ThreadItem & { type: "fileChange" } ): Promise { - const patches: ToolCallContent[] = []; - for (const change of item.changes) { - const content = await createPatchContent(change); - if (content) patches.push(content); - // ignore unparseable diffs - } + const patches = await createFileChangeContents(item.changes); + const details = createFileChangeDetails(item.changes); return { sessionUpdate: "tool_call", toolCallId: item.id, - title: "Editing files", + title: details.title, kind: "edit", status: toAcpStatus(item.status), content: patches, + locations: details.locations, + rawInput: details.rawInput, + }; +} + +export function createFileChangeCompletionUpdate( + item: ThreadItem & { type: "fileChange" } +): UpdateSessionEvent { + return { + sessionUpdate: "tool_call_update", + toolCallId: item.id, + status: toAcpStatus(item.status), + }; +} + +export async function createFileChangeContents(changes: Array): Promise { + const patches: ToolCallContent[] = []; + for (const change of changes) { + const content = await createPatchContent(change); + if (content) patches.push(content); + // ignore unparseable diffs + } + return patches; +} + +export function createFileChangeLocations(changes: Array): Array<{ path: string }> { + return Array.from(new Set(changes.map(change => change.path))).map(path => ({ path })); +} + +export function createRawFileChangeInput(changes: Array): { + changes: Array<{ + path: string; + kind: FileUpdateChange["kind"]; + diff: string; + }>; +} { + return { + changes: changes.map(change => ({ + path: change.path, + kind: change.kind, + diff: change.diff, + })), + }; +} + +export function parseUnifiedDiffChanges(unifiedDiff: string): Array { + try { + return parsePatch(unifiedDiff) + .map(patch => { + const oldFileName = normalizeDiffPath(patch.oldFileName); + const newFileName = normalizeDiffPath(patch.newFileName); + const path = newFileName === "/dev/null" ? oldFileName : newFileName; + if (!path) { + return null; + } + return { + path, + kind: toPatchChangeKind(oldFileName, newFileName), + diff: formatParsedPatch(patch, oldFileName, newFileName), + } satisfies FileUpdateChange; + }) + .filter((change): change is FileUpdateChange => change !== null); + } catch { + return []; + } +} + +function createFileChangeDetails(changes: Array): { + title: string; + locations: Array<{ path: string }>; + rawInput: { + changes: Array<{ + path: string; + kind: FileUpdateChange["kind"]; + diff: string; + }>; + }; +} { + const uniquePaths = createFileChangeLocations(changes); + return { + title: uniquePaths.length > 0 ? uniquePaths.map(location => location.path).join(", ") : "File change", + locations: uniquePaths, + rawInput: createRawFileChangeInput(changes), }; } @@ -308,6 +387,52 @@ function isUnifiedDiff(content: string): boolean { return content.startsWith("--- ") || content.includes("\n--- "); } +function normalizeDiffPath(fileName: string | undefined): string | undefined { + if (!fileName || fileName === "/dev/null") { + return fileName; + } + return fileName.replace(/^[ab]\//, ""); +} + +function toPatchChangeKind(oldFileName: string | undefined, newFileName: string | undefined): FileUpdateChange["kind"] { + if (oldFileName === "/dev/null") { + return { type: "add" }; + } + if (newFileName === "/dev/null") { + return { type: "delete" }; + } + return { + type: "update", + move_path: oldFileName && newFileName && oldFileName !== newFileName ? oldFileName : null, + }; +} + +function formatParsedPatch( + patch: ReturnType[number], + oldFileName: string | undefined, + newFileName: string | undefined, +): string { + const lines = [ + `--- ${oldFileName ?? "/dev/null"}`, + `+++ ${newFileName ?? "/dev/null"}`, + ]; + for (const hunk of patch.hunks) { + lines.push(`@@ -${formatHunkRange(hunk.oldStart, hunk.oldLines)} +${formatHunkRange(hunk.newStart, hunk.newLines)} @@`); + lines.push(...hunk.lines); + } + return lines.join("\n"); +} + +function formatHunkRange(start: number, lineCount: number): string { + if (lineCount === 0) { + return `${start - 1},0`; + } + if (lineCount === 1) { + return `${start}`; + } + return `${start},${lineCount}`; +} + /** * Recreates the content of a deleted file from the unified diff. * @param unifiedDiff The unified diff of the file deletion patch diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 6287b280..d708d0ca 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -983,6 +983,10 @@ describe('ACP server test', { timeout: 40_000 }, () => { } }); + await vi.waitFor(() => { + expect(sessionState.rateLimits?.size).toBe(2); + }); + expect(sessionState.rateLimits).not.toBeNull(); expect(sessionState.rateLimits!.size).toBe(2); expect(sessionState.rateLimits!.get("standard-limit")).toEqual({ diff --git a/src/__tests__/CodexACPAgent/approval-events.test.ts b/src/__tests__/CodexACPAgent/approval-events.test.ts index 7ee43746..3837b913 100644 --- a/src/__tests__/CodexACPAgent/approval-events.test.ts +++ b/src/__tests__/CodexACPAgent/approval-events.test.ts @@ -3,6 +3,26 @@ import type { CommandExecutionRequestApprovalParams, FileChangeRequestApprovalPa import { createCodexMockTestFixture, createTestSessionState, type CodexMockTestFixture } from '../acp-test-utils'; import type { SessionState } from '../../CodexAcpServer'; import {AgentMode} from "../../AgentMode"; +import type { ServerNotification } from '../../app-server'; + +const { mockFiles, mockFileContent, clearMockFiles } = vi.hoisted(() => { + const files = new Map(); + return { + mockFiles: files, + mockFileContent: (path: string, content: string) => files.set(path, content), + clearMockFiles: () => files.clear(), + }; +}); + +vi.mock('node:fs/promises', () => ({ + readFile: (path: string) => { + const content = mockFiles.get(path); + if (content !== undefined) { + return Promise.resolve(content); + } + return Promise.reject(new Error(`ENOENT: no such file or directory, open '${path}'`)); + }, +})); describe('Approval Events', () => { let fixture: CodexMockTestFixture; @@ -10,13 +30,14 @@ describe('Approval Events', () => { beforeEach(() => { fixture = createCodexMockTestFixture(); + clearMockFiles(); vi.clearAllMocks(); }); function setupSessionWithPendingPrompt() { const codexAcpAgent = fixture.getCodexAcpAgent(); - let resolveTurnCompleted: (value: { threadId: string; turn: { id: string; items: never[]; status: string; error: null } }) => void; + let resolveTurnCompleted: (value: { threadId: string; turn: { id: string; items: never[]; status: string; error: null } }) => void = () => {}; const turnCompletedPromise = new Promise<{ threadId: string; turn: { id: string; items: never[]; status: string; error: null } }>((resolve) => { resolveTurnCompleted = resolve; }); @@ -40,7 +61,7 @@ describe('Approval Events', () => { return { promptPromise, - completeTurn: () => resolveTurnCompleted!({ + completeTurn: () => resolveTurnCompleted({ threadId: sessionId, turn: { id: "turn-id", items: [], status: "completed", error: null } }) @@ -321,5 +342,109 @@ describe('Approval Events', () => { completeTurn(); await promptPromise; }); + + it('should include diff content for file change approval when file change item is available', async () => { + const { promptPromise, completeTurn } = setupSessionWithPendingPrompt(); + fixture.setPermissionResponse({ + outcome: { outcome: 'selected', optionId: 'allow_once' } + }); + mockFileContent('/test/project/config.json', '{"feature":false}'); + + const notification = { + method: 'item/started', + params: { + threadId: sessionId, + turnId: 'turn-1', + item: { + type: 'fileChange', + id: 'file-change-with-diff', + changes: [ + { + path: '/test/project/config.json', + kind: { type: 'update' }, + diff: `--- /test/project/config.json ++++ /test/project/config.json +@@ -1 +1 @@ +-{"feature":false} ++{"feature":true}`, + }, + ], + status: 'inProgress', + }, + }, + } as ServerNotification; + + fixture.sendServerNotification(notification); + await vi.waitFor(() => { + expect(fixture.getAcpConnectionDump([])).toContain('file-change-with-diff'); + }); + fixture.clearAcpConnectionDump(); + + const params: FileChangeRequestApprovalParams = { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'file-change-with-diff', + reason: 'Updating config file', + grantRoot: null, + }; + + await fixture.sendServerRequest( + 'item/fileChange/requestApproval', + params + ); + + await expect(fixture.getAcpConnectionDump(['_meta'])).toMatchFileSnapshot( + 'data/approval-file-change-with-diff.json' + ); + + completeTurn(); + await promptPromise; + }); + + it('should include diff content from turn diff when file change item is unavailable', async () => { + const { promptPromise, completeTurn } = setupSessionWithPendingPrompt(); + fixture.setPermissionResponse({ + outcome: { outcome: 'selected', optionId: 'allow_once' } + }); + mockFileContent('/test/project/config.json', '{"feature":false}'); + + const notification: ServerNotification = { + method: 'turn/diff/updated', + params: { + threadId: sessionId, + turnId: 'turn-1', + diff: `diff --git a/test/project/config.json b/test/project/config.json +index 0000000..1111111 100644 +--- a/test/project/config.json ++++ b/test/project/config.json +@@ -1 +1 @@ +-{"feature":false} ++{"feature":true}`, + }, + }; + + fixture.sendServerNotification(notification); + await Promise.resolve(); + + const params: FileChangeRequestApprovalParams = { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'file-change-from-turn-diff', + reason: 'Updating config file', + grantRoot: null, + }; + + await fixture.sendServerRequest( + 'item/fileChange/requestApproval', + params + ); + + await expect(fixture.getAcpConnectionDump(['_meta'])).toMatchFileSnapshot( + 'data/approval-file-change-from-turn-diff.json' + ); + + completeTurn(); + await promptPromise; + }); }); }); diff --git a/src/__tests__/CodexACPAgent/data/approval-file-change-from-turn-diff.json b/src/__tests__/CodexACPAgent/data/approval-file-change-from-turn-diff.json new file mode 100644 index 00000000..016bcf51 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/approval-file-change-from-turn-diff.json @@ -0,0 +1,57 @@ +{ + "method": "requestPermission", + "args": [ + { + "sessionId": "test-session-id", + "toolCall": { + "toolCallId": "file-change-from-turn-diff", + "kind": "edit", + "status": "pending", + "locations": [ + { + "path": "test/project/config.json" + } + ], + "rawInput": { + "unifiedDiff": "diff --git a/test/project/config.json b/test/project/config.json\nindex 0000000..1111111 100644\n--- a/test/project/config.json\n+++ b/test/project/config.json\n@@ -1 +1 @@\n-{\"feature\":false}\n+{\"feature\":true}", + "changes": [ + { + "path": "test/project/config.json", + "kind": { + "type": "update", + "move_path": null + }, + "diff": "--- test/project/config.json\n+++ test/project/config.json\n@@ -1 +1 @@\n-{\"feature\":false}\n+{\"feature\":true}" + } + ] + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Updating config file" + } + } + ] + }, + "options": [ + { + "optionId": "allow_once", + "name": "Allow Once", + "kind": "allow_once" + }, + { + "optionId": "allow_always", + "name": "Allow for Session", + "kind": "allow_always" + }, + { + "optionId": "reject_once", + "name": "Reject", + "kind": "reject_once" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/approval-file-change-with-diff.json b/src/__tests__/CodexACPAgent/data/approval-file-change-with-diff.json new file mode 100644 index 00000000..ceac2a9d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/approval-file-change-with-diff.json @@ -0,0 +1,62 @@ +{ + "method": "requestPermission", + "args": [ + { + "sessionId": "test-session-id", + "toolCall": { + "toolCallId": "file-change-with-diff", + "kind": "edit", + "status": "pending", + "locations": [ + { + "path": "/test/project/config.json" + } + ], + "rawInput": { + "changes": [ + { + "path": "/test/project/config.json", + "kind": { + "type": "update" + }, + "diff": "--- /test/project/config.json\n+++ /test/project/config.json\n@@ -1 +1 @@\n-{\"feature\":false}\n+{\"feature\":true}" + } + ] + }, + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Updating config file" + } + }, + { + "type": "diff", + "oldText": "{\"feature\":false}", + "newText": "{\"feature\":true}", + "path": "/test/project/config.json", + "_meta": "_meta" + } + ] + }, + "options": [ + { + "optionId": "allow_once", + "name": "Allow Once", + "kind": "allow_once" + }, + { + "optionId": "allow_always", + "name": "Allow for Session", + "kind": "allow_always" + }, + { + "optionId": "reject_once", + "name": "Reject", + "kind": "reject_once" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json b/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json index 41b58b87..b85ec35b 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json +++ b/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json @@ -6,7 +6,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-change-2", - "title": "Editing files", + "title": "/test/project/FileA.kt, /test/project/FileB.kt", "kind": "edit", "status": "completed", "content": [ @@ -28,7 +28,33 @@ "kind": "add" } } - ] + ], + "locations": [ + { + "path": "/test/project/FileA.kt" + }, + { + "path": "/test/project/FileB.kt" + } + ], + "rawInput": { + "changes": [ + { + "path": "/test/project/FileA.kt", + "kind": { + "type": "add" + }, + "diff": "--- /dev/null\n+++ /test/project/FileA.kt\n@@ -0,0 +1 @@\n+class FileA" + }, + { + "path": "/test/project/FileB.kt", + "kind": { + "type": "add" + }, + "diff": "--- /dev/null\n+++ /test/project/FileB.kt\n@@ -0,0 +1 @@\n+class FileB" + } + ] + } } } ] diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json b/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json index 4bf59ca9..e860fe1b 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json +++ b/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json @@ -6,7 +6,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-change-1", - "title": "Editing files", + "title": "/test/project/NewFile.kt", "kind": "edit", "status": "completed", "content": [ @@ -19,7 +19,23 @@ "kind": "add" } } - ] + ], + "locations": [ + { + "path": "/test/project/NewFile.kt" + } + ], + "rawInput": { + "changes": [ + { + "path": "/test/project/NewFile.kt", + "kind": { + "type": "add" + }, + "diff": "--- /dev/null\n+++ /test/project/NewFile.kt\n@@ -0,0 +1,5 @@\n+package test.project\n+\n+class NewFile {\n+ fun hello() = \"Hello\"\n+}" + } + ] + } } } ] diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json b/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json index 31f18f5a..f12ba6d8 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json +++ b/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json @@ -6,7 +6,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-change-raw", - "title": "Editing files", + "title": "/test/project/RawFile.kt", "kind": "edit", "status": "completed", "content": [ @@ -19,7 +19,23 @@ "kind": "add" } } - ] + ], + "locations": [ + { + "path": "/test/project/RawFile.kt" + } + ], + "rawInput": { + "changes": [ + { + "path": "/test/project/RawFile.kt", + "kind": { + "type": "add" + }, + "diff": "fun main() {\n println(\"Hello, World!\")\n}\n" + } + ] + } } } ] diff --git a/src/__tests__/CodexACPAgent/data/file-change-completion-update.json b/src/__tests__/CodexACPAgent/data/file-change-completion-update.json new file mode 100644 index 00000000..25d7c7a7 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/file-change-completion-update.json @@ -0,0 +1,55 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "file-change-complete", + "title": "/test/project/OldFile.kt", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "type": "diff", + "oldText": "package test.project\n\nclass OldFile {}", + "newText": "package test.project\n\nclass OldFile { fun hello() = \"Hello\" }", + "path": "/test/project/OldFile.kt", + "_meta": { + "kind": "update" + } + } + ], + "locations": [ + { + "path": "/test/project/OldFile.kt" + } + ], + "rawInput": { + "changes": [ + { + "path": "/test/project/OldFile.kt", + "kind": { + "type": "update" + }, + "diff": "--- /test/project/OldFile.kt\n+++ /test/project/OldFile.kt\n@@ -1,3 +1,3 @@\n package test.project\n \n-class OldFile {}\n+class OldFile { fun hello() = \"Hello\" }" + } + ] + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "file-change-complete", + "status": "completed" + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/file-change-delete-file.json b/src/__tests__/CodexACPAgent/data/file-change-delete-file.json index ea6dd487..ccf568ba 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-delete-file.json +++ b/src/__tests__/CodexACPAgent/data/file-change-delete-file.json @@ -6,7 +6,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-change-3", - "title": "Editing files", + "title": "/test/project/OldFile.kt", "kind": "edit", "status": "completed", "content": [ @@ -19,7 +19,23 @@ "kind": "delete" } } - ] + ], + "locations": [ + { + "path": "/test/project/OldFile.kt" + } + ], + "rawInput": { + "changes": [ + { + "path": "/test/project/OldFile.kt", + "kind": { + "type": "delete" + }, + "diff": "--- /test/project/OldFile.kt\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-package test.project\n-\n-class OldFile {}" + } + ] + } } } ] diff --git a/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json b/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json index 60f83da7..4e040408 100644 --- a/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json +++ b/src/__tests__/CodexACPAgent/data/file-change-delete-raw-content.json @@ -6,7 +6,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "file-delete-raw", - "title": "Editing files", + "title": "/test/project/RawDeleteFile.kt", "kind": "edit", "status": "completed", "content": [ @@ -19,7 +19,23 @@ "kind": "delete" } } - ] + ], + "locations": [ + { + "path": "/test/project/RawDeleteFile.kt" + } + ], + "rawInput": { + "changes": [ + { + "path": "/test/project/RawDeleteFile.kt", + "kind": { + "type": "delete" + }, + "diff": "fun main() {\n println(\"Hello, World!\")\n}\n" + } + ] + } } } ] diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 097e7f02..ba179646 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -130,7 +130,7 @@ "update": { "sessionUpdate": "tool_call", "toolCallId": "item-file-1", - "title": "Editing files", + "title": "/test/project/Added.txt", "kind": "edit", "status": "completed", "content": [ @@ -143,7 +143,23 @@ "kind": "add" } } - ] + ], + "locations": [ + { + "path": "/test/project/Added.txt" + } + ], + "rawInput": { + "changes": [ + { + "path": "/test/project/Added.txt", + "kind": { + "type": "add" + }, + "diff": "--- /dev/null\n+++ /test/project/Added.txt\n@@ -0,0 +1,2 @@\n+Hello\n+World\n" + } + ] + } } } ] diff --git a/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json b/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json index f2530266..285d26b8 100644 --- a/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json +++ b/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json @@ -4,11 +4,19 @@ { "sessionId": "test-session-id", "update": { - "sessionUpdate": "tool_call_update", + "sessionUpdate": "tool_call", "toolCallId": "call-id", - "_meta": { - "mcp_output_delta": { - "data": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened" + "kind": "execute", + "title": "mcp.ijproxy.read_file", + "status": "in_progress", + "rawInput": { + "server": "ijproxy", + "tool": "read_file", + "arguments": { + "file_path": ".ai/local.md", + "mode": "slice", + "start_line": 1, + "max_lines": 200 } } } @@ -23,21 +31,9 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "call-id", - "status": "failed", - "rawInput": { - "server": "ijproxy", - "tool": "read_file", - "arguments": { - "file_path": ".ai/local.md", - "mode": "slice", - "start_line": 1, - "max_lines": 200 - } - }, - "rawOutput": { - "result": null, - "error": { - "message": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened" + "_meta": { + "mcp_output_delta": { + "data": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened" } } } @@ -50,11 +46,9 @@ { "sessionId": "test-session-id", "update": { - "sessionUpdate": "tool_call", + "sessionUpdate": "tool_call_update", "toolCallId": "call-id", - "kind": "execute", - "title": "mcp.ijproxy.read_file", - "status": "in_progress", + "status": "failed", "rawInput": { "server": "ijproxy", "tool": "read_file", @@ -64,6 +58,12 @@ "start_line": 1, "max_lines": 200 } + }, + "rawOutput": { + "result": null, + "error": { + "message": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json b/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json index d60c6e28..0e85c11c 100644 --- a/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json +++ b/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json @@ -4,11 +4,16 @@ { "sessionId": "test-session-id", "update": { - "sessionUpdate": "tool_call_update", + "sessionUpdate": "tool_call", "toolCallId": "call-id", - "_meta": { - "mcp_output_delta": { - "data": "Polling for status" + "kind": "execute", + "title": "mcp.server-name.tool-name", + "status": "in_progress", + "rawInput": { + "server": "server-name", + "tool": "tool-name", + "arguments": { + "argument": "example" } } } @@ -40,18 +45,9 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "call-id", - "status": "failed", - "rawInput": { - "server": "server-name", - "tool": "tool-name", - "arguments": { - "argument": "example" - } - }, - "rawOutput": { - "result": null, - "error": { - "message": "Polling for status" + "_meta": { + "mcp_output_delta": { + "data": "Polling for status" } } } @@ -64,17 +60,21 @@ { "sessionId": "test-session-id", "update": { - "sessionUpdate": "tool_call", + "sessionUpdate": "tool_call_update", "toolCallId": "call-id", - "kind": "execute", - "title": "mcp.server-name.tool-name", - "status": "in_progress", + "status": "failed", "rawInput": { "server": "server-name", "tool": "tool-name", "arguments": { "argument": "example" } + }, + "rawOutput": { + "result": null, + "error": { + "message": "Polling for status" + } } } } diff --git a/src/__tests__/CodexACPAgent/data/terminal-full-flow.json b/src/__tests__/CodexACPAgent/data/terminal-full-flow.json index ff6ef291..d69f63eb 100644 --- a/src/__tests__/CodexACPAgent/data/terminal-full-flow.json +++ b/src/__tests__/CodexACPAgent/data/terminal-full-flow.json @@ -4,11 +4,24 @@ { "sessionId": "test-session-id", "update": { - "sessionUpdate": "tool_call_update", + "sessionUpdate": "tool_call", "toolCallId": "command-flow", + "kind": "execute", + "title": "echo hello", + "status": "in_progress", + "content": [ + { + "type": "terminal", + "terminalId": "command-flow" + } + ], + "rawInput": { + "command": "echo hello", + "cwd": "/test/project" + }, "_meta": { - "terminal_output_delta": { - "data": "hello\n", + "terminal_info": { + "cwd": "/test/project", "terminal_id": "command-flow" } } @@ -24,15 +37,9 @@ "update": { "sessionUpdate": "tool_call_update", "toolCallId": "command-flow", - "status": "completed", - "rawOutput": { - "formatted_output": "hello\n", - "exit_code": 0 - }, "_meta": { - "terminal_exit": { - "exit_code": 0, - "signal": null, + "terminal_output_delta": { + "data": "hello\n", "terminal_id": "command-flow" } } @@ -46,24 +53,17 @@ { "sessionId": "test-session-id", "update": { - "sessionUpdate": "tool_call", + "sessionUpdate": "tool_call_update", "toolCallId": "command-flow", - "kind": "execute", - "title": "echo hello", - "status": "in_progress", - "content": [ - { - "type": "terminal", - "terminalId": "command-flow" - } - ], - "rawInput": { - "command": "echo hello", - "cwd": "/test/project" + "status": "completed", + "rawOutput": { + "formatted_output": "hello\n", + "exit_code": 0 }, "_meta": { - "terminal_info": { - "cwd": "/test/project", + "terminal_exit": { + "exit_code": 0, + "signal": null, "terminal_id": "command-flow" } } diff --git a/src/__tests__/CodexACPAgent/file-change-events.test.ts b/src/__tests__/CodexACPAgent/file-change-events.test.ts index b25094f2..83a69158 100644 --- a/src/__tests__/CodexACPAgent/file-change-events.test.ts +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -271,4 +271,71 @@ describe('CodexEventHandler - file change events', () => { 'data/file-change-delete-raw-content.json' ); }); + + it('should keep file path details on file change completion updates', async () => { + const updateDiff = [ + '--- /test/project/OldFile.kt', + '+++ /test/project/OldFile.kt', + '@@ -1,3 +1,3 @@', + ' package test.project', + ' ', + '-class OldFile {}', + '+class OldFile { fun hello() = "Hello" }', + ].join('\n'); + + const startedNotification = { + method: 'item/started', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'fileChange', + id: 'file-change-complete', + changes: [ + { + path: '/test/project/OldFile.kt', + kind: { type: 'update' }, + diff: updateDiff, + }, + ], + status: 'inProgress', + }, + }, + } as ServerNotification; + const completedNotification = { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'fileChange', + id: 'file-change-complete', + changes: [ + { + path: '/test/project/OldFile.kt', + kind: { type: 'update' }, + diff: updateDiff, + }, + ], + status: 'completed', + }, + }, + } as ServerNotification; + const notifications = [startedNotification, completedNotification]; + let sessionUpdateCount = 0; + mockFixture.onAcpConnectionEvent((event) => { + if (event.method === 'sessionUpdate') { + sessionUpdateCount += 1; + } + }); + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + await vi.waitFor(() => { + expect(sessionUpdateCount).toBe(2); + }); + + await expect(mockFixture.getAcpConnectionDump(['id'])).toMatchFileSnapshot( + 'data/file-change-completion-update.json' + ); + }); });