location-layer.test.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808
  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 ref key shapes 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 absent = Location.Ref.make({ directory })
  481. const present = Location.Ref.make({ directory, workspaceID: undefined })
  482. // The two shapes are not structurally Equal: own-key sets differ.
  483. expect(Object.keys(absent)).toEqual(["directory"])
  484. expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
  485. expect(Equal.equals(absent, present)).toBe(false)
  486. const first = yield* locations.contextEffect(absent)
  487. expect(yield* locations.contextEffect(present)).toBe(first)
  488. expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
  489. // Invalidating with the shape opposite to the one that booted must evict.
  490. yield* locations.invalidate(present)
  491. expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
  492. }),
  493. ),
  494. ),
  495. ),
  496. )
  497. it.live("isolates catalog state by location", () =>
  498. Effect.acquireRelease(
  499. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  500. (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
  501. ).pipe(
  502. Effect.flatMap(([blocked, allowed]) =>
  503. Effect.gen(function* () {
  504. const update = (directory: string, providerID: Provider.ID) =>
  505. Effect.gen(function* () {
  506. yield* Reference.Service
  507. const catalog = yield* Catalog.Service
  508. yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
  509. const registry = yield* Tool.Service
  510. // Tool plugins register during the forked PluginSupervisor boot; wait for
  511. // every expected tool rather than relying on batch ordering.
  512. yield* Effect.forEach(
  513. [
  514. "edit",
  515. "glob",
  516. "grep",
  517. "question",
  518. "read",
  519. "shell",
  520. "skill",
  521. "subagent",
  522. "webfetch",
  523. "websearch",
  524. "write",
  525. ],
  526. (name) => waitForTool(registry, name),
  527. )
  528. return {
  529. providers: yield* catalog.provider.all(),
  530. tools: yield* toolDefinitions(registry),
  531. }
  532. }).pipe(
  533. Effect.scoped,
  534. Effect.provide(
  535. LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
  536. ),
  537. )
  538. const blockedID = Provider.ID.make("blocked-location")
  539. const allowedID = Provider.ID.make("allowed-location")
  540. const blockedState = yield* update(blocked.path, blockedID)
  541. expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
  542. expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
  543. const blockedTools = blockedState.tools.map((tool) => tool.name)
  544. expect(blockedTools.filter((name) => name !== "execute").sort()).toEqual([
  545. "edit",
  546. "glob",
  547. "grep",
  548. "patch",
  549. "question",
  550. "read",
  551. "shell",
  552. "skill",
  553. "subagent",
  554. "webfetch",
  555. "websearch",
  556. "write",
  557. ])
  558. const allowedState = yield* update(allowed.path, allowedID)
  559. expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
  560. expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
  561. const allowedTools = allowedState.tools.map((tool) => tool.name)
  562. expect(blockedTools.includes("execute")).toBe(allowedTools.includes("execute"))
  563. expect(allowedTools.filter((name) => name !== "execute").sort()).toEqual([
  564. "edit",
  565. "glob",
  566. "grep",
  567. "patch",
  568. "question",
  569. "read",
  570. "shell",
  571. "skill",
  572. "subagent",
  573. "webfetch",
  574. "websearch",
  575. "write",
  576. ])
  577. }),
  578. ),
  579. ),
  580. )
  581. it.live("rejects an unavailable selected model during location model resolution", () =>
  582. Effect.acquireRelease(
  583. Effect.promise(() => tmpdir()),
  584. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  585. ).pipe(
  586. Effect.flatMap((dir) =>
  587. Effect.gen(function* () {
  588. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  589. yield* Effect.promise(() =>
  590. fs.writeFile(
  591. path.join(dir.path, "opencode.json"),
  592. JSON.stringify({
  593. providers: {
  594. unavailable: {
  595. name: "Unavailable",
  596. package: "test-provider",
  597. models: { chat: { disabled: true } },
  598. },
  599. },
  600. }),
  601. ),
  602. )
  603. const failure = yield* SessionRunnerModel.Service.use((models) =>
  604. models.resolve(
  605. Session.Info.make({
  606. id: Session.ID.make("ses_unavailable_model"),
  607. projectID: Project.ID.global,
  608. title: "test",
  609. model: {
  610. id: Model.ID.make("chat"),
  611. providerID: Provider.ID.make("unavailable"),
  612. },
  613. cost: Money.USD.zero,
  614. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  615. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  616. location,
  617. }),
  618. ),
  619. ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
  620. expect(failure).toMatchObject({
  621. _tag: "SessionRunnerModel.ModelUnavailableError",
  622. providerID: "unavailable",
  623. modelID: "chat",
  624. })
  625. }),
  626. ),
  627. ),
  628. )
  629. it.live("explains replacements for unavailable legacy provider models", () =>
  630. Effect.acquireRelease(
  631. Effect.promise(() => tmpdir()),
  632. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  633. ).pipe(
  634. Effect.flatMap((dir) =>
  635. Effect.gen(function* () {
  636. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  637. for (const [providerID, replacement] of [
  638. ["azure-cognitive-services", "azure"],
  639. ["google-vertex-anthropic", "google-vertex"],
  640. ] as const) {
  641. const failure = yield* SessionRunnerModel.Service.use((models) =>
  642. models.resolve(
  643. Session.Info.make({
  644. id: Session.ID.make(`ses_removed_${providerID}`),
  645. projectID: Project.ID.global,
  646. title: "test",
  647. model: {
  648. id: Model.ID.make("chat"),
  649. providerID: Provider.ID.make(providerID),
  650. },
  651. cost: Money.USD.zero,
  652. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  653. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  654. location,
  655. }),
  656. ),
  657. ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
  658. expect(failure).toMatchObject({
  659. _tag: "SessionRunnerModel.ModelUnavailableError",
  660. providerID,
  661. modelID: "chat",
  662. })
  663. expect(failure.message).toBe(
  664. `Model unavailable: ${providerID}/chat. This provider has been deprecated; use ${replacement}/chat instead.`,
  665. )
  666. }
  667. }),
  668. ),
  669. ),
  670. )
  671. it.live("preserves the selected catalog identity when the package model id differs", () =>
  672. Effect.acquireRelease(
  673. Effect.promise(() => tmpdir()),
  674. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  675. ).pipe(
  676. Effect.flatMap((dir) =>
  677. Effect.gen(function* () {
  678. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  679. const resolved = yield* Effect.gen(function* () {
  680. const catalog = yield* Catalog.Service
  681. yield* catalog.transform((editor) => {
  682. editor.provider.update(Provider.ID.make("aliased"), (provider) => {
  683. provider.package = Provider.aisdk("@ai-sdk/openai")
  684. })
  685. editor.model.update(Provider.ID.make("aliased"), Model.ID.make("fast"), (model) => {
  686. // Catalog id and package model id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
  687. model.modelID = Model.ID.make("base")
  688. model.variants = [{ id: Model.VariantID.make("high") }]
  689. })
  690. })
  691. const models = yield* SessionRunnerModel.Service
  692. return yield* models.resolve(
  693. Session.Info.make({
  694. id: Session.ID.make("ses_aliased_model"),
  695. projectID: Project.ID.global,
  696. title: "test",
  697. model: {
  698. id: Model.ID.make("fast"),
  699. providerID: Provider.ID.make("aliased"),
  700. variant: Model.VariantID.make("high"),
  701. },
  702. cost: Money.USD.zero,
  703. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  704. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  705. location,
  706. }),
  707. )
  708. }).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
  709. expect(resolved.ref).toEqual(
  710. Model.Ref.make({
  711. id: Model.ID.make("fast"),
  712. providerID: Provider.ID.make("aliased"),
  713. variant: Model.VariantID.make("high"),
  714. }),
  715. )
  716. expect(String(resolved.model.id)).toBe("base")
  717. }),
  718. ),
  719. ),
  720. )
  721. it.live("installs public plugins into a location", () =>
  722. Effect.acquireRelease(
  723. Effect.promise(() => tmpdir()),
  724. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  725. ).pipe(
  726. Effect.flatMap((dir) =>
  727. Effect.gen(function* () {
  728. const plugins = yield* Plugin.Service
  729. const reviewer = EffectPlugin.define({
  730. id: "reviewer",
  731. effect: (ctx) =>
  732. ctx.agent
  733. .transform((agent) => {
  734. agent.update("reviewer", (item) => {
  735. item.description = "Reviews code"
  736. item.mode = "subagent"
  737. })
  738. })
  739. .pipe(Effect.asVoid),
  740. })
  741. yield* plugins.activate([{ ...reviewer, version: "1" }])
  742. expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
  743. description: "Reviews code",
  744. mode: "subagent",
  745. })
  746. }).pipe(
  747. Effect.scoped,
  748. Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
  749. ),
  750. ),
  751. ),
  752. )
  753. })