Skip to content
56 changes: 20 additions & 36 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1332,19 +1332,11 @@ describe("ClineProvider", () => {
text: "Edited message content",
})

// Verify correct messages were kept (only messages before the edited one)
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([
mockMessages[0],
mockMessages[1],
mockMessages[2],
])
// Verify correct messages were kept - delete from the preceding user message to truly replace it
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([])

// Verify correct API messages were kept (only messages before the edited one)
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([
mockApiHistory[0],
mockApiHistory[1],
mockApiHistory[2],
])
// Verify correct API messages were kept
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([])

// The new flow calls webviewMessageHandler recursively with askResponse
// We need to verify the recursive call happened by checking if the handler was called again
Expand Down Expand Up @@ -3016,7 +3008,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }, { ts: 3000 }] as any[]
mockCline.overwriteClineMessages = vi.fn()
mockCline.overwriteApiConversationHistory = vi.fn()
mockCline.handleWebviewAskResponse = vi.fn()
mockCline.submitUserMessage = vi.fn()

await provider.addClineToStack(mockCline)
;(provider as any).getTaskWithId = vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -3046,9 +3038,11 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
text: "Edited message with preserved images",
})

// Verify messages were edited correctly - messages up to the edited message should remain
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0], mockMessages[1]])
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }, { ts: 2000 }])
// Verify messages were edited correctly - the ORIGINAL user message and all subsequent messages are removed
expect(mockCline.overwriteClineMessages).toHaveBeenCalledWith([mockMessages[0]])
expect(mockCline.overwriteApiConversationHistory).toHaveBeenCalledWith([{ ts: 1000 }])
// Verify submitUserMessage was called with the edited content
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with preserved images", undefined)
})

test("handles editing messages with file attachments", async () => {
Expand All @@ -3070,7 +3064,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }, { ts: 3000 }] as any[]
mockCline.overwriteClineMessages = vi.fn()
mockCline.overwriteApiConversationHistory = vi.fn()
mockCline.handleWebviewAskResponse = vi.fn()
mockCline.submitUserMessage = vi.fn()

await provider.addClineToStack(mockCline)
;(provider as any).getTaskWithId = vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -3101,11 +3095,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
})

expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
expect(mockCline.handleWebviewAskResponse).toHaveBeenCalledWith(
"messageResponse",
"Edited message with file attachment",
undefined,
)
expect(mockCline.submitUserMessage).toHaveBeenCalledWith("Edited message with file attachment", undefined)
})
})

Expand Down Expand Up @@ -3197,7 +3187,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: "Edited message" })

// The error should be caught and shown
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Error editing message: Connection lost")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.message.error_editing_message")
})
})

Expand Down Expand Up @@ -3320,7 +3310,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
text: "Edited message",
})

expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Error editing message: Unauthorized")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.message.error_editing_message")
})

describe("Malformed Requests and Invalid Formats", () => {
Expand Down Expand Up @@ -3544,7 +3534,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {

// Verify cleanup was attempted before failure
expect(cleanupSpy).toHaveBeenCalled()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Error editing message: Operation failed")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.message.error_editing_message")
})

test("validates proper cleanup during failed delete operations", async () => {
Expand Down Expand Up @@ -3584,9 +3574,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {

// Verify cleanup was attempted before failure
expect(cleanupSpy).toHaveBeenCalled()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Error deleting message: Delete operation failed",
)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.message.error_deleting_message")
})
})

Expand All @@ -3609,7 +3597,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
mockCline.apiConversationHistory = [{ ts: 1000 }, { ts: 2000 }] as any[]
mockCline.overwriteClineMessages = vi.fn()
mockCline.overwriteApiConversationHistory = vi.fn()
mockCline.handleWebviewAskResponse = vi.fn()
mockCline.submitUserMessage = vi.fn()

await provider.addClineToStack(mockCline)
;(provider as any).getTaskWithId = vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -3638,11 +3626,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
await messageHandler({ type: "editMessageConfirm", messageTs: 2000, text: largeEditedContent })

expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
expect(mockCline.handleWebviewAskResponse).toHaveBeenCalledWith(
"messageResponse",
largeEditedContent,
undefined,
)
expect(mockCline.submitUserMessage).toHaveBeenCalledWith(largeEditedContent, undefined)
})

test("handles deleting messages with large payloads", async () => {
Expand Down Expand Up @@ -3822,7 +3806,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {
] as any[]
mockCline.overwriteClineMessages = vi.fn()
mockCline.overwriteApiConversationHistory = vi.fn()
mockCline.handleWebviewAskResponse = vi.fn()
mockCline.submitUserMessage = vi.fn()

await provider.addClineToStack(mockCline)
;(provider as any).getTaskWithId = vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -3855,7 +3839,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => {

// Should handle future timestamps correctly
expect(mockCline.overwriteClineMessages).toHaveBeenCalled()
expect(mockCline.handleWebviewAskResponse).toHaveBeenCalled()
expect(mockCline.submitUserMessage).toHaveBeenCalled()
})
})
})
Expand Down
245 changes: 245 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { describe, it, expect, beforeEach, vi } from "vitest"
import { webviewMessageHandler } from "../webviewMessageHandler"
import * as vscode from "vscode"
import { ClineProvider } from "../ClineProvider"

// Mock the saveTaskMessages function
vi.mock("../../task-persistence", () => ({
saveTaskMessages: vi.fn(),
}))

// Mock the i18n module
vi.mock("../../../i18n", () => ({
t: vi.fn((key: string) => key),
changeLanguage: vi.fn(),
}))

vi.mock("vscode", () => ({
window: {
showErrorMessage: vi.fn(),
showWarningMessage: vi.fn(),
showInformationMessage: vi.fn(),
},
workspace: {
workspaceFolders: undefined,
getConfiguration: vi.fn(() => ({
get: vi.fn(),
update: vi.fn(),
})),
},
ConfigurationTarget: {
Global: 1,
Workspace: 2,
WorkspaceFolder: 3,
},
Uri: {
parse: vi.fn((str) => ({ toString: () => str })),
file: vi.fn((path) => ({ fsPath: path })),
},
env: {
openExternal: vi.fn(),
clipboard: {
writeText: vi.fn(),
},
},
commands: {
executeCommand: vi.fn(),
},
}))

describe("webviewMessageHandler delete functionality", () => {
let provider: any
let getCurrentTaskMock: any

beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()

// Create mock task
getCurrentTaskMock = {
clineMessages: [],
apiConversationHistory: [],
overwriteClineMessages: vi.fn(async () => {}),
overwriteApiConversationHistory: vi.fn(async () => {}),
taskId: "test-task-id",
}

// Create mock provider
provider = {
getCurrentTask: vi.fn(() => getCurrentTaskMock),
postMessageToWebview: vi.fn(),
contextProxy: {
getValue: vi.fn(),
setValue: vi.fn(async () => {}),
globalStorageUri: { fsPath: "/test/path" },
},
log: vi.fn(),
cwd: "/test/cwd",
}
})

describe("handleDeleteMessageConfirm", () => {
it("should handle deletion when apiConversationHistoryIndex is -1 (message not in API history)", async () => {
// Setup test data with a user message and assistant response
const userMessageTs = 1000
const assistantMessageTs = 1001

getCurrentTaskMock.clineMessages = [
{ ts: userMessageTs, say: "user", text: "Hello" },
{ ts: assistantMessageTs, say: "assistant", text: "Hi there" },
]

// API history has the assistant message but not the user message
// This simulates the case where the user message wasn't in API history
getCurrentTaskMock.apiConversationHistory = [
{ ts: assistantMessageTs, role: "assistant", content: { type: "text", text: "Hi there" } },
{
ts: 1002,
role: "assistant",
content: { type: "text", text: "attempt_completion" },
name: "attempt_completion",
},
]

// Call delete for the user message
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: userMessageTs,
})

// Verify that clineMessages was truncated at the correct index
expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledWith([])

// When message is not found in API history (index is -1),
// API history should be truncated from the first API message at/after the deleted timestamp (fallback)
expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalledWith([])
})

it("should handle deletion when exact apiConversationHistoryIndex is found", async () => {
// Setup test data where message exists in both arrays
const messageTs = 1000

getCurrentTaskMock.clineMessages = [
{ ts: 900, say: "user", text: "Previous message" },
{ ts: messageTs, say: "user", text: "Delete this" },
{ ts: 1100, say: "assistant", text: "Response" },
]

getCurrentTaskMock.apiConversationHistory = [
{ ts: 900, role: "user", content: { type: "text", text: "Previous message" } },
{ ts: messageTs, role: "user", content: { type: "text", text: "Delete this" } },
{ ts: 1100, role: "assistant", content: { type: "text", text: "Response" } },
]

// Call delete
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: messageTs,
})

// Verify truncation at correct indices
expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledWith([
{ ts: 900, say: "user", text: "Previous message" },
])

expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalledWith([
{ ts: 900, role: "user", content: { type: "text", text: "Previous message" } },
])
})

it("should handle deletion when message not found in clineMessages", async () => {
getCurrentTaskMock.clineMessages = [{ ts: 1000, say: "user", text: "Some message" }]

getCurrentTaskMock.apiConversationHistory = []

// Call delete with non-existent timestamp
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: 9999,
})

// Verify error message was shown (expecting translation key since t() is mocked to return the key)
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.message.message_not_found")

// Verify no truncation occurred
expect(getCurrentTaskMock.overwriteClineMessages).not.toHaveBeenCalled()
expect(getCurrentTaskMock.overwriteApiConversationHistory).not.toHaveBeenCalled()
})

it("should handle deletion with attempt_completion in API history", async () => {
// Setup test data with attempt_completion
const userMessageTs = 1000
const attemptCompletionTs = 1001

getCurrentTaskMock.clineMessages = [
{ ts: userMessageTs, say: "user", text: "Fix the bug" },
{ ts: attemptCompletionTs, say: "assistant", text: "I've fixed the bug" },
]

// API history has attempt_completion but user message is missing
getCurrentTaskMock.apiConversationHistory = [
{
ts: attemptCompletionTs,
role: "assistant",
content: {
type: "text",
text: "I've fixed the bug in the code",
},
name: "attempt_completion",
},
{
ts: 1002,
role: "user",
content: { type: "text", text: "Looks good, but..." },
},
]

// Call delete for the user message
await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: userMessageTs,
})

// Verify that clineMessages was truncated
expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledWith([])

// API history should be truncated from first message at/after deleted timestamp (fallback)
expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalledWith([])
})

it("should preserve messages before the deleted one", async () => {
const messageTs = 2000

getCurrentTaskMock.clineMessages = [
{ ts: 1000, say: "user", text: "First message" },
{ ts: 1500, say: "assistant", text: "First response" },
{ ts: messageTs, say: "user", text: "Delete this" },
{ ts: 2500, say: "assistant", text: "Response to delete" },
]

getCurrentTaskMock.apiConversationHistory = [
{ ts: 1000, role: "user", content: { type: "text", text: "First message" } },
{ ts: 1500, role: "assistant", content: { type: "text", text: "First response" } },
{ ts: messageTs, role: "user", content: { type: "text", text: "Delete this" } },
{ ts: 2500, role: "assistant", content: { type: "text", text: "Response to delete" } },
]

await webviewMessageHandler(provider, {
type: "deleteMessageConfirm",
messageTs: messageTs,
})

// Should preserve messages before the deleted one
expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledWith([
{ ts: 1000, say: "user", text: "First message" },
{ ts: 1500, say: "assistant", text: "First response" },
])

// API history should be truncated at the exact index
expect(getCurrentTaskMock.overwriteApiConversationHistory).toHaveBeenCalledWith([
{ ts: 1000, role: "user", content: { type: "text", text: "First message" } },
{ ts: 1500, role: "assistant", content: { type: "text", text: "First response" } },
])
})
})
})
Loading
Loading