Skip to content

Commit 3d0d2b5

Browse files
committed
fix(webview): serialize webviewDidLaunch telemetry init, polish locale copy
1 parent 7ff0890 commit 3d0d2b5

16 files changed

Lines changed: 185 additions & 53 deletions

File tree

PRIVACY.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,6 @@ go—and, importantly, where they don't.
4040
We retain telemetry only as long as needed for product analytics and
4141
debugging. This PostHog-based telemetry does **not** collect your code or AI
4242
prompts, and you can opt out at any time through the settings.
43-
- **Zoo Code Cloud Task Sync**: If you sign in to Zoo Code Cloud, your task
44-
history — including your prompts and the assistant's responses — is uploaded
45-
to and stored on Zoo Code's servers so you can view and resume tasks across
46-
devices. This is separate from, and more than, the PostHog usage telemetry
47-
described above. Task sync is on by default once you're signed in (or follows
48-
your organization's policy if your account belongs to one); you can turn it
49-
off in Zoo Code Cloud account settings, and signing out of Zoo Code Cloud
50-
stops it entirely.
5143
- **Marketplace Requests**: When you browse or search the Marketplace for Model
5244
Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API
5345
call to Zoo Code's backend servers to retrieve listing information. These

src/__tests__/extension.spec.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ describe("extension.ts", () => {
288288
telemetryClient: null,
289289
authService: null,
290290
hasActiveSession: vi.fn().mockReturnValue(false),
291-
} as any
291+
} as unknown as never
292292
})
293293

294294
vi.mocked(CloudService.hasInstance).mockReturnValue(true)
@@ -297,7 +297,11 @@ describe("extension.ts", () => {
297297
const { activate } = await import("../extension")
298298
await activate(mockContext)
299299

300-
const provider = (ClineProvider as any).getVisibleInstance()
300+
const provider = (
301+
ClineProvider as unknown as {
302+
getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType<typeof vi.fn> }
303+
}
304+
).getVisibleInstance()
301305
provider.postStateToWebviewWithoutClineMessages.mockClear()
302306

303307
await authStateChangedHandler!({
@@ -324,7 +328,7 @@ describe("extension.ts", () => {
324328
beforeEach(async () => {
325329
vi.resetModules()
326330
const vscode = await import("vscode")
327-
;(vscode.env as any).isTelemetryEnabled = true
331+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true
328332
})
329333

330334
test("registers a listener for vscode.env.onDidChangeTelemetryEnabled", async () => {
@@ -342,9 +346,11 @@ describe("extension.ts", () => {
342346
const { TelemetryService } = await import("@roo-code/telemetry")
343347
const { ContextProxy } = await import("../core/config/ContextProxy")
344348

345-
const mockContextProxyInstance = await (ContextProxy.getInstance as any)()
349+
const mockContextProxyInstance = await (
350+
ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType<typeof vi.fn> }>
351+
)()
346352
vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled")
347-
;(vscode.env as any).isTelemetryEnabled = true
353+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true
348354

349355
const { activate } = await import("../extension")
350356
await activate(mockContext)
@@ -355,7 +361,7 @@ describe("extension.ts", () => {
355361
// The real vscode.env.onDidChangeTelemetryEnabled event carries no payload; the handler
356362
// must read the current vscode.env.isTelemetryEnabled value, not any argument it's called with.
357363
const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
358-
onDidChangeHandler(undefined as any)
364+
onDidChangeHandler(undefined as never)
359365

360366
expect(updateTelemetryStateMock).toHaveBeenCalledWith(true)
361367
})
@@ -365,9 +371,11 @@ describe("extension.ts", () => {
365371
const { TelemetryService } = await import("@roo-code/telemetry")
366372
const { ContextProxy } = await import("../core/config/ContextProxy")
367373

368-
const mockContextProxyInstance = await (ContextProxy.getInstance as any)()
374+
const mockContextProxyInstance = await (
375+
ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType<typeof vi.fn> }>
376+
)()
369377
vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("disabled")
370-
;(vscode.env as any).isTelemetryEnabled = true
378+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true
371379

372380
const { activate } = await import("../extension")
373381
await activate(mockContext)
@@ -376,7 +384,7 @@ describe("extension.ts", () => {
376384
updateTelemetryStateMock.mockClear()
377385

378386
const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
379-
onDidChangeHandler(undefined as any)
387+
onDidChangeHandler(undefined as never)
380388

381389
expect(updateTelemetryStateMock).toHaveBeenCalledWith(false)
382390
})
@@ -386,9 +394,11 @@ describe("extension.ts", () => {
386394
const { TelemetryService } = await import("@roo-code/telemetry")
387395
const { ContextProxy } = await import("../core/config/ContextProxy")
388396

389-
const mockContextProxyInstance = await (ContextProxy.getInstance as any)()
397+
const mockContextProxyInstance = await (
398+
ContextProxy.getInstance as unknown as () => Promise<{ getGlobalState: ReturnType<typeof vi.fn> }>
399+
)()
390400
vi.mocked(mockContextProxyInstance.getGlobalState).mockReturnValue("enabled")
391-
;(vscode.env as any).isTelemetryEnabled = true
401+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = true
392402

393403
const { activate } = await import("../extension")
394404
await activate(mockContext)
@@ -399,10 +409,10 @@ describe("extension.ts", () => {
399409
// Simulate the user turning off VS Code's global telemetry toggle: the live env value
400410
// flips before the event fires, and the handler must honor it rather than only the
401411
// stored extension setting.
402-
;(vscode.env as any).isTelemetryEnabled = false
412+
;(vscode.env as { isTelemetryEnabled: boolean }).isTelemetryEnabled = false
403413

404414
const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
405-
onDidChangeHandler(undefined as any)
415+
onDidChangeHandler(undefined as never)
406416

407417
expect(updateTelemetryStateMock).toHaveBeenCalledWith(false)
408418
})
@@ -414,11 +424,15 @@ describe("extension.ts", () => {
414424
const { activate } = await import("../extension")
415425
await activate(mockContext)
416426

417-
const visibleInstance = (ClineProvider as any).getVisibleInstance()
427+
const visibleInstance = (
428+
ClineProvider as unknown as {
429+
getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType<typeof vi.fn> }
430+
}
431+
).getVisibleInstance()
418432
vi.mocked(visibleInstance.postStateToWebviewWithoutClineMessages).mockClear()
419433

420434
const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0]
421-
onDidChangeHandler(undefined as any)
435+
onDidChangeHandler(undefined as never)
422436

423437
expect(visibleInstance.postStateToWebviewWithoutClineMessages).toHaveBeenCalled()
424438
})

src/api/providers/fetchers/__tests__/modelCache.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,7 @@ describe("MODEL_CACHE_EMPTY_RESPONSE throttling", () => {
713713

714714
const NodeCacheModule = await import("node-cache")
715715
const MockedNodeCache = vi.mocked(NodeCacheModule.default)
716-
const mockCache: any = new MockedNodeCache()
716+
const mockCache = vi.mocked(new MockedNodeCache())
717717
mockCache.get.mockReturnValue(undefined)
718718
})
719719

src/core/assistant-message/__tests__/presentAssistantMessage-validation-error.spec.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
import { describe, it, expect, beforeEach, vi } from "vitest"
44
import { presentAssistantMessage } from "../presentAssistantMessage"
55

6+
type ToolResult = { type: string; tool_use_id: string; is_error?: boolean }
7+
type MockTask = Record<string, unknown> & {
8+
assistantMessageContent: unknown[]
9+
userMessageContent: ToolResult[]
10+
pushToolResultToUserContent: ReturnType<typeof vi.fn>
11+
recordToolUsage: ReturnType<typeof vi.fn>
12+
recordToolError: ReturnType<typeof vi.fn>
13+
}
14+
615
// Mock dependencies
716
vi.mock("../../task/Task")
817
vi.mock("../../tools/validateToolUse", () => ({
@@ -24,7 +33,7 @@ vi.mock("@roo-code/telemetry", () => ({
2433
}))
2534

2635
describe("presentAssistantMessage - validateToolUse throws", () => {
27-
let mockTask: any
36+
let mockTask: MockTask
2837

2938
beforeEach(() => {
3039
mockTask = {
@@ -59,11 +68,11 @@ describe("presentAssistantMessage - validateToolUse throws", () => {
5968
},
6069
say: vi.fn().mockResolvedValue(undefined),
6170
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
62-
}
71+
} as unknown as MockTask
6372

64-
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: any) => {
73+
mockTask.pushToolResultToUserContent = vi.fn().mockImplementation((toolResult: ToolResult) => {
6574
const existingResult = mockTask.userMessageContent.find(
66-
(block: any) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
75+
(block) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id,
6776
)
6877
if (existingResult) {
6978
return false
@@ -86,7 +95,7 @@ describe("presentAssistantMessage - validateToolUse throws", () => {
8695
},
8796
]
8897

89-
await presentAssistantMessage(mockTask)
98+
await presentAssistantMessage(mockTask as never)
9099

91100
// The failed attempt must not vanish from telemetry: neither recordToolUsage (the tool
92101
// never actually ran) nor silence -- it must show up via recordToolError instead.
@@ -98,9 +107,9 @@ describe("presentAssistantMessage - validateToolUse throws", () => {
98107

99108
// The tool_result error is still sent to the model as before.
100109
const toolResult = mockTask.userMessageContent.find(
101-
(item: any) => item.type === "tool_result" && item.tool_use_id === toolCallId,
110+
(item) => item.type === "tool_result" && item.tool_use_id === toolCallId,
102111
)
103112
expect(toolResult).toBeDefined()
104-
expect(toolResult.is_error).toBe(true)
113+
expect(toolResult?.is_error).toBe(true)
105114
})
106115
})

src/core/task/__tests__/Task.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ const mockMessages = [
239239
]
240240

241241
describe("Cline", () => {
242-
let mockProvider: any
242+
let mockProvider: ClineProvider
243243
let mockApiConfig: ProviderSettings
244244
let mockOutputChannel: any
245245
let mockExtensionContext: vscode.ExtensionContext
@@ -2980,7 +2980,7 @@ describe("Telemetry installments (idle/shutdown flush)", () => {
29802980
} as unknown as vscode.OutputChannel,
29812981
"sidebar",
29822982
new ContextProxy(mockExtensionContext),
2983-
) as any
2983+
)
29842984
mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
29852985
mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
29862986

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ vi.mock("../../../api/providers/fetchers/lmstudio", () => ({
1111
getLMStudioModels: vi.fn(),
1212
}))
1313

14+
vi.mock("../../../integrations/theme/getTheme", () => ({
15+
getTheme: vi.fn().mockResolvedValue({}),
16+
}))
17+
1418
vi.mock("../../../integrations/openai-codex/oauth", () => ({
1519
openAiCodexOAuthManager: {
1620
getAccessToken: vi.fn(),
@@ -1817,4 +1821,112 @@ describe("webviewMessageHandler - telemetrySetting", () => {
18171821
const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls
18181822
expect(calls.at(-1)).toEqual([true])
18191823
})
1824+
1825+
// CodeRabbit follow-up on the finding #12 fix: webviewDidLaunch's telemetry init read state
1826+
// via an async provider.getStateToPostToWebview().then(...) continuation, outside
1827+
// telemetrySettingQueue -- so it could resolve after a concurrent "telemetrySetting" message
1828+
// and clobber that message's queued (correct) update with a stale value. webviewDidLaunch now
1829+
// reads getGlobalState synchronously and is routed through the same queue.
1830+
it("does not let webviewDidLaunch's telemetry init race and clobber a concurrent telemetrySetting message", async () => {
1831+
const { TelemetryService } = await import("@roo-code/telemetry")
1832+
vi.mocked(TelemetryService.hasInstance).mockReturnValue(true)
1833+
vi.mocked(vscode.env).isTelemetryEnabled = true
1834+
1835+
// webviewDidLaunch starts out "unset" (disclosed opt-out default -- opted in). Scoped to
1836+
// the "telemetrySetting" key specifically -- webviewDidLaunch also calls
1837+
// updateGlobalState("customModes", ...) through the same contextProxy mock, which must
1838+
// not clobber storedSetting.
1839+
let storedSetting: string | undefined = "unset"
1840+
vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) =>
1841+
key === "telemetrySetting" ? storedSetting : undefined,
1842+
)
1843+
vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value) => {
1844+
if (key === "telemetrySetting") {
1845+
storedSetting = value as string
1846+
}
1847+
})
1848+
1849+
vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([])
1850+
const providerForLaunch = mockClineProvider as unknown as {
1851+
getMcpHub: ReturnType<typeof vi.fn>
1852+
providerSettingsManager: { listConfig: ReturnType<typeof vi.fn> }
1853+
getStateToPostToWebview: ReturnType<typeof vi.fn>
1854+
}
1855+
providerForLaunch.getMcpHub = vi.fn().mockReturnValue(undefined)
1856+
providerForLaunch.providerSettingsManager = {
1857+
listConfig: vi.fn().mockResolvedValue(undefined),
1858+
}
1859+
1860+
// Deferred-promise handshake instead of setTimeout delays, so ordering is enforced
1861+
// explicitly rather than by racing real clock delays. Signals when webviewDidLaunch has
1862+
// taken its (pre-fix) state snapshot -- only fires under the *old* code path
1863+
// (provider.getStateToPostToWebview().then(...)); the fix never calls it at all.
1864+
let snapshotTaken!: () => void
1865+
const snapshotTakenPromise = new Promise<void>((resolve) => {
1866+
snapshotTaken = resolve
1867+
})
1868+
let releaseSnapshot!: () => void
1869+
const snapshotReleased = new Promise<void>((resolve) => {
1870+
releaseSnapshot = resolve
1871+
})
1872+
1873+
// Snapshots storedSetting at call time (mirroring the real ClineProvider building its
1874+
// state object synchronously before any internal awaits), signals it was taken, then
1875+
// waits until the test explicitly releases it -- by which point the concurrent
1876+
// telemetrySetting write below has already landed, making the snapshot genuinely stale
1877+
// once its .then() callback finally runs.
1878+
providerForLaunch.getStateToPostToWebview = vi.fn().mockImplementation(async () => {
1879+
const snapshot = storedSetting
1880+
snapshotTaken()
1881+
await snapshotReleased
1882+
return { telemetrySetting: snapshot }
1883+
})
1884+
1885+
// webviewDidLaunch fires first (e.g. webview reload) -- its telemetry init is now queued
1886+
// behind telemetrySettingQueue rather than resolving independently.
1887+
const launch = webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" })
1888+
1889+
// Wait for webviewDidLaunch to either take its (pre-fix) snapshot, or flush a fixed
1890+
// number of microtask turns as a same-tick fallback for the fixed code path (which never
1891+
// triggers that signal) -- enough for its synchronous prefix (await getCustomModes(),
1892+
// await updateGlobalState()) to run, without relying on a wall-clock timer.
1893+
await Promise.race([
1894+
snapshotTakenPromise,
1895+
(async () => {
1896+
for (let i = 0; i < 10; i++) {
1897+
await Promise.resolve()
1898+
}
1899+
})(),
1900+
])
1901+
1902+
// A concurrent "telemetrySetting" message turns telemetry off, and is awaited to
1903+
// completion -- including its own updateTelemetryState(false) call -- *before* the
1904+
// deferred (pre-fix-only) snapshot below is released. Against the pre-fix code, this
1905+
// proves the snapshot it captured earlier ("unset") is genuinely stale by the time its
1906+
// .then() callback finally runs: the user's real, later choice already landed.
1907+
const disable = webviewMessageHandler(mockClineProvider, { type: "telemetrySetting", text: "disabled" })
1908+
await disable
1909+
1910+
// Now release the deferred snapshot so a getStateToPostToWebview() call, if the old code
1911+
// path is exercised, resolves (with its already-captured, now-stale value) only after
1912+
// the disable write above has fully landed.
1913+
const snapshotResolved = vi.mocked(providerForLaunch.getStateToPostToWebview).mock.results[0]?.value as
1914+
| Promise<unknown>
1915+
| undefined
1916+
releaseSnapshot()
1917+
1918+
await Promise.all([launch, snapshotResolved])
1919+
1920+
// webviewDidLaunch's telemetry init is fire-and-forget from the handler's own point of
1921+
// view (the "webviewDidLaunch" case doesn't await it), so even awaiting
1922+
// getStateToPostToWebview() directly isn't enough to observe its .then() callback --
1923+
// flush one more microtask turn for that callback to run.
1924+
await Promise.resolve()
1925+
1926+
// The user's explicit "disabled" choice must be the final state -- webviewDidLaunch's
1927+
// queued re-application of the (by-then-stale) "unset"/opted-in state must not run after
1928+
// and override it.
1929+
const calls = vi.mocked(TelemetryService.instance.updateTelemetryState).mock.calls
1930+
expect(calls.at(-1)).toEqual([false])
1931+
})
18201932
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -648,12 +648,22 @@ export const webviewMessageHandler = async (
648648
// vscode.env.isTelemetryEnabled is ANDed in (matching extension.ts's
649649
// onDidChangeTelemetryEnabled listener) so a webview reload can't re-enable
650650
// telemetry while VS Code's global toggle is off.
651-
provider.getStateToPostToWebview().then((state) => {
652-
const { telemetrySetting } = state
653-
TelemetryService.instance.updateTelemetryState(
654-
isTelemetryOptedIn(telemetrySetting) && vscode.env.isTelemetryEnabled,
655-
)
656-
})
651+
//
652+
// Read the setting synchronously via getGlobalState (same as the "telemetrySetting"
653+
// handler below) rather than awaiting provider.getStateToPostToWebview() -- that
654+
// async gap let this continuation resolve after a concurrent "telemetrySetting"
655+
// message's queued update and clobber it with a stale value, the same interleaving
656+
// class of bug telemetrySettingQueue exists to prevent. Routing through the queue
657+
// here too means webviewDidLaunch can't race a concurrent telemetrySetting message
658+
// either.
659+
telemetrySettingQueue = telemetrySettingQueue
660+
.catch(() => undefined)
661+
.then(async () => {
662+
const telemetrySetting = getGlobalState("telemetrySetting") || "unset"
663+
TelemetryService.instance.updateTelemetryState(
664+
isTelemetryOptedIn(telemetrySetting) && vscode.env.isTelemetryEnabled,
665+
)
666+
})
657667

658668
provider.isViewLaunched = true
659669
break

src/eslint-suppressions.json

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,6 @@
2424
"count": 6
2525
}
2626
},
27-
"__tests__/extension.spec.ts": {
28-
"@typescript-eslint/no-explicit-any": {
29-
"count": 2
30-
}
31-
},
3227
"__tests__/history-resume-delegation.spec.ts": {
3328
"@typescript-eslint/no-explicit-any": {
3429
"count": 72
@@ -611,7 +606,7 @@
611606
},
612607
"core/assistant-message/presentAssistantMessage.ts": {
613608
"@typescript-eslint/no-explicit-any": {
614-
"count": 7
609+
"count": 3
615610
}
616611
},
617612
"core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": {

0 commit comments

Comments
 (0)