Skip to content

Commit fe00539

Browse files
committed
fix(webview): refresh pinned views on non-activating saves; treat the view overlay as a complete profile
Port of the review fixes landed on the get-state unit: 1. A save that does not activate (upsertProviderProfile with activate=false, and the zoo-gateway callback's non-active profile path) rewrote the profile's settings on disk but left every view pinned to it serving the pre-save snapshot from its buffer. The non-activating branch now refreshes pinned sibling views (and this view when it holds the pin), and the callback routes non-active profiles through upsertProviderProfile instead of a bare saveConfig so the refresh cannot be forgotten at that call site. 2. getState() spread the pinned overlay over the shared provider settings, so any key the pinned profile never defined leaked in from whatever profile the shared store points at (another profile's model id, region, tuning). The overlay is a complete profile snapshot: it is now used in place, with only apiProvider falling back to the shared state when the profile omits one. Proofs: ClineProvider.spec 294/294 (2 new tests: pinned-sibling refresh on non-activating save; no shared-key leak through the overlay; callback test updated to assert the upsert path); negative controls: dropping the else-branch refresh reddens exactly the refresh test, reverting the merge semantics reddens exactly the leak test, restores byte-exact (hash match); sticky-mode 33/33, webviewMessageHandler 89/89; eslint --max-warnings=0 clean; tsc clean except the two known local telemetry-dist noise lines; prettier clean on touched files.
1 parent 9c2bee3 commit fe00539

2 files changed

Lines changed: 146 additions & 10 deletions

File tree

‎src/core/webview/ClineProvider.ts‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2544,6 +2544,15 @@ export class ClineProvider
25442544
// applies only mode, currentApiConfigName, apiConfiguration and provider-settings keys, so a
25452545
// listApiConfigMeta-only payload (deleted call or empty object) is a behavior-preserving no-op.
25462546
this._updateViewLocalStateFromMutation({ listApiConfigMeta })
2547+
2548+
// A save that does not activate still changes the profile's settings, and
2549+
// every view pinned to it buffers a full copy of them. Refresh those views
2550+
// (and this one when it is the pinned view) or their getState() keeps
2551+
// serving the settings captured before the save under the same name.
2552+
if (this.pinnedProfileName === name) {
2553+
await this._saveViewLocalStateFromMutation({ apiConfiguration: providerSettings })
2554+
}
2555+
await this.refreshViewLocalStateForUpdatedProfile(name, providerSettings)
25472556
}
25482557

25492558
await this.postStateToWebview()
@@ -3072,8 +3081,10 @@ export class ClineProvider
30723081
// picks up the new token immediately for the current task.
30733082
await this.upsertProviderProfile(entry.name, updated, true)
30743083
} else {
3075-
// Non-active profiles just need the token saved to disk.
3076-
await this.providerSettingsManager.saveConfig(entry.name, updated)
3084+
// Non-active profiles need the token saved to disk and the views pinned
3085+
// to them refreshed: a direct saveConfig leaves a pinned view serving the
3086+
// old token until it reloads.
3087+
await this.upsertProviderProfile(entry.name, updated, false)
30773088
}
30783089
}
30793090
}
@@ -3886,11 +3897,21 @@ export class ClineProvider
38863897
const taskSyncEnabled: boolean = false
38873898

38883899
// Return the same structure as before.
3900+
// A view-local profile is a complete snapshot, not a patch over the shared store.
3901+
// Spreading it on top of providerSettings would let a key the profile never defined
3902+
// leak in from another profile, which ContextProxy.setProviderSettings deliberately
3903+
// prevents by clearing omitted keys. Use the view's own settings in place and take
3904+
// only apiProvider from the shared state when the profile does not carry one.
3905+
const viewApiConfiguration = mergedStateValues.apiConfiguration
3906+
const resolvedApiConfiguration = viewApiConfiguration
3907+
? {
3908+
...viewApiConfiguration,
3909+
apiProvider: viewApiConfiguration.apiProvider ?? providerSettings.apiProvider,
3910+
}
3911+
: providerSettings
3912+
38893913
return {
3890-
apiConfiguration: {
3891-
...providerSettings,
3892-
...mergedStateValues.apiConfiguration,
3893-
},
3914+
apiConfiguration: resolvedApiConfiguration,
38943915
lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId,
38953916
customInstructions: mergedStateValues.customInstructions,
38963917
apiModelId: mergedStateValues.apiModelId,

‎src/core/webview/__tests__/ClineProvider.spec.ts‎

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,13 @@ describe("ClineProvider", () => {
501501
let mockPostMessage: any
502502
let updateGlobalStateSpy: any
503503

504+
afterEach(() => {
505+
// Tests construct providers directly and many never dispose them; clear the
506+
// static registry so a leaked instance cannot claim stable view state ids
507+
// ahead of a later test.
508+
ClineProvider["activeInstances"] = new Set()
509+
})
510+
504511
beforeEach(() => {
505512
vi.clearAllMocks()
506513

@@ -2351,6 +2358,10 @@ describe("ClineProvider", () => {
23512358
})
23522359
// @ts-ignore - Replace providerSettingsManager with a test double for the profile lookup.
23532360
provider.providerSettingsManager = { getProfile: getProfileSpy }
2361+
// The ownership check requires the writer to release the id before the
2362+
// next view adopts it; the durable entry it wrote stays in storage.
2363+
await writer.dispose()
2364+
23542365
await provider.contextProxy.setValue(
23552366
"viewStates",
23562367
mockContext.globalState.get<RooCodeSettings["viewStates"]>("viewStates"),
@@ -2363,7 +2374,6 @@ describe("ClineProvider", () => {
23632374
})
23642375
expect(getProfileSpy).toHaveBeenCalledWith({ name: "my-profile" })
23652376
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Loaded state for viewId"))
2366-
await writer.dispose()
23672377
await provider.dispose()
23682378
})
23692379

@@ -2491,6 +2501,10 @@ describe("ClineProvider", () => {
24912501
}
24922502
// A pre-load buffer write that was never persisted must not be merged over the load.
24932503
provider["viewLocalState"] = { mode: "architect" }
2504+
// The ownership check requires the writer to release the id before the
2505+
// next view adopts it; the durable entry it wrote stays in storage.
2506+
await writer.dispose()
2507+
24942508
await provider.contextProxy.setValue(
24952509
"viewStates",
24962510
mockContext.globalState.get<RooCodeSettings["viewStates"]>("viewStates"),
@@ -2502,7 +2516,6 @@ describe("ClineProvider", () => {
25022516
apiProvider: providerIdentifiers.openrouter,
25032517
openRouterModelId: "model-x",
25042518
})
2505-
await writer.dispose()
25062519
await provider.dispose()
25072520
})
25082521

@@ -2892,6 +2905,10 @@ describe("ClineProvider", () => {
28922905
}),
28932906
}
28942907
vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
2908+
// The ownership check requires the writer to release the id before the
2909+
// next view adopts it; the durable entry it wrote stays in storage.
2910+
await writer.dispose()
2911+
28952912
await provider.contextProxy.setValue(
28962913
"viewStates",
28972914
mockContext.globalState.get<RooCodeSettings["viewStates"]>("viewStates"),
@@ -2904,7 +2921,6 @@ describe("ClineProvider", () => {
29042921
// The buffer must track the activated profile so getValues() agrees with the proxy.
29052922
expect(provider.getValues().currentApiConfigName).toBe("new-profile")
29062923
expect(provider.contextProxy.getValue("currentApiConfigName")).toBe("new-profile")
2907-
await writer.dispose()
29082924
await provider.dispose()
29092925
})
29102926

@@ -6997,6 +7013,11 @@ describe("getTelemetryProperties", () => {
69977013
const { CloudService } = await import("@roo-code/cloud")
69987014
const mockCloudService = {
69997015
isAuthenticated: vi.fn().mockReturnValue(true),
7016+
7017+
// Provider dispose detaches the settings listener; later tests construct
7018+
// and dispose providers against this surviving instance getter.
7019+
on: vi.fn(),
7020+
off: vi.fn(),
70007021
}
70017022

70027023
// Update the existing mock
@@ -7015,6 +7036,11 @@ describe("getTelemetryProperties", () => {
70157036
const { CloudService } = await import("@roo-code/cloud")
70167037
const mockCloudService = {
70177038
isAuthenticated: vi.fn().mockReturnValue(false),
7039+
7040+
// Provider dispose detaches the settings listener; later tests construct
7041+
// and dispose providers against this surviving instance getter.
7042+
on: vi.fn(),
7043+
off: vi.fn(),
70187044
}
70197045

70207046
// Update the existing mock
@@ -7056,6 +7082,11 @@ describe("getTelemetryProperties", () => {
70567082
isAuthenticated: vi.fn().mockImplementation(() => {
70577083
throw new Error("Authentication check error")
70587084
}),
7085+
7086+
// Provider dispose detaches the settings listener; later tests construct
7087+
// and dispose providers against this surviving instance getter.
7088+
on: vi.fn(),
7089+
off: vi.fn(),
70597090
}
70607091

70617092
// Update the existing mock
@@ -8560,12 +8591,15 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
85608591
}),
85618592
true,
85628593
)
8563-
expect(saveConfig).toHaveBeenCalledWith(
8594+
// The non-active profile now goes through upsertProviderProfile so the views
8595+
// pinned to it are refreshed, not just the file on disk.
8596+
expect(upsertSpy).toHaveBeenCalledWith(
85648597
"Backup Zoo",
85658598
expect.objectContaining({
85668599
zooSessionToken: "new-token",
85678600
zooGatewayBaseUrl: "https://www.zoocode.dev/api/gateway/v1",
85688601
}),
8602+
false,
85698603
)
85708604
})
85718605

@@ -8662,4 +8696,85 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
86628696
})
86638697
})
86648698
})
8699+
describe("profile save without activation and overlay completeness", () => {
8700+
it("refreshes views pinned to a profile saved without activation", async () => {
8701+
// Defensive: the doubles in this suite can leave console writes pending
8702+
// at worker teardown.
8703+
vi.spyOn(console, "log").mockImplementation(() => {})
8704+
vi.spyOn(console, "warn").mockImplementation(() => {})
8705+
vi.spyOn(console, "error").mockImplementation(() => {})
8706+
const sibling = new ClineProvider(
8707+
mockContext,
8708+
mockOutputChannel,
8709+
"editor",
8710+
new ContextProxy(mockContext),
8711+
new WebviewFocusTracker(),
8712+
)
8713+
try {
8714+
// The sibling loaded the profile earlier: its buffer pins the name and
8715+
// carries the settings snapshot taken at that load.
8716+
sibling["viewLocalState"].currentApiConfigName = "saved-profile"
8717+
sibling["viewLocalState"].apiConfiguration = {
8718+
apiProvider: providerIdentifiers.anthropic,
8719+
apiKey: "old-key",
8720+
}
8721+
vi.spyOn(sibling, "postStateToWebview").mockResolvedValue(undefined)
8722+
vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
8723+
// @ts-ignore - Replace providerSettingsManager with a test double.
8724+
provider.providerSettingsManager = {
8725+
saveConfig: vi.fn().mockResolvedValue("saved-id"),
8726+
listConfig: vi.fn().mockResolvedValue([]),
8727+
setModeConfig: vi.fn(),
8728+
}
8729+
8730+
await provider.upsertProviderProfile(
8731+
"saved-profile",
8732+
{ apiProvider: providerIdentifiers.anthropic, apiKey: "new-key" },
8733+
false,
8734+
)
8735+
8736+
// A save that does not activate still rewrites the profile; the pinned
8737+
// sibling must not keep serving the pre-save snapshot.
8738+
expect(sibling["viewLocalState"].apiConfiguration).toEqual({
8739+
apiProvider: providerIdentifiers.anthropic,
8740+
apiKey: "new-key",
8741+
})
8742+
// Let queued view-local persistence settle while the console spies are
8743+
// still installed, so teardown has no pending writes.
8744+
await new Promise((resolve) => setTimeout(resolve, 50))
8745+
} finally {
8746+
await sibling.dispose()
8747+
}
8748+
})
8749+
8750+
it("does not leak shared provider keys into a pinned view's api configuration", async () => {
8751+
vi.spyOn(console, "log").mockImplementation(() => {})
8752+
vi.spyOn(console, "warn").mockImplementation(() => {})
8753+
// The shared store sits on a profile with keys the pinned profile never
8754+
// sets; spreading the overlay over the shared keys would report a mixed
8755+
// configuration (e.g. another profile's model id and region).
8756+
await provider.contextProxy.setValue("apiProvider", providerIdentifiers.bedrock)
8757+
await provider.contextProxy.setValue("apiModelId", "anthropic.claude-v1")
8758+
await provider.contextProxy.setValue("awsRegion", "us-east-1")
8759+
provider["viewLocalState"].currentApiConfigName = "pinned-profile"
8760+
provider["viewLocalState"].apiConfiguration = {
8761+
apiProvider: providerIdentifiers.anthropic,
8762+
apiKey: "k",
8763+
}
8764+
8765+
// The cloud doubles in this suite make getState() log its defensive catch
8766+
// branches; silence them so teardown has no pending console writes.
8767+
vi.spyOn(console, "error").mockImplementation(() => {})
8768+
8769+
const state = await provider.getState()
8770+
8771+
expect(state.apiConfiguration.apiProvider).toBe(providerIdentifiers.anthropic)
8772+
expect(state.apiConfiguration.apiKey).toBe("k")
8773+
expect(state.apiConfiguration.apiModelId).toBeUndefined()
8774+
expect(state.apiConfiguration.awsRegion).toBeUndefined()
8775+
8776+
// Let any deferred logging settle while the spies are installed.
8777+
await new Promise((resolve) => setTimeout(resolve, 50))
8778+
})
8779+
})
86658780
})

0 commit comments

Comments
 (0)