mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
make extension plan mode more similar to CLI
- basically, don't auto continue when agent switches to act mode
This commit is contained in:
@@ -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[] };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.)";
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user