location-layer.test.ts 30 KB

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