Skip to content
18 changes: 16 additions & 2 deletions packages/types/src/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod"
import { RooCodeEventName } from "./events.js"
import { type ClineMessage, type BlockingAsk, type TokenUsage } from "./message.js"
import { type ToolUsage, type ToolName } from "./tool.js"
import { type Experiments } from "./experiment.js"

/**
* TaskProviderLike
Expand All @@ -12,13 +13,26 @@ export interface TaskProviderState {
mode?: string
}

export interface InitTaskOptions {
mode_slug?: string
enableDiff?: boolean
enableCheckpoints?: boolean
fuzzyMatchThreshold?: number
consecutiveMistakeLimit?: number
experiments?: Experiments
}
export interface TaskProviderLike {
readonly cwd: string

getCurrentCline(): TaskLike | undefined
getCurrentTaskStack(): string[]

initClineWithTask(text?: string, images?: string[], parentTask?: TaskLike): Promise<TaskLike>
initClineWithTask(
text?: string,
images?: string[],
parentTask?: TaskLike,
options?: InitTaskOptions,
): Promise<TaskLike>
cancelTask(): Promise<void>
clearTask(): Promise<void>
postStateToWebview(): Promise<void>
Expand Down Expand Up @@ -81,7 +95,7 @@ export interface TaskLike {
off<K extends keyof TaskEvents>(event: K, listener: (...args: TaskEvents[K]) => void | Promise<void>): this

setMessageResponse(text: string, images?: string[]): void
submitUserMessage(text: string, images?: string[]): void
submitUserMessage(text: string, images?: string[], mode_slug?: string): void
}

export type TaskEvents = {
Expand Down
64 changes: 52 additions & 12 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { t } from "../../i18n"
import { ClineApiReqCancelReason, ClineApiReqInfo } from "../../shared/ExtensionMessage"
import { getApiMetrics } from "../../shared/getApiMetrics"
import { ClineAskResponse } from "../../shared/WebviewMessage"
import { defaultModeSlug } from "../../shared/modes"
import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { DiffStrategy } from "../../shared/tools"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { getModelMaxOutputTokens } from "../../shared/api"
Expand Down Expand Up @@ -303,6 +303,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.instanceId = crypto.randomUUID().slice(0, 8)
this.taskNumber = -1

// Initialize workspacePath FIRST before any code that uses this.cwd
// This MUST happen before creating RooIgnoreController or RooProtectedController
const defaultPath = path.join(os.homedir(), "Desktop")
const workspaceFromVSCode = getWorkspacePath(defaultPath)

// Ensure workspacePath is never undefined or empty - use the VSCode workspace or fallback
// Check for both undefined and empty string from parentTask
const parentWorkspace = parentTask?.workspacePath
this.workspacePath = parentWorkspace && parentWorkspace.trim() !== "" ? parentWorkspace : workspaceFromVSCode

// Now create controllers with properly initialized workspacePath
this.rooIgnoreController = new RooIgnoreController(this.cwd)
this.rooProtectedController = new RooProtectedController(this.cwd)
this.fileContextTracker = new FileContextTracker(provider, this.taskId)
Expand Down Expand Up @@ -757,27 +768,52 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.askResponseImages = images
}

public submitUserMessage(text: string, images?: string[]): void {
public submitUserMessage(text: string, images?: string[], mode_slug?: string): void {
try {
const trimmed = (text ?? "").trim()
const imgs = images ?? []

if (!trimmed && imgs.length === 0) {
return
}

const provider = this.providerRef.deref()
if (!provider) {
console.error("[Task#submitUserMessage] Provider reference lost")
return
}

void provider.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
text: trimmed,
images: imgs,
})
// Run asynchronously to allow awaiting mode switch before sending the message
void (async () => {
// If a mode slug is provided, handle the mode switch first (same behavior as initClineWithTask)
try {
const modeSlugValue = (mode_slug ?? "").trim()
if (modeSlugValue.length > 0) {
const customModes = await provider.customModesManager.getCustomModes()
const targetMode = getModeBySlug(modeSlugValue, customModes)
if (targetMode) {
await provider.handleModeSwitch(targetMode.slug)
provider.log(`[Task#submitUserMessage] Applied mode from mode_slug: '${targetMode.slug}'`)
} else {
provider.log(`[Task#submitUserMessage] Ignoring invalid mode_slug: '${modeSlugValue}'.`)
}
}
} catch (err) {
provider.log(
`[Task#submitUserMessage] Failed to apply mode_slug: ${
err instanceof Error ? err.message : String(err)
}`,
)
}

// If there's no content to send, exit after potential mode switch
if (!trimmed && imgs.length === 0) {
return
}

await provider.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
text: trimmed,
images: imgs,
})
})()
} catch (error) {
console.error("[Task#submitUserMessage] Failed to submit user message:", error)
}
Expand Down Expand Up @@ -2518,6 +2554,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Getters

public get cwd() {
if (!this.workspacePath) {
// Return a fallback to prevent crashes
return path.join(os.homedir(), "Desktop")
}
return this.workspacePath
}
}
2 changes: 1 addition & 1 deletion src/core/tools/codebaseSearchTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export async function codebaseSearchTool(
removeClosingTag: RemoveClosingTag,
) {
const toolName = "codebase_search"
const workspacePath = (cline.cwd && cline.cwd.trim() !== '') ? cline.cwd : getWorkspacePath()
const workspacePath = cline.cwd && cline.cwd.trim() !== "" ? cline.cwd : getWorkspacePath()

if (!workspacePath) {
// This case should ideally not happen if Cline is initialized correctly
Expand Down
40 changes: 28 additions & 12 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type TerminalActionId,
type TerminalActionPromptType,
type HistoryItem,
type InitTaskOptions,
RooCodeEventName,
requestyDefaultModelId,
openRouterDefaultModelId,
Expand Down Expand Up @@ -75,7 +76,7 @@ import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../api/provi
import { ContextProxy } from "../config/ContextProxy"
import { ProviderSettingsManager } from "../config/ProviderSettingsManager"
import { CustomModesManager } from "../config/CustomModesManager"
import { Task, TaskOptions } from "../task/Task"
import { Task } from "../task/Task"
import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt"

import { webviewMessageHandler } from "./webviewMessageHandler"
Expand Down Expand Up @@ -691,17 +692,7 @@ export class ClineProvider
// from the stack and the caller is resumed in this way we can have a chain
// of tasks, each one being a sub task of the previous one until the main
// task is finished.
public async initClineWithTask(
text?: string,
images?: string[],
parentTask?: Task,
options: Partial<
Pick<
TaskOptions,
"enableDiff" | "enableCheckpoints" | "fuzzyMatchThreshold" | "consecutiveMistakeLimit" | "experiments"
>
> = {},
) {
public async initClineWithTask(text?: string, images?: string[], parentTask?: Task, options: InitTaskOptions = {}) {
const {
apiConfiguration,
organizationAllowList,
Expand All @@ -717,6 +708,31 @@ export class ClineProvider
throw new OrganizationAllowListViolationError(t("common:errors.violated_organization_allowlist"))
}

// Bridge: If a mode_slug is provided by the cloud extension bridge, honor it before creating the task
// This ensures the task initializes with the correct mode and associated provider profile
try {
const modeSlugFromBridge: string | undefined = (options as any)?.mode_slug
if (typeof modeSlugFromBridge === "string" && modeSlugFromBridge.trim().length > 0) {
const customModes = await this.customModesManager.getCustomModes()
const targetMode = getModeBySlug(modeSlugFromBridge, customModes)
if (targetMode) {
// Switch provider/global mode first so Task reads it during initialization
await this.handleModeSwitch(targetMode.slug)
this.log(`[initClineWithTask] Applied mode from bridge: '${targetMode.slug}'`)
} else {
this.log(
`[initClineWithTask] Ignoring invalid mode_slug from bridge: '${modeSlugFromBridge}'. Falling back to current mode.`,
)
}
}
} catch (err) {
this.log(
`[initClineWithTask] Failed to apply mode_slug from bridge: ${
err instanceof Error ? err.message : String(err)
}`,
)
}

const task = new Task({
provider: this,
apiConfiguration,
Expand Down
78 changes: 78 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2208,6 +2208,84 @@ describe("ClineProvider", () => {
})
})
})
describe("Bridge mode_slug handling", () => {
let provider: ClineProvider
let mockContext: vscode.ExtensionContext
let mockOutputChannel: vscode.OutputChannel
let mockWebviewView: vscode.WebviewView

beforeEach(() => {
vi.clearAllMocks()

mockContext = {
extensionPath: "/test/path",
extensionUri: {} as vscode.Uri,
globalState: {
get: vi.fn(),
update: vi.fn(),
keys: vi.fn().mockReturnValue([]),
},
secrets: {
get: vi.fn(),
store: vi.fn(),
delete: vi.fn(),
},
subscriptions: [],
extension: {
packageJSON: { version: "1.0.0" },
},
globalStorageUri: {
fsPath: "/test/storage/path",
},
} as unknown as vscode.ExtensionContext

mockOutputChannel = {
appendLine: vi.fn(),
clear: vi.fn(),
dispose: vi.fn(),
} as unknown as vscode.OutputChannel

mockWebviewView = {
webview: {
postMessage: vi.fn(),
html: "",
options: {},
onDidReceiveMessage: vi.fn(),
asWebviewUri: vi.fn(),
cspSource: "vscode-webview://test-csp-source",
},
visible: true,
onDidDispose: vi.fn(),
onDidChangeVisibility: vi.fn(),
} as unknown as vscode.WebviewView

provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
})

it("applies mode_slug from bridge options when starting task", async () => {
await provider.resolveWebviewView(mockWebviewView)

// Spy on handleModeSwitch to ensure it's invoked with the bridge-provided mode
const handleModeSwitchSpy = vi.spyOn(provider, "handleModeSwitch").mockResolvedValue(undefined as any)

// Ensure getModeBySlug returns a valid mode for the provided slug
const { getModeBySlug } = await import("../../../shared/modes")
vi.mocked(getModeBySlug).mockReturnValueOnce({
slug: "architect",
name: "Architect Mode",
roleDefinition: "You are an architect",
groups: ["read", "edit"] as any,
} as any)

// Pass mode_slug through the options object (as provided by the bridge package)
await provider.initClineWithTask("Started from bridge", undefined, undefined, {
experiments: {},
mode_slug: "architect",
} as any)

expect(handleModeSwitchSpy).toHaveBeenCalledWith("architect")
})
})

describe("Project MCP Settings", () => {
let provider: ClineProvider
Expand Down
3 changes: 3 additions & 0 deletions src/extension/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,13 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
text,
images,
newTab,
mode_slug,
}: {
configuration: RooCodeSettings
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't RooCodeSettings allow you to set the mode? Or am I misremembering?

text?: string
images?: string[]
newTab?: boolean
mode_slug?: string
}) {
let provider: ClineProvider

Expand Down Expand Up @@ -150,6 +152,7 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {

const cline = await provider.initClineWithTask(text, images, undefined, {
consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER,
mode_slug,
})

if (!cline) {
Expand Down