make extension plan mode more similar to CLI

- basically, don't auto continue when agent switches to act mode
This commit is contained in:
Max Paulus 🥪
2026-06-04 12:19:31 -07:00
parent 7e19d85b9c
commit cf25cd66a7
4 changed files with 433 additions and 263 deletions
+175 -136
View File
@@ -1,7 +1,10 @@
import type { ClineMessage } from "@shared/ExtensionMessage"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { StateManager } from "@/core/storage/StateManager"
import { SdkModeCoordinator, type SdkModeCoordinatorOptions } from "./sdk-mode-coordinator"
import type { ClineMessage } from "@shared/ExtensionMessage";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StateManager } from "@/core/storage/StateManager";
import {
SdkModeCoordinator,
type SdkModeCoordinatorOptions,
} from "./sdk-mode-coordinator";
vi.mock("@/shared/services/Logger", () => ({
Logger: {
@@ -10,122 +13,139 @@ vi.mock("@/shared/services/Logger", () => ({
log: vi.fn(),
warn: vi.fn(),
},
}))
}));
vi.mock("@core/storage/disk", () => ({
saveClineMessages: vi.fn().mockResolvedValue(undefined),
}))
}));
describe("SdkModeCoordinator", () => {
beforeEach(() => {
vi.clearAllMocks()
})
vi.clearAllMocks();
});
it("applies a queued switch_to_act_mode change", async () => {
const activeSession = makeActiveSession()
const { coordinator, state, options } = makeCoordinator({ activeSession })
const activeSession = makeActiveSession();
const { coordinator, state, options } = makeCoordinator({ activeSession });
coordinator.queueSwitchToActMode()
expect(coordinator.hasPendingModeChange()).toBe(true)
coordinator.queueSwitchToActMode();
expect(coordinator.hasPendingModeChange()).toBe(true);
await coordinator.applyPendingModeChange()
await coordinator.applyPendingModeChange();
expect(coordinator.hasPendingModeChange()).toBe(false)
expect(state.mode).toBe("act")
expect(options.sessions.setRunning).toHaveBeenCalledWith(true)
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
expect.objectContaining({ send: expect.any(Function) }),
"new-session",
"The user approved switching to act mode. Continue with the approved plan now.",
)
expect(options.postStateToWebview).toHaveBeenCalledOnce()
})
expect(coordinator.hasPendingModeChange()).toBe(false);
expect(state.mode).toBe("act");
expect(options.sessions.setRunning).not.toHaveBeenCalledWith(true);
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled();
expect(options.postStateToWebview).toHaveBeenCalledOnce();
});
it("preserves pending input by returning false when toggling mode without an active session", async () => {
const { coordinator, state, options } = makeCoordinator({ mode: "act" })
const { coordinator, state, options } = makeCoordinator({ mode: "act" });
await expect(coordinator.togglePlanActMode("plan")).resolves.toBe(false)
await expect(coordinator.togglePlanActMode("plan")).resolves.toBe(false);
expect(state.mode).toBe("plan")
expect(options.postStateToWebview).toHaveBeenCalledOnce()
})
expect(state.mode).toBe("plan");
expect(options.postStateToWebview).toHaveBeenCalledOnce();
});
it("returns false without side effects when toggling to the current mode", async () => {
const { coordinator, options } = makeCoordinator({ mode: "plan" })
const { coordinator, options } = makeCoordinator({ mode: "plan" });
await expect(coordinator.togglePlanActMode("plan")).resolves.toBe(false)
await expect(coordinator.togglePlanActMode("plan")).resolves.toBe(false);
expect(options.stateManager.setGlobalState).not.toHaveBeenCalled()
expect(options.postStateToWebview).not.toHaveBeenCalled()
})
expect(options.stateManager.setGlobalState).not.toHaveBeenCalled();
expect(options.postStateToWebview).not.toHaveBeenCalled();
});
it("rebuilds an active session for the new mode while preserving the session id", async () => {
const activeSession = makeActiveSession()
const task = makeTask("old-session")
const { coordinator, options } = makeCoordinator({ activeSession, task })
const activeSession = makeActiveSession();
const task = makeTask("old-session");
const { coordinator, options } = makeCoordinator({ activeSession, task });
await coordinator.rebuildSessionForMode("plan")
await coordinator.rebuildSessionForMode("plan");
expect(options.loadInitialMessages).toHaveBeenCalledWith(activeSession.sdkHost, "old-session")
expect(options.sessionConfigBuilder.build).toHaveBeenCalledWith({ cwd: "/workspace", mode: "plan" })
expect(options.buildStartSessionInput).toHaveBeenCalledWith(expect.objectContaining({ sessionId: "old-session" }), {
expect(options.loadInitialMessages).toHaveBeenCalledWith(
activeSession.sdkHost,
"old-session",
);
expect(options.sessionConfigBuilder.build).toHaveBeenCalledWith({
cwd: "/workspace",
mode: "plan",
})
});
expect(options.buildStartSessionInput).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: "old-session" }),
{
cwd: "/workspace",
mode: "plan",
},
);
expect(options.sessions.replaceActiveSession).toHaveBeenCalledWith({
startInput: { prompt: "start" },
initialMessages: [{ role: "user", content: "hello" }],
disposeReason: "modeChange",
})
expect(task.taskId).toBe("new-session")
expect(options.resetMessageTranslator).toHaveBeenCalledOnce()
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
expect(options.postStateToWebview).toHaveBeenCalledOnce()
})
});
expect(task.taskId).toBe("new-session");
expect(options.resetMessageTranslator).toHaveBeenCalledOnce();
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled();
expect(options.postStateToWebview).toHaveBeenCalledOnce();
});
it("auto-continues with the canned act-mode prompt when togglePlanActMode switches plan -> act on an active session", async () => {
const activeSession = makeActiveSession()
const task = makeTask("old-session")
const { coordinator, options, state } = makeCoordinator({ activeSession, task, mode: "plan" })
it("does not auto-continue when togglePlanActMode switches plan -> act on an active session", async () => {
const activeSession = makeActiveSession();
const task = makeTask("old-session");
const { coordinator, options, state } = makeCoordinator({
activeSession,
task,
mode: "plan",
});
await expect(coordinator.togglePlanActMode("act")).resolves.toBe(false)
await expect(coordinator.togglePlanActMode("act")).resolves.toBe(false);
expect(state.mode).toBe("act")
expect(options.sessions.setRunning).toHaveBeenCalledWith(true)
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
expect.objectContaining({ send: expect.any(Function) }),
"new-session",
"The user approved switching to act mode. Continue with the approved plan now.",
)
})
expect(state.mode).toBe("act");
expect(options.sessions.setRunning).not.toHaveBeenCalledWith(true);
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled();
});
it("auto-continues with the user-supplied chatContent message when provided on plan -> act toggle", async () => {
const activeSession = makeActiveSession()
const task = makeTask("old-session")
const { coordinator, options } = makeCoordinator({ activeSession, task, mode: "plan" })
it("preserves user-supplied chatContent instead of submitting it during plan -> act toggle", async () => {
const activeSession = makeActiveSession();
const task = makeTask("old-session");
const { coordinator, options } = makeCoordinator({
activeSession,
task,
mode: "plan",
});
await coordinator.togglePlanActMode("act", { message: " go ahead and implement step 1 ", images: [], files: [] })
await coordinator.togglePlanActMode("act", {
message: " go ahead and implement step 1 ",
images: [],
files: [],
});
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
expect.objectContaining({ send: expect.any(Function) }),
"new-session",
"go ahead and implement step 1",
)
})
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled();
});
it("does not auto-continue on act -> plan toggle even when an active session exists", async () => {
const activeSession = makeActiveSession()
const task = makeTask("old-session")
const { coordinator, options, state } = makeCoordinator({ activeSession, task, mode: "act" })
const activeSession = makeActiveSession();
const task = makeTask("old-session");
const { coordinator, options, state } = makeCoordinator({
activeSession,
task,
mode: "act",
});
await coordinator.togglePlanActMode("plan", { message: "draft message", images: [], files: [] })
await coordinator.togglePlanActMode("plan", {
message: "draft message",
images: [],
files: [],
});
expect(state.mode).toBe("plan")
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
})
expect(state.mode).toBe("plan");
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled();
});
it("emits an auth error and skips replacement when the target cline provider has no token", async () => {
const activeSession = makeActiveSession()
const activeSession = makeActiveSession();
const { coordinator, options } = makeCoordinator({
activeSession,
config: {
@@ -133,45 +153,53 @@ describe("SdkModeCoordinator", () => {
modelId: "cline-model",
apiKey: undefined,
},
})
});
await coordinator.rebuildSessionForMode("act")
await coordinator.rebuildSessionForMode("act");
expect(options.emitClineAuthError).toHaveBeenCalledOnce()
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(options.postStateToWebview).toHaveBeenCalledOnce()
})
expect(options.emitClineAuthError).toHaveBeenCalledOnce();
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled();
expect(options.postStateToWebview).toHaveBeenCalledOnce();
});
it("cancels and finalizes a running turn before rebuilding for mode change", async () => {
const activeSession = makeActiveSession({ isRunning: true })
const task = makeTask("old-session", [{ ts: 1, type: "say", say: "text", text: "partial", partial: true }])
const { coordinator, options } = makeCoordinator({ activeSession, task })
const activeSession = makeActiveSession({ isRunning: true });
const task = makeTask("old-session", [
{ ts: 1, type: "say", say: "text", text: "partial", partial: true },
]);
const { coordinator, options } = makeCoordinator({ activeSession, task });
await coordinator.rebuildSessionForMode("act")
await coordinator.rebuildSessionForMode("act");
expect(options.interactions.clearPending).toHaveBeenCalledWith("Mode changed")
expect(options.messages.cancelPendingSave).toHaveBeenCalledOnce()
expect(activeSession.sdkHost.abort).toHaveBeenCalledWith("old-session")
expect(options.sessions.setRunning).toHaveBeenCalledWith(false)
expect(options.messages.finalizeMessagesForSave).toHaveBeenCalledWith(task.messageStateHandler.getClineMessages())
expect(options.messages.appendMessages).toHaveBeenCalledWith([{ ts: 1, type: "say", say: "text", text: "done" }])
})
})
expect(options.interactions.clearPending).toHaveBeenCalledWith(
"Mode changed",
);
expect(options.messages.cancelPendingSave).toHaveBeenCalledOnce();
expect(activeSession.sdkHost.abort).toHaveBeenCalledWith("old-session");
expect(options.sessions.setRunning).toHaveBeenCalledWith(false);
expect(options.messages.finalizeMessagesForSave).toHaveBeenCalledWith(
task.messageStateHandler.getClineMessages(),
);
expect(options.messages.appendMessages).toHaveBeenCalledWith([
{ ts: 1, type: "say", say: "text", text: "done" },
]);
});
});
function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
const state = { mode: input.mode ?? "plan" }
const activeSession = input.activeSession
const state = { mode: input.mode ?? "plan" };
const activeSession = input.activeSession;
const config = {
providerId: "anthropic",
modelId: "claude",
apiKey: "key",
...input.config,
}
};
const options = {
stateManager: {
getGlobalSettingsKey: vi.fn((key: string) => state[key as "mode"]),
setGlobalState: vi.fn(async (key: string, value: string) => {
state[key as "mode"] = value as "act" | "plan"
state[key as "mode"] = value as "act" | "plan";
}),
} as unknown as StateManager,
sessions: {
@@ -190,62 +218,70 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
appendAndEmit: vi.fn(),
appendMessages: vi.fn(),
cancelPendingSave: vi.fn(),
finalizeMessagesForSave: vi.fn(() => [{ ts: 1, type: "say", say: "text", text: "done" }]),
finalizeMessagesForSave: vi.fn(() => [
{ ts: 1, type: "say", say: "text", text: "done" },
]),
},
sessionConfigBuilder: {
build: vi.fn().mockResolvedValue(config),
},
getTask: vi.fn(() => input.task),
getWorkspaceRoot: vi.fn().mockResolvedValue("/workspace"),
loadInitialMessages: vi.fn().mockResolvedValue([{ role: "user", content: "hello" }]),
loadInitialMessages: vi
.fn()
.mockResolvedValue([{ role: "user", content: "hello" }]),
buildStartSessionInput: vi.fn(() => ({ prompt: "start" })),
emitClineAuthError: vi.fn(),
resetMessageTranslator: vi.fn(),
postStateToWebview: vi.fn().mockResolvedValue(undefined),
} as unknown as SdkModeCoordinatorOptions & {
stateManager: StateManager & {
getGlobalSettingsKey: ReturnType<typeof vi.fn>
setGlobalState: ReturnType<typeof vi.fn>
}
getGlobalSettingsKey: ReturnType<typeof vi.fn>;
setGlobalState: ReturnType<typeof vi.fn>;
};
sessions: SdkModeCoordinatorOptions["sessions"] & {
getActiveSession: ReturnType<typeof vi.fn>
fireAndForgetSend: ReturnType<typeof vi.fn>
replaceActiveSession: ReturnType<typeof vi.fn>
setRunning: ReturnType<typeof vi.fn>
}
interactions: SdkModeCoordinatorOptions["interactions"] & { clearPending: ReturnType<typeof vi.fn> }
getActiveSession: ReturnType<typeof vi.fn>;
fireAndForgetSend: ReturnType<typeof vi.fn>;
replaceActiveSession: ReturnType<typeof vi.fn>;
setRunning: ReturnType<typeof vi.fn>;
};
interactions: SdkModeCoordinatorOptions["interactions"] & {
clearPending: ReturnType<typeof vi.fn>;
};
messages: SdkModeCoordinatorOptions["messages"] & {
appendAndEmit: ReturnType<typeof vi.fn>
appendMessages: ReturnType<typeof vi.fn>
cancelPendingSave: ReturnType<typeof vi.fn>
finalizeMessagesForSave: ReturnType<typeof vi.fn>
}
sessionConfigBuilder: SdkModeCoordinatorOptions["sessionConfigBuilder"] & { build: ReturnType<typeof vi.fn> }
getTask: ReturnType<typeof vi.fn>
getWorkspaceRoot: ReturnType<typeof vi.fn>
loadInitialMessages: ReturnType<typeof vi.fn>
buildStartSessionInput: ReturnType<typeof vi.fn>
emitClineAuthError: ReturnType<typeof vi.fn>
resetMessageTranslator: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
}
appendAndEmit: ReturnType<typeof vi.fn>;
appendMessages: ReturnType<typeof vi.fn>;
cancelPendingSave: ReturnType<typeof vi.fn>;
finalizeMessagesForSave: ReturnType<typeof vi.fn>;
};
sessionConfigBuilder: SdkModeCoordinatorOptions["sessionConfigBuilder"] & {
build: ReturnType<typeof vi.fn>;
};
getTask: ReturnType<typeof vi.fn>;
getWorkspaceRoot: ReturnType<typeof vi.fn>;
loadInitialMessages: ReturnType<typeof vi.fn>;
buildStartSessionInput: ReturnType<typeof vi.fn>;
emitClineAuthError: ReturnType<typeof vi.fn>;
resetMessageTranslator: ReturnType<typeof vi.fn>;
postStateToWebview: ReturnType<typeof vi.fn>;
};
return {
coordinator: new SdkModeCoordinator(options),
options,
state,
}
};
}
interface MakeCoordinatorInput {
mode: "act" | "plan"
activeSession: ReturnType<typeof makeActiveSession>
mode: "act" | "plan";
activeSession: ReturnType<typeof makeActiveSession>;
config: {
providerId: string
modelId: string
apiKey: string | undefined
}
task: ReturnType<typeof makeTask>
providerId: string;
modelId: string;
apiKey: string | undefined;
};
task: ReturnType<typeof makeTask>;
}
function makeActiveSession(input: { isRunning?: boolean } = {}) {
@@ -260,7 +296,7 @@ function makeActiveSession(input: { isRunning?: boolean } = {}) {
unsubscribe: vi.fn(),
startResult: { sessionId: "old-session" },
isRunning: input.isRunning ?? false,
}
};
}
function makeTask(taskId: string, messages: Array<Partial<ClineMessage>> = []) {
@@ -269,5 +305,8 @@ function makeTask(taskId: string, messages: Array<Partial<ClineMessage>> = []) {
messageStateHandler: {
getClineMessages: vi.fn(() => messages as ClineMessage[]),
},
} as unknown as { taskId: string; messageStateHandler: { getClineMessages: () => ClineMessage[] } }
} as unknown as {
taskId: string;
messageStateHandler: { getClineMessages: () => ClineMessage[] };
};
}
+142 -104
View File
@@ -1,198 +1,236 @@
import type { ChatContent } from "@shared/ChatContent"
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { Mode } from "@shared/storage/types"
import type { StateManager } from "@/core/storage/StateManager"
import { Logger } from "@/shared/services/Logger"
import type { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
import type { SdkSessionConfigBuilder } from "./sdk-session-config-builder"
import { isAbortError, type SdkSessionLifecycle } from "./sdk-session-lifecycle"
import type { SdkSessionHost } from "./session-host"
import type { TaskProxy } from "./task-proxy"
import type { VscodeSessionHost } from "./vscode-session-host"
import type { ChatContent } from "@shared/ChatContent";
import type { ClineMessage } from "@shared/ExtensionMessage";
import type { Mode } from "@shared/storage/types";
import type { StateManager } from "@/core/storage/StateManager";
import { Logger } from "@/shared/services/Logger";
import type { SdkInteractionCoordinator } from "./sdk-interaction-coordinator";
import type { SdkMessageCoordinator } from "./sdk-message-coordinator";
import type { SdkSessionConfigBuilder } from "./sdk-session-config-builder";
import {
isAbortError,
type SdkSessionLifecycle,
} from "./sdk-session-lifecycle";
import type { SdkSessionHost } from "./session-host";
import type { TaskProxy } from "./task-proxy";
import type { VscodeSessionHost } from "./vscode-session-host";
type StartInput = Parameters<VscodeSessionHost["start"]>[0]
type InitialMessages = StartInput["initialMessages"]
type SessionConfig = Awaited<ReturnType<SdkSessionConfigBuilder["build"]>>
const ACT_MODE_CONTINUATION_PROMPT = "The user approved switching to act mode. Continue with the approved plan now."
type StartInput = Parameters<VscodeSessionHost["start"]>[0];
type InitialMessages = StartInput["initialMessages"];
type SessionConfig = Awaited<ReturnType<SdkSessionConfigBuilder["build"]>>;
export interface SdkModeCoordinatorOptions {
stateManager: StateManager
sessions: SdkSessionLifecycle
interactions: SdkInteractionCoordinator
messages: SdkMessageCoordinator
sessionConfigBuilder: SdkSessionConfigBuilder
getTask: () => TaskProxy | undefined
getWorkspaceRoot: () => Promise<string>
loadInitialMessages: (sdkHost: SdkSessionHost, sessionId: string) => Promise<unknown[]>
buildStartSessionInput: (config: SessionConfig, input: { cwd: string; mode: Mode }) => StartInput
emitClineAuthError: () => void
resetMessageTranslator: () => void
postStateToWebview: () => Promise<void>
stateManager: StateManager;
sessions: SdkSessionLifecycle;
interactions: SdkInteractionCoordinator;
messages: SdkMessageCoordinator;
sessionConfigBuilder: SdkSessionConfigBuilder;
getTask: () => TaskProxy | undefined;
getWorkspaceRoot: () => Promise<string>;
loadInitialMessages: (
sdkHost: SdkSessionHost,
sessionId: string,
) => Promise<unknown[]>;
buildStartSessionInput: (
config: SessionConfig,
input: { cwd: string; mode: Mode },
) => StartInput;
emitClineAuthError: () => void;
resetMessageTranslator: () => void;
postStateToWebview: () => Promise<void>;
}
export class SdkModeCoordinator {
private pendingModeChange: Mode | null = null
private pendingModeChange: Mode | null = null;
constructor(private readonly options: SdkModeCoordinatorOptions) {}
queueSwitchToActMode(): void {
this.pendingModeChange = "act"
this.pendingModeChange = "act";
}
hasPendingModeChange(): boolean {
return this.pendingModeChange !== null
return this.pendingModeChange !== null;
}
async applyPendingModeChange(): Promise<void> {
const target = this.pendingModeChange
const target = this.pendingModeChange;
if (!target) {
return
return;
}
this.pendingModeChange = null
Logger.log(`[SdkController] applyPendingModeChange: switching to ${target}`)
await this.rebuildSessionForMode(target, { autoContinue: target === "act" })
this.pendingModeChange = null;
Logger.log(
`[SdkController] applyPendingModeChange: switching to ${target}`,
);
// Match CLI interactive behavior: switch_to_act_mode changes the active
// session configuration after the current turn stops, but it does not submit
// a follow-up prompt or continue executing act-mode tools on its own. The
// user must explicitly send the next message in Act mode.
await this.rebuildSessionForMode(target);
}
async toggleActModeForYoloMode(): Promise<boolean> {
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode");
if (currentMode === "act") {
return false
return false;
}
await this.options.stateManager.setGlobalState("mode", "act")
await this.options.postStateToWebview()
return true
await this.options.stateManager.setGlobalState("mode", "act");
await this.options.postStateToWebview();
return true;
}
async togglePlanActMode(modeToSwitchTo: Mode, chatContent?: ChatContent): Promise<boolean> {
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
async togglePlanActMode(
modeToSwitchTo: Mode,
_chatContent?: ChatContent,
): Promise<boolean> {
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode");
if (currentMode === modeToSwitchTo) {
return false
return false;
}
if (this.options.sessions.getActiveSession()) {
// Switching plan -> act on an active session resumes the LLM with a
// continuation prompt. When the caller supplies chatContent.message
// (e.g. from the textarea), use it; otherwise fall back to the canned
// ACT_MODE_CONTINUATION_PROMPT.
const switchingToAct = modeToSwitchTo === "act"
const userPrompt = chatContent?.message?.trim()
await this.rebuildSessionForMode(modeToSwitchTo, {
autoContinue: switchingToAct,
continuationPrompt: switchingToAct ? userPrompt || undefined : undefined,
})
return false
// Match CLI interactive behavior: changing Plan/Act mode updates the
// session configuration and preserves any typed input, but it does not
// submit that input or auto-continue the agent. This prevents the extension
// from entering Act mode and executing tools without an explicit user send.
await this.rebuildSessionForMode(modeToSwitchTo);
return false;
}
this.options.stateManager.setGlobalState("mode", modeToSwitchTo)
await this.options.postStateToWebview()
return false
this.options.stateManager.setGlobalState("mode", modeToSwitchTo);
await this.options.postStateToWebview();
return false;
}
async rebuildSessionForMode(
newMode: Mode,
options: { autoContinue?: boolean; continuationPrompt?: string } = {},
): Promise<void> {
this.options.stateManager.setGlobalState("mode", newMode)
this.options.stateManager.setGlobalState("mode", newMode);
const activeSession = this.options.sessions.getActiveSession()
const activeSession = this.options.sessions.getActiveSession();
if (!activeSession) {
await this.options.postStateToWebview()
return
await this.options.postStateToWebview();
return;
}
const { sdkHost: oldManager, sessionId: oldSessionId } = activeSession
const wasRunning = activeSession.isRunning
const { sdkHost: oldManager, sessionId: oldSessionId } = activeSession;
const wasRunning = activeSession.isRunning;
Logger.log(`[SdkController] Rebuilding session ${oldSessionId} for mode change -> ${newMode} (wasRunning=${wasRunning})`)
Logger.log(
`[SdkController] Rebuilding session ${oldSessionId} for mode change -> ${newMode} (wasRunning=${wasRunning})`,
);
if (wasRunning) {
await this.cancelRunningTurnForModeChange(oldManager, oldSessionId)
await this.cancelRunningTurnForModeChange(oldManager, oldSessionId);
}
try {
const initialMessages = await this.options.loadInitialMessages(oldManager, oldSessionId)
const cwd = await this.options.getWorkspaceRoot()
const config = await this.options.sessionConfigBuilder.build({ cwd, mode: newMode })
const initialMessages = await this.options.loadInitialMessages(
oldManager,
oldSessionId,
);
const cwd = await this.options.getWorkspaceRoot();
const config = await this.options.sessionConfigBuilder.build({
cwd,
mode: newMode,
});
Logger.log(
`[SdkController] Mode rebuild config: mode=${newMode}, provider=${config.providerId}, model=${config.modelId}, hasApiKey=${!!config.apiKey}`,
)
config.sessionId = oldSessionId
);
config.sessionId = oldSessionId;
if (config.providerId === "cline" && !config.apiKey) {
Logger.warn(
`[SdkController] Mode rebuild: new mode '${newMode}' provider is 'cline' but no auth token - emitting auth error`,
)
this.options.emitClineAuthError()
await this.options.postStateToWebview()
return
);
this.options.emitClineAuthError();
await this.options.postStateToWebview();
return;
}
const startInput = this.options.buildStartSessionInput(config, { cwd, mode: newMode })
const startInput = this.options.buildStartSessionInput(config, {
cwd,
mode: newMode,
});
const rebuildResult = await this.options.sessions.replaceActiveSession({
startInput,
initialMessages: initialMessages as InitialMessages,
disposeReason: "modeChange",
})
});
if (!rebuildResult) {
return
return;
}
const { sdkHost, startResult } = rebuildResult
const task = this.options.getTask()
const { sdkHost, startResult } = rebuildResult;
const task = this.options.getTask();
if (task && task.taskId !== startResult.sessionId) {
Logger.warn(
`[SdkController] Mode rebuild returned a new session ID (${startResult.sessionId}); updating task proxy`,
)
task.taskId = startResult.sessionId
);
task.taskId = startResult.sessionId;
}
this.options.resetMessageTranslator()
this.options.resetMessageTranslator();
if (options.autoContinue) {
const prompt = options.continuationPrompt ?? ACT_MODE_CONTINUATION_PROMPT
this.options.sessions.setRunning(true)
this.options.sessions.fireAndForgetSend(sdkHost, startResult.sessionId, prompt)
const prompt =
options.continuationPrompt ?? ACT_MODE_CONTINUATION_PROMPT;
this.options.sessions.setRunning(true);
this.options.sessions.fireAndForgetSend(
sdkHost,
startResult.sessionId,
prompt,
);
}
await this.options.postStateToWebview()
await this.options.postStateToWebview();
Logger.log(`[SdkController] Session rebuilt for mode ${newMode}: ${oldSessionId} -> ${startResult.sessionId}`)
Logger.log(
`[SdkController] Session rebuilt for mode ${newMode}: ${oldSessionId} -> ${startResult.sessionId}`,
);
} catch (error) {
Logger.error("[SdkController] Failed to rebuild session for mode change:", error)
Logger.error(
"[SdkController] Failed to rebuild session for mode change:",
error,
);
const errorMessage: ClineMessage = {
ts: Date.now(),
type: "say",
say: "error",
text: `Failed to switch mode: ${error instanceof Error ? error.message : String(error)}`,
partial: false,
}
};
this.options.messages.appendAndEmit([errorMessage], {
type: "status",
payload: { sessionId: oldSessionId, status: "error" },
})
await this.options.postStateToWebview()
});
await this.options.postStateToWebview();
}
}
private async cancelRunningTurnForModeChange(oldManager: SdkSessionHost, oldSessionId: string): Promise<void> {
this.options.interactions.clearPending("Mode changed")
this.options.messages.cancelPendingSave()
private async cancelRunningTurnForModeChange(
oldManager: SdkSessionHost,
oldSessionId: string,
): Promise<void> {
this.options.interactions.clearPending("Mode changed");
this.options.messages.cancelPendingSave();
try {
await oldManager.abort(oldSessionId)
await oldManager.abort(oldSessionId);
} catch (error) {
if (!isAbortError(error)) {
Logger.error("[SdkController] Failed to abort old session during mode change:", error)
Logger.error(
"[SdkController] Failed to abort old session during mode change:",
error,
);
}
}
this.options.sessions.setRunning(false)
this.options.sessions.setRunning(false);
const task = this.options.getTask()
const task = this.options.getTask();
if (!task?.messageStateHandler) {
return
return;
}
const current = task.messageStateHandler.getClineMessages()
const finalized = this.options.messages.finalizeMessagesForSave(current)
this.options.messages.appendMessages(finalized)
const current = task.messageStateHandler.getClineMessages();
const finalized = this.options.messages.finalizeMessagesForSave(current);
this.options.messages.appendMessages(finalized);
}
}
@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from "vitest";
import { SdkSessionConfigBuilder } from "./sdk-session-config-builder";
const mocks = vi.hoisted(() => ({
buildSessionConfig: vi.fn(),
buildAgentHooks: vi.fn(() => ({})),
}));
vi.mock("./cline-session-factory", () => ({
buildSessionConfig: mocks.buildSessionConfig,
}));
vi.mock("./hooks-adapter", () => ({
buildAgentHooks: mocks.buildAgentHooks,
}));
describe("SdkSessionConfigBuilder", () => {
it("adds the CLI plan-mode switch_to_act_mode tool only in plan mode", async () => {
const stateManager = {
getGlobalSettingsKey: vi.fn(() => "plan"),
};
const onSwitchToActMode = vi.fn();
const builder = new SdkSessionConfigBuilder({
stateManager: stateManager as never,
emitHookMessage: vi.fn(),
onSwitchToActMode,
});
mocks.buildSessionConfig.mockResolvedValueOnce({
extraTools: [],
hooks: {},
});
const planConfig = await builder.build({ cwd: "/workspace", mode: "plan" });
const switchTool = planConfig.extraTools?.find(
(tool) => tool.name === "switch_to_act_mode",
);
expect(switchTool).toBeDefined();
expect(await switchTool?.execute({}, {} as never)).toBe(
"You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)",
);
expect(onSwitchToActMode).toHaveBeenCalledOnce();
mocks.buildSessionConfig.mockResolvedValueOnce({
extraTools: [switchTool],
hooks: {},
});
const actConfig = await builder.build({ cwd: "/workspace", mode: "act" });
expect(
actConfig.extraTools?.some((tool) => tool.name === "switch_to_act_mode"),
).toBe(false);
});
it("stops before the next model call after switch_to_act_mode queues a mode change", async () => {
const baseBeforeModel = vi.fn(async () => ({ metadata: "base" }));
mocks.buildAgentHooks.mockReturnValueOnce({ beforeModel: baseBeforeModel });
mocks.buildSessionConfig.mockResolvedValueOnce({ hooks: {} });
const builder = new SdkSessionConfigBuilder({
stateManager: {} as never,
emitHookMessage: vi.fn(),
onSwitchToActMode: vi.fn(),
shouldStopAfterModeSwitch: () => true,
});
const config = await builder.build({ cwd: "/workspace", mode: "act" });
await expect(config.hooks?.beforeModel?.({} as never)).resolves.toEqual({
metadata: "base",
stop: true,
});
expect(baseBeforeModel).toHaveBeenCalledOnce();
});
});
@@ -1,42 +1,61 @@
import { type AgentTool, createTool } from "@cline/shared"
import type { StateManager } from "@/core/storage/StateManager"
import { buildSessionConfig, type SessionConfigInput } from "./cline-session-factory"
import { buildAgentHooks, type HookMessageEmitter } from "./hooks-adapter"
import { type AgentTool, createTool } from "@cline/shared";
import type { StateManager } from "@/core/storage/StateManager";
import {
buildSessionConfig,
type SessionConfigInput,
} from "./cline-session-factory";
import { buildAgentHooks, type HookMessageEmitter } from "./hooks-adapter";
export interface SdkSessionConfigBuilderOptions {
stateManager: StateManager
emitHookMessage: HookMessageEmitter
onSwitchToActMode: () => void
shouldStopAfterModeSwitch?: () => boolean
stateManager: StateManager;
emitHookMessage: HookMessageEmitter;
onSwitchToActMode: () => void;
shouldStopAfterModeSwitch?: () => boolean;
}
export class SdkSessionConfigBuilder {
constructor(private readonly options: SdkSessionConfigBuilderOptions) {}
async build(input: SessionConfigInput): Promise<Awaited<ReturnType<typeof buildSessionConfig>>> {
const config = await buildSessionConfig(input)
const baseHooks = buildAgentHooks(this.options.stateManager, this.options.emitHookMessage)
async build(
input: SessionConfigInput,
): Promise<Awaited<ReturnType<typeof buildSessionConfig>>> {
const config = await buildSessionConfig(input);
const baseHooks = buildAgentHooks(
this.options.stateManager,
this.options.emitHookMessage,
);
config.hooks = {
...baseHooks,
beforeModel: async (ctx) => {
const baseControl = await baseHooks.beforeModel?.(ctx)
const baseControl = await baseHooks.beforeModel?.(ctx);
if (this.options.shouldStopAfterModeSwitch?.()) {
return {
...baseControl,
stop: true,
}
};
}
return baseControl
return baseControl;
},
}
};
if (input.mode === "plan") {
config.extraTools = [...(config.extraTools ?? [])]
// Match the CLI interactive runtime: plan-mode sessions expose a
// switch_to_act_mode tool in addition to the read-only planning tools.
config.extraTools = [
...(config.extraTools ?? []),
this.createSwitchToActModeTool(),
];
} else {
// The switch tool is plan-only in the CLI and should disappear after
// rebuilding the session in act mode.
config.extraTools = config.extraTools?.filter(
(tool) => tool.name !== "switch_to_act_mode",
);
}
return config
return config;
}
private _createSwitchToActModeTool(): AgentTool {
private createSwitchToActModeTool(): AgentTool {
return createTool({
name: "switch_to_act_mode",
description:
@@ -49,13 +68,14 @@ export class SdkSessionConfigBuilder {
retryable: false,
maxRetries: 0,
execute: async () => {
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
const currentMode =
this.options.stateManager.getGlobalSettingsKey("mode");
if (currentMode === "act") {
return "Already in act mode."
return "Already in act mode.";
}
this.options.onSwitchToActMode()
return "Act mode switch queued. Stop this turn now; the session will restart in act mode before any editing, command, or other act-mode tools are available."
this.options.onSwitchToActMode();
return "You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)";
},
})
});
}
}