diff --git a/cli/src/cursor/utils/cursorExtensionAdapter.test.ts b/cli/src/cursor/utils/cursorExtensionAdapter.test.ts index be2b7c379..efc568842 100644 --- a/cli/src/cursor/utils/cursorExtensionAdapter.test.ts +++ b/cli/src/cursor/utils/cursorExtensionAdapter.test.ts @@ -74,9 +74,12 @@ describe('CursorExtensionAdapter', () => { answers: { q1: ['opt-a'] } }); expect(handled).toBe(true); + // Cursor ACP expects the outcome nested under `outcome` (see cursor.com/docs/cli/acp). await expect(pending).resolves.toEqual({ - outcome: 'answered', - answers: [{ questionId: 'q1', selectedOptionIds: ['opt-a'] }] + outcome: { + outcome: 'answered', + answers: [{ questionId: 'q1', selectedOptionIds: ['opt-a'] }] + } }); }); @@ -90,10 +93,14 @@ describe('CursorExtensionAdapter', () => { decision: 'denied' }); - await expect(pending).resolves.toEqual({ outcome: 'cancelled' }); + await expect(pending).resolves.toEqual({ outcome: { outcome: 'cancelled' } }); }); - it('resolves create_plan approval as accepted', async () => { + it('resolves create_plan approval as accepted with nested outcome envelope', async () => { + // Regression for the plan-approval bug: operator clicks "Yes" on a Cursor + // CreatePlan approval, but the agent received `User cancelled` because the + // response outcome was returned flat instead of nested. Cursor reads + // `response.outcome.outcome`, so the envelope MUST be nested. const { handlers, adapter } = createHarness(); const pending = handlers.get('cursor/create_plan')!({ toolCallId: 'plan-1', @@ -106,7 +113,23 @@ describe('CursorExtensionAdapter', () => { decision: 'approved' }); - await expect(pending).resolves.toEqual({ outcome: 'accepted' }); + await expect(pending).resolves.toEqual({ outcome: { outcome: 'accepted' } }); + }); + + it('resolves create_plan approved_for_session as accepted with nested envelope', async () => { + const { handlers, adapter } = createHarness(); + const pending = handlers.get('cursor/create_plan')!({ + toolCallId: 'plan-1b', + plan: '# Plan' + }, null); + + await adapter.handlePermissionResponse({ + id: 'plan-1b', + approved: true, + decision: 'approved_for_session' + }); + + await expect(pending).resolves.toEqual({ outcome: { outcome: 'accepted' } }); }); it('resolves create_plan denial as rejected', async () => { @@ -119,7 +142,20 @@ describe('CursorExtensionAdapter', () => { decision: 'denied' }); - await expect(pending).resolves.toEqual({ outcome: 'rejected' }); + await expect(pending).resolves.toEqual({ outcome: { outcome: 'rejected' } }); + }); + + it('resolves create_plan abort as cancelled', async () => { + const { handlers, adapter } = createHarness(); + const pending = handlers.get('cursor/create_plan')!({ toolCallId: 'plan-3' }, null); + + await adapter.handlePermissionResponse({ + id: 'plan-3', + approved: false, + decision: 'abort' + }); + + await expect(pending).resolves.toEqual({ outcome: { outcome: 'cancelled' } }); }); it('returns false from handlePermissionResponse for unrelated permission ids', async () => { @@ -198,8 +234,8 @@ describe('CursorExtensionAdapter', () => { await adapter.cancelAll('User aborted'); - await expect(askPending).resolves.toEqual({ outcome: 'cancelled' }); - await expect(planPending).resolves.toEqual({ outcome: 'cancelled' }); + await expect(askPending).resolves.toEqual({ outcome: { outcome: 'cancelled' } }); + await expect(planPending).resolves.toEqual({ outcome: { outcome: 'cancelled' } }); expect(getAgentState().requests).toEqual({}); expect(getAgentState().completedRequests).toMatchObject({ 'q-cancel': { status: 'canceled', decision: 'abort' }, diff --git a/cli/src/cursor/utils/cursorExtensionAdapter.ts b/cli/src/cursor/utils/cursorExtensionAdapter.ts index cad638bf3..161b02908 100644 --- a/cli/src/cursor/utils/cursorExtensionAdapter.ts +++ b/cli/src/cursor/utils/cursorExtensionAdapter.ts @@ -103,19 +103,19 @@ export class CursorExtensionAdapter { const decision = response.decision ?? (response.approved ? 'approved' : 'denied'); if (pending.tool === 'CursorAskQuestion') { if (decision === 'abort' || decision === 'denied') { - pending.respond({ outcome: 'cancelled' }); + pending.respond(wrapOutcome({ outcome: 'cancelled' })); } else { - pending.respond({ + pending.respond(wrapOutcome({ outcome: 'answered', answers: formatQuestionAnswers(pending.arguments, response.answers) - }); + })); } } else if (decision === 'abort') { - pending.respond({ outcome: 'cancelled' }); + pending.respond(wrapOutcome({ outcome: 'cancelled' })); } else if (decision === 'denied') { - pending.respond({ outcome: 'rejected' }); + pending.respond(wrapOutcome({ outcome: 'rejected' })); } else { - pending.respond({ outcome: 'accepted' }); + pending.respond(wrapOutcome({ outcome: 'accepted' })); } const status = response.approved ? 'approved' : 'denied'; @@ -205,11 +205,7 @@ export class CursorExtensionAdapter { this.pending.clear(); for (const [id, pending] of entries) { - pending.respond( - pending.tool === 'CursorAskQuestion' - ? { outcome: 'cancelled' } - : { outcome: 'cancelled' } - ); + pending.respond(wrapOutcome({ outcome: 'cancelled' })); this.session.updateAgentState((currentState) => { const requestEntry = currentState.requests?.[id]; @@ -235,6 +231,19 @@ export class CursorExtensionAdapter { } } +/** + * Cursor's ACP blocking extension methods (`cursor/ask_question`, + * `cursor/create_plan`) expect the JSON-RPC result to nest the outcome under an + * `outcome` key, e.g. `{ outcome: { outcome: "accepted" } }`. Returning the + * outcome object flat (`{ outcome: "accepted" }`) makes Cursor read + * `response.outcome.outcome` as undefined and fall back to a cancellation, so an + * approved plan is relayed to the agent as `User cancelled`. See + * https://cursor.com/docs/cli/acp (CursorCreatePlanResponse / CursorAskQuestionResponse). + */ +function wrapOutcome(outcome: T): { outcome: T } { + return { outcome }; +} + function extractToolCallId(params: unknown): string | null { if (!isObject(params)) return null; return asString(params.toolCallId);