session-error.test.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  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(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
  41. expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
  42. "provider.internal",
  43. )
  44. expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
  45. "provider.invalid-output",
  46. )
  47. expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
  48. expect(
  49. toSessionError(
  50. llm(
  51. new NoRouteReason({
  52. route: "route",
  53. provider: ProviderID.make("provider"),
  54. model: ModelID.make("model"),
  55. }),
  56. ),
  57. ).type,
  58. ).toBe("provider.no-route")
  59. expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
  60. })
  61. test("preserves the permission rejection type without exposing internal fields", () => {
  62. const blocked = new Permission.BlockedError({ rules: [], permission: "external_directory", resources: [] })
  63. expect(toSessionError(blocked)).toEqual({
  64. type: "permission.rejected",
  65. message: "Permission denied: external_directory",
  66. })
  67. expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({
  68. type: "permission.rejected",
  69. message: "Permission denied: external_directory",
  70. })
  71. expect(toSessionError(new Tool.Error({ message: "failed" }))).toEqual({
  72. type: "tool.execution",
  73. message: "failed",
  74. })
  75. })
  76. test("preserves provider HTTP status", () => {
  77. const http = new HttpContext({
  78. request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
  79. response: new HttpResponseDetails({ status: 413, headers: {} }),
  80. })
  81. expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
  82. type: "provider.invalid-request",
  83. message: "too large",
  84. status: 413,
  85. })
  86. expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
  87. type: "provider.internal",
  88. message: "bad gateway",
  89. status: 502,
  90. })
  91. })
  92. test("preserves unresolved provider endpoint errors", () => {
  93. const error = new ModelResolver.UnresolvedProviderVariablesError({
  94. providerID: Provider.ID.make("cloudflare-workers-ai"),
  95. modelID: ID.make("model"),
  96. variables: ["CLOUDFLARE_ACCOUNT_ID"],
  97. })
  98. expect(toSessionError(error)).toEqual({
  99. type: "provider.no-route",
  100. message:
  101. "Cannot initialize cloudflare-workers-ai/model: CLOUDFLARE_ACCOUNT_ID is required to resolve the provider endpoint",
  102. })
  103. })
  104. test("retries only rate limits, provider-internal failures, and transport failures", () => {
  105. const eligible = [
  106. llm(new RateLimitReason({ message: "rate" })),
  107. llm(new ProviderInternalReason({ message: "internal", status: 500 })),
  108. llm(new TransportReason({ message: "transport" })),
  109. ]
  110. const ineligible = [
  111. llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
  112. llm(new QuotaExceededReason({ message: "quota" })),
  113. llm(new ContentPolicyReason({ message: "blocked" })),
  114. llm(new InvalidProviderOutputReason({ message: "output" })),
  115. llm(new InvalidRequestReason({ message: "request" })),
  116. llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
  117. llm(new UnknownProviderReason({ message: "unknown" })),
  118. ]
  119. expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
  120. expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
  121. })
  122. })