ClineProvider.apiHandlerRebuild.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. // npx vitest core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts
  2. import * as vscode from "vscode"
  3. import { TelemetryService } from "@roo-code/telemetry"
  4. import { getModelId } from "@roo-code/types"
  5. import { ContextProxy } from "../../config/ContextProxy"
  6. import { Task, TaskOptions } from "../../task/Task"
  7. import { ClineProvider } from "../ClineProvider"
  8. // Mock setup
  9. vi.mock("fs/promises", () => ({
  10. mkdir: vi.fn().mockResolvedValue(undefined),
  11. writeFile: vi.fn().mockResolvedValue(undefined),
  12. readFile: vi.fn().mockResolvedValue(""),
  13. unlink: vi.fn().mockResolvedValue(undefined),
  14. rmdir: vi.fn().mockResolvedValue(undefined),
  15. }))
  16. vi.mock("../../../utils/storage", () => ({
  17. getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"),
  18. getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"),
  19. getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"),
  20. }))
  21. vi.mock("p-wait-for", () => ({
  22. __esModule: true,
  23. default: vi.fn().mockResolvedValue(undefined),
  24. }))
  25. vi.mock("delay", () => {
  26. const delayFn = (_ms: number) => Promise.resolve()
  27. delayFn.createDelay = () => delayFn
  28. delayFn.reject = () => Promise.reject(new Error("Delay rejected"))
  29. delayFn.range = () => Promise.resolve()
  30. return { default: delayFn }
  31. })
  32. vi.mock("vscode", () => ({
  33. ExtensionContext: vi.fn(),
  34. OutputChannel: vi.fn(),
  35. WebviewView: vi.fn(),
  36. Uri: {
  37. joinPath: vi.fn(),
  38. file: vi.fn(),
  39. },
  40. commands: {
  41. executeCommand: vi.fn().mockResolvedValue(undefined),
  42. },
  43. window: {
  44. showInformationMessage: vi.fn(),
  45. showWarningMessage: vi.fn(),
  46. showErrorMessage: vi.fn(),
  47. onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })),
  48. },
  49. workspace: {
  50. getConfiguration: vi.fn().mockReturnValue({
  51. get: vi.fn().mockReturnValue([]),
  52. update: vi.fn(),
  53. }),
  54. onDidChangeConfiguration: vi.fn().mockImplementation(() => ({
  55. dispose: vi.fn(),
  56. })),
  57. },
  58. env: {
  59. uriScheme: "vscode",
  60. language: "en",
  61. appName: "Visual Studio Code",
  62. },
  63. ExtensionMode: {
  64. Production: 1,
  65. Development: 2,
  66. Test: 3,
  67. },
  68. version: "1.85.0",
  69. }))
  70. vi.mock("../../../utils/tts", () => ({
  71. setTtsEnabled: vi.fn(),
  72. setTtsSpeed: vi.fn(),
  73. }))
  74. vi.mock("../../../api", () => ({
  75. buildApiHandler: vi.fn(),
  76. }))
  77. vi.mock("../../../integrations/workspace/WorkspaceTracker", () => {
  78. return {
  79. default: vi.fn().mockImplementation(() => ({
  80. initializeFilePaths: vi.fn(),
  81. dispose: vi.fn(),
  82. })),
  83. }
  84. })
  85. vi.mock("../../task/Task", () => ({
  86. Task: vi.fn().mockImplementation((options) => {
  87. const mockTask = {
  88. api: undefined,
  89. abortTask: vi.fn(),
  90. handleWebviewAskResponse: vi.fn(),
  91. clineMessages: [],
  92. apiConversationHistory: [],
  93. overwriteClineMessages: vi.fn(),
  94. overwriteApiConversationHistory: vi.fn(),
  95. taskId: options?.historyItem?.id || "test-task-id",
  96. emit: vi.fn(),
  97. updateApiConfiguration: vi.fn().mockImplementation(function (this: any, newConfig: any) {
  98. this.apiConfiguration = newConfig
  99. }),
  100. }
  101. // Define apiConfiguration as a property so tests can read it
  102. Object.defineProperty(mockTask, "apiConfiguration", {
  103. value: options?.apiConfiguration || { apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" },
  104. writable: true,
  105. configurable: true,
  106. })
  107. return mockTask
  108. }),
  109. }))
  110. vi.mock("@roo-code/cloud", () => ({
  111. CloudService: {
  112. hasInstance: vi.fn().mockReturnValue(true),
  113. get instance() {
  114. return {
  115. isAuthenticated: vi.fn().mockReturnValue(false),
  116. }
  117. },
  118. },
  119. BridgeOrchestrator: {
  120. isEnabled: vi.fn().mockReturnValue(false),
  121. },
  122. getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"),
  123. }))
  124. describe("ClineProvider - API Handler Rebuild Guard", () => {
  125. let provider: ClineProvider
  126. let mockContext: vscode.ExtensionContext
  127. let mockOutputChannel: vscode.OutputChannel
  128. let mockWebviewView: vscode.WebviewView
  129. let mockPostMessage: any
  130. let defaultTaskOptions: TaskOptions
  131. let buildApiHandlerMock: any
  132. beforeEach(async () => {
  133. vi.clearAllMocks()
  134. if (!TelemetryService.hasInstance()) {
  135. TelemetryService.createInstance([])
  136. }
  137. const globalState: Record<string, any> = {
  138. mode: "code",
  139. currentApiConfigName: "test-config",
  140. }
  141. const secrets: Record<string, string | undefined> = {}
  142. mockContext = {
  143. extensionPath: "/test/path",
  144. extensionUri: {} as vscode.Uri,
  145. globalState: {
  146. get: vi.fn().mockImplementation((key: string) => globalState[key]),
  147. update: vi.fn().mockImplementation((key: string, value: any) => (globalState[key] = value)),
  148. keys: vi.fn().mockImplementation(() => Object.keys(globalState)),
  149. },
  150. secrets: {
  151. get: vi.fn().mockImplementation((key: string) => secrets[key]),
  152. store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)),
  153. delete: vi.fn().mockImplementation((key: string) => delete secrets[key]),
  154. },
  155. workspaceState: {
  156. get: vi.fn().mockReturnValue(undefined),
  157. update: vi.fn().mockResolvedValue(undefined),
  158. keys: vi.fn().mockReturnValue([]),
  159. },
  160. subscriptions: [],
  161. extension: {
  162. packageJSON: { version: "1.0.0" },
  163. },
  164. globalStorageUri: {
  165. fsPath: "/test/storage/path",
  166. },
  167. } as unknown as vscode.ExtensionContext
  168. mockOutputChannel = {
  169. appendLine: vi.fn(),
  170. clear: vi.fn(),
  171. dispose: vi.fn(),
  172. } as unknown as vscode.OutputChannel
  173. mockPostMessage = vi.fn()
  174. mockWebviewView = {
  175. webview: {
  176. postMessage: mockPostMessage,
  177. html: "",
  178. options: {},
  179. onDidReceiveMessage: vi.fn(),
  180. asWebviewUri: vi.fn(),
  181. },
  182. visible: true,
  183. onDidDispose: vi.fn().mockImplementation((callback) => {
  184. callback()
  185. return { dispose: vi.fn() }
  186. }),
  187. onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })),
  188. } as unknown as vscode.WebviewView
  189. provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
  190. // Mock providerSettingsManager
  191. ;(provider as any).providerSettingsManager = {
  192. saveConfig: vi.fn().mockResolvedValue("test-id"),
  193. listConfig: vi
  194. .fn()
  195. .mockResolvedValue([
  196. { name: "test-config", id: "test-id", apiProvider: "openrouter", modelId: "openai/gpt-4" },
  197. ]),
  198. setModeConfig: vi.fn(),
  199. activateProfile: vi.fn().mockResolvedValue({
  200. name: "test-config",
  201. id: "test-id",
  202. apiProvider: "openrouter",
  203. openRouterModelId: "openai/gpt-4",
  204. }),
  205. getProfile: vi.fn().mockResolvedValue({
  206. name: "test-config",
  207. id: "test-id",
  208. apiProvider: "openrouter",
  209. openRouterModelId: "openai/gpt-4",
  210. }),
  211. }
  212. // Get the buildApiHandler mock
  213. const { buildApiHandler } = await import("../../../api")
  214. buildApiHandlerMock = vi.mocked(buildApiHandler)
  215. // Setup default mock implementation
  216. buildApiHandlerMock.mockReturnValue({
  217. getModel: vi.fn().mockReturnValue({
  218. id: "openai/gpt-4",
  219. info: { contextWindow: 128000 },
  220. }),
  221. })
  222. defaultTaskOptions = {
  223. provider,
  224. apiConfiguration: {
  225. apiProvider: "openrouter",
  226. openRouterModelId: "openai/gpt-4",
  227. },
  228. }
  229. await provider.resolveWebviewView(mockWebviewView)
  230. })
  231. describe("upsertProviderProfile", () => {
  232. test("calls updateApiConfiguration when provider/model unchanged but profile settings changed (explicit save)", async () => {
  233. // Create a task with the current config
  234. const mockTask = new Task({
  235. ...defaultTaskOptions,
  236. apiConfiguration: {
  237. apiProvider: "openrouter",
  238. openRouterModelId: "openai/gpt-4",
  239. },
  240. })
  241. mockTask.api = {
  242. getModel: vi.fn().mockReturnValue({
  243. id: "openai/gpt-4",
  244. info: { contextWindow: 128000 },
  245. }),
  246. } as any
  247. await provider.addClineToStack(mockTask)
  248. // Save settings with SAME provider and model (simulating Save button click)
  249. await provider.upsertProviderProfile(
  250. "test-config",
  251. {
  252. apiProvider: "openrouter",
  253. openRouterModelId: "openai/gpt-4",
  254. // Other settings that might change
  255. rateLimitSeconds: 5,
  256. modelTemperature: 0.7,
  257. },
  258. true,
  259. )
  260. // Verify updateApiConfiguration was called because we force rebuild on explicit save/switch
  261. expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith(
  262. expect.objectContaining({
  263. apiProvider: "openrouter",
  264. openRouterModelId: "openai/gpt-4",
  265. rateLimitSeconds: 5,
  266. modelTemperature: 0.7,
  267. }),
  268. )
  269. // Verify task.apiConfiguration was synchronized
  270. expect((mockTask as any).apiConfiguration.openRouterModelId).toBe("openai/gpt-4")
  271. expect((mockTask as any).apiConfiguration.rateLimitSeconds).toBe(5)
  272. expect((mockTask as any).apiConfiguration.modelTemperature).toBe(0.7)
  273. })
  274. test("calls updateApiConfiguration when provider changes", async () => {
  275. const mockTask = new Task({
  276. ...defaultTaskOptions,
  277. apiConfiguration: {
  278. apiProvider: "openrouter",
  279. openRouterModelId: "openai/gpt-4",
  280. },
  281. })
  282. mockTask.api = {
  283. getModel: vi.fn().mockReturnValue({
  284. id: "openai/gpt-4",
  285. info: { contextWindow: 128000 },
  286. }),
  287. } as any
  288. await provider.addClineToStack(mockTask)
  289. // Change provider to anthropic
  290. await provider.upsertProviderProfile(
  291. "test-config",
  292. {
  293. apiProvider: "anthropic",
  294. apiModelId: "claude-3-5-sonnet-20241022",
  295. },
  296. true,
  297. )
  298. // Verify updateApiConfiguration was called since provider changed
  299. expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith(
  300. expect.objectContaining({
  301. apiProvider: "anthropic",
  302. apiModelId: "claude-3-5-sonnet-20241022",
  303. }),
  304. )
  305. })
  306. test("calls updateApiConfiguration when model changes", async () => {
  307. const mockTask = new Task({
  308. ...defaultTaskOptions,
  309. apiConfiguration: {
  310. apiProvider: "openrouter",
  311. openRouterModelId: "openai/gpt-4",
  312. },
  313. })
  314. mockTask.api = {
  315. getModel: vi.fn().mockReturnValue({
  316. id: "openai/gpt-4",
  317. info: { contextWindow: 128000 },
  318. }),
  319. } as any
  320. await provider.addClineToStack(mockTask)
  321. // Change model to different model
  322. await provider.upsertProviderProfile(
  323. "test-config",
  324. {
  325. apiProvider: "openrouter",
  326. openRouterModelId: "anthropic/claude-3-5-sonnet-20241022",
  327. },
  328. true,
  329. )
  330. // Verify updateApiConfiguration was called since model changed
  331. expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith(
  332. expect.objectContaining({
  333. apiProvider: "openrouter",
  334. openRouterModelId: "anthropic/claude-3-5-sonnet-20241022",
  335. }),
  336. )
  337. })
  338. test("does nothing when no task is running", async () => {
  339. // Don't add any task to stack
  340. buildApiHandlerMock.mockClear()
  341. await provider.upsertProviderProfile(
  342. "test-config",
  343. {
  344. apiProvider: "openrouter",
  345. openRouterModelId: "openai/gpt-4",
  346. },
  347. true,
  348. )
  349. // Should not call buildApiHandler when there's no task
  350. expect(buildApiHandlerMock).not.toHaveBeenCalled()
  351. })
  352. })
  353. describe("activateProviderProfile", () => {
  354. test("calls updateApiConfiguration when provider/model unchanged but settings differ (explicit profile switch)", async () => {
  355. const mockTask = new Task({
  356. ...defaultTaskOptions,
  357. apiConfiguration: {
  358. apiProvider: "openrouter",
  359. openRouterModelId: "openai/gpt-4",
  360. modelTemperature: 0.3,
  361. },
  362. })
  363. mockTask.api = {
  364. getModel: vi.fn().mockReturnValue({
  365. id: "openai/gpt-4",
  366. info: { contextWindow: 128000 },
  367. }),
  368. } as any
  369. await provider.addClineToStack(mockTask)
  370. // Mock activateProfile to return same provider/model but different non-model setting
  371. ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({
  372. name: "test-config",
  373. id: "test-id",
  374. apiProvider: "openrouter",
  375. openRouterModelId: "openai/gpt-4",
  376. modelTemperature: 0.9,
  377. rateLimitSeconds: 7,
  378. })
  379. await provider.activateProviderProfile({ name: "test-config" })
  380. // Verify updateApiConfiguration was called due to forced rebuild on explicit switch
  381. expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith(
  382. expect.objectContaining({
  383. apiProvider: "openrouter",
  384. openRouterModelId: "openai/gpt-4",
  385. }),
  386. )
  387. // Verify task.apiConfiguration was synchronized
  388. expect((mockTask as any).apiConfiguration.openRouterModelId).toBe("openai/gpt-4")
  389. expect((mockTask as any).apiConfiguration.modelTemperature).toBe(0.9)
  390. expect((mockTask as any).apiConfiguration.rateLimitSeconds).toBe(7)
  391. })
  392. test("calls updateApiConfiguration when provider changes and syncs task.apiConfiguration", async () => {
  393. const mockTask = new Task({
  394. ...defaultTaskOptions,
  395. apiConfiguration: {
  396. apiProvider: "openrouter",
  397. openRouterModelId: "openai/gpt-4",
  398. },
  399. })
  400. mockTask.api = {
  401. getModel: vi.fn().mockReturnValue({
  402. id: "openai/gpt-4",
  403. info: { contextWindow: 128000 },
  404. }),
  405. } as any
  406. await provider.addClineToStack(mockTask)
  407. // Mock activateProfile to return different provider
  408. ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({
  409. name: "anthropic-config",
  410. id: "anthropic-id",
  411. apiProvider: "anthropic",
  412. apiModelId: "claude-3-5-sonnet-20241022",
  413. })
  414. await provider.activateProviderProfile({ name: "anthropic-config" })
  415. // Verify updateApiConfiguration was called
  416. expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith(
  417. expect.objectContaining({
  418. apiProvider: "anthropic",
  419. apiModelId: "claude-3-5-sonnet-20241022",
  420. }),
  421. )
  422. // And task.apiConfiguration synced
  423. expect((mockTask as any).apiConfiguration.apiProvider).toBe("anthropic")
  424. expect((mockTask as any).apiConfiguration.apiModelId).toBe("claude-3-5-sonnet-20241022")
  425. })
  426. test("calls updateApiConfiguration when model changes and syncs task.apiConfiguration", async () => {
  427. const mockTask = new Task({
  428. ...defaultTaskOptions,
  429. apiConfiguration: {
  430. apiProvider: "openrouter",
  431. openRouterModelId: "openai/gpt-4",
  432. },
  433. })
  434. mockTask.api = {
  435. getModel: vi.fn().mockReturnValue({
  436. id: "openai/gpt-4",
  437. info: { contextWindow: 128000 },
  438. }),
  439. } as any
  440. await provider.addClineToStack(mockTask)
  441. // Mock activateProfile to return different model
  442. ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({
  443. name: "test-config",
  444. id: "test-id",
  445. apiProvider: "openrouter",
  446. openRouterModelId: "anthropic/claude-3-5-sonnet-20241022",
  447. })
  448. await provider.activateProviderProfile({ name: "test-config" })
  449. // Verify updateApiConfiguration was called
  450. expect(mockTask.updateApiConfiguration).toHaveBeenCalledWith(
  451. expect.objectContaining({
  452. apiProvider: "openrouter",
  453. openRouterModelId: "anthropic/claude-3-5-sonnet-20241022",
  454. }),
  455. )
  456. // And task.apiConfiguration synced
  457. expect((mockTask as any).apiConfiguration.apiProvider).toBe("openrouter")
  458. expect((mockTask as any).apiConfiguration.openRouterModelId).toBe("anthropic/claude-3-5-sonnet-20241022")
  459. })
  460. })
  461. describe("profile switching sequence", () => {
  462. test("A -> B -> A updates task.apiConfiguration each time", async () => {
  463. const mockTask = new Task({
  464. ...defaultTaskOptions,
  465. apiConfiguration: {
  466. apiProvider: "openrouter",
  467. openRouterModelId: "openai/gpt-4",
  468. },
  469. })
  470. mockTask.api = {
  471. getModel: vi.fn().mockReturnValue({
  472. id: "openai/gpt-4",
  473. info: { contextWindow: 128000 },
  474. }),
  475. } as any
  476. await provider.addClineToStack(mockTask)
  477. // First switch: A -> B (openrouter -> anthropic)
  478. ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({
  479. name: "anthropic-config",
  480. id: "anthropic-id",
  481. apiProvider: "anthropic",
  482. apiModelId: "claude-3-5-sonnet-20241022",
  483. })
  484. await provider.activateProviderProfile({ name: "anthropic-config" })
  485. expect(mockTask.updateApiConfiguration).toHaveBeenCalled()
  486. expect((mockTask as any).apiConfiguration.apiProvider).toBe("anthropic")
  487. expect((mockTask as any).apiConfiguration.apiModelId).toBe("claude-3-5-sonnet-20241022")
  488. // Second switch: B -> A (anthropic -> openrouter gpt-4)
  489. ;(mockTask.updateApiConfiguration as any).mockClear()
  490. ;(provider as any).providerSettingsManager.activateProfile = vi.fn().mockResolvedValue({
  491. name: "test-config",
  492. id: "test-id",
  493. apiProvider: "openrouter",
  494. openRouterModelId: "openai/gpt-4",
  495. })
  496. await provider.activateProviderProfile({ name: "test-config" })
  497. // updateApiConfiguration called again, and apiConfiguration must be updated
  498. expect(mockTask.updateApiConfiguration).toHaveBeenCalled()
  499. expect((mockTask as any).apiConfiguration.apiProvider).toBe("openrouter")
  500. expect((mockTask as any).apiConfiguration.openRouterModelId).toBe("openai/gpt-4")
  501. })
  502. })
  503. describe("getModelId helper", () => {
  504. test("correctly extracts model ID from different provider configurations", () => {
  505. expect(getModelId({ apiProvider: "openrouter", openRouterModelId: "openai/gpt-4" })).toBe("openai/gpt-4")
  506. expect(getModelId({ apiProvider: "anthropic", apiModelId: "claude-3-5-sonnet-20241022" })).toBe(
  507. "claude-3-5-sonnet-20241022",
  508. )
  509. expect(getModelId({ apiProvider: "openai", openAiModelId: "gpt-4-turbo" })).toBe("gpt-4-turbo")
  510. expect(getModelId({ apiProvider: "bedrock", apiModelId: "anthropic.claude-v2" })).toBe(
  511. "anthropic.claude-v2",
  512. )
  513. })
  514. test("returns undefined when no model ID is present", () => {
  515. expect(getModelId({ apiProvider: "anthropic" })).toBeUndefined()
  516. expect(getModelId({})).toBeUndefined()
  517. })
  518. })
  519. })