mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-08 10:09:52 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41c0ed4fb7 |
@@ -241,6 +241,8 @@ export interface ParserState {
|
|||||||
readonly reasoningEmitted: boolean
|
readonly reasoningEmitted: boolean
|
||||||
readonly latestToolIndex?: number
|
readonly latestToolIndex?: number
|
||||||
readonly nextToolIndex: number
|
readonly nextToolIndex: number
|
||||||
|
readonly outputStarted: boolean
|
||||||
|
readonly requireFinishReason: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -707,9 +709,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
Boolean(delta?.content) ||
|
Boolean(delta?.content) ||
|
||||||
reasoning !== undefined ||
|
reasoning !== undefined ||
|
||||||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
||||||
toolDeltas.some(
|
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
|
||||||
(tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments),
|
|
||||||
)
|
|
||||||
if (state.finishReason !== undefined) {
|
if (state.finishReason !== undefined) {
|
||||||
if (hasLateContent)
|
if (hasLateContent)
|
||||||
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
|
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
|
||||||
@@ -749,8 +749,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
|
const fallback = toolDeltas.length > 1 ? position : (latestToolIndex ?? position)
|
||||||
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
|
const fallbackTool = tools[fallback] ?? pendingTools[fallback]
|
||||||
const index =
|
const index =
|
||||||
tool.index ?? matched ??
|
tool.index ?? matched ?? (tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
||||||
(tool.id && fallbackTool?.id && fallbackTool.id !== tool.id ? nextToolIndex : fallback)
|
|
||||||
const current = tools[index]
|
const current = tools[index]
|
||||||
const pending = pendingTools[index]
|
const pending = pendingTools[index]
|
||||||
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||||
@@ -806,6 +805,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
|||||||
reasoningEmitted,
|
reasoningEmitted,
|
||||||
latestToolIndex,
|
latestToolIndex,
|
||||||
nextToolIndex,
|
nextToolIndex,
|
||||||
|
outputStarted: state.outputStarted || hasLateContent,
|
||||||
|
requireFinishReason: state.requireFinishReason,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
] as const
|
] as const
|
||||||
@@ -836,6 +837,23 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
|||||||
return events
|
return events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onHalt = (state: ParserState) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (state.finishReason !== undefined || state.requireFinishReason) return finishEvents(state)
|
||||||
|
if (!state.outputStarted) return []
|
||||||
|
if (Object.keys(state.pendingTools).length > 0)
|
||||||
|
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
|
||||||
|
// Chat has no per-call stop event, so an accepted EOF must finalize every
|
||||||
|
// accumulated tool input before publishing the synthetic terminal reason.
|
||||||
|
const finished = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||||
|
return finishEvents({
|
||||||
|
...state,
|
||||||
|
tools: finished.tools,
|
||||||
|
toolCallEvents: finished.events,
|
||||||
|
finishReason: { normalized: "unknown" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Protocol And OpenAI Route
|
// Protocol And OpenAI Route
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -863,9 +881,11 @@ export const protocol = Protocol.make({
|
|||||||
reasoningDetailsObserved: false,
|
reasoningDetailsObserved: false,
|
||||||
reasoningEmitted: false,
|
reasoningEmitted: false,
|
||||||
nextToolIndex: 0,
|
nextToolIndex: 0,
|
||||||
|
outputStarted: false,
|
||||||
|
requireFinishReason: request.model.compatibility?.requireFinishReason ?? true,
|
||||||
}),
|
}),
|
||||||
step,
|
step,
|
||||||
onHalt: finishEvents,
|
onHalt,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Framing } from "./framing"
|
|||||||
import { HttpTransport } from "./transport"
|
import { HttpTransport } from "./transport"
|
||||||
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
|
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
|
||||||
import { WebSocketExecutor } from "./transport"
|
import { WebSocketExecutor } from "./transport"
|
||||||
import type { Protocol } from "./protocol"
|
import type { Protocol, ProtocolStream } from "./protocol"
|
||||||
import { applyCachePolicy } from "../cache-policy"
|
import { applyCachePolicy } from "../cache-policy"
|
||||||
import * as ProviderShared from "../protocols/shared"
|
import * as ProviderShared from "../protocols/shared"
|
||||||
import type { ProtocolID, ProviderOptions } from "../schema"
|
import type { ProtocolID, ProviderOptions } from "../schema"
|
||||||
@@ -243,28 +243,56 @@ const incompleteStreamError = (route: string) =>
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
|
const ensureTerminalEvent = (route: string, required: boolean) => (events: Stream.Stream<LLMEvent, AIError>) =>
|
||||||
Stream.suspend(() => {
|
Stream.suspend(() => {
|
||||||
let terminal = false
|
let terminal = false
|
||||||
|
let output = false
|
||||||
|
const fallback = Stream.suspend(() => {
|
||||||
|
if (terminal) return Stream.empty
|
||||||
|
if (required || !output) return Stream.fail(incompleteStreamError(route))
|
||||||
|
// The compatibility override trusts a clean stream end, but it cannot
|
||||||
|
// recover the provider's omitted reason.
|
||||||
|
const reason = { normalized: "unknown" as const }
|
||||||
|
return Stream.make(LLMEvent.stepFinish({ index: 0, reason }), LLMEvent.finish({ reason }))
|
||||||
|
})
|
||||||
return events.pipe(
|
return events.pipe(
|
||||||
Stream.mapEffect((event) => {
|
Stream.mapEffect((event) => {
|
||||||
if (terminal)
|
if (terminal)
|
||||||
return Effect.fail(
|
return Effect.fail(
|
||||||
ProviderShared.eventError(route, `Provider emitted ${event.type} after the terminal event`),
|
ProviderShared.eventError(route, `Provider emitted ${event.type} after the terminal event`),
|
||||||
)
|
)
|
||||||
|
output = true
|
||||||
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
||||||
return Effect.succeed(event)
|
return Effect.succeed(event)
|
||||||
}),
|
}),
|
||||||
Stream.onEnd(
|
Stream.concat(fallback),
|
||||||
Effect.suspend(() =>
|
|
||||||
terminal
|
|
||||||
? Effect.void
|
|
||||||
: Effect.fail(incompleteStreamError(route)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type ProtocolEvent<Event> = { readonly type: "event"; readonly event: Event } | { readonly type: "halt" }
|
||||||
|
|
||||||
|
const parseProtocolEvents = <Event, State>(
|
||||||
|
events: Stream.Stream<Event, AIError>,
|
||||||
|
request: LLMRequest,
|
||||||
|
protocol: { readonly stream: ProtocolStream<unknown, Event, State> },
|
||||||
|
) =>
|
||||||
|
events.pipe(
|
||||||
|
Stream.map((event): ProtocolEvent<Event> => ({ type: "event", event })),
|
||||||
|
// A normal halt becomes an in-band parser input so finalization may fail.
|
||||||
|
Stream.concat(Stream.succeed({ type: "halt" } as const)),
|
||||||
|
Stream.mapAccumEffect(
|
||||||
|
() => protocol.stream.initial(request),
|
||||||
|
(state, event) => {
|
||||||
|
if (event.type === "event") return protocol.stream.step(state, event.event)
|
||||||
|
if (!protocol.stream.onHalt) return Effect.succeed([state, []] as const)
|
||||||
|
const events = protocol.stream.onHalt(state)
|
||||||
|
return Effect.isEffect(events)
|
||||||
|
? events.pipe(Effect.map((events) => [state, events] as const))
|
||||||
|
: Effect.succeed([state, events] as const)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||||
): Route<Body, Prepared> {
|
): Route<Body, Prepared> {
|
||||||
@@ -329,14 +357,9 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
Stream.mapEffect(decodeEvent(route)),
|
Stream.mapEffect(decodeEvent(route)),
|
||||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||||
)
|
)
|
||||||
return events.pipe(
|
return parseProtocolEvents(events, request, protocol).pipe(
|
||||||
Stream.mapAccumEffect(
|
|
||||||
() => protocol.stream.initial(request),
|
|
||||||
protocol.stream.step,
|
|
||||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
|
||||||
),
|
|
||||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||||
requireTerminalEvent(route),
|
ensureTerminalEvent(route, request.model.compatibility?.requireFinishReason ?? true),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
} satisfies Route<Body, Prepared>
|
} satisfies Route<Body, Prepared>
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export interface ProtocolStream<Frame, Event, State> {
|
|||||||
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
|
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
|
||||||
/** Optional request-completion signal for transports that do not end naturally. */
|
/** Optional request-completion signal for transports that do not end naturally. */
|
||||||
readonly terminal?: (event: Event) => boolean
|
readonly terminal?: (event: Event) => boolean
|
||||||
/** Optional flush emitted when the framed stream ends. */
|
/** Optional flush emitted when the framed stream ends successfully. */
|
||||||
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent>
|
readonly onHalt?: (state: State) => ReadonlyArray<LLMEvent> | Effect.Effect<ReadonlyArray<LLMEvent>, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -138,6 +138,29 @@ describe("llm route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
unterminated.effect("synthesizes an unknown finish when a terminal event is not required", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* (yield* LLMClient.Service).generate(
|
||||||
|
LLMRequest.update(request, {
|
||||||
|
model: updateModel(request.model, { compatibility: { requireFinishReason: false } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("partial")
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "unknown" })
|
||||||
|
expect(response.events.slice(-2)).toEqual([
|
||||||
|
{
|
||||||
|
type: "step-finish",
|
||||||
|
index: 0,
|
||||||
|
reason: { normalized: "unknown" },
|
||||||
|
usage: undefined,
|
||||||
|
providerMetadata: undefined,
|
||||||
|
},
|
||||||
|
{ type: "finish", reason: { normalized: "unknown" }, usage: undefined },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("selects routes by model route value", () =>
|
it.effect("selects routes by model route value", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const request = LLM.request({
|
|||||||
generation: { maxTokens: 20, temperature: 0 },
|
generation: { maxTokens: 20, temperature: 0 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const optionalFinishRequest = LLMRequest.update(request, {
|
||||||
|
model: LanguageModel.update(model, { compatibility: { requireFinishReason: false } }),
|
||||||
|
})
|
||||||
|
|
||||||
describe("OpenAI Chat route", () => {
|
describe("OpenAI Chat route", () => {
|
||||||
it.effect("prepares OpenAI Chat payload", () =>
|
it.effect("prepares OpenAI Chat payload", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -596,6 +600,20 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("accepts text and usage without a finish reason when configured", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
deltaChunk({ role: "assistant", content: "Hello" }),
|
||||||
|
usageChunk({ prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }),
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(optionalFinishRequest).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.text).toBe("Hello")
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "unknown" })
|
||||||
|
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 1, totalTokens: 6 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
||||||
@@ -1145,21 +1163,89 @@ describe("OpenAI Chat route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("fails on malformed stream events", () =>
|
it.effect("finalizes a streamed tool call without a finish reason when configured", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
deltaChunk({
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
|
||||||
|
}),
|
||||||
|
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(optionalFinishRequest, {
|
||||||
|
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "unknown" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("settles malformed tool input without a finish reason when configured", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
|
deltaChunk({
|
||||||
|
role: "assistant",
|
||||||
|
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(optionalFinishRequest, {
|
||||||
|
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
|
||||||
|
expect(response.events.filter(LLMEvent.is.toolInputError)).toMatchObject([
|
||||||
|
{ id: "call_1", name: "lookup", raw: '{"query"' },
|
||||||
|
])
|
||||||
|
expect(response.toolCalls).toEqual([])
|
||||||
|
expect(response.finishReason).toEqual({ normalized: "unknown" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects incomplete tool identity without a finish reason when configured", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }] }))
|
||||||
|
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(
|
||||||
|
Effect.provide(fixedResponse(body)),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("rejects an empty stream when a finish reason is not required", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(
|
||||||
|
Effect.provide(fixedResponse(sseEvents())),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("fails on malformed stream events when a finish reason is not required", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(deltaChunk({ content: 123 }))
|
const body = sseEvents(deltaChunk({ content: 123 }))
|
||||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(
|
||||||
|
Effect.provide(fixedResponse(body)),
|
||||||
|
Effect.flip,
|
||||||
|
)
|
||||||
|
|
||||||
expect(error.message).toContain("Invalid openai/openai-chat stream event")
|
expect(error.message).toContain("Invalid openai/openai-chat stream event")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("surfaces transport errors that occur mid-stream", () =>
|
it.effect("surfaces transport errors when a finish reason is not required", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const layer = truncatedStream([
|
const layer = truncatedStream([
|
||||||
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
|
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
|
||||||
])
|
])
|
||||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
const error = yield* LLMClient.generate(optionalFinishRequest).pipe(Effect.provide(layer), Effect.flip)
|
||||||
|
|
||||||
expect(error.message).toContain("Failed to read openai/openai-chat stream")
|
expect(error.message).toContain("Failed to read openai/openai-chat stream")
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2440,6 +2440,30 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("continues after an unknown finish containing a local tool call", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const session = yield* setup
|
||||||
|
yield* admit(session, "Echo this")
|
||||||
|
yield* TestLLM.push(
|
||||||
|
TestLLM.complete(
|
||||||
|
{ reason: { normalized: "unknown" } },
|
||||||
|
LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }),
|
||||||
|
),
|
||||||
|
TestLLM.text("Done", "text-final"),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* session.resume(sessionID)
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(2)
|
||||||
|
expect(executions).toEqual(["hello"])
|
||||||
|
expect(yield* session.context(sessionID)).toMatchObject([
|
||||||
|
{ type: "user", text: "Echo this" },
|
||||||
|
{ type: "assistant", finish: "unknown", content: [{ type: "tool", state: { status: "completed" } }] },
|
||||||
|
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Done" }] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("reloads a model switch before a tool-driven continuation step", () =>
|
it.effect("reloads a model switch before a tool-driven continuation step", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
|
|||||||
Reference in New Issue
Block a user