message-v2.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  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. },
  284. ],
  285. },
  286. ])
  287. })
  288. test("replaces compacted tool output with placeholder", () => {
  289. const userID = "m-user"
  290. const assistantID = "m-assistant"
  291. const input: MessageV2.WithParts[] = [
  292. {
  293. info: userInfo(userID),
  294. parts: [
  295. {
  296. ...basePart(userID, "u1"),
  297. type: "text",
  298. text: "run tool",
  299. },
  300. ] as MessageV2.Part[],
  301. },
  302. {
  303. info: assistantInfo(assistantID, userID),
  304. parts: [
  305. {
  306. ...basePart(assistantID, "a1"),
  307. type: "tool",
  308. callID: "call-1",
  309. tool: "bash",
  310. state: {
  311. status: "completed",
  312. input: { cmd: "ls" },
  313. output: "this should be cleared",
  314. title: "Bash",
  315. metadata: {},
  316. time: { start: 0, end: 1, compacted: 1 },
  317. },
  318. },
  319. ] as MessageV2.Part[],
  320. },
  321. ]
  322. expect(MessageV2.toModelMessage(input)).toStrictEqual([
  323. {
  324. role: "user",
  325. content: [{ type: "text", text: "run tool" }],
  326. },
  327. {
  328. role: "assistant",
  329. content: [
  330. {
  331. type: "tool-call",
  332. toolCallId: "call-1",
  333. toolName: "bash",
  334. input: { cmd: "ls" },
  335. providerExecuted: undefined,
  336. },
  337. ],
  338. },
  339. {
  340. role: "tool",
  341. content: [
  342. {
  343. type: "tool-result",
  344. toolCallId: "call-1",
  345. toolName: "bash",
  346. output: { type: "text", value: "[Old tool result content cleared]" },
  347. },
  348. ],
  349. },
  350. ])
  351. })
  352. test("converts assistant tool error into error-text tool result", () => {
  353. const userID = "m-user"
  354. const assistantID = "m-assistant"
  355. const input: MessageV2.WithParts[] = [
  356. {
  357. info: userInfo(userID),
  358. parts: [
  359. {
  360. ...basePart(userID, "u1"),
  361. type: "text",
  362. text: "run tool",
  363. },
  364. ] as MessageV2.Part[],
  365. },
  366. {
  367. info: assistantInfo(assistantID, userID),
  368. parts: [
  369. {
  370. ...basePart(assistantID, "a1"),
  371. type: "tool",
  372. callID: "call-1",
  373. tool: "bash",
  374. state: {
  375. status: "error",
  376. input: { cmd: "ls" },
  377. error: "nope",
  378. time: { start: 0, end: 1 },
  379. metadata: {},
  380. },
  381. metadata: { openai: { tool: "meta" } },
  382. },
  383. ] as MessageV2.Part[],
  384. },
  385. ]
  386. expect(MessageV2.toModelMessage(input)).toStrictEqual([
  387. {
  388. role: "user",
  389. content: [{ type: "text", text: "run tool" }],
  390. },
  391. {
  392. role: "assistant",
  393. content: [
  394. {
  395. type: "tool-call",
  396. toolCallId: "call-1",
  397. toolName: "bash",
  398. input: { cmd: "ls" },
  399. providerExecuted: undefined,
  400. providerOptions: { openai: { tool: "meta" } },
  401. },
  402. ],
  403. },
  404. {
  405. role: "tool",
  406. content: [
  407. {
  408. type: "tool-result",
  409. toolCallId: "call-1",
  410. toolName: "bash",
  411. output: { type: "error-text", value: "nope" },
  412. },
  413. ],
  414. },
  415. ])
  416. })
  417. test("filters assistant messages with non-abort errors", () => {
  418. const assistantID = "m-assistant"
  419. const input: MessageV2.WithParts[] = [
  420. {
  421. info: assistantInfo(
  422. assistantID,
  423. "m-parent",
  424. new MessageV2.APIError({ message: "boom", isRetryable: true }).toObject() as MessageV2.APIError,
  425. ),
  426. parts: [
  427. {
  428. ...basePart(assistantID, "a1"),
  429. type: "text",
  430. text: "should not render",
  431. },
  432. ] as MessageV2.Part[],
  433. },
  434. ]
  435. expect(MessageV2.toModelMessage(input)).toStrictEqual([])
  436. })
  437. test("includes aborted assistant messages only when they have non-step-start/reasoning content", () => {
  438. const assistantID1 = "m-assistant-1"
  439. const assistantID2 = "m-assistant-2"
  440. const aborted = new MessageV2.AbortedError({ message: "aborted" }).toObject() as MessageV2.Assistant["error"]
  441. const input: MessageV2.WithParts[] = [
  442. {
  443. info: assistantInfo(assistantID1, "m-parent", aborted),
  444. parts: [
  445. {
  446. ...basePart(assistantID1, "a1"),
  447. type: "reasoning",
  448. text: "thinking",
  449. time: { start: 0 },
  450. },
  451. {
  452. ...basePart(assistantID1, "a2"),
  453. type: "text",
  454. text: "partial answer",
  455. },
  456. ] as MessageV2.Part[],
  457. },
  458. {
  459. info: assistantInfo(assistantID2, "m-parent", aborted),
  460. parts: [
  461. {
  462. ...basePart(assistantID2, "b1"),
  463. type: "step-start",
  464. },
  465. {
  466. ...basePart(assistantID2, "b2"),
  467. type: "reasoning",
  468. text: "thinking",
  469. time: { start: 0 },
  470. },
  471. ] as MessageV2.Part[],
  472. },
  473. ]
  474. expect(MessageV2.toModelMessage(input)).toStrictEqual([
  475. {
  476. role: "assistant",
  477. content: [
  478. { type: "reasoning", text: "thinking", providerOptions: undefined },
  479. { type: "text", text: "partial answer" },
  480. ],
  481. },
  482. ])
  483. })
  484. test("splits assistant messages on step-start boundaries", () => {
  485. const assistantID = "m-assistant"
  486. const input: MessageV2.WithParts[] = [
  487. {
  488. info: assistantInfo(assistantID, "m-parent"),
  489. parts: [
  490. {
  491. ...basePart(assistantID, "p1"),
  492. type: "text",
  493. text: "first",
  494. },
  495. {
  496. ...basePart(assistantID, "p2"),
  497. type: "step-start",
  498. },
  499. {
  500. ...basePart(assistantID, "p3"),
  501. type: "text",
  502. text: "second",
  503. },
  504. ] as MessageV2.Part[],
  505. },
  506. ]
  507. expect(MessageV2.toModelMessage(input)).toStrictEqual([
  508. {
  509. role: "assistant",
  510. content: [{ type: "text", text: "first" }],
  511. },
  512. {
  513. role: "assistant",
  514. content: [{ type: "text", text: "second" }],
  515. },
  516. ])
  517. })
  518. test("drops messages that only contain step-start parts", () => {
  519. const assistantID = "m-assistant"
  520. const input: MessageV2.WithParts[] = [
  521. {
  522. info: assistantInfo(assistantID, "m-parent"),
  523. parts: [
  524. {
  525. ...basePart(assistantID, "p1"),
  526. type: "step-start",
  527. },
  528. ] as MessageV2.Part[],
  529. },
  530. ]
  531. expect(MessageV2.toModelMessage(input)).toStrictEqual([])
  532. })
  533. })