session-error.test.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  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 { Tool } from "@opencode-ai/schema/tool"
  23. import { toSessionError } from "@opencode-ai/core/session/to-session-error"
  24. import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
  25. const llm = (reason: AIError["reason"]) => new AIError({ module: "test", method: "stream", reason })
  26. describe("toSessionError", () => {
  27. test("maps every AI error reason to the open wire type", () => {
  28. expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
  29. type: "provider.rate-limit",
  30. message: "rate",
  31. })
  32. expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
  33. "provider.auth",
  34. )
  35. expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
  36. expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
  37. expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
  38. expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
  39. "provider.internal",
  40. )
  41. expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
  42. "provider.invalid-output",
  43. )
  44. expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
  45. expect(
  46. toSessionError(
  47. llm(
  48. new NoRouteReason({
  49. route: "route",
  50. provider: ProviderID.make("provider"),
  51. model: ModelID.make("model"),
  52. }),
  53. ),
  54. ).type,
  55. ).toBe("provider.no-route")
  56. expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
  57. })
  58. test("preserves the permission rejection type without exposing internal fields", () => {
  59. const blocked = new Permission.BlockedError({ rules: [], permission: "external_directory", resources: [] })
  60. expect(toSessionError(blocked)).toEqual({
  61. type: "permission.rejected",
  62. message: "Permission denied: external_directory",
  63. })
  64. expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({
  65. type: "permission.rejected",
  66. message: "Permission denied: external_directory",
  67. })
  68. expect(toSessionError(new Tool.Error({ message: "failed" }))).toEqual({
  69. type: "tool.execution",
  70. message: "failed",
  71. })
  72. })
  73. test("preserves provider HTTP status", () => {
  74. const http = new HttpContext({
  75. request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
  76. response: new HttpResponseDetails({ status: 413, headers: {} }),
  77. })
  78. expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
  79. type: "provider.invalid-request",
  80. message: "too large",
  81. status: 413,
  82. })
  83. expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
  84. type: "provider.internal",
  85. message: "bad gateway",
  86. status: 502,
  87. })
  88. })
  89. test("retries only rate limits, provider-internal failures, and transport failures", () => {
  90. const eligible = [
  91. llm(new RateLimitReason({ message: "rate" })),
  92. llm(new ProviderInternalReason({ message: "internal", status: 500 })),
  93. llm(new TransportReason({ message: "transport" })),
  94. ]
  95. const ineligible = [
  96. llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
  97. llm(new QuotaExceededReason({ message: "quota" })),
  98. llm(new ContentPolicyReason({ message: "blocked" })),
  99. llm(new InvalidProviderOutputReason({ message: "output" })),
  100. llm(new InvalidRequestReason({ message: "request" })),
  101. llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
  102. llm(new UnknownProviderReason({ message: "unknown" })),
  103. ]
  104. expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
  105. expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
  106. })
  107. })