Skip to content

Commit c83c2ac

Browse files
committed
Add Temperature Override feature with validation and tests
1 parent 7409c48 commit c83c2ac

File tree

6 files changed

+501
-2
lines changed

6 files changed

+501
-2
lines changed

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,12 @@
270270
"configuration": {
271271
"title": "Roo Code",
272272
"properties": {
273-
"roo-cline.allowedCommands": {
273+
"roo-code.enableTemperatureOverride": {
274+
"type": "boolean",
275+
"default": true,
276+
"description": "Enable temperature override using @customTemperature syntax"
277+
},
278+
"roo-code.allowedCommands": {
274279
"type": "array",
275280
"items": {
276281
"type": "string"

src/core/Cline.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import getFolderSize from "get-folder-size"
1212
import { serializeError } from "serialize-error"
1313
import * as vscode from "vscode"
1414

15+
import { TemperatureOverrideService } from "./services/TemperatureOverrideService"
1516
import { TokenUsage } from "../schemas"
1617
import { ApiHandler, buildApiHandler } from "../api"
1718
import { ApiStream } from "../api/transform/stream"
@@ -31,7 +32,7 @@ import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
3132
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
3233
import { listFiles } from "../services/glob/list-files"
3334
import { CheckpointStorage } from "../shared/checkpoints"
34-
import { ApiConfiguration } from "../shared/api"
35+
import { ApiConfiguration, ApiHandlerOptions } from "../shared/api"
3536
import { findLastIndex } from "../shared/array"
3637
import { combineApiRequests } from "../shared/combineApiRequests"
3738
import { combineCommandSequences } from "../shared/combineCommandSequences"
@@ -119,6 +120,9 @@ export class Cline extends EventEmitter<ClineEvents> {
119120
readonly taskId: string
120121
readonly instanceId: string
121122

123+
private tempOverrideService: TemperatureOverrideService
124+
private originalTemp?: number
125+
122126
readonly rootTask: Cline | undefined = undefined
123127
readonly parentTask: Cline | undefined = undefined
124128
readonly taskNumber: number
@@ -197,6 +201,8 @@ export class Cline extends EventEmitter<ClineEvents> {
197201
throw new Error("Either historyItem or task/images must be provided")
198202
}
199203

204+
this.tempOverrideService = new TemperatureOverrideService()
205+
200206
this.rooIgnoreController = new RooIgnoreController(this.cwd)
201207
this.rooIgnoreController.initialize().catch((error) => {
202208
console.error("Failed to initialize RooIgnoreController:", error)
@@ -861,6 +867,42 @@ export class Cline extends EventEmitter<ClineEvents> {
861867
}
862868

863869
private async initiateTaskLoop(userContent: UserContent): Promise<void> {
870+
// Handle temperature override if present
871+
try {
872+
const firstBlock = userContent[0]
873+
if (firstBlock.type === "text") {
874+
const tempOverride = this.tempOverrideService.parseAndApplyOverride(
875+
firstBlock.text || "",
876+
this.apiConfiguration.modelTemperature || 0,
877+
)
878+
879+
if (tempOverride) {
880+
// Store original temp for restoration
881+
this.originalTemp = tempOverride.originalTemp
882+
883+
// Apply override to apiConfiguration
884+
this.apiConfiguration.modelTemperature = tempOverride.temperature
885+
886+
// For providers using direct options access (like MistralHandler)
887+
// we need to update both the configuration and the provider's copy of options
888+
if (this.api && "options" in this.api) {
889+
// Use type assertion since we know these providers have options
890+
const provider = this.api as { options: ApiHandlerOptions }
891+
// Update the provider's copy of options
892+
provider.options.modelTemperature = tempOverride.temperature
893+
}
894+
895+
firstBlock.text = tempOverride.cleanedInput
896+
}
897+
}
898+
} catch (error) {
899+
await this.say(
900+
"error",
901+
`Temperature override error: ${error instanceof Error ? error.message : String(error)}`,
902+
)
903+
return
904+
}
905+
864906
// Kicks off the checkpoints initialization process in the background.
865907
this.getCheckpointService()
866908

@@ -893,6 +935,20 @@ export class Cline extends EventEmitter<ClineEvents> {
893935
this.consecutiveMistakeCount++
894936
}
895937
}
938+
939+
// Restore original temperature if override was applied
940+
if (this.originalTemp !== undefined) {
941+
// Restore both apiConfiguration and provider options
942+
this.apiConfiguration.modelTemperature = this.originalTemp
943+
944+
// Also restore provider's copy if it exists
945+
if (this.api && "options" in this.api) {
946+
const provider = this.api as { options: ApiHandlerOptions }
947+
provider.options.modelTemperature = this.originalTemp
948+
}
949+
950+
this.originalTemp = undefined
951+
}
896952
}
897953

898954
async abortTask(isAbandoned = false) {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const telemetryService = {
2+
captureTaskCreated: jest.fn(),
3+
captureTaskRestarted: jest.fn(),
4+
captureTaskCompleted: jest.fn(),
5+
captureError: jest.fn(),
6+
}
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { Cline } from "../Cline"
2+
import { ClineProvider } from "../webview/ClineProvider"
3+
import { TemperatureOverrideService } from "../services/TemperatureOverrideService"
4+
import * as vscode from "vscode"
5+
6+
// Mock dependencies
7+
jest.mock("../webview/ClineProvider")
8+
jest.mock("../services/TemperatureOverrideService")
9+
jest.mock("../ignore/RooIgnoreController")
10+
11+
// Mock storagePathManager to avoid file system errors
12+
jest.mock("../../shared/storagePathManager", () => ({
13+
getStorageBasePath: jest.fn().mockReturnValue("/mock/storage/path"),
14+
getTaskDirectoryPath: jest.fn().mockReturnValue("/mock/task/path"),
15+
ensureDirectoryExists: jest.fn().mockResolvedValue(true),
16+
}))
17+
18+
// More complete vscode mock
19+
jest.mock("vscode", () => ({
20+
workspace: {
21+
getConfiguration: jest.fn().mockReturnValue({
22+
get: jest.fn().mockReturnValue(true),
23+
}),
24+
createFileSystemWatcher: jest.fn().mockReturnValue({
25+
onDidChange: jest.fn(),
26+
onDidCreate: jest.fn(),
27+
onDidDelete: jest.fn(),
28+
dispose: jest.fn(),
29+
}),
30+
},
31+
window: {
32+
createTextEditorDecorationType: jest.fn().mockReturnValue({
33+
dispose: jest.fn(),
34+
}),
35+
showErrorMessage: jest.fn(),
36+
tabGroups: {
37+
all: [],
38+
},
39+
},
40+
env: {
41+
language: "en",
42+
},
43+
RelativePattern: jest.fn().mockImplementation(() => ({})),
44+
}))
45+
46+
describe("Temperature Override Integration", () => {
47+
let cline: Cline
48+
let mockProvider: jest.Mocked<ClineProvider>
49+
let mockGetConfig: jest.Mock
50+
let mockTempOverrideService: jest.Mocked<TemperatureOverrideService>
51+
let mockApi: any
52+
const defaultTemp = 0
53+
beforeEach(() => {
54+
// Reset mocks
55+
jest.clearAllMocks()
56+
57+
mockProvider = {
58+
context: {
59+
globalStorageUri: { fsPath: "/test/storage" },
60+
},
61+
postStateToWebview: jest.fn(),
62+
postMessageToWebview: jest.fn(),
63+
log: jest.fn(),
64+
getState: jest.fn().mockReturnValue({
65+
terminalOutputLineLimit: 100,
66+
maxWorkspaceFiles: 50,
67+
}),
68+
} as any
69+
70+
mockGetConfig = jest.fn().mockReturnValue(true)
71+
;(vscode.workspace.getConfiguration as jest.Mock).mockImplementation(() => ({
72+
get: mockGetConfig,
73+
}))
74+
75+
// Mock the TemperatureOverrideService
76+
mockTempOverrideService = {
77+
parseAndApplyOverride: jest.fn().mockImplementation((input, currentTemp) => {
78+
// Check configuration like the real service does
79+
const config = vscode.workspace.getConfiguration("roo-cline")
80+
if (!config.get<boolean>("enableTemperatureOverride", true)) {
81+
return null
82+
}
83+
84+
if (input.startsWith("@customTemperature:")) {
85+
const match = input.match(/^@customTemperature:([^ ]*)/)
86+
if (match && match[1] === "0.9") {
87+
// Preserve leading whitespace after command like real service
88+
return {
89+
temperature: 0.9,
90+
originalTemp: currentTemp,
91+
cleanedInput: input.substring(match[0].length),
92+
}
93+
}
94+
}
95+
return null
96+
}),
97+
} as any
98+
99+
// Set up the mock behavior for the disabled test
100+
;(TemperatureOverrideService as jest.Mock).mockImplementation(() => mockTempOverrideService)
101+
102+
// Mock console.error to reduce test noise
103+
jest.spyOn(console, "error").mockImplementation(() => {})
104+
105+
// Create mock API with options property
106+
mockApi = {
107+
options: {
108+
modelTemperature: defaultTemp,
109+
},
110+
}
111+
112+
cline = new Cline({
113+
provider: mockProvider,
114+
apiConfiguration: {
115+
modelTemperature: defaultTemp,
116+
},
117+
task: "test task",
118+
})
119+
120+
// Mock the API handler
121+
Object.defineProperty(cline, "api", {
122+
get: () => mockApi,
123+
})
124+
125+
// Mock providerRef.deref() to return our mockProvider with getState
126+
Object.defineProperty(cline, "providerRef", {
127+
get: () => ({
128+
deref: () => mockProvider,
129+
}),
130+
})
131+
})
132+
133+
afterEach(() => {
134+
jest.restoreAllMocks()
135+
})
136+
137+
describe("initiateTaskLoop", () => {
138+
it("should not process temperature override when disabled", async () => {
139+
// Setup the mock to return null for this specific test
140+
jest.spyOn(mockTempOverrideService, "parseAndApplyOverride").mockReturnValue(null)
141+
142+
const userContent = [
143+
{
144+
type: "text" as const,
145+
text: "@customTemperature:0.9 Do something",
146+
},
147+
]
148+
149+
// @ts-ignore - accessing private method for testing
150+
await cline.initiateTaskLoop(userContent)
151+
152+
expect(cline.apiConfiguration.modelTemperature).toBe(defaultTemp)
153+
expect(userContent[0].text).toBe("@customTemperature:0.9 Do something")
154+
})
155+
156+
it("should apply temperature override when enabled", async () => {
157+
const userContent = [
158+
{
159+
type: "text" as const,
160+
text: "@customTemperature:0.9 Do something",
161+
},
162+
]
163+
164+
// @ts-ignore - accessing private method for testing
165+
await cline.initiateTaskLoop(userContent)
166+
167+
expect(cline.apiConfiguration.modelTemperature).toBe(0.9) // Should be set to the override value
168+
expect(userContent[0].text).toBe(" Do something") // Should preserve leading space like real service
169+
expect(mockGetConfig).toHaveBeenCalledWith("enableTemperatureOverride", true)
170+
})
171+
172+
it("should handle invalid temperature override gracefully", async () => {
173+
const userContent = [
174+
{
175+
type: "text" as const,
176+
text: "@customTemperature:3.0 Do something",
177+
},
178+
]
179+
180+
// @ts-ignore - accessing private method for testing
181+
await cline.initiateTaskLoop(userContent)
182+
183+
// Should not modify temperature when invalid
184+
expect(cline.apiConfiguration.modelTemperature).toBe(defaultTemp)
185+
expect(userContent[0].text).toBe("@customTemperature:3.0 Do something")
186+
})
187+
188+
it("should handle image blocks without error", async () => {
189+
const userContent = [
190+
{
191+
type: "image" as const,
192+
source: "data:image/png;base64,...",
193+
},
194+
]
195+
196+
// @ts-ignore - accessing private method for testing
197+
await cline.initiateTaskLoop(userContent)
198+
199+
expect(cline.apiConfiguration.modelTemperature).toBe(defaultTemp)
200+
})
201+
202+
// Additional test for provider with direct options access
203+
it("should update and restore provider options for handlers with direct access", async () => {
204+
const userContent = [
205+
{
206+
type: "text" as const,
207+
text: "@customTemperature:0.9 Do something",
208+
},
209+
]
210+
211+
// @ts-ignore - accessing private method for testing
212+
await cline.initiateTaskLoop(userContent)
213+
214+
// Check both apiConfiguration and provider options are updated
215+
expect(cline.apiConfiguration.modelTemperature).toBe(0.9)
216+
expect(mockApi.options.modelTemperature).toBe(0.9)
217+
218+
// @ts-ignore - accessing private property for testing
219+
expect(cline.originalTemp).toBe(defaultTemp)
220+
221+
// Call abortTask which restores temperature
222+
await cline.abortTask()
223+
224+
// Check both are restored
225+
expect(cline.apiConfiguration.modelTemperature).toBe(defaultTemp)
226+
expect(mockApi.options.modelTemperature).toBe(defaultTemp)
227+
// @ts-ignore - accessing private property for testing
228+
expect(cline.originalTemp).toBeUndefined()
229+
})
230+
})
231+
})

0 commit comments

Comments
 (0)