mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 01:06:16 -04:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c13699e2c6 | |||
| 1557f31415 | |||
| c7a10ac38b | |||
| baa6976a8d | |||
| 73406e786f | |||
| 76d490afeb | |||
| 5ad32d4e7b | |||
| 1477b38ea2 | |||
| 0e256a1de1 | |||
| 2e1f1c2af1 | |||
| b5f391cd8c | |||
| ed00ae267b | |||
| eebb26aa7e | |||
| 895275eb1e | |||
| 913321e4b6 | |||
| 50d5c57193 | |||
| e07f5fbb5d | |||
| f21aa32367 |
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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ export function lazy<T>(fn: () => T) {
|
|||||||
value = undefined
|
value = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.loaded = () => loaded
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,8 +18,7 @@ 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[] = []
|
||||||
@@ -40,11 +38,9 @@ describe("Bus (Effect-native)", () => {
|
|||||||
|
|
||||||
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[] = []
|
||||||
@@ -64,11 +60,9 @@ describe("Bus (Effect-native)", () => {
|
|||||||
|
|
||||||
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[] = []
|
||||||
@@ -89,11 +83,9 @@ describe("Bus (Effect-native)", () => {
|
|||||||
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[] = []
|
||||||
@@ -123,7 +115,6 @@ describe("Bus (Effect-native)", () => {
|
|||||||
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", () =>
|
||||||
|
|||||||
@@ -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> },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,39 +1,61 @@
|
|||||||
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 () => {
|
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 promise = ask({
|
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"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -45,19 +67,17 @@ test("ask - returns pending promise", async () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}).pipe(Effect.forkScoped)
|
||||||
expect(promise).toBeInstanceOf(Promise)
|
|
||||||
await rejectAll()
|
|
||||||
await promise.catch(() => {})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ask - adds to pending list", async () => {
|
expect(yield* waitForPending(1)).toHaveLength(1)
|
||||||
await using tmp = await tmpdir({ git: true })
|
yield* rejectAll
|
||||||
await Instance.provide({
|
expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
|
||||||
directory: tmp.path,
|
}),
|
||||||
fn: async () => {
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
it.instance("ask - adds to pending list", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
question: "What would you like to do?",
|
question: "What would you like to do?",
|
||||||
@@ -69,27 +89,24 @@ test("ask - adds to pending list", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const promise = ask({
|
const fiber = yield* askEffect({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
}).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
const pending = await list()
|
const pending = yield* waitForPending(1)
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
expect(pending[0].questions).toEqual(questions)
|
expect(pending[0].questions).toEqual(questions)
|
||||||
await rejectAll()
|
yield* rejectAll
|
||||||
await promise.catch(() => {})
|
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({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
question: "What would you like to do?",
|
question: "What would you like to do?",
|
||||||
@@ -101,31 +118,27 @@ test("reply - resolves the pending ask with answers", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const promise = ask({
|
const fiber = yield* askEffect({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
}).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
const pending = await list()
|
const pending = yield* waitForPending(1)
|
||||||
const requestID = pending[0].id
|
const requestID = pending[0].id
|
||||||
|
|
||||||
await reply({
|
yield* replyEffect({
|
||||||
requestID,
|
requestID,
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
})
|
||||||
|
|
||||||
const answers = await promise
|
expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]])
|
||||||
expect(answers).toEqual([["Option 1"]])
|
}),
|
||||||
},
|
{ git: true },
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("reply - removes from pending list", async () => {
|
it.instance("reply - removes from pending list", () =>
|
||||||
await using tmp = await tmpdir({ git: true })
|
Effect.gen(function* () {
|
||||||
await Instance.provide({
|
const fiber = yield* askEffect({
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const promise = ask({
|
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -137,45 +150,36 @@ test("reply - removes from pending list", async () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
const pending = await list()
|
const pending = yield* waitForPending(1)
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
|
|
||||||
await reply({
|
yield* replyEffect({
|
||||||
requestID: pending[0].id,
|
requestID: pending[0].id,
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
})
|
||||||
await promise
|
yield* Fiber.join(fiber)
|
||||||
|
|
||||||
const after = await list()
|
const after = yield* listEffect
|
||||||
expect(after.length).toBe(0)
|
expect(after.length).toBe(0)
|
||||||
},
|
}),
|
||||||
})
|
{ git: true },
|
||||||
})
|
)
|
||||||
|
|
||||||
test("reply - does nothing for unknown requestID", async () => {
|
it.instance("reply - does nothing for unknown requestID", () =>
|
||||||
await using tmp = await tmpdir({ git: true })
|
replyEffect({
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await reply({
|
|
||||||
requestID: QuestionID.make("que_unknown"),
|
requestID: QuestionID.make("que_unknown"),
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
}),
|
||||||
// Should not throw
|
{ git: true },
|
||||||
},
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
fn: async () => {
|
|
||||||
const promise = ask({
|
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -187,22 +191,21 @@ test("reject - throws RejectedError", async () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
const pending = await list()
|
const pending = yield* waitForPending(1)
|
||||||
await reject(pending[0].id)
|
yield* rejectEffect(pending[0].id)
|
||||||
|
|
||||||
await expect(promise).rejects.toBeInstanceOf(Question.RejectedError)
|
const exit = yield* Fiber.await(fiber)
|
||||||
},
|
expect(exit._tag).toBe("Failure")
|
||||||
})
|
if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError")
|
||||||
})
|
}),
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
test("reject - removes from pending list", async () => {
|
it.instance("reject - removes from pending list", () =>
|
||||||
await using tmp = await tmpdir({ git: true })
|
Effect.gen(function* () {
|
||||||
await Instance.provide({
|
const fiber = yield* askEffect({
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const promise = ask({
|
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -214,38 +217,26 @@ test("reject - removes from pending list", async () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
const pending = await list()
|
const pending = yield* waitForPending(1)
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
|
|
||||||
await reject(pending[0].id)
|
yield* rejectEffect(pending[0].id)
|
||||||
promise.catch(() => {}) // Ignore rejection
|
expect((yield* Fiber.await(fiber))._tag).toBe("Failure")
|
||||||
|
|
||||||
const after = await list()
|
const after = yield* listEffect
|
||||||
expect(after.length).toBe(0)
|
expect(after.length).toBe(0)
|
||||||
},
|
}),
|
||||||
})
|
{ git: true },
|
||||||
})
|
)
|
||||||
|
|
||||||
test("reject - does nothing for unknown requestID", async () => {
|
it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true })
|
||||||
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
|
// multiple questions tests
|
||||||
|
|
||||||
test("ask - handles multiple questions", async () => {
|
it.instance("ask - handles multiple questions", () =>
|
||||||
await using tmp = await tmpdir({ git: true })
|
Effect.gen(function* () {
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
question: "What would you like to do?",
|
question: "What would you like to do?",
|
||||||
@@ -265,32 +256,28 @@ test("ask - handles multiple questions", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const promise = ask({
|
const fiber = yield* askEffect({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
}).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
const pending = await list()
|
const pending = yield* waitForPending(1)
|
||||||
|
|
||||||
await reply({
|
yield* replyEffect({
|
||||||
requestID: pending[0].id,
|
requestID: pending[0].id,
|
||||||
answers: [["Build"], ["Dev"]],
|
answers: [["Build"], ["Dev"]],
|
||||||
})
|
})
|
||||||
|
|
||||||
const answers = await promise
|
expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]])
|
||||||
expect(answers).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,
|
|
||||||
fn: async () => {
|
|
||||||
const p1 = ask({
|
|
||||||
sessionID: SessionID.make("ses_test1"),
|
sessionID: SessionID.make("ses_test1"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -299,9 +286,9 @@ test("list - returns all pending requests", async () => {
|
|||||||
options: [{ label: "A", description: "A" }],
|
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: [
|
||||||
{
|
{
|
||||||
@@ -310,36 +297,31 @@ test("list - returns all pending requests", async () => {
|
|||||||
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,
|
|
||||||
fn: async () => {
|
|
||||||
const pending = await list()
|
|
||||||
expect(pending.length).toBe(0)
|
expect(pending.length).toBe(0)
|
||||||
},
|
}),
|
||||||
})
|
{ git: true },
|
||||||
})
|
)
|
||||||
|
|
||||||
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,
|
|
||||||
fn: () =>
|
|
||||||
ask({
|
|
||||||
sessionID: SessionID.make("ses_one"),
|
sessionID: SessionID.make("ses_one"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -348,13 +330,9 @@ test("questions stay isolated by directory", async () => {
|
|||||||
options: [{ label: "A", description: "A" }],
|
options: [{ label: "A", description: "A" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}).pipe(provideInstance(one), Effect.forkScoped)
|
||||||
})
|
|
||||||
|
|
||||||
const p2 = Instance.provide({
|
const fiber2 = yield* askEffect({
|
||||||
directory: two.path,
|
|
||||||
fn: () =>
|
|
||||||
ask({
|
|
||||||
sessionID: SessionID.make("ses_two"),
|
sessionID: SessionID.make("ses_two"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -363,43 +341,28 @@ test("questions stay isolated by directory", async () => {
|
|||||||
options: [{ label: "B", description: "B" }],
|
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 pending = Instance.provide({
|
const fiber = yield* askEffect({
|
||||||
directory: tmp.path,
|
|
||||||
fn: () => {
|
|
||||||
return ask({
|
|
||||||
sessionID: SessionID.make("ses_dispose"),
|
sessionID: SessionID.make("ses_dispose"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -408,33 +371,21 @@ test("pending question rejects on instance dispose", async () => {
|
|||||||
options: [{ label: "Yes", description: "Yes" }],
|
options: [{ label: "Yes", description: "Yes" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}).pipe(provideInstance(dir), Effect.forkScoped)
|
||||||
},
|
|
||||||
})
|
|
||||||
const result = pending.then(
|
|
||||||
() => "resolved" as const,
|
|
||||||
(err) => err,
|
|
||||||
)
|
|
||||||
|
|
||||||
await 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: async () => {
|
|
||||||
const items = await list()
|
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
await Instance.dispose()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await result).toBeInstanceOf(Question.RejectedError)
|
const exit = yield* Fiber.await(fiber)
|
||||||
})
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("pending question rejects on instance reload", async () => {
|
it.live("pending question rejects on instance reload", () =>
|
||||||
await using tmp = await tmpdir({ git: true })
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
const pending = Instance.provide({
|
const fiber = yield* askEffect({
|
||||||
directory: tmp.path,
|
|
||||||
fn: () => {
|
|
||||||
return ask({
|
|
||||||
sessionID: SessionID.make("ses_reload"),
|
sessionID: SessionID.make("ses_reload"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -443,22 +394,13 @@ test("pending question rejects on instance reload", async () => {
|
|||||||
options: [{ label: "Yes", description: "Yes" }],
|
options: [{ label: "Yes", description: "Yes" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}).pipe(provideInstance(dir), Effect.forkScoped)
|
||||||
},
|
|
||||||
})
|
|
||||||
const result = pending.then(
|
|
||||||
() => "resolved" as const,
|
|
||||||
(err) => err,
|
|
||||||
)
|
|
||||||
|
|
||||||
await Instance.provide({
|
expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1)
|
||||||
directory: tmp.path,
|
yield* Effect.promise(() => Instance.reload({ directory: dir }))
|
||||||
fn: async () => {
|
|
||||||
const items = await list()
|
|
||||||
expect(items).toHaveLength(1)
|
|
||||||
await Instance.reload({ directory: tmp.path })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(await result).toBeInstanceOf(Question.RejectedError)
|
const exit = yield* Fiber.await(fiber)
|
||||||
})
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).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) {
|
||||||
|
|||||||
@@ -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,31 +33,30 @@ 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* () {
|
||||||
yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n"))
|
const test = yield* TestInstance
|
||||||
yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n"))
|
yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\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",
|
pattern: "*.ts",
|
||||||
path: dir,
|
path: test.directory,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
)
|
)
|
||||||
expect(result.metadata.count).toBe(1)
|
expect(result.metadata.count).toBe(1)
|
||||||
expect(result.output).toContain(path.join(dir, "a.ts"))
|
expect(result.output).toContain(path.join(test.directory, "a.ts"))
|
||||||
expect(result.output).not.toContain(path.join(dir, "b.txt"))
|
expect(result.output).not.toContain(path.join(test.directory, "b.txt"))
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("rejects exact file paths", () =>
|
it.instance("rejects exact file paths", () =>
|
||||||
provideTmpdirInstance((dir) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = path.join(dir, "a.ts")
|
const test = yield* TestInstance
|
||||||
|
const file = path.join(test.directory, "a.ts")
|
||||||
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
|
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
|
||||||
const info = yield* GlobTool
|
const info = yield* GlobTool
|
||||||
const glob = yield* info.init()
|
const glob = yield* info.init()
|
||||||
@@ -76,6 +75,5 @@ describe("tool.glob", () => {
|
|||||||
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
|
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,47 +54,45 @@ 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* () {
|
||||||
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world"))
|
const test = yield* TestInstance
|
||||||
|
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* () {
|
||||||
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3"))
|
const test = yield* TestInstance
|
||||||
|
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 file = path.join(dir, "test.txt")
|
const test = yield* TestInstance
|
||||||
|
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()
|
||||||
@@ -109,6 +107,5 @@ describe("tool.grep", () => {
|
|||||||
expect(result.output).toContain(file)
|
expect(result.output).toContain(file)
|
||||||
expect(result.output).toContain("Line 2: line2")
|
expect(result.output).toContain("Line 2: line2")
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,8 +33,7 @@ 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
|
||||||
@@ -59,11 +57,9 @@ describe("tool.question", () => {
|
|||||||
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
|
||||||
@@ -83,7 +79,6 @@ describe("tool.question", () => {
|
|||||||
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
|
||||||
|
|||||||
@@ -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,10 +16,10 @@ 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 opencode = path.join(dir, ".opencode")
|
const test = yield* TestInstance
|
||||||
|
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(() =>
|
||||||
@@ -42,13 +41,12 @@ describe("tool.registry", () => {
|
|||||||
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 opencode = path.join(dir, ".opencode")
|
const test = yield* TestInstance
|
||||||
|
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(() =>
|
||||||
@@ -70,13 +68,12 @@ describe("tool.registry", () => {
|
|||||||
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 opencode = path.join(dir, ".opencode")
|
const test = yield* TestInstance
|
||||||
|
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(() =>
|
||||||
@@ -147,6 +144,5 @@ describe("tool.registry", () => {
|
|||||||
const ids = yield* registry.ids()
|
const ids = yield* registry.ids()
|
||||||
expect(ids).toContain("cowsay")
|
expect(ids).toContain("cowsay")
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,10 +58,10 @@ 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 filepath = path.join(dir, "newfile.txt")
|
const test = yield* TestInstance
|
||||||
|
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")
|
||||||
@@ -70,30 +70,27 @@ describe("tool.write", () => {
|
|||||||
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 filepath = path.join(dir, "nested", "deep", "file.txt")
|
const test = yield* TestInstance
|
||||||
|
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")
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user