Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
37 changes: 19 additions & 18 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}

this.providerProfileChangeListener = async () => {
if (provider.getCurrentTask()?.taskId !== this.taskId) {
return
}

try {
const newState = await provider.getState()
if (newState?.apiConfiguration) {
Expand Down Expand Up @@ -1537,6 +1541,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
if (provider) {
if (mode) {
await provider.setMode(mode)
await this.waitForModeInitialization()
this._taskMode = mode
}

if (providerProfile) {
Expand Down Expand Up @@ -1591,7 +1597,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Get condensing configuration
const state = await this.providerRef.deref()?.getState()
const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE
const { mode, apiConfiguration } = state ?? {}
const mode = await this.getTaskMode()
const apiConfiguration = this.apiConfiguration

const { contextTokens: prevContextTokens } = this.getTokenUsage()

Expand Down Expand Up @@ -3782,16 +3789,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

const state = await this.providerRef.deref()?.getState()

const {
mode,
customModes,
customModePrompts,
customInstructions,
experiments,
language,
apiConfiguration,
enableSubfolderRules,
} = state ?? {}
const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } =
state ?? {}
const mode = await this.getTaskMode()
const apiConfiguration = this.apiConfiguration

return await (async () => {
const provider = this.providerRef.deref()
Expand Down Expand Up @@ -3857,7 +3858,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

private async handleContextWindowExceededError(): Promise<void> {
const state = await this.providerRef.deref()?.getState()
const { profileThresholds = {}, mode, apiConfiguration } = state ?? {}
const { profileThresholds = {} } = state ?? {}
const mode = await this.getTaskMode()
const apiConfiguration = this.apiConfiguration

const { contextTokens } = this.getTokenUsage()
await this.safeEnsureModelFetched()
Expand Down Expand Up @@ -3997,9 +4000,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
* the `api_req_rate_limit_wait` say type (not an error).
*/
private async maybeWaitForProviderRateLimit(retryAttempt: number): Promise<void> {
const state = await this.providerRef.deref()?.getState()
const rateLimitSeconds =
state?.apiConfiguration?.rateLimitSeconds ?? this.apiConfiguration?.rateLimitSeconds ?? 0
const rateLimitSeconds = this.apiConfiguration?.rateLimitSeconds ?? 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const lastRequestTime = this.rateLimitClock.getLastRequestTime()
if (rateLimitSeconds <= 0 || !lastRequestTime) {
Expand Down Expand Up @@ -4032,14 +4033,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const state = await this.providerRef.deref()?.getState()

const {
apiConfiguration,
autoApprovalEnabled,
requestDelaySeconds,
mode,
autoCondenseContext = true,
autoCondenseContextPercent = 100,
profileThresholds = {},
} = state ?? {}
const mode = await this.getTaskMode()
const apiConfiguration = this.apiConfiguration
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Get condensing configuration for automatic triggers.
const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE
Expand Down Expand Up @@ -4452,7 +4453,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

// Respect provider rate limit window
let rateLimitDelay = 0
const rateLimit = (state?.apiConfiguration ?? this.apiConfiguration)?.rateLimitSeconds || 0
const rateLimit = this.apiConfiguration?.rateLimitSeconds || 0
const lastRequestTime = this.rateLimitClock.getLastRequestTime()
if (lastRequestTime && rateLimit > 0) {
const elapsed = performance.now() - lastRequestTime
Expand Down
157 changes: 154 additions & 3 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Anthropic } from "@anthropic-ai/sdk"

import {
providerIdentifiers,
RooCodeEventName,
type GlobalState,
type ProviderSettings,
type ModelInfo,
Expand All @@ -16,6 +17,7 @@ import {
import { TelemetryService } from "@roo-code/telemetry"

import { Task } from "../Task"
import { SYSTEM_PROMPT } from "../../prompts/system"
import { createRateLimitClock } from "../RateLimitClock"
import { summarizeConversation } from "../../condense"
import { ClineProvider } from "../../webview/ClineProvider"
Expand Down Expand Up @@ -223,6 +225,15 @@ vi.mock("../../condense", async (importOriginal) => {
}),
}
})

vi.mock("../../prompts/system", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../prompts/system")>()
return {
...actual,
SYSTEM_PROMPT: vi.fn(actual.SYSTEM_PROMPT),
}
})

// Mock storagePathManager to prevent dynamic import issues.
vi.mock("../../../utils/storage", () => ({
getTaskDirectoryPath: vi
Expand Down Expand Up @@ -451,6 +462,115 @@ describe("Cline", () => {
})
})

describe("task-local configuration isolation", () => {
it("uses the task mode and API configuration when focused provider state differs", async () => {
const taskApiConfiguration: ProviderSettings = {
...mockApiConfig,
todoListEnabled: true,
}
vi.spyOn(mockProvider, "getState").mockResolvedValue({ mode: "architect", mcpEnabled: false })

const task = new Task({
provider: mockProvider,
apiConfiguration: taskApiConfiguration,
task: "test task",
startTask: false,
})
await task.getTaskMode()

vi.spyOn(mockProvider, "getState").mockResolvedValue({
mode: "code",
mcpEnabled: false,
apiConfiguration: { ...mockApiConfig, todoListEnabled: false },
})
vi.mocked(SYSTEM_PROMPT).mockResolvedValueOnce("mock system prompt")

await getTaskTestAccess(task).getSystemPrompt()

const systemPromptCall = requireDefined(vi.mocked(SYSTEM_PROMPT).mock.calls.at(-1))
expect(systemPromptCall[5]).toBe("architect")
expect(systemPromptCall[12]).toMatchObject({ todoListEnabled: true })
})

it("uses the task mode in request metadata when focused provider state differs", async () => {
vi.spyOn(mockProvider, "getState").mockResolvedValue({
mode: "ask",
mcpEnabled: false,
autoApprovalEnabled: true,
requestDelaySeconds: 0,
})
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})
await task.getTaskMode()
vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt")

vi.spyOn(mockProvider, "getState").mockResolvedValue({
mode: "code",
mcpEnabled: false,
autoApprovalEnabled: true,
requestDelaySeconds: 0,
})
const stream = (async function* () {
yield { type: "text", text: "response" } as ApiStreamChunk
})()
const createMessage = vi.spyOn(task.api, "createMessage").mockReturnValue(stream)
task.apiConversationHistory = [
{ role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() },
]

await task.attemptApiRequest().next()

const metadata = requireDefined(createMessage.mock.calls[0])[2]
expect(metadata?.mode).toBe("ask")
})

it("only applies profile changes to the focused task", async () => {
const parentConfiguration: ProviderSettings = {
...mockApiConfig,
apiModelId: "parent-model",
rateLimitSeconds: 4,
}
const childConfiguration: ProviderSettings = {
...mockApiConfig,
apiModelId: "child-model",
rateLimitSeconds: 8,
}
const activeConfiguration: ProviderSettings = {
...mockApiConfig,
apiModelId: "active-model",
rateLimitSeconds: 12,
}
const parent = new Task({
provider: mockProvider,
apiConfiguration: parentConfiguration,
taskId: "parent-task",
task: "parent task",
startTask: false,
})
const child = new Task({
provider: mockProvider,
apiConfiguration: childConfiguration,
taskId: "child-task",
task: "child task",
startTask: false,
})
vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(child)
vi.spyOn(mockProvider, "getState").mockResolvedValue({ apiConfiguration: activeConfiguration })

mockProvider.emit(RooCodeEventName.ProviderProfileChanged, {
name: "active-profile",
provider: activeConfiguration.apiProvider,
})

await vi.waitFor(() => expect(child.apiConfiguration).toEqual(activeConfiguration))
expect(parent.apiConfiguration).toEqual(parentConfiguration)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
})

describe("sayAndCreateMissingParamError", () => {
it("surfaces a localized error notice and returns the missing-parameter tool error for both relPath branches", async () => {
const cline = new Task({
Expand Down Expand Up @@ -750,7 +870,7 @@ describe("Cline", () => {
expect(mockDelay).toHaveBeenCalledWith(1000)
})

it("should respect rate limit window in retry backoff", async () => {
it("uses the task rate limit in retry backoff when focused provider state differs", async () => {
const clock = createRateLimitClock()
const rateLimitConfig = {
...mockApiConfig,
Expand Down Expand Up @@ -815,15 +935,19 @@ describe("Cline", () => {
const providerState = await mockProvider.getState()
vi.spyOn(mockProvider, "getState").mockResolvedValue({
...providerState,
apiConfiguration: rateLimitConfig,
apiConfiguration: {
...mockApiConfig,
rateLimitSeconds: 1,
},
autoApprovalEnabled: true,
requestDelaySeconds: 3,
})

const iterator = cline.attemptApiRequest(0)
await iterator.next()

// rateLimitSeconds=10 > exponentialDelay=ceil(3*2^0)=3, so
// The task rateLimitSeconds=10 (rather than the focused provider's 1)
// exceeds exponentialDelay=ceil(3*2^0)=3, so
// finalDelay=10 and the countdown loop fires delay(1000) ten times.
expect(mockDelay).toHaveBeenCalledWith(1000)
expect(mockDelay).toHaveBeenCalledTimes(10)
Expand Down Expand Up @@ -1574,6 +1698,33 @@ describe("Cline", () => {
expect(mockProvider.postMessageToWebview).not.toHaveBeenCalled()
})

it("uses a mode selected through submitUserMessage in the next API request", async () => {
vi.spyOn(mockProvider, "getState").mockResolvedValue({ mode: "ask", mcpEnabled: false })
vi.spyOn(mockProvider, "setMode").mockResolvedValue(undefined)
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "initial task",
startTask: false,
})
vi.spyOn(task, "handleWebviewAskResponse").mockImplementation(() => {})

await task.submitUserMessage("switch modes", undefined, "code")
vi.spyOn(getTaskTestAccess(task), "getSystemPrompt").mockResolvedValue("mock system prompt")
const stream = (async function* () {
yield { type: "text", text: "response" } as ApiStreamChunk
})()
const createMessage = vi.spyOn(task.api, "createMessage").mockReturnValue(stream)
task.apiConversationHistory = [
{ role: "user", content: [{ type: "text", text: "test message" }], ts: Date.now() },
]

await task.attemptApiRequest().next()

expect(mockProvider.setMode).toHaveBeenCalledWith("code")
expect(requireDefined(createMessage.mock.calls[0])[2]?.mode).toBe("code")
})

it("should handle empty messages gracefully", async () => {
const task = new Task({
provider: mockProvider,
Expand Down
Loading