1
0

location-layer.test.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Config } from "@opencode-ai/schema/config"
  5. import { Money } from "@opencode-ai/schema/money"
  6. import { DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
  7. import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
  8. import { Agent } from "@opencode-ai/core/agent"
  9. import { Catalog } from "@opencode-ai/core/catalog"
  10. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  11. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  12. import { Global } from "@opencode-ai/util/global"
  13. import { LocationServiceMap } from "@opencode-ai/core/location-services"
  14. import { Location } from "@opencode-ai/core/location"
  15. import { Plugin } from "@opencode-ai/core/plugin"
  16. import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
  17. import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
  18. import { Model } from "@opencode-ai/core/model"
  19. import { Project } from "@opencode-ai/core/project"
  20. import { Provider } from "@opencode-ai/core/provider"
  21. import { AbsolutePath } from "@opencode-ai/core/schema"
  22. import { Session } from "@opencode-ai/core/session"
  23. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  24. import { tmpdir } from "./fixture/tmpdir"
  25. import { tempGlobalLayer } from "./fixture/global"
  26. import { testEffect } from "./lib/effect"
  27. import { toolDefinitions, waitForTool } from "./lib/tool"
  28. import { Database } from "../src/database/database"
  29. import { Bus } from "../src/bus"
  30. import { Reference } from "../src/reference"
  31. import { Tool } from "../src/tool"
  32. const it = testEffect(
  33. AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
  34. [Global.node, tempGlobalLayer],
  35. ]),
  36. )
  37. const itWithSdk = testEffect(
  38. AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
  39. [Global.node, tempGlobalLayer],
  40. ]),
  41. )
  42. describe("LocationServiceMap", () => {
  43. itWithSdk.live("preserves embedded SDK plugins after Location eviction", () =>
  44. Effect.acquireRelease(
  45. Effect.promise(() => tmpdir()),
  46. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  47. ).pipe(
  48. Effect.flatMap((dir) =>
  49. Effect.gen(function* () {
  50. const sdk = yield* SdkPlugins.Service
  51. const locations = yield* LocationServiceMap.Service
  52. const id = Agent.ID.make("persistent-sdk-agent")
  53. const plugin = EffectPlugin.define({
  54. id: "persistent-sdk-plugin",
  55. effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})),
  56. })
  57. yield* sdk.register(plugin)
  58. const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  59. const read = Effect.gen(function* () {
  60. const supervisor = yield* PluginSupervisor.Service
  61. yield* supervisor.flush
  62. const agents = yield* Agent.Service
  63. return yield* agents.get(id)
  64. })
  65. expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
  66. yield* locations.invalidate(ref)
  67. expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined()
  68. }),
  69. ),
  70. ),
  71. )
  72. itWithSdk.live("waits for explorer activation to complete", () =>
  73. Effect.acquireRelease(
  74. Effect.promise(() => tmpdir()),
  75. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  76. ).pipe(
  77. Effect.flatMap((dir) =>
  78. Effect.gen(function* () {
  79. const started = yield* Deferred.make<void>()
  80. const release = yield* Deferred.make<void>()
  81. const sdk = yield* SdkPlugins.Service
  82. yield* sdk.register(
  83. EffectPlugin.define({
  84. id: "blocked-initial-activation",
  85. effect: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))),
  86. }),
  87. )
  88. const locations = yield* LocationServiceMap.Service
  89. const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
  90. yield* Deferred.await(started)
  91. const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  92. Effect.provide(context),
  93. Effect.forkChild,
  94. )
  95. expect(flushFiber.pollUnsafe()).toBeUndefined()
  96. yield* Deferred.succeed(release, undefined)
  97. yield* Fiber.join(flushFiber)
  98. yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  99. Effect.provide(context),
  100. Effect.timeout("1 second"),
  101. )
  102. const explorer = yield* Effect.gen(function* () {
  103. const agents = yield* Agent.Service
  104. return yield* agents.resolve("explore")
  105. }).pipe(Effect.provide(context))
  106. expect(explorer).toBeDefined()
  107. expect(explorer?.permissions.length).toBeGreaterThan(0)
  108. }),
  109. ),
  110. ),
  111. )
  112. itWithSdk.live("reruns activation for SDK plugins registered during startup", () =>
  113. Effect.acquireRelease(
  114. Effect.promise(() => tmpdir()),
  115. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  116. ).pipe(
  117. Effect.flatMap((dir) =>
  118. Effect.gen(function* () {
  119. const firstStarted = yield* Deferred.make<void>()
  120. const releaseFirst = yield* Deferred.make<void>()
  121. const secondStarted = yield* Deferred.make<void>()
  122. const releaseSecond = yield* Deferred.make<void>()
  123. const sdk = yield* SdkPlugins.Service
  124. yield* sdk.register(
  125. EffectPlugin.define({
  126. id: "fixed-target-first-plugin",
  127. effect: () =>
  128. Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))),
  129. }),
  130. )
  131. const locations = yield* LocationServiceMap.Service
  132. const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
  133. yield* Deferred.await(firstStarted)
  134. const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  135. Effect.provide(context),
  136. Effect.forkChild({ startImmediately: true }),
  137. )
  138. yield* Effect.yieldNow
  139. yield* sdk.register(
  140. EffectPlugin.define({
  141. id: "fixed-target-second-plugin",
  142. effect: () =>
  143. Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
  144. }),
  145. )
  146. yield* Deferred.succeed(releaseFirst, undefined)
  147. yield* Deferred.await(secondStarted)
  148. expect(flushFiber.pollUnsafe()).toBeUndefined()
  149. yield* Deferred.succeed(releaseSecond, undefined)
  150. yield* Fiber.join(flushFiber)
  151. }),
  152. ),
  153. ),
  154. )
  155. itWithSdk.live("reruns activation for Config updates during startup", () =>
  156. Effect.acquireRelease(
  157. Effect.promise(() => tmpdir()),
  158. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  159. ).pipe(
  160. Effect.flatMap((dir) =>
  161. Effect.gen(function* () {
  162. const activations = { count: 0 }
  163. const file = path.join(dir.path, "opencode.json")
  164. yield* Effect.promise(() => fs.writeFile(file, "{}"))
  165. const firstStarted = yield* Deferred.make<void>()
  166. const releaseFirst = yield* Deferred.make<void>()
  167. const secondStarted = yield* Deferred.make<void>()
  168. const releaseSecond = yield* Deferred.make<void>()
  169. const sdk = yield* SdkPlugins.Service
  170. yield* sdk.register(
  171. EffectPlugin.define({
  172. id: "blocked-config-reload",
  173. effect: () =>
  174. Effect.sync(() => ++activations.count).pipe(
  175. Effect.flatMap((activation) =>
  176. activation === 1
  177. ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst)))
  178. : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))),
  179. ),
  180. ),
  181. }),
  182. )
  183. const locations = yield* LocationServiceMap.Service
  184. const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
  185. yield* Deferred.await(firstStarted)
  186. const bus = yield* Bus.Service
  187. const updated = yield* bus.subscribe(Config.Event.Updated).pipe(
  188. Stream.filter((event) => event.location?.directory === dir.path),
  189. Stream.runHead,
  190. Effect.forkChild({ startImmediately: true }),
  191. )
  192. yield* Effect.promise(() =>
  193. fs.writeFile(
  194. file,
  195. JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }),
  196. ),
  197. )
  198. yield* Fiber.join(updated)
  199. const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  200. Effect.provide(context),
  201. Effect.forkChild,
  202. )
  203. yield* Deferred.succeed(releaseFirst, undefined)
  204. yield* Deferred.await(secondStarted)
  205. expect(flushFiber.pollUnsafe()).toBeUndefined()
  206. yield* Deferred.succeed(releaseSecond, undefined)
  207. yield* Fiber.join(flushFiber)
  208. expect(activations.count).toBe(2)
  209. }),
  210. ),
  211. ),
  212. )
  213. itWithSdk.live("keeps flush pending while startup updates continue", () =>
  214. Effect.acquireRelease(
  215. Effect.promise(() => tmpdir()),
  216. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  217. ).pipe(
  218. Effect.flatMap((dir) =>
  219. Effect.gen(function* () {
  220. const locations = yield* LocationServiceMap.Service
  221. const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
  222. const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  223. Effect.provide(context),
  224. Effect.forkChild({ startImmediately: true }),
  225. )
  226. const bus = yield* Bus.Service
  227. yield* Effect.forEach(
  228. Array.from({ length: 5 }),
  229. () => bus.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))),
  230. { discard: true },
  231. )
  232. expect(flushFiber.pollUnsafe()).toBeUndefined()
  233. yield* Fiber.join(flushFiber)
  234. }),
  235. ),
  236. ),
  237. )
  238. itWithSdk.live("does not reload plugins when config updates leave plugin operations unchanged", () =>
  239. Effect.acquireRelease(
  240. Effect.promise(() => tmpdir()),
  241. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  242. ).pipe(
  243. Effect.flatMap((dir) =>
  244. Effect.gen(function* () {
  245. const activations = { count: 0 }
  246. const sdk = yield* SdkPlugins.Service
  247. yield* sdk.register(
  248. EffectPlugin.define({
  249. id: "unchanged-config-plugin",
  250. effect: () => Effect.sync(() => ++activations.count).pipe(Effect.asVoid),
  251. }),
  252. )
  253. const locations = yield* LocationServiceMap.Service
  254. const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
  255. yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
  256. expect(activations.count).toBe(1)
  257. yield* Bus.Service.use((bus) => bus.publish(Config.Event.Updated, {})).pipe(Effect.provide(context))
  258. yield* Effect.sleep("200 millis")
  259. expect(activations.count).toBe(1)
  260. }),
  261. ),
  262. ),
  263. )
  264. itWithSdk.live("keeps flush open while later hot reload runs", () =>
  265. Effect.acquireRelease(
  266. Effect.promise(() => tmpdir()),
  267. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  268. ).pipe(
  269. Effect.flatMap((dir) =>
  270. Effect.gen(function* () {
  271. const locations = yield* LocationServiceMap.Service
  272. const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
  273. yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context))
  274. const started = yield* Deferred.make<void>()
  275. const release = yield* Deferred.make<void>()
  276. const completed = yield* Deferred.make<void>()
  277. const sdk = yield* SdkPlugins.Service
  278. yield* sdk.register(
  279. EffectPlugin.define({
  280. id: "post-ready-plugin",
  281. effect: () =>
  282. Deferred.succeed(started, undefined).pipe(
  283. Effect.andThen(Deferred.await(release)),
  284. Effect.andThen(Deferred.succeed(completed, undefined)),
  285. ),
  286. }),
  287. )
  288. yield* Deferred.await(started)
  289. const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  290. Effect.provide(context),
  291. Effect.forkChild({ startImmediately: true }),
  292. )
  293. expect(flushFiber.pollUnsafe()).toBeUndefined()
  294. yield* Deferred.succeed(release, undefined)
  295. yield* Fiber.join(flushFiber)
  296. yield* Deferred.await(completed)
  297. }),
  298. ),
  299. ),
  300. )
  301. itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () =>
  302. Effect.acquireRelease(
  303. Effect.promise(() => tmpdir()),
  304. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  305. ).pipe(
  306. Effect.flatMap((dir) =>
  307. Effect.gen(function* () {
  308. const started = yield* Deferred.make<void>()
  309. const release = yield* Deferred.make<void>()
  310. const completed = yield* Deferred.make<void>()
  311. const sdk = yield* SdkPlugins.Service
  312. yield* sdk.register(
  313. EffectPlugin.define({
  314. id: "interrupted-waiter-plugin",
  315. effect: () =>
  316. Deferred.succeed(started, undefined).pipe(
  317. Effect.andThen(Deferred.await(release)),
  318. Effect.andThen(Deferred.succeed(completed, undefined)),
  319. ),
  320. }),
  321. )
  322. const locations = yield* LocationServiceMap.Service
  323. const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
  324. yield* Deferred.await(started)
  325. const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  326. Effect.provide(context),
  327. Effect.forkChild({ startImmediately: true }),
  328. )
  329. yield* Fiber.interrupt(flushFiber)
  330. yield* Deferred.succeed(release, undefined)
  331. yield* Deferred.await(completed)
  332. yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  333. Effect.provide(context),
  334. Effect.timeout("500 millis"),
  335. )
  336. }),
  337. ),
  338. ),
  339. )
  340. it.live("applies ordered plugin config operations during boot", () =>
  341. Effect.acquireRelease(
  342. Effect.promise(() => tmpdir()),
  343. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  344. ).pipe(
  345. Effect.flatMap((dir) =>
  346. Effect.gen(function* () {
  347. yield* Effect.promise(() =>
  348. fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })),
  349. )
  350. const plugins = yield* Effect.gen(function* () {
  351. const plugins = yield* Plugin.Service
  352. yield* (yield* PluginSupervisor.Service).flush
  353. return yield* plugins.list()
  354. }).pipe(
  355. Effect.scoped,
  356. Effect.provide(
  357. LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
  358. ),
  359. )
  360. expect(plugins.map((plugin) => plugin.id)).toEqual([Plugin.ID.make("opencode.agent")])
  361. }),
  362. ),
  363. ),
  364. )
  365. it.live("reloads the plugin generation after config updates", () =>
  366. Effect.acquireRelease(
  367. Effect.promise(() => tmpdir()),
  368. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  369. ).pipe(
  370. Effect.flatMap((dir) =>
  371. Effect.gen(function* () {
  372. const file = path.join(dir.path, "opencode.json")
  373. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
  374. yield* Effect.gen(function* () {
  375. const registry = yield* Plugin.Service
  376. const supervisor = yield* PluginSupervisor.Service
  377. yield* supervisor.flush
  378. expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
  379. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
  380. for (let attempt = 0; attempt < 100; attempt++) {
  381. if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break
  382. yield* Effect.sleep("20 millis")
  383. }
  384. expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"])
  385. yield* Effect.promise(() =>
  386. fs.writeFile(
  387. file,
  388. JSON.stringify({
  389. plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
  390. }),
  391. ),
  392. )
  393. for (let attempt = 0; attempt < 100; attempt++) {
  394. if ((yield* registry.list()).length === 0) break
  395. yield* Effect.sleep("20 millis")
  396. }
  397. expect(yield* registry.list()).toEqual([])
  398. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
  399. for (let attempt = 0; attempt < 100; attempt++) {
  400. if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break
  401. yield* Effect.sleep("20 millis")
  402. }
  403. expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
  404. }).pipe(
  405. Effect.scoped,
  406. Effect.provide(
  407. LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
  408. ),
  409. )
  410. }),
  411. ),
  412. ),
  413. )
  414. it.live("routes located events only to their location", () =>
  415. Effect.acquireRelease(
  416. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  417. (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
  418. ).pipe(
  419. Effect.flatMap(([first, second]) =>
  420. Effect.scoped(
  421. Effect.gen(function* () {
  422. const locations = yield* LocationServiceMap.Service
  423. const bus = yield* Bus.Service
  424. const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) })
  425. const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) })
  426. const firstContext = yield* locations.contextEffect(firstRef)
  427. const secondContext = yield* locations.contextEffect(secondRef)
  428. const received = { first: 0, second: 0 }
  429. yield* bus.subscribe(Config.Event.Updated).pipe(
  430. Stream.runForEach(() => Effect.sync(() => received.first++)),
  431. Effect.provideContext(firstContext),
  432. Effect.forkScoped({ startImmediately: true }),
  433. )
  434. yield* bus.subscribe(Config.Event.Updated).pipe(
  435. Stream.runForEach(() => Effect.sync(() => received.second++)),
  436. Effect.provideContext(secondContext),
  437. Effect.forkScoped({ startImmediately: true }),
  438. )
  439. yield* Effect.sleep("10 millis")
  440. yield* bus.publish(Config.Event.Updated, {}, { location: firstRef })
  441. yield* Effect.sleep("10 millis")
  442. expect(received).toEqual({ first: 1, second: 0 })
  443. }),
  444. ),
  445. ),
  446. ),
  447. )
  448. it.live("reuses cached services for constructed and decoded location refs", () =>
  449. Effect.acquireRelease(
  450. Effect.promise(() => tmpdir()),
  451. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  452. ).pipe(
  453. Effect.flatMap((dir) =>
  454. Effect.scoped(
  455. Effect.gen(function* () {
  456. const locations = yield* LocationServiceMap.Service
  457. const directory = AbsolutePath.make(dir.path)
  458. const constructed = Location.Ref.make({ directory })
  459. const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory })
  460. expect(constructed).toEqual({ directory, workspaceID: undefined })
  461. expect(decoded).toEqual(constructed)
  462. expect(Equal.equals(constructed, decoded)).toBe(true)
  463. expect(Hash.hash(constructed)).toBe(Hash.hash(decoded))
  464. expect(yield* locations.contextEffect(constructed)).toBe(yield* locations.contextEffect(decoded))
  465. }),
  466. ),
  467. ),
  468. ),
  469. )
  470. it.live("normalizes equivalent refs to one cached location graph", () =>
  471. Effect.acquireRelease(
  472. Effect.promise(() => tmpdir()),
  473. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  474. ).pipe(
  475. Effect.flatMap((dir) =>
  476. Effect.scoped(
  477. Effect.gen(function* () {
  478. const locations = yield* LocationServiceMap.Service
  479. const directory = AbsolutePath.make(dir.path)
  480. const alternate = AbsolutePath.make(directory.replaceAll("\\", "/"))
  481. const absent = Location.Ref.make({ directory: alternate })
  482. const present = Location.Ref.make({ directory, workspaceID: undefined })
  483. // The two shapes are not structurally Equal: own-key sets differ.
  484. expect(Object.keys(absent)).toEqual(["directory"])
  485. expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
  486. expect(Equal.equals(absent, present)).toBe(false)
  487. if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
  488. const first = yield* locations.contextEffect(absent)
  489. expect(yield* locations.contextEffect(present)).toBe(first)
  490. expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
  491. Location.Ref.make({ directory, workspaceID: undefined }),
  492. ])
  493. // Invalidating with the shape opposite to the one that booted must evict.
  494. yield* locations.invalidate(present)
  495. expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
  496. }),
  497. ),
  498. ),
  499. ),
  500. )
  501. it.live("isolates catalog state by location", () =>
  502. Effect.acquireRelease(
  503. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  504. (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
  505. ).pipe(
  506. Effect.flatMap(([blocked, allowed]) =>
  507. Effect.gen(function* () {
  508. const update = (directory: string, providerID: Provider.ID) =>
  509. Effect.gen(function* () {
  510. yield* Reference.Service
  511. const catalog = yield* Catalog.Service
  512. yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
  513. const registry = yield* Tool.Service
  514. // Tool plugins register during the forked PluginSupervisor boot; wait for
  515. // every expected tool rather than relying on batch ordering.
  516. yield* Effect.forEach(
  517. [
  518. "edit",
  519. "glob",
  520. "grep",
  521. "question",
  522. "read",
  523. "shell",
  524. "skill",
  525. "subagent",
  526. "webfetch",
  527. "websearch",
  528. "write",
  529. ],
  530. (name) => waitForTool(registry, name),
  531. )
  532. return {
  533. providers: yield* catalog.provider.all(),
  534. tools: yield* toolDefinitions(registry),
  535. }
  536. }).pipe(
  537. Effect.scoped,
  538. Effect.provide(
  539. LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
  540. ),
  541. )
  542. const blockedID = Provider.ID.make("blocked-location")
  543. const allowedID = Provider.ID.make("allowed-location")
  544. const blockedState = yield* update(blocked.path, blockedID)
  545. expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
  546. expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
  547. const blockedTools = blockedState.tools.map((tool) => tool.name)
  548. expect(blockedTools.filter((name) => name !== "execute").sort()).toEqual([
  549. "edit",
  550. "glob",
  551. "grep",
  552. "patch",
  553. "question",
  554. "read",
  555. "shell",
  556. "skill",
  557. "subagent",
  558. "webfetch",
  559. "websearch",
  560. "write",
  561. ])
  562. const allowedState = yield* update(allowed.path, allowedID)
  563. expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
  564. expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
  565. const allowedTools = allowedState.tools.map((tool) => tool.name)
  566. expect(blockedTools.includes("execute")).toBe(allowedTools.includes("execute"))
  567. expect(allowedTools.filter((name) => name !== "execute").sort()).toEqual([
  568. "edit",
  569. "glob",
  570. "grep",
  571. "patch",
  572. "question",
  573. "read",
  574. "shell",
  575. "skill",
  576. "subagent",
  577. "webfetch",
  578. "websearch",
  579. "write",
  580. ])
  581. }),
  582. ),
  583. ),
  584. )
  585. it.live("rejects an unavailable selected model during location model resolution", () =>
  586. Effect.acquireRelease(
  587. Effect.promise(() => tmpdir()),
  588. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  589. ).pipe(
  590. Effect.flatMap((dir) =>
  591. Effect.gen(function* () {
  592. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  593. yield* Effect.promise(() =>
  594. fs.writeFile(
  595. path.join(dir.path, "opencode.json"),
  596. JSON.stringify({
  597. providers: {
  598. unavailable: {
  599. name: "Unavailable",
  600. package: "test-provider",
  601. models: { chat: { disabled: true } },
  602. },
  603. },
  604. }),
  605. ),
  606. )
  607. const failure = yield* SessionRunnerModel.Service.use((models) =>
  608. models.resolve(
  609. Session.Info.make({
  610. id: Session.ID.make("ses_unavailable_model"),
  611. projectID: Project.ID.global,
  612. title: "test",
  613. model: {
  614. id: Model.ID.make("chat"),
  615. providerID: Provider.ID.make("unavailable"),
  616. },
  617. cost: Money.USD.zero,
  618. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  619. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  620. location,
  621. }),
  622. ),
  623. ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
  624. expect(failure).toMatchObject({
  625. _tag: "SessionRunnerModel.ModelUnavailableError",
  626. providerID: "unavailable",
  627. modelID: "chat",
  628. })
  629. }),
  630. ),
  631. ),
  632. )
  633. it.live("explains replacements for unavailable legacy provider models", () =>
  634. Effect.acquireRelease(
  635. Effect.promise(() => tmpdir()),
  636. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  637. ).pipe(
  638. Effect.flatMap((dir) =>
  639. Effect.gen(function* () {
  640. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  641. for (const [providerID, replacement] of [
  642. ["azure-cognitive-services", "azure"],
  643. ["google-vertex-anthropic", "google-vertex"],
  644. ] as const) {
  645. const failure = yield* SessionRunnerModel.Service.use((models) =>
  646. models.resolve(
  647. Session.Info.make({
  648. id: Session.ID.make(`ses_removed_${providerID}`),
  649. projectID: Project.ID.global,
  650. title: "test",
  651. model: {
  652. id: Model.ID.make("chat"),
  653. providerID: Provider.ID.make(providerID),
  654. },
  655. cost: Money.USD.zero,
  656. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  657. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  658. location,
  659. }),
  660. ),
  661. ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
  662. expect(failure).toMatchObject({
  663. _tag: "SessionRunnerModel.ModelUnavailableError",
  664. providerID,
  665. modelID: "chat",
  666. })
  667. expect(failure.message).toBe(
  668. `Model unavailable: ${providerID}/chat. This provider has been deprecated; use ${replacement}/chat instead.`,
  669. )
  670. }
  671. }),
  672. ),
  673. ),
  674. )
  675. it.live("preserves the selected catalog identity when the package model id differs", () =>
  676. Effect.acquireRelease(
  677. Effect.promise(() => tmpdir()),
  678. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  679. ).pipe(
  680. Effect.flatMap((dir) =>
  681. Effect.gen(function* () {
  682. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  683. const resolved = yield* Effect.gen(function* () {
  684. const catalog = yield* Catalog.Service
  685. yield* catalog.transform((editor) => {
  686. editor.provider.update(Provider.ID.make("aliased"), (provider) => {
  687. provider.package = Provider.aisdk("@ai-sdk/openai")
  688. })
  689. editor.model.update(Provider.ID.make("aliased"), Model.ID.make("fast"), (model) => {
  690. // Catalog id and package model id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
  691. model.modelID = Model.ID.make("base")
  692. model.variants = [{ id: Model.VariantID.make("high") }]
  693. })
  694. })
  695. const models = yield* SessionRunnerModel.Service
  696. return yield* models.resolve(
  697. Session.Info.make({
  698. id: Session.ID.make("ses_aliased_model"),
  699. projectID: Project.ID.global,
  700. title: "test",
  701. model: {
  702. id: Model.ID.make("fast"),
  703. providerID: Provider.ID.make("aliased"),
  704. variant: Model.VariantID.make("high"),
  705. },
  706. cost: Money.USD.zero,
  707. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  708. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  709. location,
  710. }),
  711. )
  712. }).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
  713. expect(resolved.ref).toEqual(
  714. Model.Ref.make({
  715. id: Model.ID.make("fast"),
  716. providerID: Provider.ID.make("aliased"),
  717. variant: Model.VariantID.make("high"),
  718. }),
  719. )
  720. expect(String(resolved.model.id)).toBe("base")
  721. }),
  722. ),
  723. ),
  724. )
  725. it.live("installs public plugins into a location", () =>
  726. Effect.acquireRelease(
  727. Effect.promise(() => tmpdir()),
  728. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  729. ).pipe(
  730. Effect.flatMap((dir) =>
  731. Effect.gen(function* () {
  732. const plugins = yield* Plugin.Service
  733. const reviewer = EffectPlugin.define({
  734. id: "reviewer",
  735. effect: (ctx) =>
  736. ctx.agent
  737. .transform((agent) => {
  738. agent.update("reviewer", (item) => {
  739. item.description = "Reviews code"
  740. item.mode = "subagent"
  741. })
  742. })
  743. .pipe(Effect.asVoid),
  744. })
  745. yield* plugins.activate([{ ...reviewer, version: "1" }])
  746. expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
  747. description: "Reviews code",
  748. mode: "subagent",
  749. })
  750. }).pipe(
  751. Effect.scoped,
  752. Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
  753. ),
  754. ),
  755. ),
  756. )
  757. })