Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 3997706bc5 feat(core): add plan workspace directory 2026-08-20 14:11:43 -05:00
30 changed files with 238 additions and 1119 deletions
+4 -10
View File
@@ -188,14 +188,12 @@ const statusError =
// Classifies an HTTP failure captured outside the executor (for example by the
// AI SDK's own fetch) onto the same reason types and HttpContext that
// executor-driven requests produce. Callers may supply originating request
// details when their transport exposes them; language model calls otherwise
// default to POST with no request headers.
// executor-driven requests produce. The originating request is not available on
// that path, so the method is assumed (language model calls are always POST),
// request headers are empty.
export const classifyHttpFailure = (input: {
readonly message: string
readonly url: string
readonly method?: string | undefined
readonly requestHeaders?: Record<string, string> | undefined
readonly status?: number | undefined
readonly code?: string | undefined
readonly responseHeaders?: Record<string, string> | undefined
@@ -212,11 +210,7 @@ export const classifyHttpFailure = (input: {
retryAfterMs: retryAfter,
rateLimit,
http: new HttpContext({
request: new HttpRequestDetails({
method: input.method ?? "POST",
url: input.url,
headers: input.requestHeaders ?? {},
}),
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
response:
input.status === undefined
? undefined
@@ -2,7 +2,6 @@ import { Cause, Effect, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
import { classifyHttpFailure } from "../executor.js"
import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js"
import type {
@@ -32,18 +31,6 @@ type WebSocketConstructorWithHeaders = (
options?: { readonly headers?: Headers.Headers },
) => globalThis.WebSocket
interface UpgradeResponse extends AsyncIterable<unknown> {
readonly statusCode?: number
readonly headers: Readonly<Record<string, string | ReadonlyArray<string> | undefined>>
}
type UpgradeListener = (request: unknown, response: UpgradeResponse) => void
interface UpgradeAwareWebSocket extends globalThis.WebSocket {
readonly once?: (event: "unexpected-response", listener: UpgradeListener) => void
readonly off?: (event: "unexpected-response", listener: UpgradeListener) => void
}
const MAX_FRAME_BYTES = 16 * 1024 * 1024
const transportError = (
method: string,
@@ -104,59 +91,6 @@ const binaryMessage = (data: unknown) => {
return undefined
}
const upgradeHeaders = (headers: UpgradeResponse["headers"]) =>
Object.fromEntries(
Object.entries(headers).flatMap(([name, value]) => {
if (value === undefined) return []
return [[name, Array.isArray(value) ? value.join(", ") : String(value)]]
}),
)
const upgradeBody = (input: WebSocketRequest, response: UpgradeResponse) =>
Stream.fromAsyncIterable(response, (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to read WebSocket upgrade response", {
url: input.url,
operation: "read",
phase: "connect",
delivery: "not-sent",
}),
).pipe(
Stream.mapEffect((chunk) => {
if (typeof chunk === "string") return Effect.succeed(new TextEncoder().encode(chunk))
const binary = binaryMessage(chunk)
if (binary) return Effect.succeed(binary)
return Effect.fail(
transportError("open", "Unsupported WebSocket upgrade response body", {
url: input.url,
operation: "read",
phase: "connect",
delivery: "not-sent",
}),
)
}),
Stream.decodeText(),
Stream.runFold(
() => "",
(body, chunk) => body + chunk,
),
)
const rejectedUpgrade = (input: WebSocketRequest, response: UpgradeResponse, body: string | undefined) =>
new AIError({
module: "WebSocketConnector",
method: "open",
reason: classifyHttpFailure({
message: `WebSocket upgrade rejected with HTTP ${response.statusCode ?? "unknown"}`,
method: "GET",
url: input.url,
requestHeaders: { ...input.headers },
status: response.statusCode,
code: "UnexpectedServerResponse",
responseHeaders: upgradeHeaders(response.headers),
responseBody: body,
}),
})
const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
if (ws.readyState === globalThis.WebSocket.OPEN) return Effect.void
if (ws.readyState === globalThis.WebSocket.CLOSING || ws.readyState === globalThis.WebSocket.CLOSED) {
@@ -171,13 +105,10 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
)
}
return Effect.callback<void, AIError>((resume, signal) => {
const upgrade: UpgradeAwareWebSocket = ws
const unexpectedResponse = upgrade.once && upgrade.off ? upgrade : undefined
const cleanup = () => {
ws.removeEventListener("open", onOpen)
ws.removeEventListener("error", onError)
ws.removeEventListener("close", onClose)
unexpectedResponse?.off?.("unexpected-response", onUnexpectedResponse)
signal.removeEventListener("abort", onAbort)
}
const onAbort = () => {
@@ -216,25 +147,9 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
),
)
}
const onUnexpectedResponse: UpgradeListener = (_request, response) => {
cleanup()
resume(
upgradeBody(input, response).pipe(
Effect.catch(() => Effect.succeed(undefined)),
Effect.flatMap((body) => Effect.fail(rejectedUpgrade(input, response, body))),
Effect.ensuring(
Effect.sync(() => {
ws.addEventListener("error", () => {}, { once: true })
if (ws.readyState === globalThis.WebSocket.CONNECTING) ws.close()
}),
),
),
)
}
ws.addEventListener("open", onOpen, { once: true })
ws.addEventListener("error", onError, { once: true })
ws.addEventListener("close", onClose, { once: true })
unexpectedResponse?.once?.("unexpected-response", onUnexpectedResponse)
signal.addEventListener("abort", onAbort, { once: true })
})
}
-120
View File
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
@@ -67,53 +66,6 @@ const expectAIError = (error: unknown) => {
}
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
interface RejectedUpgradeResponse extends AsyncIterable<Uint8Array> {
readonly statusCode: number
readonly headers: Readonly<Record<string, string | undefined>>
}
type RejectedUpgradeListener = (request: unknown, response: RejectedUpgradeResponse) => void
class RejectedUpgradeSocket extends EventTarget {
readyState = globalThis.WebSocket.CONNECTING
listener?: RejectedUpgradeListener
constructor(
readonly response: { status: number; headers: Readonly<Record<string, string | undefined>>; body: string },
) {
super()
}
once(_event: "unexpected-response", listener: RejectedUpgradeListener) {
this.listener = listener
const body = this.response.body
queueMicrotask(() =>
listener(
{},
{
statusCode: this.response.status,
headers: this.response.headers,
async *[Symbol.asyncIterator]() {
const split = Math.floor(body.length / 2)
yield new TextEncoder().encode(body.slice(0, split))
yield new TextEncoder().encode(body.slice(split))
},
},
),
)
}
off(_event: "unexpected-response", listener: RejectedUpgradeListener) {
if (this.listener === listener) this.listener = undefined
}
close() {
this.readyState = globalThis.WebSocket.CLOSED
}
send() {}
}
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
describe("RequestExecutor", () => {
@@ -670,78 +622,6 @@ describe("WebSocket channel execution", () => {
}),
)
it.effect("preserves rejected upgrade diagnostics", () =>
Effect.gen(function* () {
const cases = [
{
status: 401,
headers: { "x-request-id": "req_401" },
body: '{"error":{"message":"invalid key"}}',
reason: { _tag: "Authentication", kind: "invalid" },
},
{
status: 403,
headers: { "x-request-id": "req_403" },
body: '{"error":{"message":"forbidden"}}',
reason: { _tag: "Authentication", kind: "insufficient-permissions" },
},
{
status: 429,
headers: {
"retry-after": "2",
"x-request-id": "req_429",
"x-ratelimit-limit-requests": "500",
},
body: '{"error":{"message":"rate limited"}}',
reason: { _tag: "RateLimit", retryAfterMs: 2_000 },
},
{
status: 503,
headers: { "retry-after-ms": "250", "x-request-id": "req_503" },
body: '{"error":{"message":"overloaded"}}',
reason: { _tag: "ProviderInternal", status: 503, retryAfterMs: 250 },
},
] as const
yield* Effect.forEach(
cases,
(item) =>
Effect.gen(function* () {
const socket = new RejectedUpgradeSocket(item)
const error = yield* WebSocketTransport.open({
url: "wss://api.openai.test/v1/responses",
headers: Headers.fromInput({ authorization: "Bearer secret", "x-client": "visible" }),
}).pipe(
Effect.provideService(Socket.WebSocketConstructor, () => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fixture implements the socket surface used by the connector.
return socket as unknown as globalThis.WebSocket
}),
Effect.flip,
)
expectAIError(error)
expect(error.reason).toMatchObject(item.reason)
expect(errorHttp(error)).toMatchObject({
request: {
method: "GET",
url: "wss://api.openai.test/v1/responses",
headers: { authorization: "Bearer secret", "x-client": "visible" },
},
response: { status: item.status, headers: item.headers },
body: item.body,
requestId: `req_${item.status}`,
...(item.status === 429
? { rateLimit: { retryAfterMs: 2_000, limit: { requests: "500" } } }
: item.status === 503
? { rateLimit: { retryAfterMs: 250 } }
: {}),
})
}),
{ discard: true },
)
}),
)
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))))
@@ -1,100 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"tool",
"continuation"
],
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
}
]
}
]
}
@@ -1,119 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"reconnect",
"full-context"
],
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
}
]
}
]
}
@@ -1,129 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "openai",
"protocol": "openai-responses",
"transport": "websocket",
"model": "gpt-5.5",
"tags": [
"prefix:openai-responses-websocket",
"provider:openai",
"protocol:openai-responses",
"transport:websocket",
"continuation",
"recovery"
],
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
"recordedAt": "2026-08-20T00:00:00.000Z"
},
"interactions": [
{
"transport": "websocket",
"connection": {
"sequence": 0,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
}
]
},
{
"transport": "websocket",
"connection": {
"sequence": 1,
"url": "wss://api.openai.com/v1/responses",
"protocols": [],
"close": {
"code": 1000,
"reason": ""
}
},
"events": [
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
},
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
},
{
"direction": "server",
"kind": "text",
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
}
]
}
]
}
@@ -1,216 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Socket } from "effect/unstable/socket"
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
import {
LLMClient,
WebSocketTransport,
type ChannelCheckpoint,
type ChannelObservation,
type WebSocketChannelExchange,
type WebSocketChannelExecutor,
type WebSocketConnection,
} from "../../src/route.js"
import { configure } from "../../src/providers/openai.js"
import { decodeJson } from "../../src/protocols/shared.js"
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
import { recordedTests } from "../recorded-test.js"
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
const recorded = recordedTests({
prefix: "openai-responses-websocket",
provider: "openai",
protocol: "openai-responses",
requires: ["OPENAI_API_KEY"],
tags: ["transport:websocket"],
metadata: { transport: "websocket", model: model.id },
})
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 terminal = (observation: ChannelObservation) => observation.type !== "frame"
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
const makeChannel = Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
let connection: WebSocketConnection | undefined
let checkpoint: ChannelCheckpoint | undefined
let pending: ChannelCheckpoint | undefined
let opens = 0
const sent: unknown[] = []
const close = Effect.suspend(() => {
const current = connection
connection = undefined
return current ? current.close : Effect.void
})
yield* Effect.addFinalizer(() => close)
const executor: WebSocketChannelExecutor = {
execute: (exchange: WebSocketChannelExchange) =>
Effect.gen(function* () {
if (!connection) {
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
Effect.provideService(Socket.WebSocketConstructor, constructor),
)
opens += 1
}
const current = connection
const create = yield* exchange.driver.create(checkpoint)
if (create.mode === "full") checkpoint = undefined
pending = undefined
sent.push(decodeJson(create.message))
yield* current.sendText(create.message)
const decoder = new TextDecoder()
return {
frames: current.messages.pipe(
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
if (!terminal(observation)) return
pending = observation.type === "completed" ? observation.checkpoint : undefined
if (observation.type !== "completed") checkpoint = undefined
}),
),
Stream.takeUntil(terminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.sync(() => {
checkpoint = pending
pending = undefined
}),
}
}),
}
return {
executor,
sent,
opens: () => opens,
reconnect: (preserveCheckpoint = false) =>
close.pipe(
Effect.andThen(
Effect.sync(() => {
pending = undefined
if (!preserveCheckpoint) checkpoint = undefined
}),
),
),
}
})
describe("OpenAI Responses WebSocket recorded", () => {
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_tool",
model,
system: "Call get_weather once, then reply exactly: Paris is sunny.",
prompt: "What is the weather in Paris?",
tools: [weatherTool],
generation: { maxTokens: 50 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const call = first.toolCalls[0]
if (!call) yield* Effect.die("Expected get_weather tool call")
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
Message.tool({ id: call.id, name: call.name, result: result.result }),
],
}),
{ webSocket: channel.executor },
)
expect(second.text).toBe("Paris is sunny.")
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
}),
)
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_reconnect",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Alpha.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
yield* channel.reconnect()
const second = yield* LLMClient.generate(
LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
}),
{ webSocket: channel.executor },
)
expect(first.text).toBe("Alpha.")
expect(second.text).toBe("Beta.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
],
})
}),
)
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
Effect.gen(function* () {
const channel = yield* makeChannel
const request = LLM.request({
id: "recorded_openai_responses_websocket_rejection",
model,
system: "Follow the user's exact reply instruction.",
prompt: "Reply exactly: Ready.",
generation: { maxTokens: 30 },
cache: "none",
})
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
const continuation = LLMRequest.update(request, {
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
})
yield* channel.reconnect(true)
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
expect(rejected).toMatchObject({
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
})
expect(recovered.text).toBe("Recovered.")
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
],
})
}),
)
})
+2 -10
View File
@@ -1,7 +1,5 @@
import { HttpRecorder } from "@opencode-ai/http-recorder"
import { NodeSocket } from "@effect/platform-node"
import { Layer } from "effect"
import { Socket } from "effect/unstable/socket"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor } from "../src/route.js"
@@ -18,7 +16,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -71,7 +69,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
...metadata,
}
if (recording) {
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
}
const requestExecutor = RequestExecutor.layer.pipe(
@@ -83,16 +81,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}),
),
)
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
...recorderOptions,
directory: FIXTURES_DIR,
metadata: recorderMetadata,
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
return Layer.mergeAll(
requestExecutor,
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
webSocket,
)
},
})
+11 -11
View File
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Command } from "@opencode-ai/schema/command"
import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
@@ -111,7 +111,7 @@ export class ToolCallError extends Schema.TaggedError<ToolCallError>()("MCP.Tool
type ServerEntry = {
readonly config: Mcp.ServerConfig
status: Status
readonly startup: Latch.Latch
readonly startup: Deferred.Deferred<void>
scope?: Scope.Closeable
client?: MCPClient.Connection
tools?: ReadonlyArray<Tool>
@@ -535,7 +535,7 @@ export const layer = (options?: Options) =>
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}).pipe(Effect.ensuring(entry.startup.open))
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
const scope = entry.scope
@@ -562,7 +562,7 @@ export const layer = (options?: Options) =>
const entry: ServerEntry = {
config: serverConfig,
status: { status: "pending" },
startup: Latch.makeUnsafe(),
startup: Deferred.makeUnsafe<void>(),
}
entries.set(name, entry)
yield* Effect.gen(function* () {
@@ -575,7 +575,7 @@ export const layer = (options?: Options) =>
yield* startServer(name, entry)
}).pipe(
// Settle startup even when registration fails or replacement is interrupted, so readers cannot hang.
Effect.ensuring(entry.startup.open),
Effect.ensuring(Effect.sync(() => Deferred.doneUnsafe(entry.startup, Exit.void))),
)
})
@@ -597,7 +597,7 @@ export const layer = (options?: Options) =>
entries.set(name, {
config: server,
status: { status: "pending" },
startup: Latch.makeUnsafe(),
startup: Deferred.makeUnsafe<void>(),
})
}
yield* Effect.forEach(entries, ([name, entry]) => register(name, entry), { discard: true })
@@ -607,7 +607,7 @@ export const layer = (options?: Options) =>
for (const [name, entry] of entries) {
if (entry.config.disabled) {
entry.status = { status: "disabled" }
entry.startup.openUnsafe()
Deferred.doneUnsafe(entry.startup, Exit.void)
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
continue
}
@@ -685,7 +685,7 @@ export const layer = (options?: Options) =>
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
const whenAllReady = Effect.suspend(() =>
Effect.forEach(Array.from(entries.values()), (entry) => entry.startup.await, {
Effect.forEach(Array.from(entries.values()), (entry) => Deferred.await(entry.startup), {
concurrency: "unbounded",
discard: true,
}),
@@ -734,7 +734,7 @@ export const layer = (options?: Options) =>
}),
callTool: Effect.fn("MCP.callTool")(function* (input) {
const target = yield* requireServer(input.server)
yield* target.entry.startup.await
yield* Deferred.await(target.entry.startup)
if (!target.entry.client)
return yield* new ToolCallError({
server: target.name,
@@ -773,7 +773,7 @@ export const layer = (options?: Options) =>
}),
prompt: Effect.fn("MCP.prompt")(function* (input) {
const target = yield* requireServer(input.server)
yield* target.entry.startup.await
yield* Deferred.await(target.entry.startup)
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.prompt({ name: input.name, args: input.args })
@@ -827,7 +827,7 @@ export const layer = (options?: Options) =>
}),
readResource: Effect.fn("MCP.readResource")(function* (input) {
const target = yield* requireServer(input.server)
yield* target.entry.startup.await
yield* Deferred.await(target.entry.startup)
if (!target.entry.client) return undefined
const result = yield* target.entry.client
.readResource({ uri: input.uri })
+56 -16
View File
@@ -2,14 +2,19 @@ export * as PlanPlugin from "./plan.js"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Patch } from "@opencode-ai/util/patch"
import { Effect, Result, Stream } from "effect"
import path from "path"
import { Agent } from "../agent.js"
import { Environment } from "../environment/index.js"
import { SessionEvent } from "../session/event.js"
const plan = Agent.ID.make("plan")
const enter = `<system-reminder>
You are in Plan mode. You are not allowed to edit or create files, and you may not ask a subagent to do that either.
const enter = (directory: string) => `<system-reminder>
You are in Plan mode. You may only edit or create files in the Plan directory: ${directory}
You may not modify files outside that directory, and you may not ask a subagent to do that either.
You are in Plan mode until the user switches agents. Plan mode is not changed by user intent, tone, or imperative language. If the user asks you to change files, do not edit. Tell them they need to switch agents.
</system-reminder>`
@@ -21,6 +26,12 @@ You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply.
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
const environment = yield* Environment.Service
const global = yield* Global.Service
const directory = path.join(global.home, ".opencode", "plan")
const enterReminder = enter(directory)
yield* environment.files.mkdir(directory).pipe(Effect.orDie)
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
@@ -33,18 +44,20 @@ export const Plugin = define({
yield* ctx.tool.hook("execute.before", (event) => {
if (event.agent !== plan) return Effect.void
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
const outside = mutationPaths(event.tool, event.input).find((target) => !contained(directory, target))
if (!outside) return Effect.void
return new ToolFailure({
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
message: `Cannot use ${event.tool} to modify ${outside} in Plan mode. You can only edit files in the Plan directory: ${directory}`,
})
})
// Compaction and committed reverts can strip reminders while the session's agent stays
// put. Reconcile per request, appending near the tail so the cached prefix stays warm.
yield* ctx.session.hook("context", (event) => {
const reminder = lastReminder(event.messages)
const missing = event.agent === plan && reminder !== enter
const stale = event.agent !== plan && reminder === enter
const text = missing ? enter : stale ? leave : undefined
const reminder = lastReminder(event.messages, enterReminder)
const missing = event.agent === plan && reminder !== enterReminder
const stale = event.agent !== plan && reminder === enterReminder
const text = missing ? enterReminder : stale ? leave : undefined
if (!text) return Effect.void
// Before the user's prompt, matching where agent-switch reminders land.
const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length
@@ -64,7 +77,7 @@ export const Plugin = define({
event.type === "session.created" || event.type === "session.agent.selected",
),
Stream.runForEach((event) => {
const text = switchReminder(event)
const text = switchReminder(event, enterReminder)
if (!text) return Effect.void
return ctx.session
.synthetic({
@@ -83,20 +96,47 @@ export const Plugin = define({
}),
})
function switchReminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
function switchReminder(
event: SessionEvent.Created | SessionEvent.AgentSelected,
enterReminder: string,
): string | undefined {
if (event.type === "session.created") {
if (event.data.agent !== plan) return
return enter
if (event.data.agent !== plan) return undefined
return enterReminder
}
if (event.data.agent === event.data.previous) return
if (event.data.agent === plan) return enter
if (event.data.agent === event.data.previous) return undefined
if (event.data.agent === plan) return enterReminder
if (event.data.previous === plan) return leave
return undefined
}
function lastReminder(messages: ReadonlyArray<Message>) {
function lastReminder(messages: ReadonlyArray<Message>, enterReminder: string) {
return messages.reduce<string | undefined>((found, message) => {
const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined
if (part?.type !== "text") return found
return part.text === enter || part.text === leave ? part.text : found
return part.text === enterReminder || part.text === leave ? part.text : found
}, undefined)
}
function mutationPaths(tool: "edit" | "write" | "patch", input: unknown) {
if (typeof input !== "object" || input === null) return []
if (tool !== "patch") {
const target = Reflect.get(input, "path")
return typeof target === "string" ? [path.resolve(target)] : []
}
const patchText = Reflect.get(input, "patchText")
if (typeof patchText !== "string") return []
const parsed = Patch.parse(patchText)
if (Result.isFailure(parsed)) return []
return parsed.success.flatMap((hunk) => [
path.resolve(hunk.path),
...(hunk.type === "update" && hunk.movePath ? [path.resolve(hunk.movePath)] : []),
])
}
function contained(directory: string, target: string) {
const relative = path.relative(directory, target)
return (
relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative))
)
}
+5 -5
View File
@@ -3,7 +3,7 @@ export { Service, type Interface } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
import { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
const sdk = yield* SdkPlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const ready = yield* Latch.make()
const ready = { current: yield* Deferred.make<void>() }
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
@@ -164,7 +164,7 @@ export const layer = Layer.effect(
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
yield* ready.close
if (yield* Deferred.isDone(ready.current)) ready.current = yield* Deferred.make<void>()
return observed
}),
),
@@ -176,12 +176,12 @@ export const layer = Layer.effect(
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate()
if (observed === target) yield* ready.open
if (observed === target) yield* Deferred.succeed(ready.current, undefined)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: ready.await })
return Service.of({ flush: Effect.suspend(() => Deferred.await(ready.current)) })
}),
)
+4 -16
View File
@@ -116,19 +116,8 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const truncateToolOutput = (value: string) => {
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
let end = 0
for (let count = 0; count < TOOL_OUTPUT_MAX_CHARS && end < value.length; count++) {
const code = value.charCodeAt(end)
end +=
code >= 0xd800 && code <= 0xdbff && value.charCodeAt(end + 1) >= 0xdc00 && value.charCodeAt(end + 1) <= 0xdfff
? 2
: 1
}
if (end === value.length) return value
return `${value.slice(0, end)}\n[truncated]`
}
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
content
@@ -157,7 +146,7 @@ const serialize = (message: SessionMessage.Info) => {
if (part.state.status === "completed")
return [
`[Assistant tool call]: ${part.name}(${input})`,
`[Tool result]: ${truncateToolOutput(serializeToolContent(part.state.content))}`,
`[Tool result]: ${truncate(serializeToolContent(part.state.content))}`,
]
if (part.state.status === "error")
return [`[Assistant tool call]: ${part.name}(${input})`, `[Tool error]: ${part.state.error.message}`]
@@ -168,8 +157,7 @@ const serialize = (message: SessionMessage.Info) => {
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell")
return `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}`
return ""
}
+1 -9
View File
@@ -89,15 +89,7 @@ const classifyToolExits = (
.flatMap((cause) => {
if (Cause.hasInterrupts(cause)) return []
const reasons = cause.reasons.flatMap(
(reason): Array<Cause.Reason<never>> =>
Cause.isFailReason(reason)
? isDecline(reason.error)
? []
: // A typed failure here broke the ExecuteError contract (the per-fiber
// `catchTag("Tool.Error")` consumes honest ones). Surfacing it as a defect
// keeps it from being dropped, which would leave its call unsettled forever.
[Cause.makeDieReason(reason.error)]
: [reason],
(reason): Array<Cause.Reason<never>> => (Cause.isFailReason(reason) ? [] : [reason]),
)
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
})
+5 -5
View File
@@ -1,7 +1,7 @@
export * as Shell from "./shell.js"
import path from "path"
import { Context, Deferred, Duration, Effect, Fiber, Latch, Layer, Schema, Schedule, Stream } from "effect"
import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Schedule, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
@@ -286,7 +286,7 @@ const layer = () =>
sessions.set(id, session)
const stream = createWriteStream(file)
const outputDone = Latch.makeUnsafe()
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
@@ -304,8 +304,8 @@ const layer = () =>
stream.end(() => resolve())
}),
)
yield* outputDone.open
}).pipe(Effect.catch(() => outputDone.open)),
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
@@ -324,7 +324,7 @@ const layer = () =>
draft.time.completed = Date.now()
})
yield* beforeWait
yield* outputDone.await
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
+44 -63
View File
@@ -2,6 +2,7 @@ export * as ShellTool from "./shell.js"
import path from "path"
import { ToolFailure } from "@opencode-ai/ai"
import type { Content } from "@opencode-ai/schema/tool"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js"
@@ -71,27 +72,10 @@ const Output = Schema.Struct({
type Output = typeof Output.Type
const resultMessages = (output: Output) => {
const notice = (() => {
if (output.status === "running") return BACKGROUND_INSTRUCTION
if (output.timeout) return "Command timed out before completion."
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
})()
return [output.output, ...(notice ? [notice] : [])]
}
const toolResult = (output: Output) => {
return {
output,
content: resultMessages(output).map((text) => ({ type: "text" as const, text })),
metadata: {
status: output.status,
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.shellID !== undefined ? { shellID: output.shellID } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
const modelOutput = (output: Output): string | undefined => {
if (output.status === "running") return BACKGROUND_INSTRUCTION
if (output.timeout) return "Command timed out before completion."
return `Command exited with code ${output.exit}.`
}
export const Plugin = {
@@ -108,50 +92,32 @@ export const Plugin = {
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
id: string,
shellID: string,
command: string,
settled: Deferred.Deferred<Output>,
) {
yield* runtime.job.wait({ id: id }).pipe(
Effect.flatMap((result) =>
Effect.gen(function* () {
const info = result.info
if (!info) return
const state =
info.status === "completed"
? "completed"
: info.status === "error"
? "error"
: info.status === "cancelled"
? "cancelled"
: undefined
if (state === undefined) return
const output = state === "completed" ? yield* Deferred.await(settled) : undefined
const text = output
? resultMessages(output).join("\n\n")
Effect.flatMap((result) => {
const state =
result.info?.status === "completed"
? "completed"
: result.info?.status === "error"
? "error"
: result.info?.status === "cancelled"
? "cancelled"
: undefined
if (state === undefined) return Effect.void
const text =
state === "completed"
? (result.info!.output ?? "")
: state === "error"
? (info.error ?? "Command failed")
? (result.info!.error ?? "Command failed")
: "Command cancelled"
yield* runtime.session.synthetic({
sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: {
source: "shell",
jobID: id,
shellID,
state,
...(output
? {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
: {}),
},
})
}),
),
return runtime.session.synthetic({
sessionID,
text: `<shell id="${id}" state="${state}" command="${command}">\n${text}\n</shell>`,
description: command,
metadata: { source: "shell", jobID: id, state },
})
}),
Effect.forkIn(scope, { startImmediately: true }),
)
})
@@ -302,7 +268,7 @@ export const Plugin = {
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -316,7 +282,7 @@ export const Plugin = {
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
yield* notifyWhenDone(context.sessionID, context.id, info.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
@@ -330,7 +296,22 @@ export const Plugin = {
return yield* Deferred.await(settled)
}).pipe(
Effect.map(toolResult),
Effect.map((output) => {
const content: Array<Content> = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) content.push({ type: "text", text: model })
return {
output,
content,
metadata: {
status: output.status,
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
...("timeout" in output && output.timeout !== undefined ? { timeout: output.timeout } : {}),
},
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
+1 -14
View File
@@ -13,20 +13,7 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
Effect.gen(function* () {
const decoded = yield* decodeInput(tool.input, input)
// Tool implementations declare `Tool.Error` but plugins can fail with anything at
// runtime. A foreign typed failure would slip past every `catchTag("Tool.Error")`
// downstream and leave its call permanently unsettled, so the declared contract is
// enforced here at the untrusted boundary. Declines tunnel through as defects and
// interrupts are not errors; neither is touched.
const result = yield* tool.execute(decoded, context).pipe(
Effect.mapError((error: unknown) =>
error instanceof Tool.Error
? error
: new Tool.Error({
message: error instanceof globalThis.Error ? error.message : String(error),
}),
),
)
const result = yield* tool.execute(decoded, context)
if (tool.output === undefined) {
if ("output" in result) return yield* Effect.die("Tool result declared output without an output schema")
return {
+81 -2
View File
@@ -2,7 +2,9 @@ import { describe, expect } from "bun:test"
import { Message } from "@opencode-ai/ai"
import { DateTime, Effect, Stream } from "effect"
import type { SessionContext } from "@opencode-ai/plugin/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
import { Agent } from "@opencode-ai/core/agent"
import { Environment } from "@opencode-ai/core/environment/index"
import { Event } from "@opencode-ai/schema/event"
import { Model } from "@opencode-ai/core/model"
import { PlanPlugin } from "@opencode-ai/core/plugin/plan"
@@ -11,12 +13,17 @@ import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/schema/tool"
import { Global } from "@opencode-ai/util/global"
import path from "path"
import { it } from "../lib/effect"
import { host } from "./host"
const sessionID = Session.ID.make("ses_plan_test")
const plan = Agent.ID.make("plan")
const build = Agent.ID.make("build")
const home = "/home/plan-test"
const planDirectory = path.join(home, ".opencode", "plan")
const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentSelected => ({
id: Event.ID.create(),
@@ -30,6 +37,8 @@ const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentS
const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.AgentSelected> = []) {
const persisted = new Array<string>()
let contextHook: ((input: SessionContext) => Effect.Effect<void>) | undefined
let toolHook: ((input: ToolHooks["execute.before"]) => Effect.Effect<void, Tool.Error>) | undefined
const driver = Environment.makeMemoryDriver()
yield* PlanPlugin.Plugin.effect(
host({
agent: {
@@ -40,7 +49,14 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
},
tool: {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.succeed({ dispose: Effect.void }),
hook: (name, callback) => {
if (name === "execute.before") {
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
toolHook = callback as unknown as (input: ToolHooks["execute.before"]) => Effect.Effect<void, Tool.Error>
}
return Effect.succeed({ dispose: Effect.void })
},
},
event: {
subscribe: () => Stream.fromIterable(events),
@@ -65,9 +81,16 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
},
},
}),
).pipe(
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
Effect.provideService(
Environment.Service,
Environment.Service.of({ files: Environment.makeFiles(driver), spawner: driver.spawner }),
),
)
if (!contextHook) return yield* Effect.die("plan plugin did not register a context hook")
return { persisted, contextHook }
if (!toolHook) return yield* Effect.die("plan plugin did not register a tool hook")
return { persisted, contextHook, toolHook, files: Environment.makeFiles(driver) }
})
const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => ({
@@ -79,6 +102,15 @@ const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => (
tools: {},
})
const toolRequest = (tool: "edit" | "write" | "patch", input: unknown): ToolHooks["execute.before"] => ({
tool,
input,
sessionID,
agent: plan,
messageID: SessionMessage.ID.make("msg_plan_tool"),
id: Tool.CallID.make("call_plan_tool"),
})
const settle = (persisted: ReadonlyArray<string>, expected: number, remaining = 1000): Effect.Effect<void, Error> =>
Effect.gen(function* () {
if (persisted.length >= expected) return
@@ -104,6 +136,7 @@ describe("plan plugin reminders", () => {
const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
yield* settle(persisted, 2)
expect(persisted[0]).toContain("You are in Plan mode")
expect(persisted[0]).toContain(planDirectory)
expect(persisted[1]).toContain("NO LONGER in Plan mode")
}),
)
@@ -178,3 +211,49 @@ describe("plan plugin reminders", () => {
}),
)
})
describe("plan plugin mutations", () => {
it.effect("creates the Plan directory", () =>
Effect.gen(function* () {
const { files } = yield* run()
expect((yield* files.stat(planDirectory)).type).toBe("directory")
}),
)
it.effect("allows edit and write inside the Plan directory", () =>
Effect.gen(function* () {
const { toolHook } = yield* run()
yield* toolHook(toolRequest("write", { path: path.join(planDirectory, "work.md") }))
yield* toolHook(toolRequest("edit", { path: path.join(planDirectory, "work.md") }))
}),
)
it.effect("rejects edit and write outside the Plan directory with the allowed path", () =>
Effect.gen(function* () {
const { toolHook } = yield* run()
for (const tool of ["edit", "write"] as const) {
const failure = yield* toolHook(toolRequest(tool, { path: "/workspace/source.ts" })).pipe(Effect.flip)
expect(failure.message).toContain("You can only edit files in the Plan directory")
expect(failure.message).toContain(planDirectory)
}
}),
)
it.effect("rejects a patch when any target is outside the Plan directory", () =>
Effect.gen(function* () {
const { toolHook } = yield* run()
const failure = yield* toolHook(
toolRequest("patch", {
patchText: `*** Begin Patch
*** Add File: ${path.join(planDirectory, "work.md")}
+plan
*** Add File: /workspace/source.ts
+code
*** End Patch`,
}),
).pipe(Effect.flip)
expect(failure.message).toContain("/workspace/source.ts")
expect(failure.message).toContain(planDirectory)
}),
)
})
@@ -110,13 +110,6 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64)
})
test("compaction truncation does not split surrogate pairs", () => {
const prefix = "a".repeat(1_999)
expect(SessionCompaction.truncateToolOutput(`${prefix}😀suffix`)).toBe(`${prefix}😀\n[truncated]`)
expect(SessionCompaction.truncateToolOutput("😀".repeat(2_000))).toBe("😀".repeat(2_000))
})
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
@@ -34,16 +34,13 @@ const exchange = (server: WebSocketServerFixture, id: string): WebSocketChannelE
const withServer = <A>(
options: WebSocketServerOptions,
effect: (
server: WebSocketServerFixture,
constructor: Socket.WebSocketConstructor["Service"],
) => Effect.Effect<A, unknown, SessionModelTransport.Service>,
effect: (server: WebSocketServerFixture) => Effect.Effect<A, unknown, SessionModelTransport.Service>,
) =>
Effect.runPromise(
Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
const server = yield* makeWebSocketServer(options)
return yield* effect(server, constructor).pipe(
return yield* effect(server).pipe(
Effect.provide(
SessionModelTransport.makeLayer({
open: (input) =>
@@ -296,27 +293,8 @@ describe("SessionModelTransport local WebSocket server", () => {
)
})
test("preserves a real rejected upgrade response", async () => {
await withServer({ upgrade: () => false }, (server, constructor) =>
Effect.gen(function* () {
const error = yield* WebSocketTransport.open({ url: server.url, headers: Headers.empty }).pipe(
Effect.provideService(Socket.WebSocketConstructor, constructor),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "UnknownProvider",
status: 426,
http: {
request: { method: "GET", url: server.url },
response: { status: 426, headers: { "x-upgrade-rejected": "true" } },
body: "WebSocket upgrade required",
},
})
}),
)
})
// Effect's browser-compatible constructor does not expose upgrade response bodies or headers.
// The real 426 fixture therefore pins the observable contract: a not-sent connect failure and one HTTP fallback.
test("falls back once after a real rejected upgrade", async () => {
let fallbacks = 0
await withServer({ upgrade: () => false }, (server) =>
-18
View File
@@ -94,24 +94,6 @@ test("declared outputs cannot bypass validation and raw outputs stay JSON-compat
)
})
test("foreign typed failures settle as Tool.Error at the untrusted boundary", async () => {
class ForeignFailure extends Schema.TaggedError<ForeignFailure>()("Plugin.ForeignFailure", {
message: Schema.String,
}) {}
const lying: Info = {
name: "lying",
description: "Fails with a non-Tool.Error typed failure",
input: Schema.Struct({}),
execute: () => new ForeignFailure({ message: "transport died" }) as never,
}
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
expect(exit._tag).toBe("Failure")
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
expect(error).toBeInstanceOf(Tool.Error)
expect((error as Tool.Error).message).toBe("transport died")
})
test("execute supports callable namespace tools", async () => {
const callable: Info = {
name: "admin",
-42
View File
@@ -760,52 +760,10 @@ describe("ShellTool", () => {
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
expect((yield* shell.wait(id)).status).toBe("timeout")
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
text: expect.stringContaining("Command timed out before completion."),
description: idleCommand,
metadata: {
source: "shell",
shellID,
state: "completed",
timeout: true,
truncated: false,
},
})
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("preserves a background command's non-zero exit", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.type === "synthetic"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
const settled = yield* executeTool(
registry,
call({ command: bodyExitCommand, background: true }, "call-background-nonzero"),
)
const shellID = settled.metadata?.shellID
expect(typeof shellID).toBe("string")
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
text: expect.stringContaining("Command exited with code 7."),
description: bodyExitCommand,
metadata: {
source: "shell",
jobID: "call-background-nonzero",
shellID,
state: "completed",
exit: 7,
truncated: false,
},
})
}),
@@ -31,11 +31,8 @@ interface PendingRecordings {
}
type Frame = string | Uint8Array
const normalizeProtocols = (protocols: unknown): Array<string> => {
if (typeof protocols === "string") return [protocols]
if (Array.isArray(protocols)) return protocols.filter((protocol): protocol is string => typeof protocol === "string")
return []
}
const normalizeProtocols = (protocols?: string | Array<string>): Array<string> =>
protocols === undefined ? [] : typeof protocols === "string" ? [protocols] : [...protocols]
const frameFromWebSocketData = async (data: unknown): Promise<Frame> => {
if (typeof data === "string") return data
if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer())
@@ -374,7 +371,7 @@ const makeRecordingWebSocketConstructor = (
return (url, protocols) => {
const sequence = nextSequence++
const requestedProtocols = normalizeProtocols(protocols)
const native = Reflect.apply(upstream, undefined, [url, protocols])
const native = upstream(url, requestedProtocols)
const events: WebSocketEvent[] = []
let opened = false
let failed = false
@@ -80,38 +80,6 @@ describe("WebSocket", () => {
])
})
test("constructor recording forwards handshake options", async () => {
using directory = tempDirectory("http-recorder-websocket-constructor-")
let received: unknown
const recorder = HttpRecorder.layerWebSocketConstructor("websocket/constructor-options", {
directory: directory.path,
}).pipe(
Layer.provide(
Layer.succeed(Socket.WebSocketConstructor, (url, options) => {
received = options
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the fixture implements the WebSocket surface used by the recorder.
return new EchoWebSocket(url) as unknown as globalThis.WebSocket
}),
),
)
await Effect.runPromise(
Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
const options = { headers: { authorization: "Bearer fixture" } }
const socket = Reflect.apply(constructor, undefined, ["wss://echo.example.test/options", options])
yield* Effect.callback<void>((resume) => {
socket.addEventListener("open", () => {
socket.close()
resume(Effect.void)
})
})
}).pipe(Effect.scoped, Effect.provide(recorder)),
)
expect(received).toEqual({ headers: { authorization: "Bearer fixture" } })
})
test("constructor replay validates dynamic URLs and protocols without opening a live socket", async () => {
using directory = tempDirectory("http-recorder-websocket-constructor-")
await seedCassetteDirectory(directory.path, "websocket/constructor", [
+5 -5
View File
@@ -3,7 +3,7 @@ export * as ServerProcess from "./process"
import { NodeHttpServer } from "@effect/platform-node"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
@@ -47,7 +47,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const hostname = options.hostname ?? "127.0.0.1"
const port = Option.fromNullishOr(options.port)
const shutdown = yield* Latch.make()
const shutdown = yield* Deferred.make<void>()
const status = yield* Status.make()
const bound = yield* listen({ hostname, port })
const application = yield* Ref.make(Option.none<App>())
@@ -61,7 +61,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
)
.pipe(withoutParentSpan)
if (lifecycle)
yield* lifecycle.onListen(bound.http.address, shutdown.open.pipe(Effect.asVoid)).pipe(
yield* lifecycle.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe(
Effect.flatMap((cleanup) =>
Effect.addFinalizer(() => Scope.close(bound.scope, Exit.void).pipe(Effect.andThen(cleanup))),
),
@@ -101,7 +101,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect()
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* status.ready
return { address: bound.http.address, shutdown: shutdown.await }
return { address: bound.http.address, shutdown: Deferred.await(shutdown) }
}).pipe(
Effect.catchCause((cause) => {
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
@@ -119,7 +119,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
}),
)
if (!lifecycle) return yield* boot
return yield* Effect.raceFirst(boot, shutdown.await.pipe(Effect.andThen(Effect.interrupt)))
return yield* Effect.raceFirst(boot, Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
})
function listen(options: { readonly hostname: string; readonly port: Option.Option<number> }) {
+4 -4
View File
@@ -1,6 +1,6 @@
import { render, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "./component/register-spinner"
import { Effect, Latch } from "effect"
import { Deferred, Effect } from "effect"
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type SessionInfo } from "@opencode-ai/client"
import { Global } from "@opencode-ai/util/global"
@@ -274,13 +274,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
.forEach((result) => log("error", "Failed to dispose TUI resource", { error: result.reason }))
}),
)
const shutdown = yield* Latch.make()
const shutdown = yield* Deferred.make<unknown>()
const onSighup = () => destroyRenderer(renderer)
yield* Effect.acquireRelease(
Effect.sync(() => process.on("SIGHUP", onSighup)),
() => Effect.sync(() => process.off("SIGHUP", onSighup)),
)
renderer.once("destroy", () => shutdown.openUnsafe())
renderer.once("destroy", () => Deferred.doneUnsafe(shutdown, Effect.void))
yield* Effect.tryPromise(async () => {
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
void renderer.getPalette({ size: 16 }).catch(() => undefined)
@@ -443,7 +443,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
renderer.requestRender()
}
})
yield* shutdown.await
yield* Deferred.await(shutdown)
return { epilogue: exit.epilogue, reason: exit.reason }
}),
)
+1 -1
View File
@@ -189,7 +189,7 @@ export const Definitions = {
"prompt.clear": keybind("ctrl+c", "Clear input field"),
"prompt.paste": keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
"input.submit": keybind("return", "Submit input"),
"input.newline": keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
"input.newline": keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
"input.move.left": keybind("left,ctrl+b", "Move cursor left in input"),
"input.move.right": keybind("right,ctrl+f", "Move cursor right in input"),
"input.move.up": keybind("up", "Move cursor up in input"),
+1 -1
View File
@@ -174,7 +174,7 @@ export const Definitions = {
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_newline: keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
+5 -23
View File
@@ -121,14 +121,7 @@ const TRANSCRIPT_BACKFILL_CHUNK = 60
type PendingAction = "steer" | "queue" | "cancel"
const context = createContext<{
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
width: number
/**
* Shared reactive terminal size. Transcript-row components must read this
* instead of calling useTerminalDimensions(), which registers one renderer
* resize listener per mounted component and grows with transcript length.
*/
terminal: { width: number; height: number }
sessionID: string
thinkingMode: () => ThinkingMode
showThinking: () => boolean
@@ -1131,25 +1124,12 @@ export function Session(props: { verticalTabsWidth: number }) {
),
)
// Memoized per axis so width readers do not re-run on height-only resizes
// (dimensions() is one object signal with identity equality) and vice versa.
const terminalWidth = createMemo(() => dimensions().width)
const terminalHeight = createMemo(() => dimensions().height)
return (
<context.Provider
value={{
get width() {
return contentWidth()
},
terminal: {
get width() {
return terminalWidth()
},
get height() {
return terminalHeight()
},
},
sessionID: route.sessionID,
thinkingMode,
showThinking,
@@ -1825,6 +1805,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
const ctx = use()
const data = useData()
const local = useLocal()
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const model = createMemo(
() =>
@@ -1848,10 +1829,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
{Locale.titlecase(props.message.agent)}
</span>
<Show when={ctx.terminal.width >= 28}>
<Show when={dimensions().width >= 28}>
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
</Show>
<Show when={duration() && (ctx.terminal.width < 28 || ctx.terminal.width >= 36)}>
<Show when={duration() && (dimensions().width < 28 || dimensions().width >= 36)}>
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
</Show>
<Show when={interrupted()}>
@@ -2540,8 +2521,9 @@ function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
function SessionImages(props: { images: readonly { uri: string }[]; paddingLeft?: number }) {
const ctx = use()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : []))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(ctx.terminal.height / 4))))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const visible = createMemo(() => images().slice(0, 3))
return (
@@ -107,29 +107,6 @@ test("dialog prompt submit wins when return is also input newline", async () =>
}
})
test("alt return inserts a newline with default keybinds", async () => {
await using tmp = await tmpdir()
const confirmed: string[] = []
const prompt = await mountPrompt({
root: tmp.path,
keybinds: {},
onConfirm: (value) => confirmed.push(value),
})
try {
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
const textarea = prompt.app.renderer.currentFocusedEditor
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
prompt.app.mockInput.pressEnter({ meta: true })
expect(confirmed).toEqual([])
expect(textarea.plainText).toBe("draft\n")
} finally {
await prompt.cleanup()
}
})
test("dialog prompt submit can be rebound separately from input submit", async () => {
await using tmp = await tmpdir()
const confirmed: string[] = []
+1 -1
View File
@@ -21,7 +21,7 @@ describe("run runtime boot", () => {
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("down")
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+c")
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("return")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,alt+return,ctrl+j")
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("shift+return,ctrl+return,ctrl+j")
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("<leader>return")
})