message-v2.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924
  1. import { describe, expect, test } from "bun:test"
  2. import { APICallError } from "ai"
  3. import { MessageV2 } from "../../src/session/message-v2"
  4. import type { Provider } from "../../src/provider/provider"
  5. const sessionID = "session"
  6. const model: Provider.Model = {
  7. id: "test-model",
  8. providerID: "test",
  9. api: {
  10. id: "test-model",
  11. url: "https://example.com",
  12. npm: "@ai-sdk/openai",
  13. },
  14. name: "Test Model",
  15. capabilities: {
  16. temperature: true,
  17. reasoning: false,
  18. attachment: false,
  19. toolcall: true,
  20. input: {
  21. text: true,
  22. audio: false,
  23. image: false,
  24. video: false,
  25. pdf: false,
  26. },
  27. output: {
  28. text: true,
  29. audio: false,
  30. image: false,
  31. video: false,
  32. pdf: false,
  33. },
  34. interleaved: false,
  35. },
  36. cost: {
  37. input: 0,
  38. output: 0,
  39. cache: {
  40. read: 0,
  41. write: 0,
  42. },
  43. },
  44. limit: {
  45. context: 0,
  46. input: 0,
  47. output: 0,
  48. },
  49. status: "active",
  50. options: {},
  51. headers: {},
  52. release_date: "2026-01-01",
  53. }
  54. function userInfo(id: string): MessageV2.User {
  55. return {
  56. id,
  57. sessionID,
  58. role: "user",
  59. time: { created: 0 },
  60. agent: "user",
  61. model: { providerID: "test", modelID: "test" },
  62. tools: {},
  63. mode: "",
  64. } as unknown as MessageV2.User
  65. }
  66. function assistantInfo(
  67. id: string,
  68. parentID: string,
  69. error?: MessageV2.Assistant["error"],
  70. meta?: { providerID: string; modelID: string },
  71. ): MessageV2.Assistant {
  72. const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id }
  73. return {
  74. id,
  75. sessionID,
  76. role: "assistant",
  77. time: { created: 0 },
  78. error,
  79. parentID,
  80. modelID: infoModel.modelID,
  81. providerID: infoModel.providerID,
  82. mode: "",
  83. agent: "agent",
  84. path: { cwd: "/", root: "/" },
  85. cost: 0,
  86. tokens: {
  87. input: 0,
  88. output: 0,
  89. reasoning: 0,
  90. cache: { read: 0, write: 0 },
  91. },
  92. } as unknown as MessageV2.Assistant
  93. }
  94. function basePart(messageID: string, id: string) {
  95. return {
  96. id,
  97. sessionID,
  98. messageID,
  99. }
  100. }
  101. describe("session.message-v2.toModelMessage", () => {
  102. test("filters out messages with no parts", () => {
  103. const input: MessageV2.WithParts[] = [
  104. {
  105. info: userInfo("m-empty"),
  106. parts: [],
  107. },
  108. {
  109. info: userInfo("m-user"),
  110. parts: [
  111. {
  112. ...basePart("m-user", "p1"),
  113. type: "text",
  114. text: "hello",
  115. },
  116. ] as MessageV2.Part[],
  117. },
  118. ]
  119. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  120. {
  121. role: "user",
  122. content: [{ type: "text", text: "hello" }],
  123. },
  124. ])
  125. })
  126. test("filters out messages with only ignored parts", () => {
  127. const messageID = "m-user"
  128. const input: MessageV2.WithParts[] = [
  129. {
  130. info: userInfo(messageID),
  131. parts: [
  132. {
  133. ...basePart(messageID, "p1"),
  134. type: "text",
  135. text: "ignored",
  136. ignored: true,
  137. },
  138. ] as MessageV2.Part[],
  139. },
  140. ]
  141. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
  142. })
  143. test("includes synthetic text parts", () => {
  144. const messageID = "m-user"
  145. const input: MessageV2.WithParts[] = [
  146. {
  147. info: userInfo(messageID),
  148. parts: [
  149. {
  150. ...basePart(messageID, "p1"),
  151. type: "text",
  152. text: "hello",
  153. synthetic: true,
  154. },
  155. ] as MessageV2.Part[],
  156. },
  157. {
  158. info: assistantInfo("m-assistant", messageID),
  159. parts: [
  160. {
  161. ...basePart("m-assistant", "a1"),
  162. type: "text",
  163. text: "assistant",
  164. synthetic: true,
  165. },
  166. ] as MessageV2.Part[],
  167. },
  168. ]
  169. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  170. {
  171. role: "user",
  172. content: [{ type: "text", text: "hello" }],
  173. },
  174. {
  175. role: "assistant",
  176. content: [{ type: "text", text: "assistant" }],
  177. },
  178. ])
  179. })
  180. test("converts user text/file parts and injects compaction/subtask prompts", () => {
  181. const messageID = "m-user"
  182. const input: MessageV2.WithParts[] = [
  183. {
  184. info: userInfo(messageID),
  185. parts: [
  186. {
  187. ...basePart(messageID, "p1"),
  188. type: "text",
  189. text: "hello",
  190. },
  191. {
  192. ...basePart(messageID, "p2"),
  193. type: "text",
  194. text: "ignored",
  195. ignored: true,
  196. },
  197. {
  198. ...basePart(messageID, "p3"),
  199. type: "file",
  200. mime: "image/png",
  201. filename: "img.png",
  202. url: "https://example.com/img.png",
  203. },
  204. {
  205. ...basePart(messageID, "p4"),
  206. type: "file",
  207. mime: "text/plain",
  208. filename: "note.txt",
  209. url: "https://example.com/note.txt",
  210. },
  211. {
  212. ...basePart(messageID, "p5"),
  213. type: "file",
  214. mime: "application/x-directory",
  215. filename: "dir",
  216. url: "https://example.com/dir",
  217. },
  218. {
  219. ...basePart(messageID, "p6"),
  220. type: "compaction",
  221. auto: true,
  222. },
  223. {
  224. ...basePart(messageID, "p7"),
  225. type: "subtask",
  226. prompt: "prompt",
  227. description: "desc",
  228. agent: "agent",
  229. },
  230. ] as MessageV2.Part[],
  231. },
  232. ]
  233. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  234. {
  235. role: "user",
  236. content: [
  237. { type: "text", text: "hello" },
  238. {
  239. type: "file",
  240. mediaType: "image/png",
  241. filename: "img.png",
  242. data: "https://example.com/img.png",
  243. },
  244. { type: "text", text: "What did we do so far?" },
  245. { type: "text", text: "The following tool was executed by the user" },
  246. ],
  247. },
  248. ])
  249. })
  250. test("converts assistant tool completion into tool-call + tool-result messages with attachments", () => {
  251. const userID = "m-user"
  252. const assistantID = "m-assistant"
  253. const input: MessageV2.WithParts[] = [
  254. {
  255. info: userInfo(userID),
  256. parts: [
  257. {
  258. ...basePart(userID, "u1"),
  259. type: "text",
  260. text: "run tool",
  261. },
  262. ] as MessageV2.Part[],
  263. },
  264. {
  265. info: assistantInfo(assistantID, userID),
  266. parts: [
  267. {
  268. ...basePart(assistantID, "a1"),
  269. type: "text",
  270. text: "done",
  271. metadata: { openai: { assistant: "meta" } },
  272. },
  273. {
  274. ...basePart(assistantID, "a2"),
  275. type: "tool",
  276. callID: "call-1",
  277. tool: "bash",
  278. state: {
  279. status: "completed",
  280. input: { cmd: "ls" },
  281. output: "ok",
  282. title: "Bash",
  283. metadata: {},
  284. time: { start: 0, end: 1 },
  285. attachments: [
  286. {
  287. ...basePart(assistantID, "file-1"),
  288. type: "file",
  289. mime: "image/png",
  290. filename: "attachment.png",
  291. url: "data:image/png;base64,Zm9v",
  292. },
  293. ],
  294. },
  295. metadata: { openai: { tool: "meta" } },
  296. },
  297. ] as MessageV2.Part[],
  298. },
  299. ]
  300. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  301. {
  302. role: "user",
  303. content: [{ type: "text", text: "run tool" }],
  304. },
  305. {
  306. role: "assistant",
  307. content: [
  308. { type: "text", text: "done", providerOptions: { openai: { assistant: "meta" } } },
  309. {
  310. type: "tool-call",
  311. toolCallId: "call-1",
  312. toolName: "bash",
  313. input: { cmd: "ls" },
  314. providerExecuted: undefined,
  315. providerOptions: { openai: { tool: "meta" } },
  316. },
  317. ],
  318. },
  319. {
  320. role: "tool",
  321. content: [
  322. {
  323. type: "tool-result",
  324. toolCallId: "call-1",
  325. toolName: "bash",
  326. output: {
  327. type: "content",
  328. value: [
  329. { type: "text", text: "ok" },
  330. { type: "media", mediaType: "image/png", data: "Zm9v" },
  331. ],
  332. },
  333. providerOptions: { openai: { tool: "meta" } },
  334. },
  335. ],
  336. },
  337. ])
  338. })
  339. test("omits provider metadata when assistant model differs", () => {
  340. const userID = "m-user"
  341. const assistantID = "m-assistant"
  342. const input: MessageV2.WithParts[] = [
  343. {
  344. info: userInfo(userID),
  345. parts: [
  346. {
  347. ...basePart(userID, "u1"),
  348. type: "text",
  349. text: "run tool",
  350. },
  351. ] as MessageV2.Part[],
  352. },
  353. {
  354. info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }),
  355. parts: [
  356. {
  357. ...basePart(assistantID, "a1"),
  358. type: "text",
  359. text: "done",
  360. metadata: { openai: { assistant: "meta" } },
  361. },
  362. {
  363. ...basePart(assistantID, "a2"),
  364. type: "tool",
  365. callID: "call-1",
  366. tool: "bash",
  367. state: {
  368. status: "completed",
  369. input: { cmd: "ls" },
  370. output: "ok",
  371. title: "Bash",
  372. metadata: {},
  373. time: { start: 0, end: 1 },
  374. },
  375. metadata: { openai: { tool: "meta" } },
  376. },
  377. ] as MessageV2.Part[],
  378. },
  379. ]
  380. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  381. {
  382. role: "user",
  383. content: [{ type: "text", text: "run tool" }],
  384. },
  385. {
  386. role: "assistant",
  387. content: [
  388. { type: "text", text: "done" },
  389. {
  390. type: "tool-call",
  391. toolCallId: "call-1",
  392. toolName: "bash",
  393. input: { cmd: "ls" },
  394. providerExecuted: undefined,
  395. },
  396. ],
  397. },
  398. {
  399. role: "tool",
  400. content: [
  401. {
  402. type: "tool-result",
  403. toolCallId: "call-1",
  404. toolName: "bash",
  405. output: { type: "text", value: "ok" },
  406. },
  407. ],
  408. },
  409. ])
  410. })
  411. test("replaces compacted tool output with placeholder", () => {
  412. const userID = "m-user"
  413. const assistantID = "m-assistant"
  414. const input: MessageV2.WithParts[] = [
  415. {
  416. info: userInfo(userID),
  417. parts: [
  418. {
  419. ...basePart(userID, "u1"),
  420. type: "text",
  421. text: "run tool",
  422. },
  423. ] as MessageV2.Part[],
  424. },
  425. {
  426. info: assistantInfo(assistantID, userID),
  427. parts: [
  428. {
  429. ...basePart(assistantID, "a1"),
  430. type: "tool",
  431. callID: "call-1",
  432. tool: "bash",
  433. state: {
  434. status: "completed",
  435. input: { cmd: "ls" },
  436. output: "this should be cleared",
  437. title: "Bash",
  438. metadata: {},
  439. time: { start: 0, end: 1, compacted: 1 },
  440. },
  441. },
  442. ] as MessageV2.Part[],
  443. },
  444. ]
  445. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  446. {
  447. role: "user",
  448. content: [{ type: "text", text: "run tool" }],
  449. },
  450. {
  451. role: "assistant",
  452. content: [
  453. {
  454. type: "tool-call",
  455. toolCallId: "call-1",
  456. toolName: "bash",
  457. input: { cmd: "ls" },
  458. providerExecuted: undefined,
  459. },
  460. ],
  461. },
  462. {
  463. role: "tool",
  464. content: [
  465. {
  466. type: "tool-result",
  467. toolCallId: "call-1",
  468. toolName: "bash",
  469. output: { type: "text", value: "[Old tool result content cleared]" },
  470. },
  471. ],
  472. },
  473. ])
  474. })
  475. test("converts assistant tool error into error-text tool result", () => {
  476. const userID = "m-user"
  477. const assistantID = "m-assistant"
  478. const input: MessageV2.WithParts[] = [
  479. {
  480. info: userInfo(userID),
  481. parts: [
  482. {
  483. ...basePart(userID, "u1"),
  484. type: "text",
  485. text: "run tool",
  486. },
  487. ] as MessageV2.Part[],
  488. },
  489. {
  490. info: assistantInfo(assistantID, userID),
  491. parts: [
  492. {
  493. ...basePart(assistantID, "a1"),
  494. type: "tool",
  495. callID: "call-1",
  496. tool: "bash",
  497. state: {
  498. status: "error",
  499. input: { cmd: "ls" },
  500. error: "nope",
  501. time: { start: 0, end: 1 },
  502. metadata: {},
  503. },
  504. metadata: { openai: { tool: "meta" } },
  505. },
  506. ] as MessageV2.Part[],
  507. },
  508. ]
  509. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  510. {
  511. role: "user",
  512. content: [{ type: "text", text: "run tool" }],
  513. },
  514. {
  515. role: "assistant",
  516. content: [
  517. {
  518. type: "tool-call",
  519. toolCallId: "call-1",
  520. toolName: "bash",
  521. input: { cmd: "ls" },
  522. providerExecuted: undefined,
  523. providerOptions: { openai: { tool: "meta" } },
  524. },
  525. ],
  526. },
  527. {
  528. role: "tool",
  529. content: [
  530. {
  531. type: "tool-result",
  532. toolCallId: "call-1",
  533. toolName: "bash",
  534. output: { type: "error-text", value: "nope" },
  535. providerOptions: { openai: { tool: "meta" } },
  536. },
  537. ],
  538. },
  539. ])
  540. })
  541. test("filters assistant messages with non-abort errors", () => {
  542. const assistantID = "m-assistant"
  543. const input: MessageV2.WithParts[] = [
  544. {
  545. info: assistantInfo(
  546. assistantID,
  547. "m-parent",
  548. new MessageV2.APIError({ message: "boom", isRetryable: true }).toObject() as MessageV2.APIError,
  549. ),
  550. parts: [
  551. {
  552. ...basePart(assistantID, "a1"),
  553. type: "text",
  554. text: "should not render",
  555. },
  556. ] as MessageV2.Part[],
  557. },
  558. ]
  559. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
  560. })
  561. test("includes aborted assistant messages only when they have non-step-start/reasoning content", () => {
  562. const assistantID1 = "m-assistant-1"
  563. const assistantID2 = "m-assistant-2"
  564. const aborted = new MessageV2.AbortedError({ message: "aborted" }).toObject() as MessageV2.Assistant["error"]
  565. const input: MessageV2.WithParts[] = [
  566. {
  567. info: assistantInfo(assistantID1, "m-parent", aborted),
  568. parts: [
  569. {
  570. ...basePart(assistantID1, "a1"),
  571. type: "reasoning",
  572. text: "thinking",
  573. time: { start: 0 },
  574. },
  575. {
  576. ...basePart(assistantID1, "a2"),
  577. type: "text",
  578. text: "partial answer",
  579. },
  580. ] as MessageV2.Part[],
  581. },
  582. {
  583. info: assistantInfo(assistantID2, "m-parent", aborted),
  584. parts: [
  585. {
  586. ...basePart(assistantID2, "b1"),
  587. type: "step-start",
  588. },
  589. {
  590. ...basePart(assistantID2, "b2"),
  591. type: "reasoning",
  592. text: "thinking",
  593. time: { start: 0 },
  594. },
  595. ] as MessageV2.Part[],
  596. },
  597. ]
  598. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  599. {
  600. role: "assistant",
  601. content: [
  602. { type: "reasoning", text: "thinking", providerOptions: undefined },
  603. { type: "text", text: "partial answer" },
  604. ],
  605. },
  606. ])
  607. })
  608. test("splits assistant messages on step-start boundaries", () => {
  609. const assistantID = "m-assistant"
  610. const input: MessageV2.WithParts[] = [
  611. {
  612. info: assistantInfo(assistantID, "m-parent"),
  613. parts: [
  614. {
  615. ...basePart(assistantID, "p1"),
  616. type: "text",
  617. text: "first",
  618. },
  619. {
  620. ...basePart(assistantID, "p2"),
  621. type: "step-start",
  622. },
  623. {
  624. ...basePart(assistantID, "p3"),
  625. type: "text",
  626. text: "second",
  627. },
  628. ] as MessageV2.Part[],
  629. },
  630. ]
  631. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  632. {
  633. role: "assistant",
  634. content: [{ type: "text", text: "first" }],
  635. },
  636. {
  637. role: "assistant",
  638. content: [{ type: "text", text: "second" }],
  639. },
  640. ])
  641. })
  642. test("drops messages that only contain step-start parts", () => {
  643. const assistantID = "m-assistant"
  644. const input: MessageV2.WithParts[] = [
  645. {
  646. info: assistantInfo(assistantID, "m-parent"),
  647. parts: [
  648. {
  649. ...basePart(assistantID, "p1"),
  650. type: "step-start",
  651. },
  652. ] as MessageV2.Part[],
  653. },
  654. ]
  655. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
  656. })
  657. test("converts pending/running tool calls to error results to prevent dangling tool_use", () => {
  658. const userID = "m-user"
  659. const assistantID = "m-assistant"
  660. const input: MessageV2.WithParts[] = [
  661. {
  662. info: userInfo(userID),
  663. parts: [
  664. {
  665. ...basePart(userID, "u1"),
  666. type: "text",
  667. text: "run tool",
  668. },
  669. ] as MessageV2.Part[],
  670. },
  671. {
  672. info: assistantInfo(assistantID, userID),
  673. parts: [
  674. {
  675. ...basePart(assistantID, "a1"),
  676. type: "tool",
  677. callID: "call-pending",
  678. tool: "bash",
  679. state: {
  680. status: "pending",
  681. input: { cmd: "ls" },
  682. raw: "",
  683. },
  684. },
  685. {
  686. ...basePart(assistantID, "a2"),
  687. type: "tool",
  688. callID: "call-running",
  689. tool: "read",
  690. state: {
  691. status: "running",
  692. input: { path: "/tmp" },
  693. time: { start: 0 },
  694. },
  695. },
  696. ] as MessageV2.Part[],
  697. },
  698. ]
  699. const result = MessageV2.toModelMessages(input, model)
  700. expect(result).toStrictEqual([
  701. {
  702. role: "user",
  703. content: [{ type: "text", text: "run tool" }],
  704. },
  705. {
  706. role: "assistant",
  707. content: [
  708. {
  709. type: "tool-call",
  710. toolCallId: "call-pending",
  711. toolName: "bash",
  712. input: { cmd: "ls" },
  713. providerExecuted: undefined,
  714. },
  715. {
  716. type: "tool-call",
  717. toolCallId: "call-running",
  718. toolName: "read",
  719. input: { path: "/tmp" },
  720. providerExecuted: undefined,
  721. },
  722. ],
  723. },
  724. {
  725. role: "tool",
  726. content: [
  727. {
  728. type: "tool-result",
  729. toolCallId: "call-pending",
  730. toolName: "bash",
  731. output: { type: "error-text", value: "[Tool execution was interrupted]" },
  732. },
  733. {
  734. type: "tool-result",
  735. toolCallId: "call-running",
  736. toolName: "read",
  737. output: { type: "error-text", value: "[Tool execution was interrupted]" },
  738. },
  739. ],
  740. },
  741. ])
  742. })
  743. })
  744. describe("session.message-v2.fromError", () => {
  745. test("serializes context_length_exceeded as ContextOverflowError", () => {
  746. const input = {
  747. type: "error",
  748. error: {
  749. code: "context_length_exceeded",
  750. },
  751. }
  752. const result = MessageV2.fromError(input, { providerID: "test" })
  753. expect(result).toStrictEqual({
  754. name: "ContextOverflowError",
  755. data: {
  756. message: "Input exceeds context window of this model",
  757. responseBody: JSON.stringify(input),
  758. },
  759. })
  760. })
  761. test("serializes response error codes", () => {
  762. const cases = [
  763. {
  764. code: "insufficient_quota",
  765. message: "Quota exceeded. Check your plan and billing details.",
  766. },
  767. {
  768. code: "usage_not_included",
  769. message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.",
  770. },
  771. {
  772. code: "invalid_prompt",
  773. message: "Invalid prompt from test",
  774. },
  775. ]
  776. cases.forEach((item) => {
  777. const input = {
  778. type: "error",
  779. error: {
  780. code: item.code,
  781. message: item.code === "invalid_prompt" ? item.message : undefined,
  782. },
  783. }
  784. const result = MessageV2.fromError(input, { providerID: "test" })
  785. expect(result).toStrictEqual({
  786. name: "APIError",
  787. data: {
  788. message: item.message,
  789. isRetryable: false,
  790. responseBody: JSON.stringify(input),
  791. },
  792. })
  793. })
  794. })
  795. test("maps github-copilot 403 to reauth guidance", () => {
  796. const error = new APICallError({
  797. message: "forbidden",
  798. url: "https://api.githubcopilot.com/v1/chat/completions",
  799. requestBodyValues: {},
  800. statusCode: 403,
  801. responseHeaders: { "content-type": "application/json" },
  802. responseBody: '{"error":"forbidden"}',
  803. isRetryable: false,
  804. })
  805. const result = MessageV2.fromError(error, { providerID: "github-copilot" })
  806. expect(result).toStrictEqual({
  807. name: "APIError",
  808. data: {
  809. message:
  810. "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode.",
  811. statusCode: 403,
  812. isRetryable: false,
  813. responseHeaders: { "content-type": "application/json" },
  814. responseBody: '{"error":"forbidden"}',
  815. metadata: {
  816. url: "https://api.githubcopilot.com/v1/chat/completions",
  817. },
  818. },
  819. })
  820. })
  821. test("detects context overflow from APICallError provider messages", () => {
  822. const cases = [
  823. "prompt is too long: 213462 tokens > 200000 maximum",
  824. "Your input exceeds the context window of this model",
  825. "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)",
  826. "Please reduce the length of the messages or completion",
  827. "400 status code (no body)",
  828. "413 status code (no body)",
  829. ]
  830. cases.forEach((message) => {
  831. const error = new APICallError({
  832. message,
  833. url: "https://example.com",
  834. requestBodyValues: {},
  835. statusCode: 400,
  836. responseHeaders: { "content-type": "application/json" },
  837. isRetryable: false,
  838. })
  839. const result = MessageV2.fromError(error, { providerID: "test" })
  840. expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true)
  841. })
  842. })
  843. test("does not classify 429 no body as context overflow", () => {
  844. const result = MessageV2.fromError(
  845. new APICallError({
  846. message: "429 status code (no body)",
  847. url: "https://example.com",
  848. requestBodyValues: {},
  849. statusCode: 429,
  850. responseHeaders: { "content-type": "application/json" },
  851. isRetryable: false,
  852. }),
  853. { providerID: "test" },
  854. )
  855. expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(false)
  856. expect(MessageV2.APIError.isInstance(result)).toBe(true)
  857. })
  858. test("serializes unknown inputs", () => {
  859. const result = MessageV2.fromError(123, { providerID: "test" })
  860. expect(result).toStrictEqual({
  861. name: "UnknownError",
  862. data: {
  863. message: "123",
  864. },
  865. })
  866. })
  867. })