| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- import fs from "fs/promises"
- import path from "path"
- import { expect } from "bun:test"
- import { Deferred, Effect, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
- import { testEffect } from "../../core/test/lib/effect"
- import { tmpdir } from "../../core/test/fixture/tmpdir"
- import type { OpenCodeEvent } from "../src"
- const it = testEffect(Layer.empty)
- type Sdk = typeof import("../src")
- type Fixture = { readonly directory: string; readonly sdk: Sdk }
- const withEmbedded = <A, E, R>(prefix: string, f: (fixture: Fixture) => Effect.Effect<A, E, R>) =>
- Effect.acquireRelease(
- Effect.promise(() => tmpdir(prefix)),
- (directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
- ).pipe(
- Effect.flatMap((directory) =>
- Effect.promise(() => import("../src")).pipe(Effect.flatMap((sdk) => f({ directory: directory.path, sdk }))),
- ),
- )
- const sessionID = (fixture: Fixture) => fixture.sdk.Session.ID.create()
- const location = (fixture: Fixture) =>
- fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
- it.live("exposes app metadata to plugins", () =>
- withEmbedded("opencode-embedded-app-", (fixture) =>
- Effect.gen(function* () {
- const opencode = yield* fixture.sdk.OpenCode.create({
- app: { name: "test", version: "1.2.3", channel: "beta" },
- })
- const app = yield* Deferred.make<{ readonly name: string; readonly version: string; readonly channel: string }>()
- yield* opencode.plugin({
- id: `app-${crypto.randomUUID()}`,
- effect: (ctx) => Deferred.succeed(app, ctx.app).pipe(Effect.asVoid),
- })
- yield* opencode.plugin.list({ location: location(fixture) })
- expect(yield* Deferred.await(app).pipe(Effect.timeout("4 seconds"))).toEqual({
- name: "test",
- version: "1.2.3",
- channel: "beta",
- })
- }),
- ),
- )
- it.live(
- "reloads every booted Location after SDK plugin registration",
- () =>
- withEmbedded("opencode-embedded-plugin-reload-", (fixture) =>
- Effect.gen(function* () {
- const opencode = yield* fixture.sdk.OpenCode.create()
- const booted = yield* Deferred.make<void>()
- const activated = yield* Deferred.make<boolean>()
- const bootCount = yield* Ref.make(0)
- const activationCount = yield* Ref.make(0)
- const secondDirectory = path.join(fixture.directory, "second")
- yield* Effect.promise(() => fs.mkdir(secondDirectory))
- const refs = [
- location(fixture),
- fixture.sdk.Location.Ref.make({ directory: fixture.sdk.AbsolutePath.make(secondDirectory) }),
- ]
- const bootstrapID = `bootstrap-sdk-${crypto.randomUUID()}`
- const id = `late-sdk-${crypto.randomUUID()}`
- yield* opencode.plugin({
- id: bootstrapID,
- effect: (ctx) =>
- Effect.gen(function* () {
- yield* ctx.tool
- .transform((draft) =>
- draft.add(
- "bootstrap_sdk_tool",
- fixture.sdk.Tool.make({
- description: "Marks the initial Location plugin generation",
- input: Schema.Struct({}),
- output: Schema.Void,
- execute: () => Effect.void,
- }),
- ),
- )
- .pipe(Effect.orDie)
- if (yield* Ref.updateAndGet(bootCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))) {
- yield* Deferred.succeed(booted, undefined)
- }
- }),
- })
- yield* Effect.all(
- refs.map((ref) => opencode.plugin.list({ location: ref })),
- { discard: true },
- )
- yield* Deferred.await(booted).pipe(Effect.timeout("4 seconds"))
- yield* opencode.plugin({
- id,
- effect: (ctx) =>
- Effect.gen(function* () {
- yield* ctx.tool
- .transform((draft) =>
- draft.add(
- "late_sdk_tool",
- fixture.sdk.Tool.make({
- description: "Tool registered after Location boot",
- input: Schema.Struct({}),
- output: Schema.Void,
- execute: () => Effect.void,
- }),
- ),
- )
- .pipe(Effect.orDie)
- if (
- yield* Ref.updateAndGet(activationCount, (count) => count + 1).pipe(Effect.map((count) => count === 2))
- ) {
- yield* Deferred.succeed(activated, true)
- }
- }),
- })
- expect(yield* Deferred.await(activated).pipe(Effect.timeout("10 seconds"))).toBe(true)
- }),
- ),
- 25_000,
- )
- it.live(
- "preserves SDK plugins across Location eviction",
- () =>
- withEmbedded("opencode-embedded-plugin-eviction-", (fixture) =>
- Effect.gen(function* () {
- const opencode = yield* fixture.sdk.OpenCode.create()
- const ref = location(fixture)
- const connected = yield* Latch.make(false)
- const booted = yield* Deferred.make<void>()
- // The rebooted Location commits its second plugin generation.
- const recommitted = yield* Deferred.make<void>()
- const generations = yield* Ref.make(0)
- const id = `evicted-sdk-${crypto.randomUUID()}`
- yield* opencode.events.subscribe().pipe(
- Stream.runForEach((event) => {
- if (event.type === "server.connected") return connected.open
- if (event.type !== "plugin.updated" || event.location?.directory !== fixture.directory) return Effect.void
- return Ref.updateAndGet(generations, (total) => total + 1).pipe(
- Effect.flatMap((total) => {
- if (total === 1) return Deferred.succeed(booted, undefined)
- if (total === 2) return Deferred.succeed(recommitted, undefined)
- return Effect.void
- }),
- Effect.asVoid,
- )
- }),
- Effect.forkScoped,
- )
- yield* connected.await
- yield* opencode.plugin({ id, effect: () => Effect.void })
- yield* opencode.plugin.list({ location: ref })
- yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds"))
- yield* opencode.debug.location.evict({ location: ref })
- yield* opencode.plugin.list({ location: ref })
- yield* Deferred.await(recommitted).pipe(Effect.timeout("5 seconds"))
- expect((yield* opencode.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).toContain(id)
- }),
- ),
- 15_000,
- )
- it.live(
- "keeps SDK plugin registration isolated between embedded hosts",
- () =>
- withEmbedded("opencode-embedded-plugin-isolation-", (fixture) =>
- Effect.gen(function* () {
- const first = yield* fixture.sdk.OpenCode.create()
- const second = yield* fixture.sdk.OpenCode.create()
- const firstReady = yield* Deferred.make<void>()
- const secondReady = yield* Deferred.make<void>()
- const activated = yield* Deferred.make<void>()
- const ref = location(fixture)
- const id = `isolated-sdk-${crypto.randomUUID()}`
- yield* first.plugin({
- id: `first-ready-${crypto.randomUUID()}`,
- effect: () => Deferred.succeed(firstReady, undefined),
- })
- yield* second.plugin({
- id: `second-ready-${crypto.randomUUID()}`,
- effect: () => Deferred.succeed(secondReady, undefined),
- })
- yield* Effect.all([first.plugin.list({ location: ref }), second.plugin.list({ location: ref })], {
- discard: true,
- })
- yield* Effect.all([Deferred.await(firstReady), Deferred.await(secondReady)], { discard: true })
- yield* first.plugin({ id, effect: () => Deferred.succeed(activated, undefined) })
- yield* Deferred.await(activated).pipe(Effect.timeout("5 seconds"))
- expect((yield* second.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).not.toContain(id)
- }),
- ),
- 15_000,
- )
- it.live(
- "embedded client uses the real router and handlers",
- () =>
- withEmbedded("opencode-embedded-", (fixture) =>
- Effect.gen(function* () {
- const opencode = yield* fixture.sdk.OpenCode.create()
- const id = sessionID(fixture)
- const model = fixture.sdk.Model.Ref.make({
- id: fixture.sdk.Model.ID.make("embedded"),
- providerID: fixture.sdk.Provider.ID.make("test"),
- })
- yield* opencode.plugin({
- id: `embedded-tools-${crypto.randomUUID()}`,
- effect: (ctx) =>
- ctx.tool
- .transform((draft) =>
- draft.add(
- "embedded_tool",
- fixture.sdk.Tool.make({
- description: "Embedded test tool",
- input: Schema.Struct({}),
- output: Schema.Struct({ ok: Schema.Boolean }),
- execute: () => Effect.succeed({ ok: true }),
- }),
- ),
- )
- .pipe(Effect.orDie),
- })
- const created = yield* opencode.sessions.create({
- id,
- agent: fixture.sdk.Agent.ID.make("build"),
- location: location(fixture),
- })
- yield* opencode.sessions.switchModel({ sessionID: id, model })
- const selected = yield* opencode.sessions.get({ sessionID: id })
- const page = yield* opencode.sessions.list({ directory: fixture.sdk.AbsolutePath.make(fixture.directory) })
- const active = yield* opencode.sessions.active()
- const admitted = yield* opencode.sessions.prompt({
- sessionID: id,
- text: "Do not run",
- resume: false,
- })
- const context = yield* opencode.sessions.context({ sessionID: id })
- const pendingAfterAdmit = yield* opencode.sessions.pending.list({ sessionID: id })
- yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "deploy-target", value: "production" })
- yield* opencode.sessions.instructions.entry.put({ sessionID: id, key: "flags", value: { beta: true } })
- const contextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
- yield* opencode.sessions.instructions.entry.remove({ sessionID: id, key: "flags" })
- const remainingContextEntries = yield* opencode.sessions.instructions.entry.list({ sessionID: id })
- const wake = yield* opencode.sessions.prompt({
- sessionID: id,
- text: "Promote this input",
- })
- const prompted = yield* opencode.sessions.log({ sessionID: id, follow: true }).pipe(
- Stream.filter((event) => event.type === "session.input.promoted" && event.data.inputID === wake.id),
- Stream.runHead,
- Effect.timeout("10 seconds"),
- Effect.map(Option.getOrThrow),
- )
- const wakeContext = yield* opencode.sessions.context({ sessionID: id })
- const pendingAfterPromote = yield* opencode.sessions.pending.list({ sessionID: id })
- const event = yield* opencode.sessions.log({ sessionID: id }).pipe(
- Stream.filter((item) => item.type !== "log.synced"),
- Stream.take(1),
- Stream.runHead,
- Effect.map(Option.getOrUndefined),
- )
- const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe(
- Option.getOrThrow,
- )
- const message = yield* opencode.sessions.message({ sessionID: id, messageID: modelMessage.id })
- yield* opencode.sessions.interrupt({ sessionID: id })
- const other = yield* opencode.sessions.create({ location: location(fixture) })
- const missingSessionID = fixture.sdk.Session.ID.create()
- const missing = yield* Effect.all(
- [
- opencode.sessions.log({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip),
- opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip),
- opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip),
- opencode.sessions.instructions.entry.list({ sessionID: missingSessionID }).pipe(Effect.flip),
- opencode.sessions.pending.list({ sessionID: missingSessionID }).pipe(Effect.flip),
- ],
- { concurrency: "unbounded" },
- )
- const missingMessage = yield* Effect.flip(
- opencode.sessions.message({
- sessionID: other.id,
- messageID: modelMessage.id,
- }),
- )
- expect(created.id).toBe(id)
- expect(selected.model?.id).toBe(model.id)
- expect(selected.model?.providerID).toBe(model.providerID)
- expect(page.data.some((session) => session.id === id)).toBe(true)
- expect(active).toEqual({})
- expect(admitted.sessionID).toBe(id)
- expect(pendingAfterAdmit).toContainEqual(
- expect.objectContaining({ id: admitted.id, type: "user", delivery: "steer" }),
- )
- expect(prompted.type).toBe("session.input.promoted")
- expect(pendingAfterPromote.map((item) => item.id)).not.toContainAnyValues([admitted.id, wake.id])
- expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" }))
- expect(contextEntries).toEqual([
- { key: "deploy-target", value: "production" },
- { key: "flags", value: { beta: true } },
- ])
- expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }])
- expect(context.some((message) => message.type === "model-switched")).toBe(true)
- expect(event).toMatchObject({ type: "session.model.selected", durable: { seq: 1 } })
- expect(message).toEqual(modelMessage)
- expect(missing.map((error) => error._tag)).toEqual([
- "SessionNotFoundError",
- "SessionNotFoundError",
- "SessionNotFoundError",
- "SessionNotFoundError",
- "SessionNotFoundError",
- ])
- expect(missingMessage._tag).toBe("MessageNotFoundError")
- }),
- ),
- 10_000,
- )
- it.live(
- "Location-owned runner events reach the ready global client",
- () =>
- withEmbedded("opencode-embedded-events-", (fixture) =>
- Effect.gen(function* () {
- const opencode = yield* fixture.sdk.OpenCode.create()
- const id = sessionID(fixture)
- const connected = yield* Latch.make(false)
- const prompted = yield* Deferred.make<Extract<OpenCodeEvent, { type: "session.input.promoted" }>>()
- yield* opencode.events.subscribe().pipe(
- Stream.runForEach((event) =>
- event.type === "server.connected"
- ? connected.open
- : event.type === "session.input.promoted" && event.data.sessionID === id
- ? Deferred.succeed(prompted, event).pipe(Effect.asVoid)
- : Effect.void,
- ),
- Effect.forkScoped,
- )
- yield* connected.await
- yield* opencode.sessions.create({ id, location: location(fixture) })
- yield* opencode.sessions.prompt({
- sessionID: id,
- text: "Observe this input",
- })
- const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds"))
- expect(event.durable).toEqual(expect.objectContaining({ aggregateID: id, seq: expect.any(Number) }))
- }),
- ),
- 10_000,
- )
- it.live(
- "independent embedded hosts do not share live notifications",
- () =>
- withEmbedded("opencode-embedded-hosts-", (fixture) =>
- Effect.gen(function* () {
- const first = yield* fixture.sdk.OpenCode.create()
- const second = yield* fixture.sdk.OpenCode.create()
- const id = sessionID(fixture)
- const firstReady = yield* Latch.make(false)
- const secondReady = yield* Latch.make(false)
- const firstEvent = yield* Latch.make(false)
- const secondEvent = yield* Latch.make(false)
- const observe = (ready: Latch.Latch, event: Latch.Latch) =>
- Stream.runForEach((notification: OpenCodeEvent) =>
- notification.type === "server.connected"
- ? ready.open
- : notification.type === "session.agent.selected" && notification.data.sessionID === id
- ? event.open
- : Effect.void,
- )
- yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped)
- yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped)
- yield* Effect.all([firstReady.await, secondReady.await], { discard: true })
- yield* first.sessions.create({ id, location: location(fixture) })
- yield* first.sessions.switchAgent({ sessionID: id, agent: fixture.sdk.Agent.ID.make("plan") })
- yield* firstEvent.await.pipe(Effect.timeout("2 seconds"))
- expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true)
- }),
- ),
- 10_000,
- )
- it.live("embedded client is available as a Layer service", () =>
- withEmbedded("opencode-embedded-layer-", (fixture) => {
- const id = sessionID(fixture)
- return Effect.gen(function* () {
- const opencode = yield* fixture.sdk.OpenCode.Service
- const created = yield* opencode.sessions.create({ id, location: location(fixture) })
- expect(created.id).toBe(id)
- }).pipe(Effect.provide(fixture.sdk.OpenCode.layer))
- }),
- )
|