location-layer.test.ts 32 KB

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