mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a02790945 |
@@ -1073,7 +1073,13 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
|
||||
}
|
||||
|
||||
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const code =
|
||||
event.code ||
|
||||
event.error?.code ||
|
||||
event.error?.type ||
|
||||
event.response?.error?.code ||
|
||||
event.response?.error?.type ||
|
||||
undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
@@ -1084,7 +1090,7 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
return new AIError({
|
||||
module: id,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message, code, status }),
|
||||
reason: classifyProviderFailure({ message, code, status, stream: true }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -74,9 +74,11 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const TRANSIENT_TEXT = /\btry again (?:later|in\b)|\b(?:currently|temporarily) at capacity\b|\bretry your request\b/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly stream?: boolean | undefined
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
@@ -133,6 +135,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (TRANSIENT_TEXT.test(text))
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
if (input.status === 429) {
|
||||
return new RateLimitReason({
|
||||
...common,
|
||||
@@ -149,6 +157,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||
return new InvalidRequestReason(common)
|
||||
if (input.stream)
|
||||
return new ProviderInternalReason({
|
||||
...common,
|
||||
status: input.status,
|
||||
retryAfterMs: input.retryAfterMs,
|
||||
})
|
||||
return new UnknownProviderReason({ ...common, status: input.status })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))))
|
||||
|
||||
-100
@@ -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\"}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
-119
@@ -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\"}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
-129
@@ -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\"}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -75,6 +75,26 @@ describe("provider error classification", () => {
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient gateway message fallbacks as provider internal", () => {
|
||||
expect(
|
||||
[
|
||||
"The model is currently at capacity due to high demand.",
|
||||
"The service is temporarily at capacity.",
|
||||
"Please try again in a few minutes.",
|
||||
"Please retry your request shortly.",
|
||||
].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies provider stream errors as provider internal", () => {
|
||||
expect(
|
||||
[
|
||||
"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing: https://docs.x.ai/developers/advanced-api-usage/priority-processing",
|
||||
"The model is temporarily unavailable.",
|
||||
].map((message) => classifyProviderFailure({ message, stream: true })._tag),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
|
||||
@@ -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." }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -809,7 +809,7 @@ describe("OpenAI Responses route", () => {
|
||||
"ProviderInternal",
|
||||
"RateLimit",
|
||||
"ProviderInternal",
|
||||
"UnknownProvider",
|
||||
"ProviderInternal",
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2661,6 +2661,19 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies code-less capacity errors as transient", () =>
|
||||
Effect.gen(function* () {
|
||||
const message =
|
||||
"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing: https://docs.x.ai/developers/advanced-api-usage/priority-processing"
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", message }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to error code when no message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
@@ -2801,7 +2814,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Something went wrong" })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "Something went wrong" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2812,7 +2825,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2823,7 +2836,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2834,7 +2847,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "OpenAI Responses response failed" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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)] : []
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", [
|
||||
|
||||
@@ -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> }) {
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user