message-v2.test.ts 16 KB

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