Skip to content

Commit 69307ba

Browse files
fix: prevent race condition from deleting wrong API messages (#10113)
Co-authored-by: daniel-lxs <[email protected]>
1 parent ef3c88c commit 69307ba

File tree

2 files changed

+159
-5
lines changed

2 files changed

+159
-5
lines changed

src/core/message-manager/index.spec.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,4 +728,125 @@ describe("MessageManager", () => {
728728
expect(apiCall[0].role).toBe("system")
729729
})
730730
})
731+
732+
describe("Race condition handling", () => {
733+
it("should preserve assistant message when clineMessage timestamp is earlier due to async execution", async () => {
734+
// This test reproduces the bug where deleting a user_feedback clineMessage
735+
// incorrectly removes an assistant API message that was added AFTER the
736+
// clineMessage (due to async tool execution during streaming).
737+
//
738+
// Timeline (race condition scenario):
739+
// - T1 (100): clineMessage "user_feedback" created during tool execution
740+
// - T2 (200): assistant API message added when stream completes
741+
// - T3 (300): user API message (tool_result) added after pWaitFor
742+
//
743+
// When deleting the clineMessage at T1, we should:
744+
// - Keep the assistant message at T2
745+
// - Remove the user message at T3
746+
747+
mockTask.clineMessages = [
748+
{ ts: 50, say: "user", text: "Initial request" },
749+
{ ts: 100, say: "user_feedback", text: "tell me a joke 3" }, // Race: created BEFORE assistant API msg
750+
]
751+
752+
mockTask.apiConversationHistory = [
753+
{ ts: 50, role: "user", content: [{ type: "text", text: "Initial request" }] },
754+
{
755+
ts: 200, // Race: added AFTER clineMessage at ts=100
756+
role: "assistant",
757+
content: [{ type: "tool_use", id: "tool_1", name: "attempt_completion", input: {} }],
758+
},
759+
{
760+
ts: 300,
761+
role: "user",
762+
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "tell me a joke 3" }],
763+
},
764+
]
765+
766+
// Delete the user_feedback clineMessage at ts=100
767+
await manager.rewindToTimestamp(100)
768+
769+
// The fix ensures we find the first API user message at or after cutoff (ts=300)
770+
// and use that as the actual cutoff, preserving the assistant message (ts=200)
771+
const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0]
772+
expect(apiCall).toHaveLength(2)
773+
expect(apiCall[0].ts).toBe(50) // Initial user message preserved
774+
expect(apiCall[1].ts).toBe(200) // Assistant message preserved (was incorrectly removed before fix)
775+
expect(apiCall[1].role).toBe("assistant")
776+
})
777+
778+
it("should handle normal case where timestamps are properly ordered", async () => {
779+
// Normal case: clineMessage timestamp aligns with API message timestamp
780+
mockTask.clineMessages = [
781+
{ ts: 100, say: "user", text: "First" },
782+
{ ts: 200, say: "user_feedback", text: "Feedback" },
783+
]
784+
785+
mockTask.apiConversationHistory = [
786+
{ ts: 100, role: "user", content: [{ type: "text", text: "First" }] },
787+
{ ts: 150, role: "assistant", content: [{ type: "text", text: "Response" }] },
788+
{ ts: 200, role: "user", content: [{ type: "text", text: "Feedback" }] },
789+
]
790+
791+
await manager.rewindToTimestamp(200)
792+
793+
// Should keep messages before the user message at ts=200
794+
const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0]
795+
expect(apiCall).toHaveLength(2)
796+
expect(apiCall[0].ts).toBe(100)
797+
expect(apiCall[1].ts).toBe(150)
798+
})
799+
800+
it("should fall back to original cutoff when no user message found at or after cutoff", async () => {
801+
// Edge case: no API user message at or after the cutoff timestamp
802+
mockTask.clineMessages = [
803+
{ ts: 100, say: "user", text: "First" },
804+
{ ts: 200, say: "assistant", text: "Response" },
805+
{ ts: 300, say: "assistant", text: "Another response" },
806+
]
807+
808+
mockTask.apiConversationHistory = [
809+
{ ts: 100, role: "user", content: [{ type: "text", text: "First" }] },
810+
{ ts: 150, role: "assistant", content: [{ type: "text", text: "Response" }] },
811+
{ ts: 250, role: "assistant", content: [{ type: "text", text: "Another response" }] },
812+
// No user message at or after ts=200
813+
]
814+
815+
await manager.rewindToTimestamp(200)
816+
817+
// Falls back to original behavior: keep messages with ts < 200
818+
// This removes the assistant message at ts=250
819+
const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0]
820+
expect(apiCall).toHaveLength(2)
821+
expect(apiCall[0].ts).toBe(100)
822+
expect(apiCall[1].ts).toBe(150)
823+
})
824+
825+
it("should handle multiple assistant messages before the user message in race condition", async () => {
826+
// Complex race scenario: multiple assistant messages added before user message
827+
mockTask.clineMessages = [
828+
{ ts: 50, say: "user", text: "Initial" },
829+
{ ts: 100, say: "user_feedback", text: "Feedback" }, // Race condition
830+
]
831+
832+
mockTask.apiConversationHistory = [
833+
{ ts: 50, role: "user", content: [{ type: "text", text: "Initial" }] },
834+
{ ts: 150, role: "assistant", content: [{ type: "text", text: "First assistant msg" }] }, // After clineMessage
835+
{ ts: 200, role: "assistant", content: [{ type: "text", text: "Second assistant msg" }] }, // After clineMessage
836+
{ ts: 250, role: "user", content: [{ type: "text", text: "Feedback" }] },
837+
]
838+
839+
await manager.rewindToTimestamp(100)
840+
841+
// Should preserve both assistant messages (ts=150, ts=200) because the first
842+
// user message at or after cutoff is at ts=250
843+
const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0]
844+
expect(apiCall).toHaveLength(3)
845+
expect(apiCall[0].ts).toBe(50)
846+
expect(apiCall[1].ts).toBe(150)
847+
expect(apiCall[1].role).toBe("assistant")
848+
expect(apiCall[2].ts).toBe(200)
849+
expect(apiCall[2].role).toBe("assistant")
850+
})
851+
})
731852
})

src/core/message-manager/index.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ export class MessageManager {
133133
* 1. Avoids multiple writes to API history
134134
* 2. Only writes if the history actually changed
135135
* 3. Handles both truncation and cleanup atomically
136+
*
137+
* Note on timestamp handling:
138+
* Due to async execution during streaming, clineMessage timestamps may not
139+
* perfectly align with API message timestamps. Specifically, a "user_feedback"
140+
* clineMessage can have a timestamp BEFORE the assistant API message that
141+
* triggered it (because tool execution happens concurrently with stream
142+
* completion). To handle this race condition, we find the first API user
143+
* message at or after the cutoff and use its timestamp as the actual boundary.
136144
*/
137145
private async truncateApiHistoryWithCleanup(
138146
cutoffTs: number,
@@ -142,10 +150,35 @@ export class MessageManager {
142150
const originalHistory = this.task.apiConversationHistory
143151
let apiHistory = [...originalHistory]
144152

145-
// Step 1: Filter by timestamp
146-
apiHistory = apiHistory.filter((m) => !m.ts || m.ts < cutoffTs)
153+
// Step 1: Determine the actual cutoff timestamp
154+
// Check if there's an API message with an exact timestamp match
155+
const hasExactMatch = apiHistory.some((m) => m.ts === cutoffTs)
156+
// Check if there are any messages before the cutoff that would be preserved
157+
const hasMessageBeforeCutoff = apiHistory.some((m) => m.ts !== undefined && m.ts < cutoffTs)
158+
159+
let actualCutoff: number = cutoffTs
160+
161+
if (!hasExactMatch && hasMessageBeforeCutoff) {
162+
// No exact match but there are earlier messages means we might have a race
163+
// condition where the clineMessage timestamp is earlier than any API message
164+
// due to async execution. In this case, look for the first API user message
165+
// at or after the cutoff to use as the actual boundary.
166+
// This ensures assistant messages that preceded the user's response are preserved.
167+
const firstUserMsgIndexToRemove = apiHistory.findIndex(
168+
(m) => m.ts !== undefined && m.ts >= cutoffTs && m.role === "user",
169+
)
170+
171+
if (firstUserMsgIndexToRemove !== -1) {
172+
// Use the user message's timestamp as the actual cutoff
173+
actualCutoff = apiHistory[firstUserMsgIndexToRemove].ts!
174+
}
175+
// else: no user message found, use original cutoffTs (fallback)
176+
}
177+
178+
// Step 2: Filter by the actual cutoff timestamp
179+
apiHistory = apiHistory.filter((m) => !m.ts || m.ts < actualCutoff)
147180

148-
// Step 2: Remove Summaries whose condense_context was removed
181+
// Step 3: Remove Summaries whose condense_context was removed
149182
if (removedIds.condenseIds.size > 0) {
150183
apiHistory = apiHistory.filter((msg) => {
151184
if (msg.isSummary && msg.condenseId && removedIds.condenseIds.has(msg.condenseId)) {
@@ -156,7 +189,7 @@ export class MessageManager {
156189
})
157190
}
158191

159-
// Step 3: Remove truncation markers whose sliding_window_truncation was removed
192+
// Step 4: Remove truncation markers whose sliding_window_truncation was removed
160193
if (removedIds.truncationIds.size > 0) {
161194
apiHistory = apiHistory.filter((msg) => {
162195
if (msg.isTruncationMarker && msg.truncationId && removedIds.truncationIds.has(msg.truncationId)) {
@@ -169,7 +202,7 @@ export class MessageManager {
169202
})
170203
}
171204

172-
// Step 4: Cleanup orphaned tags (unless skipped)
205+
// Step 5: Cleanup orphaned tags (unless skipped)
173206
if (!skipCleanup) {
174207
apiHistory = cleanupAfterTruncation(apiHistory)
175208
}

0 commit comments

Comments
 (0)