Skip to content

Commit 5b1157c

Browse files
committed
test(vscode-e2e): cover cross-panel view state isolation
1 parent 44367f2 commit 5b1157c

6 files changed

Lines changed: 191 additions & 2 deletions

File tree

apps/vscode-e2e/fixtures/modes.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@
1313
}
1414
]
1515
}
16+
},
17+
{
18+
"match": {
19+
"userMessage": "Use the `switch_mode` tool to switch to debug mode."
20+
},
21+
"response": {
22+
"toolCalls": [
23+
{
24+
"name": "switch_mode",
25+
"arguments": "{\"mode_slug\":\"debug\",\"reason\":\"User requested to switch to debug mode.\"}",
26+
"id": "call_modes_switch_002"
27+
}
28+
]
29+
}
1630
}
1731
]
1832
}

apps/vscode-e2e/src/runTest.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { addSearchFilesResultFixtures } from "./fixtures/search-files"
2121
import { addSubtaskFixtures } from "./fixtures/subtasks"
2222
import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool"
2323
import { addWriteToFileResultFixtures } from "./fixtures/write-to-file"
24+
import { toolResultContains } from "./fixtures/tool-result"
2425

2526
function getCliFlagValue(flag: string) {
2627
return process.argv.find((arg, index) => process.argv[index - 1] === flag)
@@ -130,6 +131,36 @@ async function main() {
130131
addWriteToFileResultFixtures(mock)
131132
addDeepSeekV4Fixtures(mock)
132133

134+
mock.addFixture({
135+
match: {
136+
predicate: (req) => toolResultContains(req, "call_modes_switch_001", []),
137+
},
138+
response: {
139+
toolCalls: [
140+
{
141+
name: "attempt_completion",
142+
arguments: JSON.stringify({ result: "Switched to ❓ Ask mode as requested." }),
143+
id: "call_modes_post_switch_001",
144+
},
145+
],
146+
},
147+
})
148+
149+
mock.addFixture({
150+
match: {
151+
predicate: (req) => toolResultContains(req, "call_modes_switch_002", []),
152+
},
153+
response: {
154+
toolCalls: [
155+
{
156+
name: "attempt_completion",
157+
arguments: JSON.stringify({ result: "Switched to 🪲 Debug mode as requested." }),
158+
id: "call_modes_post_switch_002",
159+
},
160+
],
161+
},
162+
})
163+
133164
// The modes test (switch_mode → ask) triggers a second API call whose last
134165
// user message starts with <environment_details> directly — no <user_message>
135166
// wrapper. JSON fixtures use substring matching so a bare "<environment_details>"
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import * as assert from "assert"
2+
3+
import { isSecretStateKey, RooCodeEventName, type ClineMessage, type GlobalState } from "@roo-code/types"
4+
5+
import { waitUntilCompleted } from "./utils"
6+
import { setDefaultSuiteTimeout } from "./test-utils"
7+
8+
const findSecretStatePath = (value: unknown, path: string[] = []): string | undefined => {
9+
if (!value || typeof value !== "object") {
10+
return undefined
11+
}
12+
13+
for (const [key, nestedValue] of Object.entries(value)) {
14+
const nextPath = [...path, key]
15+
16+
if (isSecretStateKey(key)) {
17+
return nextPath.join(".")
18+
}
19+
20+
const nestedSecretPath = findSecretStatePath(nestedValue, nextPath)
21+
if (nestedSecretPath) {
22+
return nestedSecretPath
23+
}
24+
}
25+
26+
return undefined
27+
}
28+
29+
suite("Roo Code View State", function () {
30+
setDefaultSuiteTimeout(this)
31+
32+
teardown(async () => {
33+
try {
34+
await globalThis.api.cancelCurrentTask()
35+
} catch {
36+
// Task might not be running.
37+
}
38+
})
39+
40+
test("sidebar and tab panel keep mode isolated through the real ContextProxy singleton", async () => {
41+
const modeEvents: Array<{ taskId: string; mode: string }> = []
42+
const completionHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
43+
if (message.type === "ask" && message.ask === "completion_result") {
44+
void globalThis.api.approveTaskAsk(taskId)
45+
}
46+
}
47+
48+
globalThis.api.on(RooCodeEventName.TaskModeSwitched, (taskId, mode) => modeEvents.push({ taskId, mode }))
49+
globalThis.api.on(RooCodeEventName.Message, completionHandler)
50+
51+
try {
52+
const sidebarTaskId = await globalThis.api.startNewTask({
53+
configuration: {
54+
mode: "code",
55+
alwaysAllowModeSwitch: true,
56+
autoApprovalEnabled: true,
57+
apiKey: "sidebar-secret-must-not-persist",
58+
},
59+
text: "Use the `switch_mode` tool to switch to ask mode.",
60+
})
61+
await waitUntilCompleted({ api: globalThis.api, taskId: sidebarTaskId })
62+
63+
const tabTaskId = await globalThis.api.startNewTask({
64+
configuration: {
65+
mode: "code",
66+
alwaysAllowModeSwitch: true,
67+
autoApprovalEnabled: true,
68+
apiKey: "tab-secret-must-not-persist",
69+
},
70+
text: "Use the `switch_mode` tool to switch to debug mode.",
71+
newTab: true,
72+
})
73+
await waitUntilCompleted({ api: globalThis.api, taskId: tabTaskId })
74+
75+
// Each task's switch must be attributed to its own taskId only.
76+
assert.deepStrictEqual(
77+
modeEvents.filter((event) => event.taskId === sidebarTaskId).map((event) => event.mode),
78+
["ask"],
79+
)
80+
assert.deepStrictEqual(
81+
modeEvents.filter((event) => event.taskId === tabTaskId).map((event) => event.mode),
82+
["debug"],
83+
)
84+
85+
// The tab panel's switch must not overwrite the sidebar's own state.
86+
// api.getConfiguration() always reads the sidebar provider.
87+
assert.strictEqual(globalThis.api.getConfiguration().mode, "ask")
88+
89+
const viewStates = globalThis.api.getGlobalState("viewStates") as GlobalState["viewStates"]
90+
assert.ok(viewStates, "Expected persisted viewStates to exist")
91+
92+
const persistedEntries = Object.entries(viewStates)
93+
assert.ok(persistedEntries.length >= 2, "Expected at least sidebar and tab persisted view state entries")
94+
assert.ok(
95+
persistedEntries.some(([, entry]) => entry.mode === "ask"),
96+
"Expected one persisted view state entry for the sidebar ask mode",
97+
)
98+
assert.ok(
99+
persistedEntries.some(([, entry]) => entry.mode === "debug"),
100+
"Expected one persisted view state entry for the tab debug mode",
101+
)
102+
103+
for (const [viewStateId, entry] of persistedEntries) {
104+
const secretStatePath = findSecretStatePath(entry)
105+
assert.strictEqual(
106+
secretStatePath,
107+
undefined,
108+
`Persisted viewStates.${viewStateId} leaked secret state at ${secretStatePath}`,
109+
)
110+
}
111+
} finally {
112+
globalThis.api.off(RooCodeEventName.Message, completionHandler)
113+
}
114+
})
115+
})

packages/types/src/api.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { EventEmitter } from "events"
22
import type { Socket } from "net"
33

44
import type { RooCodeEvents } from "./events.js"
5-
import type { RooCodeSettings } from "./global-settings.js"
5+
import type { GlobalState, RooCodeSettings } from "./global-settings.js"
66
import type { HistoryItem } from "./history.js"
77
import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js"
88
import type { IpcMessage, IpcServerEvents } from "./ipc.js"
@@ -92,6 +92,10 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
9292
* confirming a completion result. No-ops if no task is active.
9393
*/
9494
approveCurrentAsk(): Promise<void>
95+
/**
96+
* Programmatically approves the pending ask for a task by ID. Intended for use in tests only.
97+
*/
98+
approveTaskAsk(taskId: string): Promise<boolean>
9599
/**
96100
* Returns true if the API is ready to use.
97101
*/
@@ -106,6 +110,10 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
106110
* @param values An object containing key-value pairs to set.
107111
*/
108112
setConfiguration(values: RooCodeSettings): Promise<void>
113+
/**
114+
* Returns a value from VS Code globalState. Intended for use in tests only.
115+
*/
116+
getGlobalState<K extends keyof GlobalState>(key: K): GlobalState[K]
109117
/**
110118
* Returns a list of all configured profile names
111119
* @returns Array of profile names

src/core/webview/ClineProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3097,7 +3097,7 @@ export class ClineProvider
30973097
}
30983098

30993099
public getValues() {
3100-
return this.contextProxy.getValues()
3100+
return { ...this.contextProxy.getValues(), ...this.viewLocalState }
31013101
}
31023102

31033103
public async setValues(values: RooCodeSettings) {

src/extension/api.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import pWaitFor from "p-wait-for"
88

99
import {
1010
type RooCodeAPI,
11+
type GlobalState,
1112
type RooCodeSettings,
1213
type RooCodeEvents,
1314
type ProviderSettings,
@@ -35,6 +36,7 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
3536
private readonly sidebarProvider: ClineProvider
3637
private readonly context: vscode.ExtensionContext
3738
private readonly ipc?: IpcServer
39+
private readonly tasksById = new Map<string, { approveAsk(): void }>()
3840
private readonly log: (...args: unknown[]) => void
3941
private logfile?: string
4042

@@ -309,6 +311,17 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
309311
this.sidebarProvider.getCurrentTask()?.approveAsk()
310312
}
311313

314+
public async approveTaskAsk(taskId: string): Promise<boolean> {
315+
const task = this.tasksById.get(taskId)
316+
317+
if (!task) {
318+
return false
319+
}
320+
321+
task.approveAsk()
322+
return true
323+
}
324+
312325
public isReady() {
313326
return this.sidebarProvider.viewLaunched
314327
}
@@ -329,6 +342,8 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
329342

330343
private registerListeners(provider: ClineProvider) {
331344
provider.on(RooCodeEventName.TaskCreated, (task) => {
345+
this.tasksById.set(task.taskId, task)
346+
332347
// Task Lifecycle
333348

334349
task.on(RooCodeEventName.TaskStarted, async () => {
@@ -340,6 +355,7 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
340355
this.emit(RooCodeEventName.TaskCompleted, task.taskId, tokenUsage, toolUsage, {
341356
isSubtask: !!task.parentTaskId,
342357
})
358+
this.tasksById.delete(task.taskId)
343359

344360
await this.fileLog(
345361
`[${new Date().toISOString()}] taskCompleted -> ${task.taskId} | ${JSON.stringify(tokenUsage, null, 2)} | ${JSON.stringify(toolUsage, null, 2)}\n`,
@@ -348,6 +364,7 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
348364

349365
task.on(RooCodeEventName.TaskAborted, () => {
350366
this.emit(RooCodeEventName.TaskAborted, task.taskId)
367+
this.tasksById.delete(task.taskId)
351368
})
352369

353370
task.on(RooCodeEventName.TaskFocused, () => {
@@ -508,6 +525,10 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
508525
await this.sidebarProvider.postStateToWebview()
509526
}
510527

528+
public getGlobalState<K extends keyof GlobalState>(key: K): GlobalState[K] {
529+
return this.context.globalState.get<GlobalState[K]>(key)
530+
}
531+
511532
public setTerminalProfile(name: string | undefined): void {
512533
const previousProfile = Terminal.getTerminalProfile()
513534
Terminal.setTerminalProfile(name)

0 commit comments

Comments
 (0)