Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 44 additions & 8 deletions cli/src/cursor/utils/cursorExtensionAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }]
}
});
});

Expand All @@ -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',
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' },
Expand Down
31 changes: 20 additions & 11 deletions cli/src/cursor/utils/cursorExtensionAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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];
Expand All @@ -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<T extends { outcome: string }>(outcome: T): { outcome: T } {
return { outcome };
}

function extractToolCallId(params: unknown): string | null {
if (!isObject(params)) return null;
return asString(params.toolCallId);
Expand Down
Loading