account.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import path from "path"
  2. import { describe, expect } from "bun:test"
  3. import { produce } from "immer"
  4. import { Effect, Fiber, Layer, Option, Stream } from "effect"
  5. import { AccountV2 } from "@opencode-ai/core/account"
  6. import { Catalog } from "@opencode-ai/core/catalog"
  7. import { EventV2 } from "@opencode-ai/core/event"
  8. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  9. import { Global } from "@opencode-ai/core/global"
  10. import { PluginV2 } from "@opencode-ai/core/plugin"
  11. import { AccountPlugin } from "@opencode-ai/core/plugin/account"
  12. import { ModelV2 } from "@opencode-ai/core/model"
  13. import { ProviderV2 } from "@opencode-ai/core/provider"
  14. import { tmpdir } from "./fixture/tmpdir"
  15. import { testEffect } from "./lib/effect"
  16. const it = testEffect(PluginV2.defaultLayer)
  17. function context(
  18. records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
  19. updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
  20. ): Catalog.Context {
  21. return {
  22. data: records,
  23. updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn),
  24. updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn),
  25. provider: {
  26. update: (providerID, fn) => {
  27. const record = records.find((item) => item.provider.id === providerID)
  28. const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
  29. if (record) record.provider = provider
  30. else records.push({ provider, models: new Map<ModelV2.ID, ModelV2.Info>() })
  31. updates.push({
  32. id: providerID,
  33. enabled: provider.enabled,
  34. apiKey:
  35. typeof provider.options.aisdk.provider.apiKey === "string"
  36. ? provider.options.aisdk.provider.apiKey
  37. : undefined,
  38. })
  39. },
  40. remove: (providerID) => {
  41. const index = records.findIndex((item) => item.provider.id === providerID)
  42. if (index !== -1) records.splice(index, 1)
  43. },
  44. },
  45. model: {
  46. update: () => {},
  47. remove: () => {},
  48. },
  49. }
  50. }
  51. function testLayer(dir: string) {
  52. return AccountV2.layer.pipe(
  53. Layer.provide(AppFileSystem.defaultLayer),
  54. Layer.provideMerge(EventV2.defaultLayer),
  55. Layer.provide(
  56. Global.layerWith({
  57. data: dir,
  58. cache: path.join(dir, "cache"),
  59. config: path.join(dir, "config"),
  60. state: path.join(dir, "state"),
  61. tmp: path.join(dir, "tmp"),
  62. bin: path.join(dir, "bin"),
  63. log: path.join(dir, "log"),
  64. repos: path.join(dir, "repos"),
  65. }),
  66. ),
  67. )
  68. }
  69. describe("AccountV2", () => {
  70. it.live("emits account lifecycle events", () =>
  71. Effect.acquireRelease(
  72. Effect.promise(() => tmpdir()),
  73. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  74. ).pipe(
  75. Effect.flatMap((tmp) =>
  76. Effect.gen(function* () {
  77. const accounts = yield* AccountV2.Service
  78. const eventSvc = yield* EventV2.Service
  79. const addedFiber = yield* eventSvc
  80. .subscribe(AccountV2.Event.Added)
  81. .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
  82. const switchedFiber = yield* eventSvc
  83. .subscribe(AccountV2.Event.Switched)
  84. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  85. const removedFiber = yield* eventSvc
  86. .subscribe(AccountV2.Event.Removed)
  87. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  88. yield* Effect.yieldNow
  89. const first = yield* accounts.create({
  90. serviceID: AccountV2.ServiceID.make("provider"),
  91. credential: new AccountV2.ApiKeyCredential({ type: "api", key: "raw-key" }),
  92. })
  93. expect(first).toBeDefined()
  94. if (!first) return
  95. expect(first.description).toBe("default")
  96. expect(first.credential.type).toBe("api")
  97. if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key")
  98. yield* accounts.update(first.id, { description: "keep" })
  99. const updated = yield* accounts.get(first.id)
  100. expect(updated?.description).toBe("keep")
  101. expect(updated?.credential.type).toBe("api")
  102. if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key")
  103. const second = yield* accounts.create({
  104. serviceID: AccountV2.ServiceID.make("provider"),
  105. credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
  106. })
  107. expect(second).toBeDefined()
  108. if (!second) return
  109. yield* accounts.remove(second.id)
  110. const added = Array.from(yield* Fiber.join(addedFiber))
  111. const switched = Array.from(yield* Fiber.join(switchedFiber))
  112. const removed = Array.from(yield* Fiber.join(removedFiber))
  113. expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id])
  114. expect(switched.map((event) => event.data)).toEqual([
  115. { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
  116. { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
  117. { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: first.id },
  118. ])
  119. expect(removed[0]?.data.account.id).toBe(second.id)
  120. }).pipe(Effect.provide(testLayer(tmp.path))),
  121. ),
  122. ),
  123. )
  124. it.live("always switches to newly created accounts", () =>
  125. Effect.acquireRelease(
  126. Effect.promise(() => tmpdir()),
  127. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  128. ).pipe(
  129. Effect.flatMap((tmp) =>
  130. Effect.gen(function* () {
  131. const accounts = yield* AccountV2.Service
  132. const eventSvc = yield* EventV2.Service
  133. const switchedFiber = yield* eventSvc
  134. .subscribe(AccountV2.Event.Switched)
  135. .pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
  136. yield* Effect.yieldNow
  137. const first = yield* accounts.create({
  138. serviceID: AccountV2.ServiceID.make("provider"),
  139. credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
  140. })
  141. const second = yield* accounts.create({
  142. serviceID: AccountV2.ServiceID.make("provider"),
  143. credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
  144. })
  145. const third = yield* accounts.create({
  146. serviceID: AccountV2.ServiceID.make("provider"),
  147. credential: new AccountV2.ApiKeyCredential({ type: "api", key: "third-key" }),
  148. })
  149. expect(first).toBeDefined()
  150. expect(second).toBeDefined()
  151. expect(third).toBeDefined()
  152. if (!first || !second || !third) return
  153. expect((yield* accounts.active(AccountV2.ServiceID.make("provider")))?.id).toBe(third.id)
  154. expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
  155. { serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
  156. { serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
  157. { serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: third.id },
  158. ])
  159. }).pipe(Effect.provide(testLayer(tmp.path))),
  160. ),
  161. ),
  162. )
  163. it.live("account plugin refreshes providers on account lifecycle events", () =>
  164. Effect.acquireRelease(
  165. Effect.promise(() => tmpdir()),
  166. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  167. ).pipe(
  168. Effect.flatMap((tmp) =>
  169. Effect.gen(function* () {
  170. const accounts = yield* AccountV2.Service
  171. const plugin = yield* PluginV2.Service
  172. const records = [
  173. {
  174. provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")),
  175. models: new Map<ModelV2.ID, ModelV2.Info>(),
  176. },
  177. ]
  178. const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
  179. const catalog = Catalog.Service.of({
  180. loader: () => Effect.die("unexpected catalog.loader"),
  181. provider: {
  182. get: () => Effect.die("unexpected provider.get"),
  183. all: () => Effect.succeed([]),
  184. available: () => Effect.succeed([]),
  185. },
  186. model: {
  187. get: () => Effect.die("unexpected model.get"),
  188. all: () => Effect.succeed([]),
  189. available: () => Effect.succeed([]),
  190. default: () => Effect.succeed(Option.none<ModelV2.Info>()),
  191. setDefault: () => Effect.die("unexpected model.setDefault"),
  192. small: () => Effect.succeed(Option.none<ModelV2.Info>()),
  193. },
  194. })
  195. const eventSvc = yield* EventV2.Service
  196. yield* plugin.add({
  197. ...AccountPlugin,
  198. effect: AccountPlugin.effect.pipe(
  199. Effect.provideService(AccountV2.Service, accounts),
  200. Effect.provideService(Catalog.Service, catalog),
  201. Effect.provideService(EventV2.Service, eventSvc),
  202. Effect.provideService(PluginV2.Service, plugin),
  203. ),
  204. })
  205. yield* Effect.yieldNow
  206. const first = yield* accounts.create({
  207. serviceID: AccountV2.ServiceID.make("provider"),
  208. credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
  209. })
  210. expect(first).toBeDefined()
  211. if (!first) return
  212. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  213. expect(updates).toEqual([
  214. {
  215. id: ProviderV2.ID.make("provider"),
  216. enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
  217. apiKey: "first-key",
  218. },
  219. ])
  220. updates.length = 0
  221. const second = yield* accounts.create({
  222. serviceID: AccountV2.ServiceID.make("provider"),
  223. credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
  224. })
  225. expect(second).toBeDefined()
  226. if (!second) return
  227. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  228. expect(updates).toEqual([
  229. {
  230. id: ProviderV2.ID.make("provider"),
  231. enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
  232. apiKey: "second-key",
  233. },
  234. ])
  235. updates.length = 0
  236. yield* accounts.activate(first.id)
  237. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  238. expect(updates).toEqual([
  239. {
  240. id: ProviderV2.ID.make("provider"),
  241. enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
  242. apiKey: "first-key",
  243. },
  244. ])
  245. updates.length = 0
  246. yield* accounts.remove(first.id)
  247. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  248. expect(updates).toEqual([
  249. {
  250. id: ProviderV2.ID.make("provider"),
  251. enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
  252. apiKey: "second-key",
  253. },
  254. ])
  255. updates.length = 0
  256. yield* accounts.remove(second.id)
  257. yield* plugin.trigger("catalog.transform", context(records, updates), {})
  258. expect(updates).toEqual([])
  259. }).pipe(Effect.provide(testLayer(tmp.path))),
  260. ),
  261. ),
  262. )
  263. })