Skip to content

Commit 5f3b044

Browse files
committed
fix: resolve orphaned partial ask messages bug and remove CLI workaround
1 parent d17b93f commit 5f3b044

File tree

5 files changed

+190
-107
lines changed

5 files changed

+190
-107
lines changed

cli/src/state/atoms/__tests__/partial-race-condition.test.ts

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -98,50 +98,6 @@ describe("Partial Race Condition Bug Fix", () => {
9898
expect(messages[0]?.text).toBe("git status")
9999
})
100100

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-
145101
it("should handle the race condition for any ask message type", () => {
146102
// Test with followup message
147103
const initialState: ExtensionState = {

cli/src/state/atoms/extension.ts

Lines changed: 1 addition & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,7 @@ export const updateExtensionStateAtom = atom(null, (get, set, state: ExtensionSt
197197
const incomingMessages = state.clineMessages || state.chatMessages || []
198198

199199
// Reconcile with current messages to preserve streaming state
200-
let reconciledMessages = reconcileMessages(currentMessages, incomingMessages, versionMap, streamingSet)
201-
202-
// Auto-complete orphaned partial ask messages (CLI-only workaround for extension bug)
203-
reconciledMessages = autoCompleteOrphanedPartialAsks(reconciledMessages)
200+
const reconciledMessages = reconcileMessages(currentMessages, incomingMessages, versionMap, streamingSet)
204201

205202
set(chatMessagesAtom, reconciledMessages)
206203

@@ -459,57 +456,6 @@ function getMessageContentLength(msg: ExtensionChatMessage): number {
459456
return length
460457
}
461458

462-
/**
463-
* Auto-complete orphaned partial ask messages (CLI-only workaround)
464-
*
465-
* This handles the extension bug where ask messages can get stuck with partial=true
466-
* when other messages (like checkpoint_saved) are added between the partial message
467-
* and its completion, causing the extension to create a new message instead of updating.
468-
*
469-
* Detection logic:
470-
* - If an ask message has partial=true
471-
* - AND there's a subsequent message with a later timestamp
472-
* - AND that subsequent message is NOT command_output (which is expected during command execution)
473-
* - THEN mark the partial ask as complete (partial=false)
474-
*
475-
* This ensures messages don't get stuck in partial state indefinitely.
476-
*/
477-
function autoCompleteOrphanedPartialAsks(messages: ExtensionChatMessage[]): ExtensionChatMessage[] {
478-
const result = [...messages]
479-
480-
for (let i = 0; i < result.length; i++) {
481-
const msg = result[i]
482-
483-
// Only process partial ask messages
484-
if (!msg || msg.type !== "ask" || !msg.partial) {
485-
continue
486-
}
487-
488-
// Check if there's a subsequent message (not command_output)
489-
let hasSubsequentMessage = false
490-
for (let j = i + 1; j < result.length; j++) {
491-
const nextMsg = result[j]
492-
if (!nextMsg) continue
493-
494-
// Skip command_output messages as they're expected during command execution
495-
if (nextMsg.ask === "command_output") {
496-
continue
497-
}
498-
499-
// Found a subsequent non-command_output message
500-
hasSubsequentMessage = true
501-
break
502-
}
503-
504-
// If there's a subsequent message, this partial ask is orphaned - mark it complete
505-
if (hasSubsequentMessage) {
506-
result[i] = { ...msg, partial: false }
507-
}
508-
}
509-
510-
return result
511-
}
512-
513459
/**
514460
* Helper function to reconcile messages from state updates with existing messages
515461
* Strategy:

src/core/task/Task.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ import { ensureLocalKilorulesDirExists } from "../context/instructions/kilo-rule
119119
import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
120120
import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
121121
import { MessageQueueService } from "../message-queue/MessageQueueService"
122+
import { findPartialAskMessage, findPartialSayMessage } from "./message-utils" // kilocode_change
122123

123124
import { AutoApprovalHandler } from "./AutoApprovalHandler"
124125
import { isAnyRecognizedKiloCodeError, isPaymentRequiredError } from "../../shared/kilocode/errorUtils"
@@ -768,10 +769,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
768769
let askTs: number
769770

770771
if (partial !== undefined) {
771-
const lastMessage = this.clineMessages.at(-1)
772+
// kilocode_change start: Fix orphaned partial asks by searching backwards
773+
// Search for the most recent partial ask of this type, handling cases where
774+
// non-interactive messages (like checkpoint_saved) are inserted during streaming
775+
const partialResult = findPartialAskMessage(this.clineMessages, type)
776+
const lastMessage = partialResult?.message
777+
// kilocode_change end
772778

773-
const isUpdatingPreviousPartial =
774-
lastMessage && lastMessage.partial && lastMessage.type === "ask" && lastMessage.ask === type
779+
const isUpdatingPreviousPartial = lastMessage !== undefined
775780

776781
if (partial) {
777782
if (isUpdatingPreviousPartial) {
@@ -1157,10 +1162,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11571162
}
11581163

11591164
if (partial !== undefined) {
1160-
const lastMessage = this.clineMessages.at(-1)
1165+
// kilocode_change start: Fix orphaned partial says by searching backwards
1166+
// Search for the most recent partial say of this type
1167+
const partialResult = findPartialSayMessage(this.clineMessages, type)
1168+
const lastMessage = partialResult?.message
1169+
// kilocode_change end
11611170

1162-
const isUpdatingPreviousPartial =
1163-
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
1171+
const isUpdatingPreviousPartial = lastMessage !== undefined
11641172

11651173
if (partial) {
11661174
if (isUpdatingPreviousPartial) {
@@ -3146,11 +3154,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
31463154
// stream.
31473155
yield* iterator
31483156

3149-
// kilocode_change start
3157+
// kilocode_change start
31503158
if (apiConfiguration?.rateLimitAfter) {
31513159
Task.lastGlobalApiRequestTime = performance.now()
31523160
}
3153-
// kilocode_change end
3161+
// kilocode_change end
31543162
}
31553163

31563164
// Checkpoints
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Tests for message utility functions
3+
* @kilocode_change - Tests for orphaned partial ask messages bug fix
4+
*/
5+
6+
import { describe, it, expect } from "vitest"
7+
import { findPartialAskMessage, findPartialSayMessage } from "../message-utils"
8+
import type { ClineMessage } from "@roo-code/types"
9+
10+
describe("findPartialAskMessage", () => {
11+
it("should find the most recent partial ask message", () => {
12+
const messages: ClineMessage[] = [
13+
{ ts: 1, type: "ask", ask: "tool", text: "test", partial: false },
14+
{ ts: 2, type: "ask", ask: "tool", text: "test", partial: true },
15+
{ ts: 3, type: "say", say: "checkpoint_saved", text: "hash123" },
16+
]
17+
18+
const result = findPartialAskMessage(messages, "tool")
19+
expect(result).toBeDefined()
20+
expect(result?.message.ts).toBe(2)
21+
expect(result?.index).toBe(1)
22+
})
23+
24+
it("should return undefined when no partial ask exists", () => {
25+
const messages: ClineMessage[] = [
26+
{ ts: 1, type: "ask", ask: "tool", text: "test", partial: false },
27+
{ ts: 2, type: "say", say: "checkpoint_saved", text: "hash123" },
28+
]
29+
30+
const result = findPartialAskMessage(messages, "tool")
31+
expect(result).toBeUndefined()
32+
})
33+
34+
it("should find the correct type when multiple ask types exist", () => {
35+
const messages: ClineMessage[] = [
36+
{ ts: 1, type: "ask", ask: "tool", text: "test", partial: true },
37+
{ ts: 2, type: "ask", ask: "command", text: "test2", partial: true },
38+
{ ts: 3, type: "say", say: "checkpoint_saved", text: "hash123" },
39+
]
40+
41+
const result = findPartialAskMessage(messages, "command")
42+
expect(result).toBeDefined()
43+
expect(result?.message.ask).toBe("command")
44+
expect(result?.message.ts).toBe(2)
45+
})
46+
47+
it("should handle multiple checkpoints between partial start and completion", () => {
48+
const messages: ClineMessage[] = [
49+
{ ts: 1, type: "ask", ask: "tool", text: "test", partial: true },
50+
{ ts: 2, type: "say", say: "checkpoint_saved", text: "hash1" },
51+
{ ts: 3, type: "say", say: "checkpoint_saved", text: "hash2" },
52+
{ ts: 4, type: "say", say: "checkpoint_saved", text: "hash3" },
53+
]
54+
55+
const result = findPartialAskMessage(messages, "tool")
56+
expect(result).toBeDefined()
57+
expect(result?.message.ts).toBe(1)
58+
expect(result?.index).toBe(0)
59+
})
60+
61+
it("should find the most recent partial when multiple partials of same type exist", () => {
62+
const messages: ClineMessage[] = [
63+
{ ts: 1, type: "ask", ask: "tool", text: "old", partial: true },
64+
{ ts: 2, type: "ask", ask: "tool", text: "new", partial: true },
65+
{ ts: 3, type: "say", say: "checkpoint_saved", text: "hash123" },
66+
]
67+
68+
const result = findPartialAskMessage(messages, "tool")
69+
expect(result).toBeDefined()
70+
expect(result?.message.text).toBe("new")
71+
expect(result?.message.ts).toBe(2)
72+
})
73+
})
74+
75+
describe("findPartialSayMessage", () => {
76+
it("should find the most recent partial say message", () => {
77+
const messages: ClineMessage[] = [
78+
{ ts: 1, type: "say", say: "text", text: "test", partial: false },
79+
{ ts: 2, type: "say", say: "text", text: "test", partial: true },
80+
{ ts: 3, type: "say", say: "checkpoint_saved", text: "hash123" },
81+
]
82+
83+
const result = findPartialSayMessage(messages, "text")
84+
expect(result).toBeDefined()
85+
expect(result?.message.ts).toBe(2)
86+
expect(result?.index).toBe(1)
87+
})
88+
89+
it("should return undefined when no partial say exists", () => {
90+
const messages: ClineMessage[] = [
91+
{ ts: 1, type: "say", say: "text", text: "test", partial: false },
92+
{ ts: 2, type: "say", say: "checkpoint_saved", text: "hash123" },
93+
]
94+
95+
const result = findPartialSayMessage(messages, "text")
96+
expect(result).toBeUndefined()
97+
})
98+
99+
it("should find the correct type when multiple say types exist", () => {
100+
const messages: ClineMessage[] = [
101+
{ ts: 1, type: "say", say: "text", text: "test", partial: true },
102+
{ ts: 2, type: "say", say: "reasoning", text: "thinking", partial: true },
103+
{ ts: 3, type: "say", say: "checkpoint_saved", text: "hash123" },
104+
]
105+
106+
const result = findPartialSayMessage(messages, "reasoning")
107+
expect(result).toBeDefined()
108+
expect(result?.message.say).toBe("reasoning")
109+
expect(result?.message.ts).toBe(2)
110+
})
111+
112+
it("should handle multiple checkpoints between partial start and completion", () => {
113+
const messages: ClineMessage[] = [
114+
{ ts: 1, type: "say", say: "text", text: "test", partial: true },
115+
{ ts: 2, type: "say", say: "checkpoint_saved", text: "hash1" },
116+
{ ts: 3, type: "say", say: "checkpoint_saved", text: "hash2" },
117+
]
118+
119+
const result = findPartialSayMessage(messages, "text")
120+
expect(result).toBeDefined()
121+
expect(result?.message.ts).toBe(1)
122+
expect(result?.index).toBe(0)
123+
})
124+
})

src/core/task/message-utils.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Utility functions for message handling in Task
3+
* @kilocode_change - Created to fix orphaned partial ask messages bug
4+
*/
5+
6+
import type { ClineMessage, ClineAsk, ClineSay } from "@roo-code/types"
7+
8+
/**
9+
* Search backwards through messages to find the most recent partial ask message
10+
* of the specified type. This handles cases where non-interactive messages
11+
* (like checkpoint_saved) are inserted between partial start and completion.
12+
*
13+
* @param messages - Array of Cline messages to search
14+
* @param type - The ask type to search for
15+
* @returns The partial message and its index, or undefined if not found
16+
*/
17+
export function findPartialAskMessage(
18+
messages: ClineMessage[],
19+
type: ClineAsk,
20+
): { message: ClineMessage; index: number } | undefined {
21+
for (let i = messages.length - 1; i >= 0; i--) {
22+
const msg = messages[i]
23+
if (msg.type === "ask" && msg.ask === type && msg.partial === true) {
24+
return { message: msg, index: i }
25+
}
26+
}
27+
return undefined
28+
}
29+
30+
/**
31+
* Search backwards through messages to find the most recent partial say message
32+
* of the specified type. Similar to findPartialAskMessage but for say messages.
33+
*
34+
* @param messages - Array of Cline messages to search
35+
* @param type - The say type to search for
36+
* @returns The partial message and its index, or undefined if not found
37+
*/
38+
export function findPartialSayMessage(
39+
messages: ClineMessage[],
40+
type: ClineSay,
41+
): { message: ClineMessage; index: number } | undefined {
42+
for (let i = messages.length - 1; i >= 0; i--) {
43+
const msg = messages[i]
44+
if (msg.type === "say" && msg.say === type && msg.partial === true) {
45+
return { message: msg, index: i }
46+
}
47+
}
48+
return undefined
49+
}

0 commit comments

Comments
 (0)