message-v2.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  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: "user",
  251. content: [
  252. { type: "text", text: "Tool bash returned an attachment:" },
  253. {
  254. type: "file",
  255. mediaType: "image/png",
  256. filename: "attachment.png",
  257. data: "https://example.com/attachment.png",
  258. },
  259. ],
  260. },
  261. {
  262. role: "assistant",
  263. content: [
  264. { type: "text", text: "done", providerOptions: { openai: { assistant: "meta" } } },
  265. {
  266. type: "tool-call",
  267. toolCallId: "call-1",
  268. toolName: "bash",
  269. input: { cmd: "ls" },
  270. providerExecuted: undefined,
  271. providerOptions: { openai: { tool: "meta" } },
  272. },
  273. ],
  274. },
  275. {
  276. role: "tool",
  277. content: [
  278. {
  279. type: "tool-result",
  280. toolCallId: "call-1",
  281. toolName: "bash",
  282. output: { type: "text", value: "ok" },
  283. providerOptions: { openai: { tool: "meta" } },
  284. },
  285. ],
  286. },
  287. ])
  288. })
  289. test("replaces compacted tool output with placeholder", () => {
  290. const userID = "m-user"
  291. const assistantID = "m-assistant"
  292. const input: MessageV2.WithParts[] = [
  293. {
  294. info: userInfo(userID),
  295. parts: [
  296. {
  297. ...basePart(userID, "u1"),
  298. type: "text",
  299. text: "run tool",
  300. },
  301. ] as MessageV2.Part[],
  302. },
  303. {
  304. info: assistantInfo(assistantID, userID),
  305. parts: [
  306. {
  307. ...basePart(assistantID, "a1"),
  308. type: "tool",
  309. callID: "call-1",
  310. tool: "bash",
  311. state: {
  312. status: "completed",
  313. input: { cmd: "ls" },
  314. output: "this should be cleared",
  315. title: "Bash",
  316. metadata: {},
  317. time: { start: 0, end: 1, compacted: 1 },
  318. },
  319. },
  320. ] as MessageV2.Part[],
  321. },
  322. ]
  323. expect(MessageV2.toModelMessage(input)).toStrictEqual([
  324. {
  325. role: "user",
  326. content: [{ type: "text", text: "run tool" }],
  327. },
  328. {
  329. role: "assistant",
  330. content: [
  331. {
  332. type: "tool-call",
  333. toolCallId: "call-1",
  334. toolName: "bash",
  335. input: { cmd: "ls" },
  336. providerExecuted: undefined,
  337. },
  338. ],
  339. },
  340. {
  341. role: "tool",
  342. content: [
  343. {
  344. type: "tool-result",
  345. toolCallId: "call-1",
  346. toolName: "bash",
  347. output: { type: "text", value: "[Old tool result content cleared]" },
  348. },
  349. ],
  350. },
  351. ])
  352. })
  353. test("converts assistant tool error into error-text tool result", () => {
  354. const userID = "m-user"
  355. const assistantID = "m-assistant"
  356. const input: MessageV2.WithParts[] = [
  357. {
  358. info: userInfo(userID),
  359. parts: [
  360. {
  361. ...basePart(userID, "u1"),
  362. type: "text",
  363. text: "run tool",
  364. },
  365. ] as MessageV2.Part[],
  366. },
  367. {
  368. info: assistantInfo(assistantID, userID),
  369. parts: [
  370. {
  371. ...basePart(assistantID, "a1"),
  372. type: "tool",
  373. callID: "call-1",
  374. tool: "bash",
  375. state: {
  376. status: "error",
  377. input: { cmd: "ls" },
  378. error: "nope",
  379. time: { start: 0, end: 1 },
  380. metadata: {},
  381. },
  382. metadata: { openai: { tool: "meta" } },
  383. },
  384. ] as MessageV2.Part[],
  385. },
  386. ]
  387. expect(MessageV2.toModelMessage(input)).toStrictEqual([
  388. {
  389. role: "user",
  390. content: [{ type: "text", text: "run tool" }],
  391. },
  392. {
  393. role: "assistant",
  394. content: [
  395. {
  396. type: "tool-call",
  397. toolCallId: "call-1",
  398. toolName: "bash",
  399. input: { cmd: "ls" },
  400. providerExecuted: undefined,
  401. providerOptions: { openai: { tool: "meta" } },
  402. },
  403. ],
  404. },
  405. {
  406. role: "tool",
  407. content: [
  408. {
  409. type: "tool-result",
  410. toolCallId: "call-1",
  411. toolName: "bash",
  412. output: { type: "error-text", value: "nope" },
  413. providerOptions: { openai: { tool: "meta" } },
  414. },
  415. ],
  416. },
  417. ])
  418. })
  419. test("filters assistant messages with non-abort errors", () => {
  420. const assistantID = "m-assistant"
  421. const input: MessageV2.WithParts[] = [
  422. {
  423. info: assistantInfo(
  424. assistantID,
  425. "m-parent",
  426. new MessageV2.APIError({ message: "boom", isRetryable: true }).toObject() as MessageV2.APIError,
  427. ),
  428. parts: [
  429. {
  430. ...basePart(assistantID, "a1"),
  431. type: "text",
  432. text: "should not render",
  433. },
  434. ] as MessageV2.Part[],
  435. },
  436. ]
  437. expect(MessageV2.toModelMessage(input)).toStrictEqual([])
  438. })
  439. test("includes aborted assistant messages only when they have non-step-start/reasoning content", () => {
  440. const assistantID1 = "m-assistant-1"
  441. const assistantID2 = "m-assistant-2"
  442. const aborted = new MessageV2.AbortedError({ message: "aborted" }).toObject() as MessageV2.Assistant["error"]
  443. const input: MessageV2.WithParts[] = [
  444. {
  445. info: assistantInfo(assistantID1, "m-parent", aborted),
  446. parts: [
  447. {
  448. ...basePart(assistantID1, "a1"),
  449. type: "reasoning",
  450. text: "thinking",
  451. time: { start: 0 },
  452. },
  453. {
  454. ...basePart(assistantID1, "a2"),
  455. type: "text",
  456. text: "partial answer",
  457. },
  458. ] as MessageV2.Part[],
  459. },
  460. {
  461. info: assistantInfo(assistantID2, "m-parent", aborted),
  462. parts: [
  463. {
  464. ...basePart(assistantID2, "b1"),
  465. type: "step-start",
  466. },
  467. {
  468. ...basePart(assistantID2, "b2"),
  469. type: "reasoning",
  470. text: "thinking",
  471. time: { start: 0 },
  472. },
  473. ] as MessageV2.Part[],
  474. },
  475. ]
  476. expect(MessageV2.toModelMessage(input)).toStrictEqual([
  477. {
  478. role: "assistant",
  479. content: [
  480. { type: "reasoning", text: "thinking", providerOptions: undefined },
  481. { type: "text", text: "partial answer" },
  482. ],
  483. },
  484. ])
  485. })
  486. test("splits assistant messages on step-start boundaries", () => {
  487. const assistantID = "m-assistant"
  488. const input: MessageV2.WithParts[] = [
  489. {
  490. info: assistantInfo(assistantID, "m-parent"),
  491. parts: [
  492. {
  493. ...basePart(assistantID, "p1"),
  494. type: "text",
  495. text: "first",
  496. },
  497. {
  498. ...basePart(assistantID, "p2"),
  499. type: "step-start",
  500. },
  501. {
  502. ...basePart(assistantID, "p3"),
  503. type: "text",
  504. text: "second",
  505. },
  506. ] as MessageV2.Part[],
  507. },
  508. ]
  509. expect(MessageV2.toModelMessage(input)).toStrictEqual([
  510. {
  511. role: "assistant",
  512. content: [{ type: "text", text: "first" }],
  513. },
  514. {
  515. role: "assistant",
  516. content: [{ type: "text", text: "second" }],
  517. },
  518. ])
  519. })
  520. test("drops messages that only contain step-start parts", () => {
  521. const assistantID = "m-assistant"
  522. const input: MessageV2.WithParts[] = [
  523. {
  524. info: assistantInfo(assistantID, "m-parent"),
  525. parts: [
  526. {
  527. ...basePart(assistantID, "p1"),
  528. type: "step-start",
  529. },
  530. ] as MessageV2.Part[],
  531. },
  532. ]
  533. expect(MessageV2.toModelMessage(input)).toStrictEqual([])
  534. })
  535. test("converts pending/running tool calls to error results to prevent dangling tool_use", () => {
  536. const userID = "m-user"
  537. const assistantID = "m-assistant"
  538. const input: MessageV2.WithParts[] = [
  539. {
  540. info: userInfo(userID),
  541. parts: [
  542. {
  543. ...basePart(userID, "u1"),
  544. type: "text",
  545. text: "run tool",
  546. },
  547. ] as MessageV2.Part[],
  548. },
  549. {
  550. info: assistantInfo(assistantID, userID),
  551. parts: [
  552. {
  553. ...basePart(assistantID, "a1"),
  554. type: "tool",
  555. callID: "call-pending",
  556. tool: "bash",
  557. state: {
  558. status: "pending",
  559. input: { cmd: "ls" },
  560. raw: "",
  561. },
  562. },
  563. {
  564. ...basePart(assistantID, "a2"),
  565. type: "tool",
  566. callID: "call-running",
  567. tool: "read",
  568. state: {
  569. status: "running",
  570. input: { path: "/tmp" },
  571. time: { start: 0 },
  572. },
  573. },
  574. ] as MessageV2.Part[],
  575. },
  576. ]
  577. const result = MessageV2.toModelMessage(input)
  578. expect(result).toStrictEqual([
  579. {
  580. role: "user",
  581. content: [{ type: "text", text: "run tool" }],
  582. },
  583. {
  584. role: "assistant",
  585. content: [
  586. {
  587. type: "tool-call",
  588. toolCallId: "call-pending",
  589. toolName: "bash",
  590. input: { cmd: "ls" },
  591. providerExecuted: undefined,
  592. },
  593. {
  594. type: "tool-call",
  595. toolCallId: "call-running",
  596. toolName: "read",
  597. input: { path: "/tmp" },
  598. providerExecuted: undefined,
  599. },
  600. ],
  601. },
  602. {
  603. role: "tool",
  604. content: [
  605. {
  606. type: "tool-result",
  607. toolCallId: "call-pending",
  608. toolName: "bash",
  609. output: { type: "error-text", value: "[Tool execution was interrupted]" },
  610. },
  611. {
  612. type: "tool-result",
  613. toolCallId: "call-running",
  614. toolName: "read",
  615. output: { type: "error-text", value: "[Tool execution was interrupted]" },
  616. },
  617. ],
  618. },
  619. ])
  620. })
  621. })