mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 15:16:32 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3997706bc5 |
@@ -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\"}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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." }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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