session-model-request.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import { describe, expect, test } from "bun:test"
  2. import { Message, ToolResultPart } from "@opencode-ai/ai"
  3. import { boundImages, composeHttpMiddleware, unsupportedParts } from "@opencode-ai/core/session/model-request"
  4. import type { SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session"
  5. import { Effect } from "effect"
  6. import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  7. const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
  8. describe("SessionModelRequest.unsupportedParts", () => {
  9. test("replaces unsupported user media with a visible error", () => {
  10. const messages = unsupportedParts(
  11. [
  12. Message.user([
  13. Message.text("Describe this image"),
  14. { type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "logo.png" },
  15. ]),
  16. ],
  17. capabilities(["text"]),
  18. )
  19. expect(messages[0]?.content).toEqual([
  20. Message.text("Describe this image"),
  21. Message.text('ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.'),
  22. ])
  23. })
  24. test("replaces unsupported media nested in tool results", () => {
  25. const messages = unsupportedParts(
  26. [
  27. Message.tool(
  28. ToolResultPart.make({
  29. id: "call_1",
  30. name: "read",
  31. result: {
  32. type: "content",
  33. value: [
  34. { type: "text", text: "Image read successfully" },
  35. { type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "logo.png" },
  36. ],
  37. },
  38. }),
  39. ),
  40. ],
  41. capabilities(["text"]),
  42. )
  43. expect(messages[0]?.content[0]).toMatchObject({
  44. type: "tool-result",
  45. result: {
  46. type: "content",
  47. value: [
  48. { type: "text", text: "Image read successfully" },
  49. {
  50. type: "text",
  51. text: 'ERROR: Cannot read "logo.png" (this model does not support image input). Inform the user.',
  52. },
  53. ],
  54. },
  55. })
  56. })
  57. test("preserves supported media", () => {
  58. const message = Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })
  59. expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
  60. })
  61. })
  62. describe("SessionModelRequest.boundImages", () => {
  63. test("preserves images below the trigger", () => {
  64. const messages = [Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })]
  65. expect(boundImages(messages)).toBe(messages)
  66. })
  67. test("replaces oldest images until the retained payload reaches the target", () => {
  68. const image = "a".repeat(9 * 1024 * 1024)
  69. const messages = [
  70. Message.user({ type: "media", mediaType: "image/png", data: image, filename: "first.png" }),
  71. Message.user({ type: "media", mediaType: "image/png", data: image, filename: "second.png" }),
  72. Message.user({ type: "media", mediaType: "image/png", data: image, filename: "third.png" }),
  73. ]
  74. const result = boundImages(messages)
  75. expect(result[0]?.content[0]).toMatchObject({ type: "text" })
  76. expect(result[1]?.content[0]).toMatchObject({ type: "text" })
  77. expect(result[2]?.content[0]).toMatchObject({ type: "media", filename: "third.png" })
  78. })
  79. test("replaces images nested in tool results", () => {
  80. const image = "a".repeat(13 * 1024 * 1024)
  81. const result = boundImages([
  82. Message.tool(
  83. ToolResultPart.make({
  84. id: "call_1",
  85. name: "read",
  86. result: {
  87. type: "content",
  88. value: [
  89. { type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "first.png" },
  90. { type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "second.png" },
  91. ],
  92. },
  93. }),
  94. ),
  95. ])
  96. expect(result[0]?.content[0]).toMatchObject({
  97. type: "tool-result",
  98. result: {
  99. type: "content",
  100. value: [{ type: "text" }, { type: "file", name: "second.png" }],
  101. },
  102. })
  103. })
  104. })
  105. describe("SessionModelRequest.composeHttpMiddleware", () => {
  106. test("keeps WebSocket eligibility when no middleware is registered", () => {
  107. expect(composeHttpMiddleware([])).toBeUndefined()
  108. })
  109. test("forces HTTP when middleware is registered", () => {
  110. expect(composeHttpMiddleware([(request, next) => next(request)])).toBeFunction()
  111. })
  112. test("preserves middleware nesting order", async () => {
  113. const order: string[] = []
  114. const middleware =
  115. (name: string): SessionHttpMiddleware =>
  116. (request, next) =>
  117. Effect.sync(() => order.push(`${name}:before`)).pipe(
  118. Effect.andThen(next(request)),
  119. Effect.tap(() => Effect.sync(() => order.push(`${name}:after`))),
  120. )
  121. const composed = composeHttpMiddleware([middleware("first"), middleware("second")])
  122. if (!composed) throw new Error("Expected HTTP middleware")
  123. const request = HttpClientRequest.post("https://provider.test/responses").pipe(
  124. HttpClientRequest.bodyText("payload", "text/plain"),
  125. )
  126. const response = await Effect.runPromise(
  127. composed(request, (sent) =>
  128. Effect.sync(() => {
  129. order.push("send")
  130. return HttpClientResponse.fromWeb(sent, new Response("response"))
  131. }),
  132. ),
  133. )
  134. expect(order).toEqual(["second:before", "first:before", "send", "first:after", "second:after"])
  135. expect(await Effect.runPromise(response.text)).toBe("response")
  136. })
  137. test("preserves a synthetic replacement response", async () => {
  138. let sent = false
  139. const composed = composeHttpMiddleware([() => Effect.succeed(new Response("synthetic", { status: 202 }))])
  140. if (!composed) throw new Error("Expected HTTP middleware")
  141. const request = HttpClientRequest.post("https://provider.test/responses")
  142. const response = await Effect.runPromise(
  143. composed(request, (input) =>
  144. Effect.sync(() => {
  145. sent = true
  146. return HttpClientResponse.fromWeb(input, new Response("network"))
  147. }),
  148. ),
  149. )
  150. expect(sent).toBe(false)
  151. expect(response.status).toBe(202)
  152. expect(response.request.url).toBe(request.url)
  153. expect(await Effect.runPromise(response.text)).toBe("synthetic")
  154. })
  155. })