Compare commits

...

1 Commits

Author SHA1 Message Date
Shoubhit Dash 142b3d6a27 feat(core): manage session websocket channels 2026-08-10 21:21:18 +05:30
9 changed files with 1008 additions and 28 deletions
+36 -11
View File
@@ -198,10 +198,24 @@ export const fromWebSocket = (
yield* waitOpen(ws, input)
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const offer = (message: string | Uint8Array) => {
if (Queue.offerUnsafe(messages, message)) return
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "WebSocket inbound queue overflow", {
url: input.url,
kind: "queue-overflow",
phase: "receive",
}),
),
)
}
const onMessage = (event: MessageEvent) => {
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
if (typeof event.data === "string") return offer(event.data)
const binary = binaryMessage(event.data)
if (binary) return Queue.offerUnsafe(messages, binary)
if (binary) return offer(binary)
Queue.failCauseUnsafe(
messages,
Cause.fail(
@@ -249,15 +263,26 @@ export const fromWebSocket = (
return {
sendText: (message) =>
Effect.try({
try: () => ws.send(message),
catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
kind: "write",
phase: "send",
delivery: "not-sent",
}),
Effect.suspend(() => {
if (ws.readyState !== globalThis.WebSocket.OPEN)
return Effect.fail(
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
url: input.url,
kind: "write",
phase: "send",
delivery: "not-sent",
}),
)
return Effect.try({
try: () => ws.send(message),
catch: (error) =>
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),
close: cleanup.pipe(
+30 -9
View File
@@ -7,7 +7,7 @@ import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAI from "../src/providers/openai"
import { dynamicResponse, fixedResponse } from "./lib/http"
import { deltaChunk } from "./lib/openai-chunks"
import { sseRaw } from "./lib/sse"
import { sseEvents, sseRaw } from "./lib/sse"
import { it } from "./lib/effect"
const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe(
@@ -463,16 +463,37 @@ describe("WebSocket channel execution", () => {
}),
)
it.effect("requires a per-call WebSocket executor", () =>
it.effect("rejects a closed socket before attempting to send", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse("")), Effect.flip)
class ClosedBeforeSend extends EventTarget {
readyState = globalThis.WebSocket.OPEN
sends = 0
send() {
this.sends++
}
close() {}
}
const socket = new ClosedBeforeSend()
const connection = yield* WebSocketTransport.fromWebSocket(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
socket as unknown as globalThis.WebSocket,
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
)
socket.readyState = globalThis.WebSocket.CLOSED
expect(error.reason).toMatchObject({
_tag: "Transport",
phase: "prepare",
delivery: "not-sent",
})
expect(error.message).toContain("StreamOptions.webSocket")
const error = yield* connection.sendText("create").pipe(Effect.flip)
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "send", delivery: "not-sent" })
expect(socket.sends).toBe(0)
yield* connection.close
}),
)
it.effect("uses HTTP when no per-call WebSocket executor is provided", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...frames))))
expect(response.text).toBe("Hi")
}),
)
@@ -1,4 +1,6 @@
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { NodeSocket } from "@effect/platform-node"
import { Socket } from "effect/unstable/socket"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
@@ -10,4 +12,10 @@ export const requestExecutor = makeGlobalNode({
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
export const webSocketConstructor = makeGlobalNode({
service: Socket.WebSocketConstructor,
layer: NodeSocket.layerWebSocketConstructorWS,
deps: [],
})
export * as LayerNodePlatform from "./app-node-platform"
+2
View File
@@ -33,6 +33,7 @@ import { WebSearch } from "./websearch"
import { ReferenceInstructions } from "./reference/instructions"
import { SessionRunnerLLM } from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionModelTransport } from "./session/model-transport"
import { SessionCompaction } from "./session/compaction"
import { SessionTitle } from "./session/title"
import { Skill } from "./skill"
@@ -90,6 +91,7 @@ const locationServiceNodes = [
McpTool.node,
SessionInstructions.node,
SessionRunnerModel.node,
SessionModelTransport.node,
SessionCompaction.node,
SessionTitle.node,
Snapshot.node,
+16 -3
View File
@@ -1,7 +1,7 @@
export * as Session from "./session"
export * from "./session/schema"
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, ne, or, type SQL } from "drizzle-orm"
import { Project } from "./project"
@@ -28,6 +28,7 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution"
import { SessionModelTransport } from "./session/model-transport"
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map"
@@ -323,6 +324,16 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const location = Location.Ref.make({
directory: session.location.directory,
workspaceID: session.location.workspaceID,
})
if (!(yield* RcMap.has(locations.rcMap, location))) return
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
Effect.provide(locations.get(location)),
)
})
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => {
@@ -446,9 +457,10 @@ const layer = Layer.effect(
return session
}),
remove: Effect.fn("Session.remove")(function* (sessionID) {
yield* result.get(sessionID)
const session = yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
yield* execution.awaitIdle(sessionID)
yield* closeTransport(session)
const children = yield* result.list({ parentID: sessionID })
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
yield* bus.publish(SessionEvent.Deleted, { sessionID })
@@ -748,8 +760,9 @@ const layer = Layer.effect(
yield* persistProject(project)
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
}
yield* execution.awaitIdle(input.sessionID)
yield* closeTransport(current)
yield* bus.publish(SessionEvent.Moved, {
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
+19 -3
View File
@@ -10,12 +10,14 @@ import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app"
import { Model } from "../model"
import { Provider } from "../provider"
import { Permission } from "../permission"
import { PluginHooks } from "../plugin/hooks"
import { QuestionTool } from "../tool/plugin/question"
import { Tool } from "../tool"
import { SessionContext } from "./context"
import { SessionModelHeaders } from "./model-headers"
import { SessionModelTransport } from "./model-transport"
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics"
import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
@@ -201,7 +203,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const transport = yield* SessionModelTransport.Service
const app = yield* App.Metadata
const webSocket = yield* Config.boolean("OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET").pipe(
Config.withDefault(false),
Effect.orDie,
)
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
Config.withDefault(false),
Effect.orDie,
@@ -274,7 +281,16 @@ export const layer = Layer.effect(
}),
})
const http = composeHttpMiddleware(middlewares)
const options: StreamOptions = http ? { http } : {}
const webSocketEligible = middlewares.length === 0
const options: StreamOptions = {
...(http ? { http } : {}),
...(webSocket &&
webSocketEligible &&
resolved.ref.providerID === Provider.ID.openai &&
model.route.id === "openai-responses"
? { webSocket: transport.bind(session.id) }
: {}),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current)
@@ -306,7 +322,7 @@ export const layer = Layer.effect(
return {
request,
options,
webSocketEligible: middlewares.length === 0,
webSocketEligible,
executeTool,
stepLimitReached,
}
@@ -319,5 +335,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [PluginHooks.node, App.node],
deps: [PluginHooks.node, SessionModelTransport.node, App.node],
})
@@ -0,0 +1,381 @@
export * as SessionModelTransport from "./model-transport"
import {
WebSocketTransport,
type ChannelObservation,
type WebSocketChannelExchange,
type WebSocketChannelExecution,
type WebSocketChannelExecutor,
type WebSocketConnection,
type WebSocketConnector,
} from "@opencode-ai/ai/route"
import { AIError, TransportReason } from "@opencode-ai/ai"
import { Hash } from "@opencode-ai/util/hash"
import { Cause, Clock, Context, Effect, Fiber, Layer, Queue, Scope, Semaphore, Stream } from "effect"
import { Socket } from "effect/unstable/socket"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionSchema } from "./schema"
import { webSocketConstructor } from "../effect/app-node-platform"
const ROTATE_AFTER_MS = 55 * 60 * 1000
const INBOUND_CAPACITY = 128
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
interface Active {
readonly queue: Queue.Queue<string, AIError>
readonly lifecycle: { delivery: Delivery }
}
interface Channel {
readonly affinity: string
readonly connection: WebSocketConnection
readonly openedAt: number
active?: Active
closing: boolean
poisoned: boolean
reader?: Fiber.Fiber<unknown, unknown>
}
interface State {
readonly lock: Semaphore.Semaphore
channel?: Channel
}
export interface Interface {
readonly bind: (sessionID: SessionSchema.ID) => WebSocketChannelExecutor
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelTransport") {}
const transportError = (
method: string,
message: string,
input: {
readonly url?: string
readonly kind?: string
readonly phase?: TransportReason["phase"]
readonly delivery?: TransportReason["delivery"]
} = {},
) =>
new AIError({
module: "SessionModelTransport",
method,
reason: new TransportReason({ message, ...input }),
})
const annotate = (
error: AIError,
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
) => {
if (error.reason._tag !== "Transport") return error
return 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,
recovery: error.reason.recovery,
...input,
}),
})
}
const affinity = (exchange: WebSocketChannelExchange) =>
`${exchange.connect.url}:${Hash.sha256(JSON.stringify(Object.entries(exchange.connect.headers).sort(([a], [b]) => a.localeCompare(b))))}`
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 makeLayer = (connector: WebSocketConnector) =>
Layer.effect(
Service,
Effect.gen(function* () {
const scope = yield* Scope.Scope
const states = new Map<SessionSchema.ID, State>()
const state = (sessionID: SessionSchema.ID) => {
const current = states.get(sessionID)
if (current) return current
const created = { lock: Semaphore.makeUnsafe(1) }
states.set(sessionID, created)
return created
}
const closeChannel = Effect.fn("SessionModelTransport.closeChannel")(function* (owner: State, channel: Channel) {
if (owner.channel === channel) owner.channel = undefined
if (channel.closing) return
channel.closing = true
if (channel.reader) yield* Fiber.interrupt(channel.reader)
yield* channel.connection.close
if (channel.active)
Queue.failCauseUnsafe(
channel.active.queue,
Cause.fail(
transportError("close", "Session WebSocket closed", {
kind: "close",
phase: "close",
delivery:
channel.active.lifecycle.delivery === "queued" ||
channel.active.lifecycle.delivery === "connecting" ||
channel.active.lifecycle.delivery === "ready"
? "not-sent"
: channel.active.lifecycle.delivery === "provider-observed" ||
channel.active.lifecycle.delivery === "terminal"
? "accepted"
: "ambiguous",
}),
),
)
})
const poison = Effect.fn("SessionModelTransport.poison")(function* (
owner: State,
channel: Channel,
error: AIError,
) {
channel.poisoned = true
if (owner.channel === channel) owner.channel = undefined
if (channel.closing) return
channel.closing = true
if (channel.active) Queue.failCauseUnsafe(channel.active.queue, Cause.fail(error))
yield* channel.connection.close
})
const open = Effect.fn("SessionModelTransport.open")(function* (
owner: State,
exchange: WebSocketChannelExchange,
key: string,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const connection = yield* restore(connector.open(exchange.connect))
const channel: Channel = {
affinity: key,
connection,
openedAt: yield* Clock.currentTimeMillis,
closing: false,
poisoned: false,
}
owner.channel = channel
channel.reader = yield* connection.messages.pipe(
Stream.runForEach((message) =>
Effect.gen(function* () {
const active = channel.active
if (!active)
return yield* transportError("receive", "WebSocket data arrived without an active exchange", {
url: exchange.connect.url,
kind: "idle-data",
phase: "receive",
})
active.lifecycle.delivery = "provider-observed"
if (typeof message !== "string")
return yield* transportError("receive", "Unsupported binary WebSocket frame", {
url: exchange.connect.url,
kind: "message",
phase: "receive",
})
if (Queue.offerUnsafe(active.queue, message)) return undefined
return yield* transportError("receive", "Session WebSocket inbound queue overflow", {
url: exchange.connect.url,
kind: "queue-overflow",
phase: "receive",
delivery: "accepted",
})
}),
),
Effect.catch((error) =>
channel.closing
? Effect.void
: poison(
owner,
channel,
annotate(error, {
phase:
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery:
channel.active?.lifecycle.delivery === "provider-observed" ||
channel.active?.lifecycle.delivery === "terminal" ||
(error.reason._tag === "Transport" && error.reason.kind === "queue-overflow")
? "accepted"
: "ambiguous",
}),
),
),
Effect.forkIn(scope, { startImmediately: true }),
)
yield* Effect.logDebug("session websocket connected", {
sessionTransport: "websocket",
phase: "connect",
})
return channel
}),
)
})
const fallback = (exchange: WebSocketChannelExchange): WebSocketChannelExecution => ({
frames: exchange.fallback(),
complete: Effect.void,
})
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
exchange: WebSocketChannelExchange,
lifecycle: { delivery: Delivery },
) {
const key = affinity(exchange)
const now = yield* Clock.currentTimeMillis
const current = owner.channel
const rotateAfterMs = exchange.connect.rotateAfterMs ?? ROTATE_AFTER_MS
const rotation = current
? current.poisoned
? "poisoned"
: current.affinity !== key
? "affinity"
: now - current.openedAt >= rotateAfterMs
? "age"
: undefined
: undefined
if (current && rotation) {
yield* Effect.logDebug("session websocket rotating", {
sessionTransport: "websocket",
phase: "connect",
reason: rotation,
})
yield* closeChannel(owner, current)
}
lifecycle.delivery = owner.channel ? "ready" : "connecting"
if (owner.channel)
yield* Effect.logDebug("session websocket reused", {
sessionTransport: "websocket",
phase: "connect",
})
const channel = owner.channel
? owner.channel
: yield* open(owner, exchange, key).pipe(
Effect.catch((error) =>
Effect.logWarning("session websocket connect failed; using http", {
sessionTransport: "websocket",
phase: "connect",
delivery: "not-sent",
kind: error.reason._tag === "Transport" ? error.reason.kind : error.reason._tag,
}).pipe(Effect.andThen(Effect.succeed(undefined))),
),
)
if (!channel) return fallback(exchange)
lifecycle.delivery = "ready"
const create = yield* exchange.driver.create(undefined).pipe(
Effect.tapError(() => closeChannel(owner, channel)),
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
channel.active = active
lifecycle.delivery = "send-attempted"
const sent = yield* channel.connection.sendText(create.message).pipe(
Effect.onInterrupt(() => closeChannel(owner, channel)),
Effect.result,
)
if (sent._tag === "Failure") {
const failure = sent.failure
const notSent = failure.reason._tag === "Transport" && failure.reason.delivery === "not-sent"
yield* closeChannel(owner, channel)
if (notSent) return fallback(exchange)
return yield* annotate(failure, { phase: "send", delivery: "ambiguous" })
}
let terminal = false
const frames = Stream.fromQueue(active.queue).pipe(
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
if (!observationTerminal(observation)) return
terminal = true
lifecycle.delivery = "terminal"
}),
),
Stream.takeUntil(observationTerminal),
Stream.mapEffect(observationFrame),
Stream.ensuring(
Effect.gen(function* () {
if (channel.active === active) channel.active = undefined
const pending = yield* Queue.size(active.queue)
yield* Queue.shutdown(active.queue)
if (terminal && pending === 0) return
const error = terminal
? transportError("receive", "WebSocket data arrived after the terminal event", {
url: exchange.connect.url,
kind: "idle-data",
phase: "receive",
delivery: "accepted",
})
: transportError("execute", "Session WebSocket exchange did not reach a terminal event", {
url: exchange.connect.url,
kind: "incomplete",
phase: "receive",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
})
yield* poison(owner, channel, error)
}),
),
)
return { frames, complete: Effect.void }
})
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
const lifecycle = { delivery: "queued" as Delivery }
return Effect.succeed({
frames: Stream.unwrap(
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
Effect.andThen(start(owner, exchange, lifecycle)),
Effect.map((execution) => execution.frames),
),
),
complete: Effect.void,
})
},
})
const close = Effect.fn("SessionModelTransport.close")(function* (sessionID: SessionSchema.ID) {
const owner = states.get(sessionID)
if (!owner) return
yield* owner.lock.withPermit(
Effect.gen(function* () {
if (owner.channel) yield* closeChannel(owner, owner.channel)
}),
)
})
const closeAll = Effect.forEach(states.values(), (owner) =>
owner.lock.withPermit(
Effect.gen(function* () {
if (owner.channel) yield* closeChannel(owner, owner.channel)
}),
),
).pipe(Effect.asVoid)
yield* Effect.addFinalizer(() => closeAll)
return Service.of({ bind, close, closeAll })
}),
)
export const layer = Layer.unwrap(
Effect.map(Socket.WebSocketConstructor, (constructor) =>
makeLayer({
open: (input) =>
WebSocketTransport.open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
}),
),
)
export const node = makeLocationNode({ service: Service, layer, deps: [webSocketConstructor] })
@@ -0,0 +1,471 @@
import { describe, expect, test } from "bun:test"
import { AIError, TransportReason } from "@opencode-ai/ai"
import type {
ChannelObservation,
WebSocketChannelExchange,
WebSocketConnection,
WebSocketConnector,
} from "@opencode-ai/ai/route"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { Session } from "@opencode-ai/schema/session"
import { Deferred, Effect, Fiber, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
const session = Session.ID.make("ses_transport")
const otherSession = Session.ID.make("ses_transport_other")
const queue = <A, E = never>() => Effect.runSync(Queue.unbounded<A, E>())
const error = (message: string, delivery?: TransportReason["delivery"]) =>
new AIError({
module: "test",
method: "websocket",
reason: new TransportReason({ message, phase: "send", delivery }),
})
const exchange = (
id: string,
input: {
readonly headers?: Record<string, string>
readonly fallback?: () => Stream.Stream<string, AIError>
readonly rotateAfterMs?: number
} = {},
): WebSocketChannelExchange => ({
id,
connect: {
url: "wss://provider.test/responses",
headers: Headers.fromInput(input.headers),
rotateAfterMs: input.rotateAfterMs,
},
fallback: input.fallback ?? (() => Stream.make(`fallback:${id}`)),
driver: {
create: () => Effect.succeed({ message: id, mode: "full" }),
observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> =>
Effect.succeed({ type: "completed", frame }),
},
})
const run = <A, E>(connector: WebSocketConnector, effect: Effect.Effect<A, E, SessionModelTransport.Service>) =>
Effect.runPromise(effect.pipe(Effect.provide(SessionModelTransport.makeLayer(connector)), Effect.scoped))
const collect = (executor: ReturnType<SessionModelTransport.Interface["bind"]>, item: WebSocketChannelExchange) =>
Effect.gen(function* () {
const execution = yield* executor.execute(item)
return Array.from(yield* Stream.runCollect(execution.frames))
}).pipe(Effect.scoped)
const automatic = () => {
const connections: Array<{
readonly messages: Queue.Queue<string | Uint8Array, AIError>
closed: number
sent: string[]
}> = []
const connector: WebSocketConnector = {
open: () =>
Effect.gen(function* () {
const messages = yield* Queue.unbounded<string | Uint8Array, AIError>()
const record = { messages, closed: 0, sent: [] as string[] }
connections.push(record)
const connection: WebSocketConnection = {
sendText: (message) =>
Effect.sync(() => {
record.sent.push(message)
Queue.offerUnsafe(messages, `completed:${message}`)
}),
messages: Stream.fromQueue(messages),
close: Effect.sync(() => {
record.closed++
}).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}
return connection
}),
}
return { connector, connections }
}
describe("SessionModelTransport", () => {
test("reuses one physical connection for sequential Session calls", async () => {
const fixture = automatic()
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
expect(yield* collect(transport.bind(session), exchange("first"))).toEqual(["completed:first"])
expect(yield* collect(transport.bind(session), exchange("second"))).toEqual(["completed:second"])
expect(fixture.connections).toHaveLength(1)
expect(fixture.connections[0]?.sent).toEqual(["first", "second"])
}),
)
})
test("serializes concurrent calls for one Session", async () => {
const started = Deferred.makeUnsafe<void>()
const release = Deferred.makeUnsafe<void>()
const messages = queue<string | Uint8Array, AIError>()
const sent: string[] = []
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: (message) =>
Effect.gen(function* () {
sent.push(message)
if (message === "first") {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}
Queue.offerUnsafe(messages, `completed:${message}`)
}),
messages: Stream.fromQueue(messages),
close: Queue.shutdown(messages).pipe(Effect.asVoid),
}),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
const first = yield* collect(executor, exchange("first")).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const second = yield* collect(executor, exchange("second")).pipe(Effect.forkChild({ startImmediately: true }))
yield* Effect.yieldNow
expect(sent).toEqual(["first"])
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
expect(sent).toEqual(["first", "second"])
}),
)
})
test("isolates connections and permits concurrency across Sessions", async () => {
const started = queue<string>()
const release = Deferred.makeUnsafe<void>()
let opened = 0
const connector: WebSocketConnector = {
open: () =>
Effect.gen(function* () {
opened++
const messages = yield* Queue.unbounded<string | Uint8Array, AIError>()
return {
sendText: (message) =>
Effect.gen(function* () {
Queue.offerUnsafe(started, message)
yield* Deferred.await(release)
Queue.offerUnsafe(messages, `completed:${message}`)
}),
messages: Stream.fromQueue(messages),
close: Queue.shutdown(messages).pipe(Effect.asVoid),
}
}),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const first = yield* collect(transport.bind(session), exchange("first")).pipe(
Effect.forkChild({ startImmediately: true }),
)
const second = yield* collect(transport.bind(otherSession), exchange("second")).pipe(
Effect.forkChild({ startImmediately: true }),
)
expect(new Set([yield* Queue.take(started), yield* Queue.take(started)])).toEqual(new Set(["first", "second"]))
expect(opened).toBe(2)
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
)
})
test("cancels a queued call without affecting the active exchange", async () => {
const started = Deferred.makeUnsafe<void>()
const release = Deferred.makeUnsafe<void>()
const messages = queue<string | Uint8Array, AIError>()
const sent: string[] = []
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: (message) =>
Effect.gen(function* () {
sent.push(message)
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
Queue.offerUnsafe(messages, `completed:${message}`)
}),
messages: Stream.fromQueue(messages),
close: Queue.shutdown(messages).pipe(Effect.asVoid),
}),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
const active = yield* collect(executor, exchange("active")).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(started)
const queued = yield* collect(executor, exchange("queued")).pipe(Effect.forkChild({ startImmediately: true }))
yield* Fiber.interrupt(queued)
expect(sent).toEqual(["active"])
yield* Deferred.succeed(release, undefined)
expect(yield* Fiber.join(active)).toEqual(["completed:active"])
}),
)
})
test("closes the connection when an active exchange is interrupted", async () => {
const started = Deferred.makeUnsafe<void>()
const messages = queue<string | Uint8Array, AIError>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
messages: Stream.fromQueue(messages),
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const fiber = yield* collect(transport.bind(session), exchange("first")).pipe(
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(closed).toBe(1)
}),
)
})
test("closes a newly opened connection when request creation is interrupted", async () => {
const opened = Deferred.makeUnsafe<void>()
const messages = queue<string | Uint8Array, AIError>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Deferred.succeed(opened, undefined).pipe(
Effect.as({
sendText: () => Effect.void,
messages: Stream.fromQueue(messages),
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const item = exchange("first")
const fiber = yield* collect(transport.bind(session), {
...item,
driver: { create: () => Effect.never, observe: item.driver.observe },
}).pipe(Effect.forkChild({ startImmediately: true }))
yield* Deferred.await(opened)
yield* Fiber.interrupt(fiber)
expect(closed).toBe(1)
}),
)
})
test("falls back once when connection setup fails before send", async () => {
let fallbacks = 0
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const result = yield* collect(
transport.bind(session),
exchange("first", {
fallback: () => {
fallbacks++
return Stream.make("http")
},
}),
)
expect(result).toEqual(["http"])
expect(fallbacks).toBe(1)
}),
)
})
test("does not fall back after an ambiguous send failure", async () => {
const messages = queue<string | Uint8Array, AIError>()
let fallbacks = 0
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: () => Effect.fail(error("send failed")),
messages: Stream.fromQueue(messages),
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const result = yield* Effect.result(
collect(
transport.bind(session),
exchange("first", {
fallback: () => {
fallbacks++
return Stream.make("http")
},
}),
),
)
expect(result).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", phase: "send", delivery: "ambiguous" } },
})
expect(fallbacks).toBe(0)
expect(closed).toBe(1)
}),
)
})
test("rotates when handshake affinity or connection age changes", async () => {
const fixture = automatic()
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
yield* collect(executor, exchange("first", { headers: { authorization: "one" } }))
yield* collect(executor, exchange("second", { headers: { authorization: "one" } }))
yield* collect(executor, exchange("third", { headers: { authorization: "two" } }))
yield* Effect.sleep("5 millis")
yield* collect(executor, exchange("fourth", { headers: { authorization: "two" }, rotateAfterMs: 1 }))
expect(fixture.connections).toHaveLength(3)
expect(fixture.connections.slice(0, 2).map((item) => item.closed)).toEqual([1, 1])
}),
)
})
test("poisons a socket that receives data while idle", async () => {
const fixture = automatic()
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
yield* collect(executor, exchange("first"))
const connection = fixture.connections[0]
if (!connection) throw new Error("Expected connection")
Queue.offerUnsafe(connection.messages, "late")
yield* Effect.yieldNow
yield* collect(executor, exchange("second"))
expect(fixture.connections).toHaveLength(2)
expect(fixture.connections[0]?.closed).toBe(1)
}),
)
})
test("poisons instead of dropping data when the inbound queue overflows", async () => {
const messages = queue<string | Uint8Array, AIError>()
let closed = 0
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: () =>
Effect.sync(() => {
for (let index = 0; index <= 129; index++) Queue.offerUnsafe(messages, `frame:${index}`)
}),
messages: Stream.fromQueue(messages),
close: Effect.sync(() => closed++).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const item = exchange("first")
const result = yield* Effect.result(
collect(transport.bind(session), {
...item,
driver: {
create: item.driver.create,
observe: (_create, frame) => Effect.sleep("1 millis").pipe(Effect.as({ type: "frame" as const, frame })),
},
}),
)
expect(result).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", kind: "queue-overflow", delivery: "accepted" } },
})
expect(closed).toBe(1)
}),
)
})
test("poisons unsupported binary frames after provider observation", async () => {
const messages = queue<string | Uint8Array, AIError>()
const closed = Deferred.makeUnsafe<void>()
const connector: WebSocketConnector = {
open: () =>
Effect.succeed({
sendText: () => Effect.sync(() => Queue.offerUnsafe(messages, new Uint8Array([1]))).pipe(Effect.asVoid),
messages: Stream.fromQueue(messages),
close: Deferred.succeed(closed, undefined).pipe(Effect.andThen(Queue.shutdown(messages)), Effect.asVoid),
}),
}
await run(
connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const result = yield* Effect.result(collect(transport.bind(session), exchange("first")))
expect(result).toMatchObject({
_tag: "Failure",
failure: { reason: { _tag: "Transport", kind: "message", delivery: "accepted" } },
})
yield* Deferred.await(closed)
}),
)
})
test("closes individual and all owned connections", async () => {
const fixture = automatic()
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
yield* collect(transport.bind(session), exchange("first"))
yield* collect(transport.bind(otherSession), exchange("second"))
yield* transport.close(session)
expect(fixture.connections.map((item) => item.closed)).toEqual([1, 0])
yield* transport.closeAll
expect(fixture.connections.map((item) => item.closed)).toEqual([1, 1])
}),
)
})
test("closes owned connections when the Location scope ends", async () => {
const fixture = automatic()
await run(
fixture.connector,
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
yield* collect(transport.bind(session), exchange("first"))
expect(fixture.connections[0]?.closed).toBe(0)
}),
)
expect(fixture.connections[0]?.closed).toBe(1)
})
})
+45 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect } from "bun:test"
import path from "node:path"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -9,8 +10,10 @@ import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { testEffect } from "./lib/effect"
const projects = Layer.succeed(
@@ -22,16 +25,33 @@ const projects = Layer.succeed(
commit: () => Effect.void,
}),
)
const closed: Session.ID[] = []
const transport = Layer.succeed(
SessionModelTransport.Service,
SessionModelTransport.Service.of({
bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }),
close: (sessionID) => Effect.sync(() => closed.push(sessionID)),
closeAll: Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionProjector.node,
SessionStore.node,
Session.node,
LocationServiceMap.node,
]),
[
[Project.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
[SessionModelTransport.node, transport],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const location = Location.Ref.make({ directory: AbsolutePath.make(import.meta.dir) })
describe("Session.remove", () => {
it.effect("removes a session and its children", () =>
@@ -39,10 +59,13 @@ describe("Session.remove", () => {
const session = yield* Session.Service
const parent = yield* session.create({ location })
const child = yield* session.create({ parentID: parent.id })
yield* (yield* LocationServiceMap.Service).contextEffect(location)
closed.length = 0
yield* session.remove(parent.id)
expect((yield* session.list()).data).toEqual([])
expect(closed).toEqual([parent.id, child.id])
expect(yield* Effect.result(session.get(parent.id))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.result(session.get(child.id))).toMatchObject({ _tag: "Failure" })
}),
@@ -60,3 +83,23 @@ describe("Session.remove", () => {
}),
)
})
describe("Session.move", () => {
it.effect("closes the source Location transport before moving", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const item = yield* sessions.create({ location })
yield* (yield* LocationServiceMap.Service).contextEffect(location)
closed.length = 0
const destination = AbsolutePath.make(path.dirname(import.meta.dir))
yield* sessions.move({
sessionID: item.id,
directory: destination,
})
expect(closed).toEqual([item.id])
expect((yield* sessions.get(item.id)).location.directory).toBe(destination)
}),
)
})