message-v2.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792
  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 and emits attachment message", () => {
  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: "https://example.com/attachment.png",
  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: "user",
  306. content: [
  307. { type: "text", text: "The tool bash returned the following attachments:" },
  308. {
  309. type: "file",
  310. mediaType: "image/png",
  311. filename: "attachment.png",
  312. data: "https://example.com/attachment.png",
  313. },
  314. ],
  315. },
  316. {
  317. role: "assistant",
  318. content: [
  319. { type: "text", text: "done", providerOptions: { openai: { assistant: "meta" } } },
  320. {
  321. type: "tool-call",
  322. toolCallId: "call-1",
  323. toolName: "bash",
  324. input: { cmd: "ls" },
  325. providerExecuted: undefined,
  326. providerOptions: { openai: { tool: "meta" } },
  327. },
  328. ],
  329. },
  330. {
  331. role: "tool",
  332. content: [
  333. {
  334. type: "tool-result",
  335. toolCallId: "call-1",
  336. toolName: "bash",
  337. output: { type: "text", value: "ok" },
  338. providerOptions: { openai: { tool: "meta" } },
  339. },
  340. ],
  341. },
  342. ])
  343. })
  344. test("omits provider metadata when assistant model differs", () => {
  345. const userID = "m-user"
  346. const assistantID = "m-assistant"
  347. const input: MessageV2.WithParts[] = [
  348. {
  349. info: userInfo(userID),
  350. parts: [
  351. {
  352. ...basePart(userID, "u1"),
  353. type: "text",
  354. text: "run tool",
  355. },
  356. ] as MessageV2.Part[],
  357. },
  358. {
  359. info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }),
  360. parts: [
  361. {
  362. ...basePart(assistantID, "a1"),
  363. type: "text",
  364. text: "done",
  365. metadata: { openai: { assistant: "meta" } },
  366. },
  367. {
  368. ...basePart(assistantID, "a2"),
  369. type: "tool",
  370. callID: "call-1",
  371. tool: "bash",
  372. state: {
  373. status: "completed",
  374. input: { cmd: "ls" },
  375. output: "ok",
  376. title: "Bash",
  377. metadata: {},
  378. time: { start: 0, end: 1 },
  379. },
  380. metadata: { openai: { tool: "meta" } },
  381. },
  382. ] as MessageV2.Part[],
  383. },
  384. ]
  385. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  386. {
  387. role: "user",
  388. content: [{ type: "text", text: "run tool" }],
  389. },
  390. {
  391. role: "assistant",
  392. content: [
  393. { type: "text", text: "done" },
  394. {
  395. type: "tool-call",
  396. toolCallId: "call-1",
  397. toolName: "bash",
  398. input: { cmd: "ls" },
  399. providerExecuted: undefined,
  400. },
  401. ],
  402. },
  403. {
  404. role: "tool",
  405. content: [
  406. {
  407. type: "tool-result",
  408. toolCallId: "call-1",
  409. toolName: "bash",
  410. output: { type: "text", value: "ok" },
  411. },
  412. ],
  413. },
  414. ])
  415. })
  416. test("replaces compacted tool output with placeholder", () => {
  417. const userID = "m-user"
  418. const assistantID = "m-assistant"
  419. const input: MessageV2.WithParts[] = [
  420. {
  421. info: userInfo(userID),
  422. parts: [
  423. {
  424. ...basePart(userID, "u1"),
  425. type: "text",
  426. text: "run tool",
  427. },
  428. ] as MessageV2.Part[],
  429. },
  430. {
  431. info: assistantInfo(assistantID, userID),
  432. parts: [
  433. {
  434. ...basePart(assistantID, "a1"),
  435. type: "tool",
  436. callID: "call-1",
  437. tool: "bash",
  438. state: {
  439. status: "completed",
  440. input: { cmd: "ls" },
  441. output: "this should be cleared",
  442. title: "Bash",
  443. metadata: {},
  444. time: { start: 0, end: 1, compacted: 1 },
  445. },
  446. },
  447. ] as MessageV2.Part[],
  448. },
  449. ]
  450. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  451. {
  452. role: "user",
  453. content: [{ type: "text", text: "run tool" }],
  454. },
  455. {
  456. role: "assistant",
  457. content: [
  458. {
  459. type: "tool-call",
  460. toolCallId: "call-1",
  461. toolName: "bash",
  462. input: { cmd: "ls" },
  463. providerExecuted: undefined,
  464. },
  465. ],
  466. },
  467. {
  468. role: "tool",
  469. content: [
  470. {
  471. type: "tool-result",
  472. toolCallId: "call-1",
  473. toolName: "bash",
  474. output: { type: "text", value: "[Old tool result content cleared]" },
  475. },
  476. ],
  477. },
  478. ])
  479. })
  480. test("converts assistant tool error into error-text tool result", () => {
  481. const userID = "m-user"
  482. const assistantID = "m-assistant"
  483. const input: MessageV2.WithParts[] = [
  484. {
  485. info: userInfo(userID),
  486. parts: [
  487. {
  488. ...basePart(userID, "u1"),
  489. type: "text",
  490. text: "run tool",
  491. },
  492. ] as MessageV2.Part[],
  493. },
  494. {
  495. info: assistantInfo(assistantID, userID),
  496. parts: [
  497. {
  498. ...basePart(assistantID, "a1"),
  499. type: "tool",
  500. callID: "call-1",
  501. tool: "bash",
  502. state: {
  503. status: "error",
  504. input: { cmd: "ls" },
  505. error: "nope",
  506. time: { start: 0, end: 1 },
  507. metadata: {},
  508. },
  509. metadata: { openai: { tool: "meta" } },
  510. },
  511. ] as MessageV2.Part[],
  512. },
  513. ]
  514. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  515. {
  516. role: "user",
  517. content: [{ type: "text", text: "run tool" }],
  518. },
  519. {
  520. role: "assistant",
  521. content: [
  522. {
  523. type: "tool-call",
  524. toolCallId: "call-1",
  525. toolName: "bash",
  526. input: { cmd: "ls" },
  527. providerExecuted: undefined,
  528. providerOptions: { openai: { tool: "meta" } },
  529. },
  530. ],
  531. },
  532. {
  533. role: "tool",
  534. content: [
  535. {
  536. type: "tool-result",
  537. toolCallId: "call-1",
  538. toolName: "bash",
  539. output: { type: "error-text", value: "nope" },
  540. providerOptions: { openai: { tool: "meta" } },
  541. },
  542. ],
  543. },
  544. ])
  545. })
  546. test("filters assistant messages with non-abort errors", () => {
  547. const assistantID = "m-assistant"
  548. const input: MessageV2.WithParts[] = [
  549. {
  550. info: assistantInfo(
  551. assistantID,
  552. "m-parent",
  553. new MessageV2.APIError({ message: "boom", isRetryable: true }).toObject() as MessageV2.APIError,
  554. ),
  555. parts: [
  556. {
  557. ...basePart(assistantID, "a1"),
  558. type: "text",
  559. text: "should not render",
  560. },
  561. ] as MessageV2.Part[],
  562. },
  563. ]
  564. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
  565. })
  566. test("includes aborted assistant messages only when they have non-step-start/reasoning content", () => {
  567. const assistantID1 = "m-assistant-1"
  568. const assistantID2 = "m-assistant-2"
  569. const aborted = new MessageV2.AbortedError({ message: "aborted" }).toObject() as MessageV2.Assistant["error"]
  570. const input: MessageV2.WithParts[] = [
  571. {
  572. info: assistantInfo(assistantID1, "m-parent", aborted),
  573. parts: [
  574. {
  575. ...basePart(assistantID1, "a1"),
  576. type: "reasoning",
  577. text: "thinking",
  578. time: { start: 0 },
  579. },
  580. {
  581. ...basePart(assistantID1, "a2"),
  582. type: "text",
  583. text: "partial answer",
  584. },
  585. ] as MessageV2.Part[],
  586. },
  587. {
  588. info: assistantInfo(assistantID2, "m-parent", aborted),
  589. parts: [
  590. {
  591. ...basePart(assistantID2, "b1"),
  592. type: "step-start",
  593. },
  594. {
  595. ...basePart(assistantID2, "b2"),
  596. type: "reasoning",
  597. text: "thinking",
  598. time: { start: 0 },
  599. },
  600. ] as MessageV2.Part[],
  601. },
  602. ]
  603. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  604. {
  605. role: "assistant",
  606. content: [
  607. { type: "reasoning", text: "thinking", providerOptions: undefined },
  608. { type: "text", text: "partial answer" },
  609. ],
  610. },
  611. ])
  612. })
  613. test("splits assistant messages on step-start boundaries", () => {
  614. const assistantID = "m-assistant"
  615. const input: MessageV2.WithParts[] = [
  616. {
  617. info: assistantInfo(assistantID, "m-parent"),
  618. parts: [
  619. {
  620. ...basePart(assistantID, "p1"),
  621. type: "text",
  622. text: "first",
  623. },
  624. {
  625. ...basePart(assistantID, "p2"),
  626. type: "step-start",
  627. },
  628. {
  629. ...basePart(assistantID, "p3"),
  630. type: "text",
  631. text: "second",
  632. },
  633. ] as MessageV2.Part[],
  634. },
  635. ]
  636. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
  637. {
  638. role: "assistant",
  639. content: [{ type: "text", text: "first" }],
  640. },
  641. {
  642. role: "assistant",
  643. content: [{ type: "text", text: "second" }],
  644. },
  645. ])
  646. })
  647. test("drops messages that only contain step-start parts", () => {
  648. const assistantID = "m-assistant"
  649. const input: MessageV2.WithParts[] = [
  650. {
  651. info: assistantInfo(assistantID, "m-parent"),
  652. parts: [
  653. {
  654. ...basePart(assistantID, "p1"),
  655. type: "step-start",
  656. },
  657. ] as MessageV2.Part[],
  658. },
  659. ]
  660. expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
  661. })
  662. test("converts pending/running tool calls to error results to prevent dangling tool_use", () => {
  663. const userID = "m-user"
  664. const assistantID = "m-assistant"
  665. const input: MessageV2.WithParts[] = [
  666. {
  667. info: userInfo(userID),
  668. parts: [
  669. {
  670. ...basePart(userID, "u1"),
  671. type: "text",
  672. text: "run tool",
  673. },
  674. ] as MessageV2.Part[],
  675. },
  676. {
  677. info: assistantInfo(assistantID, userID),
  678. parts: [
  679. {
  680. ...basePart(assistantID, "a1"),
  681. type: "tool",
  682. callID: "call-pending",
  683. tool: "bash",
  684. state: {
  685. status: "pending",
  686. input: { cmd: "ls" },
  687. raw: "",
  688. },
  689. },
  690. {
  691. ...basePart(assistantID, "a2"),
  692. type: "tool",
  693. callID: "call-running",
  694. tool: "read",
  695. state: {
  696. status: "running",
  697. input: { path: "/tmp" },
  698. time: { start: 0 },
  699. },
  700. },
  701. ] as MessageV2.Part[],
  702. },
  703. ]
  704. const result = MessageV2.toModelMessages(input, model)
  705. expect(result).toStrictEqual([
  706. {
  707. role: "user",
  708. content: [{ type: "text", text: "run tool" }],
  709. },
  710. {
  711. role: "assistant",
  712. content: [
  713. {
  714. type: "tool-call",
  715. toolCallId: "call-pending",
  716. toolName: "bash",
  717. input: { cmd: "ls" },
  718. providerExecuted: undefined,
  719. },
  720. {
  721. type: "tool-call",
  722. toolCallId: "call-running",
  723. toolName: "read",
  724. input: { path: "/tmp" },
  725. providerExecuted: undefined,
  726. },
  727. ],
  728. },
  729. {
  730. role: "tool",
  731. content: [
  732. {
  733. type: "tool-result",
  734. toolCallId: "call-pending",
  735. toolName: "bash",
  736. output: { type: "error-text", value: "[Tool execution was interrupted]" },
  737. },
  738. {
  739. type: "tool-result",
  740. toolCallId: "call-running",
  741. toolName: "read",
  742. output: { type: "error-text", value: "[Tool execution was interrupted]" },
  743. },
  744. ],
  745. },
  746. ])
  747. })
  748. })