fix(vscode): keep the session's mode in sync when Plan/Act is toggled during a session start (#15026)

* test(vscode): cover Plan/Act toggles that race a session start

A toggle that lands while a session is being started or replaced sees no
active session, flips only the setting, and leaves the session running with
the other mode's tools. These tests reproduce that and fail on main.

* fix(vscode): keep the session's mode in sync when Plan/Act is toggled during a session start

Starting or replacing a session detaches the outgoing one before the new one
is active. A toggle in that window saw no session, flipped only the setting,
and the session that finished starting kept the other mode's tools, so the UI
showed Act while every write was blocked as plan mode.

- Sessions record the mode they were built for.
- The toggle decides under the rebuild lock, after waiting for session starts
  that run outside it (new task, edited message, checkpoint restore).
- The toggle and the rebuild compare against the session's mode rather than
  the setting, which also fixes the stale mode notice the late rebuild left.

* test(vscode): release session-start waiters when the start fails

Cover a waiter that is already in place when a tracked start rejects. The
previous check only waited after the rejection, so an implementation that
rejected existing waiters would still have passed.
This commit is contained in:
Mikołaj Kondratek
2026-10-19 12:43:41 +02:00
committed by GitHub
parent 0e4b58c1c9
commit 9857d19fc4
9 changed files with 436 additions and 85 deletions
+74 -5
View File
@@ -562,9 +562,11 @@ describe("hasWorkspaceCheckpointForMessage", () => {
const readLiveMessages = vi.fn().mockResolvedValue(sdkMessages)
const readMessages = vi.fn().mockResolvedValue(sdkMessages.slice(0, 2))
const restore = vi.fn().mockRejectedValue(new Error("stop at restore"))
const controller = {
// The prototype supplies the private implementation the public method delegates to.
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } },
sessions: {
trackSessionStart: (operation: () => Promise<unknown>) => operation(),
getActiveSession: () => ({
sessionId: "task-a",
isRunning: false,
@@ -584,7 +586,7 @@ describe("hasWorkspaceCheckpointForMessage", () => {
stateManager: { getGlobalSettingsKey: () => "act" },
sessionConfigBuilder: { build: async () => ({ providerId: "anthropic", apiKey: "key", modelId: "model" }) },
resolveContextMentions: async (text: string) => text,
}
})
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toBe(true)
await expect(
@@ -598,6 +600,68 @@ describe("hasWorkspaceCheckpointForMessage", () => {
expect(readMessages).not.toHaveBeenCalled()
})
it("runs message edits and checkpoint restores inside a tracked session start", async () => {
// Both read the mode setting and swap the session outside the rebuild
// lock, so a mode toggle must be able to wait for them to finish.
let tracking = false
const trackedRestores: boolean[] = []
const restore = vi.fn(async () => {
trackedRestores.push(tracking)
throw new Error("stop at restore")
})
const restoreActiveSession = vi.fn(async () => {
trackedRestores.push(tracking)
return {}
})
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } },
sessions: {
trackSessionStart: async (operation: () => Promise<unknown>) => {
tracking = true
try {
return await operation()
} finally {
tracking = false
}
},
restoreActiveSession,
getActiveSession: () => ({
sessionId: "task-a",
isRunning: false,
sdkHost: {
get: async () => ({
cwd: "C:/work",
metadata: { checkpoint: { history: [{ ref: "checkpoint-b", createdAt: 1, runCount: 2 }] } },
}),
readLiveMessages: vi.fn().mockResolvedValue(sdkMessages),
readMessages: vi.fn().mockResolvedValue(sdkMessages),
restore,
},
}),
},
taskHistory: { findHistoryItem: async () => undefined },
getWorkspaceRoot: async () => "C:/work",
stateManager: { getGlobalSettingsKey: () => "act" },
sessionConfigBuilder: { build: async () => ({ providerId: "anthropic", apiKey: "key", modelId: "model" }) },
resolveContextMentions: async (text: string) => text,
postStateToWebview: async () => undefined,
})
await expect(
SdkController.prototype.editMessageAndRegenerate.call(controller as never, {
messageTs: 3,
text: "continue, edited",
restoreWorkspace: true,
}),
).rejects.toThrow("stop at restore")
await SdkController.prototype.restoreCheckpoint.call(controller as never, {
checkpointRunCount: 2,
restoreType: "workspace",
})
expect(trackedRestores).toEqual([true, true])
})
it("uses and disposes a temporary host for a history task", async () => {
const tempHost = {
get: vi.fn().mockResolvedValue({
@@ -799,11 +863,16 @@ describe("cloud tasks stay in the cloud", () => {
it("refuses to rebuild a cloud task's conversation as a local session", async () => {
const startNewSession = vi.fn()
const controller = {
// The prototype supplies the private implementation the public method delegates to.
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "ses-cloud", messageStateHandler: { getClineMessages: () => [] } },
sessions: { getActiveSession: () => undefined, startNewSession },
sessions: {
getActiveSession: () => undefined,
startNewSession,
trackSessionStart: (operation: () => Promise<unknown>) => operation(),
},
stateManager,
}
})
await expect(
SdkController.prototype.editMessageAndRegenerate.call(controller as never, { messageTs: 1, text: "edited" }),
+21
View File
@@ -1764,6 +1764,18 @@ export class Controller {
images?: string[]
files?: string[]
restoreWorkspace?: boolean
}): Promise<void> {
// Reads the mode setting and replaces the session outside the rebuild
// lock; tracking it makes a concurrent mode toggle wait for the result.
await this.sessions.trackSessionStart(() => this.performEditMessageAndRegenerate(input))
}
private async performEditMessageAndRegenerate(input: {
messageTs: number
text: string
images?: string[]
files?: string[]
restoreWorkspace?: boolean
}): Promise<void> {
const editedText = input.text.trim()
if (!editedText && (input.images?.length ?? 0) === 0 && (input.files?.length ?? 0) === 0) {
@@ -1918,6 +1930,15 @@ export class Controller {
}
async restoreCheckpoint(input: { checkpointRunCount: number; restoreType: ClineCheckpointRestore }): Promise<void> {
// Reads the mode setting and swaps the session outside the rebuild lock;
// tracking it makes a concurrent mode toggle wait for the result.
await this.sessions.trackSessionStart(() => this.performRestoreCheckpoint(input))
}
private async performRestoreCheckpoint(input: {
checkpointRunCount: number
restoreType: ClineCheckpointRestore
}): Promise<void> {
const restoreMessages = input.restoreType === "task" || input.restoreType === "taskAndWorkspace"
const restoreWorkspace = input.restoreType === "workspace" || input.restoreType === "taskAndWorkspace"
const checkpointRunCount = Number(input.checkpointRunCount)
@@ -90,6 +90,12 @@ export interface ActiveSession {
sessionId: string
/** The config used to start the active session. */
startConfig?: Pick<CoreSessionConfig, "providerId" | "modelId">
/**
* The mode the session's tools were built for. It can differ from the
* persisted mode setting while a start or rebuild is in flight, so mode
* switches compare against this rather than the setting.
*/
mode?: Mode
/** The runtime host instance managing this session (VscodeSessionHost) */
sdkHost: SdkSessionHost
/** Unsubscribe function for session events */
@@ -2,6 +2,7 @@ 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 { SdkSessionRebuildScheduler } from "./sdk-session-rebuild-scheduler"
vi.mock("@/shared/services/Logger", () => ({
Logger: {
@@ -21,6 +22,102 @@ describe("SdkModeCoordinator", () => {
vi.clearAllMocks()
})
describe("toggles racing a session start", () => {
it("applies a toggle made while an earlier switch is still replacing the session", async () => {
const { coordinator, options, state } = makeCoordinator({
activeSession: makeActiveSession({ mode: "act" }),
task: makeTask("old-session"),
mode: "act",
serializeRebuilds: true,
})
let current: ActiveSessionFixture | undefined = makeActiveSession({ mode: "act" })
options.sessions.getActiveSession.mockImplementation(() => current)
let finishPlanStart!: () => void
const planStart = new Promise<void>((resolve) => {
finishPlanStart = resolve
})
options.sessions.replaceActiveSession
.mockImplementationOnce(async () => {
// The outgoing session is detached before its replacement starts,
// so no session is active until the plan session is up.
current = undefined
await planStart
current = makeActiveSession({ mode: "plan" })
return { startResult: { sessionId: "old-session" }, sdkHost: current.sdkHost }
})
.mockImplementationOnce(async () => {
current = makeActiveSession({ mode: "act" })
return { startResult: { sessionId: "old-session" }, sdkHost: current.sdkHost }
})
const toPlan = coordinator.togglePlanActMode("plan")
await vi.waitFor(() => expect(options.sessions.replaceActiveSession).toHaveBeenCalledOnce())
const toAct = coordinator.togglePlanActMode("act")
finishPlanStart()
await Promise.all([toPlan, toAct])
expect(state.mode).toBe("act")
expect(options.buildStartSessionInput.mock.calls.map(([, startInput]) => startInput.mode)).toEqual(["plan", "act"])
expect(current?.mode).toBe("act")
// act -> plan -> act before any message was sent cancels out.
expect(coordinator.consumeModeSwitchNotice("old-session")).toBeNull()
})
it("applies a toggle made while a new task's session is still starting", async () => {
const { coordinator, options, state } = makeCoordinator({ mode: "plan", task: makeTask("new-task") })
const planSession = makeActiveSession({ mode: "plan", isRunning: true })
let current: ActiveSessionFixture | undefined
options.sessions.getActiveSession.mockImplementation(() => current)
options.sessions.waitForSessionStarts.mockImplementationOnce(async () => {
// The task's plan session finishes starting and its first turn runs.
current = planSession
})
await coordinator.togglePlanActMode("act")
expect(state.mode).toBe("act")
expect(planSession.sdkHost.abort).toHaveBeenCalledWith("old-session")
expect(options.sessions.replaceActiveSession).toHaveBeenCalledWith(
expect.objectContaining({ expectedSession: planSession }),
)
expect(options.buildStartSessionInput).toHaveBeenCalledWith(expect.anything(), { cwd: "/workspace", mode: "act" })
expect(coordinator.consumeModeSwitchNotice("new-session")).toEqual({ from: "plan", to: "act" })
})
it("rebuilds when the setting already matches but the session was built for the other mode", async () => {
const planSession = makeActiveSession({ mode: "plan" })
const { coordinator, options, state } = makeCoordinator({
activeSession: planSession,
task: makeTask("old-session"),
mode: "act",
})
await coordinator.togglePlanActMode("act")
expect(state.mode).toBe("act")
expect(options.sessions.replaceActiveSession).toHaveBeenCalledWith(
expect.objectContaining({ expectedSession: planSession }),
)
expect(options.buildStartSessionInput).toHaveBeenCalledWith(expect.anything(), { cwd: "/workspace", mode: "act" })
expect(coordinator.consumeModeSwitchNotice("new-session")).toEqual({ from: "plan", to: "act" })
})
it("does not rebuild or announce a switch to the mode the session already runs in", async () => {
const { coordinator, options, state } = makeCoordinator({
activeSession: makeActiveSession({ mode: "plan" }),
task: makeTask("old-session"),
mode: "act",
})
await expect(coordinator.togglePlanActMode("plan")).resolves.toBe(false)
expect(state.mode).toBe("plan")
expect(options.sessions.replaceActiveSession).not.toHaveBeenCalled()
expect(options.postStateToWebview).toHaveBeenCalledOnce()
expect(coordinator.consumeModeSwitchNotice("old-session")).toBeNull()
})
})
it("preserves pending input by returning false when toggling mode without an active session", async () => {
const { coordinator, state, options } = makeCoordinator({ mode: "act" })
@@ -798,6 +895,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
}),
fireAndForgetSend: vi.fn(),
setRunning: vi.fn(),
waitForSessionStarts: vi.fn().mockResolvedValue(undefined),
},
interactions: {
clearPending: vi.fn(),
@@ -836,6 +934,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
fireAndForgetSend: ReturnType<typeof vi.fn>
replaceActiveSession: ReturnType<typeof vi.fn>
setRunning: ReturnType<typeof vi.fn>
waitForSessionStarts: ReturnType<typeof vi.fn>
}
interactions: SdkModeCoordinatorOptions["interactions"] & {
clearPending: ReturnType<typeof vi.fn>
@@ -863,6 +962,10 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
onAutoContinueFailed: ReturnType<typeof vi.fn>
}
if (input.serializeRebuilds) {
options.rebuilds = new SdkSessionRebuildScheduler({ sessions: options.sessions })
}
return {
coordinator: new SdkModeCoordinator(options),
options,
@@ -880,11 +983,16 @@ interface MakeCoordinatorInput {
}
task: ReturnType<typeof makeTask>
turnPhase: string
/** Use the real rebuild lock instead of a pass-through. */
serializeRebuilds: boolean
}
function makeActiveSession(input: { isRunning?: boolean } = {}) {
type ActiveSessionFixture = ReturnType<typeof makeActiveSession>
function makeActiveSession(input: { isRunning?: boolean; mode?: "act" | "plan" } = {}) {
return {
sessionId: "old-session",
mode: input.mode,
sdkHost: {
abort: vi.fn().mockResolvedValue(undefined),
send: vi.fn(),
@@ -894,6 +1002,7 @@ function makeActiveSession(input: { isRunning?: boolean } = {}) {
unsubscribe: vi.fn(),
startResult: { sessionId: "old-session" },
isRunning: input.isRunning ?? false,
queuedPromptCount: 0,
}
}
+27 -7
View File
@@ -111,17 +111,29 @@ export class SdkModeCoordinator {
}
async togglePlanActMode(modeToSwitchTo: Mode, chatContent?: ChatContent): Promise<boolean> {
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
if (currentMode === modeToSwitchTo) {
return false
}
// Decide under the rebuild lock, after any session start in flight has
// settled. Starts and rebuilds detach the outgoing session before the
// replacement is active, so a toggle that looked during that window
// would see no session, flip only the setting, and leave the session
// that finishes starting on the other mode's tools.
return this.options.rebuilds.runExclusive(async () => {
await this.options.sessions.waitForSessionStarts()
return this.performTogglePlanActMode(modeToSwitchTo, chatContent)
})
}
private async performTogglePlanActMode(modeToSwitchTo: Mode, chatContent?: ChatContent): Promise<boolean> {
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
const activeSession = this.options.sessions.getActiveSession()
// Cloud sessions are Act-only; the webview disables the toggle, this
// guards the keyboard shortcut and any other caller.
if (activeSession?.sdkHost && "isCloud" in activeSession.sdkHost) {
return false
}
if (currentMode === modeToSwitchTo && (activeSession?.mode ?? modeToSwitchTo) === modeToSwitchTo) {
return false
}
if (activeSession) {
// awaiting_followup is also used for non-plan turns, so it is not
// sufficient evidence that the user has a plan to approve. Require the
@@ -150,7 +162,7 @@ export class SdkModeCoordinator {
const userImages = chatContent?.images?.length ? chatContent.images : undefined
const userFiles = chatContent?.files?.length ? chatContent.files : undefined
const hasUserContent = !!(userPrompt || userImages || userFiles)
const continuationSent = await this.rebuildSessionForMode(modeToSwitchTo, {
const continuationSent = await this.performRebuildSessionForMode(modeToSwitchTo, {
autoContinue,
userContinuationPrompt: autoContinue ? userPrompt : undefined,
userImages: autoContinue ? userImages : undefined,
@@ -191,14 +203,22 @@ export class SdkModeCoordinator {
userFiles?: string[]
},
): Promise<boolean> {
const previousMode = this.options.stateManager.getGlobalSettingsKey("mode")
const activeSession = this.options.sessions.getActiveSession()
// The mode the model is actually running under. Rolling back to it keeps
// the toggle coherent with the session's tools, and it is the "from" side
// of the mode notice.
const previousMode = activeSession?.mode ?? this.options.stateManager.getGlobalSettingsKey("mode")
this.options.stateManager.setGlobalState("mode", newMode)
const activeSession = this.options.sessions.getActiveSession()
if (!activeSession) {
await this.options.postStateToWebview()
return false
}
if (activeSession.mode === newMode) {
// Only the setting had drifted; the session already has this mode's tools.
await this.options.postStateToWebview()
return false
}
const { sdkHost: oldManager, sessionId: oldSessionId } = activeSession
const wasRunning = activeSession.isRunning
@@ -53,6 +53,56 @@ describe("SdkSessionLifecycle", () => {
expect(lifecycle.getActiveSession()).toBeUndefined()
})
it("records the mode the active session was built for", async () => {
const sdkHost = makeSdkHost({ startResult: { sessionId: "session-123" } })
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
const lifecycle = makeLifecycle()
await lifecycle.startNewSession({
config: { providerId: "anthropic", modelId: "claude-sonnet-4", mode: "plan" },
} as StartInput)
expect(lifecycle.getActiveSession()?.mode).toBe("plan")
})
it("waits for tracked session starts, including ones that fail", async () => {
const lifecycle = makeLifecycle()
let finishStart!: () => void
const start = lifecycle.trackSessionStart(
() =>
new Promise<void>((resolve) => {
finishStart = resolve
}),
)
let waited = false
const waiting = lifecycle.waitForSessionStarts().then(() => {
waited = true
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(waited).toBe(false)
finishStart()
await start
await waiting
expect(waited).toBe(true)
// A waiter already in place when a start fails is released, not rejected:
// a mode toggle waiting on a failed new-task start must still go ahead.
let failStart!: (error: Error) => void
const failing = lifecycle.trackSessionStart(
() =>
new Promise<void>((_resolve, reject) => {
failStart = reject
}),
)
const waitingOnFailure = lifecycle.waitForSessionStarts()
failStart(new Error("start failed"))
await expect(failing).rejects.toThrow("start failed")
await expect(waitingOnFailure).resolves.toBeUndefined()
await expect(lifecycle.waitForSessionStarts()).resolves.toBeUndefined()
})
it("stores the provider and model config used to start the active session", async () => {
const sdkHost = makeSdkHost({ startResult: { sessionId: "session-123" } })
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
@@ -7,6 +7,7 @@ import type {
StartSessionResult,
} from "@cline/core"
import { formatModeSwitchNotice, type ModeSwitchNotice } from "@cline/shared"
import type { Mode } from "@shared/storage/types"
import { StateManager } from "@/core/storage/StateManager"
import type { VscodeTerminalManager } from "@/hosts/vscode/terminal/VscodeTerminalManager"
import { McpHub } from "@/services/mcp/McpHub"
@@ -57,6 +58,10 @@ export interface SdkSessionLifecycleOptions {
onDidBecomeIdle?: () => void
}
function toSessionMode(mode: string | undefined): Mode | undefined {
return mode === "plan" || mode === "act" ? mode : undefined
}
export class SdkSessionLifecycle {
private activeSession: ActiveSession | undefined
private sharedHost: SdkSessionHost | undefined
@@ -77,9 +82,41 @@ export class SdkSessionLifecycle {
* session idle, or a passive rebuild could replace it mid-turn.
*/
private turnGeneration = 0
/**
* Session starts that read the mode setting outside the rebuild lock (new
* task, edited message, checkpoint restore). Until one settles, the session
* it is building is not active yet, so a mode toggle waits for it instead
* of mistaking "no active session" for "nothing to rebuild".
*/
private readonly pendingStarts = new Set<Promise<void>>()
constructor(private readonly options: SdkSessionLifecycleOptions) {}
/**
* Runs a session start, from reading the mode setting until the new
* session is active and its first turn is sent, as one tracked unit.
*/
async trackSessionStart<T>(operation: () => Promise<T>): Promise<T> {
const run = operation()
const settled = run.then(
() => undefined,
() => undefined,
)
this.pendingStarts.add(settled)
try {
return await run
} finally {
this.pendingStarts.delete(settled)
}
}
/** Resolves once no tracked session start is in flight. */
async waitForSessionStarts(): Promise<void> {
while (this.pendingStarts.size > 0) {
await Promise.all(this.pendingStarts)
}
}
getActiveSession(): ActiveSession | undefined {
return this.activeSession
}
@@ -214,6 +251,7 @@ export class SdkSessionLifecycle {
modelId: startInput.config.modelId,
}
: undefined,
mode: toSessionMode(startInput.config?.mode),
sdkHost,
unsubscribe: hostOverride ? this.subscribeSessionHost(hostOverride) : () => {},
startResult,
@@ -322,6 +360,7 @@ export class SdkSessionLifecycle {
modelId: input.start.config.modelId,
}
: activeSession.startConfig,
mode: toSessionMode(input.start?.config?.mode) ?? activeSession.mode,
startResult: restored.startResult,
isRunning: false,
queuedPromptCount: 0,
@@ -23,6 +23,37 @@ describe("SdkTaskStartCoordinator", () => {
vi.mocked(isDirectory).mockResolvedValue(false)
})
it("starts the session and sends the first prompt inside one tracked session start", async () => {
const { coordinator, options } = makeCoordinator()
let tracking = false
options.sessions.trackSessionStart.mockImplementation(async (operation: () => Promise<unknown>) => {
tracking = true
try {
return await operation()
} finally {
tracking = false
}
})
const trackedCalls: string[] = []
options.sessionConfigBuilder.build.mockImplementation(async () => {
trackedCalls.push(`build:${tracking}`)
return { providerId: "anthropic", modelId: "model", apiKey: "key" }
})
options.sessions.startNewSession.mockImplementation((startInput?: { config?: { sessionId?: string } }) => {
trackedCalls.push(`start:${tracking}`)
return { startResult: { sessionId: startInput?.config?.sessionId ?? "session-123" }, sdkHost: { send: vi.fn() } }
})
options.sessions.fireAndForgetSend.mockImplementation(() => {
trackedCalls.push(`send:${tracking}`)
})
await coordinator.initTask("hello")
// The mode is read when the config is built, so a toggle has to wait from
// there until the session is active and its first turn is on its way.
expect(trackedCalls).toEqual(["build:true", "start:true", "send:true"])
})
it("initializes a new task, emits the task message, and sends the resolved prompt", async () => {
const { coordinator, options, state } = makeCoordinator()
@@ -269,6 +300,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
sdkHost,
})),
fireAndForgetSend: vi.fn(),
trackSessionStart: vi.fn((operation: () => Promise<unknown>) => operation()),
},
messages: {
appendAndEmit: vi.fn(),
@@ -315,6 +347,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
sessions: SdkTaskStartCoordinatorOptions["sessions"] & {
startNewSession: ReturnType<typeof vi.fn>
fireAndForgetSend: ReturnType<typeof vi.fn>
trackSessionStart: ReturnType<typeof vi.fn>
}
messages: SdkTaskStartCoordinatorOptions["messages"] & {
appendAndEmit: ReturnType<typeof vi.fn>
@@ -75,88 +75,92 @@ export class SdkTaskStartCoordinator {
try {
await this.options.clearTask()
const cwd = await this.options.getWorkspaceRoot()
const mode = this.getCurrentMode()
Logger.log(`[SdkController] Building session config: mode=${mode}, cwd=${cwd}`)
const config = await this.options.sessionConfigBuilder.build({
prompt,
images,
files,
historyItem,
taskSettings,
cwd,
mode,
})
providerId = config.providerId
modelId = config.modelId
// Tracked from the mode read until the first prompt is sent, so a mode
// toggle in between waits for this session instead of missing it.
return await this.options.sessions.trackSessionStart(async () => {
const cwd = await this.options.getWorkspaceRoot()
const mode = this.getCurrentMode()
Logger.log(`[SdkController] Building session config: mode=${mode}, cwd=${cwd}`)
const config = await this.options.sessionConfigBuilder.build({
prompt,
images,
files,
historyItem,
taskSettings,
cwd,
mode,
})
providerId = config.providerId
modelId = config.modelId
Logger.log(
`[SdkController] Session config: provider=${config.providerId}, model=${config.modelId}, hasApiKey=${!!config.apiKey}`,
)
if (usesClineAccountAuth(config.providerId) && !config.apiKey) {
Logger.warn(
`[SdkController] ${config.providerId} provider selected but no Cline auth token — emitting auth error`,
Logger.log(
`[SdkController] Session config: provider=${config.providerId}, model=${config.modelId}, hasApiKey=${!!config.apiKey}`,
)
// No task/session id exists yet, so this preflight auth UI path is
// intentionally not recorded as task-joinable provider error telemetry.
this.options.emitClineAuthError(prompt)
return undefined
}
taskSessionId = config.sessionId?.trim() || createSessionId()
const configWithSessionId = {
...config,
sessionId: taskSessionId,
}
if (usesClineAccountAuth(config.providerId) && !config.apiKey) {
Logger.warn(
`[SdkController] ${config.providerId} provider selected but no Cline auth token — emitting auth error`,
)
// No task/session id exists yet, so this preflight auth UI path is
// intentionally not recorded as task-joinable provider error telemetry.
this.options.emitClineAuthError(prompt)
return undefined
}
const startInput = this.options.buildStartSessionInput(configWithSessionId, {
prompt: prompt,
images,
files,
historyItem,
taskSettings,
cwd,
mode,
})
taskSessionId = config.sessionId?.trim() || createSessionId()
const configWithSessionId = {
...config,
sessionId: taskSessionId,
}
const task = this.createAndSetTask(taskSessionId)
this.emitInitialTaskMessage(taskSessionId, prompt ?? "", images, files)
const startInput = this.options.buildStartSessionInput(configWithSessionId, {
prompt: prompt,
images,
files,
historyItem,
taskSettings,
cwd,
mode,
})
// The turn phase was already set to "streaming" (in SdkController.initTask), but the
// webview only learns the phase through a full state post. Ship one now, in parallel
// with the potentially slow session startup below, so the chat shows the thinking
// indicator as soon as the task message lands instead of after startNewSession settles.
this.options.postStateToWebview().catch((error) => {
Logger.error("[SdkController] Failed to post state after emitting initial task message:", error)
})
const task = this.createAndSetTask(taskSessionId)
this.emitInitialTaskMessage(taskSessionId, prompt ?? "", images, files)
const { startResult, sdkHost } = await this.options.sessions.startNewSession(startInput)
if (startResult.sessionId !== taskSessionId) {
Logger.warn(
`[SdkController] SDK returned session id ${startResult.sessionId} after requested id ${taskSessionId}`,
// The turn phase was already set to "streaming" (in SdkController.initTask), but the
// webview only learns the phase through a full state post. Ship one now, in parallel
// with the potentially slow session startup below, so the chat shows the thinking
// indicator as soon as the task message lands instead of after startNewSession settles.
this.options.postStateToWebview().catch((error) => {
Logger.error("[SdkController] Failed to post state after emitting initial task message:", error)
})
const { startResult, sdkHost } = await this.options.sessions.startNewSession(startInput)
if (startResult.sessionId !== taskSessionId) {
Logger.warn(
`[SdkController] SDK returned session id ${startResult.sessionId} after requested id ${taskSessionId}`,
)
task.taskId = startResult.sessionId
taskSessionId = startResult.sessionId
}
const newHistoryItem = this.options.createHistoryItemFromSession(
taskSessionId,
prompt ?? "",
configWithSessionId.modelId,
cwd,
)
task.taskId = startResult.sessionId
taskSessionId = startResult.sessionId
}
await this.options.taskHistory.updateTaskHistoryItem(newHistoryItem)
await this.options.postStateToWebview()
const newHistoryItem = this.options.createHistoryItemFromSession(
taskSessionId,
prompt ?? "",
configWithSessionId.modelId,
cwd,
)
await this.options.taskHistory.updateTaskHistoryItem(newHistoryItem)
await this.options.postStateToWebview()
if (prompt?.trim() || images?.length || files?.length) {
Logger.log(`[SdkController] Sending prompt to session: ${taskSessionId}`)
const resolvedTask = await this.options.resolveContextMentions(prompt || "")
this.options.sessions.fireAndForgetSend(sdkHost, taskSessionId, resolvedTask, images, files)
}
if (prompt?.trim() || images?.length || files?.length) {
Logger.log(`[SdkController] Sending prompt to session: ${taskSessionId}`)
const resolvedTask = await this.options.resolveContextMentions(prompt || "")
this.options.sessions.fireAndForgetSend(sdkHost, taskSessionId, resolvedTask, images, files)
}
Logger.log(`[SdkController] Task initialized: ${taskSessionId}`)
return taskSessionId
Logger.log(`[SdkController] Task initialized: ${taskSessionId}`)
return taskSessionId
})
} catch (error) {
this.options.captureProviderApiError?.({
sessionId: taskSessionId,