Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
asOpenCodePermissionDetails,
formatRawDetails,
getDefaultApprovalOptions,
isPlanApprovalAccepted,
isPlanApprovalRequest,
outcomeForSelection,
readInputString,
Expand Down Expand Up @@ -92,7 +93,7 @@ export function ThreadRuntimeRequestPanel(props: ThreadRuntimeRequestPanelProps)
if (!primaryOptionId) return;
const isPlanApproval = isPlanApprovalRequest(request);
const outcome = outcomeForSelection(request.requestType, primaryOptionId, isPlanApproval);
if (outcome === "accepted" && isPlanApproval) {
if (outcome === "accepted" && isPlanApproval && isPlanApprovalAccepted(primaryOptionId)) {
onPlanApproved?.(primaryOptionId);
}
submitRaw(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ export function isNegativeOption(option: UserInputOption): boolean {
);
}

/**
* Plan-review options that ask for another planning round instead of approving.
* These read as positive to {@link NEGATIVE_OPTION_PATTERN} — Kimi Code offers
* `plan_approve` / `plan_revise` / `plan_reject_and_exit` — so without this a
* "Revise" selection was treated as an approval and left plan mode in the
* composer while the agent was still planning ("Plan mode remains active").
*/
const PLAN_KEEP_PLANNING_PATTERN = /(revise|revision|keep[\s_-]?planning)/i;

/**
* True when a plan-review selection approves the plan, i.e. the thread really
* leaves plan mode. Revise/keep-planning and every negative option do not.
*/
export function isPlanApprovalAccepted(optionId: string): boolean {
return !NEGATIVE_OPTION_PATTERN.test(optionId) && !PLAN_KEEP_PLANNING_PATTERN.test(optionId);
}

export function isPlanApprovalRequest(request: OpenRuntimeRequest): boolean {
const details = asPermissionRequestDetails(request.payload.details);
if (!details) return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { isPlanApprovalAccepted, outcomeForSelection } from "./helpers";

describe("isPlanApprovalAccepted", () => {
// Kimi Code's plan review offers these three option ids, and its own result
// text states the consequence: "Plan mode deactivated." for approve and
// reject-and-exit, "Plan mode remains active." for revise. Only the first
// means the thread leaves plan mode.
it.each(["plan_approve", "approve", "default", "auto"])("accepts %s", (optionId) => {
expect(isPlanApprovalAccepted(optionId)).toBe(true);
});

it.each(["plan_revise", "revise", "keep_planning", "keep-planning", "Revision requested"])(
"does not accept %s",
(optionId) => {
expect(isPlanApprovalAccepted(optionId)).toBe(false);
},
);

it.each(["plan_reject_and_exit", "deny", "reject", "cancel"])(
"does not accept the negative option %s",
(optionId) => {
expect(isPlanApprovalAccepted(optionId)).toBe(false);
},
);

it("stays independent of the outcome reported for the request", () => {
// A revise selection is still forwarded to the agent as a selection — only
// the "did we leave plan mode?" conclusion changes.
expect(outcomeForSelection("tool_call_approval", "plan_revise", true)).toBe("accepted");
expect(isPlanApprovalAccepted("plan_revise")).toBe(false);
});
});
110 changes: 110 additions & 0 deletions src/renderer/components/thread/ThreadView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,116 @@ describe("ThreadView", () => {
});
});

it("keeps plan mode when a plan review asks for revisions", async () => {
const now = new Date().toISOString();

useAppStore.setState({
projects: [
{
id: "project-1",
name: "Repo",
location: { kind: "windows", path: "C:\\repo" },
createdAt: now,
},
],
runtimeRequestsByThread: {
"thread-kimi-revise": [
{
requestId: "perm-plan",
threadId: "thread-kimi-revise",
requestType: "tool_user_input",
receivedAt: now,
payload: {
summary: "Proposed plan",
details: {
toolName: "ExitPlanMode",
input: {
planFilePath: "C:\\Users\\sdsle\\.claude\\plans\\plan.md",
},
},
options: [
{ optionId: "plan_approve", label: "Approve" },
{ optionId: "plan_revise", label: "Revise" },
{ optionId: "plan_reject_and_exit", label: "Reject and Exit" },
],
},
},
],
},
});

renderThreadView({
thread: {
id: "thread-kimi-revise",
projectId: "project-1",
title: "Claude plan thread",
agentKind: "claude",
config: {
model: "opus",
mode: "plan",
},
status: "needs_reply",
attention: "needs_reply",
canResumeWithConfig: true,
archived: false,
done: false,
starred: false,
presentationMode: "gui",
sessionRef: {
providerSessionId: "session-claude-plan",
discoveredAt: now,
},
createdAt: now,
updatedAt: now,
},
agentStatus: {
kind: "claude",
label: "Claude Code",
installed: true,
authState: "authenticated",
capabilities: {
models: [{ id: "opus", label: "Opus" }],
efforts: ["low"],
modelEfforts: {},
modes: ["agent", "plan"],
approvalPolicies: [
{ id: "auto", label: "Auto" },
{ id: "bypassPermissions", label: "Bypass Permissions" },
],
sandboxModes: [],
supportsResume: true,
supportsDirectInput: true,
liveInputMode: "server",
presentationMode: "gui",
settingDefs: [],
},
},
projectLocation: {
kind: "windows",
path: "C:\\repo",
},
});

expect(screen.getByText("Proposed plan")).toBeInTheDocument();

// "Revise" reads as positive to the negative-option pattern, but Kimi keeps
// plan mode active for it — the composer must not drop out of plan mode.
fireEvent.click(screen.getByRole("button", { name: "Revise" }));

await waitFor(() => {
expect(runtimeActions.resolveThreadServerRequest).toHaveBeenCalledWith("thread-kimi-revise", {
requestId: "perm-plan",
method: "requestPermission",
response: { optionId: "plan_revise" },
analytics: {
outcome: "accepted",
requestType: "tool_user_input",
},
});
});
expect(runtimeActions.changeThreadConfig).not.toHaveBeenCalled();
});

it("uses the ACP composer controls for per-thread GUI presentation", () => {
useSharedSettings.setState({ collapseTerminalComposer: true });

Expand Down
18 changes: 18 additions & 0 deletions src/supervisor/agents/acp/canonicalMapping/contentExtraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,24 @@ export function isAcpExitPlanModeTool(
);
}

/**
* Detect ACP tool calls that represent the cross-provider `EnterPlanMode`
* convention — the counterpart of {@link isAcpExitPlanModeTool}.
*/
export function isAcpEnterPlanModeTool(
title: string | null | undefined,
kind: string | null | undefined,
): boolean {
const t = (title ?? "").trim().toLowerCase();
const k = (kind ?? "").trim().toLowerCase();
return (
t === "enterplanmode" ||
t === "enter_plan_mode" ||
k === "enterplanmode" ||
k === "enter_plan_mode"
);
}

export interface AcpPlanReviewContent {
plan: string;
planFilePath?: string;
Expand Down
Loading