session-error.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import { describe, expect, test } from "bun:test"
  2. import {
  3. AuthenticationReason,
  4. ContentPolicyReason,
  5. InvalidProviderOutputReason,
  6. InvalidRequestReason,
  7. AIError,
  8. NoRouteReason,
  9. ModelID,
  10. ProviderID,
  11. ProviderInternalReason,
  12. QuotaExceededReason,
  13. RateLimitReason,
  14. TransportReason,
  15. UnknownProviderReason,
  16. ToolFailure,
  17. HttpContext,
  18. HttpRequestDetails,
  19. HttpResponseDetails,
  20. } from "@opencode-ai/ai"
  21. import { Permission } from "@opencode-ai/core/permission"
  22. import { ID } from "@opencode-ai/core/model"
  23. import { ModelResolver } from "@opencode-ai/core/model-resolver"
  24. import { Provider } from "@opencode-ai/core/provider"
  25. import { Tool } from "@opencode-ai/schema/tool"
  26. import { toSessionError } from "@opencode-ai/core/session/to-session-error"
  27. import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
  28. const llm = (reason: AIError["reason"]) => new AIError({ module: "test", method: "stream", reason })
  29. describe("toSessionError", () => {
  30. test("maps every AI error reason to the open wire type", () => {
  31. expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
  32. type: "provider.rate-limit",
  33. message: "rate",
  34. })
  35. expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
  36. "provider.auth",
  37. )
  38. expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
  39. expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
  40. expect(
  41. toSessionError(llm(new TransportReason({ message: "transport", transport: "http", operation: "request" }))).type,
  42. ).toBe("provider.transport")
  43. expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
  44. "provider.internal",
  45. )
  46. expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
  47. "provider.invalid-output",
  48. )
  49. expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
  50. expect(
  51. toSessionError(
  52. llm(
  53. new NoRouteReason({
  54. route: "route",
  55. provider: ProviderID.make("provider"),
  56. model: ModelID.make("model"),
  57. }),
  58. ),
  59. ).type,
  60. ).toBe("provider.no-route")
  61. expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
  62. })
  63. test("preserves the permission rejection type without exposing internal fields", () => {
  64. const blocked = new Permission.BlockedError({ rules: [], permission: "external_directory", resources: [] })
  65. expect(toSessionError(blocked)).toEqual({
  66. type: "permission.rejected",
  67. message: "Permission denied: external_directory",
  68. })
  69. expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({
  70. type: "permission.rejected",
  71. message: "Permission denied: external_directory",
  72. })
  73. expect(toSessionError(new Tool.Error({ message: "failed" }))).toEqual({
  74. type: "tool.execution",
  75. message: "failed",
  76. })
  77. })
  78. test("preserves provider HTTP status", () => {
  79. const http = new HttpContext({
  80. request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
  81. response: new HttpResponseDetails({ status: 413, headers: {} }),
  82. })
  83. expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
  84. type: "provider.invalid-request",
  85. message: "too large",
  86. status: 413,
  87. })
  88. expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
  89. type: "provider.internal",
  90. message: "bad gateway",
  91. status: 502,
  92. })
  93. })
  94. test("preserves unresolved provider endpoint errors", () => {
  95. const error = new ModelResolver.UnresolvedProviderVariablesError({
  96. providerID: Provider.ID.make("cloudflare-workers-ai"),
  97. modelID: ID.make("model"),
  98. variables: ["CLOUDFLARE_ACCOUNT_ID"],
  99. })
  100. expect(toSessionError(error)).toEqual({
  101. type: "provider.no-route",
  102. message:
  103. "Cannot initialize cloudflare-workers-ai/model: CLOUDFLARE_ACCOUNT_ID is required to resolve the provider endpoint",
  104. })
  105. })
  106. test("retries only rate limits, provider-internal failures, and transport failures", () => {
  107. const eligible = [
  108. llm(new RateLimitReason({ message: "rate" })),
  109. llm(new ProviderInternalReason({ message: "internal", status: 500 })),
  110. llm(new TransportReason({ message: "transport", transport: "http", operation: "request" })),
  111. ]
  112. const ineligible = [
  113. llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
  114. llm(new QuotaExceededReason({ message: "quota" })),
  115. llm(new ContentPolicyReason({ message: "blocked" })),
  116. llm(new InvalidProviderOutputReason({ message: "output" })),
  117. llm(new InvalidRequestReason({ message: "request" })),
  118. llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
  119. llm(new UnknownProviderReason({ message: "unknown" })),
  120. ]
  121. expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
  122. expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
  123. })
  124. test("retries transport failures only when delivery is absent or not sent", () => {
  125. const retryable = [
  126. llm(new TransportReason({ message: "http transport", transport: "http", operation: "request" })),
  127. llm(
  128. new TransportReason({
  129. message: "connect failed",
  130. transport: "websocket",
  131. operation: "request",
  132. delivery: "not-sent",
  133. phase: "connect",
  134. }),
  135. ),
  136. ]
  137. const ineligible = [
  138. llm(
  139. new TransportReason({
  140. message: "send uncertain",
  141. transport: "websocket",
  142. operation: "write",
  143. delivery: "ambiguous",
  144. phase: "send",
  145. }),
  146. ),
  147. llm(
  148. new TransportReason({
  149. message: "response interrupted",
  150. transport: "websocket",
  151. operation: "read",
  152. delivery: "accepted",
  153. phase: "receive",
  154. }),
  155. ),
  156. llm(
  157. new TransportReason({
  158. message: "continuation rejected",
  159. transport: "websocket",
  160. operation: "read",
  161. delivery: "rejected",
  162. recovery: "retry-full",
  163. phase: "receive",
  164. }),
  165. ),
  166. ]
  167. expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true])
  168. expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false])
  169. })
  170. })