Skip to content

Commit 3673b22

Browse files
authored
Merge pull request #3260 from Kilo-Org/catrielmuller/cli-improve-approval-monitor
CLI - Improve/Fix Approval Monitor
2 parents e97f994 + f1a6290 commit 3673b22

File tree

5 files changed

+395
-19
lines changed

5 files changed

+395
-19
lines changed

.changeset/forty-laws-argue.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@kilocode/cli": patch
3+
---
4+
5+
Improved stability of the approval menu, preventing it from showing when you don't expect it
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/**
2+
* Test for the specific race condition bug where command messages
3+
* with partial=true never transition to partial=false
4+
*
5+
* Bug scenario:
6+
* 1. Extension sends messageUpdated with partial=false
7+
* 2. Extension sends state update with stale partial=true version
8+
* 3. CLI should keep the partial=false version, not revert to partial=true
9+
*/
10+
11+
import { describe, it, expect, beforeEach } from "vitest"
12+
import { createStore } from "jotai"
13+
import type { ExtensionChatMessage, ExtensionState } from "../../../types/messages.js"
14+
import {
15+
chatMessagesAtom,
16+
updateExtensionStateAtom,
17+
updateChatMessageByTsAtom,
18+
streamingMessagesSetAtom,
19+
} from "../extension.js"
20+
21+
describe("Partial Race Condition Bug Fix", () => {
22+
let store: ReturnType<typeof createStore>
23+
24+
beforeEach(() => {
25+
store = createStore()
26+
})
27+
28+
it("should not revert partial=false to partial=true when stale state arrives", () => {
29+
// Step 1: Initial state with a command message (partial=true)
30+
const initialState: ExtensionState = {
31+
version: "1.0.0",
32+
apiConfiguration: {},
33+
chatMessages: [
34+
{
35+
ts: 1761239614416,
36+
type: "ask",
37+
ask: "command",
38+
text: "git status",
39+
partial: true,
40+
},
41+
],
42+
mode: "code",
43+
customModes: [],
44+
taskHistoryFullLength: 0,
45+
taskHistoryVersion: 0,
46+
renderContext: "cli",
47+
telemetrySetting: "disabled",
48+
}
49+
50+
store.set(updateExtensionStateAtom, initialState)
51+
52+
// Verify message is tracked as streaming
53+
expect(store.get(streamingMessagesSetAtom).has(1761239614416)).toBe(true)
54+
expect(store.get(chatMessagesAtom)[0]?.partial).toBe(true)
55+
56+
// Step 2: Extension sends messageUpdated with partial=false (command complete)
57+
const completedMessage: ExtensionChatMessage = {
58+
ts: 1761239614416,
59+
type: "ask",
60+
ask: "command",
61+
text: "git status",
62+
partial: false,
63+
}
64+
65+
store.set(updateChatMessageByTsAtom, completedMessage)
66+
67+
// Verify message is now complete
68+
expect(store.get(chatMessagesAtom)[0]?.partial).toBe(false)
69+
expect(store.get(streamingMessagesSetAtom).has(1761239614416)).toBe(false)
70+
71+
// Step 3: Stale state update arrives with partial=true (race condition)
72+
const staleState: ExtensionState = {
73+
version: "1.0.0",
74+
apiConfiguration: {},
75+
chatMessages: [
76+
{
77+
ts: 1761239614416,
78+
type: "ask",
79+
ask: "command",
80+
text: "git status",
81+
partial: true, // Stale partial flag
82+
},
83+
],
84+
mode: "code",
85+
customModes: [],
86+
taskHistoryFullLength: 0,
87+
taskHistoryVersion: 0,
88+
renderContext: "cli",
89+
telemetrySetting: "disabled",
90+
}
91+
92+
store.set(updateExtensionStateAtom, staleState)
93+
94+
// BUG FIX: Message should remain partial=false, not revert to partial=true
95+
const messages = store.get(chatMessagesAtom)
96+
expect(messages).toHaveLength(1)
97+
expect(messages[0]?.partial).toBe(false) // Should stay false!
98+
expect(messages[0]?.text).toBe("git status")
99+
})
100+
101+
it("should auto-complete orphaned partial ask when subsequent messages arrive (CLI workaround)", () => {
102+
// Simulate the extension bug: partial ask message followed by other messages
103+
// without ever sending a partial=false update
104+
const stateWithOrphanedPartial: ExtensionState = {
105+
version: "1.0.0",
106+
apiConfiguration: {},
107+
chatMessages: [
108+
{
109+
ts: 1000,
110+
type: "ask",
111+
ask: "command",
112+
text: "git status",
113+
partial: true, // Orphaned - never completed by extension
114+
},
115+
{
116+
ts: 2000,
117+
type: "say",
118+
say: "checkpoint_saved",
119+
text: "abc123",
120+
},
121+
{
122+
ts: 3000,
123+
type: "say",
124+
say: "text",
125+
text: "Command executed",
126+
},
127+
],
128+
mode: "code",
129+
customModes: [],
130+
taskHistoryFullLength: 0,
131+
taskHistoryVersion: 0,
132+
renderContext: "cli",
133+
telemetrySetting: "disabled",
134+
}
135+
136+
store.set(updateExtensionStateAtom, stateWithOrphanedPartial)
137+
138+
// CLI should auto-complete the orphaned partial ask
139+
const messages = store.get(chatMessagesAtom)
140+
expect(messages).toHaveLength(3)
141+
expect(messages[0]?.partial).toBe(false) // Auto-completed!
142+
expect(messages[0]?.ask).toBe("command")
143+
})
144+
145+
it("should handle the race condition for any ask message type", () => {
146+
// Test with followup message
147+
const initialState: ExtensionState = {
148+
version: "1.0.0",
149+
apiConfiguration: {},
150+
chatMessages: [
151+
{
152+
ts: 2000,
153+
type: "ask",
154+
ask: "followup",
155+
text: '{"question":"What should I do?"}',
156+
partial: true,
157+
},
158+
],
159+
mode: "code",
160+
customModes: [],
161+
taskHistoryFullLength: 0,
162+
taskHistoryVersion: 0,
163+
renderContext: "cli",
164+
telemetrySetting: "disabled",
165+
}
166+
167+
store.set(updateExtensionStateAtom, initialState)
168+
169+
// Complete the message via messageUpdated
170+
const completedMessage: ExtensionChatMessage = {
171+
ts: 2000,
172+
type: "ask",
173+
ask: "followup",
174+
text: '{"question":"What should I do?","suggest":[{"answer":"Option 1"},{"answer":"Option 2"}]}',
175+
partial: false,
176+
}
177+
178+
store.set(updateChatMessageByTsAtom, completedMessage)
179+
expect(store.get(chatMessagesAtom)[0]?.partial).toBe(false)
180+
181+
// Stale state arrives
182+
const staleState: ExtensionState = {
183+
version: "1.0.0",
184+
apiConfiguration: {},
185+
chatMessages: [
186+
{
187+
ts: 2000,
188+
type: "ask",
189+
ask: "followup",
190+
text: '{"question":"What should I do?"}',
191+
partial: true,
192+
},
193+
],
194+
mode: "code",
195+
customModes: [],
196+
taskHistoryFullLength: 0,
197+
taskHistoryVersion: 0,
198+
renderContext: "cli",
199+
telemetrySetting: "disabled",
200+
}
201+
202+
store.set(updateExtensionStateAtom, staleState)
203+
204+
// Should keep the completed version
205+
const messages = store.get(chatMessagesAtom)
206+
expect(messages[0]?.partial).toBe(false)
207+
expect(messages[0]?.text).toContain("suggest")
208+
})
209+
210+
it("should still allow legitimate partial updates during active streaming", () => {
211+
// Initial state
212+
const initialState: ExtensionState = {
213+
version: "1.0.0",
214+
apiConfiguration: {},
215+
chatMessages: [
216+
{
217+
ts: 3000,
218+
type: "ask",
219+
ask: "command",
220+
text: "npm",
221+
partial: true,
222+
},
223+
],
224+
mode: "code",
225+
customModes: [],
226+
taskHistoryFullLength: 0,
227+
taskHistoryVersion: 0,
228+
renderContext: "cli",
229+
telemetrySetting: "disabled",
230+
}
231+
232+
store.set(updateExtensionStateAtom, initialState)
233+
234+
// Streaming update with more content
235+
const streamingUpdate: ExtensionChatMessage = {
236+
ts: 3000,
237+
type: "ask",
238+
ask: "command",
239+
text: "npm install",
240+
partial: true,
241+
}
242+
243+
store.set(updateChatMessageByTsAtom, streamingUpdate)
244+
expect(store.get(chatMessagesAtom)[0]?.text).toBe("npm install")
245+
expect(store.get(chatMessagesAtom)[0]?.partial).toBe(true)
246+
247+
// Another streaming update with even more content
248+
const streamingUpdate2: ExtensionChatMessage = {
249+
ts: 3000,
250+
type: "ask",
251+
ask: "command",
252+
text: "npm install express",
253+
partial: true,
254+
}
255+
256+
store.set(updateChatMessageByTsAtom, streamingUpdate2)
257+
expect(store.get(chatMessagesAtom)[0]?.text).toBe("npm install express")
258+
expect(store.get(chatMessagesAtom)[0]?.partial).toBe(true)
259+
260+
// State update with shorter content should be rejected (streaming protection)
261+
const staleState: ExtensionState = {
262+
version: "1.0.0",
263+
apiConfiguration: {},
264+
chatMessages: [
265+
{
266+
ts: 3000,
267+
type: "ask",
268+
ask: "command",
269+
text: "npm",
270+
partial: false,
271+
},
272+
],
273+
mode: "code",
274+
customModes: [],
275+
taskHistoryFullLength: 0,
276+
taskHistoryVersion: 0,
277+
renderContext: "cli",
278+
telemetrySetting: "disabled",
279+
}
280+
281+
store.set(updateExtensionStateAtom, staleState)
282+
283+
// Should keep the longer streaming version
284+
expect(store.get(chatMessagesAtom)[0]?.text).toBe("npm install express")
285+
expect(store.get(chatMessagesAtom)[0]?.partial).toBe(true)
286+
})
287+
})

cli/src/state/atoms/approval.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,17 +216,16 @@ export const setPendingApprovalAtom = atom(null, (get, set, message: ExtensionCh
216216
if (message?.isAnswered) {
217217
return
218218
}
219+
// Get the current pending message to check if this is a new message or an update
220+
const current = get(pendingApprovalAtom)
221+
const isNewMessage = !current || current.ts !== message?.ts
219222

220223
// Always create a new object reference to force Jotai to re-evaluate dependent atoms
221224
// This is critical for streaming messages that update from partial to complete
222225
// and ensures approvalOptionsAtom recalculates when message content changes
223226
const messageToSet = message ? { ...message } : null
224227
set(pendingApprovalAtom, messageToSet)
225228

226-
// Get the current pending message to check if this is a new message or an update
227-
const current = get(pendingApprovalAtom)
228-
const isNewMessage = !current || current.ts !== message?.ts
229-
230229
// Reset selection if this is a new message (different timestamp)
231230
if (isNewMessage) {
232231
set(selectedIndexAtom, 0)

0 commit comments

Comments
 (0)