mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 489ac620a9 | |||
| 8046b1066f | |||
| 68b23a80eb | |||
| fb869d4619 | |||
| ddfa605c7c | |||
| ad40cebbd3 | |||
| 0125b73145 | |||
| 397ad07c68 | |||
| 9643c58020 | |||
| 4e30cf4dbf |
@@ -0,0 +1,319 @@
|
|||||||
|
import {
|
||||||
|
LLMClient,
|
||||||
|
LLMEvent,
|
||||||
|
type LLMClientShape,
|
||||||
|
type LLMClientService,
|
||||||
|
type LLMError,
|
||||||
|
type LLMRequest,
|
||||||
|
} from "@opencode-ai/llm"
|
||||||
|
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Queue, Ref, Stream } from "effect"
|
||||||
|
|
||||||
|
class RunnerEndedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Runner completed before the next LLM request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RunnerScenarioActiveError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("RunnerScenario.run is already active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RunnerLLMRequestError extends Error {
|
||||||
|
constructor(readonly id: number) {
|
||||||
|
super(`Runner LLM request #${id} was not handled by the interaction`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RunnerLLMResponseClosedError extends Error {
|
||||||
|
constructor(readonly id: number) {
|
||||||
|
super(`Runner LLM request #${id} is no longer awaiting a response`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerLLMCall {
|
||||||
|
readonly request: LLMRequest
|
||||||
|
readonly respond: {
|
||||||
|
readonly stop: () => Effect.Effect<void, Error>
|
||||||
|
readonly text: (text: string, options?: { readonly id?: string }) => Effect.Effect<void, Error>
|
||||||
|
readonly toolCall: (
|
||||||
|
name: string,
|
||||||
|
input: unknown,
|
||||||
|
options?: { readonly id?: string; readonly providerExecuted?: boolean },
|
||||||
|
) => Effect.Effect<void, Error>
|
||||||
|
readonly events: (...events: ReadonlyArray<LLMEvent>) => Effect.Effect<void, Error>
|
||||||
|
readonly stream: (stream: Stream.Stream<LLMEvent, LLMError>) => Effect.Effect<void, Error>
|
||||||
|
readonly fail: (error: LLMError) => Effect.Effect<void, Error>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallState {
|
||||||
|
readonly generation: number
|
||||||
|
readonly status: "pending" | "responded" | "closed"
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
readonly accepting: boolean
|
||||||
|
readonly active: number | undefined
|
||||||
|
readonly nextGeneration: number
|
||||||
|
readonly nextID: number
|
||||||
|
readonly calls: ReadonlyMap<number, CallState>
|
||||||
|
readonly requests: ReadonlyArray<LLMRequest>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registration =
|
||||||
|
| { readonly _tag: "Registered"; readonly id: number; readonly generation: number }
|
||||||
|
| { readonly _tag: "Unexpected"; readonly error: Error }
|
||||||
|
|
||||||
|
interface PendingCall {
|
||||||
|
readonly _tag: "Call"
|
||||||
|
readonly generation: number
|
||||||
|
readonly id: number
|
||||||
|
readonly call: RunnerLLMCall
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunnerEnd {
|
||||||
|
readonly _tag: "End"
|
||||||
|
readonly generation: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunnerLLMInternal {
|
||||||
|
readonly llm: RunnerLLM
|
||||||
|
readonly begin: Effect.Effect<number, RunnerScenarioActiveError>
|
||||||
|
readonly finishInteraction: (generation: number) => Effect.Effect<void, Error>
|
||||||
|
readonly end: (generation: number) => Effect.Effect<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerLLM {
|
||||||
|
readonly layer: Layer.Layer<LLMClientService>
|
||||||
|
readonly next: () => Effect.Effect<RunnerLLMCall, Error>
|
||||||
|
readonly requests: Effect.Effect<ReadonlyArray<LLMRequest>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = (...events: ReadonlyArray<LLMEvent>) => Stream.fromIterable(events)
|
||||||
|
|
||||||
|
const stop = () =>
|
||||||
|
events(
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||||
|
LLMEvent.finish({ reason: "stop" }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const text = (value: string, options?: { readonly id?: string }) => {
|
||||||
|
const id = options?.id ?? "text"
|
||||||
|
return events(
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
LLMEvent.textStart({ id }),
|
||||||
|
LLMEvent.textDelta({ id, text: value }),
|
||||||
|
LLMEvent.textEnd({ id }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
|
||||||
|
LLMEvent.finish({ reason: "stop" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCall = (
|
||||||
|
name: string,
|
||||||
|
input: unknown,
|
||||||
|
options?: { readonly id?: string; readonly providerExecuted?: boolean },
|
||||||
|
) => {
|
||||||
|
const id = options?.id ?? `call-${name}`
|
||||||
|
return events(
|
||||||
|
LLMEvent.stepStart({ index: 0 }),
|
||||||
|
LLMEvent.toolCall({ id, name, input, providerExecuted: options?.providerExecuted }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
|
||||||
|
LLMEvent.finish({ reason: "tool-calls" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeRunnerLLM = Effect.gen(function* () {
|
||||||
|
const calls = yield* Queue.unbounded<PendingCall | RunnerEnd>()
|
||||||
|
const state = yield* Ref.make<State>({
|
||||||
|
accepting: false,
|
||||||
|
active: undefined,
|
||||||
|
nextGeneration: 1,
|
||||||
|
nextID: 1,
|
||||||
|
calls: new Map(),
|
||||||
|
requests: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const respond = (
|
||||||
|
id: number,
|
||||||
|
response: Deferred.Deferred<Stream.Stream<LLMEvent, LLMError>>,
|
||||||
|
stream: Stream.Stream<LLMEvent, LLMError>,
|
||||||
|
) =>
|
||||||
|
Effect.uninterruptible(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const pending = yield* Ref.modify(state, (current) => {
|
||||||
|
const call = current.calls.get(id)
|
||||||
|
if (call?.status !== "pending") return [false, current]
|
||||||
|
return [true, { ...current, calls: new Map(current.calls).set(id, { ...call, status: "responded" }) }]
|
||||||
|
})
|
||||||
|
if (!pending) return yield* Effect.fail(new RunnerLLMResponseClosedError(id))
|
||||||
|
yield* Deferred.succeed(response, stream)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const stream = (request: LLMRequest) =>
|
||||||
|
Stream.unwrap(
|
||||||
|
Effect.uninterruptibleMask((restore) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* Deferred.make<Stream.Stream<LLMEvent, LLMError>>()
|
||||||
|
const registered = yield* Ref.modify<State, Registration>(state, (current) => {
|
||||||
|
if (!current.accepting || current.active === undefined) {
|
||||||
|
const error = new RunnerLLMRequestError(current.nextID)
|
||||||
|
return [
|
||||||
|
{ _tag: "Unexpected" as const, error },
|
||||||
|
{ ...current, nextID: current.nextID + 1, requests: [...current.requests, request] },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ _tag: "Registered" as const, id: current.nextID, generation: current.active },
|
||||||
|
{
|
||||||
|
...current,
|
||||||
|
nextID: current.nextID + 1,
|
||||||
|
calls: new Map(current.calls).set(current.nextID, {
|
||||||
|
generation: current.active,
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
|
requests: [...current.requests, request],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
if (registered._tag === "Unexpected") return yield* Effect.fail(registered.error)
|
||||||
|
const reply = (stream: Stream.Stream<LLMEvent, LLMError>) => respond(registered.id, response, stream)
|
||||||
|
yield* Queue.offer(calls, {
|
||||||
|
_tag: "Call",
|
||||||
|
generation: registered.generation,
|
||||||
|
id: registered.id,
|
||||||
|
call: {
|
||||||
|
request,
|
||||||
|
respond: {
|
||||||
|
stop: () => reply(stop()),
|
||||||
|
text: (value, options) => reply(text(value, options)),
|
||||||
|
toolCall: (name, input, options) => reply(toolCall(name, input, options)),
|
||||||
|
events: (...input) => reply(events(...input)),
|
||||||
|
stream: reply,
|
||||||
|
fail: (error) => reply(Stream.fail(error)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return yield* restore(Deferred.await(response)).pipe(
|
||||||
|
Effect.onInterrupt(() =>
|
||||||
|
Ref.update(state, (current) => {
|
||||||
|
const call = current.calls.get(registered.id)
|
||||||
|
if (call?.status !== "pending") return current
|
||||||
|
return { ...current, calls: new Map(current.calls).set(registered.id, { ...call, status: "closed" }) }
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const next = (): Effect.Effect<RunnerLLMCall, Error> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const generation = (yield* Ref.get(state)).active
|
||||||
|
if (generation === undefined) return yield* Effect.fail(new RunnerEndedError())
|
||||||
|
const pending = yield* Queue.take(calls)
|
||||||
|
if (pending.generation !== generation) return yield* next()
|
||||||
|
if (pending._tag === "End") return yield* Effect.fail(new RunnerEndedError())
|
||||||
|
if ((yield* Ref.get(state)).calls.get(pending.id)?.status === "pending") return pending.call
|
||||||
|
return yield* next()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
llm: {
|
||||||
|
layer: Layer.succeed(
|
||||||
|
LLMClient.Service,
|
||||||
|
LLMClient.Service.of({
|
||||||
|
prepare: () => Effect.die("RunnerLLM.prepare is not implemented"),
|
||||||
|
stream: stream as LLMClientShape["stream"],
|
||||||
|
generate: () => Effect.die("RunnerLLM.generate is not implemented"),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
next,
|
||||||
|
requests: Ref.get(state).pipe(Effect.map((state) => state.requests)),
|
||||||
|
},
|
||||||
|
begin: Ref.modify<State, Effect.Effect<number, RunnerScenarioActiveError>>(state, (current) =>
|
||||||
|
current.active !== undefined
|
||||||
|
? [Effect.fail(new RunnerScenarioActiveError()), current]
|
||||||
|
: [
|
||||||
|
Effect.succeed(current.nextGeneration),
|
||||||
|
{
|
||||||
|
...current,
|
||||||
|
accepting: true,
|
||||||
|
active: current.nextGeneration,
|
||||||
|
nextGeneration: current.nextGeneration + 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
).pipe(Effect.flatten),
|
||||||
|
finishInteraction: (generation) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const unanswered = yield* Ref.modify(state, (current) => [
|
||||||
|
[...current.calls].find(([, call]) => call.generation === generation && call.status === "pending")?.[0],
|
||||||
|
current.active === generation ? { ...current, accepting: false } : current,
|
||||||
|
])
|
||||||
|
if (unanswered !== undefined) return yield* Effect.fail(new RunnerLLMRequestError(unanswered))
|
||||||
|
}),
|
||||||
|
end: (generation) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Ref.update(state, (current) => ({
|
||||||
|
...current,
|
||||||
|
accepting: current.active === generation ? false : current.accepting,
|
||||||
|
active: current.active === generation ? undefined : current.active,
|
||||||
|
calls: new Map(
|
||||||
|
[...current.calls].map(([id, call]) => [
|
||||||
|
id,
|
||||||
|
call.generation === generation && call.status === "pending"
|
||||||
|
? { ...call, status: "closed" as const }
|
||||||
|
: call,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
yield* Queue.offer(calls, { _tag: "End", generation })
|
||||||
|
}),
|
||||||
|
} satisfies RunnerLLMInternal
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface RunnerScenario<RunError, RunRequirements> {
|
||||||
|
readonly llm: RunnerLLM
|
||||||
|
readonly run: <A, E, R>(
|
||||||
|
interaction: () => Effect.gen.Return<A, E, R>,
|
||||||
|
) => Effect.Effect<A, RunError | E | Error, RunRequirements | R>
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RunnerScenario {
|
||||||
|
export const make = <RunResult, RunError, RunRequirements>(
|
||||||
|
start: (llm: RunnerLLM) => Effect.Effect<RunResult, RunError, RunRequirements>,
|
||||||
|
): Effect.Effect<RunnerScenario<RunError, RunRequirements>> =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const internal = yield* makeRunnerLLM
|
||||||
|
const run = <A, E, R>(
|
||||||
|
interaction: () => Effect.gen.Return<A, E, R>,
|
||||||
|
): Effect.Effect<A, RunError | E | Error, RunRequirements | R> =>
|
||||||
|
Effect.uninterruptibleMask((restore) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const generation = yield* internal.begin
|
||||||
|
const runner = yield* start(internal.llm).pipe(Effect.ensuring(internal.end(generation)), Effect.forkChild)
|
||||||
|
return yield* restore(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const interactionExit = yield* Effect.gen(interaction).pipe(Effect.exit)
|
||||||
|
if (Exit.isSuccess(interactionExit)) {
|
||||||
|
yield* internal.finishInteraction(generation)
|
||||||
|
yield* Fiber.join(runner)
|
||||||
|
return interactionExit.value
|
||||||
|
}
|
||||||
|
const error = Cause.findErrorOption(interactionExit.cause)
|
||||||
|
if (Option.isSome(error) && error.value instanceof RunnerEndedError) yield* Fiber.join(runner)
|
||||||
|
return yield* Effect.failCause(interactionExit.cause)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.ensuring(Fiber.interrupt(runner).pipe(Effect.andThen(internal.end(generation)))))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
llm: internal.llm,
|
||||||
|
run,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { LLM, LLMError, LLMEvent, Model, TransportReason } from "@opencode-ai/llm"
|
||||||
|
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||||
|
import { Deferred, Effect, Fiber, Stream } from "effect"
|
||||||
|
import { it } from "./lib/effect"
|
||||||
|
import { RunnerScenario } from "./lib/runner-scenario"
|
||||||
|
|
||||||
|
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
|
||||||
|
const request = (text: string) => LLM.request({ model, prompt: text })
|
||||||
|
const textOf = (events: ReadonlyArray<LLMEvent>) => events.filter(LLMEvent.is.textDelta).map((event) => event.text)
|
||||||
|
|
||||||
|
describe("RunnerScenario", () => {
|
||||||
|
it.effect("drives provider requests in execution order", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const output: LLMEvent[][] = []
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
output.push(Array.from(yield* LLM.stream(request("Echo this")).pipe(Stream.runCollect)))
|
||||||
|
output.push(Array.from(yield* LLM.stream(request("Continue")).pipe(Stream.runCollect)))
|
||||||
|
}).pipe(Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* scenario.run(function* () {
|
||||||
|
const first = yield* scenario.llm.next()
|
||||||
|
expect(first.request.messages[0]?.content).toEqual([{ type: "text", text: "Echo this" }])
|
||||||
|
yield* first.respond.toolCall("echo", { text: "hello" })
|
||||||
|
|
||||||
|
const second = yield* scenario.llm.next()
|
||||||
|
expect(second.request.messages[0]?.content).toEqual([{ type: "text", text: "Continue" }])
|
||||||
|
yield* second.respond.text("Done")
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(output[0]?.find(LLMEvent.is.toolCall)).toMatchObject({ name: "echo", input: { text: "hello" } })
|
||||||
|
expect(textOf(output[1] ?? [])).toEqual(["Done"])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("pauses the runner until the pending request receives a response", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const completed = yield* Deferred.make<void>()
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
LLM.stream(request("Wait")).pipe(
|
||||||
|
Stream.runDrain,
|
||||||
|
Effect.andThen(Deferred.succeed(completed, undefined)),
|
||||||
|
Effect.provide(llm.layer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* scenario.run(function* () {
|
||||||
|
const call = yield* scenario.llm.next()
|
||||||
|
expect(Deferred.isDoneUnsafe(completed)).toBe(false)
|
||||||
|
yield* call.respond.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Deferred.isDoneUnsafe(completed)).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("accepts raw failing streams for lifecycle edge cases", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const failure = new LLMError({
|
||||||
|
module: "test",
|
||||||
|
method: "stream",
|
||||||
|
reason: new TransportReason({ message: "unavailable" }),
|
||||||
|
})
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
LLM.stream(request("Fail")).pipe(Stream.runDrain, Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* scenario
|
||||||
|
.run(function* () {
|
||||||
|
const call = yield* scenario.llm.next()
|
||||||
|
yield* call.respond.stream(
|
||||||
|
Stream.concat(Stream.make(LLMEvent.stepStart({ index: 0 })), Stream.fail(failure)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip),
|
||||||
|
).toBe(failure)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("fails when an interaction leaves a request unanswered", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
LLM.stream(request("Wait")).pipe(Stream.runDrain, Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const failure = yield* scenario
|
||||||
|
.run(function* () {
|
||||||
|
yield* scenario.llm.next()
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({ message: "Runner LLM request #1 was not handled by the interaction" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("fails when the runner starts another request before the interaction finishes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* LLM.stream(request("First")).pipe(Stream.runDrain)
|
||||||
|
yield* LLM.stream(request("Unexpected")).pipe(Stream.runDrain)
|
||||||
|
}).pipe(Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const failure = yield* scenario
|
||||||
|
.run(function* () {
|
||||||
|
const call = yield* scenario.llm.next()
|
||||||
|
yield* call.respond.stop()
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip)
|
||||||
|
|
||||||
|
expect(failure).toMatchObject({ message: "Runner LLM request #2 was not handled by the interaction" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects a second response to the same request", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
LLM.stream(request("Once")).pipe(Stream.runDrain, Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
let duplicate: Error | undefined
|
||||||
|
|
||||||
|
yield* scenario.run(function* () {
|
||||||
|
const call = yield* scenario.llm.next()
|
||||||
|
yield* call.respond.stop()
|
||||||
|
duplicate = yield* call.respond.stop().pipe(Effect.flip, Effect.orDie)
|
||||||
|
})
|
||||||
|
expect(duplicate?.message).toBe("Runner LLM request #1 is no longer awaiting a response")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("does not wait for the runner after a duplicate response", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
LLM.stream(request("Once")).pipe(Stream.runDrain, Effect.andThen(Effect.never), Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* scenario
|
||||||
|
.run(function* () {
|
||||||
|
const call = yield* scenario.llm.next()
|
||||||
|
yield* call.respond.stop()
|
||||||
|
yield* call.respond.stop()
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip),
|
||||||
|
).toMatchObject({ message: "Runner LLM request #1 is no longer awaiting a response" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("surfaces a runner failure while waiting for its next request", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const failure = new Error("runner failed")
|
||||||
|
const scenario = yield* RunnerScenario.make(() => Effect.fail<Error>(failure))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* scenario
|
||||||
|
.run(function* () {
|
||||||
|
yield* scenario.llm.next()
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip),
|
||||||
|
).toBe(failure)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves a runner failure when its pending request closes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fail = yield* Deferred.make<void>()
|
||||||
|
const closed = yield* Deferred.make<void>()
|
||||||
|
const failure = new Error("runner failed")
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
Effect.raceFirst(
|
||||||
|
LLM.stream(request("Fail pending")).pipe(
|
||||||
|
Stream.runDrain,
|
||||||
|
Effect.ensuring(Deferred.succeed(closed, undefined)),
|
||||||
|
),
|
||||||
|
Deferred.await(fail).pipe(Effect.andThen(Effect.fail(failure))),
|
||||||
|
).pipe(Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
yield* scenario
|
||||||
|
.run(function* () {
|
||||||
|
const call = yield* scenario.llm.next()
|
||||||
|
yield* Deferred.succeed(fail, undefined)
|
||||||
|
yield* Deferred.await(closed)
|
||||||
|
expect((yield* call.respond.stop().pipe(Effect.flip)).message).toBe(
|
||||||
|
"Runner LLM request #1 is no longer awaiting a response",
|
||||||
|
)
|
||||||
|
yield* scenario.llm.next()
|
||||||
|
})
|
||||||
|
.pipe(Effect.flip),
|
||||||
|
).toBe(failure)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects a stale response after the runner closes a request", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const release = yield* Deferred.make<void>()
|
||||||
|
const closed = yield* Deferred.make<void>()
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
Effect.raceFirst(
|
||||||
|
LLM.stream(request("Close pending")).pipe(
|
||||||
|
Stream.runDrain,
|
||||||
|
Effect.ensuring(Deferred.succeed(closed, undefined)),
|
||||||
|
),
|
||||||
|
Deferred.await(release),
|
||||||
|
).pipe(Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* scenario.run(function* () {
|
||||||
|
const call = yield* scenario.llm.next()
|
||||||
|
yield* Deferred.succeed(release, undefined)
|
||||||
|
yield* Deferred.await(closed)
|
||||||
|
expect((yield* call.respond.stop().pipe(Effect.flip)).message).toBe(
|
||||||
|
"Runner LLM request #1 is no longer awaiting a response",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("interrupts a runner that hangs after its final response", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const interrupted = yield* Deferred.make<void>()
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
LLM.stream(request("Hang")).pipe(
|
||||||
|
Stream.runDrain,
|
||||||
|
Effect.andThen(Deferred.succeed(started, undefined)),
|
||||||
|
Effect.andThen(Effect.never),
|
||||||
|
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||||
|
Effect.provide(llm.layer),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const run = yield* scenario
|
||||||
|
.run(function* () {
|
||||||
|
const call = yield* scenario.llm.next()
|
||||||
|
yield* call.respond.stop()
|
||||||
|
})
|
||||||
|
.pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
|
||||||
|
yield* Fiber.interrupt(run)
|
||||||
|
|
||||||
|
expect(Deferred.isDoneUnsafe(interrupted)).toBe(true)
|
||||||
|
expect(run.pollUnsafe()).toBeDefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("runs one scenario sequentially while retaining request history", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
LLM.stream(request("Once")).pipe(Stream.runDrain, Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
yield* scenario.run(function* () {
|
||||||
|
yield* (yield* scenario.llm.next()).respond.stop()
|
||||||
|
})
|
||||||
|
yield* scenario.run(function* () {
|
||||||
|
yield* (yield* scenario.llm.next()).respond.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(yield* scenario.llm.requests).toHaveLength(2)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects concurrent runs of one scenario", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const scenario = yield* RunnerScenario.make((llm) =>
|
||||||
|
LLM.stream(request("Once")).pipe(Stream.runDrain, Effect.provide(llm.layer)),
|
||||||
|
)
|
||||||
|
const active = yield* scenario
|
||||||
|
.run(function* () {
|
||||||
|
yield* scenario.llm.next()
|
||||||
|
yield* Effect.never
|
||||||
|
})
|
||||||
|
.pipe(Effect.forkChild)
|
||||||
|
yield* Effect.yieldNow
|
||||||
|
|
||||||
|
const failure = yield* scenario.run(function* (): Effect.gen.Return<void> {}).pipe(Effect.exit)
|
||||||
|
expect(failure._tag).toBe("Failure")
|
||||||
|
if (failure._tag !== "Failure") throw new Error("Expected concurrent run to fail")
|
||||||
|
const error = failure.cause.reasons.find((reason) => reason._tag === "Fail")
|
||||||
|
expect(error?._tag).toBe("Fail")
|
||||||
|
if (error?._tag !== "Fail") throw new Error("Expected a typed failure")
|
||||||
|
expect(error.error).toMatchObject({ message: "RunnerScenario.run is already active" })
|
||||||
|
yield* Fiber.interrupt(active)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
+1794
-1843
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user