mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(cursor): CreatePlan Yes accepts and continues task (nested outcome + plan→execute) (#1097)
* fix(cursor): nest ACP extension outcome so plan approvals aren't cancelled
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" } }`. The
adapter returned it flat (`{ outcome: "accepted" }`), so Cursor read
`response.outcome.outcome` as undefined and fell back to a cancellation
— an approved plan was relayed to the agent as `User cancelled`, making
plan mode unusable over HAPI for Cursor sessions.
Wrap every ask_question / create_plan response in the nested envelope
and add regression tests asserting the exact wire shape for the
affirmative approval -> CreatePlan path (plus approved_for_session,
reject, and abort).
Fixes #79
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): continue task after CreatePlan Yes (plan→execute)
Nested ACP accept alone is not a complete fix: Yes unblocked create_plan
but the prompt turn still ended with the plan "done" and no execution.
Mirror Claude ExitPlanMode: on accept, leave plan/ask for an executable
mode and queue a continue prompt so the original user task keeps going.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(cursor): sync runCursor enqueue mode after CreatePlan accept
Codex Major on #1097: setPermissionMode alone left runCursor's
currentPermissionMode stale, so the next user message could re-enter
plan/ask after Yes. Notify onPermissionModeChanged from CursorSession
so the enqueue source of truth stays aligned with the session.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
Debian
parent
311e0cef55
commit
e4cc3916c8
@@ -24,8 +24,10 @@ import {
|
||||
applyCursorAcpMode,
|
||||
applyCursorAcpModel,
|
||||
isCursorAutoReviewMode,
|
||||
resolveCursorModeAfterPlanApproval,
|
||||
wireIdForCursorSessionState
|
||||
} from './utils/cursorModeConfig';
|
||||
import { CURSOR_PLAN_CONTINUE } from './utils/cursorPlanContinue';
|
||||
import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cursorSpecialCommands';
|
||||
import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels';
|
||||
import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache';
|
||||
@@ -34,6 +36,7 @@ import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle';
|
||||
class CursorAcpRemoteLauncher extends RemoteLauncherBase {
|
||||
private readonly session: CursorSession;
|
||||
private backend: ReturnType<typeof createCursorAcpBackend> | null = null;
|
||||
private acpSessionId: string | null = null;
|
||||
private permissionAdapter: PermissionAdapter | null = null;
|
||||
private extensionAdapter: CursorExtensionAdapter | null = null;
|
||||
private happyServer: { stop: () => void } | null = null;
|
||||
@@ -115,7 +118,8 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
|
||||
const extensionAdapter = new CursorExtensionAdapter(
|
||||
session.client,
|
||||
backend,
|
||||
(message) => this.handleAgentMessage(message)
|
||||
(message) => this.handleAgentMessage(message),
|
||||
() => this.handleCreatePlanAccepted()
|
||||
);
|
||||
this.extensionAdapter = extensionAdapter;
|
||||
|
||||
@@ -155,6 +159,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
|
||||
mcpServers: mcpServerList
|
||||
});
|
||||
}
|
||||
this.acpSessionId = acpSessionId;
|
||||
|
||||
if (acpSessionId !== resumeSessionId) {
|
||||
session.onSessionFoundWithProtocol(acpSessionId, 'acp');
|
||||
@@ -304,6 +309,35 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
|
||||
setCursorAcpModelsSnapshot(null);
|
||||
}
|
||||
|
||||
private handleCreatePlanAccepted(): void {
|
||||
const backend = this.backend;
|
||||
const acpSessionId = this.acpSessionId;
|
||||
if (!backend || !acpSessionId) {
|
||||
logger.warn('[cursor-acp] CreatePlan accepted but ACP session is not ready; skip continue handoff');
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.session;
|
||||
const executeMode = resolveCursorModeAfterPlanApproval(
|
||||
session.getPermissionMode() as PermissionMode
|
||||
) as PermissionMode;
|
||||
|
||||
// Leave plan/ask for an executable mode, then queue a continue prompt so
|
||||
// Yes means "keep going on the user task" (Claude ExitPlanMode parallel).
|
||||
session.setPermissionMode(executeMode);
|
||||
void applyCursorAcpMode(backend, acpSessionId, executeMode).then(() => {
|
||||
this.applyDisplayMode(executeMode);
|
||||
});
|
||||
|
||||
session.queue.unshiftIsolated(CURSOR_PLAN_CONTINUE, {
|
||||
permissionMode: executeMode,
|
||||
model: session.model
|
||||
});
|
||||
logger.debug('[cursor-acp] CreatePlan accepted — queued continue prompt', {
|
||||
executeMode
|
||||
});
|
||||
}
|
||||
|
||||
private handleAgentMessage(message: AgentMessage): void {
|
||||
const converted = convertAgentMessage(message);
|
||||
if (converted) {
|
||||
|
||||
@@ -31,6 +31,8 @@ interface LoopOptions {
|
||||
model?: string;
|
||||
sessionMetadata?: Metadata | null;
|
||||
onSessionReady?: (session: CursorSession) => void;
|
||||
/** Keep runCursor's enqueue mode in sync when the session leaves plan/ask. */
|
||||
onPermissionModeChanged?: (mode: PermissionMode) => void;
|
||||
}
|
||||
|
||||
export async function loop(opts: LoopOptions): Promise<void> {
|
||||
@@ -52,7 +54,8 @@ export async function loop(opts: LoopOptions): Promise<void> {
|
||||
cursorWorktree: opts.cursorWorktree,
|
||||
cursorAddDirs: opts.cursorAddDirs,
|
||||
model: opts.model,
|
||||
permissionMode: opts.permissionMode ?? 'default'
|
||||
permissionMode: opts.permissionMode ?? 'default',
|
||||
onPermissionModeChanged: opts.onPermissionModeChanged
|
||||
});
|
||||
|
||||
await runLocalRemoteSession({
|
||||
|
||||
@@ -183,6 +183,9 @@ export async function runCursor(opts: {
|
||||
model: opts.model,
|
||||
sessionMetadata: bootstrap.metadata,
|
||||
onModeChange: createModeChangeHandler(session),
|
||||
onPermissionModeChanged: (permissionMode) => {
|
||||
currentPermissionMode = permissionMode;
|
||||
},
|
||||
onSessionReady: (instance) => {
|
||||
sessionWrapperRef.current = instance;
|
||||
syncSessionMode();
|
||||
|
||||
@@ -38,4 +38,31 @@ describe('CursorSession', () => {
|
||||
cursorSessionProtocol: 'acp'
|
||||
});
|
||||
});
|
||||
|
||||
it('notifies onPermissionModeChanged so runCursor enqueue mode stays in sync', () => {
|
||||
const onPermissionModeChanged = vi.fn();
|
||||
const session = new CursorSession({
|
||||
api: {} as never,
|
||||
client: {
|
||||
updateMetadata: vi.fn(),
|
||||
keepAlive: vi.fn(),
|
||||
emitMessagesConsumed: vi.fn()
|
||||
} as never,
|
||||
path: '/tmp',
|
||||
logPath: '/tmp/log',
|
||||
sessionId: null,
|
||||
messageQueue: new MessageQueue2<EnhancedMode>(() => 'hash'),
|
||||
onModeChange: vi.fn(),
|
||||
startedBy: 'runner',
|
||||
startingMode: 'remote',
|
||||
mode: 'remote',
|
||||
permissionMode: 'plan',
|
||||
onPermissionModeChanged
|
||||
});
|
||||
|
||||
session.setPermissionMode('default');
|
||||
|
||||
expect(session.getPermissionMode()).toBe('default');
|
||||
expect(onPermissionModeChanged).toHaveBeenCalledWith('default');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ type LocalLaunchFailure = {
|
||||
};
|
||||
|
||||
type CursorModelApplyHandler = (model: string | null | undefined) => Promise<string | null>;
|
||||
type CursorPermissionModeChangedHandler = (mode: PermissionMode) => void;
|
||||
|
||||
export class CursorSession extends AgentSessionBase<EnhancedMode> {
|
||||
readonly cursorArgs?: string[];
|
||||
@@ -23,6 +24,7 @@ export class CursorSession extends AgentSessionBase<EnhancedMode> {
|
||||
readonly startingMode: 'local' | 'remote';
|
||||
localLaunchFailure: LocalLaunchFailure | null = null;
|
||||
private modelApplyHandler: CursorModelApplyHandler | null = null;
|
||||
private permissionModeChangedHandler: CursorPermissionModeChangedHandler | null = null;
|
||||
|
||||
constructor(opts: {
|
||||
api: ApiClient;
|
||||
@@ -40,6 +42,7 @@ export class CursorSession extends AgentSessionBase<EnhancedMode> {
|
||||
cursorAddDirs?: readonly string[];
|
||||
model?: string;
|
||||
permissionMode?: PermissionMode;
|
||||
onPermissionModeChanged?: CursorPermissionModeChangedHandler;
|
||||
}) {
|
||||
super({
|
||||
api: opts.api,
|
||||
@@ -67,10 +70,13 @@ export class CursorSession extends AgentSessionBase<EnhancedMode> {
|
||||
this.startedBy = opts.startedBy;
|
||||
this.startingMode = opts.startingMode;
|
||||
this.permissionMode = opts.permissionMode;
|
||||
this.permissionModeChangedHandler = opts.onPermissionModeChanged ?? null;
|
||||
}
|
||||
|
||||
setPermissionMode = (mode: PermissionMode): void => {
|
||||
this.permissionMode = mode;
|
||||
// Keep runCursor's enqueue source of truth in sync (CreatePlan accept, ACP mode sync).
|
||||
this.permissionModeChangedHandler?.(mode);
|
||||
};
|
||||
|
||||
setModel = (model: string | null | undefined): void => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CursorExtensionAdapter } from './cursorExtensionAdapter';
|
||||
|
||||
type ExtensionHandler = (params: unknown, requestId: string | number | null) => Promise<unknown>;
|
||||
|
||||
function createHarness() {
|
||||
function createHarness(options?: { onCreatePlanAccepted?: () => void }) {
|
||||
const handlers = new Map<string, ExtensionHandler>();
|
||||
let agentState: AgentState = { requests: {}, completedRequests: {} };
|
||||
const messages: AgentMessage[] = [];
|
||||
@@ -24,9 +24,14 @@ function createHarness() {
|
||||
}
|
||||
} as unknown as AcpSdkBackend;
|
||||
|
||||
const adapter = new CursorExtensionAdapter(session, backend, (message) => {
|
||||
messages.push(message);
|
||||
});
|
||||
const adapter = new CursorExtensionAdapter(
|
||||
session,
|
||||
backend,
|
||||
(message) => {
|
||||
messages.push(message);
|
||||
},
|
||||
options?.onCreatePlanAccepted
|
||||
);
|
||||
|
||||
return {
|
||||
handlers,
|
||||
@@ -74,9 +79,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,11 +98,16 @@ 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 () => {
|
||||
const { handlers, adapter } = createHarness();
|
||||
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 onCreatePlanAccepted = vi.fn();
|
||||
const { handlers, adapter } = createHarness({ onCreatePlanAccepted });
|
||||
const pending = handlers.get('cursor/create_plan')!({
|
||||
toolCallId: 'plan-1',
|
||||
plan: '# Plan'
|
||||
@@ -106,7 +119,65 @@ describe('CursorExtensionAdapter', () => {
|
||||
decision: 'approved'
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toEqual({ outcome: 'accepted' });
|
||||
await expect(pending).resolves.toEqual({ outcome: { outcome: 'accepted' } });
|
||||
expect(onCreatePlanAccepted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('resolves create_plan approved_for_session as accepted with nested envelope', async () => {
|
||||
const onCreatePlanAccepted = vi.fn();
|
||||
const { handlers, adapter } = createHarness({ onCreatePlanAccepted });
|
||||
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' } });
|
||||
expect(onCreatePlanAccepted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not invoke create-plan continue handoff on denial or abort', async () => {
|
||||
const onCreatePlanAccepted = vi.fn();
|
||||
const { handlers, adapter } = createHarness({ onCreatePlanAccepted });
|
||||
const denied = handlers.get('cursor/create_plan')!({ toolCallId: 'plan-deny' }, null);
|
||||
const aborted = handlers.get('cursor/create_plan')!({ toolCallId: 'plan-abort' }, null);
|
||||
|
||||
await adapter.handlePermissionResponse({
|
||||
id: 'plan-deny',
|
||||
approved: false,
|
||||
decision: 'denied'
|
||||
});
|
||||
await adapter.handlePermissionResponse({
|
||||
id: 'plan-abort',
|
||||
approved: false,
|
||||
decision: 'abort'
|
||||
});
|
||||
|
||||
await expect(denied).resolves.toEqual({ outcome: { outcome: 'rejected' } });
|
||||
await expect(aborted).resolves.toEqual({ outcome: { outcome: 'cancelled' } });
|
||||
expect(onCreatePlanAccepted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invoke create-plan continue handoff for ask_question answers', async () => {
|
||||
const onCreatePlanAccepted = vi.fn();
|
||||
const { handlers, adapter } = createHarness({ onCreatePlanAccepted });
|
||||
const pending = handlers.get('cursor/ask_question')!({ toolCallId: 'q-ok' }, null);
|
||||
|
||||
await adapter.handlePermissionResponse({
|
||||
id: 'q-ok',
|
||||
approved: true,
|
||||
answers: { q1: ['a'] }
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
outcome: { outcome: 'answered' }
|
||||
});
|
||||
expect(onCreatePlanAccepted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves create_plan denial as rejected', async () => {
|
||||
@@ -119,7 +190,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 +282,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' },
|
||||
|
||||
@@ -21,13 +21,17 @@ type PermissionResponseMessage = {
|
||||
|
||||
export type CursorExtensionMessageHandler = (message: AgentMessage) => void;
|
||||
|
||||
/** Invoked when the operator accepts a CreatePlan request (Yes / Yes for session). */
|
||||
export type CursorCreatePlanAcceptedHandler = () => void;
|
||||
|
||||
export class CursorExtensionAdapter {
|
||||
private readonly pending = new Map<string, PendingExtensionRequest>();
|
||||
|
||||
constructor(
|
||||
private readonly session: ApiSessionClient,
|
||||
private readonly backend: AcpSdkBackend,
|
||||
private readonly onMessage: CursorExtensionMessageHandler
|
||||
private readonly onMessage: CursorExtensionMessageHandler,
|
||||
private readonly onCreatePlanAccepted?: CursorCreatePlanAcceptedHandler
|
||||
) {
|
||||
this.registerHandlers();
|
||||
}
|
||||
@@ -103,19 +107,29 @@ 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' });
|
||||
// Accept first so Cursor unblocks, then hand off to execute (mode
|
||||
// switch + continue prompt). Without the handoff, Yes ends the turn
|
||||
// with "plan done" instead of continuing the user's task.
|
||||
pending.respond(wrapOutcome({ outcome: 'accepted' }));
|
||||
if (pending.tool === 'CursorCreatePlan') {
|
||||
try {
|
||||
this.onCreatePlanAccepted?.();
|
||||
} catch (error) {
|
||||
logger.warn('[cursor-acp] onCreatePlanAccepted failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const status = response.approved ? 'approved' : 'denied';
|
||||
@@ -205,11 +219,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 +245,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);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
applyCursorAcpMode,
|
||||
isCursorAutoReviewMode,
|
||||
resolveCursorAcpWireId,
|
||||
resolveCursorModeAfterPlanApproval,
|
||||
toCursorAcpMode,
|
||||
wireIdForCursorSessionState
|
||||
} from './cursorModeConfig';
|
||||
@@ -30,6 +31,21 @@ describe('toCursorAcpMode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCursorModeAfterPlanApproval', () => {
|
||||
it('leaves plan/ask for default so Yes can execute the task', () => {
|
||||
expect(resolveCursorModeAfterPlanApproval('plan')).toBe('default');
|
||||
expect(resolveCursorModeAfterPlanApproval('ask')).toBe('default');
|
||||
expect(resolveCursorModeAfterPlanApproval(undefined)).toBe('default');
|
||||
});
|
||||
|
||||
it('preserves executable modes (yolo, default, debug, autoReview)', () => {
|
||||
expect(resolveCursorModeAfterPlanApproval('yolo')).toBe('yolo');
|
||||
expect(resolveCursorModeAfterPlanApproval('default')).toBe('default');
|
||||
expect(resolveCursorModeAfterPlanApproval('debug')).toBe('debug');
|
||||
expect(resolveCursorModeAfterPlanApproval('autoReview')).toBe('autoReview');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCursorAcpMode', () => {
|
||||
it('prefers set_config_option for mode changes', async () => {
|
||||
const setConfigOption = vi.fn(async () => {});
|
||||
|
||||
@@ -18,6 +18,20 @@ export function toCursorAcpMode(mode: CursorPermissionMode | undefined): CursorA
|
||||
return 'agent';
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission mode to use after the operator accepts a CreatePlan request.
|
||||
* Plan/ask are read-only planning modes — leave them for an executable mode so
|
||||
* "Yes" means continue the task, not "plan complete, stop".
|
||||
*/
|
||||
export function resolveCursorModeAfterPlanApproval(
|
||||
mode: CursorPermissionMode | undefined
|
||||
): CursorPermissionMode {
|
||||
if (mode === 'plan' || mode === 'ask' || mode === undefined) {
|
||||
return 'default';
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
/** True when HAPI permission mode should spawn/toggle Cursor Auto-review. */
|
||||
export function isCursorAutoReviewMode(mode: CursorPermissionMode | undefined): boolean {
|
||||
return mode === 'autoReview';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Queued after the operator accepts a Cursor `create_plan` request so the
|
||||
* session continues toward the original user task (mirror of Claude's
|
||||
* PLAN_FAKE_RESTART after ExitPlanMode approval). Without this, Yes only
|
||||
* unblocks ACP and the prompt turn ends — plan complete, task abandoned.
|
||||
*/
|
||||
export const CURSOR_PLAN_CONTINUE =
|
||||
'The plan was approved. Continue executing it now toward completing the user\'s original request. Do not stop solely because the plan was accepted.';
|
||||
Reference in New Issue
Block a user