content.test.ts 6.4 KB

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