convert-to-copilot-messages.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages"
  2. import { describe, test, expect } from "bun:test"
  3. describe("user messages", () => {
  4. test("should convert messages with only a text part to a string content", () => {
  5. const result = convertToCopilotMessages([
  6. {
  7. role: "user",
  8. content: [{ type: "text", text: "Hello" }],
  9. },
  10. ])
  11. expect(result).toEqual([{ role: "user", content: "Hello" }])
  12. })
  13. test("should convert messages with image parts", () => {
  14. const result = convertToCopilotMessages([
  15. {
  16. role: "user",
  17. content: [
  18. { type: "text", text: "Hello" },
  19. {
  20. type: "file",
  21. data: Buffer.from([0, 1, 2, 3]).toString("base64"),
  22. mediaType: "image/png",
  23. },
  24. ],
  25. },
  26. ])
  27. expect(result).toEqual([
  28. {
  29. role: "user",
  30. content: [
  31. { type: "text", text: "Hello" },
  32. {
  33. type: "image_url",
  34. image_url: { url: "data:image/png;base64,AAECAw==" },
  35. },
  36. ],
  37. },
  38. ])
  39. })
  40. test("should convert messages with image parts from Uint8Array", () => {
  41. const result = convertToCopilotMessages([
  42. {
  43. role: "user",
  44. content: [
  45. { type: "text", text: "Hi" },
  46. {
  47. type: "file",
  48. data: new Uint8Array([0, 1, 2, 3]),
  49. mediaType: "image/png",
  50. },
  51. ],
  52. },
  53. ])
  54. expect(result).toEqual([
  55. {
  56. role: "user",
  57. content: [
  58. { type: "text", text: "Hi" },
  59. {
  60. type: "image_url",
  61. image_url: { url: "data:image/png;base64,AAECAw==" },
  62. },
  63. ],
  64. },
  65. ])
  66. })
  67. test("should handle URL-based images", () => {
  68. const result = convertToCopilotMessages([
  69. {
  70. role: "user",
  71. content: [
  72. {
  73. type: "file",
  74. data: new URL("https://example.com/image.jpg"),
  75. mediaType: "image/*",
  76. },
  77. ],
  78. },
  79. ])
  80. expect(result).toEqual([
  81. {
  82. role: "user",
  83. content: [
  84. {
  85. type: "image_url",
  86. image_url: { url: "https://example.com/image.jpg" },
  87. },
  88. ],
  89. },
  90. ])
  91. })
  92. test("should handle multiple text parts without flattening", () => {
  93. const result = convertToCopilotMessages([
  94. {
  95. role: "user",
  96. content: [
  97. { type: "text", text: "Part 1" },
  98. { type: "text", text: "Part 2" },
  99. ],
  100. },
  101. ])
  102. expect(result).toEqual([
  103. {
  104. role: "user",
  105. content: [
  106. { type: "text", text: "Part 1" },
  107. { type: "text", text: "Part 2" },
  108. ],
  109. },
  110. ])
  111. })
  112. })
  113. describe("assistant messages", () => {
  114. test("should convert assistant text messages", () => {
  115. const result = convertToCopilotMessages([
  116. {
  117. role: "assistant",
  118. content: [{ type: "text", text: "Hello back!" }],
  119. },
  120. ])
  121. expect(result).toEqual([
  122. {
  123. role: "assistant",
  124. content: "Hello back!",
  125. tool_calls: undefined,
  126. reasoning_text: undefined,
  127. reasoning_opaque: undefined,
  128. },
  129. ])
  130. })
  131. test("should handle assistant message with null content when only tool calls", () => {
  132. const result = convertToCopilotMessages([
  133. {
  134. role: "assistant",
  135. content: [
  136. {
  137. type: "tool-call",
  138. toolCallId: "call1",
  139. toolName: "calculator",
  140. input: { a: 1, b: 2 },
  141. },
  142. ],
  143. },
  144. ])
  145. expect(result).toEqual([
  146. {
  147. role: "assistant",
  148. content: null,
  149. tool_calls: [
  150. {
  151. id: "call1",
  152. type: "function",
  153. function: {
  154. name: "calculator",
  155. arguments: JSON.stringify({ a: 1, b: 2 }),
  156. },
  157. },
  158. ],
  159. reasoning_text: undefined,
  160. reasoning_opaque: undefined,
  161. },
  162. ])
  163. })
  164. test("should concatenate multiple text parts", () => {
  165. const result = convertToCopilotMessages([
  166. {
  167. role: "assistant",
  168. content: [
  169. { type: "text", text: "First part. " },
  170. { type: "text", text: "Second part." },
  171. ],
  172. },
  173. ])
  174. expect(result[0].content).toBe("First part. Second part.")
  175. })
  176. })
  177. describe("tool calls", () => {
  178. test("should stringify arguments to tool calls", () => {
  179. const result = convertToCopilotMessages([
  180. {
  181. role: "assistant",
  182. content: [
  183. {
  184. type: "tool-call",
  185. input: { foo: "bar123" },
  186. toolCallId: "quux",
  187. toolName: "thwomp",
  188. },
  189. ],
  190. },
  191. {
  192. role: "tool",
  193. content: [
  194. {
  195. type: "tool-result",
  196. toolCallId: "quux",
  197. toolName: "thwomp",
  198. output: { type: "json", value: { oof: "321rab" } },
  199. },
  200. ],
  201. },
  202. ])
  203. expect(result).toEqual([
  204. {
  205. role: "assistant",
  206. content: null,
  207. tool_calls: [
  208. {
  209. id: "quux",
  210. type: "function",
  211. function: {
  212. name: "thwomp",
  213. arguments: JSON.stringify({ foo: "bar123" }),
  214. },
  215. },
  216. ],
  217. reasoning_text: undefined,
  218. reasoning_opaque: undefined,
  219. },
  220. {
  221. role: "tool",
  222. tool_call_id: "quux",
  223. content: JSON.stringify({ oof: "321rab" }),
  224. },
  225. ])
  226. })
  227. test("should handle text output type in tool results", () => {
  228. const result = convertToCopilotMessages([
  229. {
  230. role: "tool",
  231. content: [
  232. {
  233. type: "tool-result",
  234. toolCallId: "call-1",
  235. toolName: "getWeather",
  236. output: { type: "text", value: "It is sunny today" },
  237. },
  238. ],
  239. },
  240. ])
  241. expect(result).toEqual([
  242. {
  243. role: "tool",
  244. tool_call_id: "call-1",
  245. content: "It is sunny today",
  246. },
  247. ])
  248. })
  249. test("should handle multiple tool results as separate messages", () => {
  250. const result = convertToCopilotMessages([
  251. {
  252. role: "tool",
  253. content: [
  254. {
  255. type: "tool-result",
  256. toolCallId: "call1",
  257. toolName: "api1",
  258. output: { type: "text", value: "Result 1" },
  259. },
  260. {
  261. type: "tool-result",
  262. toolCallId: "call2",
  263. toolName: "api2",
  264. output: { type: "text", value: "Result 2" },
  265. },
  266. ],
  267. },
  268. ])
  269. expect(result).toHaveLength(2)
  270. expect(result[0]).toEqual({
  271. role: "tool",
  272. tool_call_id: "call1",
  273. content: "Result 1",
  274. })
  275. expect(result[1]).toEqual({
  276. role: "tool",
  277. tool_call_id: "call2",
  278. content: "Result 2",
  279. })
  280. })
  281. test("should handle text plus multiple tool calls", () => {
  282. const result = convertToCopilotMessages([
  283. {
  284. role: "assistant",
  285. content: [
  286. { type: "text", text: "Checking... " },
  287. {
  288. type: "tool-call",
  289. toolCallId: "call1",
  290. toolName: "searchTool",
  291. input: { query: "Weather" },
  292. },
  293. { type: "text", text: "Almost there..." },
  294. {
  295. type: "tool-call",
  296. toolCallId: "call2",
  297. toolName: "mapsTool",
  298. input: { location: "Paris" },
  299. },
  300. ],
  301. },
  302. ])
  303. expect(result).toEqual([
  304. {
  305. role: "assistant",
  306. content: "Checking... Almost there...",
  307. tool_calls: [
  308. {
  309. id: "call1",
  310. type: "function",
  311. function: {
  312. name: "searchTool",
  313. arguments: JSON.stringify({ query: "Weather" }),
  314. },
  315. },
  316. {
  317. id: "call2",
  318. type: "function",
  319. function: {
  320. name: "mapsTool",
  321. arguments: JSON.stringify({ location: "Paris" }),
  322. },
  323. },
  324. ],
  325. reasoning_text: undefined,
  326. reasoning_opaque: undefined,
  327. },
  328. ])
  329. })
  330. })
  331. describe("reasoning (copilot-specific)", () => {
  332. test("should omit reasoning_text without reasoning_opaque", () => {
  333. const result = convertToCopilotMessages([
  334. {
  335. role: "assistant",
  336. content: [
  337. { type: "reasoning", text: "Let me think about this..." },
  338. { type: "text", text: "The answer is 42." },
  339. ],
  340. },
  341. ])
  342. expect(result).toEqual([
  343. {
  344. role: "assistant",
  345. content: "The answer is 42.",
  346. tool_calls: undefined,
  347. reasoning_text: undefined,
  348. reasoning_opaque: undefined,
  349. },
  350. ])
  351. })
  352. test("should include reasoning_opaque from providerOptions", () => {
  353. const result = convertToCopilotMessages([
  354. {
  355. role: "assistant",
  356. content: [
  357. {
  358. type: "reasoning",
  359. text: "Thinking...",
  360. providerOptions: {
  361. copilot: { reasoningOpaque: "opaque-signature-123" },
  362. },
  363. },
  364. { type: "text", text: "Done!" },
  365. ],
  366. },
  367. ])
  368. expect(result).toEqual([
  369. {
  370. role: "assistant",
  371. content: "Done!",
  372. tool_calls: undefined,
  373. reasoning_text: "Thinking...",
  374. reasoning_opaque: "opaque-signature-123",
  375. },
  376. ])
  377. })
  378. test("should include reasoning_opaque from text part providerOptions", () => {
  379. const result = convertToCopilotMessages([
  380. {
  381. role: "assistant",
  382. content: [
  383. {
  384. type: "text",
  385. text: "Done!",
  386. providerOptions: {
  387. copilot: { reasoningOpaque: "opaque-text-456" },
  388. },
  389. },
  390. ],
  391. },
  392. ])
  393. expect(result).toEqual([
  394. {
  395. role: "assistant",
  396. content: "Done!",
  397. tool_calls: undefined,
  398. reasoning_text: undefined,
  399. reasoning_opaque: "opaque-text-456",
  400. },
  401. ])
  402. })
  403. test("should handle reasoning-only assistant message", () => {
  404. const result = convertToCopilotMessages([
  405. {
  406. role: "assistant",
  407. content: [
  408. {
  409. type: "reasoning",
  410. text: "Just thinking, no response yet",
  411. providerOptions: {
  412. copilot: { reasoningOpaque: "sig-abc" },
  413. },
  414. },
  415. ],
  416. },
  417. ])
  418. expect(result).toEqual([
  419. {
  420. role: "assistant",
  421. content: null,
  422. tool_calls: undefined,
  423. reasoning_text: "Just thinking, no response yet",
  424. reasoning_opaque: "sig-abc",
  425. },
  426. ])
  427. })
  428. })
  429. describe("full conversation", () => {
  430. test("should convert a multi-turn conversation with reasoning", () => {
  431. const result = convertToCopilotMessages([
  432. {
  433. role: "system",
  434. content: "You are a helpful assistant.",
  435. },
  436. {
  437. role: "user",
  438. content: [{ type: "text", text: "What is 2+2?" }],
  439. },
  440. {
  441. role: "assistant",
  442. content: [
  443. {
  444. type: "reasoning",
  445. text: "Let me calculate 2+2...",
  446. providerOptions: {
  447. copilot: { reasoningOpaque: "sig-abc" },
  448. },
  449. },
  450. { type: "text", text: "2+2 equals 4." },
  451. ],
  452. },
  453. {
  454. role: "user",
  455. content: [{ type: "text", text: "What about 3+3?" }],
  456. },
  457. ])
  458. expect(result).toHaveLength(4)
  459. const systemMsg = result[0]
  460. expect(systemMsg.role).toBe("system")
  461. // Assistant message should have reasoning fields
  462. const assistantMsg = result[2] as {
  463. reasoning_text?: string
  464. reasoning_opaque?: string
  465. }
  466. expect(assistantMsg.reasoning_text).toBe("Let me calculate 2+2...")
  467. expect(assistantMsg.reasoning_opaque).toBe("sig-abc")
  468. })
  469. })