From 51858f38a226c01f1540388385c023a38c147d85 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 15 Dec 2025 17:21:16 -0700 Subject: [PATCH 1/3] fix: prevent race condition from deleting wrong API messages during message deletion When deleting a user message from chat history, assistant API messages were incorrectly removed due to a timestamp race condition. During async tool execution, a clineMessage (e.g., user_feedback) could be created BEFORE the assistant API message was added to history. The fix finds the first API user message at or after the cutoff timestamp and uses that as the actual boundary, ensuring assistant messages that logically preceded the user's response are preserved. Added 4 test cases covering: - Race condition scenario - Normal timestamp ordering - Fallback when no user message found - Multiple assistant messages in race condition --- src/core/message-manager/index.spec.ts | 121 +++++++++++++++++++++++++ src/core/message-manager/index.ts | 43 ++++++++- 2 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/core/message-manager/index.spec.ts b/src/core/message-manager/index.spec.ts index 84ee10e8956..e2c11db3b7e 100644 --- a/src/core/message-manager/index.spec.ts +++ b/src/core/message-manager/index.spec.ts @@ -728,4 +728,125 @@ describe("MessageManager", () => { expect(apiCall[0].role).toBe("system") }) }) + + describe("Race condition handling", () => { + it("should preserve assistant message when clineMessage timestamp is earlier due to async execution", async () => { + // This test reproduces the bug where deleting a user_feedback clineMessage + // incorrectly removes an assistant API message that was added AFTER the + // clineMessage (due to async tool execution during streaming). + // + // Timeline (race condition scenario): + // - T1 (100): clineMessage "user_feedback" created during tool execution + // - T2 (200): assistant API message added when stream completes + // - T3 (300): user API message (tool_result) added after pWaitFor + // + // When deleting the clineMessage at T1, we should: + // - Keep the assistant message at T2 + // - Remove the user message at T3 + + mockTask.clineMessages = [ + { ts: 50, say: "user", text: "Initial request" }, + { ts: 100, say: "user_feedback", text: "tell me a joke 3" }, // Race: created BEFORE assistant API msg + ] + + mockTask.apiConversationHistory = [ + { ts: 50, role: "user", content: [{ type: "text", text: "Initial request" }] }, + { + ts: 200, // Race: added AFTER clineMessage at ts=100 + role: "assistant", + content: [{ type: "tool_use", id: "tool_1", name: "attempt_completion", input: {} }], + }, + { + ts: 300, + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool_1", content: "tell me a joke 3" }], + }, + ] + + // Delete the user_feedback clineMessage at ts=100 + await manager.rewindToTimestamp(100) + + // The fix ensures we find the first API user message at or after cutoff (ts=300) + // and use that as the actual cutoff, preserving the assistant message (ts=200) + const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0] + expect(apiCall).toHaveLength(2) + expect(apiCall[0].ts).toBe(50) // Initial user message preserved + expect(apiCall[1].ts).toBe(200) // Assistant message preserved (was incorrectly removed before fix) + expect(apiCall[1].role).toBe("assistant") + }) + + it("should handle normal case where timestamps are properly ordered", async () => { + // Normal case: clineMessage timestamp aligns with API message timestamp + mockTask.clineMessages = [ + { ts: 100, say: "user", text: "First" }, + { ts: 200, say: "user_feedback", text: "Feedback" }, + ] + + mockTask.apiConversationHistory = [ + { ts: 100, role: "user", content: [{ type: "text", text: "First" }] }, + { ts: 150, role: "assistant", content: [{ type: "text", text: "Response" }] }, + { ts: 200, role: "user", content: [{ type: "text", text: "Feedback" }] }, + ] + + await manager.rewindToTimestamp(200) + + // Should keep messages before the user message at ts=200 + const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0] + expect(apiCall).toHaveLength(2) + expect(apiCall[0].ts).toBe(100) + expect(apiCall[1].ts).toBe(150) + }) + + it("should fall back to original cutoff when no user message found at or after cutoff", async () => { + // Edge case: no API user message at or after the cutoff timestamp + mockTask.clineMessages = [ + { ts: 100, say: "user", text: "First" }, + { ts: 200, say: "assistant", text: "Response" }, + { ts: 300, say: "assistant", text: "Another response" }, + ] + + mockTask.apiConversationHistory = [ + { ts: 100, role: "user", content: [{ type: "text", text: "First" }] }, + { ts: 150, role: "assistant", content: [{ type: "text", text: "Response" }] }, + { ts: 250, role: "assistant", content: [{ type: "text", text: "Another response" }] }, + // No user message at or after ts=200 + ] + + await manager.rewindToTimestamp(200) + + // Falls back to original behavior: keep messages with ts < 200 + // This removes the assistant message at ts=250 + const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0] + expect(apiCall).toHaveLength(2) + expect(apiCall[0].ts).toBe(100) + expect(apiCall[1].ts).toBe(150) + }) + + it("should handle multiple assistant messages before the user message in race condition", async () => { + // Complex race scenario: multiple assistant messages added before user message + mockTask.clineMessages = [ + { ts: 50, say: "user", text: "Initial" }, + { ts: 100, say: "user_feedback", text: "Feedback" }, // Race condition + ] + + mockTask.apiConversationHistory = [ + { ts: 50, role: "user", content: [{ type: "text", text: "Initial" }] }, + { ts: 150, role: "assistant", content: [{ type: "text", text: "First assistant msg" }] }, // After clineMessage + { ts: 200, role: "assistant", content: [{ type: "text", text: "Second assistant msg" }] }, // After clineMessage + { ts: 250, role: "user", content: [{ type: "text", text: "Feedback" }] }, + ] + + await manager.rewindToTimestamp(100) + + // Should preserve both assistant messages (ts=150, ts=200) because the first + // user message at or after cutoff is at ts=250 + const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0] + expect(apiCall).toHaveLength(3) + expect(apiCall[0].ts).toBe(50) + expect(apiCall[1].ts).toBe(150) + expect(apiCall[1].role).toBe("assistant") + expect(apiCall[2].ts).toBe(200) + expect(apiCall[2].role).toBe("assistant") + }) + }) }) diff --git a/src/core/message-manager/index.ts b/src/core/message-manager/index.ts index b78aa95d681..e35f290c398 100644 --- a/src/core/message-manager/index.ts +++ b/src/core/message-manager/index.ts @@ -133,6 +133,14 @@ export class MessageManager { * 1. Avoids multiple writes to API history * 2. Only writes if the history actually changed * 3. Handles both truncation and cleanup atomically + * + * Note on timestamp handling: + * Due to async execution during streaming, clineMessage timestamps may not + * perfectly align with API message timestamps. Specifically, a "user_feedback" + * clineMessage can have a timestamp BEFORE the assistant API message that + * triggered it (because tool execution happens concurrently with stream + * completion). To handle this race condition, we find the first API user + * message at or after the cutoff and use its timestamp as the actual boundary. */ private async truncateApiHistoryWithCleanup( cutoffTs: number, @@ -142,10 +150,35 @@ export class MessageManager { const originalHistory = this.task.apiConversationHistory let apiHistory = [...originalHistory] - // Step 1: Filter by timestamp - apiHistory = apiHistory.filter((m) => !m.ts || m.ts < cutoffTs) + // Step 1: Determine the actual cutoff timestamp + // Check if there's an API message with an exact timestamp match + const hasExactMatch = apiHistory.some((m) => m.ts === cutoffTs) + // Check if there are any messages before the cutoff that would be preserved + const hasMessageBeforeCutoff = apiHistory.some((m) => m.ts !== undefined && m.ts < cutoffTs) + + let actualCutoff: number = cutoffTs + + if (!hasExactMatch && hasMessageBeforeCutoff) { + // No exact match but there are earlier messages means we might have a race + // condition where the clineMessage timestamp is earlier than any API message + // due to async execution. In this case, look for the first API user message + // at or after the cutoff to use as the actual boundary. + // This ensures assistant messages that preceded the user's response are preserved. + const firstUserMsgIndexToRemove = apiHistory.findIndex( + (m) => m.ts !== undefined && m.ts >= cutoffTs && m.role === "user", + ) + + if (firstUserMsgIndexToRemove !== -1) { + // Use the user message's timestamp as the actual cutoff + actualCutoff = apiHistory[firstUserMsgIndexToRemove].ts! + } + // else: no user message found, use original cutoffTs (fallback) + } + + // Step 2: Filter by the actual cutoff timestamp + apiHistory = apiHistory.filter((m) => !m.ts || m.ts < actualCutoff) - // Step 2: Remove Summaries whose condense_context was removed + // Step 3: Remove Summaries whose condense_context was removed if (removedIds.condenseIds.size > 0) { apiHistory = apiHistory.filter((msg) => { if (msg.isSummary && msg.condenseId && removedIds.condenseIds.has(msg.condenseId)) { @@ -156,7 +189,7 @@ export class MessageManager { }) } - // Step 3: Remove truncation markers whose sliding_window_truncation was removed + // Step 4: Remove truncation markers whose sliding_window_truncation was removed if (removedIds.truncationIds.size > 0) { apiHistory = apiHistory.filter((msg) => { if (msg.isTruncationMarker && msg.truncationId && removedIds.truncationIds.has(msg.truncationId)) { @@ -169,7 +202,7 @@ export class MessageManager { }) } - // Step 4: Cleanup orphaned tags (unless skipped) + // Step 5: Cleanup orphaned tags (unless skipped) if (!skipCleanup) { apiHistory = cleanupAfterTruncation(apiHistory) } From acc5567c7caf6995bdd730a04dfea0f46fd41cfe Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 15 Dec 2025 19:50:05 -0500 Subject: [PATCH 2/3] fix: handle inverse race condition where API user message is before clineMessage Adds handling for a second race condition scenario where the API user message (tool_result) timestamp is BEFORE the clineMessage timestamp. This can happen when the tool_result is logged before the user_feedback clineMessage due to timing. The fix: 1. Detects the inverse race pattern by finding the last assistant message before the cutoff and checking if there are user messages between that assistant and the cutoff 2. When detected, uses the last assistant timestamp + 1 as the cutoff to ensure we keep the assistant response but remove the user message that belongs to the turn being deleted Added test case demonstrating the inverse race condition scenario. --- src/core/message-manager/index.spec.ts | 56 ++++++++++++++++ src/core/message-manager/index.ts | 89 ++++++++++++++++++++------ 2 files changed, 127 insertions(+), 18 deletions(-) diff --git a/src/core/message-manager/index.spec.ts b/src/core/message-manager/index.spec.ts index e2c11db3b7e..185b7797adc 100644 --- a/src/core/message-manager/index.spec.ts +++ b/src/core/message-manager/index.spec.ts @@ -848,5 +848,61 @@ describe("MessageManager", () => { expect(apiCall[2].ts).toBe(200) expect(apiCall[2].role).toBe("assistant") }) + + it("should handle inverse race condition where user API message is BEFORE clineMessage timestamp", async () => { + // This test covers the edge case where the API user message (tool_result) + // is logged BEFORE the clineMessage timestamp due to inverse timing. + // + // Timeline (inverse race condition scenario): + // - T1 (50): API user (initial request) - Turn 1 + // - T2 (100): API assistant (response) - Turn 1 + // - T3 (120): API user (tool_result for Turn 2, logged early due to race) - Turn 2, SHOULD DELETE + // - T4 (150): clineMessage "user_feedback" being deleted - Turn 2 + // - T5 (180): API assistant (response to Turn 2) - Turn 2, SHOULD DELETE + // - T6 (250): API user (Turn 3) - Turn 3, SHOULD KEEP + // + // When deleting the clineMessage at T4=150, we should: + // - Keep Turn 1 (ts=50, ts=100) + // - Remove Turn 2's API user at ts=120 (even though it's before clineMessage!) + // - Remove Turn 2's assistant at ts=180 + // - Keep Turn 3 (ts=250) + // + // The current fix only looks for user messages at or AFTER cutoff, + // so it would incorrectly skip the user at ts=120 and find ts=250 instead. + + mockTask.clineMessages = [ + { ts: 50, say: "user", text: "Initial request" }, + { ts: 150, say: "user_feedback", text: "Turn 2 feedback" }, // Being deleted + { ts: 250, say: "user", text: "Turn 3" }, + ] + + mockTask.apiConversationHistory = [ + { ts: 50, role: "user", content: [{ type: "text", text: "Initial request" }] }, + { ts: 100, role: "assistant", content: [{ type: "text", text: "Response 1" }] }, + { + ts: 120, // Inverse race: logged BEFORE clineMessage at ts=150! + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool_1", content: "Turn 2 feedback" }], + }, + { + ts: 180, // After clineMessage + role: "assistant", + content: [{ type: "text", text: "Response to Turn 2" }], + }, + { ts: 250, role: "user", content: [{ type: "text", text: "Turn 3" }] }, + ] + + // Delete the user_feedback clineMessage at ts=150 + await manager.rewindToTimestamp(150) + + // Expected: Keep Turn 1 (ts=50, ts=100), remove Turn 2 (ts=120, ts=180) + // The API user at ts=120 should be removed even though it's before the clineMessage + const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0] + expect(apiCall).toHaveLength(2) + expect(apiCall[0].ts).toBe(50) // Turn 1 user - kept + expect(apiCall[1].ts).toBe(100) // Turn 1 assistant - kept + // ts=120 (Turn 2 user) should be removed + // ts=180 (Turn 2 assistant) should be removed + }) }) }) diff --git a/src/core/message-manager/index.ts b/src/core/message-manager/index.ts index e35f290c398..f6180426d13 100644 --- a/src/core/message-manager/index.ts +++ b/src/core/message-manager/index.ts @@ -136,11 +136,15 @@ export class MessageManager { * * Note on timestamp handling: * Due to async execution during streaming, clineMessage timestamps may not - * perfectly align with API message timestamps. Specifically, a "user_feedback" - * clineMessage can have a timestamp BEFORE the assistant API message that - * triggered it (because tool execution happens concurrently with stream - * completion). To handle this race condition, we find the first API user - * message at or after the cutoff and use its timestamp as the actual boundary. + * perfectly align with API message timestamps. There are two race condition scenarios: + * + * 1. Original race: clineMessage timestamp is BEFORE the assistant API message + * (tool execution happens concurrently with stream completion) + * Solution: Find the first API user message at or after the cutoff + * + * 2. Inverse race: API user message (tool_result) timestamp is BEFORE the clineMessage + * (the tool_result was logged before the user_feedback clineMessage) + * Solution: Find the last assistant message before cutoff and use that as boundary */ private async truncateApiHistoryWithCleanup( cutoffTs: number, @@ -159,20 +163,35 @@ export class MessageManager { let actualCutoff: number = cutoffTs if (!hasExactMatch && hasMessageBeforeCutoff) { - // No exact match but there are earlier messages means we might have a race - // condition where the clineMessage timestamp is earlier than any API message - // due to async execution. In this case, look for the first API user message - // at or after the cutoff to use as the actual boundary. - // This ensures assistant messages that preceded the user's response are preserved. - const firstUserMsgIndexToRemove = apiHistory.findIndex( - (m) => m.ts !== undefined && m.ts >= cutoffTs && m.role === "user", - ) - - if (firstUserMsgIndexToRemove !== -1) { - // Use the user message's timestamp as the actual cutoff - actualCutoff = apiHistory[firstUserMsgIndexToRemove].ts! + // No exact match but there are earlier messages - check for race conditions + // + // First, check for "inverse race" pattern: + // Find the last assistant message before cutoff + const lastAssistantBeforeCutoff = this.findLastAssistantBeforeCutoff(apiHistory, cutoffTs) + + if (lastAssistantBeforeCutoff !== undefined) { + // Check if there are user messages between the last assistant and cutoff + // This indicates an "inverse race" where the user's tool_result was logged + // before the clineMessage timestamp + const hasUserBetweenAssistantAndCutoff = apiHistory.some( + (m) => + m.ts !== undefined && m.ts > lastAssistantBeforeCutoff && m.ts < cutoffTs && m.role === "user", + ) + + if (hasUserBetweenAssistantAndCutoff) { + // Inverse race detected: use the assistant timestamp + 1 as cutoff + // This ensures we keep the assistant response but remove the user + // message that belongs to the turn being deleted + actualCutoff = lastAssistantBeforeCutoff + 1 + } else { + // No inverse race, check for original race condition + // Look for the first API user message at or after the cutoff + actualCutoff = this.findFirstUserCutoff(apiHistory, cutoffTs) + } + } else { + // No assistant before cutoff, use original race condition logic + actualCutoff = this.findFirstUserCutoff(apiHistory, cutoffTs) } - // else: no user message found, use original cutoffTs (fallback) } // Step 2: Filter by the actual cutoff timestamp @@ -215,4 +234,38 @@ export class MessageManager { await this.task.overwriteApiConversationHistory(apiHistory) } } + + /** + * Find the timestamp of the last assistant message before the cutoff. + * Returns undefined if no assistant message exists before cutoff. + */ + private findLastAssistantBeforeCutoff(apiHistory: ApiMessage[], cutoffTs: number): number | undefined { + let lastAssistantTs: number | undefined + + for (const msg of apiHistory) { + if (msg.ts !== undefined && msg.ts < cutoffTs && msg.role === "assistant") { + if (lastAssistantTs === undefined || msg.ts > lastAssistantTs) { + lastAssistantTs = msg.ts + } + } + } + + return lastAssistantTs + } + + /** + * Find the cutoff based on the first user message at or after the original cutoff. + * Falls back to the original cutoff if no user message is found. + */ + private findFirstUserCutoff(apiHistory: ApiMessage[], cutoffTs: number): number { + const firstUserMsgIndex = apiHistory.findIndex( + (m) => m.ts !== undefined && m.ts >= cutoffTs && m.role === "user", + ) + + if (firstUserMsgIndex !== -1) { + return apiHistory[firstUserMsgIndex].ts! + } + + return cutoffTs + } } From f6096b173da711ff73188ddbcaf1622bf64ef216 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 15 Dec 2025 20:10:21 -0500 Subject: [PATCH 3/3] Revert "fix: handle inverse race condition where API user message is before clineMessage" This reverts commit acc5567c7caf6995bdd730a04dfea0f46fd41cfe. --- src/core/message-manager/index.spec.ts | 56 ---------------- src/core/message-manager/index.ts | 89 ++++++-------------------- 2 files changed, 18 insertions(+), 127 deletions(-) diff --git a/src/core/message-manager/index.spec.ts b/src/core/message-manager/index.spec.ts index 185b7797adc..e2c11db3b7e 100644 --- a/src/core/message-manager/index.spec.ts +++ b/src/core/message-manager/index.spec.ts @@ -848,61 +848,5 @@ describe("MessageManager", () => { expect(apiCall[2].ts).toBe(200) expect(apiCall[2].role).toBe("assistant") }) - - it("should handle inverse race condition where user API message is BEFORE clineMessage timestamp", async () => { - // This test covers the edge case where the API user message (tool_result) - // is logged BEFORE the clineMessage timestamp due to inverse timing. - // - // Timeline (inverse race condition scenario): - // - T1 (50): API user (initial request) - Turn 1 - // - T2 (100): API assistant (response) - Turn 1 - // - T3 (120): API user (tool_result for Turn 2, logged early due to race) - Turn 2, SHOULD DELETE - // - T4 (150): clineMessage "user_feedback" being deleted - Turn 2 - // - T5 (180): API assistant (response to Turn 2) - Turn 2, SHOULD DELETE - // - T6 (250): API user (Turn 3) - Turn 3, SHOULD KEEP - // - // When deleting the clineMessage at T4=150, we should: - // - Keep Turn 1 (ts=50, ts=100) - // - Remove Turn 2's API user at ts=120 (even though it's before clineMessage!) - // - Remove Turn 2's assistant at ts=180 - // - Keep Turn 3 (ts=250) - // - // The current fix only looks for user messages at or AFTER cutoff, - // so it would incorrectly skip the user at ts=120 and find ts=250 instead. - - mockTask.clineMessages = [ - { ts: 50, say: "user", text: "Initial request" }, - { ts: 150, say: "user_feedback", text: "Turn 2 feedback" }, // Being deleted - { ts: 250, say: "user", text: "Turn 3" }, - ] - - mockTask.apiConversationHistory = [ - { ts: 50, role: "user", content: [{ type: "text", text: "Initial request" }] }, - { ts: 100, role: "assistant", content: [{ type: "text", text: "Response 1" }] }, - { - ts: 120, // Inverse race: logged BEFORE clineMessage at ts=150! - role: "user", - content: [{ type: "tool_result", tool_use_id: "tool_1", content: "Turn 2 feedback" }], - }, - { - ts: 180, // After clineMessage - role: "assistant", - content: [{ type: "text", text: "Response to Turn 2" }], - }, - { ts: 250, role: "user", content: [{ type: "text", text: "Turn 3" }] }, - ] - - // Delete the user_feedback clineMessage at ts=150 - await manager.rewindToTimestamp(150) - - // Expected: Keep Turn 1 (ts=50, ts=100), remove Turn 2 (ts=120, ts=180) - // The API user at ts=120 should be removed even though it's before the clineMessage - const apiCall = mockTask.overwriteApiConversationHistory.mock.calls[0][0] - expect(apiCall).toHaveLength(2) - expect(apiCall[0].ts).toBe(50) // Turn 1 user - kept - expect(apiCall[1].ts).toBe(100) // Turn 1 assistant - kept - // ts=120 (Turn 2 user) should be removed - // ts=180 (Turn 2 assistant) should be removed - }) }) }) diff --git a/src/core/message-manager/index.ts b/src/core/message-manager/index.ts index f6180426d13..e35f290c398 100644 --- a/src/core/message-manager/index.ts +++ b/src/core/message-manager/index.ts @@ -136,15 +136,11 @@ export class MessageManager { * * Note on timestamp handling: * Due to async execution during streaming, clineMessage timestamps may not - * perfectly align with API message timestamps. There are two race condition scenarios: - * - * 1. Original race: clineMessage timestamp is BEFORE the assistant API message - * (tool execution happens concurrently with stream completion) - * Solution: Find the first API user message at or after the cutoff - * - * 2. Inverse race: API user message (tool_result) timestamp is BEFORE the clineMessage - * (the tool_result was logged before the user_feedback clineMessage) - * Solution: Find the last assistant message before cutoff and use that as boundary + * perfectly align with API message timestamps. Specifically, a "user_feedback" + * clineMessage can have a timestamp BEFORE the assistant API message that + * triggered it (because tool execution happens concurrently with stream + * completion). To handle this race condition, we find the first API user + * message at or after the cutoff and use its timestamp as the actual boundary. */ private async truncateApiHistoryWithCleanup( cutoffTs: number, @@ -163,35 +159,20 @@ export class MessageManager { let actualCutoff: number = cutoffTs if (!hasExactMatch && hasMessageBeforeCutoff) { - // No exact match but there are earlier messages - check for race conditions - // - // First, check for "inverse race" pattern: - // Find the last assistant message before cutoff - const lastAssistantBeforeCutoff = this.findLastAssistantBeforeCutoff(apiHistory, cutoffTs) - - if (lastAssistantBeforeCutoff !== undefined) { - // Check if there are user messages between the last assistant and cutoff - // This indicates an "inverse race" where the user's tool_result was logged - // before the clineMessage timestamp - const hasUserBetweenAssistantAndCutoff = apiHistory.some( - (m) => - m.ts !== undefined && m.ts > lastAssistantBeforeCutoff && m.ts < cutoffTs && m.role === "user", - ) - - if (hasUserBetweenAssistantAndCutoff) { - // Inverse race detected: use the assistant timestamp + 1 as cutoff - // This ensures we keep the assistant response but remove the user - // message that belongs to the turn being deleted - actualCutoff = lastAssistantBeforeCutoff + 1 - } else { - // No inverse race, check for original race condition - // Look for the first API user message at or after the cutoff - actualCutoff = this.findFirstUserCutoff(apiHistory, cutoffTs) - } - } else { - // No assistant before cutoff, use original race condition logic - actualCutoff = this.findFirstUserCutoff(apiHistory, cutoffTs) + // No exact match but there are earlier messages means we might have a race + // condition where the clineMessage timestamp is earlier than any API message + // due to async execution. In this case, look for the first API user message + // at or after the cutoff to use as the actual boundary. + // This ensures assistant messages that preceded the user's response are preserved. + const firstUserMsgIndexToRemove = apiHistory.findIndex( + (m) => m.ts !== undefined && m.ts >= cutoffTs && m.role === "user", + ) + + if (firstUserMsgIndexToRemove !== -1) { + // Use the user message's timestamp as the actual cutoff + actualCutoff = apiHistory[firstUserMsgIndexToRemove].ts! } + // else: no user message found, use original cutoffTs (fallback) } // Step 2: Filter by the actual cutoff timestamp @@ -234,38 +215,4 @@ export class MessageManager { await this.task.overwriteApiConversationHistory(apiHistory) } } - - /** - * Find the timestamp of the last assistant message before the cutoff. - * Returns undefined if no assistant message exists before cutoff. - */ - private findLastAssistantBeforeCutoff(apiHistory: ApiMessage[], cutoffTs: number): number | undefined { - let lastAssistantTs: number | undefined - - for (const msg of apiHistory) { - if (msg.ts !== undefined && msg.ts < cutoffTs && msg.role === "assistant") { - if (lastAssistantTs === undefined || msg.ts > lastAssistantTs) { - lastAssistantTs = msg.ts - } - } - } - - return lastAssistantTs - } - - /** - * Find the cutoff based on the first user message at or after the original cutoff. - * Falls back to the original cutoff if no user message is found. - */ - private findFirstUserCutoff(apiHistory: ApiMessage[], cutoffTs: number): number { - const firstUserMsgIndex = apiHistory.findIndex( - (m) => m.ts !== undefined && m.ts >= cutoffTs && m.role === "user", - ) - - if (firstUserMsgIndex !== -1) { - return apiHistory[firstUserMsgIndex].ts! - } - - return cutoffTs - } }