message-v2.test.ts 19 KB

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