mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6639cb5ad | |||
| b300116d0a | |||
| d10b652637 | |||
| b03ca0d4e2 | |||
| 25aaea3d31 | |||
| cae7a139bc | |||
| 5ea62ab05f | |||
| faadc05c88 |
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
/**
|
||||
@@ -214,7 +214,8 @@ const FakeEcho = {
|
||||
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// tool-loop behavior without spending tokens on every example.
|
||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
// yield* generateOnce
|
||||
@@ -222,6 +223,6 @@ const program = Effect.gen(function* () {
|
||||
// yield* generateStructuredObject
|
||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||
yield* streamWithTools
|
||||
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
|
||||
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
|
||||
|
||||
Effect.runPromise(program)
|
||||
|
||||
@@ -211,43 +211,11 @@ export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
// event-level `error` envelope, so accept all three shapes here.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("error"),
|
||||
status: Schema.optional(Schema.Number),
|
||||
status_code: Schema.optional(Schema.Number),
|
||||
code: optionalNull(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
|
||||
|
||||
const decodeKnownErrorEvent = (event: Event) =>
|
||||
decodeWebSocketErrorEvent({
|
||||
...event,
|
||||
status: typeof event.status === "number" ? event.status : undefined,
|
||||
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
|
||||
headers: ProviderShared.isRecord(event.headers)
|
||||
? Object.fromEntries(
|
||||
Object.entries(event.headers).filter(
|
||||
(entry): entry is [string, string | number | boolean] =>
|
||||
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
@@ -272,9 +240,6 @@ export const Event = Schema.StructWithRest(
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
status: Schema.optional(Schema.Unknown),
|
||||
status_code: Schema.optional(Schema.Unknown),
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
@@ -667,9 +632,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||
// `finish` event; `response.failed` and `error` are hard failures. All four end
|
||||
// the stream, so keep this set aligned with `step` and the protocol's terminal predicate.
|
||||
const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
|
||||
// `finish` event; `response.failed` is a hard failure. All three end the stream,
|
||||
// so keep this set aligned with `step` and the protocol's terminal predicate.
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
@@ -1004,16 +969,10 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
|
||||
const providerError = (state: ParserState, event: Event, fallback: string) => {
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
? event.status
|
||||
: typeof event.status_code === "number"
|
||||
? event.status_code
|
||||
: undefined
|
||||
return new AIError({
|
||||
module: state.id,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message, code, status }),
|
||||
reason: classifyProviderFailure({ message, code }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1056,11 +1015,7 @@ export const step = (state: ParserState, event: Event) => {
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||
if (event.type === "error")
|
||||
return decodeKnownErrorEvent(event).pipe(
|
||||
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
|
||||
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
|
||||
)
|
||||
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ const SERVER_CODES = new Set([
|
||||
"overloaded_error",
|
||||
"server_error",
|
||||
"server_is_overloaded",
|
||||
"slow_down",
|
||||
"serviceunavailableexception",
|
||||
])
|
||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import * as Option from "effect/Option"
|
||||
import { Auth } from "./auth"
|
||||
import { Endpoint, type EndpointPatch } from "./endpoint"
|
||||
import { RequestExecutor } from "./executor"
|
||||
import { Framing } from "./framing"
|
||||
import { HttpTransport } from "./transport"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
|
||||
import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
import * as ProviderShared from "../protocols/shared"
|
||||
@@ -56,7 +58,6 @@ export interface Route<Body, Prepared = unknown> {
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
runtime: TransportRuntime,
|
||||
options?: StreamOptions,
|
||||
) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
|
||||
@@ -156,7 +157,6 @@ export interface Interface {
|
||||
|
||||
export interface StreamOptions {
|
||||
readonly http?: HttpMiddleware
|
||||
readonly webSocket?: WebSocketChannelExecutor
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
@@ -255,7 +255,13 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
|
||||
if (LLMEvent.is.finish(event) || LLMEvent.is.providerError(event)) terminal = true
|
||||
return Effect.succeed(event)
|
||||
}),
|
||||
Stream.onEnd(Effect.suspend(() => (terminal ? Effect.void : Effect.fail(incompleteStreamError(route))))),
|
||||
Stream.onEnd(
|
||||
Effect.suspend(() =>
|
||||
terminal
|
||||
? Effect.void
|
||||
: Effect.fail(incompleteStreamError(route)),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -315,27 +321,22 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
headers: routeInput.headers,
|
||||
middleware: options?.http,
|
||||
}),
|
||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime, options?: StreamOptions) => {
|
||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||
const route = `${request.model.provider}/${request.model.route.id}`
|
||||
return Stream.unwrap(
|
||||
routeInput.transport.execute(prepared, request, runtime, options).pipe(
|
||||
Effect.map((execution) => {
|
||||
const events = execution.frames.pipe(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
)
|
||||
const stream = events.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))),
|
||||
requireTerminalEvent(route),
|
||||
)
|
||||
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
|
||||
}),
|
||||
const events = routeInput.transport
|
||||
.frames(prepared, request, runtime)
|
||||
.pipe(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
)
|
||||
return events.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))),
|
||||
requireTerminalEvent(route),
|
||||
)
|
||||
},
|
||||
} satisfies Route<Body, Prepared>
|
||||
@@ -418,7 +419,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request, options)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime, options)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -456,6 +457,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
Effect.gen(function* () {
|
||||
const stream = streamRequestWith({
|
||||
http: yield* RequestExecutor.Service,
|
||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
||||
})
|
||||
return Service.of({ stream, generate: generateWith(stream) })
|
||||
}),
|
||||
|
||||
@@ -16,28 +16,11 @@ export { AuthOptions } from "./auth-options"
|
||||
export { Endpoint } from "./endpoint"
|
||||
export { Framing } from "./framing"
|
||||
export { Protocol } from "./protocol"
|
||||
export { HttpTransport, WebSocketTransport } from "./transport"
|
||||
export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport"
|
||||
export * as Transport from "./transport"
|
||||
export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth"
|
||||
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options"
|
||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||
export type { Definition as FramingDef } from "./framing"
|
||||
export type { Protocol as ProtocolDef } from "./protocol"
|
||||
export type {
|
||||
ChannelCheckpoint,
|
||||
ChannelCreate,
|
||||
ChannelObservation,
|
||||
HttpHandler,
|
||||
HttpMiddleware,
|
||||
Transport as TransportDef,
|
||||
TransportExecuteOptions,
|
||||
TransportExecution,
|
||||
TransportRuntime,
|
||||
WebSocketConnection,
|
||||
WebSocketChannelDriver,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketChannelExecution,
|
||||
WebSocketChannelExecutor,
|
||||
WebSocketConnector,
|
||||
WebSocketRequest,
|
||||
} from "./transport"
|
||||
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||
|
||||
@@ -86,28 +86,26 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
execute: (prepared, request, runtime) =>
|
||||
Effect.succeed({
|
||||
frames: Stream.unwrap(
|
||||
runtime.http
|
||||
.execute(prepared.request, prepared.middleware)
|
||||
.pipe(
|
||||
Effect.map((response) =>
|
||||
prepared.framing.frame(
|
||||
response.stream.pipe(
|
||||
Stream.mapError((error) =>
|
||||
ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||
ProviderShared.errorText(error),
|
||||
),
|
||||
frames: (prepared, request, runtime) =>
|
||||
Stream.unwrap(
|
||||
runtime.http
|
||||
.execute(prepared.request, prepared.middleware)
|
||||
.pipe(
|
||||
Effect.map((response) =>
|
||||
prepared.framing.frame(
|
||||
response.stream.pipe(
|
||||
Stream.mapError((error) =>
|
||||
ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||
ProviderShared.errorText(error),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
export const sseJson = {
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
import type { Effect, Scope, Stream } from "effect"
|
||||
import type { Effect, Stream } from "effect"
|
||||
import { Endpoint } from "../endpoint"
|
||||
import { Auth } from "../auth"
|
||||
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
|
||||
import type { WebSocketChannelExecutor } from "./websocket-channel"
|
||||
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
||||
import type { AIError, LLMRequest } from "../../schema"
|
||||
|
||||
export interface TransportRuntime {
|
||||
readonly http: RequestExecutorInterface
|
||||
}
|
||||
|
||||
export interface TransportExecution<Frame> {
|
||||
readonly frames: Stream.Stream<Frame, AIError>
|
||||
/** Optional successful-consumption acknowledgement. HTTP leaves this absent. */
|
||||
readonly complete?: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface TransportExecuteOptions {
|
||||
readonly webSocket?: WebSocketChannelExecutor
|
||||
readonly webSocket?: WebSocketExecutorInterface
|
||||
}
|
||||
|
||||
export interface Transport<Body, Prepared, Frame> {
|
||||
readonly id: string
|
||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
|
||||
readonly execute: (
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
runtime: TransportRuntime,
|
||||
options?: TransportExecuteOptions,
|
||||
) => Effect.Effect<TransportExecution<Frame>, AIError, Scope.Scope>
|
||||
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
|
||||
}
|
||||
|
||||
export interface TransportPrepareInput<Body> {
|
||||
@@ -42,14 +28,4 @@ export interface TransportPrepareInput<Body> {
|
||||
|
||||
export * as HttpTransport from "./http"
|
||||
export type { HttpHandler, HttpMiddleware } from "../executor"
|
||||
export type {
|
||||
ChannelCheckpoint,
|
||||
ChannelCreate,
|
||||
ChannelObservation,
|
||||
WebSocketChannelDriver,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketChannelExecution,
|
||||
WebSocketChannelExecutor,
|
||||
} from "./websocket-channel"
|
||||
export type { WebSocketConnection, WebSocketConnector, WebSocketRequest } from "./websocket"
|
||||
export { WebSocketTransport } from "./websocket"
|
||||
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Effect, Scope, Stream } from "effect"
|
||||
import type { Headers } from "effect/unstable/http"
|
||||
import type { AIError } from "../../schema"
|
||||
|
||||
export interface WebSocketChannelExecutor {
|
||||
readonly execute: (
|
||||
exchange: WebSocketChannelExchange,
|
||||
) => Effect.Effect<WebSocketChannelExecution, AIError, Scope.Scope>
|
||||
}
|
||||
|
||||
export interface WebSocketChannelExecution {
|
||||
readonly frames: Stream.Stream<string, AIError>
|
||||
/** Commits staged state after the decoded Route stream ends successfully. */
|
||||
readonly complete: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface WebSocketChannelExchange {
|
||||
readonly id: string
|
||||
readonly connect: {
|
||||
readonly url: string
|
||||
readonly headers: Headers.Headers
|
||||
}
|
||||
readonly fallback: () => Stream.Stream<string, AIError>
|
||||
readonly driver: WebSocketChannelDriver
|
||||
}
|
||||
|
||||
export interface WebSocketChannelDriver {
|
||||
readonly create: (checkpoint: ChannelCheckpoint | undefined) => Effect.Effect<ChannelCreate, AIError>
|
||||
readonly observe: (create: ChannelCreate, frame: string) => Effect.Effect<ChannelObservation, AIError>
|
||||
}
|
||||
|
||||
export interface ChannelCreate {
|
||||
readonly message: string
|
||||
readonly mode: "full" | "incremental"
|
||||
}
|
||||
|
||||
export type ChannelObservation =
|
||||
| { readonly type: "frame"; readonly frame: string }
|
||||
| { readonly type: "completed"; readonly frame: string; readonly checkpoint?: ChannelCheckpoint }
|
||||
| { readonly type: "incomplete"; readonly frame: string }
|
||||
| { readonly type: "provider-failure"; readonly error: AIError }
|
||||
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "retry-full" }
|
||||
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "rotate-and-retry-full" }
|
||||
|
||||
export interface ChannelCheckpoint {
|
||||
readonly protocol: string
|
||||
readonly value: unknown
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import { Cause, Effect, Queue, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { AIError, TransportReason } from "../../schema"
|
||||
import * as HttpTransport from "./http"
|
||||
import type { Transport } from "./index"
|
||||
import type {
|
||||
ChannelObservation,
|
||||
WebSocketChannelDriver,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketChannelExecutor,
|
||||
} from "./websocket-channel"
|
||||
|
||||
export interface WebSocketRequest {
|
||||
readonly url: string
|
||||
@@ -22,57 +15,28 @@ export interface WebSocketConnection {
|
||||
readonly close: Effect.Effect<void, never>
|
||||
}
|
||||
|
||||
export interface WebSocketConnector {
|
||||
export interface Interface {
|
||||
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
||||
}
|
||||
|
||||
type WebSocketConstructorWithHeaders = (
|
||||
type WebSocketConstructorWithHeaders = new (
|
||||
url: string,
|
||||
options?: { readonly headers?: Headers.Headers },
|
||||
) => globalThis.WebSocket
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
|
||||
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: {
|
||||
readonly url?: string
|
||||
readonly kind?: string
|
||||
readonly phase?: TransportReason["phase"]
|
||||
readonly delivery?: TransportReason["delivery"]
|
||||
} = {},
|
||||
input: { readonly url?: string; readonly kind?: string } = {},
|
||||
) =>
|
||||
new AIError({
|
||||
module: "WebSocketConnector",
|
||||
module: "WebSocketExecutor",
|
||||
method,
|
||||
reason: new TransportReason({
|
||||
message,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
phase: input.phase,
|
||||
delivery: input.delivery,
|
||||
}),
|
||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
||||
})
|
||||
|
||||
const annotateTransportError = (
|
||||
error: AIError,
|
||||
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
|
||||
) =>
|
||||
error.reason._tag === "Transport"
|
||||
? new AIError({
|
||||
module: error.module,
|
||||
method: error.method,
|
||||
reason: new TransportReason({
|
||||
message: error.reason.message,
|
||||
kind: error.reason.kind,
|
||||
url: error.reason.url,
|
||||
http: error.reason.http,
|
||||
phase: input.phase,
|
||||
delivery: input.delivery,
|
||||
recovery: error.reason.recovery,
|
||||
}),
|
||||
})
|
||||
: error
|
||||
|
||||
const eventMessage = (event: Event) => {
|
||||
if ("message" in event && typeof event.message === "string") return event.message
|
||||
return event.type
|
||||
@@ -92,8 +56,6 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -117,12 +79,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
cleanup()
|
||||
resume(
|
||||
Effect.fail(
|
||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -133,8 +90,6 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -164,31 +119,21 @@ const webSocketUrl = (value: string) =>
|
||||
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||
url: value,
|
||||
kind: "websocket",
|
||||
phase: "prepare",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
})
|
||||
|
||||
export const open = (input: WebSocketRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const constructor = yield* Socket.WebSocketConstructor
|
||||
const ws = yield* Effect.try({
|
||||
try: () =>
|
||||
// Platform implementations may extend Effect's browser-compatible constructor with handshake options.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
|
||||
headers: input.headers,
|
||||
}),
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
})
|
||||
return yield* fromWebSocket(ws, input)
|
||||
})
|
||||
Effect.try({
|
||||
try: () =>
|
||||
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
}),
|
||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
||||
|
||||
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
|
||||
|
||||
export const fromWebSocket = (
|
||||
ws: globalThis.WebSocket,
|
||||
@@ -205,11 +150,7 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "Unsupported WebSocket message payload", {
|
||||
url: input.url,
|
||||
kind: "message",
|
||||
phase: "receive",
|
||||
}),
|
||||
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -217,23 +158,16 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||
url: input.url,
|
||||
kind: "message",
|
||||
phase: "receive",
|
||||
}),
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
const onClose = (event: CloseEvent) => {
|
||||
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket closed with code ${event.code}`, {
|
||||
url: input.url,
|
||||
kind: "close",
|
||||
phase: "close",
|
||||
}),
|
||||
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -255,8 +189,6 @@ export const fromWebSocket = (
|
||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
url: input.url,
|
||||
kind: "write",
|
||||
phase: "send",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
@@ -274,57 +206,6 @@ export const fromWebSocket = (
|
||||
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
|
||||
typeof message === "string" ? message : decoder.decode(message)
|
||||
|
||||
const observationFrame = (observation: ChannelObservation) => {
|
||||
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
|
||||
return Effect.succeed(observation.frame)
|
||||
return Effect.fail(observation.error)
|
||||
}
|
||||
|
||||
const observationTerminal = (observation: ChannelObservation) => observation.type !== "frame"
|
||||
|
||||
export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* Effect.acquireRelease(
|
||||
connector
|
||||
.open(exchange.connect)
|
||||
.pipe(Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" }))),
|
||||
(connection) => connection.close,
|
||||
)
|
||||
const create = yield* exchange.driver.create(undefined)
|
||||
yield* connection.sendText(create.message)
|
||||
const decoder = new TextDecoder()
|
||||
let observed = false
|
||||
return {
|
||||
frames: connection.messages.pipe(
|
||||
Stream.map((message) => {
|
||||
observed = true
|
||||
return messageText(message, decoder)
|
||||
}),
|
||||
Stream.mapError((error) =>
|
||||
annotateTransportError(error, {
|
||||
phase: error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||
delivery: observed ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.takeUntil(observationTerminal),
|
||||
Stream.mapEffect(observationFrame),
|
||||
),
|
||||
complete: Effect.void,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const direct: Effect.Effect<WebSocketChannelExecutor, never, Socket.WebSocketConstructor> = Effect.gen(
|
||||
function* () {
|
||||
const constructor = yield* Socket.WebSocketConstructor
|
||||
return makeDirect({
|
||||
open: (input) => open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export interface JsonPrepared {
|
||||
readonly url: string
|
||||
readonly headers: Headers.Headers
|
||||
@@ -356,37 +237,27 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
||||
}
|
||||
}),
|
||||
execute: (prepared, request, _runtime, options) => {
|
||||
const webSocket = options?.webSocket
|
||||
frames: (prepared, _request, runtime) => {
|
||||
const webSocket = runtime.webSocket
|
||||
if (!webSocket) {
|
||||
return Effect.fail(
|
||||
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
|
||||
return Stream.fail(
|
||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||
url: prepared.url,
|
||||
kind: "websocket",
|
||||
phase: "prepare",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
)
|
||||
}
|
||||
const driver: WebSocketChannelDriver = {
|
||||
create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
|
||||
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
|
||||
}
|
||||
const exchange: WebSocketChannelExchange = {
|
||||
id: request.id ?? "request",
|
||||
connect: { url: prepared.url, headers: prepared.headers },
|
||||
fallback: () =>
|
||||
Stream.fail(
|
||||
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
|
||||
url: prepared.url,
|
||||
kind: "websocket",
|
||||
phase: "fallback",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
),
|
||||
driver,
|
||||
}
|
||||
return webSocket.execute(exchange)
|
||||
const decoder = new TextDecoder()
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* Effect.acquireRelease(
|
||||
webSocket.open({ url: prepared.url, headers: prepared.headers }),
|
||||
(connection) => connection.close,
|
||||
)
|
||||
yield* connection.sendText(prepared.message)
|
||||
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -395,12 +266,15 @@ export const jsonTransport = {
|
||||
with: json,
|
||||
} as const
|
||||
|
||||
export const WebSocketTransport = {
|
||||
json,
|
||||
jsonTransport,
|
||||
direct,
|
||||
makeDirect,
|
||||
export const WebSocketExecutor = {
|
||||
Service,
|
||||
layer,
|
||||
open,
|
||||
fromWebSocket,
|
||||
messageText,
|
||||
} as const
|
||||
|
||||
export const WebSocketTransport = {
|
||||
json,
|
||||
jsonTransport,
|
||||
} as const
|
||||
|
||||
@@ -98,13 +98,6 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
|
||||
kind: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
phase: Schema.optional(
|
||||
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
|
||||
),
|
||||
delivery: Schema.optional(Schema.Literals(["not-sent", "rejected", "ambiguous", "accepted"])),
|
||||
recovery: Schema.optional(
|
||||
Schema.Literals(["retry-connect", "retry-full", "rotate-and-retry-full", "fallback-http", "fail"]),
|
||||
),
|
||||
}) {}
|
||||
|
||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, AIError } from "../src"
|
||||
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route"
|
||||
import { LLMClient, RequestExecutor } from "../src/route"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import * as OpenAI from "../src/providers/openai"
|
||||
import { dynamicResponse, fixedResponse } from "./lib/http"
|
||||
import { dynamicResponse } from "./lib/http"
|
||||
import { deltaChunk } from "./lib/openai-chunks"
|
||||
import { sseRaw } from "./lib/sse"
|
||||
import { it } from "./lib/effect"
|
||||
@@ -414,127 +413,3 @@ describe("RequestExecutor", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("WebSocket channel execution", () => {
|
||||
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
"gpt-4.1-mini",
|
||||
)
|
||||
const request = LLM.request({ model, prompt: "Say hello." })
|
||||
const frames = [
|
||||
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
]
|
||||
|
||||
it.effect("runs a channel driver through the direct executor", () =>
|
||||
Effect.gen(function* () {
|
||||
const sent = yield* Ref.make("")
|
||||
const closed = yield* Ref.make(false)
|
||||
const observed = yield* Ref.make(0)
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: (message) => Ref.set(sent, message),
|
||||
messages: Stream.make("one", "done", "late"),
|
||||
close: Ref.set(closed, true),
|
||||
}),
|
||||
})
|
||||
const received = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const execution = yield* webSocket.execute({
|
||||
id: "exchange_1",
|
||||
connect: { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
||||
fallback: () => Stream.empty,
|
||||
driver: {
|
||||
create: () => Effect.succeed({ message: "create", mode: "full" }),
|
||||
observe: (_create, frame) =>
|
||||
Ref.update(observed, (value) => value + 1).pipe(
|
||||
Effect.as(
|
||||
frame === "done" ? { type: "completed" as const, frame } : { type: "frame" as const, frame },
|
||||
),
|
||||
),
|
||||
},
|
||||
})
|
||||
return yield* Stream.runCollect(execution.frames)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Array.from(received)).toEqual(["one", "done"])
|
||||
expect(yield* Ref.get(sent)).toBe("create")
|
||||
expect(yield* Ref.get(observed)).toBe(2)
|
||||
expect(yield* Ref.get(closed)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires a per-call WebSocket executor", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse("")), Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
phase: "prepare",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
expect(error.message).toContain("StreamOptions.webSocket")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("commits channel execution only after complete consumption", () =>
|
||||
Effect.gen(function* () {
|
||||
const commits = yield* Ref.make(0)
|
||||
const executor = (input: Stream.Stream<string, AIError>): WebSocketChannelExecutor => ({
|
||||
execute: () =>
|
||||
Effect.succeed({
|
||||
frames: input,
|
||||
complete: Ref.update(commits, (value) => value + 1),
|
||||
}),
|
||||
})
|
||||
|
||||
const response = yield* LLMClient.generate(request, {
|
||||
webSocket: executor(Stream.fromArray(frames)),
|
||||
}).pipe(Effect.provide(fixedResponse("")))
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(yield* Ref.get(commits)).toBe(1)
|
||||
|
||||
yield* LLMClient.generate(request, { webSocket: executor(Stream.make("not-json")) }).pipe(
|
||||
Effect.provide(fixedResponse("")),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(yield* Ref.get(commits)).toBe(1)
|
||||
|
||||
yield* LLMClient.stream(request, { webSocket: executor(Stream.fromArray(frames)) }).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.provide(fixedResponse("")),
|
||||
)
|
||||
expect(yield* Ref.get(commits)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not commit interrupted channel execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const commits = yield* Ref.make(0)
|
||||
const started = yield* Deferred.make<void>()
|
||||
const executor: WebSocketChannelExecutor = {
|
||||
execute: () =>
|
||||
Effect.succeed({
|
||||
frames: Stream.fromEffect(
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.as(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })),
|
||||
),
|
||||
).pipe(Stream.concat(Stream.never)),
|
||||
complete: Ref.update(commits, (value) => value + 1),
|
||||
}),
|
||||
}
|
||||
const fiber = yield* LLMClient.stream(request, { webSocket: executor }).pipe(
|
||||
Stream.runDrain,
|
||||
Effect.provide(fixedResponse("")),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
expect(yield* Ref.get(commits)).toBe(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
|
||||
import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route"
|
||||
import { Route, Protocol } from "@opencode-ai/ai/route"
|
||||
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
||||
import {
|
||||
CloudflareAIGateway,
|
||||
@@ -37,7 +37,6 @@ describe("public exports", () => {
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
expect(Route.make).toBeFunction()
|
||||
expect(Protocol.make).toBeFunction()
|
||||
expect(WebSocketTransport.makeDirect).toBeFunction()
|
||||
})
|
||||
|
||||
test("provider barrels expose user-facing facades", async () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLMClient, RequestExecutor } from "../../src/route"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import type { Service as LLMClientService } from "../../src/route/client"
|
||||
import type { Service as RequestExecutorService } from "../../src/route/executor"
|
||||
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket"
|
||||
|
||||
export type HandlerInput = {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
@@ -31,12 +32,13 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
||||
),
|
||||
)
|
||||
|
||||
export type RuntimeEnv = RequestExecutorService | LLMClientService
|
||||
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
||||
|
||||
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
|
||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
|
||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
|
||||
return Layer.mergeAll(deps, llmClientLayer)
|
||||
}
|
||||
|
||||
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
|
||||
|
||||
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies V1 overloaded provider codes", () => {
|
||||
expect(
|
||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
|
||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
|
||||
(message) => classifyProviderFailure({ message })._tag,
|
||||
),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
TransportReason,
|
||||
Usage,
|
||||
} from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketTransport } from "../../src/route"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -239,29 +238,34 @@ describe("OpenAI Responses route", () => {
|
||||
const sent: string[] = []
|
||||
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
let closed = false
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({
|
||||
execute: () => Effect.die("unexpected HTTP request"),
|
||||
}),
|
||||
)
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: (input) =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
opened.push({ url: input.url, authorization: input.headers.authorization })
|
||||
sent.push(message)
|
||||
}),
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||
]),
|
||||
close: Effect.sync(() => {
|
||||
closed = true
|
||||
}),
|
||||
const deps = Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({
|
||||
execute: () => Effect.die("unexpected HTTP request"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
Layer.succeed(
|
||||
WebSocketExecutor.Service,
|
||||
WebSocketExecutor.Service.of({
|
||||
open: (input) =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
opened.push({ url: input.url, authorization: input.headers.authorization })
|
||||
sent.push(message)
|
||||
}),
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||
]),
|
||||
close: Effect.sync(() => {
|
||||
closed = true
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
@@ -269,7 +273,6 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{ webSocket },
|
||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
@@ -285,158 +288,15 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes a direct WebSocket execution after partial consumption", () =>
|
||||
Effect.gen(function* () {
|
||||
const closed = yield* Ref.make(false)
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||
]),
|
||||
close: Ref.set(closed, true),
|
||||
}),
|
||||
})
|
||||
|
||||
yield* LLMClient.stream(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
"gpt-4.1-mini",
|
||||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{ webSocket },
|
||||
).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runDrain,
|
||||
Effect.provide(
|
||||
LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(yield* Ref.get(closed)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("terminates WebSocket control events without waiting for the socket to close", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = [
|
||||
{ type: "error", error: { code: "slow_down", message: "Try later" } },
|
||||
{
|
||||
type: "error",
|
||||
status_code: 429,
|
||||
message: "Rate limited",
|
||||
headers: { "retry-after": 1, "x-request-id": "request", cached: false, invalid: [] },
|
||||
},
|
||||
{
|
||||
type: "response.failed",
|
||||
response: { error: { code: "server_error", message: "Unavailable" } },
|
||||
},
|
||||
{ type: "error", status: "not-a-status", message: "Malformed status" },
|
||||
]
|
||||
|
||||
const errors = yield* Effect.forEach(events, (event) =>
|
||||
LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
"gpt-4.1-mini",
|
||||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{
|
||||
webSocket: WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.make(ProviderShared.encodeJson(event)).pipe(Stream.concat(Stream.never)),
|
||||
close: Effect.void,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
),
|
||||
)
|
||||
|
||||
expect(errors.map((error) => error.reason._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
"RateLimit",
|
||||
"ProviderInternal",
|
||||
"UnknownProvider",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("marks post-send WebSocket failures with delivery state", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = new AIError({
|
||||
module: "test",
|
||||
method: "receive",
|
||||
reason: new TransportReason({ message: "socket closed", phase: "close" }),
|
||||
})
|
||||
const streams = [
|
||||
Stream.fail(failure),
|
||||
Stream.make(ProviderShared.encodeJson({ type: "response.created" })).pipe(Stream.concat(Stream.fail(failure))),
|
||||
]
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
)
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Effect.void,
|
||||
messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
"gpt-4.1-mini",
|
||||
)
|
||||
|
||||
const errors = yield* Effect.forEach(["first", "second"], (prompt) =>
|
||||
LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
|
||||
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
|
||||
Effect.flip,
|
||||
),
|
||||
)
|
||||
|
||||
expect(errors.map((error) => error.reason)).toEqual([
|
||||
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "ambiguous" }),
|
||||
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "accepted" }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails immediately when WebSocket is already closed", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* WebSocketTransport.fromWebSocket(
|
||||
const error = yield* WebSocketExecutor.fromWebSocket(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
|
||||
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
|
||||
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("closed before opening")
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "connect", delivery: "not-sent" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { Layer } from "effect"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { LLMClient, RequestExecutor } from "../src/route"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
|
||||
import { ImageClient } from "../src/image-client"
|
||||
import type { Service as ImageClientService } from "../src/image-client"
|
||||
import type { Service as LLMClientService } from "../src/route/client"
|
||||
import type { Service as RequestExecutorService } from "../src/route/executor"
|
||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
||||
import {
|
||||
recordedEffectGroup,
|
||||
type RecordedCaseOptions as RunnerCaseOptions,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||
|
||||
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
|
||||
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
|
||||
|
||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||
readonly options?: HttpRecorder.RecorderOptions
|
||||
@@ -81,10 +82,11 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
}),
|
||||
),
|
||||
)
|
||||
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
|
||||
return Layer.mergeAll(
|
||||
requestExecutor,
|
||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
deps,
|
||||
LLMClient.layer.pipe(Layer.provide(deps)),
|
||||
ImageClient.layer.pipe(Layer.provide(deps)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
LanguageModel,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
TransportReason,
|
||||
Usage,
|
||||
} from "../src/schema"
|
||||
import { ProviderShared } from "../src/protocols/shared"
|
||||
@@ -109,21 +108,3 @@ test("AI errors expose the shared runtime tag", async () => {
|
||||
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
|
||||
).toBe("caught")
|
||||
})
|
||||
|
||||
test("transport errors serialize execution facts", () => {
|
||||
const reason = new TransportReason({
|
||||
message: "connection closed",
|
||||
phase: "receive",
|
||||
delivery: "ambiguous",
|
||||
recovery: "fail",
|
||||
})
|
||||
|
||||
expect(Schema.encodeSync(TransportReason)(reason)).toEqual({
|
||||
_tag: "Transport",
|
||||
message: "connection closed",
|
||||
phase: "receive",
|
||||
delivery: "ambiguous",
|
||||
recovery: "fail",
|
||||
})
|
||||
expect(Schema.decodeUnknownSync(TransportReason)(Schema.encodeSync(TransportReason)(reason))).toEqual(reason)
|
||||
})
|
||||
|
||||
@@ -319,7 +319,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
transport: {
|
||||
id: "ai-sdk",
|
||||
prepare: (input) => Effect.succeed(input.body),
|
||||
execute: () => Effect.succeed({ frames: Stream.empty }),
|
||||
frames: () => Stream.empty,
|
||||
},
|
||||
defaults: {
|
||||
headers: info.headers,
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as Bus from "./bus"
|
||||
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm"
|
||||
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
@@ -134,8 +134,6 @@ export interface Interface {
|
||||
readonly after?: number
|
||||
readonly follow?: boolean
|
||||
}) => Stream.Stream<LogItem>
|
||||
/** Latest committed seq per aggregate. Aggregates without events are absent. */
|
||||
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Event.Seq>>
|
||||
/** @deprecated Use `subscribe()` and consume the returned stream. */
|
||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||
@@ -657,19 +655,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
}),
|
||||
)
|
||||
|
||||
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Event.Seq>> => {
|
||||
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
|
||||
return db
|
||||
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
|
||||
.from(EventSequenceTable)
|
||||
.where(inArray(EventSequenceTable.aggregate_id, Array.from(aggregateIDs)))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Event.Seq.make(row.seq)]))),
|
||||
)
|
||||
}
|
||||
|
||||
const listen = (listener: Subscriber): Effect.Effect<Unsubscribe> =>
|
||||
Effect.sync(() => {
|
||||
listeners.push(listener)
|
||||
@@ -691,7 +676,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
publish,
|
||||
subscribe,
|
||||
log,
|
||||
sequences,
|
||||
listen,
|
||||
project,
|
||||
replay,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as Formatter from "./formatter"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -11,16 +11,7 @@ import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { make, type Info } from "./formatter/builtins"
|
||||
|
||||
export const Status = Schema.Struct({
|
||||
name: Schema.String,
|
||||
extensions: Schema.Array(Schema.String),
|
||||
enabled: Schema.Boolean,
|
||||
}).annotate({ identifier: "FormatterStatus" })
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
@@ -84,23 +75,6 @@ const layer = Layer.effect(
|
||||
return result
|
||||
})
|
||||
|
||||
const init = Effect.fn("Formatter.init")(function* () {
|
||||
yield* load
|
||||
})
|
||||
|
||||
const status = Effect.fn("Formatter.status")(function* () {
|
||||
yield* load
|
||||
return yield* Effect.forEach(formatters, (formatter) =>
|
||||
command(formatter).pipe(
|
||||
Effect.map((enabled) => ({
|
||||
name: formatter.name,
|
||||
extensions: [...formatter.extensions],
|
||||
enabled: enabled !== false,
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) =>
|
||||
@@ -143,7 +117,7 @@ const layer = Layer.effect(
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ init, status, file })
|
||||
return Service.of({ file })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
+1
-224
@@ -1,8 +1,7 @@
|
||||
export * as Git from "./git"
|
||||
|
||||
import path from "path"
|
||||
import { randomUUID } from "crypto"
|
||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -36,9 +35,6 @@ const snapshotConfig = `[core]
|
||||
threads = true
|
||||
`
|
||||
|
||||
export const ChangeSet = Schema.String.pipe(Schema.brand("Git.ChangeSet"))
|
||||
export type ChangeSet = typeof ChangeSet.Type
|
||||
|
||||
export const TreeID = Schema.String.pipe(Schema.brand("Git.TreeID"))
|
||||
export type TreeID = typeof TreeID.Type
|
||||
|
||||
@@ -73,13 +69,6 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
|
||||
operation: Schema.Literals(["capture", "apply", "reset"]),
|
||||
directory: AbsolutePath,
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly repo: {
|
||||
readonly discover: (input: AbsolutePath) => Effect.Effect<Repository | undefined>
|
||||
@@ -116,20 +105,6 @@ export interface Interface {
|
||||
) => Effect.Effect<void, OperationError>
|
||||
readonly resetHard: (repository: Repository, revision: string) => Effect.Effect<void, OperationError>
|
||||
}
|
||||
readonly change: {
|
||||
readonly capture: (input: { repository: Repository; path: AbsolutePath }) => Effect.Effect<ChangeSet, PatchError>
|
||||
readonly apply: (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
changes: ChangeSet
|
||||
}) => Effect.Effect<void, PatchError>
|
||||
readonly discard: (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
index: "preserve" | "reset"
|
||||
untracked: "preserve" | "remove"
|
||||
}) => Effect.Effect<void, PatchError>
|
||||
}
|
||||
readonly worktree: {
|
||||
readonly create: (input: {
|
||||
repository: Repository
|
||||
@@ -175,17 +150,10 @@ export interface Interface {
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||
readonly preview: (input: {
|
||||
repository: Repository
|
||||
current: TreeID
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
context?: number
|
||||
}) => Effect.Effect<readonly File.Diff[], OperationError>
|
||||
readonly restore: (input: {
|
||||
repository: Repository
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
}) => Effect.Effect<void, OperationError>
|
||||
readonly checkout: (input: { repository: Repository; tree: TreeID }) => Effect.Effect<void, OperationError>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,58 +625,6 @@ const layer = Layer.effect(
|
||||
return { mode: match[1], object: match[2] }
|
||||
})
|
||||
|
||||
const preview = Effect.fn("Git.tree.preview")(
|
||||
(input: {
|
||||
repository: Repository
|
||||
current: TreeID
|
||||
files: ReadonlyMap<RelativePath, TreeID>
|
||||
context?: number
|
||||
}) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.gen(function* () {
|
||||
const index = path.join(input.repository.gitDirectory, `preview-${randomUUID()}.index`)
|
||||
const env = { GIT_INDEX_FILE: index }
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* repositoryOperation("diff", input.repository, ["read-tree", input.current], { env })
|
||||
yield* Effect.forEach(
|
||||
input.files,
|
||||
([file, tree]) =>
|
||||
Effect.gen(function* () {
|
||||
const source = yield* entry(input.repository, tree, file)
|
||||
if (!source) {
|
||||
yield* repositoryOperation(
|
||||
"diff",
|
||||
input.repository,
|
||||
["update-index", "--force-remove", "--", file],
|
||||
{ env },
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* repositoryOperation(
|
||||
"diff",
|
||||
input.repository,
|
||||
["update-index", "--add", "--cacheinfo", source.mode, source.object, file],
|
||||
{ env },
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const target = TreeID.make(
|
||||
(yield* repositoryOperation("diff", input.repository, ["write-tree"], { env })).text.trim(),
|
||||
)
|
||||
return yield* treeDiff({
|
||||
repository: input.repository,
|
||||
from: input.current,
|
||||
to: target,
|
||||
context: input.context,
|
||||
paths: Array.from(input.files.keys()),
|
||||
})
|
||||
}).pipe(Effect.ensuring(fs.remove(index).pipe(Effect.catch(() => Effect.void))))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const restore = Effect.fn("Git.tree.restore")(
|
||||
(input: { repository: Repository; files: ReadonlyMap<RelativePath, TreeID> }) =>
|
||||
locked(
|
||||
@@ -738,142 +654,6 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
|
||||
const checkoutTree = Effect.fn("Git.tree.checkout")((input: { repository: Repository; tree: TreeID }) =>
|
||||
locked(
|
||||
input.repository,
|
||||
Effect.gen(function* () {
|
||||
yield* repositoryOperation("restore", input.repository, ["read-tree", input.tree])
|
||||
yield* repositoryOperation("restore", input.repository, ["checkout-index", "--all", "--force"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const capture = Effect.fn("Git.change.capture")(function* (input: { repository: Repository; path: AbsolutePath }) {
|
||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
||||
const tracked = yield* execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (tracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory: input.path,
|
||||
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const untracked = yield* execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (untracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory: input.path,
|
||||
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
|
||||
execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
Effect.flatMap((result) =>
|
||||
// git diff --no-index returns 1 when differences were found.
|
||||
result.exitCode === 0 || result.exitCode === 1
|
||||
? Effect.succeed(result.text)
|
||||
: Effect.fail(
|
||||
new PatchError({
|
||||
operation: "capture",
|
||||
directory: input.path,
|
||||
message:
|
||||
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return ChangeSet.make([tracked.text, ...created].filter(Boolean).join("\n"))
|
||||
})
|
||||
|
||||
const apply = Effect.fn("Git.change.apply")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
changes: ChangeSet
|
||||
}) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", ["apply", "-"], {
|
||||
cwd: input.path,
|
||||
extendEnv: true,
|
||||
stdin: Stream.make(new TextEncoder().encode(input.changes)),
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "apply", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "apply",
|
||||
directory: input.path,
|
||||
message:
|
||||
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
|
||||
})
|
||||
})
|
||||
|
||||
const discard = Effect.fn("Git.change.discard")(function* (input: {
|
||||
repository: Repository
|
||||
path: AbsolutePath
|
||||
index: "preserve" | "reset"
|
||||
untracked: "preserve" | "remove"
|
||||
}) {
|
||||
const scope = path.relative(input.repository.worktree, input.path).replaceAll("\\", "/") || "."
|
||||
const restore = yield* execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(input.index === "reset" ? ["checkout", "HEAD", "--", scope] : ["checkout", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (restore.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory: input.path,
|
||||
message: restore.stderr.trim() || restore.text.trim() || "Failed to restore tracked changes",
|
||||
})
|
||||
}
|
||||
if (input.untracked === "preserve") return
|
||||
const clean = yield* execute(
|
||||
input.repository.worktree,
|
||||
proc,
|
||||
)(["clean", "-fd", "--", scope]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "reset", directory: input.path, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (clean.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory: input.path,
|
||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
||||
})
|
||||
})
|
||||
|
||||
const worktreeRun = Effect.fnUntraced(function* (
|
||||
operation: "create" | "remove" | "list",
|
||||
repository: Repository,
|
||||
@@ -949,7 +729,6 @@ const layer = Layer.effect(
|
||||
remote: { get: remote },
|
||||
history: { head, branch, defaultRemoteBranch: remoteHead, rootCommits: roots },
|
||||
sync: { fetchRemotes: fetch, fetchBranch, checkoutRemoteBranch: checkout, resetHard: reset },
|
||||
change: { capture, apply, discard },
|
||||
worktree: { create: worktreeCreate, remove: worktreeRemove, list: worktreeList },
|
||||
index: { refresh, ignored },
|
||||
tree: {
|
||||
@@ -957,9 +736,7 @@ const layer = Layer.effect(
|
||||
write: writeTree,
|
||||
files: treeFiles,
|
||||
diff: treeDiff,
|
||||
preview,
|
||||
restore,
|
||||
checkout: checkoutTree,
|
||||
},
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -18,9 +18,8 @@ export function isRetryable(error: AIError) {
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
return true
|
||||
case "Transport":
|
||||
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
|
||||
return true
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
case "Authentication":
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
export * as ShellSelect from "./select"
|
||||
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { readFile } from "fs/promises"
|
||||
import { statSync } from "fs"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
import { Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { which } from "../util/which"
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
|
||||
bash: { login: true, posix: true },
|
||||
dash: { login: true, posix: true },
|
||||
@@ -33,37 +30,6 @@ export const Options = Schema.Struct({
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
|
||||
const pid = proc.pid
|
||||
if (!pid || opts?.exited?.()) return
|
||||
|
||||
if (process.platform === "win32") {
|
||||
await new Promise<void>((resolve) => {
|
||||
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
killer.once("exit", () => resolve())
|
||||
killer.once("error", () => resolve())
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
await setTimeout(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch {
|
||||
proc.kill("SIGTERM")
|
||||
await setTimeout(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
proc.kill("SIGKILL")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stat(file: string) {
|
||||
return statSync(file, { throwIfNoEntry: false }) ?? undefined
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Hash } from "@opencode-ai/util/hash"
|
||||
export { ID }
|
||||
|
||||
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
|
||||
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
|
||||
operation: Schema.Literals(["capture", "files", "diff", "restore"]),
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
@@ -36,10 +36,6 @@ export interface RestoreInput {
|
||||
readonly files: ReadonlyMap<RelativePath, ID>
|
||||
}
|
||||
|
||||
export interface PreviewInput extends RestoreInput {
|
||||
readonly context?: number
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Capture the current Location-scoped filesystem state as a content-addressed
|
||||
@@ -60,25 +56,11 @@ export interface Interface {
|
||||
*/
|
||||
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||
|
||||
/**
|
||||
* Preview the filesystem result of a selective restore without modifying the
|
||||
* worktree. Each project-relative path maps to the tree it would be restored
|
||||
* from.
|
||||
*/
|
||||
readonly preview: (input: PreviewInput) => Effect.Effect<readonly File.Diff[], Error>
|
||||
|
||||
/**
|
||||
* Restore selected project-relative paths from their associated trees. A path
|
||||
* absent from its selected tree is removed; paths outside the map are untouched.
|
||||
*/
|
||||
*/
|
||||
readonly restore: (input: RestoreInput) => Effect.Effect<void, Error>
|
||||
|
||||
/**
|
||||
* Replace the snapshot index with a captured tree and check out all its entries.
|
||||
* Files absent from the tree remain untouched. Prefer selective `restore` when
|
||||
* only known paths should change.
|
||||
*/
|
||||
readonly checkout: (snapshot: ID) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||
@@ -176,59 +158,26 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (
|
||||
operation: "preview" | "restore",
|
||||
worktree: AbsolutePath,
|
||||
input: RestoreInput,
|
||||
) {
|
||||
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||
const files = new Map<RelativePath, Git.TreeID>()
|
||||
for (const [file, snapshot] of input.files) {
|
||||
const absolute = path.resolve(worktree, file)
|
||||
if (!FSUtil.contains(worktree, absolute))
|
||||
return yield* new Error({ operation, message: `Path escapes the project: ${file}` })
|
||||
return yield* new Error({ operation: "restore", message: `Path escapes the project: ${file}` })
|
||||
files.set(file, Git.TreeID.make(snapshot))
|
||||
}
|
||||
return files
|
||||
})
|
||||
|
||||
const preview = Effect.fn("Snapshot.preview")(function* (input: PreviewInput) {
|
||||
if (!(yield* enabled())) return yield* new Error({ operation: "preview", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
const files = yield* plan("preview", repo.worktree, input)
|
||||
const current = yield* git.tree
|
||||
.capture({
|
||||
repository: repo.snapshotRepository,
|
||||
scopes: Array.from(files.keys()),
|
||||
ignores: repo.source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
return yield* git.tree
|
||||
.preview({
|
||||
repository: repo.snapshotRepository,
|
||||
current,
|
||||
files,
|
||||
context: input.context,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("preview", cause)))
|
||||
})
|
||||
|
||||
const restore = Effect.fn("Snapshot.restore")(function* (input: RestoreInput) {
|
||||
if (!(yield* enabled())) return yield* new Error({ operation: "restore", message: "Snapshots are disabled" })
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
yield* git.tree
|
||||
.restore({ repository: repo.snapshotRepository, files: yield* plan("restore", repo.worktree, input) })
|
||||
.restore({ repository: repo.snapshotRepository, files: yield* plan(repo.worktree, input) })
|
||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
})
|
||||
|
||||
const checkout = Effect.fn("Snapshot.checkout")(function* (snapshot: ID) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
yield* git.tree
|
||||
.checkout({ repository: repo.snapshotRepository, tree: Git.TreeID.make(snapshot) })
|
||||
.pipe(Effect.mapError((cause) => failure("restore", cause)))
|
||||
})
|
||||
|
||||
return Service.of({ capture, files, diff, preview, restore, checkout })
|
||||
return Service.of({ capture, files, diff, restore })
|
||||
}).pipe(Effect.withSpan("Snapshot.boot")),
|
||||
)
|
||||
|
||||
@@ -244,9 +193,7 @@ export const noopLayer = Layer.succeed(
|
||||
capture: () => Effect.succeed(undefined),
|
||||
files: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
preview: () => Effect.succeed([]),
|
||||
restore: () => Effect.void,
|
||||
checkout: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1298,24 +1298,4 @@ describe("Bus", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sequences returns the latest committed seq per aggregate and omits unknown aggregates", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const first = Session.ID.create()
|
||||
const second = Session.ID.create()
|
||||
yield* bus.publish(DurableMessage, durableData(first, "zero"))
|
||||
yield* bus.publish(DurableMessage, durableData(first, "one"))
|
||||
yield* bus.publish(DurableMessage, durableData(second, "zero"))
|
||||
|
||||
const sequences = yield* bus.sequences([first, second, Session.ID.create()])
|
||||
|
||||
expect(sequences).toEqual(
|
||||
new Map([
|
||||
[first, Event.Seq.make(1)],
|
||||
[second, Event.Seq.make(0)],
|
||||
]),
|
||||
)
|
||||
expect(yield* bus.sequences([])).toEqual(new Map())
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -56,52 +56,22 @@ function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>)
|
||||
}
|
||||
|
||||
describe("Formatter", () => {
|
||||
it.live("status() returns empty list when no formatters are configured", () =>
|
||||
it.live("does not run formatters marked as disabled in config", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() returns built-in formatters when formatter is true", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt?.extensions).toContain(".go")
|
||||
}),
|
||||
).pipe(Effect.provide(formatterLayer(directory, true))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() keeps built-in formatters when config object is provided", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
|
||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
||||
}),
|
||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() excludes formatters marked as disabled in config", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
|
||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
||||
}),
|
||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("service initializes without error", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.disabled")
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(directory, {
|
||||
disabled: {
|
||||
disabled: true,
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [".disabled"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -115,22 +85,29 @@ describe("Formatter", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() initializes formatter state per directory", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([off, on]) =>
|
||||
it.live("loads formatter state per directory", () =>
|
||||
withTemp((off) =>
|
||||
withTemp((on) =>
|
||||
Effect.gen(function* () {
|
||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||
Effect.provide(formatterLayer(off.path, false)),
|
||||
const offFile = path.join(off, "test.isolated")
|
||||
const onFile = path.join(on, "test.isolated")
|
||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.file(offFile)).pipe(
|
||||
Effect.provide(formatterLayer(off, false)),
|
||||
)
|
||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||
Effect.provide(formatterLayer(on.path, true)),
|
||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.file(onFile)).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(on, {
|
||||
isolated: {
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [".isolated"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(disabled).toEqual([])
|
||||
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
|
||||
expect(disabled).toBe(false)
|
||||
expect(enabled).toBe(true)
|
||||
}),
|
||||
(directories) =>
|
||||
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -185,9 +185,6 @@ describe("Git trees", () => {
|
||||
])
|
||||
|
||||
const files = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* git.tree.restore({ repository, files })
|
||||
expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n")
|
||||
|
||||
@@ -110,26 +110,4 @@ describe("toSessionError", () => {
|
||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
})
|
||||
|
||||
test("retries transport failures only when delivery is absent or not sent", () => {
|
||||
const retryable = [
|
||||
llm(new TransportReason({ message: "http transport" })),
|
||||
llm(new TransportReason({ message: "connect failed", delivery: "not-sent", phase: "connect" })),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(new TransportReason({ message: "send uncertain", delivery: "ambiguous", phase: "send" })),
|
||||
llm(new TransportReason({ message: "response interrupted", delivery: "accepted", phase: "receive" })),
|
||||
llm(
|
||||
new TransportReason({
|
||||
message: "continuation rejected",
|
||||
delivery: "rejected",
|
||||
recovery: "retry-full",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,17 +41,15 @@ describe("Session.log", () => {
|
||||
it.effect("replays public session events and marks synced at the aggregate watermark", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
|
||||
|
||||
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
|
||||
const watermark = (yield* bus.sequences([created.id])).get(created.id)
|
||||
|
||||
// Session creation commits a non-public durable event, so the marker's
|
||||
// seq covers more of the aggregate than the public events emitted.
|
||||
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
|
||||
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
|
||||
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: Event.Seq.make(1) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -117,9 +117,6 @@ describe("Snapshot", () => {
|
||||
RelativePath.make("scope/tracked.txt"),
|
||||
])
|
||||
const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]])
|
||||
const preview = yield* snapshot.preview({ files: plan, context: 1 })
|
||||
expect(preview).toHaveLength(1)
|
||||
expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt"))
|
||||
yield* snapshot.restore({ files: plan })
|
||||
expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(location, "added.txt"))).toBe("added\n")
|
||||
@@ -185,36 +182,6 @@ describe("Snapshot", () => {
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
|
||||
await initGit(project)
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
expect(before).toBeDefined()
|
||||
if (!before) return
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
|
||||
await fs.writeFile(path.join(project, "unrelated.txt"), "keep\n")
|
||||
})
|
||||
yield* snapshot.checkout(before)
|
||||
expect(yield* read(path.join(project, "tracked.txt"))).toBe("one\n")
|
||||
expect(yield* read(path.join(project, "unrelated.txt"))).toBe("keep\n")
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function snapshotLayer(data: string, directory: string) {
|
||||
|
||||
@@ -2,10 +2,6 @@ export * as ServerAuth from "./auth"
|
||||
|
||||
import { Context, Layer, Option, Redacted } from "effect"
|
||||
|
||||
export type Credentials = {
|
||||
password?: string
|
||||
}
|
||||
|
||||
export type DecodedCredentials = {
|
||||
readonly username: string
|
||||
readonly password: Redacted.Redacted
|
||||
@@ -37,16 +33,3 @@ export function authorized(credentials: DecodedCredentials, config: Info) {
|
||||
Redacted.value(credentials.password) === config.password.value
|
||||
)
|
||||
}
|
||||
|
||||
export function header(credentials?: Credentials) {
|
||||
const password = credentials?.password
|
||||
if (!password) return undefined
|
||||
|
||||
return `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
export function headers(credentials?: Credentials) {
|
||||
const authorization = header(credentials)
|
||||
if (!authorization) return undefined
|
||||
return { Authorization: authorization }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,3 @@ test("accepts only the fixed opencode username", () => {
|
||||
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
|
||||
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
|
||||
})
|
||||
|
||||
test("encodes the fixed opencode username", () => {
|
||||
expect(ServerAuth.header({ password: "secret" })).toBe(`Basic ${Buffer.from("opencode:secret").toString("base64")}`)
|
||||
})
|
||||
|
||||
@@ -96,6 +96,7 @@ import { findMessageBoundary, messageNavigationSlack } from "./message-navigatio
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { installSyntaxHighlightCache } from "../../util/syntax-highlight-cache"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -128,6 +129,7 @@ function use() {
|
||||
}
|
||||
|
||||
export function Session() {
|
||||
installSyntaxHighlightCache()
|
||||
const setEpilogue = useEpilogue()
|
||||
const clipboard = useClipboard()
|
||||
const writeExport = async (file: string, content: string) => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getTreeSitterClient, type TreeSitterClient } from "@opentui/core"
|
||||
|
||||
const CACHE_SIZE = 500
|
||||
const installed = new WeakSet<TreeSitterClient>()
|
||||
|
||||
export function installSyntaxHighlightCache() {
|
||||
const client = getTreeSitterClient()
|
||||
if (installed.has(client)) return
|
||||
installed.add(client)
|
||||
client.highlightOnce = cacheHighlights(client.highlightOnce.bind(client))
|
||||
}
|
||||
|
||||
export function cacheHighlights(highlight: TreeSitterClient["highlightOnce"], capacity = CACHE_SIZE) {
|
||||
const cache = new Map<string, ReturnType<TreeSitterClient["highlightOnce"]>>()
|
||||
|
||||
return (content: string, filetype: string) => {
|
||||
const key = `${filetype}\0${content}`
|
||||
const cached = cache.get(key)
|
||||
if (cached) {
|
||||
cache.delete(key)
|
||||
cache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
const result = highlight(content, filetype)
|
||||
cache.set(key, result)
|
||||
if (cache.size > capacity) cache.delete(cache.keys().next().value!)
|
||||
|
||||
void result
|
||||
.then((value) => {
|
||||
if (value.error && cache.get(key) === result) cache.delete(key)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cache.get(key) === result) cache.delete(key)
|
||||
})
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { cacheHighlights } from "../../src/util/syntax-highlight-cache"
|
||||
|
||||
describe("syntax highlight cache", () => {
|
||||
test("reuses completed and in-flight highlights", async () => {
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(async () => {
|
||||
calls++
|
||||
return { highlights: [[0, 5, "keyword"]] }
|
||||
})
|
||||
|
||||
const first = highlight("const", "typescript")
|
||||
const second = highlight("const", "typescript")
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(await second).toEqual({ highlights: [[0, 5, "keyword"]] })
|
||||
expect(await highlight("const", "typescript")).toEqual({ highlights: [[0, 5, "keyword"]] })
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("evicts least recently used highlights", async () => {
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(async () => {
|
||||
calls++
|
||||
return { highlights: [] }
|
||||
}, 2)
|
||||
|
||||
await highlight("one", "text")
|
||||
await highlight("two", "text")
|
||||
await highlight("one", "text")
|
||||
await highlight("three", "text")
|
||||
await highlight("two", "text")
|
||||
|
||||
expect(calls).toBe(4)
|
||||
})
|
||||
|
||||
test("retries failed highlights", async () => {
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(async () => {
|
||||
calls++
|
||||
if (calls === 1) return { error: "parser unavailable" }
|
||||
return { highlights: [] }
|
||||
})
|
||||
|
||||
await highlight("const", "typescript")
|
||||
await highlight("const", "typescript")
|
||||
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test("an evicted failure does not delete its replacement", async () => {
|
||||
const pending = Promise.withResolvers<{ highlights: [] }>()
|
||||
let calls = 0
|
||||
const highlight = cacheHighlights(() => {
|
||||
calls++
|
||||
if (calls === 1) return pending.promise
|
||||
return Promise.resolve({ highlights: [] })
|
||||
}, 1)
|
||||
|
||||
const stale = highlight("one", "text")
|
||||
await highlight("two", "text")
|
||||
const current = highlight("one", "text")
|
||||
pending.reject(new Error("parser unavailable"))
|
||||
|
||||
await expect(stale).rejects.toThrow("parser unavailable")
|
||||
expect(highlight("one", "text")).toBe(current)
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user