content.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { describe, expect, test } from "bun:test"
  2. import type { ContentBlock } from "@agentclientprotocol/sdk"
  3. import { pathToFileURL } from "node:url"
  4. import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp/content"
  5. describe("acp content conversion", () => {
  6. test("plain text block becomes a text part", () => {
  7. expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
  8. })
  9. test("assistant-only text audience becomes synthetic", () => {
  10. expect(
  11. contentBlockToParts({
  12. type: "text",
  13. text: "internal",
  14. annotations: { audience: ["assistant"] },
  15. }),
  16. ).toEqual([{ type: "text", text: "internal", synthetic: true }])
  17. })
  18. test("user-only text audience becomes ignored", () => {
  19. expect(
  20. contentBlockToParts({
  21. type: "text",
  22. text: "visible to user",
  23. annotations: { audience: ["user"] },
  24. }),
  25. ).toEqual([{ type: "text", text: "visible to user", ignored: true }])
  26. })
  27. test("image block with base64 data becomes a data URL file part", () => {
  28. expect(
  29. contentBlockToParts({
  30. type: "image",
  31. data: "AAAA",
  32. mimeType: "image/png",
  33. uri: "file:///tmp/screenshot.png",
  34. }),
  35. ).toEqual([
  36. {
  37. type: "file",
  38. url: "data:image/png;base64,AAAA",
  39. filename: "screenshot.png",
  40. mime: "image/png",
  41. },
  42. ])
  43. })
  44. test("image block with http URI becomes a file part", () => {
  45. expect(
  46. contentBlockToParts({
  47. type: "image",
  48. data: "",
  49. mimeType: "image/jpeg",
  50. uri: "http://example.com/assets/photo.jpg",
  51. }),
  52. ).toEqual([
  53. {
  54. type: "file",
  55. url: "http://example.com/assets/photo.jpg",
  56. filename: "photo.jpg",
  57. mime: "image/jpeg",
  58. },
  59. ])
  60. })
  61. test("resource_link file URL becomes a file part with name and fallback mime", () => {
  62. expect(
  63. contentBlockToParts({
  64. type: "resource_link",
  65. uri: "file:///tmp/notes.txt",
  66. name: "client-notes.txt",
  67. }),
  68. ).toEqual([
  69. {
  70. type: "file",
  71. url: "file:///tmp/notes.txt",
  72. filename: "client-notes.txt",
  73. mime: "text/plain",
  74. },
  75. ])
  76. })
  77. test("resource_link zed path becomes a file URL part", () => {
  78. expect(
  79. contentBlockToParts({
  80. type: "resource_link",
  81. uri: "zed://workspace?path=/tmp/project/src/app.ts",
  82. name: "app.ts",
  83. mimeType: "text/typescript",
  84. }),
  85. ).toEqual([
  86. {
  87. type: "file",
  88. url: pathToFileURL("/tmp/project/src/app.ts").href,
  89. filename: "app.ts",
  90. mime: "text/typescript",
  91. },
  92. ])
  93. })
  94. test("resource with text becomes a sourced text part", () => {
  95. const result = contentBlockToParts({
  96. type: "resource",
  97. resource: {
  98. uri: "file:///tmp/context.txt#L12-L14",
  99. mimeType: "text/plain",
  100. text: "context",
  101. },
  102. })
  103. expect(result).toHaveLength(1)
  104. expect(result[0]?.type).toBe("text")
  105. if (result[0]?.type === "text") {
  106. expect(result[0].text.endsWith("\ncontext")).toBe(true)
  107. expect(result[0].text.includes("context.txt")).toBe(true)
  108. expect(result[0].text.includes("12")).toBe(true)
  109. }
  110. })
  111. test("resource with text uses URI fallback for non-file resources", () => {
  112. expect(
  113. contentBlockToParts({
  114. type: "resource",
  115. resource: {
  116. uri: "mcp://server/context",
  117. text: "context",
  118. },
  119. }),
  120. ).toEqual([{ type: "text", text: "[mcp://server/context]\ncontext" }])
  121. })
  122. test("resource with text includes file path", () => {
  123. const result = contentBlockToParts({
  124. type: "resource",
  125. resource: {
  126. uri: "file:///tmp/context.txt",
  127. mimeType: "text/plain",
  128. text: "context",
  129. },
  130. })
  131. expect(result).toHaveLength(1)
  132. expect(result[0]?.type).toBe("text")
  133. if (result[0]?.type === "text") {
  134. expect(result[0].text.endsWith("\ncontext")).toBe(true)
  135. expect(result[0].text.includes("context.txt")).toBe(true)
  136. }
  137. })
  138. test("resource with blob and mimeType becomes a data URL file part", () => {
  139. expect(
  140. contentBlockToParts({
  141. type: "resource",
  142. resource: {
  143. uri: "file:///tmp/report.pdf",
  144. mimeType: "application/pdf",
  145. blob: "JVBERg==",
  146. },
  147. }),
  148. ).toEqual([
  149. {
  150. type: "file",
  151. url: "data:application/pdf;base64,JVBERg==",
  152. filename: "report.pdf",
  153. mime: "application/pdf",
  154. },
  155. ])
  156. })
  157. test("data URL resource is preserved as a file part", () => {
  158. expect(
  159. contentBlockToParts({
  160. type: "resource",
  161. resource: {
  162. uri: "data:text/plain;base64,aGVsbG8=",
  163. mimeType: "text/plain",
  164. blob: "ignored",
  165. },
  166. }),
  167. ).toEqual([
  168. {
  169. type: "file",
  170. url: "data:text/plain;base64,aGVsbG8=",
  171. filename: "file",
  172. mime: "text/plain",
  173. },
  174. ])
  175. })
  176. test("unsupported blocks are ignored", () => {
  177. expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
  178. expect(promptContentToParts([{ type: "unknown", text: "skip" } as unknown as ContentBlock])).toEqual([])
  179. })
  180. })
  181. describe("acp replay conversion", () => {
  182. test("replays text audience annotations", () => {
  183. expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
  184. {
  185. content: {
  186. type: "text",
  187. text: "cached",
  188. annotations: { audience: ["assistant"] },
  189. },
  190. },
  191. ])
  192. })
  193. test("replays file and data URL parts as ACP content", () => {
  194. expect(
  195. partsToContentChunks([
  196. { type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
  197. { type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
  198. ]),
  199. ).toEqual([
  200. {
  201. content: {
  202. type: "resource_link",
  203. uri: "file:///tmp/readme.md",
  204. name: "readme.md",
  205. mimeType: "text/markdown",
  206. },
  207. },
  208. {
  209. content: {
  210. type: "resource",
  211. resource: {
  212. uri: pathToFileURL("note.txt").href,
  213. mimeType: "text/plain",
  214. text: "hello",
  215. },
  216. },
  217. },
  218. ])
  219. })
  220. })