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

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