Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
didAlreadyUseTool: false,
consecutiveMistakeCount: 0,
clineMessages: [],
getTaskMode: vi.fn().mockResolvedValue("code"),
api: {
getModel: () => ({ id: "test-model", info: {} }),
},
Expand Down Expand Up @@ -120,6 +121,37 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
// Should record as "custom_tool", not "my_custom_tool"
expect(mockTask.recordToolUsage).toHaveBeenCalledWith("custom_tool")
})

it("passes the task-local mode to custom tool execution", async () => {
mockTask.getTaskMode.mockResolvedValue("code")
mockTask.providerRef.deref = () => ({
getState: vi.fn().mockResolvedValue({
mode: "orchestrator",
customModes: [],
experiments: { customTools: true },
}),
})
mockTask.assistantMessageContent = [
{
type: "tool_use",
id: "tool_call_task_mode",
name: "my_custom_tool",
params: {},
partial: false,
},
]
const execute = vi.fn().mockResolvedValue("Custom tool result")
vi.mocked(customToolRegistry.has).mockReturnValue(true)
vi.mocked(customToolRegistry.get).mockReturnValue({
name: "my_custom_tool",
description: "A custom tool",
execute,
})

await presentAssistantMessage(mockTask)

expect(execute).toHaveBeenCalledWith(undefined, { mode: "code", task: mockTask })
})
})

describe("Custom tool error recording", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () =
didRejectTool: false,
didAlreadyUseTool: false,
consecutiveMistakeCount: 0,
getTaskMode: vi.fn().mockResolvedValue("code"),
api: {
getModel: () => ({ id: "test-model", info: {} }),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts

import type { Anthropic } from "@anthropic-ai/sdk"
import { describe, it, expect, beforeEach, vi } from "vitest"
import { describe, it, expect, beforeEach, vi, type Mock } from "vitest"
import { presentAssistantMessage } from "../presentAssistantMessage"
import { validateToolUse } from "../../tools/validateToolUse"
import { getModeBySlug } from "../../../shared/modes"
Expand Down Expand Up @@ -60,6 +60,7 @@ interface MockTask {
didAlreadyUseTool: boolean
consecutiveMistakeCount: number
clineMessages: unknown[]
getTaskMode: Mock<() => Promise<string>>
api: { getModel: () => { id: string; info: Record<string, unknown> } }
recordToolUsage: ReturnType<typeof vi.fn>
recordToolError: ReturnType<typeof vi.fn>
Expand Down Expand Up @@ -96,6 +97,7 @@ describe("presentAssistantMessage - tool usage attribution", () => {
didAlreadyUseTool: false,
consecutiveMistakeCount: 0,
clineMessages: [],
getTaskMode: vi.fn().mockResolvedValue("code"),
api: {
getModel: () => ({ id: "test-model", info: {} }),
},
Expand Down Expand Up @@ -187,6 +189,35 @@ describe("presentAssistantMessage - tool usage attribution", () => {
expect(mockTask.recordToolUsage).not.toHaveBeenCalledWith("mcp_")
})

it("validates tools against the task-local mode when provider state differs", async () => {
mockTask.getTaskMode.mockResolvedValue("code")
mockTask.providerRef.deref = () => ({
getState: vi.fn().mockResolvedValue({ mode: "orchestrator", customModes: [] }),
})
mockTask.assistantMessageContent = [
{
type: "tool_use",
id: "call_task_mode",
name: "read_file",
params: { path: "test.txt" },
nativeArgs: { path: "test.txt" },
partial: false,
},
]

await presentAssistantMessage(mockTask as unknown as Task)

expect(validateToolUse).toHaveBeenCalledWith(
"read_file",
"code",
[],
{},
{ path: "test.txt" },
undefined,
undefined,
)
})

it("records a safe failure key without leaking the raw tool name when validation fails", async () => {
vi.mocked(validateToolUse).mockImplementation(() => {
throw new Error('Tool "read_file" is not allowed in this mode.')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
didAlreadyUseTool: false,
consecutiveMistakeCount: 0,
clineMessages: [],
getTaskMode: vi.fn().mockResolvedValue("code"),
api: {
getModel: () => ({ id: "test-model", info: {} }),
},
Expand Down
9 changes: 5 additions & 4 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,10 @@ export async function presentAssistantMessage(cline: Task) {
break
}

// Fetch state early so it's available for toolDescription and validation
// Shared provider state supplies global settings; mode is owned by the task.
const state = await cline.providerRef.deref()?.getState()
const { mode, customModes, experiments: stateExperiments, disabledTools } = state ?? {}
const { customModes, experiments: stateExperiments, disabledTools } = state ?? {}
const mode = await cline.getTaskMode()

const toolDescription = (): string => {
switch (block.name) {
Expand Down Expand Up @@ -617,7 +618,7 @@ export async function presentAssistantMessage(cline: Task) {

validateToolUse(
block.name as ToolName,
mode ?? defaultModeSlug,
mode,
customModes ?? [],
toolRequirements,
block.params,
Expand Down Expand Up @@ -924,7 +925,7 @@ export async function presentAssistantMessage(cline: Task) {
}

const result = await customTool.execute(customToolArgs, {
mode: mode ?? defaultModeSlug,
mode,
task: cline,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe("getEnvironmentDetails", () => {
terminalOutputLineLimit: 100,
maxWorkspaceFiles: 50,
maxOpenTabsContext: 10,
mode: "code",
mode: "orchestrator",
customModes: [],
experiments: {},
customInstructions: "test instructions",
Expand All @@ -91,6 +91,7 @@ describe("getEnvironmentDetails", () => {
cwd: mockCwd,
taskId: mockTaskId,
didEditFile: false,
getTaskMode: vi.fn().mockResolvedValue("code"),
fileContextTracker: {
getAndClearRecentlyModifiedFiles: vi.fn().mockReturnValue([]),
} as unknown as FileContextTracker,
Expand Down Expand Up @@ -156,6 +157,8 @@ describe("getEnvironmentDetails", () => {

expect(mockProvider.getState).toHaveBeenCalled()

expect(mockCline.getTaskMode).toHaveBeenCalled()
expect(result).toContain("<slug>code</slug>")
expect(getFullModeDetails).toHaveBeenCalledWith("code", [], undefined, {
cwd: mockCwd,
globalCustomInstructions: "test instructions",
Expand Down
5 changes: 2 additions & 3 deletions src/core/environment/getEnvironmentDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import delay from "delay"
import type { ExperimentId } from "@roo-code/types"

import { formatLanguage } from "../../shared/language"
import { defaultModeSlug, getFullModeDetails } from "../../shared/modes"
import { getFullModeDetails } from "../../shared/modes"
import { getApiMetrics } from "../../shared/getApiMetrics"
import { listFiles } from "../../services/glob/list-files"
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
Expand Down Expand Up @@ -205,15 +205,14 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo

// Add current mode and any mode-specific warnings.
const {
mode,
customModes,
customModePrompts,
experiments = {} as Record<ExperimentId, boolean>,
customInstructions: globalCustomInstructions,
language,
} = state ?? {}

const currentMode = mode ?? defaultModeSlug
const currentMode = await cline.getTaskMode()

const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, {
cwd: cline.cwd,
Expand Down
2 changes: 1 addition & 1 deletion src/core/tools/RunSlashCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export class RunSlashCommandTool extends BaseTool<"run_slash_command"> {
const command = await getCommand(task.cwd, commandName)

if (!command) {
const currentMode = state?.mode ?? "code"
const currentMode = await task.getTaskMode()
const skillsManager = provider?.getSkillsManager()
const skillContent = await resolveSkillContentForMode(skillsManager, commandName, currentMode)

Expand Down
5 changes: 2 additions & 3 deletions src/core/tools/SkillTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,8 @@ export class SkillTool extends BaseTool<"skill"> {
return
}

// Get current mode for skill resolution
const state = await provider?.getState()
const currentMode = state?.mode ?? "code"
// Resolve skills against the task's mode, not shared provider state.
const currentMode = await task.getTaskMode()

// Fetch skill content
const skillContent = await resolveSkillContentForMode(skillsManager, skillName, currentMode)
Expand Down
6 changes: 3 additions & 3 deletions src/core/tools/SwitchModeTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import delay from "delay"

import { Task } from "../task/Task"
import { formatResponse } from "../prompts/responses"
import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
import { getModeBySlug } from "../../shared/modes"
import { BaseTool, ToolCallbacks } from "./BaseTool"
import type { ToolUse } from "../../shared/tools"

Expand Down Expand Up @@ -38,8 +38,8 @@ export class SwitchModeTool extends BaseTool<"switch_mode"> {
return
}

// Check if already in requested mode
const currentMode = (await task.providerRef.deref()?.getState())?.mode ?? defaultModeSlug
// Mode belongs to the task; provider state may still reflect its parent.
const currentMode = await task.getTaskMode()

if (currentMode === mode_slug) {
task.recordToolError("switch_mode")
Expand Down
14 changes: 10 additions & 4 deletions src/core/tools/__tests__/mcpServerRestriction.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ vi.mock("../../../shared/modes", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../shared/modes")>()
return {
...actual,
defaultModeSlug: "code",
getModeBySlug: vi.fn(),
}
})
Expand All @@ -16,8 +15,14 @@ import { getModeBySlug } from "../../../shared/modes"

const toolError = (error: string) => `ERR:${error}`

function makeTask(state: any): Task {
type ProviderModeState = {
mode?: string
customModes?: []
}

function makeTask(state: ProviderModeState, taskMode = "code"): Task {
return {
getTaskMode: vi.fn().mockResolvedValue(taskMode),
providerRef: {
deref: () => ({
getState: vi.fn().mockResolvedValue(state),
Expand Down Expand Up @@ -60,8 +65,9 @@ describe("getAllowedMcpServersForTask", () => {
groups: ["mcp"],
allowedMcpServers: ["srv-a"],
} as any)
const task = makeTask({ mode: "code", customModes: [] })
const task = makeTask({ mode: "orchestrator", customModes: [] }, "code")
await expect(getAllowedMcpServersForTask(task)).resolves.toEqual(["srv-a"])
expect(getModeBySlug).toHaveBeenCalledWith("code", [])
})

it("returns undefined when the mode does not restrict servers", async () => {
Expand All @@ -77,7 +83,7 @@ describe("getAllowedMcpServersForTask", () => {

it("returns undefined when the mode cannot be resolved", async () => {
vi.mocked(getModeBySlug).mockReturnValue(undefined as any)
const task = makeTask({ mode: "missing", customModes: [] })
const task = makeTask({ mode: "code", customModes: [] }, "missing")
await expect(getAllowedMcpServersForTask(task)).resolves.toBeUndefined()
})
})
Expand Down
4 changes: 3 additions & 1 deletion src/core/tools/__tests__/runSlashCommandTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe("runSlashCommandTool", () => {
vi.clearAllMocks()

mockTask = {
getTaskMode: vi.fn().mockResolvedValue("code"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
consecutiveMistakeCount: 0,
recordToolError: vi.fn(),
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"),
Expand Down Expand Up @@ -96,6 +97,7 @@ describe("runSlashCommandTool", () => {
},
}

mockTask.getTaskMode.mockResolvedValue("code")
const getSkillContent = vi.fn().mockResolvedValue({
name: "skill-only",
description: "Skill-generated command",
Expand All @@ -109,7 +111,7 @@ describe("runSlashCommandTool", () => {
experiments: {
runSlashCommand: true,
},
mode: "code",
mode: "orchestrator",
}),
getSkillsManager: vi.fn().mockReturnValue({
getSkillContent,
Expand Down
16 changes: 16 additions & 0 deletions src/core/tools/__tests__/skillTool.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("skillTool", () => {
}

mockTask = {
getTaskMode: vi.fn().mockResolvedValue("code"),
consecutiveMistakeCount: 0,
recordToolError: vi.fn(),
didToolFailInCurrentTurn: false,
Expand Down Expand Up @@ -67,12 +68,20 @@ describe("skillTool", () => {
skill: "non-existent",
},
}
mockTask.getTaskMode.mockResolvedValue("code")
mockTask.providerRef.deref = vi.fn().mockReturnValue({
getState: vi.fn().mockResolvedValue({ mode: "orchestrator" }),
getSkillsManager: vi.fn().mockReturnValue(mockSkillsManager),
})

mockSkillsManager.getSkillContent.mockResolvedValue(null)
mockSkillsManager.getSkillsForMode.mockReturnValue([{ name: "create-mcp-server" }])

await skillTool.handle(mockTask as Task, block, mockCallbacks)

expect(mockSkillsManager.getSkillContent).toHaveBeenCalledWith("non-existent", "code")
expect(mockSkillsManager.getSkillsForMode).toHaveBeenCalledWith("code")

expect(mockCallbacks.pushToolResult).toHaveBeenCalledWith(
formatResponse.toolError("Skill 'non-existent' not found. Available skills: create-mcp-server"),
)
Expand Down Expand Up @@ -109,6 +118,11 @@ describe("skillTool", () => {
skill: "create-mcp-server",
},
}
mockTask.getTaskMode.mockResolvedValue("code")
mockTask.providerRef.deref = vi.fn().mockReturnValue({
getState: vi.fn().mockResolvedValue({ mode: "orchestrator" }),
getSkillsManager: vi.fn().mockReturnValue(mockSkillsManager),
})

const mockSkillContent = {
name: "create-mcp-server",
Expand All @@ -121,6 +135,8 @@ describe("skillTool", () => {

await skillTool.handle(mockTask as Task, block, mockCallbacks)

expect(mockSkillsManager.getSkillContent).toHaveBeenCalledWith("create-mcp-server", "code")

expect(mockCallbacks.askApproval).toHaveBeenCalledWith(
"tool",
JSON.stringify({
Expand Down
Loading
Loading