webviewMessageHandler.edit.spec.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import type { Mock } from "vitest"
  2. import { describe, it, expect, vi, beforeEach } from "vitest"
  3. // Mock dependencies first
  4. vi.mock("vscode", () => ({
  5. window: {
  6. showWarningMessage: vi.fn(),
  7. showErrorMessage: vi.fn(),
  8. },
  9. workspace: {
  10. workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
  11. getConfiguration: vi.fn().mockReturnValue({
  12. get: vi.fn(),
  13. update: vi.fn(),
  14. }),
  15. },
  16. Uri: {
  17. file: vi.fn((path) => ({ fsPath: path })),
  18. },
  19. env: {
  20. uriScheme: "vscode",
  21. },
  22. }))
  23. vi.mock("../../task-persistence", () => ({
  24. saveTaskMessages: vi.fn(),
  25. }))
  26. vi.mock("../../../api/providers/fetchers/modelCache", () => ({
  27. getModels: vi.fn(),
  28. flushModels: vi.fn(),
  29. }))
  30. vi.mock("../checkpointRestoreHandler", () => ({
  31. handleCheckpointRestoreOperation: vi.fn(),
  32. }))
  33. // Import after mocks
  34. import { webviewMessageHandler } from "../webviewMessageHandler"
  35. import type { ClineProvider } from "../ClineProvider"
  36. import type { ClineMessage } from "@roo-code/types"
  37. import type { ApiMessage } from "../../task-persistence/apiMessages"
  38. import { MessageManager } from "../../message-manager"
  39. describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => {
  40. let mockClineProvider: ClineProvider
  41. let mockCurrentTask: any
  42. beforeEach(() => {
  43. vi.clearAllMocks()
  44. // Create a mock task with messages
  45. mockCurrentTask = {
  46. taskId: "test-task-id",
  47. clineMessages: [] as ClineMessage[],
  48. apiConversationHistory: [] as ApiMessage[],
  49. overwriteClineMessages: vi.fn(),
  50. overwriteApiConversationHistory: vi.fn(),
  51. handleWebviewAskResponse: vi.fn(),
  52. }
  53. mockCurrentTask.messageManager = new MessageManager(mockCurrentTask)
  54. // Create mock provider
  55. mockClineProvider = {
  56. getCurrentTask: vi.fn().mockReturnValue(mockCurrentTask),
  57. postMessageToWebview: vi.fn(),
  58. contextProxy: {
  59. getValue: vi.fn(),
  60. setValue: vi.fn(),
  61. globalStorageUri: { fsPath: "/mock/storage" },
  62. },
  63. log: vi.fn(),
  64. } as unknown as ClineProvider
  65. })
  66. it("should not modify API history when apiConversationHistoryIndex is -1", async () => {
  67. // Setup: User message followed by attempt_completion
  68. const userMessageTs = 1000
  69. const assistantMessageTs = 2000
  70. const completionMessageTs = 3000
  71. // UI messages (clineMessages)
  72. mockCurrentTask.clineMessages = [
  73. {
  74. ts: userMessageTs,
  75. type: "say",
  76. say: "user_feedback",
  77. text: "Hello",
  78. } as ClineMessage,
  79. {
  80. ts: completionMessageTs,
  81. type: "say",
  82. say: "completion_result",
  83. text: "Task Completed!",
  84. } as ClineMessage,
  85. ]
  86. // API conversation history - note the user message is missing (common scenario after condense)
  87. mockCurrentTask.apiConversationHistory = [
  88. {
  89. ts: assistantMessageTs,
  90. role: "assistant",
  91. content: [
  92. {
  93. type: "text",
  94. text: "I'll help you with that.",
  95. },
  96. ],
  97. },
  98. {
  99. ts: completionMessageTs,
  100. role: "assistant",
  101. content: [
  102. {
  103. type: "tool_use",
  104. name: "attempt_completion",
  105. id: "tool-1",
  106. input: {
  107. result: "Task Completed!",
  108. },
  109. },
  110. ],
  111. },
  112. ] as ApiMessage[]
  113. // Trigger edit confirmation
  114. await webviewMessageHandler(mockClineProvider, {
  115. type: "editMessageConfirm",
  116. messageTs: userMessageTs,
  117. text: "Hello World", // edited content
  118. restoreCheckpoint: false,
  119. })
  120. // Verify that UI messages were truncated at the correct index
  121. expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith(
  122. [], // All messages before index 0 (empty array)
  123. )
  124. // API history should be truncated from first message at/after edited timestamp (fallback)
  125. expect(mockCurrentTask.overwriteApiConversationHistory).toHaveBeenCalledWith([])
  126. })
  127. it("should preserve messages before the edited message when message not in API history", async () => {
  128. const earlierMessageTs = 500
  129. const userMessageTs = 1000
  130. const assistantMessageTs = 2000
  131. // UI messages
  132. mockCurrentTask.clineMessages = [
  133. {
  134. ts: earlierMessageTs,
  135. type: "say",
  136. say: "user_feedback",
  137. text: "Earlier message",
  138. } as ClineMessage,
  139. {
  140. ts: userMessageTs,
  141. type: "say",
  142. say: "user_feedback",
  143. text: "Hello",
  144. } as ClineMessage,
  145. {
  146. ts: assistantMessageTs,
  147. type: "say",
  148. say: "text",
  149. text: "Response",
  150. } as ClineMessage,
  151. ]
  152. // API history - missing the exact user message at ts=1000
  153. mockCurrentTask.apiConversationHistory = [
  154. {
  155. ts: earlierMessageTs,
  156. role: "user",
  157. content: [{ type: "text", text: "Earlier message" }],
  158. },
  159. {
  160. ts: assistantMessageTs,
  161. role: "assistant",
  162. content: [{ type: "text", text: "Response" }],
  163. },
  164. ] as ApiMessage[]
  165. await webviewMessageHandler(mockClineProvider, {
  166. type: "editMessageConfirm",
  167. messageTs: userMessageTs,
  168. text: "Hello World",
  169. restoreCheckpoint: false,
  170. })
  171. // Verify UI messages were truncated to preserve earlier message
  172. expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([
  173. {
  174. ts: earlierMessageTs,
  175. type: "say",
  176. say: "user_feedback",
  177. text: "Earlier message",
  178. },
  179. ])
  180. // API history should be truncated from the first API message at/after the edited timestamp (fallback)
  181. expect(mockCurrentTask.overwriteApiConversationHistory).toHaveBeenCalledWith([
  182. {
  183. ts: earlierMessageTs,
  184. role: "user",
  185. content: [{ type: "text", text: "Earlier message" }],
  186. },
  187. ])
  188. })
  189. it("should not use fallback when exact apiConversationHistoryIndex is found", async () => {
  190. const userMessageTs = 1000
  191. const assistantMessageTs = 2000
  192. // Both UI and API have the message at the same timestamp
  193. mockCurrentTask.clineMessages = [
  194. {
  195. ts: userMessageTs,
  196. type: "say",
  197. say: "user_feedback",
  198. text: "Hello",
  199. } as ClineMessage,
  200. {
  201. ts: assistantMessageTs,
  202. type: "say",
  203. say: "text",
  204. text: "Response",
  205. } as ClineMessage,
  206. ]
  207. mockCurrentTask.apiConversationHistory = [
  208. {
  209. ts: userMessageTs,
  210. role: "user",
  211. content: [{ type: "text", text: "Hello" }],
  212. },
  213. {
  214. ts: assistantMessageTs,
  215. role: "assistant",
  216. content: [{ type: "text", text: "Response" }],
  217. },
  218. ] as ApiMessage[]
  219. await webviewMessageHandler(mockClineProvider, {
  220. type: "editMessageConfirm",
  221. messageTs: userMessageTs,
  222. text: "Hello World",
  223. restoreCheckpoint: false,
  224. })
  225. // Both should be truncated at index 0
  226. expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([])
  227. expect(mockCurrentTask.overwriteApiConversationHistory).toHaveBeenCalledWith([])
  228. })
  229. it("should handle case where no API messages match timestamp criteria", async () => {
  230. const userMessageTs = 3000
  231. mockCurrentTask.clineMessages = [
  232. {
  233. ts: userMessageTs,
  234. type: "say",
  235. say: "user_feedback",
  236. text: "Hello",
  237. } as ClineMessage,
  238. ]
  239. // All API messages have timestamps before the edited message
  240. mockCurrentTask.apiConversationHistory = [
  241. {
  242. ts: 1000,
  243. role: "assistant",
  244. content: [{ type: "text", text: "Old message 1" }],
  245. },
  246. {
  247. ts: 2000,
  248. role: "assistant",
  249. content: [{ type: "text", text: "Old message 2" }],
  250. },
  251. ] as ApiMessage[]
  252. await webviewMessageHandler(mockClineProvider, {
  253. type: "editMessageConfirm",
  254. messageTs: userMessageTs,
  255. text: "Hello World",
  256. restoreCheckpoint: false,
  257. })
  258. // UI messages truncated
  259. expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([])
  260. // API history should not be modified when no API messages meet the timestamp criteria
  261. expect(mockCurrentTask.overwriteApiConversationHistory).not.toHaveBeenCalled()
  262. })
  263. it("should handle empty API conversation history gracefully", async () => {
  264. const userMessageTs = 1000
  265. mockCurrentTask.clineMessages = [
  266. {
  267. ts: userMessageTs,
  268. type: "say",
  269. say: "user_feedback",
  270. text: "Hello",
  271. } as ClineMessage,
  272. ]
  273. mockCurrentTask.apiConversationHistory = []
  274. await webviewMessageHandler(mockClineProvider, {
  275. type: "editMessageConfirm",
  276. messageTs: userMessageTs,
  277. text: "Hello World",
  278. restoreCheckpoint: false,
  279. })
  280. // UI messages should be truncated
  281. expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([])
  282. // API history should not be modified when message not found
  283. expect(mockCurrentTask.overwriteApiConversationHistory).not.toHaveBeenCalled()
  284. })
  285. it("should correctly handle attempt_completion in API history", async () => {
  286. const userMessageTs = 1000
  287. const completionTs = 2000
  288. const feedbackTs = 3000
  289. mockCurrentTask.clineMessages = [
  290. {
  291. ts: userMessageTs,
  292. type: "say",
  293. say: "user_feedback",
  294. text: "Do something",
  295. } as ClineMessage,
  296. {
  297. ts: completionTs,
  298. type: "say",
  299. say: "completion_result",
  300. text: "Task Completed!",
  301. } as ClineMessage,
  302. {
  303. ts: feedbackTs,
  304. type: "say",
  305. say: "user_feedback",
  306. text: "Thanks",
  307. } as ClineMessage,
  308. ]
  309. // API history with attempt_completion tool use (user message missing)
  310. mockCurrentTask.apiConversationHistory = [
  311. {
  312. ts: completionTs,
  313. role: "assistant",
  314. content: [
  315. {
  316. type: "tool_use",
  317. name: "attempt_completion",
  318. id: "tool-1",
  319. input: {
  320. result: "Task Completed!",
  321. },
  322. },
  323. ],
  324. },
  325. {
  326. ts: feedbackTs,
  327. role: "user",
  328. content: [
  329. {
  330. type: "text",
  331. text: "Thanks",
  332. },
  333. ],
  334. },
  335. ] as ApiMessage[]
  336. // Edit the first user message
  337. await webviewMessageHandler(mockClineProvider, {
  338. type: "editMessageConfirm",
  339. messageTs: userMessageTs,
  340. text: "Do something else",
  341. restoreCheckpoint: false,
  342. })
  343. // UI messages truncated at edited message
  344. expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledWith([])
  345. // API history should be truncated from first message at/after edited timestamp (fallback)
  346. expect(mockCurrentTask.overwriteApiConversationHistory).toHaveBeenCalledWith([])
  347. })
  348. })