Compare commits

...

18 Commits

Author SHA1 Message Date
Kit Langton c13699e2c6 Convert question lifecycle tests to Effect 2026-05-02 18:01:41 -04:00
Kit Langton 1557f31415 Use Effect helpers in question tests 2026-05-02 17:18:27 -04:00
Kit Langton c7a10ac38b Add global bus wait helper for server tests (#25468) 2026-05-02 16:34:34 -04:00
Kit Langton baa6976a8d Use instance test helper in tool registry tests (#25461) 2026-05-02 16:16:00 -04:00
Kit Langton 73406e786f Use instance test helper in question tool tests (#25459) 2026-05-02 16:13:32 -04:00
Kit Langton 76d490afeb test(httpapi): cover remaining route scenarios 2026-05-02 15:42:41 -04:00
Kit Langton 5ad32d4e7b test(httpapi): cover workspace removal and sync replay 2026-05-02 15:29:07 -04:00
Kit Langton 1477b38ea2 test(httpapi): cover PTY routes 2026-05-02 15:25:01 -04:00
Kit Langton 0e256a1de1 test(httpapi): cover safe MCP routes 2026-05-02 15:20:07 -04:00
Kit Langton 2e1f1c2af1 test(httpapi): cover event streams and TUI control 2026-05-02 15:16:38 -04:00
Kit Langton b5f391cd8c test(httpapi): cover global writes and summarize 2026-05-02 15:11:23 -04:00
Kit Langton ed00ae267b Use instance test helper in glob tests (#25445) 2026-05-02 14:45:58 -04:00
Kit Langton eebb26aa7e Use instance test helper in grep tests (#25444) 2026-05-02 14:45:54 -04:00
Kit Langton 895275eb1e Document instance Effect test helper (#25443) 2026-05-02 13:27:29 -04:00
Kit Langton 913321e4b6 Add instance-aware Effect test helper (#25442) 2026-05-02 13:13:13 -04:00
Kit Langton 50d5c57193 test(httpapi): cover session LLM actions 2026-05-02 13:03:37 -04:00
Kit Langton e07f5fbb5d test(httpapi): exercise prompt route with fake llm 2026-05-02 12:52:03 -04:00
Kit Langton f21aa32367 test(httpapi): add route exerciser coverage 2026-05-02 12:39:23 -04:00
20 changed files with 2444 additions and 864 deletions
File diff suppressed because it is too large Load Diff
@@ -26,13 +26,17 @@ export function nextTuiRequest() {
return request.next() return request.next()
} }
export function submitTuiRequest(body: TuiRequest) {
request.push(body)
}
export function submitTuiResponse(body: unknown) { export function submitTuiResponse(body: unknown) {
response.push(body) response.push(body)
} }
export async function callTui(ctx: Context) { export async function callTui(ctx: Context) {
const body = await ctx.req.json() const body = await ctx.req.json()
request.push({ submitTuiRequest({
path: ctx.req.path, path: ctx.req.path,
body, body,
}) })
+1
View File
@@ -122,6 +122,7 @@ export const Client = lazy(() => {
}) })
export function close() { export function close() {
if (!Client.loaded()) return
Client().$client.close() Client().$client.close()
Client.reset() Client.reset()
} }
+2
View File
@@ -14,5 +14,7 @@ export function lazy<T>(fn: () => T) {
value = undefined value = undefined
} }
result.loaded = () => loaded
return result return result
} }
+22 -11
View File
@@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s
```typescript ```typescript
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
describe("my service", () => { describe("my service", () => {
it.live("does the thing", () => it.instance("does the thing", () =>
provideTmpdirInstance(() => Effect.gen(function* () {
Effect.gen(function* () { const svc = yield* MyService.Service
const svc = yield* MyService.Service const out = yield* svc.run()
const out = yield* svc.run() expect(out).toEqual("ok")
expect(out).toEqual("ok") }),
}),
),
) )
}) })
``` ```
@@ -111,6 +108,7 @@ describe("my service", () => {
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`. - Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior. - Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context.
- Most integration-style tests in this package use `it.live(...)`. - Most integration-style tests in this package use `it.live(...)`.
### Effect Fixtures ### Effect Fixtures
@@ -122,7 +120,20 @@ Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. - `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test. Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path:
```typescript
import { TestInstance } from "../fixture/fixture"
it.instance("uses the temp directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
expect(test.directory).toContain("opencode-test-")
}),
)
```
Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime.
### Style ### Style
@@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst
- Keep the test body inside `Effect.gen(function* () { ... })`. - Keep the test body inside `Effect.gen(function* () { ... })`.
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`. - Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests. - When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
+79 -88
View File
@@ -2,9 +2,8 @@ import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Schema, Stream } from "effect" import { Deferred, Effect, Layer, Schema, Stream } from "effect"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { BusEvent } from "../../src/bus/bus-event" import { BusEvent } from "../../src/bus/bus-event"
import { Instance } from "../../src/project/instance"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const TestEvent = { const TestEvent = {
@@ -19,111 +18,103 @@ const live = Layer.mergeAll(Bus.layer, node)
const it = testEffect(live) const it = testEffect(live)
describe("Bus (Effect-native)", () => { describe("Bus (Effect-native)", () => {
it.live("publish + subscribe stream delivers events", () => it.instance("publish + subscribe stream delivers events", () =>
provideTmpdirInstance(() => Effect.gen(function* () {
Effect.gen(function* () { const bus = yield* Bus.Service
const bus = yield* Bus.Service const received: number[] = []
const received: number[] = [] const done = yield* Deferred.make<void>()
const done = yield* Deferred.make<void>()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => { Effect.sync(() => {
received.push(evt.properties.value) received.push(evt.properties.value)
if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) if (received.length === 2) Deferred.doneUnsafe(done, Effect.void)
}), }),
).pipe(Effect.forkScoped) ).pipe(Effect.forkScoped)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Ping, { value: 1 }) yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Ping, { value: 2 }) yield* bus.publish(TestEvent.Ping, { value: 2 })
yield* Deferred.await(done) yield* Deferred.await(done)
expect(received).toEqual([1, 2]) expect(received).toEqual([1, 2])
}), }),
),
) )
it.live("subscribe filters by event type", () => it.instance("subscribe filters by event type", () =>
provideTmpdirInstance(() => Effect.gen(function* () {
Effect.gen(function* () { const bus = yield* Bus.Service
const bus = yield* Bus.Service const pings: number[] = []
const pings: number[] = [] const done = yield* Deferred.make<void>()
const done = yield* Deferred.make<void>()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => { Effect.sync(() => {
pings.push(evt.properties.value) pings.push(evt.properties.value)
Deferred.doneUnsafe(done, Effect.void) Deferred.doneUnsafe(done, Effect.void)
}), }),
).pipe(Effect.forkScoped) ).pipe(Effect.forkScoped)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Pong, { message: "ignored" }) yield* bus.publish(TestEvent.Pong, { message: "ignored" })
yield* bus.publish(TestEvent.Ping, { value: 42 }) yield* bus.publish(TestEvent.Ping, { value: 42 })
yield* Deferred.await(done) yield* Deferred.await(done)
expect(pings).toEqual([42]) expect(pings).toEqual([42])
}), }),
),
) )
it.live("subscribeAll receives all types", () => it.instance("subscribeAll receives all types", () =>
provideTmpdirInstance(() => Effect.gen(function* () {
Effect.gen(function* () { const bus = yield* Bus.Service
const bus = yield* Bus.Service const types: string[] = []
const types: string[] = [] const done = yield* Deferred.make<void>()
const done = yield* Deferred.make<void>()
yield* Stream.runForEach(bus.subscribeAll(), (evt) => yield* Stream.runForEach(bus.subscribeAll(), (evt) =>
Effect.sync(() => { Effect.sync(() => {
types.push(evt.type) types.push(evt.type)
if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) if (types.length === 2) Deferred.doneUnsafe(done, Effect.void)
}), }),
).pipe(Effect.forkScoped) ).pipe(Effect.forkScoped)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Ping, { value: 1 }) yield* bus.publish(TestEvent.Ping, { value: 1 })
yield* bus.publish(TestEvent.Pong, { message: "hi" }) yield* bus.publish(TestEvent.Pong, { message: "hi" })
yield* Deferred.await(done) yield* Deferred.await(done)
expect(types).toContain("test.effect.ping") expect(types).toContain("test.effect.ping")
expect(types).toContain("test.effect.pong") expect(types).toContain("test.effect.pong")
}), }),
),
) )
it.live("multiple subscribers each receive the event", () => it.instance("multiple subscribers each receive the event", () =>
provideTmpdirInstance(() => Effect.gen(function* () {
Effect.gen(function* () { const bus = yield* Bus.Service
const bus = yield* Bus.Service const a: number[] = []
const a: number[] = [] const b: number[] = []
const b: number[] = [] const doneA = yield* Deferred.make<void>()
const doneA = yield* Deferred.make<void>() const doneB = yield* Deferred.make<void>()
const doneB = yield* Deferred.make<void>()
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => { Effect.sync(() => {
a.push(evt.properties.value) a.push(evt.properties.value)
Deferred.doneUnsafe(doneA, Effect.void) Deferred.doneUnsafe(doneA, Effect.void)
}), }),
).pipe(Effect.forkScoped) ).pipe(Effect.forkScoped)
yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) =>
Effect.sync(() => { Effect.sync(() => {
b.push(evt.properties.value) b.push(evt.properties.value)
Deferred.doneUnsafe(doneB, Effect.void) Deferred.doneUnsafe(doneB, Effect.void)
}), }),
).pipe(Effect.forkScoped) ).pipe(Effect.forkScoped)
yield* Effect.sleep("10 millis") yield* Effect.sleep("10 millis")
yield* bus.publish(TestEvent.Ping, { value: 99 }) yield* bus.publish(TestEvent.Ping, { value: 99 })
yield* Deferred.await(doneA) yield* Deferred.await(doneA)
yield* Deferred.await(doneB) yield* Deferred.await(doneB)
expect(a).toEqual([99]) expect(a).toEqual([99])
expect(b).toEqual([99]) expect(b).toEqual([99])
}), }),
),
) )
it.live("subscribeAll stream sees InstanceDisposed on disposal", () => it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
+14
View File
@@ -5,10 +5,12 @@ import path from "path"
import { Effect, Context } from "effect" import { Effect, Context } from "effect"
import type * as PlatformError from "effect/PlatformError" import type * as PlatformError from "effect/PlatformError"
import type * as Scope from "effect/Scope" import type * as Scope from "effect/Scope"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import type { Config } from "@/config/config" import type { Config } from "@/config/config"
import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { TestLLMServer } from "../lib/llm-server" import { TestLLMServer } from "../lib/llm-server"
// Re-export for test ergonomics. The implementation lives next to the runtime // Re-export for test ergonomics. The implementation lives next to the runtime
@@ -160,6 +162,18 @@ export function provideTmpdirInstance<A, E, R>(
}) })
} }
export class TestInstance extends Context.Service<TestInstance, { readonly directory: string }>()("@test/Instance") {}
export const withTmpdirInstance =
(options?: { git?: boolean; config?: Partial<Config.Info> }) =>
<A, E, R>(self: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped(options)
return yield* InstanceStore.Service.use((store) =>
store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))),
)
}).pipe(Effect.provide(InstanceStore.defaultLayer), Effect.provide(CrossSpawnSpawner.defaultLayer))
export function provideTmpdirServer<A, E, R>( export function provideTmpdirServer<A, E, R>(
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>, self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> }, options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
+25 -1
View File
@@ -3,8 +3,11 @@ import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope" import type * as Scope from "effect/Scope"
import * as TestClock from "effect/testing/TestClock" import * as TestClock from "effect/testing/TestClock"
import * as TestConsole from "effect/testing/TestConsole" import * as TestConsole from "effect/testing/TestConsole"
import type { Config } from "@/config/config"
import { TestInstance, withTmpdirInstance } from "../fixture/fixture"
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>) type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
type InstanceOptions = { git?: boolean; config?: Partial<Config.Info> }
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value)) const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
@@ -38,7 +41,28 @@ const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>)
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) => live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, liveLayer), opts) test.skip(name, () => run(value, liveLayer), opts)
return { effect, live } const instance = <A, E2>(
name: string,
value: Body<A, E2, R | TestInstance | Scope.Scope>,
instanceOptions?: InstanceOptions,
opts?: number | TestOptions,
) => test(name, () => run(body(value).pipe(withTmpdirInstance(instanceOptions)), liveLayer), opts)
instance.only = <A, E2>(
name: string,
value: Body<A, E2, R | TestInstance | Scope.Scope>,
instanceOptions?: InstanceOptions,
opts?: number | TestOptions,
) => test.only(name, () => run(body(value).pipe(withTmpdirInstance(instanceOptions)), liveLayer), opts)
instance.skip = <A, E2>(
name: string,
value: Body<A, E2, R | TestInstance | Scope.Scope>,
instanceOptions?: InstanceOptions,
opts?: number | TestOptions,
) => test.skip(name, () => run(body(value).pipe(withTmpdirInstance(instanceOptions)), liveLayer), opts)
return { effect, live, instance }
} }
// Test environment with TestClock and TestConsole // Test environment with TestClock and TestConsole
+329 -387
View File
@@ -1,64 +1,63 @@
import { afterEach, test, expect } from "bun:test" import { afterEach, expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema" import { QuestionID } from "../../src/question/schema"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime" import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
const ask = (input: { sessionID: SessionID; questions: ReadonlyArray<Question.Info>; tool?: Question.Tool }) => const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) const askEffect = Effect.fn("QuestionTest.ask")(function* (input: {
sessionID: SessionID
questions: ReadonlyArray<Question.Info>
tool?: Question.Tool
}) {
const question = yield* Question.Service
return yield* question.ask(input)
})
const reply = (input: { requestID: QuestionID; answers: ReadonlyArray<Question.Answer> }) => const listEffect = Question.Service.use((svc) => svc.list())
AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: {
requestID: QuestionID
answers: ReadonlyArray<Question.Answer>
}) {
const question = yield* Question.Service
yield* question.reply(input)
})
const rejectEffect = Effect.fn("QuestionTest.reject")(function* (id: QuestionID) {
const question = yield* Question.Service
yield* question.reject(id)
})
afterEach(async () => { afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
}) })
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */ /** Reject all pending questions so dangling Deferred fibers don't hang the test. */
async function rejectAll() { const rejectAll = Effect.gen(function* () {
const pending = await list() yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true })
for (const req of pending) {
await reject(req.id)
}
}
test("ask - returns pending promise", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
expect(promise).toBeInstanceOf(Promise)
await rejectAll()
await promise.catch(() => {})
},
})
}) })
test("ask - adds to pending list", async () => { const waitForPending = (count: number) =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ for (let i = 0; i < 100; i++) {
directory: tmp.path, const pending = yield* listEffect
fn: async () => { if (pending.length === count) return pending
const questions = [ yield* Effect.sleep("10 millis")
}
return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`))
})
it.instance("ask - remains pending until answered", () =>
Effect.gen(function* () {
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"),
questions: [
{ {
question: "What would you like to do?", question: "What would you like to do?",
header: "Action", header: "Action",
@@ -67,30 +66,81 @@ test("ask - adds to pending list", async () => {
{ label: "Option 2", description: "Second option" }, { label: "Option 2", description: "Second option" },
], ],
}, },
] ],
}).pipe(Effect.forkScoped)
const promise = ask({ expect(yield* waitForPending(1)).toHaveLength(1)
sessionID: SessionID.make("ses_test"), yield* rejectAll
questions, expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
}) }),
{ git: true },
)
const pending = await list() it.instance("ask - adds to pending list", () =>
expect(pending.length).toBe(1) Effect.gen(function* () {
expect(pending[0].questions).toEqual(questions) const questions = [
await rejectAll() {
await promise.catch(() => {}) question: "What would you like to do?",
}, header: "Action",
}) options: [
}) { label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
]
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"),
questions,
}).pipe(Effect.forkScoped)
const pending = yield* waitForPending(1)
expect(pending.length).toBe(1)
expect(pending[0].questions).toEqual(questions)
yield* rejectAll
expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
}),
{ git: true },
)
// reply tests // reply tests
test("reply - resolves the pending ask with answers", async () => { it.instance("reply - resolves the pending ask with answers", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ const questions = [
directory: tmp.path, {
fn: async () => { question: "What would you like to do?",
const questions = [ header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
]
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"),
questions,
}).pipe(Effect.forkScoped)
const pending = yield* waitForPending(1)
const requestID = pending[0].id
yield* replyEffect({
requestID,
answers: [["Option 1"]],
})
expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]])
}),
{ git: true },
)
it.instance("reply - removes from pending list", () =>
Effect.gen(function* () {
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"),
questions: [
{ {
question: "What would you like to do?", question: "What would you like to do?",
header: "Action", header: "Action",
@@ -99,366 +149,258 @@ test("reply - resolves the pending ask with answers", async () => {
{ label: "Option 2", description: "Second option" }, { label: "Option 2", description: "Second option" },
], ],
}, },
] ],
}).pipe(Effect.forkScoped)
const promise = ask({ const pending = yield* waitForPending(1)
sessionID: SessionID.make("ses_test"), expect(pending.length).toBe(1)
questions,
})
const pending = await list() yield* replyEffect({
const requestID = pending[0].id requestID: pending[0].id,
answers: [["Option 1"]],
})
yield* Fiber.join(fiber)
await reply({ const after = yield* listEffect
requestID, expect(after.length).toBe(0)
answers: [["Option 1"]], }),
}) { git: true },
)
const answers = await promise it.instance("reply - does nothing for unknown requestID", () =>
expect(answers).toEqual([["Option 1"]]) replyEffect({
}, requestID: QuestionID.make("que_unknown"),
}) answers: [["Option 1"]],
}) }),
{ git: true },
test("reply - removes from pending list", async () => { )
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const pending = await list()
expect(pending.length).toBe(1)
await reply({
requestID: pending[0].id,
answers: [["Option 1"]],
})
await promise
const after = await list()
expect(after.length).toBe(0)
},
})
})
test("reply - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await reply({
requestID: QuestionID.make("que_unknown"),
answers: [["Option 1"]],
})
// Should not throw
},
})
})
// reject tests // reject tests
test("reject - throws RejectedError", async () => { it.instance("reject - throws RejectedError", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ const fiber = yield* askEffect({
directory: tmp.path, sessionID: SessionID.make("ses_test"),
fn: async () => { questions: [
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const pending = await list()
await reject(pending[0].id)
await expect(promise).rejects.toBeInstanceOf(Question.RejectedError)
},
})
})
test("reject - removes from pending list", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = ask({
sessionID: SessionID.make("ses_test"),
questions: [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" },
],
},
],
})
const pending = await list()
expect(pending.length).toBe(1)
await reject(pending[0].id)
promise.catch(() => {}) // Ignore rejection
const after = await list()
expect(after.length).toBe(0)
},
})
})
test("reject - does nothing for unknown requestID", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
await reject(QuestionID.make("que_unknown"))
// Should not throw
},
})
})
// multiple questions tests
test("ask - handles multiple questions", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const questions = [
{ {
question: "What would you like to do?", question: "What would you like to do?",
header: "Action", header: "Action",
options: [ options: [
{ label: "Build", description: "Build the project" }, { label: "Option 1", description: "First option" },
{ label: "Test", description: "Run tests" }, { label: "Option 2", description: "Second option" },
], ],
}, },
],
}).pipe(Effect.forkScoped)
const pending = yield* waitForPending(1)
yield* rejectEffect(pending[0].id)
const exit = yield* Fiber.await(fiber)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError")
}),
{ git: true },
)
it.instance("reject - removes from pending list", () =>
Effect.gen(function* () {
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"),
questions: [
{ {
question: "Which environment?", question: "What would you like to do?",
header: "Env", header: "Action",
options: [ options: [
{ label: "Dev", description: "Development" }, { label: "Option 1", description: "First option" },
{ label: "Prod", description: "Production" }, { label: "Option 2", description: "Second option" },
], ],
}, },
] ],
}).pipe(Effect.forkScoped)
const promise = ask({ const pending = yield* waitForPending(1)
sessionID: SessionID.make("ses_test"), expect(pending.length).toBe(1)
questions,
})
const pending = await list() yield* rejectEffect(pending[0].id)
expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
await reply({ const after = yield* listEffect
requestID: pending[0].id, expect(after.length).toBe(0)
answers: [["Build"], ["Dev"]], }),
}) { git: true },
)
const answers = await promise it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true })
expect(answers).toEqual([["Build"], ["Dev"]])
}, // multiple questions tests
})
}) it.instance("ask - handles multiple questions", () =>
Effect.gen(function* () {
const questions = [
{
question: "What would you like to do?",
header: "Action",
options: [
{ label: "Build", description: "Build the project" },
{ label: "Test", description: "Run tests" },
],
},
{
question: "Which environment?",
header: "Env",
options: [
{ label: "Dev", description: "Development" },
{ label: "Prod", description: "Production" },
],
},
]
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_test"),
questions,
}).pipe(Effect.forkScoped)
const pending = yield* waitForPending(1)
yield* replyEffect({
requestID: pending[0].id,
answers: [["Build"], ["Dev"]],
})
expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]])
}),
{ git: true },
)
// list tests // list tests
test("list - returns all pending requests", async () => { it.instance("list - returns all pending requests", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ const fiber1 = yield* askEffect({
directory: tmp.path, sessionID: SessionID.make("ses_test1"),
fn: async () => { questions: [
const p1 = ask({ {
sessionID: SessionID.make("ses_test1"), question: "Question 1?",
questions: [ header: "Q1",
{ options: [{ label: "A", description: "A" }],
question: "Question 1?", },
header: "Q1", ],
options: [{ label: "A", description: "A" }], }).pipe(Effect.forkScoped)
},
],
})
const p2 = ask({ const fiber2 = yield* askEffect({
sessionID: SessionID.make("ses_test2"), sessionID: SessionID.make("ses_test2"),
questions: [ questions: [
{ {
question: "Question 2?", question: "Question 2?",
header: "Q2", header: "Q2",
options: [{ label: "B", description: "B" }], options: [{ label: "B", description: "B" }],
}, },
], ],
}) }).pipe(Effect.forkScoped)
const pending = await list() const pending = yield* waitForPending(2)
expect(pending.length).toBe(2) expect(pending.length).toBe(2)
await rejectAll() yield* rejectAll
p1.catch(() => {}) expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
p2.catch(() => {}) expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
}, }),
}) { git: true },
}) )
test("list - returns empty when no pending", async () => { it.instance("list - returns empty when no pending", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
await Instance.provide({ const pending = yield* listEffect
directory: tmp.path, expect(pending.length).toBe(0)
fn: async () => { }),
const pending = await list() { git: true },
expect(pending.length).toBe(0) )
},
})
})
test("questions stay isolated by directory", async () => { it.live("questions stay isolated by directory", () =>
await using one = await tmpdir({ git: true }) Effect.gen(function* () {
await using two = await tmpdir({ git: true }) const one = yield* tmpdirScoped({ git: true })
const two = yield* tmpdirScoped({ git: true })
const p1 = Instance.provide({ const fiber1 = yield* askEffect({
directory: one.path, sessionID: SessionID.make("ses_one"),
fn: () => questions: [
ask({ {
sessionID: SessionID.make("ses_one"), question: "Question 1?",
questions: [ header: "Q1",
{ options: [{ label: "A", description: "A" }],
question: "Question 1?", },
header: "Q1", ],
options: [{ label: "A", description: "A" }], }).pipe(provideInstance(one), Effect.forkScoped)
},
],
}),
})
const p2 = Instance.provide({ const fiber2 = yield* askEffect({
directory: two.path, sessionID: SessionID.make("ses_two"),
fn: () => questions: [
ask({ {
sessionID: SessionID.make("ses_two"), question: "Question 2?",
questions: [ header: "Q2",
{ options: [{ label: "B", description: "B" }],
question: "Question 2?", },
header: "Q2", ],
options: [{ label: "B", description: "B" }], }).pipe(provideInstance(two), Effect.forkScoped)
},
],
}),
})
const onePending = await Instance.provide({ const onePending = yield* waitForPending(1).pipe(provideInstance(one))
directory: one.path, const twoPending = yield* waitForPending(1).pipe(provideInstance(two))
fn: () => list(),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => list(),
})
expect(onePending.length).toBe(1) expect(onePending.length).toBe(1)
expect(twoPending.length).toBe(1) expect(twoPending.length).toBe(1)
expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
await Instance.provide({ yield* rejectEffect(onePending[0].id).pipe(provideInstance(one))
directory: one.path, yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two))
fn: () => reject(onePending[0].id),
})
await Instance.provide({
directory: two.path,
fn: () => reject(twoPending[0].id),
})
await p1.catch(() => {}) expect((yield* Fiber.await(fiber1))._tag).toBe("Failure")
await p2.catch(() => {}) expect((yield* Fiber.await(fiber2))._tag).toBe("Failure")
}) }),
)
test("pending question rejects on instance dispose", async () => { it.live("pending question rejects on instance dispose", () =>
await using tmp = await tmpdir({ git: true }) Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_dispose"),
questions: [
{
question: "Dispose me?",
header: "Dispose",
options: [{ label: "Yes", description: "Yes" }],
},
],
}).pipe(provideInstance(dir), Effect.forkScoped)
const pending = Instance.provide({ expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
directory: tmp.path, yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void Instance.dispose() }))
fn: () => {
return ask({
sessionID: SessionID.make("ses_dispose"),
questions: [
{
question: "Dispose me?",
header: "Dispose",
options: [{ label: "Yes", description: "Yes" }],
},
],
})
},
})
const result = pending.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({ const exit = yield* Fiber.await(fiber)
directory: tmp.path, expect(Exit.isFailure(exit)).toBe(true)
fn: async () => { if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
const items = await list() }),
expect(items).toHaveLength(1) )
await Instance.dispose()
},
})
expect(await result).toBeInstanceOf(Question.RejectedError) it.live("pending question rejects on instance reload", () =>
}) Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const fiber = yield* askEffect({
sessionID: SessionID.make("ses_reload"),
questions: [
{
question: "Reload me?",
header: "Reload",
options: [{ label: "Yes", description: "Yes" }],
},
],
}).pipe(provideInstance(dir), Effect.forkScoped)
test("pending question rejects on instance reload", async () => { expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
await using tmp = await tmpdir({ git: true }) yield* Effect.promise(() => Instance.reload({ directory: dir }))
const pending = Instance.provide({ const exit = yield* Fiber.await(fiber)
directory: tmp.path, expect(Exit.isFailure(exit)).toBe(true)
fn: () => { if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
return ask({ }),
sessionID: SessionID.make("ses_reload"), )
questions: [
{
question: "Reload me?",
header: "Reload",
options: [{ label: "Yes", description: "Yes" }],
},
],
})
},
})
const result = pending.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const items = await list()
expect(items).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
expect(await result).toBeInstanceOf(Question.RejectedError)
})
@@ -0,0 +1,34 @@
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Cause, Effect } from "effect"
export function waitGlobalBusEvent(input: {
timeout?: number
message?: string
predicate: (event: GlobalEvent) => boolean
}) {
return Effect.callback<GlobalEvent, unknown>((resume) => {
const cleanup = () => GlobalBus.off("event", handler)
const handler = (event: GlobalEvent) => {
try {
if (!input.predicate(event)) return
cleanup()
resume(Effect.succeed(event))
} catch (error) {
cleanup()
resume(Effect.fail(error))
}
}
GlobalBus.on("event", handler)
return Effect.sync(cleanup)
}).pipe(
Effect.timeout(input.timeout ?? 10_000),
Effect.mapError((error) =>
Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error,
),
)
}
export const waitGlobalBusEventPromise = (input: Parameters<typeof waitGlobalBusEvent>[0]) =>
Effect.runPromise(waitGlobalBusEvent(input))
@@ -1,12 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -18,20 +17,9 @@ function app() {
} }
async function waitDisposed(directory: string) { async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => { await waitGlobalBusEventPromise({
const timer = setTimeout(() => { message: "timed out waiting for instance disposal",
GlobalBus.off("event", onEvent) predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
}) })
} }
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
@@ -11,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Worktree } from "../../src/worktree" import { Worktree } from "../../src/worktree"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -31,20 +31,9 @@ function createSession(input?: Session.CreateInput) {
} }
async function waitReady(directory: string) { async function waitReady(directory: string) {
return await new Promise<void>((resolve, reject) => { await waitGlobalBusEventPromise({
const timer = setTimeout(() => { message: "timed out waiting for worktree.ready",
GlobalBus.off("event", onEvent) predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory,
reject(new Error("timed out waiting for worktree.ready"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
}) })
} }
@@ -1,6 +1,5 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer } from "effect" import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
@@ -20,6 +19,7 @@ import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpa
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { waitGlobalBusEvent } from "./global-bus"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard( const testStateLayer = Layer.effectDiscard(
@@ -97,24 +97,10 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
Layer.build, Layer.build,
) )
const waitDisposedEvent = Effect.promise( const waitDisposedEvent = waitGlobalBusEvent({
() => message: "timed out waiting for instance disposal",
new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => { predicate: (event) => event.payload.type === "server.instance.disposed",
const timer = setTimeout(() => { }).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed") return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve({ directory: event.directory, workspace: event.workspace })
}
GlobalBus.on("event", onEvent)
}),
)
const serveDisposeProbe = () => const serveDisposeProbe = () =>
HttpRouter.serve( HttpRouter.serve(
@@ -1,12 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -18,20 +17,9 @@ function app() {
} }
async function waitDisposed(directory: string) { async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => { await waitGlobalBusEventPromise({
const timer = setTimeout(() => { message: "timed out waiting for instance disposal",
GlobalBus.off("event", onEvent) predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
}) })
} }
@@ -117,13 +105,9 @@ describe("instance HttpApi", () => {
test("serves instance dispose through Hono bridge", async () => { test("serves instance dispose through Hono bridge", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const disposed = new Promise<string | undefined>((resolve) => { const disposed = waitGlobalBusEventPromise({
const onEvent = (event: { directory?: string; payload: { type?: string } }) => { message: "timed out waiting for instance disposal",
if (event.payload.type !== "server.instance.disposed") return predicate: (event) => event.payload.type === "server.instance.disposed",
GlobalBus.off("event", onEvent)
resolve(event.directory)
}
GlobalBus.on("event", onEvent)
}) })
const response = await app().request(InstancePaths.dispose, { const response = await app().request(InstancePaths.dispose, {
@@ -133,6 +117,6 @@ describe("instance HttpApi", () => {
expect(response.status).toBe(200) expect(response.status).toBe(200)
expect(await response.json()).toBe(true) expect(await response.json()).toBe(true)
expect(await disposed).toBe(tmp.path) expect((await disposed).directory).toBe(tmp.path)
}) })
}) })
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono" import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "../../src/bus/global"
import { TuiEvent } from "../../src/cli/cmd/tui/event" import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
@@ -12,6 +11,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { OpenApi } from "effect/unstable/httpapi" import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { waitGlobalBusEventPromise } from "./global-bus"
void Log.init({ print: false }) void Log.init({ print: false })
@@ -23,14 +23,9 @@ function app(experimental = true) {
} }
function nextCommandExecute() { function nextCommandExecute() {
return new Promise<unknown>((resolve) => { return waitGlobalBusEventPromise({
const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => { predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type,
if (event.payload.type !== TuiEvent.CommandExecute.type) return }).then((event) => event.payload.properties?.command)
GlobalBus.off("event", listener)
resolve(event.payload.properties?.command)
}
GlobalBus.on("event", listener)
})
} }
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) { async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
+38 -40
View File
@@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { provideTmpdirInstance } from "../fixture/fixture" import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect( const it = testEffect(
@@ -33,49 +33,47 @@ const ctx = {
} }
describe("tool.glob", () => { describe("tool.glob", () => {
it.live("matches files from a directory path", () => it.instance("matches files from a directory path", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n"))
yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n")) yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n"))
const info = yield* GlobTool const info = yield* GlobTool
const glob = yield* info.init() const glob = yield* info.init()
const result = yield* glob.execute( const result = yield* glob.execute(
{
pattern: "*.ts",
path: test.directory,
},
ctx,
)
expect(result.metadata.count).toBe(1)
expect(result.output).toContain(path.join(test.directory, "a.ts"))
expect(result.output).not.toContain(path.join(test.directory, "b.txt"))
}),
)
it.instance("rejects exact file paths", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "a.ts")
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const exit = yield* glob
.execute(
{ {
pattern: "*.ts", pattern: "*.ts",
path: dir, path: file,
}, },
ctx, ctx,
) )
expect(result.metadata.count).toBe(1) .pipe(Effect.exit)
expect(result.output).toContain(path.join(dir, "a.ts")) expect(Exit.isFailure(exit)).toBe(true)
expect(result.output).not.toContain(path.join(dir, "b.txt")) if (Exit.isFailure(exit)) {
}), const err = Cause.squash(exit.cause)
), expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
) }
}),
it.live("rejects exact file paths", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "a.ts")
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const exit = yield* glob
.execute(
{
pattern: "*.ts",
path: file,
},
ctx,
)
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
}
}),
),
) )
}) })
+50 -53
View File
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { GrepTool } from "../../src/tool/grep" import { GrepTool } from "../../src/tool/grep"
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { provideInstance, TestInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
@@ -54,61 +54,58 @@ describe("tool.grep", () => {
}), }),
) )
it.live("no matches returns correct output", () => it.instance("no matches returns correct output", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world")) yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world"))
const info = yield* GrepTool const info = yield* GrepTool
const grep = yield* info.init() const grep = yield* info.init()
const result = yield* grep.execute( const result = yield* grep.execute(
{ {
pattern: "xyznonexistentpatternxyz123", pattern: "xyznonexistentpatternxyz123",
path: dir, path: test.directory,
}, },
ctx, ctx,
) )
expect(result.metadata.matches).toBe(0) expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found") expect(result.output).toBe("No files found")
}), }),
),
) )
it.live("finds matches in tmp instance", () => it.instance("finds matches in tmp instance", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")) yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3"))
const info = yield* GrepTool const info = yield* GrepTool
const grep = yield* info.init() const grep = yield* info.init()
const result = yield* grep.execute( const result = yield* grep.execute(
{ {
pattern: "line", pattern: "line",
path: dir, path: test.directory,
}, },
ctx, ctx,
) )
expect(result.metadata.matches).toBeGreaterThan(0) expect(result.metadata.matches).toBeGreaterThan(0)
}), }),
),
) )
it.live("supports exact file paths", () => it.instance("supports exact file paths", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
const file = path.join(dir, "test.txt") const file = path.join(test.directory, "test.txt")
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
const info = yield* GrepTool const info = yield* GrepTool
const grep = yield* info.init() const grep = yield* info.init()
const result = yield* grep.execute( const result = yield* grep.execute(
{ {
pattern: "line2", pattern: "line2",
path: file, path: file,
}, },
ctx, ctx,
) )
expect(result.metadata.matches).toBe(1) expect(result.metadata.matches).toBe(1)
expect(result.output).toContain(file) expect(result.output).toContain(file)
expect(result.output).toContain("Line 2: line2") expect(result.output).toContain("Line 2: line2")
}), }),
),
) )
}) })
+40 -45
View File
@@ -6,7 +6,6 @@ import { SessionID, MessageID } from "../../src/session/schema"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const ctx = { const ctx = {
@@ -34,56 +33,52 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest
}) })
describe("tool.question", () => { describe("tool.question", () => {
it.live("should successfully execute with valid question parameters", () => it.instance("should successfully execute with valid question parameters", () =>
provideTmpdirInstance(() => Effect.gen(function* () {
Effect.gen(function* () { const question = yield* Question.Service
const question = yield* Question.Service const toolInfo = yield* QuestionTool
const toolInfo = yield* QuestionTool const tool = yield* toolInfo.init()
const tool = yield* toolInfo.init() const questions = [
const questions = [ {
{ question: "What is your favorite color?",
question: "What is your favorite color?", header: "Color",
header: "Color", options: [
options: [ { label: "Red", description: "The color of passion" },
{ label: "Red", description: "The color of passion" }, { label: "Blue", description: "The color of sky" },
{ label: "Blue", description: "The color of sky" }, ],
], multiple: false,
multiple: false, },
}, ]
]
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question) const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Red"]] }) yield* question.reply({ requestID: item.id, answers: [["Red"]] })
const result = yield* Fiber.join(fiber) const result = yield* Fiber.join(fiber)
expect(result.title).toBe("Asked 1 question") expect(result.title).toBe("Asked 1 question")
}), }),
),
) )
it.live("should now pass with a header longer than 12 but less than 30 chars", () => it.instance("should now pass with a header longer than 12 but less than 30 chars", () =>
provideTmpdirInstance(() => Effect.gen(function* () {
Effect.gen(function* () { const question = yield* Question.Service
const question = yield* Question.Service const toolInfo = yield* QuestionTool
const toolInfo = yield* QuestionTool const tool = yield* toolInfo.init()
const tool = yield* toolInfo.init() const questions = [
const questions = [ {
{ question: "What is your favorite animal?",
question: "What is your favorite animal?", header: "This Header is Over 12",
header: "This Header is Over 12", options: [{ label: "Dog", description: "Man's best friend" }],
options: [{ label: "Dog", description: "Man's best friend" }], },
}, ]
]
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question) const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
const result = yield* Fiber.join(fiber) const result = yield* Fiber.join(fiber)
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
}), }),
),
) )
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough // intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough
+122 -126
View File
@@ -2,10 +2,9 @@ import { afterEach, describe, expect } from "bun:test"
import path from "path" import path from "path"
import fs from "fs/promises" import fs from "fs/promises"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry" import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const node = CrossSpawnSpawner.defaultLayer const node = CrossSpawnSpawner.defaultLayer
@@ -17,136 +16,133 @@ afterEach(async () => {
}) })
describe("tool.registry", () => { describe("tool.registry", () => {
it.live("loads tools from .opencode/tool (singular)", () => it.instance("loads tools from .opencode/tool (singular)", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
const opencode = path.join(dir, ".opencode") const opencode = path.join(test.directory, ".opencode")
const tool = path.join(opencode, "tool") const tool = path.join(opencode, "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() => yield* Effect.promise(() =>
Bun.write( Bun.write(
path.join(tool, "hello.ts"), path.join(tool, "hello.ts"),
[ [
"export default {", "export default {",
" description: 'hello tool',", " description: 'hello tool',",
" args: {},", " args: {},",
" execute: async () => {", " execute: async () => {",
" return 'hello world'", " return 'hello world'",
" },", " },",
"}", "}",
"", "",
].join("\n"), ].join("\n"),
), ),
) )
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids() const ids = yield* registry.ids()
expect(ids).toContain("hello") expect(ids).toContain("hello")
}), }),
),
) )
it.live("loads tools from .opencode/tools (plural)", () => it.instance("loads tools from .opencode/tools (plural)", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
const opencode = path.join(dir, ".opencode") const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools") const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() => yield* Effect.promise(() =>
Bun.write( Bun.write(
path.join(tools, "hello.ts"), path.join(tools, "hello.ts"),
[ [
"export default {", "export default {",
" description: 'hello tool',", " description: 'hello tool',",
" args: {},", " args: {},",
" execute: async () => {", " execute: async () => {",
" return 'hello world'", " return 'hello world'",
" },", " },",
"}", "}",
"", "",
].join("\n"), ].join("\n"),
), ),
) )
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids() const ids = yield* registry.ids()
expect(ids).toContain("hello") expect(ids).toContain("hello")
}), }),
),
) )
it.live("loads tools with external dependencies without crashing", () => it.instance("loads tools with external dependencies without crashing", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
const opencode = path.join(dir, ".opencode") const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools") const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() => yield* Effect.promise(() =>
Bun.write( Bun.write(
path.join(opencode, "package.json"), path.join(opencode, "package.json"),
JSON.stringify({ JSON.stringify({
name: "custom-tools", name: "custom-tools",
dependencies: { dependencies: {
"@opencode-ai/plugin": "^0.0.0", "@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0", cowsay: "^1.6.0",
}, },
}), }),
), ),
) )
yield* Effect.promise(() => yield* Effect.promise(() =>
Bun.write( Bun.write(
path.join(opencode, "package-lock.json"), path.join(opencode, "package-lock.json"),
JSON.stringify({ JSON.stringify({
name: "custom-tools", name: "custom-tools",
lockfileVersion: 3, lockfileVersion: 3,
packages: { packages: {
"": { "": {
dependencies: { dependencies: {
"@opencode-ai/plugin": "^0.0.0", "@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0", cowsay: "^1.6.0",
},
}, },
}, },
}), },
), }),
) ),
)
const cowsay = path.join(opencode, "node_modules", "cowsay") const cowsay = path.join(opencode, "node_modules", "cowsay")
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
yield* Effect.promise(() => yield* Effect.promise(() =>
Bun.write( Bun.write(
path.join(cowsay, "package.json"), path.join(cowsay, "package.json"),
JSON.stringify({ JSON.stringify({
name: "cowsay", name: "cowsay",
type: "module", type: "module",
exports: "./index.js", exports: "./index.js",
}), }),
), ),
) )
yield* Effect.promise(() => yield* Effect.promise(() =>
Bun.write( Bun.write(
path.join(cowsay, "index.js"), path.join(cowsay, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
), ),
) )
yield* Effect.promise(() => yield* Effect.promise(() =>
Bun.write( Bun.write(
path.join(tools, "cowsay.ts"), path.join(tools, "cowsay.ts"),
[ [
"import { say } from 'cowsay'", "import { say } from 'cowsay'",
"export default {", "export default {",
" description: 'tool that imports cowsay at top level',", " description: 'tool that imports cowsay at top level',",
" args: { text: { type: 'string' } },", " args: { text: { type: 'string' } },",
" execute: async ({ text }: { text: string }) => {", " execute: async ({ text }: { text: string }) => {",
" return say({ text })", " return say({ text })",
" },", " },",
"}", "}",
"", "",
].join("\n"), ].join("\n"),
), ),
) )
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids() const ids = yield* registry.ids()
expect(ids).toContain("cowsay") expect(ids).toContain("cowsay")
}), }),
),
) )
}) })
+26 -29
View File
@@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const ctx = { const ctx = {
@@ -58,42 +58,39 @@ const run = Effect.fn("WriteToolTest.run")(function* (
describe("tool.write", () => { describe("tool.write", () => {
describe("new file creation", () => { describe("new file creation", () => {
it.live("writes content to new file", () => it.instance("writes content to new file", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
const filepath = path.join(dir, "newfile.txt") const filepath = path.join(test.directory, "newfile.txt")
const result = yield* run({ filePath: filepath, content: "Hello, World!" }) const result = yield* run({ filePath: filepath, content: "Hello, World!" })
expect(result.output).toContain("Wrote file successfully") expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(false) expect(result.metadata.exists).toBe(false)
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("Hello, World!") expect(content).toBe("Hello, World!")
}), }),
),
) )
it.live("creates parent directories if needed", () => it.instance("creates parent directories if needed", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
const filepath = path.join(dir, "nested", "deep", "file.txt") const filepath = path.join(test.directory, "nested", "deep", "file.txt")
yield* run({ filePath: filepath, content: "nested content" }) yield* run({ filePath: filepath, content: "nested content" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("nested content") expect(content).toBe("nested content")
}), }),
),
) )
it.live("handles relative paths by resolving to instance directory", () => it.instance("handles relative paths by resolving to instance directory", () =>
provideTmpdirInstance((dir) => Effect.gen(function* () {
Effect.gen(function* () { const test = yield* TestInstance
yield* run({ filePath: "relative.txt", content: "relative content" }) yield* run({ filePath: "relative.txt", content: "relative content" })
const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8")) const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8"))
expect(content).toBe("relative content") expect(content).toBe("relative content")
}), }),
),
) )
}) })