location-layer.test.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754
  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. yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  282. Effect.provide(context),
  283. Effect.timeout("1 second"),
  284. )
  285. yield* Deferred.succeed(release, undefined)
  286. yield* Deferred.await(completed)
  287. }),
  288. ),
  289. ),
  290. )
  291. itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () =>
  292. Effect.acquireRelease(
  293. Effect.promise(() => tmpdir()),
  294. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  295. ).pipe(
  296. Effect.flatMap((dir) =>
  297. Effect.gen(function* () {
  298. const started = yield* Deferred.make<void>()
  299. const release = yield* Deferred.make<void>()
  300. const completed = yield* Deferred.make<void>()
  301. const sdk = yield* SdkPlugins.Service
  302. yield* sdk.register(
  303. EffectPlugin.define({
  304. id: "interrupted-waiter-plugin",
  305. effect: () =>
  306. Deferred.succeed(started, undefined).pipe(
  307. Effect.andThen(Deferred.await(release)),
  308. Effect.andThen(Deferred.succeed(completed, undefined)),
  309. ),
  310. }),
  311. )
  312. const locations = yield* LocationServiceMap.Service
  313. const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))
  314. yield* Deferred.await(started)
  315. const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  316. Effect.provide(context),
  317. Effect.forkChild({ startImmediately: true }),
  318. )
  319. yield* Fiber.interrupt(flushFiber)
  320. yield* Deferred.succeed(release, undefined)
  321. yield* Deferred.await(completed)
  322. yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
  323. Effect.provide(context),
  324. Effect.timeout("500 millis"),
  325. )
  326. }),
  327. ),
  328. ),
  329. )
  330. it.live("applies ordered plugin config operations during boot", () =>
  331. Effect.acquireRelease(
  332. Effect.promise(() => tmpdir()),
  333. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  334. ).pipe(
  335. Effect.flatMap((dir) =>
  336. Effect.gen(function* () {
  337. yield* Effect.promise(() =>
  338. fs.writeFile(path.join(dir.path, "opencode.json"), JSON.stringify({ plugins: ["-*", "opencode.agent"] })),
  339. )
  340. const plugins = yield* Effect.gen(function* () {
  341. const plugins = yield* Plugin.Service
  342. yield* (yield* PluginSupervisor.Service).flush
  343. return yield* plugins.list()
  344. }).pipe(
  345. Effect.scoped,
  346. Effect.provide(
  347. LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
  348. ),
  349. )
  350. expect(plugins.map((plugin) => plugin.id)).toEqual([Plugin.ID.make("opencode.agent")])
  351. }),
  352. ),
  353. ),
  354. )
  355. it.live("reloads the plugin generation after config updates", () =>
  356. Effect.acquireRelease(
  357. Effect.promise(() => tmpdir()),
  358. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  359. ).pipe(
  360. Effect.flatMap((dir) =>
  361. Effect.gen(function* () {
  362. const file = path.join(dir.path, "opencode.json")
  363. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
  364. yield* Effect.gen(function* () {
  365. const registry = yield* Plugin.Service
  366. const supervisor = yield* PluginSupervisor.Service
  367. yield* supervisor.flush
  368. expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
  369. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] })))
  370. for (let attempt = 0; attempt < 100; attempt++) {
  371. if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.command")) break
  372. yield* Effect.sleep("20 millis")
  373. }
  374. expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.command"])
  375. yield* Effect.promise(() =>
  376. fs.writeFile(
  377. file,
  378. JSON.stringify({
  379. plugins: ["-*", path.join(import.meta.dir, "plugin/fixtures/failing-plugin.ts")],
  380. }),
  381. ),
  382. )
  383. for (let attempt = 0; attempt < 100; attempt++) {
  384. if ((yield* registry.list()).length === 0) break
  385. yield* Effect.sleep("20 millis")
  386. }
  387. expect(yield* registry.list()).toEqual([])
  388. yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.agent"] })))
  389. for (let attempt = 0; attempt < 100; attempt++) {
  390. if ((yield* registry.list()).some((plugin) => plugin.id === "opencode.agent")) break
  391. yield* Effect.sleep("20 millis")
  392. }
  393. expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"])
  394. }).pipe(
  395. Effect.scoped,
  396. Effect.provide(
  397. LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })),
  398. ),
  399. )
  400. }),
  401. ),
  402. ),
  403. )
  404. it.live("routes located events only to their location", () =>
  405. Effect.acquireRelease(
  406. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  407. (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
  408. ).pipe(
  409. Effect.flatMap(([first, second]) =>
  410. Effect.scoped(
  411. Effect.gen(function* () {
  412. const locations = yield* LocationServiceMap.Service
  413. const bus = yield* Bus.Service
  414. const firstRef = Location.Ref.make({ directory: AbsolutePath.make(first.path) })
  415. const secondRef = Location.Ref.make({ directory: AbsolutePath.make(second.path) })
  416. const firstContext = yield* locations.contextEffect(firstRef)
  417. const secondContext = yield* locations.contextEffect(secondRef)
  418. const received = { first: 0, second: 0 }
  419. yield* bus.subscribe(Config.Event.Updated).pipe(
  420. Stream.runForEach(() => Effect.sync(() => received.first++)),
  421. Effect.provideContext(firstContext),
  422. Effect.forkScoped({ startImmediately: true }),
  423. )
  424. yield* bus.subscribe(Config.Event.Updated).pipe(
  425. Stream.runForEach(() => Effect.sync(() => received.second++)),
  426. Effect.provideContext(secondContext),
  427. Effect.forkScoped({ startImmediately: true }),
  428. )
  429. yield* Effect.sleep("10 millis")
  430. yield* bus.publish(Config.Event.Updated, {}, { location: firstRef })
  431. yield* Effect.sleep("10 millis")
  432. expect(received).toEqual({ first: 1, second: 0 })
  433. }),
  434. ),
  435. ),
  436. ),
  437. )
  438. it.live("reuses cached services for constructed and decoded location refs", () =>
  439. Effect.acquireRelease(
  440. Effect.promise(() => tmpdir()),
  441. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  442. ).pipe(
  443. Effect.flatMap((dir) =>
  444. Effect.scoped(
  445. Effect.gen(function* () {
  446. const locations = yield* LocationServiceMap.Service
  447. const directory = AbsolutePath.make(dir.path)
  448. const constructed = Location.Ref.make({ directory })
  449. const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory })
  450. expect(constructed).toEqual({ directory, workspaceID: undefined })
  451. expect(decoded).toEqual(constructed)
  452. expect(Equal.equals(constructed, decoded)).toBe(true)
  453. expect(Hash.hash(constructed)).toBe(Hash.hash(decoded))
  454. expect(yield* locations.contextEffect(constructed)).toBe(yield* locations.contextEffect(decoded))
  455. }),
  456. ),
  457. ),
  458. ),
  459. )
  460. it.live("normalizes ref key shapes to one cached location graph", () =>
  461. Effect.acquireRelease(
  462. Effect.promise(() => tmpdir()),
  463. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  464. ).pipe(
  465. Effect.flatMap((dir) =>
  466. Effect.scoped(
  467. Effect.gen(function* () {
  468. const locations = yield* LocationServiceMap.Service
  469. const directory = AbsolutePath.make(dir.path)
  470. const absent = Location.Ref.make({ directory })
  471. const present = Location.Ref.make({ directory, workspaceID: undefined })
  472. // The two shapes are not structurally Equal: own-key sets differ.
  473. expect(Object.keys(absent)).toEqual(["directory"])
  474. expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
  475. expect(Equal.equals(absent, present)).toBe(false)
  476. const first = yield* locations.contextEffect(absent)
  477. expect(yield* locations.contextEffect(present)).toBe(first)
  478. expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
  479. // Invalidating with the shape opposite to the one that booted must evict.
  480. yield* locations.invalidate(present)
  481. expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0)
  482. }),
  483. ),
  484. ),
  485. ),
  486. )
  487. it.live("isolates catalog state by location", () =>
  488. Effect.acquireRelease(
  489. Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
  490. (dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
  491. ).pipe(
  492. Effect.flatMap(([blocked, allowed]) =>
  493. Effect.gen(function* () {
  494. const update = (directory: string, providerID: Provider.ID) =>
  495. Effect.gen(function* () {
  496. yield* Reference.Service
  497. const catalog = yield* Catalog.Service
  498. yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
  499. const registry = yield* Tool.Service
  500. // Tool plugins register during the forked PluginSupervisor boot; wait for
  501. // every expected tool rather than relying on batch ordering.
  502. yield* Effect.forEach(
  503. [
  504. "edit",
  505. "glob",
  506. "grep",
  507. "question",
  508. "read",
  509. "shell",
  510. "skill",
  511. "subagent",
  512. "webfetch",
  513. "websearch",
  514. "write",
  515. ],
  516. (name) => waitForTool(registry, name),
  517. )
  518. return {
  519. providers: yield* catalog.provider.all(),
  520. tools: yield* toolDefinitions(registry),
  521. }
  522. }).pipe(
  523. Effect.scoped,
  524. Effect.provide(
  525. LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })),
  526. ),
  527. )
  528. const blockedID = Provider.ID.make("blocked-location")
  529. const allowedID = Provider.ID.make("allowed-location")
  530. const blockedState = yield* update(blocked.path, blockedID)
  531. expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
  532. expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
  533. const blockedTools = blockedState.tools.map((tool) => tool.name)
  534. expect(blockedTools.filter((name) => name !== "execute").sort()).toEqual([
  535. "edit",
  536. "glob",
  537. "grep",
  538. "patch",
  539. "question",
  540. "read",
  541. "shell",
  542. "skill",
  543. "subagent",
  544. "webfetch",
  545. "websearch",
  546. "write",
  547. ])
  548. const allowedState = yield* update(allowed.path, allowedID)
  549. expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
  550. expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
  551. const allowedTools = allowedState.tools.map((tool) => tool.name)
  552. expect(blockedTools.includes("execute")).toBe(allowedTools.includes("execute"))
  553. expect(allowedTools.filter((name) => name !== "execute").sort()).toEqual([
  554. "edit",
  555. "glob",
  556. "grep",
  557. "patch",
  558. "question",
  559. "read",
  560. "shell",
  561. "skill",
  562. "subagent",
  563. "webfetch",
  564. "websearch",
  565. "write",
  566. ])
  567. }),
  568. ),
  569. ),
  570. )
  571. it.live("rejects an unavailable selected model during location model resolution", () =>
  572. Effect.acquireRelease(
  573. Effect.promise(() => tmpdir()),
  574. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  575. ).pipe(
  576. Effect.flatMap((dir) =>
  577. Effect.gen(function* () {
  578. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  579. yield* Effect.promise(() =>
  580. fs.writeFile(
  581. path.join(dir.path, "opencode.json"),
  582. JSON.stringify({
  583. providers: {
  584. unavailable: {
  585. name: "Unavailable",
  586. package: "test-provider",
  587. models: { chat: { disabled: true } },
  588. },
  589. },
  590. }),
  591. ),
  592. )
  593. const failure = yield* SessionRunnerModel.Service.use((models) =>
  594. models.resolve(
  595. Session.Info.make({
  596. id: Session.ID.make("ses_unavailable_model"),
  597. projectID: Project.ID.global,
  598. title: "test",
  599. model: {
  600. id: Model.ID.make("chat"),
  601. providerID: Provider.ID.make("unavailable"),
  602. },
  603. cost: Money.USD.zero,
  604. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  605. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  606. location,
  607. }),
  608. ),
  609. ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
  610. expect(failure).toMatchObject({
  611. _tag: "SessionRunnerModel.ModelUnavailableError",
  612. providerID: "unavailable",
  613. modelID: "chat",
  614. })
  615. }),
  616. ),
  617. ),
  618. )
  619. it.live("preserves the selected catalog identity when the package model id differs", () =>
  620. Effect.acquireRelease(
  621. Effect.promise(() => tmpdir()),
  622. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  623. ).pipe(
  624. Effect.flatMap((dir) =>
  625. Effect.gen(function* () {
  626. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  627. const resolved = yield* Effect.gen(function* () {
  628. const catalog = yield* Catalog.Service
  629. yield* catalog.transform((editor) => {
  630. editor.provider.update(Provider.ID.make("aliased"), (provider) => {
  631. provider.package = Provider.aisdk("@ai-sdk/openai")
  632. })
  633. editor.model.update(Provider.ID.make("aliased"), Model.ID.make("fast"), (model) => {
  634. // Catalog id and package model id intentionally differ, like gpt-5.5-fast -> gpt-5.5.
  635. model.modelID = Model.ID.make("base")
  636. model.variants = [{ id: Model.VariantID.make("high") }]
  637. })
  638. })
  639. const models = yield* SessionRunnerModel.Service
  640. return yield* models.resolve(
  641. Session.Info.make({
  642. id: Session.ID.make("ses_aliased_model"),
  643. projectID: Project.ID.global,
  644. title: "test",
  645. model: {
  646. id: Model.ID.make("fast"),
  647. providerID: Provider.ID.make("aliased"),
  648. variant: Model.VariantID.make("high"),
  649. },
  650. cost: Money.USD.zero,
  651. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  652. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  653. location,
  654. }),
  655. )
  656. }).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
  657. expect(resolved.ref).toEqual(
  658. Model.Ref.make({
  659. id: Model.ID.make("fast"),
  660. providerID: Provider.ID.make("aliased"),
  661. variant: Model.VariantID.make("high"),
  662. }),
  663. )
  664. expect(String(resolved.model.id)).toBe("base")
  665. }),
  666. ),
  667. ),
  668. )
  669. it.live("installs public plugins into a location", () =>
  670. Effect.acquireRelease(
  671. Effect.promise(() => tmpdir()),
  672. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  673. ).pipe(
  674. Effect.flatMap((dir) =>
  675. Effect.gen(function* () {
  676. const plugins = yield* Plugin.Service
  677. const reviewer = EffectPlugin.define({
  678. id: "reviewer",
  679. effect: (ctx) =>
  680. ctx.agent
  681. .transform((agent) => {
  682. agent.update("reviewer", (item) => {
  683. item.description = "Reviews code"
  684. item.mode = "subagent"
  685. })
  686. })
  687. .pipe(Effect.asVoid),
  688. })
  689. yield* plugins.activate([{ ...reviewer, version: "1" }])
  690. expect(yield* (yield* Agent.Service).get(Agent.ID.make("reviewer"))).toMatchObject({
  691. description: "Reviews code",
  692. mode: "subagent",
  693. })
  694. }).pipe(
  695. Effect.scoped,
  696. Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))),
  697. ),
  698. ),
  699. ),
  700. )
  701. })