diff --git a/src/renderer/components/thread/ThreadRuntimeRequestPanel/ThreadRuntimeRequestPanel.tsx b/src/renderer/components/thread/ThreadRuntimeRequestPanel/ThreadRuntimeRequestPanel.tsx index 37596638..84650fad 100644 --- a/src/renderer/components/thread/ThreadRuntimeRequestPanel/ThreadRuntimeRequestPanel.tsx +++ b/src/renderer/components/thread/ThreadRuntimeRequestPanel/ThreadRuntimeRequestPanel.tsx @@ -16,6 +16,7 @@ import { asOpenCodePermissionDetails, formatRawDetails, getDefaultApprovalOptions, + isPlanApprovalAccepted, isPlanApprovalRequest, outcomeForSelection, readInputString, @@ -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( diff --git a/src/renderer/components/thread/ThreadRuntimeRequestPanel/helpers.ts b/src/renderer/components/thread/ThreadRuntimeRequestPanel/helpers.ts index e0827285..5166f267 100644 --- a/src/renderer/components/thread/ThreadRuntimeRequestPanel/helpers.ts +++ b/src/renderer/components/thread/ThreadRuntimeRequestPanel/helpers.ts @@ -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; diff --git a/src/renderer/components/thread/ThreadRuntimeRequestPanel/planApproval.test.ts b/src/renderer/components/thread/ThreadRuntimeRequestPanel/planApproval.test.ts new file mode 100644 index 00000000..97f41c81 --- /dev/null +++ b/src/renderer/components/thread/ThreadRuntimeRequestPanel/planApproval.test.ts @@ -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); + }); +}); diff --git a/src/renderer/components/thread/ThreadView.test.tsx b/src/renderer/components/thread/ThreadView.test.tsx index 58e7d0d3..57453c31 100644 --- a/src/renderer/components/thread/ThreadView.test.tsx +++ b/src/renderer/components/thread/ThreadView.test.tsx @@ -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 }); diff --git a/src/supervisor/agents/acp/canonicalMapping/contentExtraction.ts b/src/supervisor/agents/acp/canonicalMapping/contentExtraction.ts index 2f873771..1365e472 100644 --- a/src/supervisor/agents/acp/canonicalMapping/contentExtraction.ts +++ b/src/supervisor/agents/acp/canonicalMapping/contentExtraction.ts @@ -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; diff --git a/src/supervisor/agents/acp/session.test.ts b/src/supervisor/agents/acp/session.test.ts index 6e0d1a82..31d54119 100644 --- a/src/supervisor/agents/acp/session.test.ts +++ b/src/supervisor/agents/acp/session.test.ts @@ -75,9 +75,13 @@ function makeConfigSyncSession( timeoutMs: number; transport: { type: "http"; url: string; headers: Record }; }>; + fsTextCapability?: boolean; } = {}, ) { const connection = { + initialize: vi + .fn<(args: { clientCapabilities: unknown }) => Promise<{ protocolVersion: number }>>() + .mockResolvedValue({ protocolVersion: 1 }), setSessionMode: vi .fn<(args: { sessionId: string; modeId: string }) => Promise>() .mockResolvedValue(undefined), @@ -113,7 +117,7 @@ function makeConfigSyncSession( resumeSession: vi .fn< (args: { sessionId: string; cwd: string; mcpServers: unknown[] }) => Promise<{ - modes?: { availableModes: Array<{ id: string }> }; + modes?: { currentModeId?: string; availableModes: Array<{ id: string }> }; configOptions?: unknown[]; }> >() @@ -187,6 +191,10 @@ function makeConfigSyncSession( session["launchOptions"] = {}; session["mcpServers"] = overrides.mcpServers ?? []; session["loadSessionErrorRewriter"] = rewriteLoadSessionError; + // Mirrors the constructor's `options?.fsTextCapability !== false` default. + session["fsTextCapability"] = overrides.fsTextCapability !== false; + session["fsAgentHomeDirs"] = []; + session["spawnReady"] = Promise.resolve(); return { connection, listener, session: session as unknown as TestableAcpSession }; } @@ -734,6 +742,83 @@ describe("ACP client protocol helpers", () => { await expect(read({ sessionId: "session-1", path: outside })).rejects.toThrow("Invalid params"); }); + it("advertises the fs text capabilities by default", async () => { + const { connection, session } = makeConfigSyncSession(); + await (session as unknown as { activate(): Promise }).activate(); + expect(connection.initialize.mock.calls[0]?.[0]).toMatchObject({ + clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }, + }); + }); + + it("withholds the fs text capabilities when the adapter opts out", async () => { + // Providers that proxy their own internal state files through the client and + // then mis-classify the JSON-RPC errors it returns opt out; they fall back + // to their local filesystem, which Poracode shares. + const { connection, session } = makeConfigSyncSession({ fsTextCapability: false }); + await (session as unknown as { activate(): Promise }).activate(); + expect(connection.initialize.mock.calls[0]?.[0]).toMatchObject({ + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, + }); + }); + + it("answers a read for a missing file with resource-not-found, not an internal error", async () => { + // A plain Node `ENOENT` escapes as JSON-RPC `-32603 Internal error`, which + // reads as a broken client rather than a missing file. Agents that probe + // for a not-yet-created file (plan files, per-session config) then treat + // the answer as fatal. + const projectRoot = makePosixProject(); + const { session } = makeConfigSyncSession(); + (session as unknown as Record)["projectLocation"] = { + kind: HOST_KIND, + path: projectRoot, + }; + + const read = (session as unknown as { handleReadTextFile: Function }).handleReadTextFile.bind( + session, + ); + + const missing = join(projectRoot, "nope.md"); + await expect(read({ sessionId: "session-1", path: missing })).rejects.toMatchObject({ + code: -32002, + }); + }); + + it("reports other fs failures as internal errors carrying the errno", async () => { + const projectRoot = makePosixProject(); + const { session } = makeConfigSyncSession(); + (session as unknown as Record)["projectLocation"] = { + kind: HOST_KIND, + path: projectRoot, + }; + + const read = (session as unknown as { handleReadTextFile: Function }).handleReadTextFile.bind( + session, + ); + + // Reading a directory fails with EISDIR — a real failure, not a missing file. + await expect(read({ sessionId: "session-1", path: projectRoot })).rejects.toMatchObject({ + code: -32603, + data: { code: "EISDIR" }, + }); + }); + + it("answers a write into a missing directory with resource-not-found", async () => { + const projectRoot = makePosixProject(); + const { session } = makeConfigSyncSession(); + (session as unknown as Record)["projectLocation"] = { + kind: HOST_KIND, + path: projectRoot, + }; + + const write = ( + session as unknown as { handleWriteTextFile: Function } + ).handleWriteTextFile.bind(session); + + await expect( + write({ sessionId: "session-1", path: join(projectRoot, "gone", "out.txt"), content: "x" }), + ).rejects.toMatchObject({ code: -32002 }); + }); + it("serves fs/write_text_file only inside the project root", async () => { const projectRoot = makePosixProject(); const { session } = makeConfigSyncSession(); @@ -1559,6 +1644,172 @@ describe("ACP turn config sync", () => { ); }); + it("does not re-push a resumed session's mode back at the agent", async () => { + const { connection, session } = makeConfigSyncSession(); + (session as unknown as Record)["agentSessionCapabilities"] = { resume: {} }; + connection.resumeSession.mockResolvedValueOnce({ + modes: { currentModeId: "plan", availableModes: [{ id: "default" }, { id: "plan" }] }, + configOptions: [], + }); + + await session.openThread( + { model: "model-a", mode: "plan" }, + { providerSessionId: "session-1", discoveredAt: new Date().toISOString() }, + ); + + expect(connection.setSessionMode).not.toHaveBeenCalled(); + }); + + it("adopts plan mode from a completed EnterPlanMode tool call", () => { + // ACP only says an agent "can" announce its own mode change via + // current_mode_update, and offers no way to read the mode mid-session. + // Kimi Code's EnterPlanMode skips the notification, so the mode is + // inferred from the tool stream instead — otherwise the composer would + // keep showing Work for the rest of a session spent planning. + const { listener, session } = makeConfigSyncSession(); + (session as unknown as Record)["currentStatus"] = "working"; + (session as unknown as Record)["currentAttention"] = "working"; + + session.handleSessionUpdate({ + update: { + sessionUpdate: "tool_call", + toolCallId: "0:tool_x", + title: "EnterPlanMode", + kind: "other", + status: "pending", + }, + }); + expect(listener.onUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ config: expect.objectContaining({ mode: "plan" }) }), + ); + + // The completed update carries no title — correlation is by tool call id. + session.handleSessionUpdate({ + update: { sessionUpdate: "tool_call_update", toolCallId: "0:tool_x", status: "completed" }, + }); + + expect(listener.onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + status: "working", + attention: "working", + config: expect.objectContaining({ mode: "plan" }), + }), + ); + }); + + it("returns to agent mode when the agent leaves plan mode, keeping the approval policy", () => { + // Adopting the entry without the exit would leave the thread claiming plan + // mode after the agent left it — and because the config would already read + // `plan`, nothing would re-assert it, so an edit could land while the + // composer still showed Plan. + const { listener, session } = makeConfigSyncSession({ + currentConfig: { model: "model-a", effort: "high", mode: "plan", approvalPolicy: "auto" }, + }); + + session.handleSessionUpdate({ + update: { + sessionUpdate: "tool_call", + toolCallId: "0:tool_exit", + title: "ExitPlanMode", + status: "pending", + }, + }); + session.handleSessionUpdate({ + update: { + sessionUpdate: "tool_call_update", + toolCallId: "0:tool_exit", + status: "completed", + content: [ + { + type: "content", + content: { type: "text", text: "Exited plan mode. Plan mode deactivated." }, + }, + ], + }, + }); + + expect(listener.onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ mode: "agent", approvalPolicy: "auto" }), + }), + ); + }); + + it("stays in plan mode when the plan review only asks for revisions", () => { + const { listener, session } = makeConfigSyncSession({ + currentConfig: { model: "model-a", effort: "high", mode: "plan", approvalPolicy: "default" }, + }); + + session.handleSessionUpdate({ + update: { + sessionUpdate: "tool_call", + toolCallId: "0:tool_exit", + title: "ExitPlanMode", + status: "pending", + }, + }); + session.handleSessionUpdate({ + update: { + sessionUpdate: "tool_call_update", + toolCallId: "0:tool_exit", + status: "failed", + content: [ + { + type: "content", + content: { type: "text", text: "User requested revisions. Plan mode remains active." }, + }, + ], + }, + }); + + expect(listener.onUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ config: expect.objectContaining({ mode: "agent" }) }), + ); + }); + + it("keeps the mode unchanged when EnterPlanMode fails", () => { + const { listener, session } = makeConfigSyncSession(); + + session.handleSessionUpdate({ + update: { + sessionUpdate: "tool_call", + toolCallId: "0:tool_x", + title: "EnterPlanMode", + status: "pending", + }, + }); + session.handleSessionUpdate({ + update: { sessionUpdate: "tool_call_update", toolCallId: "0:tool_x", status: "failed" }, + }); + + expect(listener.onUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ config: expect.objectContaining({ mode: "plan" }) }), + ); + }); + + it("ignores EnterPlanMode tool calls replayed from a loaded session's history", () => { + // On load/resume the agent's SessionModeState.currentModeId is authoritative; + // a historical entry may since have been exited. + const { listener, session } = makeConfigSyncSession(); + (session as unknown as Record)["isReplayingHistory"] = true; + + session.handleSessionUpdate({ + update: { + sessionUpdate: "tool_call", + toolCallId: "0:tool_x", + title: "EnterPlanMode", + status: "pending", + }, + }); + session.handleSessionUpdate({ + update: { sessionUpdate: "tool_call_update", toolCallId: "0:tool_x", status: "completed" }, + }); + + expect(listener.onUpdate).not.toHaveBeenCalledWith( + expect.objectContaining({ config: expect.objectContaining({ mode: "plan" }) }), + ); + }); + it("does not reopen a settled turn for an out-of-band ACP tool notification", () => { const { listener, session } = makeConfigSyncSession(); diff --git a/src/supervisor/agents/acp/session.ts b/src/supervisor/agents/acp/session.ts index 7d4267c0..6eb7e32f 100644 --- a/src/supervisor/agents/acp/session.ts +++ b/src/supervisor/agents/acp/session.ts @@ -81,6 +81,8 @@ import { AcpSessionConfigSync } from "./sessionConfigSync"; // ── Helpers ────────────────────────────────────────────────────── +import { toAcpFsRequestError } from "./sessionFsErrors"; +import { AcpPlanModeToolTracker } from "./sessionPlanMode"; import { resolveAcpReadableHostFsPath, resolveAcpResourcePath, @@ -233,6 +235,12 @@ export interface AcpStructuredSessionOptions { * profiles) under their own home dir and proxy all text IO to the client. */ fsAgentHomeDirs?: readonly string[]; + /** + * Advertise the `fs.readTextFile` / `fs.writeTextFile` client capabilities + * (default `true`). Set `false` for providers that mis-handle client fs + * errors — see `acpFsTextCapability` in the adapter contract. + */ + fsTextCapability?: boolean; } export interface AcpExternalSessionUpdateSource { @@ -266,6 +274,8 @@ export class AcpStructuredSession implements StructuredSessionHandle { private readonly mcpServers: readonly ResolvedMcpServer[]; private readonly assumedMcpCapabilities: AcpMcpCapabilities | undefined; private readonly fsAgentHomeDirs: readonly string[]; + private readonly fsTextCapability: boolean; + private planModeToolTrackerInstance: AcpPlanModeToolTracker | undefined; /** Poracode thread id (stable identifier we report in RuntimeEvents). */ private readonly threadId: string; private readonly stderrChunks: string[]; @@ -432,6 +442,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { this.mcpServers = options?.mcpServers ?? []; this.assumedMcpCapabilities = options?.assumedMcpCapabilities; this.fsAgentHomeDirs = options?.fsAgentHomeDirs ?? []; + this.fsTextCapability = options?.fsTextCapability !== false; } /** Initialize the canonical mapper once we have a stable thread id. */ @@ -688,7 +699,10 @@ export class AcpStructuredSession implements StructuredSessionHandle { protocolVersion: PROTOCOL_VERSION, clientInfo: { name: "poracode", version: "0.1.0" }, clientCapabilities: { - fs: { readTextFile: true, writeTextFile: true }, + fs: { + readTextFile: this.fsTextCapability, + writeTextFile: this.fsTextCapability, + }, elicitation: { form: {}, url: {} }, terminal: true, }, @@ -779,6 +793,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { async openThread(config: ThreadConfig, sessionRef?: SessionRef): Promise { let availableModeIds: string[] = []; + let agentCurrentModeId: string | undefined; let configOptions: unknown[] | null | undefined; this.currentConfig = undefined; this.currentSlashCommands = undefined; @@ -800,6 +815,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { this.adoptSessionRef(sessionRef); this.trackUsageScope(sessionRef.providerSessionId, false); availableModeIds = result.modes?.availableModes?.map((m) => m.id) ?? []; + agentCurrentModeId = result.modes?.currentModeId; configOptions = result.configOptions; } catch (error) { throw this.loadSessionErrorRewriter(error, sessionRef.providerSessionId); @@ -822,6 +838,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { this.adoptSessionRef(sessionRef); this.trackUsageScope(sessionRef.providerSessionId, false); availableModeIds = result.modes?.availableModes?.map((m) => m.id) ?? []; + agentCurrentModeId = result.modes?.currentModeId; configOptions = result.configOptions; } catch (error) { throw this.loadSessionErrorRewriter(error, sessionRef.providerSessionId); @@ -842,6 +859,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { this.stableSessionRef = createKnownSessionRef(result.sessionId); this.trackUsageScope(result.sessionId, true); availableModeIds = result.modes?.availableModes?.map((m) => m.id) ?? []; + agentCurrentModeId = result.modes?.currentModeId; configOptions = result.configOptions; console.log("[acp] session created:", this.sessionId, "modes:", availableModeIds); } @@ -851,6 +869,12 @@ export class AcpStructuredSession implements StructuredSessionHandle { } else { this.sessionConfigSync.rememberAvailableModes(availableModeIds); } + // `SessionModeState.currentModeId` is the agent's own statement of the mode + // it is in — authoritative for a resumed session, where it reflects state + // the agent restored. Recording it keeps `applyTurnConfig` from re-pushing + // that same mode back at the agent. + this.sessionConfigSync.rememberCurrentMode(agentCurrentModeId); + this.planModeToolTracker.reset(); this.currentConfig = await this.sessionConfigSync.applyTurnConfig( this.sessionId, config, @@ -1178,7 +1202,9 @@ export class AcpStructuredSession implements StructuredSessionHandle { params.path, this.fsAgentHomeDirs, ); - const fullContent = await readFile(path, "utf8"); + const fullContent = await readFile(path, "utf8").catch((error: unknown) => { + throw toAcpFsRequestError(error, params.path); + }); const content = sliceTextFileContent(fullContent, params.line, params.limit); return { content }; } @@ -1190,7 +1216,9 @@ export class AcpStructuredSession implements StructuredSessionHandle { params.path, this.fsAgentHomeDirs, ); - await writeFile(path, params.content, "utf8"); + await writeFile(path, params.content, "utf8").catch((error: unknown) => { + throw toAcpFsRequestError(error, params.path); + }); return {}; } @@ -1423,6 +1451,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { break; case "tool_call": + this.observePlanModeToolCall(update); // A tool call that belongs to the active prompt confirms working // state. Some ACP agents (Qwen notably) deliver background-task // notifications after prompt() has already settled; those updates @@ -1435,6 +1464,7 @@ export class AcpStructuredSession implements StructuredSessionHandle { case "tool_call_update": // Tool call status changed — still working + this.observePlanModeToolCall(update); break; case "plan": @@ -1446,20 +1476,9 @@ export class AcpStructuredSession implements StructuredSessionHandle { case "current_mode_update": case "config_option_update": { - const nextConfig = this.sessionConfigSync.reduceSessionUpdate(this.currentConfig, update); - if (nextConfig) { - this.currentConfig = nextConfig; - const sessionRef = this.currentSessionRef(); - // Configuration confirmations are metadata, not turn boundaries — - // preserve the live status so the renderer's working-time clock - // does not reset when an agent echoes a configuration change. - this.emitListenerUpdate({ - status: this.currentStatus, - attention: this.currentAttention, - config: nextConfig, - ...(sessionRef ? { sessionRef } : {}), - }); - } + this.commitAgentConfigChange( + this.sessionConfigSync.reduceSessionUpdate(this.currentConfig, update), + ); break; } @@ -1473,6 +1492,64 @@ export class AcpStructuredSession implements StructuredSessionHandle { } } + /** + * Commit a config the agent reported (mode, model, effort) and tell the + * renderer. Configuration confirmations are metadata, not turn boundaries — + * the live status is preserved so the renderer's working-time clock does not + * reset when an agent echoes a configuration change. + */ + private commitAgentConfigChange(nextConfig: ThreadConfig | undefined): void { + if (!nextConfig) return; + this.currentConfig = nextConfig; + const sessionRef = this.currentSessionRef(); + this.emitListenerUpdate({ + status: this.currentStatus, + attention: this.currentAttention, + config: nextConfig, + ...(sessionRef ? { sessionRef } : {}), + }); + } + + private get planModeToolTracker(): AcpPlanModeToolTracker { + return (this.planModeToolTrackerInstance ??= new AcpPlanModeToolTracker()); + } + + /** + * Follow the agent in and out of plan mode when its tool calls say so. ACP + * expects an agent to announce its own mode changes with + * `current_mode_update`, but the spec only says it "can" (and offers no way to + * read the mode mid-session), so agents that skip it — Kimi Code's + * `EnterPlanMode` / `ExitPlanMode` — would otherwise leave the composer + * showing a mode the agent is no longer in. Inference only: no request is sent + * to the agent. + * + * Both directions matter. Adopting the entry without the exit is worse than + * adopting neither: the thread would keep claiming plan mode after the agent + * left it, and because the config then already reads `plan`, nothing would + * re-assert it — an edit could land while the composer still showed Plan. + * + * Skipped while replaying a loaded session's history, where + * `SessionModeState.currentModeId` is the authority and a historical + * transition may since have been reversed. + */ + private observePlanModeToolCall(update: SessionUpdate): void { + if (this.isReplayingHistory || Date.now() < (this.replayHistoryUntil || 0)) return; + const transition = this.planModeToolTracker.observe(update); + if (!transition) return; + console.log( + "[acp] agent %s plan mode via tool call (no current_mode_update sent)", + transition === "entered" ? "entered" : "left", + ); + this.commitAgentConfigChange( + transition === "entered" + ? this.sessionConfigSync.reduceModeChange( + this.currentConfig, + this.sessionConfigSync.resolvePlanModeId(), + ) + : this.sessionConfigSync.reduceLeavePlanMode(this.currentConfig), + ); + } + /** * Feed a provider-recovered update through the normal ACP mapping path. * Some ACP adapters can reconstruct notifications that their server omits diff --git a/src/supervisor/agents/acp/sessionConfigSync.test.ts b/src/supervisor/agents/acp/sessionConfigSync.test.ts index 4712f6eb..bd44f3e9 100644 --- a/src/supervisor/agents/acp/sessionConfigSync.test.ts +++ b/src/supervisor/agents/acp/sessionConfigSync.test.ts @@ -518,6 +518,101 @@ describe("AcpSessionConfigSync", () => { ).toEqual({ ...currentConfig, approvalPolicy: "auto-high" }); }); + it("skips the mode push when the agent already reported that mode", async () => { + // `SessionModeState.currentModeId` from session/new|load|resume is the + // agent's own statement of its mode. Re-asserting it is not a no-op for + // every agent (Kimi records a `plan_mode.cancel`), so a resumed session + // must not have its restored mode pushed back at it. + const { connection, sync } = makeConfigSync(); + sync.rememberCurrentMode("plan"); + + await sync.applyTurnConfig("session-1", { ...previousConfig, mode: "plan" }, undefined); + + expect(connection.setSessionMode).not.toHaveBeenCalled(); + }); + + it("still pushes when the agent reports a different mode", async () => { + const { connection, sync } = makeConfigSync(); + sync.rememberCurrentMode("default"); + + await sync.applyTurnConfig("session-1", { ...previousConfig, mode: "plan" }, undefined); + + expect(connection.setSessionMode).toHaveBeenCalledWith({ + sessionId: "session-1", + modeId: "plan", + }); + }); + + it("compares the reported mode by its normalized id", async () => { + // Agents may report a mode as a spec URI (…/session-modes#plan). + const { connection, sync } = makeConfigSync(); + sync.rememberCurrentMode("https://agentclientprotocol.com/protocol/session-modes#plan"); + + await sync.applyTurnConfig("session-1", { ...previousConfig, mode: "plan" }, undefined); + + expect(connection.setSessionMode).not.toHaveBeenCalled(); + }); + + it("does not re-push a mode it just pushed on the following turn", async () => { + const { connection, sync } = makeConfigSync(); + const planConfig: ThreadConfig = { ...previousConfig, mode: "plan" }; + + await sync.applyTurnConfig("session-1", planConfig, previousConfig); + await sync.applyTurnConfig("session-1", planConfig, previousConfig); + + expect(connection.setSessionMode).toHaveBeenCalledTimes(1); + }); + + it("folds an agent-reported mode change into the config and remembers it", async () => { + const { connection, sync } = makeConfigSync(); + + expect(sync.reduceModeChange(previousConfig, "plan")).toEqual({ + ...previousConfig, + mode: "plan", + }); + // Learning the mode this way must also suppress a redundant push. + await sync.applyTurnConfig("session-1", { ...previousConfig, mode: "plan" }, previousConfig); + expect(connection.setSessionMode).not.toHaveBeenCalled(); + }); + + it("leaves plan mode without rewriting the approval policy", () => { + // Mapping the exit through a mode id would turn `auto` into `default`; + // leaving plan mode says nothing about which approvals the user picked. + const { sync } = makeConfigSync(); + + expect( + sync.reduceLeavePlanMode({ ...previousConfig, mode: "plan", approvalPolicy: "auto" }), + ).toEqual({ ...previousConfig, mode: "agent", approvalPolicy: "auto" }); + expect(sync.reduceLeavePlanMode({ ...previousConfig, mode: "agent" })).toBeUndefined(); + }); + + it("stops suppressing pushes once the agent leaves plan mode", async () => { + const { connection, sync } = makeConfigSync(); + sync.rememberCurrentMode("plan"); + sync.reduceLeavePlanMode({ ...previousConfig, mode: "plan" }); + + await sync.applyTurnConfig("session-1", { ...previousConfig, mode: "plan" }, undefined); + + expect(connection.setSessionMode).toHaveBeenCalledWith({ + sessionId: "session-1", + modeId: "plan", + }); + }); + + it("returns undefined from reduceModeChange when the config is already in that mode", () => { + const { sync } = makeConfigSync(); + + expect(sync.reduceModeChange({ ...previousConfig, mode: "plan" }, "plan")).toBeUndefined(); + }); + + it('resolves the agent\'s own id for plan mode, falling back to "plan"', () => { + expect(makeConfigSync().sync.resolvePlanModeId()).toBe("plan"); + expect( + makeConfigSync({ availableModeIds: ["default", "architect"] }).sync.resolvePlanModeId(), + ).toBe("architect"); + expect(makeConfigSync({ availableModeIds: ["default"] }).sync.resolvePlanModeId()).toBe("plan"); + }); + it("remembers config option updates and returns effort changes", async () => { const { connection, sync } = makeConfigSync(); const updatedOptions = [thoughtLevelOption("thought-new", "high")]; diff --git a/src/supervisor/agents/acp/sessionConfigSync.ts b/src/supervisor/agents/acp/sessionConfigSync.ts index 75c435f5..9b299668 100644 --- a/src/supervisor/agents/acp/sessionConfigSync.ts +++ b/src/supervisor/agents/acp/sessionConfigSync.ts @@ -1,6 +1,7 @@ import type { ClientSideConnection, SessionUpdate } from "@agentclientprotocol/sdk"; import { isThreadConfigEqual, type ThreadConfig } from "@/shared/contracts"; import { toErrorMessage } from "@/shared/errorMessage"; +import { normalizeAcpModeId } from "./probe"; import { applyAcpModeUpdateToConfig, findSelectConfigOption, @@ -30,6 +31,15 @@ type ConfigOptionUpdateWaiter = { */ export class AcpSessionConfigSync { private _availableModeIds: string[] = []; + /** + * The mode the agent last told us it is in — from `SessionModeState` at + * session open, from a `current_mode_update` notification, or from a mode we + * successfully pushed. Used to skip re-asserting a mode the agent already + * holds: a redundant `session/set_mode` is not a no-op for every agent (Kimi + * records a `plan_mode.cancel` for it), so pushing one on resume can drop + * session state the agent had restored. + */ + private agentCurrentModeId: string | undefined; private currentConfigOptions: unknown[] = []; private modeConfigId: string | undefined; private modelConfigValue: string | undefined; @@ -46,6 +56,53 @@ export class AcpSessionConfigSync { this._availableModeIds = availableModeIds; } + /** Record `SessionModeState.currentModeId` from a session open/load/resume. */ + rememberCurrentMode(modeId: string | undefined): void { + this.agentCurrentModeId = modeId; + } + + /** + * Fold an agent-reported mode change into the thread config. Returns + * `undefined` when the config is already in that mode. + */ + reduceModeChange( + currentConfig: ThreadConfig | undefined, + modeId: string, + ): ThreadConfig | undefined { + this.agentCurrentModeId = modeId; + if (!currentConfig) return undefined; + const nextConfig = applyAcpModeUpdateToConfig(currentConfig, modeId); + return isThreadConfigEqual(currentConfig, nextConfig) ? undefined : nextConfig; + } + + /** + * Fold an agent-reported plan-mode *exit* into the thread config. Unlike + * {@link reduceModeChange} this keeps the thread's approval policy: leaving + * plan mode says nothing about which approvals the user picked, and mapping + * through a mode id would rewrite `auto` to `default`. The agent's mode is + * then unknown again — it left plan mode for whatever it was in before — so + * the remembered mode is cleared rather than guessed. + */ + reduceLeavePlanMode(currentConfig: ThreadConfig | undefined): ThreadConfig | undefined { + this.agentCurrentModeId = undefined; + if (!currentConfig || currentConfig.mode !== "plan") return undefined; + return { ...currentConfig, mode: "agent" }; + } + + /** True when the agent already reported being in `modeId`. */ + private agentHoldsMode(modeId: string): boolean { + if (!this.agentCurrentModeId) return false; + return ( + normalizeAcpModeId(this.agentCurrentModeId).toLowerCase() === + normalizeAcpModeId(modeId).toLowerCase() + ); + } + + /** The Poracode mode id for plan mode as this agent names it. */ + resolvePlanModeId(): string { + return resolveAcpMode({ model: "", mode: "plan" }, this._availableModeIds) ?? "plan"; + } + rememberOptions(availableModeIds: string[], configOptions: unknown): void { const configModeIds = listSelectConfigOptionValues(configOptions, "mode"); this.rememberAvailableModes(configModeIds.length > 0 ? configModeIds : availableModeIds); @@ -70,10 +127,18 @@ export class AcpSessionConfigSync { const previousModeId = previousConfig ? resolveAcpMode(previousConfig, this._availableModeIds) : undefined; + // The agent's own report wins over `previousConfig` for "is a push needed?". + // On the first turn after a session open there is no previous config, so + // without this every open re-asserted a mode the agent already held. + const modeChangeNeeded = + Boolean(nextModeId) && + nextModeId !== previousModeId && + !this.agentHoldsMode(nextModeId as string); - if (nextModeId && nextModeId !== previousModeId && this.modeConfigId) { + if (modeChangeNeeded && this.modeConfigId) { try { - await this.setConfigOptionAndRefresh(sessionId, this.modeConfigId, nextModeId); + await this.setConfigOptionAndRefresh(sessionId, this.modeConfigId, nextModeId as string); + this.agentCurrentModeId = nextModeId; console.log("[acp] mode config set to:", nextModeId); } catch (error) { console.log( @@ -81,9 +146,10 @@ export class AcpSessionConfigSync { toErrorMessage(error), ); } - } else if (nextModeId && nextModeId !== previousModeId) { + } else if (modeChangeNeeded) { try { - await this.connection.setSessionMode({ sessionId, modeId: nextModeId }); + await this.connection.setSessionMode({ sessionId, modeId: nextModeId as string }); + this.agentCurrentModeId = nextModeId; console.log("[acp] mode set to:", nextModeId); } catch (error) { console.log("[acp] live mode change rejected, continuing: %s", toErrorMessage(error)); @@ -170,8 +236,7 @@ export class AcpSessionConfigSync { if (!("currentModeId" in update) || typeof update.currentModeId !== "string") { return undefined; } - const nextConfig = applyAcpModeUpdateToConfig(currentConfig, update.currentModeId); - return isThreadConfigEqual(currentConfig, nextConfig) ? undefined : nextConfig; + return this.reduceModeChange(currentConfig, update.currentModeId); } return undefined; diff --git a/src/supervisor/agents/acp/sessionFactory.ts b/src/supervisor/agents/acp/sessionFactory.ts index 7f6c25a8..cefa5431 100644 --- a/src/supervisor/agents/acp/sessionFactory.ts +++ b/src/supervisor/agents/acp/sessionFactory.ts @@ -66,6 +66,9 @@ export function createAcpStructuredSession( : {}), ...(input.mcpServers !== undefined ? { mcpServers: input.mcpServers } : {}), ...(input.acpFsAgentHomeDirs ? { fsAgentHomeDirs: input.acpFsAgentHomeDirs } : {}), + ...(input.acpFsTextCapability !== undefined + ? { fsTextCapability: input.acpFsTextCapability } + : {}), ...(overrides?.assumedMcpCapabilities ? { assumedMcpCapabilities: overrides.assumedMcpCapabilities } : {}), diff --git a/src/supervisor/agents/acp/sessionFsErrors.ts b/src/supervisor/agents/acp/sessionFsErrors.ts new file mode 100644 index 00000000..1ea45ccc --- /dev/null +++ b/src/supervisor/agents/acp/sessionFsErrors.ts @@ -0,0 +1,40 @@ +/** + * Map host filesystem failures from the ACP `fs/read_text_file` and + * `fs/write_text_file` handlers onto JSON-RPC errors the agent can classify. + * + * Without this, a Node errno (`ENOENT`, `EACCES`, …) escapes the handler as a + * plain `Error` and the ACP SDK reports it as `-32603 Internal error`, which + * tells the agent nothing — a missing file becomes indistinguishable from a + * broken client. The spec reserves `-32002` (resource not found) for exactly + * this case, so a "does the file exist yet?" read gets an answer instead of a + * hard failure. + */ +import { RequestError } from "@agentclientprotocol/sdk"; + +/** Node errnos that mean "this path does not resolve to a file". */ +const NOT_FOUND_CODES = new Set(["ENOENT", "ENOTDIR"]); + +function errnoOf(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = (error as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +/** + * Convert an fs error into the JSON-RPC error to send back to the agent. + * `RequestError`s (e.g. the outside-the-project rejection from + * `resolveAcp*HostFsPath`) pass through untouched; everything else keeps its + * errno and message in the error payload so failures stay diagnosable. + */ +export function toAcpFsRequestError(error: unknown, rawPath: string): unknown { + if (error instanceof RequestError) return error; + const code = errnoOf(error); + if (code !== undefined && NOT_FOUND_CODES.has(code)) { + return RequestError.resourceNotFound(rawPath); + } + return RequestError.internalError({ + path: rawPath, + ...(code !== undefined ? { code } : {}), + message: error instanceof Error ? error.message : String(error), + }); +} diff --git a/src/supervisor/agents/acp/sessionPlanMode.test.ts b/src/supervisor/agents/acp/sessionPlanMode.test.ts new file mode 100644 index 00000000..f93ed7bf --- /dev/null +++ b/src/supervisor/agents/acp/sessionPlanMode.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; +import type { SessionUpdate } from "@agentclientprotocol/sdk"; +import { AcpPlanModeToolTracker } from "./sessionPlanMode"; + +function toolCall(fields: Record): SessionUpdate { + return { sessionUpdate: "tool_call", ...fields } as unknown as SessionUpdate; +} + +function toolCallUpdate(fields: Record): SessionUpdate { + return { sessionUpdate: "tool_call_update", ...fields } as unknown as SessionUpdate; +} + +describe("AcpPlanModeToolTracker", () => { + it("reports entry when a completed update lands on a call announced as EnterPlanMode", () => { + // Verbatim shape of a real Kimi Code sequence: the call is renamed to + // "Requesting to enter plan mode" and the completed update carries no + // title, so the id announced first is the only thing to correlate on. + const tracker = new AcpPlanModeToolTracker(); + expect( + tracker.observe( + toolCall({ + toolCallId: "0:tool_x", + title: "EnterPlanMode", + kind: "other", + status: "pending", + }), + ), + ).toBeUndefined(); + expect( + tracker.observe(toolCallUpdate({ toolCallId: "0:tool_x", status: "in_progress" })), + ).toBeUndefined(); + expect( + tracker.observe( + toolCallUpdate({ + toolCallId: "0:tool_x", + title: "Requesting to enter plan mode", + status: "in_progress", + }), + ), + ).toBeUndefined(); + expect(tracker.observe(toolCallUpdate({ toolCallId: "0:tool_x", status: "completed" }))).toBe( + "entered", + ); + }); + + it("reports entry only once per call", () => { + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(toolCall({ toolCallId: "t1", title: "enter_plan_mode", status: "pending" })); + expect(tracker.observe(toolCallUpdate({ toolCallId: "t1", status: "completed" }))).toBe( + "entered", + ); + expect( + tracker.observe(toolCallUpdate({ toolCallId: "t1", status: "completed" })), + ).toBeUndefined(); + }); + + it("does not report entry when the tool call fails", () => { + // The regression this whole path came from: EnterPlanMode failing with + // `Internal error` must leave the client's mode untouched. + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(toolCall({ toolCallId: "t1", title: "EnterPlanMode", status: "pending" })); + expect(tracker.observe(toolCallUpdate({ toolCallId: "t1", status: "failed" }))).toBeUndefined(); + expect( + tracker.observe(toolCallUpdate({ toolCallId: "t1", status: "completed" })), + ).toBeUndefined(); + }); + + it("ignores unrelated tool calls", () => { + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(toolCall({ toolCallId: "t1", title: "Read", status: "pending" })); + expect( + tracker.observe(toolCallUpdate({ toolCallId: "t1", status: "completed" })), + ).toBeUndefined(); + }); + + it("matches the tool on kind as well as title, and ignores non-tool updates", () => { + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(toolCall({ toolCallId: "t1", kind: "enterPlanMode", status: "pending" })); + expect(tracker.observe(toolCallUpdate({ toolCallId: "t1", status: "completed" }))).toBe( + "entered", + ); + expect( + tracker.observe({ + sessionUpdate: "current_mode_update", + currentModeId: "plan", + } as SessionUpdate), + ).toBeUndefined(); + }); + + // The `output` strings below are the real branches of Kimi's ExitPlanMode + // review: approve/auto-approve and "Reject and Exit" both deactivate plan + // mode, while Revise, dismiss, and a plain reject keep it active — and the + // ones that decline the plan are reported as a FAILED tool call either way, + // so the status alone cannot tell them apart. + function exitCall(id: string): SessionUpdate { + return toolCall({ toolCallId: id, title: "ExitPlanMode", kind: "other", status: "pending" }); + } + + function exitResult(id: string, status: string, text: string): SessionUpdate { + return toolCallUpdate({ + toolCallId: id, + status, + content: [{ type: "content", content: { type: "text", text } }], + }); + } + + it("reports exit when an approved ExitPlanMode completes", () => { + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(exitCall("t1")); + expect( + tracker.observe( + exitResult( + "t1", + "completed", + "Exited plan mode. Plan mode deactivated. All tools are now available.", + ), + ), + ).toBe("exited"); + }); + + it("reports exit for a rejection that also left plan mode", () => { + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(exitCall("t1")); + expect( + tracker.observe(exitResult("t1", "failed", "Plan rejected by user. Plan mode deactivated.")), + ).toBe("exited"); + }); + + it.each([ + ["Revise", "User requested revisions. Plan mode remains active."], + ["dismissed", "Plan approval dismissed. Plan mode remains active."], + ["plain reject", "Plan rejected by user. Plan mode remains active."], + ])("stays in plan mode when the review ends with %s", (_case, output) => { + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(exitCall("t1")); + expect(tracker.observe(exitResult("t1", "failed", output))).toBeUndefined(); + }); + + it("stays in plan mode when a failed ExitPlanMode says nothing about the mode", () => { + // Conservative default: an unexplained failure is not evidence of an exit. + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(exitCall("t1")); + expect(tracker.observe(exitResult("t1", "failed", "Tool call aborted."))).toBeUndefined(); + }); + + it("keeps plan mode when a completed ExitPlanMode reports it still active", () => { + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(exitCall("t1")); + expect( + tracker.observe( + exitResult("t1", "completed", "Plan approval dismissed. Plan mode remains active."), + ), + ).toBeUndefined(); + }); + + it("drops correlations on reset so a reopened session starts clean", () => { + const tracker = new AcpPlanModeToolTracker(); + tracker.observe(toolCall({ toolCallId: "t1", title: "EnterPlanMode", status: "pending" })); + tracker.reset(); + expect( + tracker.observe(toolCallUpdate({ toolCallId: "t1", status: "completed" })), + ).toBeUndefined(); + }); +}); diff --git a/src/supervisor/agents/acp/sessionPlanMode.ts b/src/supervisor/agents/acp/sessionPlanMode.ts new file mode 100644 index 00000000..4584dfe1 --- /dev/null +++ b/src/supervisor/agents/acp/sessionPlanMode.ts @@ -0,0 +1,118 @@ +/** + * Infer agent-initiated plan-mode transitions from the `session/update` stream. + * + * ACP's own signal for this is the `current_mode_update` notification: the spec + * says an agent "can also change its own mode and let the Client know by + * sending the `current_mode_update` session notification" — expected practice, + * not a `MUST`, and there is no way for a client to read the current mode + * mid-session (no `session/get_mode`; `SessionModeState.currentModeId` only + * comes back from `session/new` / `session/load` / `session/resume`). + * + * Kimi Code exercises exactly that gap: its `EnterPlanMode` / `ExitPlanMode` + * tools move the session in and out of plan mode without emitting the + * notification, so the composer kept showing the stale mode. This tracker + * watches the tool-call stream instead. It is a local inference only — nothing + * is sent to the agent. + * + * Transitions have to be correlated by tool call id rather than matched on a + * single update: a real Kimi sequence renames the call and drops the title on + * the way to `completed`, e.g. + * `tool_call { toolCallId: "0:tool_x", title: "EnterPlanMode", status: "pending" }` + * `tool_call_update { toolCallId: "0:tool_x", status: "in_progress" }` + * `tool_call_update { toolCallId: "0:tool_x", title: "Requesting to enter plan mode" }` + * `tool_call_update { toolCallId: "0:tool_x", status: "completed" }` ← no title + * so the id is remembered when the tool is first announced and resolved when + * that id reports a terminal status. + */ +import type { SessionUpdate } from "@agentclientprotocol/sdk"; +import { + extractToolCallContentText, + isAcpEnterPlanModeTool, + isAcpExitPlanModeTool, +} from "./canonicalMapping/contentExtraction"; + +export type AcpPlanModeTransition = "entered" | "exited"; + +/** Terminal tool-call statuses that mean the call did not run to completion. */ +const UNSUCCESSFUL_STATUSES = new Set(["failed", "cancelled", "canceled"]); + +/** + * A plan review can end in several ways, and an agent may report the ones that + * decline the plan as a *failed* tool call even when plan mode did end. Kimi's + * `ExitPlanMode` results are explicit about which happened — "Plan mode + * deactivated." for approve/auto-approve and "Reject and Exit", "Plan mode + * remains active." for Revise, dismiss, and a plain reject — so the result text + * decides when the status alone is ambiguous. + */ +const PLAN_MODE_STILL_ACTIVE_PATTERN = /plan mode\s+(?:remains|is still)\s+active/i; +const PLAN_MODE_ENDED_PATTERN = /(?:exited plan mode|plan mode\s+(?:deactivated|exited))/i; + +function readString(update: SessionUpdate, key: string): string | undefined { + if (!(key in update)) return undefined; + const value = (update as unknown as Record)[key]; + return typeof value === "string" ? value : undefined; +} + +function readContent(update: SessionUpdate): unknown { + return "content" in update ? (update as unknown as Record).content : undefined; +} + +export class AcpPlanModeToolTracker { + private readonly enterToolCallIds = new Set(); + private readonly exitToolCallIds = new Set(); + + /** + * Feed a `tool_call` / `tool_call_update` notification. + * + * Returns the transition a tracked plan-mode tool call just completed, or + * `undefined` when the update says nothing about the mode. Conservative on + * both sides: a failed `EnterPlanMode` never enters, and an `ExitPlanMode` + * whose outcome is unclear is treated as still planning. + */ + observe(update: SessionUpdate): AcpPlanModeTransition | undefined { + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return undefined; + } + const toolCallId = readString(update, "toolCallId"); + if (!toolCallId) return undefined; + + const title = readString(update, "title"); + const kind = readString(update, "kind"); + if (isAcpEnterPlanModeTool(title, kind)) this.enterToolCallIds.add(toolCallId); + if (isAcpExitPlanModeTool(title, kind)) this.exitToolCallIds.add(toolCallId); + + const status = readString(update, "status"); + if (!status) return undefined; + + if (this.enterToolCallIds.has(toolCallId)) { + if (status === "completed") { + this.enterToolCallIds.delete(toolCallId); + return "entered"; + } + if (UNSUCCESSFUL_STATUSES.has(status)) { + // A failed EnterPlanMode leaves the agent where it was — this is the + // shape the `Tool "EnterPlanMode" failed: Internal error` regression + // produced, and it must not move the client's mode. + this.enterToolCallIds.delete(toolCallId); + } + return undefined; + } + + if (this.exitToolCallIds.has(toolCallId)) { + if (status !== "completed" && !UNSUCCESSFUL_STATUSES.has(status)) return undefined; + this.exitToolCallIds.delete(toolCallId); + const text = extractToolCallContentText(readContent(update)); + if (text && PLAN_MODE_STILL_ACTIVE_PATTERN.test(text)) return undefined; + if (status === "completed") return "exited"; + return text && PLAN_MODE_ENDED_PATTERN.test(text) ? "exited" : undefined; + } + + return undefined; + } + + /** Drop correlations across session open/replay boundaries. */ + reset(): void { + this.enterToolCallIds.clear(); + this.exitToolCallIds.clear(); + } +} diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index 726b43b6..397c064e 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -209,6 +209,17 @@ export interface CreateStructuredSessionInput { * bridge rejects those paths and provider features like plan mode break. */ acpFsAgentHomeDirs?: readonly string[]; + /** + * Advertise the ACP `fs.readTextFile` / `fs.writeTextFile` client + * capabilities (default `true`). Set `false` for providers that proxy *all* + * text IO — including their own internal state files — through the client + * and then mis-handle the JSON-RPC errors that come back: a client can only + * answer a read for a missing file with an error, and an agent that expects + * an errno-shaped `ENOENT` there treats it as a hard failure. Poracode holds + * no unsaved editor buffers, so the on-disk content the agent reads locally + * is the same content the bridge would have served. + */ + acpFsTextCapability?: boolean; } export type AcpEmptyResponseErrorResolver = (input: { diff --git a/src/supervisor/agents/kimi/acpFsCapability.test.ts b/src/supervisor/agents/kimi/acpFsCapability.test.ts new file mode 100644 index 00000000..81e15b01 --- /dev/null +++ b/src/supervisor/agents/kimi/acpFsCapability.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +// `createStructuredSession` spawns a real `kimi acp` process; stub the session +// factory (and the trust marker it writes first) so the test only inspects the +// options the adapter hands the shared ACP session. +vi.mock("../acp", () => ({ + createAcpStructuredSession: vi.fn<() => undefined>(() => undefined), +})); +vi.mock("./kimiTrust", () => ({ + ensureKimiWorkspaceTrust: vi.fn<() => Promise>(async () => {}), +})); + +import type { ProjectLocation, ThreadConfig } from "@/shared/contracts"; +import { createAcpStructuredSession } from "../acp"; +import { createKimiAdapter } from "./index"; + +async function createSessionOptions() { + const adapter = createKimiAdapter(); + await adapter.createStructuredSession?.({ + threadId: "thread-1", + projectLocation: { kind: "windows", path: "C:\\repo" } as ProjectLocation, + config: { mode: "agent" } as ThreadConfig, + }); + return vi.mocked(createAcpStructuredSession).mock.calls[0]?.[1]; +} + +describe("Kimi ACP fs capability", () => { + it("keeps the client fs text capability unadvertised so plan mode works", async () => { + // Kimi's ACP host filesystem proxies every text read through the client and + // only recognizes an errno-shaped `ENOENT` as "file missing". Plan mode + // reads its plan file before creating it, so advertising the capability + // turned that read into a JSON-RPC error and killed the turn: + // `EnterPlanMode` failed with `Internal error`, and threads opened in Plan + // mode returned no response at all. + expect(await createSessionOptions()).toMatchObject({ acpFsTextCapability: false }); + }); + + it("no longer needs the ~/.kimi-code fs carve-out", async () => { + // The carve-out only existed to let those proxied reads/writes reach Kimi's + // own home dir. With the capability unadvertised they never leave the agent. + expect(await createSessionOptions()).not.toHaveProperty("acpFsAgentHomeDirs"); + }); +}); diff --git a/src/supervisor/agents/kimi/index.ts b/src/supervisor/agents/kimi/index.ts index 13fd6da5..208d5d2e 100644 --- a/src/supervisor/agents/kimi/index.ts +++ b/src/supervisor/agents/kimi/index.ts @@ -140,12 +140,19 @@ export function createKimiAdapter(): AgentAdapter { ); session = createAcpStructuredSession(command, { ...input, - // Kimi keeps per-session state (plan-mode plan files, profiles) under - // ~/.kimi-code and, once fs capability is advertised, routes those - // reads/writes through the ACP client too. Without this carve-out the - // bridge rejects them as outside-project and plan mode breaks - // (Write/Read of the plan file and ExitPlanMode all fail). - acpFsAgentHomeDirs: [".kimi-code"], + // Kimi's ACP host filesystem routes *every* text read/write through + // the client once fs capability is advertised — including its own + // per-session state under ~/.kimi-code — and rethrows the client's + // JSON-RPC error verbatim. Its "file does not exist" check only + // recognizes an errno-shaped `ENOENT` reached through `Error.cause`, + // so no JSON-RPC code (not even `-32002` resource-not-found) reads as + // missing. Plan mode reads the plan file before creating it, so the + // read of that not-yet-existing file killed every plan-mode turn: + // `EnterPlanMode` returned `Tool "EnterPlanMode" failed: Internal + // error`, and threads started in Plan mode ended with no response at + // all. Keeping the capability unadvertised makes Kimi read and write + // through its own local filesystem, where the errno survives. + acpFsTextCapability: false, acpEmptyResponseErrorResolver: resolveKimiEmptyResponseError, acpSessionUpdateTransform: createKimiAcpSessionUpdateTransform({ subagents,