Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 32e2e3f2f7 feat(tui): surface plugin failures 2026-08-12 02:43:52 +00:00
46 changed files with 446 additions and 1126 deletions
@@ -573,10 +573,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
cache_control: cacheControl(breakpoints, part.cache),
})
}
const previous = messages.at(-1)
if (previous?.role === "user" && previous.content.every((block) => block.type === "tool_result"))
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
messages.push({ role: "user", content })
}
return messages
+30 -73
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import {
FetchHttpClient,
Headers,
@@ -297,86 +297,44 @@ export const classifyHttpFailure = (input: {
})
}
type HttpOperation = "request" | "read"
const NativeTransportFailure = Schema.Struct({
message: Schema.String,
code: Schema.optionalKey(Schema.String),
cause: Schema.optionalKey(Schema.Unknown),
})
const decodeNativeTransportFailure = Schema.decodeUnknownOption(NativeTransportFailure)
const nativeTransportFailure = (error: unknown) => {
const failure = Option.getOrUndefined(decodeNativeTransportFailure(error))
if (!failure) return undefined
if (failure.code !== undefined) return failure
const cause = Option.getOrUndefined(decodeNativeTransportFailure(failure.cause))
if (cause?.code !== undefined) return cause
return failure
}
const httpError = (input: {
readonly error: unknown
readonly request: HttpClientRequest.HttpClientRequest
readonly operation: HttpOperation
readonly redactedNames: ReadonlyArray<string | RegExp>
}) => {
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const transportError = (input: {
readonly message: string
readonly kind?: string | undefined
readonly request?: HttpClientRequest.HttpClientRequest | undefined
}) =>
new AIError({
module: "RequestExecutor",
method: input.operation,
method: "execute",
reason: new TransportReason({
message: failure.message,
transport: "http",
operation: input.operation,
code: failure.code,
url: redactUrl(request.url),
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
message: input.message,
kind: input.kind,
url: input.request ? redactUrl(input.request.url) : undefined,
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
}),
})
const source =
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
? input.error.reason.cause
: input.error
const native = nativeTransportFailure(source)
const code = native?.code
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
if (!HttpClientError.isHttpClientError(input.error))
return transportError({ message: message ?? "HTTP transport failed", code })
if (input.error.reason._tag === "TransportError") {
if (Cause.isTimeoutError(error)) {
return transportError({ message: error.message, kind: "Timeout" })
}
if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
return transportError({
message: message ?? input.error.reason.description ?? "HTTP transport failed",
code: code ?? input.error.reason._tag,
message: error.reason.description ?? "HTTP transport failed",
kind: error.reason._tag,
request,
})
}
return transportError({
message: message ?? `HTTP transport failed: ${input.error.reason._tag}`,
code: code ?? input.error.reason._tag,
message: `HTTP transport failed: ${error.reason._tag}`,
kind: error.reason._tag,
request,
})
}
export const stream = (
executor: Interface,
request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
): Stream.Stream<Uint8Array, AIError> =>
Stream.unwrap(
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
const response = yield* executor.execute(request, middleware)
return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
)
}),
)
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -385,16 +343,15 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware)
return yield* http.execute(request).pipe(
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
Effect.flatMap(statusError(request, redactedNames)),
)
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
const response = yield* middleware(request, (input) =>
http
.execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
).pipe(Effect.mapError(toHttpError(redactedNames)))
return yield* statusError(response.request, redactedNames)(response)
})
return Service.of({
+21 -4
View File
@@ -1,4 +1,4 @@
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth.js"
import { render as renderEndpoint } from "../endpoint.js"
@@ -6,7 +6,6 @@ import { Framing } from "../framing.js"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
import * as ProviderShared from "../../protocols/shared.js"
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
import { RequestExecutor } from "../executor.js"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -87,8 +86,26 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware,
}
}),
frames: (prepared, _request, runtime) =>
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request, prepared.middleware)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
),
),
),
),
})
export const sseJson = {
+13 -36
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
import { AIError, TransportReason } from "../../schema/index.js"
import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js"
@@ -29,18 +29,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
const transportError = (
method: string,
message: string,
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
input: { readonly url?: string; readonly kind?: string } = {},
) =>
new AIError({
module: "WebSocketExecutor",
method,
reason: new TransportReason({
message,
transport: "websocket",
operation: input.operation,
url: input.url,
code: input.code,
}),
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
})
const eventMessage = (event: Event) => {
@@ -61,8 +55,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
return Effect.fail(
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
url: input.url,
operation: "request",
code: "closed",
kind: "open",
}),
)
}
@@ -86,10 +79,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup()
resume(
Effect.fail(
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url,
operation: "request",
}),
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
),
)
}
@@ -99,8 +89,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
Effect.fail(
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
url: input.url,
operation: "request",
code: String(event.code),
kind: "open",
}),
),
)
@@ -129,8 +118,7 @@ const webSocketUrl = (value: string) =>
catch: (error) =>
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
url: value,
operation: "request",
code: "invalid-url",
kind: "websocket",
}),
})
@@ -141,7 +129,7 @@ export const open = (input: WebSocketRequest) =>
catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url,
operation: "request",
kind: "open",
}),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
@@ -162,10 +150,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "Unsupported WebSocket message payload", {
url: input.url,
operation: "read",
}),
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
),
)
}
@@ -173,10 +158,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url,
operation: "read",
}),
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
),
)
}
@@ -185,11 +167,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", `WebSocket closed with code ${event.code}`, {
url: input.url,
operation: "read",
code: String(event.code),
}),
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
),
)
}
@@ -210,7 +188,7 @@ export const fromWebSocket = (
catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
operation: "write",
kind: "write",
}),
}),
messages: Stream.fromQueue(messages),
@@ -265,8 +243,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
return Stream.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
url: prepared.url,
operation: "request",
code: "unavailable",
kind: "websocket",
}),
)
}
+1 -9
View File
@@ -92,18 +92,10 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
http: Schema.optional(HttpContext),
}) {}
export const TransportType = Schema.Literals(["http", "websocket"])
export type TransportType = typeof TransportType.Type
export const TransportOperation = Schema.Literals(["request", "read", "write"])
export type TransportOperation = typeof TransportOperation.Type
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"),
message: Schema.String,
transport: TransportType,
operation: TransportOperation,
code: Schema.optional(Schema.String),
kind: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
}) {}
+3 -101
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import { dynamicResponse, systemError } from "./lib/http.js"
import { dynamicResponse } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js"
@@ -67,62 +67,6 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => {
it.effect("parses response body failures at the executor seam", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: disconnected <redacted> <redacted>",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
),
),
)
it.effect("unwraps native transport failure causes", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed",
operation: "read",
code: "ECONNRESET",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
),
),
)
it.effect("preserves middleware error messages", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
@@ -135,48 +79,6 @@ describe("RequestExecutor", () => {
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("reports the request sent by middleware", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, (original, handler) =>
handler(
original.pipe(
HttpClientRequest.setUrl("https://proxy.test/v1/chat?api_key=proxy-secret"),
HttpClientRequest.setHeader("authorization", "Bearer proxy-secret"),
),
),
)
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: proxy disconnected <redacted>",
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
http: {
request: {
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
headers: { authorization: "<redacted>" },
},
},
})
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: input.request,
cause: systemError("ECONNRESET", "proxy disconnected proxy-secret"),
}),
}),
),
),
),
),
)
it.effect("classifies context overflow responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
+6 -20
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
import type { Service as LLMClientService } from "../../src/route/client.js"
import type { Service as RequestExecutorService } from "../../src/route/executor.js"
@@ -14,9 +14,7 @@ export type HandlerInput = {
) => HttpClientResponse.HttpClientResponse
}
export type Handler = (
input: HandlerInput,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
Layer.succeed(
@@ -36,12 +34,6 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export interface SystemError extends Error {
readonly code: string
}
export const systemError = (code: string, message: string): SystemError => Object.assign(new Error(message), { code })
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
@@ -71,20 +63,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
* exercise transport errors that surface during parsing.
*/
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) =>
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
dynamicResponse((input) =>
Effect.sync(() => {
const encoder = new TextEncoder()
let index = 0
const stream = new ReadableStream({
pull(controller) {
const chunk = chunks[index]
if (chunk !== undefined) {
index++
controller.enqueue(encoder.encode(chunk))
return
}
controller.error(error)
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
controller.error(new Error("connection reset"))
},
})
return input.respond(stream, { headers: SSE_HEADERS })
@@ -271,47 +271,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Check both cities."),
Message.assistant([
{ type: "text", text: "I'll check both." },
ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }),
ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }),
]),
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: [{ type: "text", text: "Check both cities." }] },
{
role: "assistant",
content: [
{ type: "text", text: "I'll check both." },
{ type: "tool_use", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool_use", id: "call_london", name: "weather", input: { city: "London" } },
],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
{ type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' },
],
},
])
expect(prepared.body.messages).toHaveLength(3)
}),
)
it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -956,54 +915,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_paris", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "call_london", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"city":"London"}' },
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
{ id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.message.content).toMatchObject([
{ type: "tool-call", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool-call", id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}),
)
it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () {
const body = sseEvents(
+7 -39
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Ref, Schema, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
HttpOptions,
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared.js"
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http.js"
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http.js"
import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
import { sseEvents } from "../lib/sse.js"
@@ -1221,44 +1221,12 @@ describe("OpenAI Chat route", () => {
it.effect("surfaces transport errors that occur mid-stream", () =>
Effect.gen(function* () {
const layer = truncatedStream(
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
systemError("ECONNRESET", "socket closed unexpectedly"),
)
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
const error = yield* LLMClient.stream(request).pipe(
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
Stream.runDrain,
Effect.provide(layer),
Effect.flip,
)
const layer = truncatedStream([
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
])
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed unexpectedly",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://api.openai.test/v1/chat/completions",
})
}),
)
it.effect("surfaces transport errors before the first stream frame", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(truncatedStream([], systemError("ECONNRESET", "socket closed before output"))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed before output",
transport: "http",
operation: "read",
code: "ECONNRESET",
})
expect(error.message).toContain("Failed to read openai/openai-chat stream")
}),
)
+1 -3
View File
@@ -763,9 +763,7 @@ function apiCallErrorReason(error: APICallError) {
if (error.statusCode !== undefined || !error.isRetryable) return reason
return new TransportReason({
message: reason.message,
transport: "http",
operation: "request",
code: error.name,
kind: error.name,
url: error.url,
http: "http" in reason ? reason.http : undefined,
})
+9 -56
View File
@@ -22,10 +22,10 @@ const snapshotConfigFile = "opencode.gitconfig"
const snapshotConfigInclude = `[include]
path = ${snapshotConfigFile}
`
const snapshotConfig = (input: { autocrlf: string; symlinks: string }) => `[core]
autocrlf = ${input.autocrlf}
const snapshotConfig = `[core]
autocrlf = false
longpaths = true
symlinks = ${input.symlinks}
symlinks = true
fsmonitor = false
untrackedCache = true
[feature]
@@ -341,46 +341,6 @@ const layer = Layer.effect(
})
})
const sourceConfig = Effect.fnUntraced(function* (
repository: Repository | undefined,
key: string,
fallback: string,
allowInput: boolean,
) {
if (!repository) return fallback
const result = yield* execute(
repository.worktree,
proc,
)(["config", "--get", key]).pipe(
Effect.mapError(
(cause) =>
new OperationError({
operation: "create",
directory: repository.worktree,
message: `Failed to resolve ${key}`,
cause,
}),
),
)
if (result.exitCode === 0) {
const value = result.text.trim().toLowerCase()
if (allowInput && value === "input") return value
if (["true", "yes", "on", "1"].includes(value)) return "true"
if (["false", "no", "off", "0"].includes(value)) return "false"
return yield* new OperationError({
operation: "create",
directory: repository.worktree,
message: `Invalid ${key} value: ${value}`,
})
}
if (result.exitCode === 1) return fallback
return yield* new OperationError({
operation: "create",
directory: repository.worktree,
message: result.stderr.trim() || `Failed to resolve ${key}`,
})
})
const create = Effect.fn("Git.repo.create")(function* (input: {
worktree: AbsolutePath
gitDirectory: AbsolutePath
@@ -403,20 +363,14 @@ const layer = Layer.effect(
commonDirectory: input.gitDirectory,
})
yield* repositoryOperation("create", repository, ["init"])
const semantics = {
autocrlf: yield* sourceConfig(input.seed, "core.autocrlf", "false", true),
symlinks: yield* sourceConfig(input.seed, "core.symlinks", "true", false),
}
const reseed = yield* Effect.gen(function* () {
const owned = path.join(input.gitDirectory, snapshotConfigFile)
const desired = snapshotConfig(semantics)
const previous = yield* fs.readFileString(owned).pipe(Effect.catch(() => Effect.succeed("")))
yield* fs.writeFileString(owned, desired)
yield* Effect.gen(function* () {
yield* fs.writeFileString(path.join(input.gitDirectory, snapshotConfigFile), snapshotConfig)
const config = path.join(input.gitDirectory, "config")
const current = yield* fs.readFileString(config)
const base = current.replace(/^\s*path\s*=\s*opencode\.gitconfig\s*\r?\n?/gm, "").trimEnd()
yield* fs.writeFileString(config, `${base}\n\n${snapshotConfigInclude}`)
return previous !== desired
if (current.includes(snapshotConfigInclude)) return
yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, {
flag: "a",
})
}).pipe(
Effect.mapError(
(cause) =>
@@ -456,7 +410,6 @@ const layer = Layer.effect(
}),
),
)
if (!reseed) return repository
yield* fs
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
.pipe(Effect.catch(() => Effect.void))
+1 -3
View File
@@ -48,12 +48,10 @@ export const schedule = (
assistantMessageID: () => SessionMessage.ID,
) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<RetryableFailure>(),
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
const minimum = retryAfter(failure)
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
}),
Schedule.tap((metadata) =>
bus.publish(SessionEvent.RetryScheduled, {
@@ -1,6 +1,5 @@
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import type { Model } from "../../model.js"
import { SessionMessage } from "../message.js"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
@@ -15,17 +14,6 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description },
})
const attachmentLocation = (file: FileAttachment) => {
if (file.source.type !== "uri") return undefined
const url = URL.parse(file.source.uri)
if (url?.protocol !== "file:") return undefined
try {
return fileURLToPath(url)
} catch {
return undefined
}
}
const textAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
@@ -48,7 +36,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
const directoryAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
@@ -67,10 +55,7 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
const attachmentContent = (file: FileAttachment): ContentPart[] => {
if (file.mime === "text/plain") return [textAttachment(file)]
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
if (imageMimes.has(file.mime)) {
const location = attachmentLocation(file)
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
}
if (imageMimes.has(file.mime)) return [media(file)]
return []
}
+5 -3
View File
@@ -83,9 +83,11 @@ const layer = Layer.effect(
const gitDirectory = AbsolutePath.make(
path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)),
)
const snapshotRepository = yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
const snapshotRepository = (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
? new Git.Repository({ worktree, gitDirectory, commonDirectory: gitDirectory })
: yield* git.repo
.create({ worktree, gitDirectory, seed: source })
.pipe(Effect.mapError((cause) => failure("capture", cause)))
return { source, worktree, snapshotRepository }
}).pipe(Effect.forkIn(lifetime)),
)
+4
View File
@@ -103,10 +103,14 @@ type GitOps = ReturnType<typeof makeGit>
const cfg = [
"--no-optional-locks",
"-c",
"core.autocrlf=false",
"-c",
"core.fsmonitor=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
] as const
+2 -9
View File
@@ -49,9 +49,7 @@ const client = LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({
execute: () => Effect.die("Unexpected HTTP request"),
}),
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
),
),
)
@@ -544,12 +542,7 @@ it.effect("retries status-less AI SDK transport failures", () =>
isRetryable: true,
}),
)
expect(error.reason).toMatchObject({
_tag: "Transport",
transport: "http",
operation: "request",
code: "AI_APICallError",
})
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
}),
+2 -15
View File
@@ -148,34 +148,21 @@ describe("Git trees", () => {
const git = yield* Git.Service
const source = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!source) throw new Error("Repository not found")
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
yield* Effect.promise(() => $`git config core.symlinks false`.cwd(root.path).quiet())
const storage = AbsolutePath.make(path.join(root.path, ".snapshot storage"))
const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path first.gitconfig`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path second.gitconfig`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf false`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.symlinks true`.quiet())
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf true`.quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
).toBe("true\n")
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.symlinks`.text()),
).toBe("false\n")
yield* Effect.promise(() => $`git config core.autocrlf input`.cwd(root.path).quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
).toBe("input\n")
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
expect(
(yield* Effect.promise(() => fs.readFile(path.join(storage, "config"), "utf8"))).match(/opencode\.gitconfig/g),
).toHaveLength(1)
expect(
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --get-all include.path`.text()),
).toBe("first.gitconfig\nsecond.gitconfig\nopencode.gitconfig\n")
).toBe("opencode.gitconfig\nfirst.gitconfig\nsecond.gitconfig\n")
yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
const before = yield* git.tree.write(repository)
+2 -4
View File
@@ -39,9 +39,7 @@ describe("toSessionError", () => {
)
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(
toSessionError(llm(new TransportReason({ message: "transport", transport: "http", operation: "request" }))).type,
).toBe("provider.transport")
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
"provider.internal",
)
@@ -113,7 +111,7 @@ describe("toSessionError", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
llm(new TransportReason({ message: "transport", transport: "http", operation: "request" })),
llm(new TransportReason({ message: "transport" })),
]
const ineligible = [
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
+1 -1
View File
@@ -32,7 +32,7 @@ describe("SessionExecution lifecycle", () => {
new AIError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected", transport: "http", operation: "request" }),
reason: new TransportReason({ message: "Disconnected" }),
}),
),
),
+10 -119
View File
@@ -11,8 +11,6 @@ import { Shell } from "@opencode-ai/schema/shell"
import { Location } from "@opencode-ai/schema/location"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { DateTime } from "effect"
import path from "path"
import { pathToFileURL } from "url"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
@@ -269,13 +267,12 @@ Recent work
])
})
test("exposes admitted reference directory source paths in model context", () => {
const location = path.resolve("/references/harness-engineering")
test("lowers directory attachments as directory context", () => {
const directory = FileAttachment.make({
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: pathToFileURL(location).href },
name: "harness-engineering",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
})
const messages = toLLMMessages(
[
@@ -298,15 +295,14 @@ Recent work
{ type: "text", text: "Review this directory" },
{
type: "text",
text: `\n\nAttached directory: ${location}\n\nlib/\nindex.ts`,
metadata: { attachment: { source: directory.source, name: "harness-engineering" } },
text: "\n\nAttached directory: src/\n\nlib/\nindex.ts",
metadata: { attachment: { source: directory.source, name: "src/" } },
},
],
})
})
test("preserves attachment order after the prompt", () => {
const directory = path.resolve("/project/src")
const messages = toLLMMessages(
[
SessionMessage.User.make({
@@ -317,7 +313,7 @@ Recent work
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: pathToFileURL(directory).href },
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
}),
FileAttachment.make({
@@ -336,13 +332,12 @@ Recent work
expect(messages).toHaveLength(1)
expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([
"Review these attachments",
`\n\nAttached directory: ${directory}\n\nindex.ts`,
"\n\nAttached directory: src/\n\nindex.ts",
"\n\nAttached file: main.ts\n\nexport const value = 1",
])
})
test("omits empty prompt text before an attachment", () => {
const directory = path.resolve("/project/src")
const messages = toLLMMessages(
[
SessionMessage.User.make({
@@ -353,7 +348,7 @@ Recent work
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: pathToFileURL(directory).href },
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
}),
],
@@ -364,9 +359,7 @@ Recent work
)
expect(messages).toHaveLength(1)
expect(messages[0]?.content).toMatchObject([
{ type: "text", text: `\n\nAttached directory: ${directory}\n\nindex.ts` },
])
expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }])
})
test("uses materialized image data as provider media and drops unsupported attachments", () => {
@@ -398,108 +391,6 @@ Recent work
])
})
test("exposes admitted local image source paths before provider media", () => {
const data = Base64.make("AAECAw==")
const location = path.resolve("/project/IMG_3480.JPG")
const image = FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: pathToFileURL(location).href },
name: "IMG_3480.JPG",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-local-image-path"),
type: "user",
text: "Inspect this image",
files: [image],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "text", text: `Attached file: ${location}` },
{ type: "media", mediaType: "image/png", data, filename: "IMG_3480.JPG" },
])
})
test("falls back to attachment names for invalid local source paths", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-invalid-local-paths"),
type: "user",
text: "Inspect these attachments",
files: [
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src%2Flib" },
name: "src/",
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/image%2Fpreview.png" },
name: "preview.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect these attachments" },
{
type: "text",
text: "\n\nAttached directory: src/\n\nindex.ts",
metadata: {
attachment: {
source: { type: "uri", uri: "file:///project/src%2Flib" },
name: "src/",
},
},
},
{ type: "media", mediaType: "image/png", data, filename: "preview.png" },
])
})
test("does not add attachment location text for non-local provider media", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-remote-image"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "https://example.com/image.png" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
])
})
test("deduplicates provider media while preserving durable attachment references", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
@@ -577,7 +468,7 @@ Recent work
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: pathToFileURL(path.resolve("/project/image.png")).href },
source: { type: "uri", uri: "file:///project/image.png" },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
+18 -25
View File
@@ -515,11 +515,7 @@ const providerUnavailable = () =>
new AIError({
module: "test",
method: "stream",
reason: new TransportReason({
message: "Provider unavailable",
transport: "http",
operation: "request",
}),
reason: new TransportReason({ message: "Provider unavailable" }),
})
const incompleteStream = () =>
@@ -3951,7 +3947,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("bounds jittered exponential backoff for eligible pre-output failures", () =>
it.effect("retries eligible pre-output failures after exponential backoff", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry transport")
@@ -3960,9 +3956,9 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("1599 millis")
yield* TestClock.adjust("1999 millis")
expect(requests).toHaveLength(1)
yield* TestClock.adjust("801 millis")
yield* TestClock.adjust("1 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
@@ -3987,7 +3983,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2400 millis")
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
@@ -4032,7 +4028,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2400 millis")
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
@@ -4089,7 +4085,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2400 millis")
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
expect(requests[1]?.messages.at(-2)).toMatchObject({
@@ -4130,7 +4126,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
yield* TestClock.adjust("2400 millis")
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
expect(executions).toEqual(["settled"])
@@ -4169,7 +4165,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
yield* TestClock.adjust("2400 millis")
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool", "user"])
@@ -4207,7 +4203,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
yield* TestClock.adjust(delay)
yield* TestLLM.wait(index + 2)
}
@@ -4228,7 +4224,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
yield* TestClock.adjust(delay)
yield* TestLLM.wait(index + 2)
}
@@ -4243,15 +4239,12 @@ describe("SessionRunnerLLM", () => {
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
for (const [index, range] of [
[1_600, 2_400],
[4_800, 7_200],
[11_200, 16_800],
[24_000, 36_000],
].entries()) {
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(range[0]!)
expect(retries[index]?.data.at).toBeLessThanOrEqual(range[1]!)
}
expect(retries.map((event) => event.data)).toMatchObject([
{ attempt: 2, at: 2_000 },
{ attempt: 3, at: 6_000 },
{ attempt: 4, at: 14_000 },
{ attempt: 5, at: 30_000 },
])
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
const assistant = requireAssistant(yield* session.context(sessionID))
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
@@ -4281,7 +4274,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2400 millis")
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
expect(requests).toHaveLength(3)
-45
View File
@@ -127,51 +127,6 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("repairs existing storage with source repository semantics", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const file = path.join(project, "line-endings.txt")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await fs.writeFile(file, "before\n")
await initGit(project, true)
await $`git config core.autocrlf false`.cwd(project).quiet()
})
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
const source = yield* git.repo.discover(AbsolutePath.make(project))
if (!source) throw new Error("Repository not found")
const location = yield* Location.Service.pipe(
Effect.provide(
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
),
)
yield* git.repo.create({
worktree: source.worktree,
gitDirectory: AbsolutePath.make(path.join(tmp.path, "snapshot", location.project.id, Hash.fast(project))),
seed: source,
})
yield* Effect.promise(async () => {
await $`git config core.autocrlf true`.cwd(project).quiet()
await fs.rm(file)
await $`git checkout -- line-endings.txt`.cwd(project).quiet()
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const before = yield* snapshot.capture()
expect(before).toBeDefined()
yield* Effect.promise(() => fs.writeFile(file, "before\n"))
expect(yield* snapshot.capture()).toBe(before)
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
-36
View File
@@ -93,42 +93,6 @@ describe("Vcs", () => {
),
)
it.live("respects repository line ending configuration", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await $`git config core.autocrlf true`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "line-endings.txt"), "before\n")
await commitAll(directory, "line endings")
await fs.rm(path.join(directory, "line-endings.txt"))
await $`git checkout -- line-endings.txt`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
}),
),
)
it.live("respects repository symlink configuration", () =>
withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
const blob = await $`printf target.txt | git hash-object -w --stdin`.cwd(directory).text()
await $`git update-index --add --cacheinfo 120000,${blob.trim()},link.txt`.cwd(directory).quiet()
await $`git commit -m symlink`.cwd(directory).quiet()
await $`git config core.symlinks false`.cwd(directory).quiet()
await $`git checkout-index -f link.txt`.cwd(directory).quiet()
})
const vcs = yield* Vcs.Service
expect(yield* vcs.status()).toEqual([])
expect(yield* vcs.diff("working")).toEqual([])
}),
),
)
it.live("caches branch info and publishes HEAD changes", () =>
withGit((directory) =>
Effect.gen(function* () {
-3
View File
@@ -13083,9 +13083,6 @@
},
"text": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["id", "time", "type", "text"],
+12 -7
View File
@@ -7,6 +7,15 @@ import { isAllowedCorsOrigin } from "./cors"
import { createRoutes } from "./routes"
import type { ServerOptions } from "./options"
export interface BootOptions {
/**
* Resumes execution-journaled Sessions once the application layer boots. Pair with
* `SessionExecution.configured({ suspendOnStart: true })` on runtimes that can die without
* teardown, so turns orphaned by a hard death replay on the next boot.
*/
readonly resumeSuspendedSessions?: boolean
}
/**
* Builds a web-standard fetch handler `(request: Request) => Promise<Response>` serving the
* same HttpApi routes as the Node server process without binding a port, owning a listener, or
@@ -23,17 +32,13 @@ import type { ServerOptions } from "./options"
* Auth follows `createRoutes` semantics: `options.password` enforces Basic auth; omitting it
* serves unauthenticated, so an embedder without a password must front the handler with its own
* access control.
*
* Sessions whose execution claim was never released resume once the layer is built, exactly as
* the Node server process does: a runtime that dies without teardown an evicted Durable
* Object leaves the same durable signature as a killed process replays orphaned turns on the
* next boot, and the sweep is a no-op when nothing is suspended.
*/
export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}) {
export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}, boot: BootOptions = {}) {
const context = yield* Layer.build(createRoutes(options, () => []).pipe(Layer.provide(HttpServer.layerServices)))
// Forked so the returned handler is never delayed; resumed drains are already
// logged and durably recorded by the execution layer.
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
if (boot.resumeSuspendedSessions)
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
return Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(
+2 -8
View File
@@ -38,7 +38,6 @@ import {
TuiStartupProvider,
TuiTerminalEnvironmentProvider,
useTuiApp,
useTuiPaths,
useTuiStartup,
type TuiApp,
} from "./context/runtime"
@@ -86,7 +85,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { tuiPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
@@ -455,7 +453,6 @@ function App(props: { pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
const paths = useTuiPaths()
const config = useConfig()
const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local")
const route = useRoute()
@@ -499,7 +496,7 @@ function App(props: { pair?: DialogPairCredentials }) {
toast.show({
variant: "error",
title: `MCP server failed: ${server.name}`,
message: "Open MCP servers to view details.",
message: "Run /mcps to view details.",
})
}
})
@@ -662,13 +659,10 @@ function App(props: { pair?: DialogPairCredentials }) {
run: () => {
route.navigate({
type: "home",
location: newSessionLocation(
config.data.session.new_location,
paths.cwd,
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
),
})
dialog.clear()
},
+10 -12
View File
@@ -384,18 +384,16 @@ export function DevToolsBar() {
>
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
</Action>
<Show when={Boolean(turnTokens())}>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
})
}
hoverBackground
>
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
</Action>
</Show>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
})
}
hoverBackground
>
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
</Action>
</box>
<For each={groups()}>
{(group) => (
@@ -93,15 +93,6 @@ export const settings: Setting[] = [
labels: ["off", "on"],
keywords: ["attachments", "images", "tool output"],
},
{
title: "New session location",
category: "Session",
path: ["session", "new_location"],
default: "launch",
values: ["launch", "inherit"],
labels: ["launch directory", "active session"],
keywords: ["directory", "cwd", "inherit"],
},
{
title: "Enabled",
category: "Tabs",
@@ -0,0 +1,85 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal, onMount } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
import { getScrollAcceleration } from "../util/scroll"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
onMount(() => dialog.setSize("large"))
const copy = () => {
void clipboard
.write(props.error)
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack }],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{props.error}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
</box>
)
}
+6 -84
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { useData } from "../context/data"
import { useClient } from "../context/client"
import { Keymap } from "../context/keymap"
@@ -6,13 +6,10 @@ import { pipe, sortBy } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { TextAttributes } from "@opentui/core"
import type { McpServer } from "@opencode-ai/client"
import { useClipboard } from "../context/clipboard"
import { useToast } from "../ui/toast"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { getScrollAcceleration } from "../util/scroll"
import { DialogErrorDetails } from "./dialog-error-details"
function statusError(status: McpServer["status"]) {
if (status.status === "failed") return status.error
@@ -143,8 +140,9 @@ export function DialogMcp() {
}
>
{(server) => (
<DialogMcpError
server={server()}
<DialogErrorDetails
title={`MCP server: ${server().name}`}
error={statusError(server().status) ?? "Unknown MCP connection error"}
onBack={() => {
setDetail()
dialog.setSize("medium")
@@ -155,79 +153,3 @@ export function DialogMcp() {
</box>
)
}
function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
const dialog = useDialog()
const clipboard = useClipboard()
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const error = () => statusError(props.server.status) ?? "Unknown MCP connection error"
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
onMount(() => dialog.setSize("large"))
const copy = () => {
void clipboard
.write(error())
.then(() => setCopied(true))
.catch(toast.error)
}
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
if (event.name === "pagedown") return scroll?.scrollBy(height())
if (event.name === "home") return scroll?.scrollTo(0)
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
})
return (
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
MCP server: {props.server.name}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
<box
backgroundColor={overlayTheme.background.default}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={getScrollAcceleration(config)}
>
<text fg={overlayTheme.text.default} wrapMode="word">
{error()}
</text>
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
</text>
</box>
</box>
)
}
+10 -94
View File
@@ -27,8 +27,6 @@ import { marqueeText } from "../util/marquee"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
// The add button renders as " + " at the end of the strip, so the tab layout leaves it room.
const ADD_TAB_WIDTH = 3
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 100
@@ -44,7 +42,6 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
add?: () => void
status(sessionID: string): SessionTabsStatus
}
@@ -106,23 +103,22 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const ordered = createMemo(() => {
const pending = preview()
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = ordered
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
const statuses = createMemo(
() =>
new Map(
items().map((tab) => {
const status = tabs.status(tab.sessionID)
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
return [
tab.sessionID,
{
@@ -149,8 +145,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
createEffect(() => {
if (!scroll) return
// The promoted new-session slot sits below the list, so bring the rail's bottom into view.
if (newTab()) return scroll.scrollTo(Math.max(0, items().length * 3 + 1 - scroll.viewport.height))
const index = items().findIndex((tab) => tab.sessionID === activeID())
if (index === -1) return
const top = index * 3
@@ -177,7 +171,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const session = createMemo(() => data.session.get(tab.sessionID))
const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
@@ -194,6 +188,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
@@ -265,7 +260,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
tabs.select(tab.sessionID)
if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
}
return (
<box
@@ -282,7 +277,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}}
onMouseUp={release}
onMouseDrag={(event) => {
if (!rail) return
if (!rail || tab === NEW_SESSION_TAB) return
const target = Math.max(
0,
Math.min(
@@ -391,7 +386,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseUp={(event) => {
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
@@ -422,63 +417,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
)
}}
</For>
{/* One slot with two states: a subdued affordance that promotes in place into the
active new-session tab, instead of spawning a separate pseudo tab above itself. */}
<Show when={tabs.add || newTab()}>
<box
height={1}
width="100%"
position="relative"
flexDirection="row"
paddingLeft={1}
backgroundColor={
newTab()
? theme.background.action.primary.selected
: addHovered()
? theme.background.action.primary.hovered
: theme.background.default
}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => {
if (!newTab()) tabs.add?.()
}}
>
<text
width={2}
fg={newTab() ? activeNumber() : addHovered() ? theme.text.default : idleNumber()}
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
+
</text>
<text
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
wrapMode="none"
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
{NEW_SESSION_TAB_TITLE}
</text>
<Show when={newTab()}>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (!addHovered()) return
event.stopPropagation()
tabs.close()
}}
>
{addHovered() ? "×" : ""}
</text>
</Show>
</box>
</Show>
</box>
</scrollbox>
</box>
@@ -493,7 +431,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
@@ -512,10 +449,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
// The promoted new-session slot joins the strip as the active tab; the idle plus affordance
// and the promoted slot are mutually exclusive states of one control.
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
const showPlus = () => Boolean(tabs.add) && !newTab()
createEffect(() => {
const pending = preview()
if (!pending || dragging()) return
@@ -523,12 +457,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
})
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(
items(),
activeID(),
dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0),
previous?.start,
),
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
)
const statuses = createMemo(
() =>
@@ -775,7 +704,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
{" "}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
{sessionTabShortcutLabel(tabNumber() - 1)}
</text>
<text
width={availableTitleWidth()}
@@ -817,19 +746,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
{" " + layout().after}
</text>
</Show>
<Show when={showPlus()}>
<text
width={ADD_TAB_WIDTH}
fg={addHovered() ? theme.text.default : theme.text.subdued}
bg={addHovered() ? theme.background.action.primary.hovered : undefined}
selectable={false}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => tabs.add?.()}
>
{" + "}
</text>
</Show>
</box>
)
}
+1 -11
View File
@@ -137,9 +137,6 @@ export const Info = Schema.Struct({
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
new_location: Schema.optional(Schema.Literals(["launch", "inherit"])).annotate({
description: "Start new sessions in the TUI launch directory or inherit the active session location",
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
tabs: Schema.optional(
@@ -205,7 +202,7 @@ export const Info = Schema.Struct({
})
export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "session" | "tabs"> & {
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "tabs"> & {
attention: {
enabled: boolean
notifications: boolean
@@ -221,9 +218,6 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
style: "block" | "underline" | "line" | "default"
blinking: boolean
}
session: Omit<NonNullable<Info["session"]>, "new_location"> & {
new_location: "launch" | "inherit"
}
tabs: {
enabled: boolean
scope: "global" | "cwd"
@@ -265,10 +259,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
blinking: input.cursor.blinking ?? true,
}
: undefined,
session: {
...input.session,
new_location: input.session?.new_location ?? "launch",
},
tabs: {
...input.tabs,
enabled: input.tabs?.enabled ?? true,
@@ -1,10 +0,0 @@
import type { LocationRef } from "@opencode-ai/client/promise"
export function newSessionLocation(
mode: "launch" | "inherit",
launchDirectory: string,
current?: LocationRef,
): LocationRef {
if (mode === "inherit" && current) return current
return { directory: launchDirectory }
}
-10
View File
@@ -7,7 +7,6 @@ import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallbac
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useConfig } from "../config"
import { useLocation } from "./location"
import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import {
@@ -49,7 +48,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const data = useData()
const event = useEvent()
const config = useConfig().data
const location = useLocation()
const paths = useTuiPaths()
const enabled = () => config.tabs.enabled
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
@@ -251,14 +249,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
},
add() {
if (!enabled()) return
const sessionID = current()
route.navigate({
type: "home",
location: (sessionID ? data.session.get(sessionID)?.location : undefined) ?? location.ref,
})
},
close(sessionID?: string) {
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
@@ -1,10 +1,11 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { usePlugin } from "../../plugin/context"
function Mcp(props: { context: Plugin.Context }) {
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
const failed = createMemo(() => list().some((item) => item.status.status === "failed"))
const failed = createMemo(() => list().filter((item) => item.status.status === "failed").length)
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
return (
@@ -14,6 +15,7 @@ function Mcp(props: { context: Plugin.Context }) {
<Switch>
<Match when={failed()}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} MCP failed
</Match>
<Match when={true}>
<span
@@ -24,11 +26,28 @@ function Mcp(props: { context: Plugin.Context }) {
>
{" "}
</span>
{count()} MCP
</Match>
</Switch>
{count()} MCP
</text>
<text fg={props.context.theme.text.subdued}>/status</text>
<text fg={props.context.theme.text.subdued}>/mcps</text>
</box>
</Show>
)
}
function Plugins(props: { context: Plugin.Context }) {
const plugins = usePlugin()
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
return (
<Show when={failed()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={props.context.theme.text.default}>
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} plugin{failed() === 1 ? "" : "s"} failed
</text>
<text fg={props.context.theme.text.subdued}>/plugins</text>
</box>
</Show>
)
@@ -50,6 +69,7 @@ function View(props: { context: Plugin.Context }) {
gap={2}
>
<Mcp context={props.context} />
<Plugins context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
@@ -1,22 +1,26 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal } from "solid-js"
import { createEffect, createMemo, createSignal, Show } from "solid-js"
import { usePlugin } from "../../plugin/context"
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
import { useDialog } from "../../ui/dialog"
import { DialogErrorDetails } from "../../component/dialog-error-details"
const id = "opencode.plugins"
function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePlugin> }) {
const [locked, setLocked] = createSignal(false)
const options = createMemo(() =>
props.plugins
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<{ title: string; error: string }>()
const dialog = useDialog()
const options = createMemo(() => {
const builtins = props.plugins
.registered()
.filter((plugin) => plugin.id !== id)
.sort((a, b) => a.id.localeCompare(b.id))
.filter((plugin) => plugin.id !== id && plugin.source === "builtin")
.map(
(plugin): DialogSelectOption<string> => ({
title: plugin.id,
value: plugin.id,
category: plugin.source === "builtin" ? "Built-in" : "External",
category: "Built-in",
footer: (
<span
style={{
@@ -29,8 +33,43 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
</span>
),
}),
),
)
)
const external = props.plugins.list().map(
(plugin): DialogSelectOption<string> => ({
title: "id" in plugin ? plugin.id : plugin.target,
value: "id" in plugin ? plugin.id : plugin.target,
category: "External",
searchText: plugin.target,
footer: (
<span
style={{
fg:
plugin.status === "active"
? props.context.theme.text.feedback.success.default
: plugin.status === "failed"
? props.context.theme.text.feedback.error.default
: props.context.theme.text.subdued,
}}
>
{plugin.status}
</span>
),
}),
)
return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title))
})
const failure = (value: string | undefined) =>
props.plugins.list().find((plugin) => {
if (plugin.status !== "failed") return false
return ("id" in plugin ? plugin.id : plugin.target) === value
})
createEffect(() => {
if (focused()) return
const first = options()[0]
if (first) setFocused(first.value)
})
const toggle = (plugin: DialogSelectOption<string>) => {
if (locked()) return
@@ -51,15 +90,53 @@ function View(props: { context: Plugin.Context; plugins: ReturnType<typeof usePl
.finally(() => setLocked(false))
}
const select = (plugin: DialogSelectOption<string>) => {
const failed = failure(plugin.value)
if (!failed || failed.status !== "failed") return toggle(plugin)
setDetail({ title: failed.target, error: failed.error })
}
return (
<DialogSelect
title="Plugins"
options={options()}
locked={locked()}
preserveSelection={true}
actions={[{ title: "toggle", command: "plugins.toggle", onTrigger: toggle }]}
onSelect={toggle}
/>
<box>
<Show
when={detail()}
fallback={
<DialogSelect
title="Plugins"
options={options()}
current={focused()}
locked={locked()}
preserveSelection={true}
onMove={(option) => setFocused(option.value)}
actions={[
{
title: "toggle",
command: "plugins.toggle",
disabled: (option) => Boolean(failure(option?.value)),
onTrigger: toggle,
},
]}
onSelect={select}
footer={
<Show when={failure(focused())}>
<text fg={props.context.theme.text.subdued}>enter to view error</text>
</Show>
}
/>
}
>
{(item) => (
<DialogErrorDetails
title={`Plugin: ${item().title}`}
error={item().error}
onBack={() => {
setDetail()
dialog.setSize("medium")
}}
/>
)}
</Show>
</box>
)
}
@@ -72,6 +149,7 @@ function Commands(props: { context: Plugin.Context }) {
id: "plugins.list",
title: "Plugins",
group: "System",
slash: { name: "plugins" },
palette: true,
run() {
props.context.ui.dialog.show(() => <View context={props.context} plugins={plugins} />)
@@ -105,21 +105,9 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
}
}
const addTab = () => {
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
if (!next) {
setLastEvent("all fixture tabs are open")
return
}
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
select(next.sessionID)
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
}
const controller = {
tabs,
current: active,
add: addTab,
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
},
@@ -295,7 +283,21 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
startRun(current)
},
},
{ bind: "t", title: "Add tab", group: "Storybook", run: addTab },
{
bind: "t",
title: "Add tab",
group: "Storybook",
run() {
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
if (!next) {
setLastEvent("all fixture tabs are open")
return
}
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
select(next.sessionID)
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
},
},
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
{
bind: "r",
+1 -8
View File
@@ -11,7 +11,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { LocationRef } from "@opencode-ai/client/promise"
import type { Config } from "../config"
import { newSessionLocation } from "../config/new-session-location"
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
import {
resolveMiniSettings,
@@ -49,7 +48,6 @@ type Reconnect = (signal: AbortSignal) => Promise<RunInput["sdk"]>
type RunRuntimeInput = {
host: MiniHost
directory: string
boot: () => Promise<BootContext>
resolveSession: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
createSession?: CreateSession
@@ -943,11 +941,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const created = await createSession(
state.sdk,
{
location: newSessionLocation(
(await tuiConfigTask).session.new_location,
input.directory,
state.location,
),
location: state.location,
agent: state.agent,
model: state.model,
variant: state.activeVariant,
@@ -1105,7 +1099,6 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
return runInteractiveRuntime(
{
host: input.host,
directory: input.directory,
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
+1 -1
View File
@@ -392,7 +392,7 @@ export type FormCancel = {
location?: LocationRef
}
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session">
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini">
export type MiniSettings = {
thinking: "show" | "hide"
+5 -1
View File
@@ -390,7 +390,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
(prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error,
)
)
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
host.toast.show({
variant: "error",
title: `Plugin failed: ${state.target}`,
message: "Run /plugins to view details.",
})
setStore("states", reconcileStore(states))
}
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
+39 -73
View File
@@ -1222,11 +1222,6 @@ function TurnTokenUsage(props: {
}) {
const config = useConfig()
const theme = useTheme()
const renderer = useRenderer()
// Collapsed by default: one summary line for the whole turn. Click to
// open the full per-step table, click again to close.
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
const verbose = () => config.data.debug?.turn_tokens === "verbose"
const steps = createMemo(() => {
let previousCache = props.previousCache
@@ -1262,78 +1257,49 @@ function TurnTokenUsage(props: {
cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)),
total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
}))
const summary = createMemo(() => {
const items = steps()
const last = items[items.length - 1]
return {
count: items.length,
newTokens: items.reduce((sum, item) => sum + item.newTokens, 0),
cached: last?.cached ?? 0,
total: last?.total ?? 0,
reuseDrops: items.filter((item) => item.reuseDrop !== undefined).length,
}
})
return (
<Show when={Boolean(config.data.debug?.turn_tokens) && steps().length > 0}>
<box paddingLeft={3} flexDirection="column">
<box
flexDirection="row"
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
<text fg={hover() ? theme.text.default : theme.text.subdued} wrapMode="none">
<span>{expanded() ? "- " : "+ "}</span>
<span style={{ attributes: TextAttributes.BOLD }}>Tokens</span>
<span>
: {summary().count} {summary().count === 1 ? "step" : "steps"} · {summary().newTokens.toLocaleString()}{" "}
new · {summary().cached.toLocaleString()} cached · {summary().total.toLocaleString()} total
</span>
<Show when={summary().reuseDrops > 0}>
<span style={{ fg: theme.text.feedback.warning.default }}>
{" "}
· ! {summary().reuseDrops} likely cache {summary().reuseDrops === 1 ? "bust" : "busts"}
</span>
</Show>
<box flexDirection="row">
<text width={INLINE_TOOL_ICON_WIDTH} fg={theme.text.subdued}>
</text>
<text fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
Tokens
</text>
</box>
<Show when={expanded()}>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={theme.text.subdued} attributes={TextAttributes.ITALIC}>
{"Step".padEnd(columns().step + 2)}
{"New".padStart(columns().newTokens)}
{" "}
{"Cached".padStart(columns().cached)}
{" "}
{"Total".padStart(columns().total)}
</text>
</box>
<For each={steps()}>
{(item) => (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
<text fg={verbose() && item.finish === "tool-call" ? undefined : theme.text.subdued}>
{item.finish.padEnd(columns().step + 2)}
<span style={{ attributes: TextAttributes.BOLD }}>
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
</span>
{" "}
{item.cached.toLocaleString().padStart(columns().cached)}
{" "}
{item.total.toLocaleString().padStart(columns().total)}
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={theme.text.subdued} attributes={TextAttributes.ITALIC}>
{"Step".padEnd(columns().step + 2)}
{"New".padStart(columns().newTokens)}
{" "}
{"Cached".padStart(columns().cached)}
{" "}
{"Total".padStart(columns().total)}
</text>
</box>
<For each={steps()}>
{(item) => (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
<text fg={verbose() && item.finish === "tool-call" ? undefined : theme.text.subdued}>
{item.finish.padEnd(columns().step + 2)}
<span style={{ attributes: TextAttributes.BOLD }}>
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
</span>
{" "}
{item.cached.toLocaleString().padStart(columns().cached)}
{" "}
{item.total.toLocaleString().padStart(columns().total)}
</text>
<TurnTokenToolCalls tools={item.tools} />
<Show when={item.reuseDrop !== undefined}>
<text fg={theme.text.feedback.warning.default}>
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
</text>
<TurnTokenToolCalls tools={item.tools} />
<Show when={item.reuseDrop !== undefined}>
<text fg={theme.text.feedback.warning.default}>
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
</text>
</Show>
</box>
)}
</For>
</Show>
</Show>
</box>
)}
</For>
</box>
</Show>
)
@@ -1814,7 +1780,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
streaming={true}
internalBlockMode="top-level"
content={content()}
tableOptions={{ style: "grid", cellPaddingX: 1 }}
tableOptions={{ style: "grid" }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
@@ -2264,7 +2230,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
tableOptions={{ style: "grid", cellPaddingX: 1 }}
tableOptions={{ style: "grid" }}
conceal={ctx.markdownMode() === "rendered"}
fg={theme.markdown.text}
bg={theme.background.default}
-7
View File
@@ -25,8 +25,6 @@ test("validates the session tabs setting", () => {
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
expect(decode({ session: { new_location: "inherit" } })).toEqual({ session: { new_location: "inherit" } })
expect(() => decode({ session: { new_location: "current" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
@@ -47,7 +45,6 @@ test("resolves nested config and keybind defaults", () => {
expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
expect(config.session.new_location).toBe("launch")
})
test("shows resolved tab defaults in settings", () => {
@@ -56,10 +53,6 @@ test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
})
test("shows the new session location default in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "session.new_location")?.default).toBe("launch")
})
test("provides config and its host interface", async () => {
const config = resolve({}, { terminalSuspend: true })
let current = {}
@@ -7,7 +7,6 @@ import path from "path"
import { ConfigProvider } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { LocationProvider } from "../../src/context/location"
import { RouteProvider, useRoute } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
@@ -87,11 +86,9 @@ async function renderSessionTabs(
>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<LocationProvider>
<SessionTabsProvider>
<Probe />
</SessionTabsProvider>
</LocationProvider>
<SessionTabsProvider>
<Probe />
</SessionTabsProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
@@ -275,17 +272,3 @@ test("tracks a temporary new session tab across close and creation", async () =>
await setup.destroy()
}
})
test("add opens the new session tab carrying the current session's location", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
setup.tabs.add()
expect(setup.route.data).toEqual({ type: "home", location: { directory } })
await wait(() => setup.tabs.newTab())
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
} finally {
await setup.destroy()
}
})
@@ -1,19 +0,0 @@
import { expect, test } from "bun:test"
import { newSessionLocation } from "../src/config/new-session-location"
test("uses the launch directory by default", () => {
expect(newSessionLocation("launch", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
directory: "/launch",
})
})
test("inherits the active session location when configured", () => {
expect(newSessionLocation("inherit", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
directory: "/session",
workspaceID: "work-1",
})
})
test("falls back to the launch directory without an active session", () => {
expect(newSessionLocation("inherit", "/launch")).toEqual({ directory: "/launch" })
})
-3
View File
@@ -13083,9 +13083,6 @@
},
"text": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["id", "time", "type", "text"],
-3
View File
@@ -13083,9 +13083,6 @@
},
"text": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["id", "time", "type", "text"],