Skip to content

Commit 47fc2ff

Browse files
committed
fix(webview): announce aborted mode switches only while their commit is current
1 parent 3c454e5 commit 47fc2ff

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

‎src/core/webview/ClineProvider.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2478,7 +2478,13 @@ export class ClineProvider
24782478
}
24792479

24802480
// The switch has committed: notify listeners only now, so a reconciled failure
2481-
// never leaves listeners tracking a mode the stores rolled back from.
2481+
// never leaves listeners tracking a mode the stores rolled back from. After an
2482+
// abort the mutation queue has advanced: announce only while this switch's
2483+
// committed mode is still current - a superseded commit stays silent because
2484+
// the newer mutation already announced its own mode.
2485+
if (signal?.aborted && this.getValues().mode !== newMode) {
2486+
return
2487+
}
24822488
if (viewScopedSwitch) {
24832489
this.emit(RooCodeEventName.ModeChanged, newMode)
24842490
}

‎src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,6 +1252,59 @@ describe("ClineProvider - Sticky Mode", () => {
12521252
expect(postSpy).toHaveBeenCalled()
12531253
})
12541254

1255+
it("stays silent when a newer mutation supersedes the aborted switch", async () => {
1256+
// Same typed double as the activation-failure rollback test above.
1257+
const mockTask = Object.assign(
1258+
{} as Task,
1259+
{
1260+
taskId: "test-task-id",
1261+
taskMode: "code",
1262+
_taskMode: "code",
1263+
emit: vi.fn(),
1264+
saveClineMessages: vi.fn(),
1265+
clineMessages: [],
1266+
apiConversationHistory: [],
1267+
updateApiConfiguration: vi.fn(),
1268+
} as Partial<Task>,
1269+
)
1270+
const historyItem: HistoryItem = {
1271+
id: "test-task-id",
1272+
ts: Date.now(),
1273+
task: "Test task",
1274+
mode: "code",
1275+
number: 1,
1276+
tokensIn: 0,
1277+
tokensOut: 0,
1278+
cacheWrites: 0,
1279+
cacheReads: 0,
1280+
totalCost: 0,
1281+
}
1282+
vi.spyOn(provider.taskHistoryStore, "get").mockReturnValue(historyItem)
1283+
vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([])
1284+
await provider.saveViewState("mode", "code")
1285+
await provider.contextProxy.setValue("mode", "code")
1286+
1287+
// The abort lands after this switch's mode writes, and a newer mutation
1288+
// immediately moves the mode stores to its own mode: this switch's commit
1289+
// is superseded, so announcing it would clobber the newer owner - the
1290+
// emit/post tail must stay silent.
1291+
const emitSpy = vi.spyOn(provider, "emit")
1292+
const postSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined)
1293+
const controller = new AbortController()
1294+
vi.spyOn(provider.providerSettingsManager, "getModeConfigId").mockImplementation(async () => {
1295+
controller.abort()
1296+
await provider.setValue("mode", "ask")
1297+
return undefined
1298+
})
1299+
vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([])
1300+
await provider.addClineToStack(mockTask)
1301+
1302+
await provider["handleModeSwitchUnlocked"]("architect", mockTask, controller.signal)
1303+
1304+
expect(provider.getValue("mode")).toBe("ask")
1305+
expect(emitSpy).not.toHaveBeenCalledWith("modeChanged", "architect")
1306+
expect(postSpy).not.toHaveBeenCalled()
1307+
})
12551308
it("keeps the cancellation result when the abort rollback write itself fails", async () => {
12561309
const mockTask = Object.assign(
12571310
{} as Task,

0 commit comments

Comments
 (0)