public-opencode.test.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, Schema } from "effect"
  5. import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
  6. import { tmpdir } from "./fixture/tmpdir"
  7. import { testEffect } from "./lib/effect"
  8. const it = testEffect(OpenCode.layer)
  9. describe("public native OpenCode API", () => {
  10. it.effect("exposes only the intentional Session capabilities", () =>
  11. Effect.gen(function* () {
  12. const opencode = yield* OpenCode.Service
  13. expect(Object.keys(opencode).sort()).toEqual(["sessions", "tools"])
  14. expect(Object.keys(opencode.sessions).sort()).toEqual([
  15. "context",
  16. "create",
  17. "events",
  18. "get",
  19. "interrupt",
  20. "list",
  21. "message",
  22. "messages",
  23. "prompt",
  24. "switchModel",
  25. ])
  26. expect(Session.ID.create()).toStartWith("ses_")
  27. expect(Session.MessageID.create()).toStartWith("msg_")
  28. expect(yield* opencode.sessions.list()).toBeArray()
  29. yield* opencode.tools.attach({
  30. public_tool: Tool.make({
  31. description: "Public tool",
  32. parameters: Schema.Struct({}),
  33. success: Schema.Struct({ ok: Schema.Boolean }),
  34. execute: () => Effect.succeed({ ok: true }),
  35. }),
  36. })
  37. }),
  38. )
  39. it.effect("switches to an available model and variant", () =>
  40. Effect.acquireRelease(
  41. Effect.promise(() => tmpdir()),
  42. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  43. ).pipe(
  44. Effect.flatMap((tmp) =>
  45. Effect.gen(function* () {
  46. yield* writeProvider(tmp.path)
  47. const opencode = yield* OpenCode.Service
  48. const sessionID = Session.ID.make("ses_public_switch_available")
  49. const model = ref({ variant: "fast" })
  50. yield* opencode.sessions.create({
  51. id: sessionID,
  52. location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
  53. })
  54. yield* opencode.sessions.switchModel({ sessionID, model })
  55. expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
  56. }),
  57. ),
  58. ),
  59. )
  60. it.effect("rejects missing and Location-disabled models without changing the Session", () =>
  61. Effect.acquireRelease(
  62. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  63. (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
  64. ).pipe(
  65. Effect.flatMap(([available, disabled]) =>
  66. Effect.gen(function* () {
  67. yield* writeProvider(available.path)
  68. yield* writeProvider(disabled.path, true)
  69. const opencode = yield* OpenCode.Service
  70. const availableID = Session.ID.make("ses_public_switch_exact_available")
  71. const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
  72. yield* opencode.sessions.create({
  73. id: availableID,
  74. location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
  75. })
  76. yield* opencode.sessions.create({
  77. id: disabledID,
  78. location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
  79. })
  80. yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
  81. const disabledError = yield* opencode.sessions
  82. .switchModel({ sessionID: disabledID, model: ref() })
  83. .pipe(Effect.flip)
  84. const missingError = yield* opencode.sessions
  85. .switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
  86. .pipe(Effect.flip)
  87. expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
  88. expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
  89. expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" }))
  90. expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined()
  91. }),
  92. ),
  93. ),
  94. )
  95. it.effect("rejects an unavailable variant without changing the Session", () =>
  96. Effect.acquireRelease(
  97. Effect.promise(() => tmpdir()),
  98. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  99. ).pipe(
  100. Effect.flatMap((tmp) =>
  101. Effect.gen(function* () {
  102. yield* writeProvider(tmp.path)
  103. const opencode = yield* OpenCode.Service
  104. const sessionID = Session.ID.make("ses_public_switch_variant")
  105. const selected = ref({ variant: "fast" })
  106. yield* opencode.sessions.create({
  107. id: sessionID,
  108. location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
  109. })
  110. yield* opencode.sessions.switchModel({ sessionID, model: selected })
  111. const error = yield* opencode.sessions
  112. .switchModel({ sessionID, model: ref({ variant: "unknown" }) })
  113. .pipe(Effect.flip)
  114. expect(error).toBeInstanceOf(Session.VariantUnavailableError)
  115. expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected)
  116. }),
  117. ),
  118. ),
  119. )
  120. it.effect("preserves the typed not-found error for a missing Session", () =>
  121. Effect.gen(function* () {
  122. const opencode = yield* OpenCode.Service
  123. const sessionID = Session.ID.make("ses_public_switch_missing")
  124. const error = yield* opencode.sessions
  125. .switchModel({
  126. sessionID,
  127. model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }),
  128. })
  129. .pipe(Effect.flip)
  130. expect(error).toBeInstanceOf(Session.NotFoundError)
  131. if (error instanceof Session.NotFoundError) expect(error.sessionID).toBe(sessionID)
  132. }),
  133. )
  134. })
  135. const ref = (input: { id?: string; variant?: string } = {}) =>
  136. Schema.decodeUnknownSync(Model.Ref)({
  137. id: input.id ?? "chat",
  138. providerID: "public-test",
  139. variant: input.variant,
  140. })
  141. const writeProvider = (directory: string, disabled = false) =>
  142. Effect.promise(() =>
  143. fs.writeFile(
  144. path.join(directory, "opencode.json"),
  145. JSON.stringify({
  146. providers: {
  147. "public-test": {
  148. name: "Public test",
  149. api: { type: "native", settings: {} },
  150. models: {
  151. chat: {
  152. disabled,
  153. variants: [{ id: "fast" }],
  154. },
  155. },
  156. },
  157. },
  158. }),
  159. ),
  160. )