Skip to content

Commit 1abf8c1

Browse files
committed
Fix API configuration profile switching
1 parent 7d38c2d commit 1abf8c1

File tree

7 files changed

+187
-7
lines changed

7 files changed

+187
-7
lines changed

src/core/__tests__/contextProxy.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,67 @@ describe("ContextProxy", () => {
250250
})
251251
})
252252

253+
describe("setApiConfiguration", () => {
254+
it("should clear old API configuration values and set new ones", async () => {
255+
// Set up initial API configuration values
256+
await proxy.updateGlobalState("apiModelId", "old-model")
257+
await proxy.updateGlobalState("openAiBaseUrl", "https://old-url.com")
258+
await proxy.updateGlobalState("modelTemperature", 0.7)
259+
260+
// Spy on setValues
261+
const setValuesSpy = jest.spyOn(proxy, "setValues")
262+
263+
// Call setApiConfiguration with new configuration
264+
await proxy.setApiConfiguration({
265+
apiModelId: "new-model",
266+
apiProvider: "anthropic",
267+
// Note: openAiBaseUrl is not included in the new config
268+
})
269+
270+
// Verify setValues was called with the correct parameters
271+
// It should include undefined for openAiBaseUrl (to clear it)
272+
// and the new values for apiModelId and apiProvider
273+
expect(setValuesSpy).toHaveBeenCalledWith(
274+
expect.objectContaining({
275+
apiModelId: "new-model",
276+
apiProvider: "anthropic",
277+
openAiBaseUrl: undefined,
278+
modelTemperature: undefined,
279+
}),
280+
)
281+
282+
// Verify the state cache has been updated correctly
283+
expect(proxy.getGlobalState("apiModelId")).toBe("new-model")
284+
expect(proxy.getGlobalState("apiProvider")).toBe("anthropic")
285+
expect(proxy.getGlobalState("openAiBaseUrl")).toBeUndefined()
286+
expect(proxy.getGlobalState("modelTemperature")).toBeUndefined()
287+
})
288+
289+
it("should handle empty API configuration", async () => {
290+
// Set up initial API configuration values
291+
await proxy.updateGlobalState("apiModelId", "old-model")
292+
await proxy.updateGlobalState("openAiBaseUrl", "https://old-url.com")
293+
294+
// Spy on setValues
295+
const setValuesSpy = jest.spyOn(proxy, "setValues")
296+
297+
// Call setApiConfiguration with empty configuration
298+
await proxy.setApiConfiguration({})
299+
300+
// Verify setValues was called with undefined for all existing API config keys
301+
expect(setValuesSpy).toHaveBeenCalledWith(
302+
expect.objectContaining({
303+
apiModelId: undefined,
304+
openAiBaseUrl: undefined,
305+
}),
306+
)
307+
308+
// Verify the state cache has been cleared
309+
expect(proxy.getGlobalState("apiModelId")).toBeUndefined()
310+
expect(proxy.getGlobalState("openAiBaseUrl")).toBeUndefined()
311+
})
312+
})
313+
253314
describe("resetAllState", () => {
254315
it("should clear all in-memory caches", async () => {
255316
// Setup initial state in caches

src/core/contextProxy.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isSecretKey,
1212
isGlobalStateKey,
1313
} from "../shared/globalState"
14+
import { API_CONFIG_KEYS, ApiConfiguration } from "../shared/api"
1415

1516
export class ContextProxy {
1617
private readonly originalContext: vscode.ExtensionContext
@@ -101,6 +102,7 @@ export class ContextProxy {
101102
? this.originalContext.secrets.delete(key)
102103
: this.originalContext.secrets.store(key, value)
103104
}
105+
104106
/**
105107
* Set a value in either secrets or global state based on key type.
106108
* If the key is in SECRET_KEYS, it will be stored as a secret.
@@ -136,6 +138,21 @@ export class ContextProxy {
136138
await Promise.all(promises)
137139
}
138140

141+
async setApiConfiguration(apiConfiguration: ApiConfiguration) {
142+
// Explicitly clear out any old API configuration values before that
143+
// might not be present in the new configuration.
144+
// If a value is not present in the new configuration, then it is assumed
145+
// that the setting's value should be `undefined` and therefore we
146+
// need to remove it from the state cache if it exists.
147+
await this.setValues({
148+
...API_CONFIG_KEYS.filter((key) => !!this.stateCache.get(key)).reduce(
149+
(acc, key) => ({ ...acc, [key]: undefined }),
150+
{} as Partial<ConfigurationValues>,
151+
),
152+
...apiConfiguration,
153+
})
154+
}
155+
139156
/**
140157
* Resets all global state, secrets, and in-memory caches.
141158
* This clears all data from both the in-memory caches and the VSCode storage.

src/core/webview/ClineProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1980,7 +1980,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
19801980
}
19811981
}
19821982

1983-
await this.contextProxy.setValues(apiConfiguration)
1983+
await this.contextProxy.setApiConfiguration(apiConfiguration)
19841984

19851985
if (this.getCurrentCline()) {
19861986
this.getCurrentCline()!.api = buildApiHandler(apiConfiguration)

src/exports/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
6868
await this.provider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" })
6969
}
7070

71+
// TODO: Change this to `setApiConfiguration`.
7172
public async setConfiguration(values: Partial<ConfigurationValues>) {
7273
await this.provider.setValues(values)
7374
}

src/shared/api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ export type ApiConfiguration = ApiHandlerOptions & {
8585
// Import GlobalStateKey type from globalState.ts
8686
import { GlobalStateKey } from "./globalState"
8787

88-
// Define API configuration keys for dynamic object building
88+
// Define API configuration keys for dynamic object building.
89+
// TODO: This needs actual type safety; a type error should be thrown if
90+
// this is not an exhaustive list of all `GlobalStateKey` values.
8991
export const API_CONFIG_KEYS: GlobalStateKey[] = [
9092
"apiModelId",
9193
"anthropicBaseUrl",

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,27 +81,28 @@ export const ExtensionStateContext = createContext<ExtensionStateContextType | u
8181

8282
export const mergeExtensionState = (prevState: ExtensionState, newState: ExtensionState) => {
8383
const {
84-
apiConfiguration: prevApiConfiguration,
8584
customModePrompts: prevCustomModePrompts,
8685
customSupportPrompts: prevCustomSupportPrompts,
8786
experiments: prevExperiments,
8887
...prevRest
8988
} = prevState
9089

9190
const {
92-
apiConfiguration: newApiConfiguration,
91+
apiConfiguration,
9392
customModePrompts: newCustomModePrompts,
9493
customSupportPrompts: newCustomSupportPrompts,
9594
experiments: newExperiments,
9695
...newRest
9796
} = newState
9897

99-
const apiConfiguration = { ...prevApiConfiguration, ...newApiConfiguration }
10098
const customModePrompts = { ...prevCustomModePrompts, ...newCustomModePrompts }
10199
const customSupportPrompts = { ...prevCustomSupportPrompts, ...newCustomSupportPrompts }
102100
const experiments = { ...prevExperiments, ...newExperiments }
103101
const rest = { ...prevRest, ...newRest }
104102

103+
// Note that we completely replace the previous apiConfiguration object with
104+
// a new one since the state that is broadcast is the entire apiConfiguration
105+
// and therefore merging is not necessary.
105106
return { ...rest, apiConfiguration, customModePrompts, customSupportPrompts, experiments }
106107
}
107108

webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// npx jest webview-ui/src/context/__tests__/ExtensionStateContext.test.tsx
1+
// cd webview-ui && npx jest src/context/__tests__/ExtensionStateContext.test.tsx
22

33
import { render, screen, act } from "@testing-library/react"
44

@@ -26,6 +26,24 @@ const TestComponent = () => {
2626
)
2727
}
2828

29+
// Test component for API configuration
30+
const ApiConfigTestComponent = () => {
31+
const { apiConfiguration, setApiConfiguration } = useExtensionState()
32+
return (
33+
<div>
34+
<div data-testid="api-configuration">{JSON.stringify(apiConfiguration)}</div>
35+
<button
36+
data-testid="update-api-config-button"
37+
onClick={() => setApiConfiguration({ apiModelId: "new-model", apiProvider: "anthropic" })}>
38+
Update API Config
39+
</button>
40+
<button data-testid="partial-update-button" onClick={() => setApiConfiguration({ modelTemperature: 0.7 })}>
41+
Partial Update
42+
</button>
43+
</div>
44+
)
45+
}
46+
2947
describe("ExtensionStateContext", () => {
3048
it("initializes with empty allowedCommands array", () => {
3149
render(
@@ -96,6 +114,70 @@ describe("ExtensionStateContext", () => {
96114

97115
consoleSpy.mockRestore()
98116
})
117+
118+
it("updates apiConfiguration through setApiConfiguration", () => {
119+
render(
120+
<ExtensionStateContextProvider>
121+
<ApiConfigTestComponent />
122+
</ExtensionStateContextProvider>,
123+
)
124+
125+
const initialContent = screen.getByTestId("api-configuration").textContent!
126+
expect(initialContent).toBeDefined()
127+
128+
act(() => {
129+
screen.getByTestId("update-api-config-button").click()
130+
})
131+
132+
const updatedContent = screen.getByTestId("api-configuration").textContent!
133+
const updatedConfig = JSON.parse(updatedContent || "{}")
134+
135+
expect(updatedConfig).toEqual(
136+
expect.objectContaining({
137+
apiModelId: "new-model",
138+
apiProvider: "anthropic",
139+
}),
140+
)
141+
})
142+
143+
it("correctly merges partial updates to apiConfiguration", () => {
144+
render(
145+
<ExtensionStateContextProvider>
146+
<ApiConfigTestComponent />
147+
</ExtensionStateContextProvider>,
148+
)
149+
150+
// First set the initial configuration
151+
act(() => {
152+
screen.getByTestId("update-api-config-button").click()
153+
})
154+
155+
// Verify initial update
156+
const initialContent = screen.getByTestId("api-configuration").textContent!
157+
const initialConfig = JSON.parse(initialContent || "{}")
158+
expect(initialConfig).toEqual(
159+
expect.objectContaining({
160+
apiModelId: "new-model",
161+
apiProvider: "anthropic",
162+
}),
163+
)
164+
165+
// Now perform a partial update
166+
act(() => {
167+
screen.getByTestId("partial-update-button").click()
168+
})
169+
170+
// Verify that the partial update was merged with the existing configuration
171+
const updatedContent = screen.getByTestId("api-configuration").textContent!
172+
const updatedConfig = JSON.parse(updatedContent || "{}")
173+
expect(updatedConfig).toEqual(
174+
expect.objectContaining({
175+
apiModelId: "new-model", // Should retain this from previous update
176+
apiProvider: "anthropic", // Should retain this from previous update
177+
modelTemperature: 0.7, // Should add this from partial update
178+
}),
179+
)
180+
})
99181
})
100182

101183
describe("mergeExtensionState", () => {
@@ -125,19 +207,35 @@ describe("mergeExtensionState", () => {
125207
const prevState: ExtensionState = {
126208
...baseState,
127209
apiConfiguration: { modelMaxTokens: 1234, modelMaxThinkingTokens: 123 },
210+
experiments: {
211+
experimentalDiffStrategy: true,
212+
search_and_replace: true,
213+
insert_content: true,
214+
} as Record<ExperimentId, boolean>,
128215
}
129216

130217
const newState: ExtensionState = {
131218
...baseState,
132219
apiConfiguration: { modelMaxThinkingTokens: 456, modelTemperature: 0.3 },
220+
experiments: {
221+
powerSteering: true,
222+
multi_search_and_replace: true,
223+
} as Record<ExperimentId, boolean>,
133224
}
134225

135226
const result = mergeExtensionState(prevState, newState)
136227

137228
expect(result.apiConfiguration).toEqual({
138-
modelMaxTokens: 1234,
139229
modelMaxThinkingTokens: 456,
140230
modelTemperature: 0.3,
141231
})
232+
233+
expect(result.experiments).toEqual({
234+
experimentalDiffStrategy: true,
235+
search_and_replace: true,
236+
insert_content: true,
237+
powerSteering: true,
238+
multi_search_and_replace: true,
239+
})
142240
})
143241
})

0 commit comments

Comments
 (0)