Compare commits

..

4 Commits

Author SHA1 Message Date
Aiden Cline 5562652f63 test(opencode): expect empty response retry 2026-08-04 23:13:23 +00:00
Aiden Cline 031510e1c3 fix(opencode): retry empty unknown responses 2026-08-04 22:52:01 +00:00
James Murdza f0afb6750e fix(server): log upstream 5xx bodies from proxied workspace requests (#40135)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:59 -04:00
James Murdza 703d09f306 fix(server): don't forward host directory to remote workspace (#40136)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 09:43:45 -04:00
9 changed files with 145 additions and 55 deletions
@@ -97,6 +97,29 @@ export function http(
headers.delete("content-encoding")
headers.delete("content-length")
// An upstream 5xx from a remote workspace sandbox arrives here as an opaque
// status — its real cause (and log line) live only inside the sandbox. Buffer
// the small error body, log it locally so it shows up in the host's log, and
// forward it unchanged (preserving content-type so the client can still parse
// the structured error, e.g. its `ref`).
if (response.status >= 500) {
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
const contentType = response.headers["content-type"] ?? "application/json"
headers.delete("content-type")
yield* Effect.logError("workspace proxy upstream error", {
url: url.toString(),
method: request.method,
status: response.status,
body: body.slice(0, 2000),
})
return HttpServerResponse.text(body, {
status: response.status,
statusText: statusText(response),
headers,
contentType,
})
}
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
statusText: statusText(response),
@@ -34,5 +34,12 @@ export function workspaceProxyURL(target: string | URL, requestURL: URL) {
proxyURL.search = requestURL.search
proxyURL.hash = requestURL.hash
proxyURL.searchParams.delete("workspace")
// The `directory` param is the *host's* working directory (e.g. a Windows
// path like `F:\proj`). It is meaningless — and dangerous — on the remote:
// the sandbox would `path.resolve` it against its own cwd, producing a bogus
// path like `/home/daytona/workspace/repo/F:\proj` that does not exist and
// crashes prompt handling. Drop it so the remote falls back to its own
// project root. This mirrors ProxyUtil.headers stripping `x-opencode-directory`.
proxyURL.searchParams.delete("directory")
return proxyURL
}
+17 -1
View File
@@ -636,14 +636,30 @@ const layer = Layer.effect(
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.reasoningMap = {}
let generated = false
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput)
yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Stream.tap((event) => {
if (
(event.type === "text-delta" && event.text.length > 0) ||
(event.type === "reasoning-delta" && event.text.length > 0) ||
event.type === "tool-input-start" ||
event.type === "tool-call"
) {
generated = true
}
return handleEvent(event)
}),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.runDrain,
)
if (ctx.assistantMessage.finish === "unknown" && !generated) {
yield* new SessionRetry.EmptyResponseError({
message: "The model returned an empty response with an unknown finish reason",
})
}
}).pipe(
Effect.onInterrupt(() =>
Effect.gen(function* () {
+7 -2
View File
@@ -1,6 +1,6 @@
import type { NamedError } from "@opencode-ai/core/util/error"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { Cause, Clock, Duration, Effect, Schedule, Schema } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"
@@ -23,6 +23,10 @@ export type Retryable = {
}
}
export class EmptyResponseError extends Schema.TaggedErrorClass<EmptyResponseError>()("SessionEmptyResponseError", {
message: Schema.String,
}) {}
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
@@ -181,7 +185,8 @@ export function policy(opts: {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
const retry =
meta.input instanceof EmptyResponseError ? { message: meta.input.message } : retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
@@ -81,11 +81,11 @@ describe("opencode run (non-interactive subprocess)", () => {
30_000,
)
// The test provider's SSE error item is interpreted by the SDK as an unknown
// finish, not a fatal provider/session error. Lock that distinction in so it
// is not accidentally used as the failure compatibility oracle.
// The test provider's SSE error item is interpreted by the SDK as an empty
// response with an unknown finish. That attempt should retry while preserving
// output from the preceding tool-call step.
cliIt.concurrent(
"unknown stream finish preserves partial output and exits 0",
"empty unknown stream finish retries and preserves partial output",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
@@ -95,9 +95,10 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
)
yield* llm.fail("upstream provider exploded mid-stream")
yield* llm.text("recovered response")
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe("partial response\n")
expect(result.stdout).toBe("partial response\nrecovered response\n")
expect(result.stderr).not.toContain("upstream provider exploded mid-stream")
}),
60_000,
@@ -213,7 +214,7 @@ describe("opencode run (non-interactive subprocess)", () => {
)
cliIt.concurrent(
"--format json records partial output for an unknown stream finish",
"--format json records an empty unknown stream retry",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
@@ -223,6 +224,7 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
)
yield* llm.fail("provider failed")
yield* llm.text("recovered json")
const result = yield* opencode.run("fail after output", { format: "json" })
const events = opencode.parseJsonEvents(result.stdout)
@@ -234,9 +236,13 @@ describe("opencode run (non-interactive subprocess)", () => {
"step_finish",
"step_start",
"step_finish",
"step_start",
"text",
"step_finish",
])
expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" }))
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" }))
expect(events.at(-2)?.part).toEqual(expect.objectContaining({ type: "text", text: "recovered json" }))
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "stop" }))
}),
60_000,
)
@@ -80,6 +80,13 @@ describe("workspaceProxyURL", () => {
expect(result.searchParams.get("keep")).toBe("yes")
})
test("strips the host directory param so the remote resolves its own root", () => {
const url = new URL("http://localhost/session/abc?directory=F%3A%5Cproj&keep=yes")
const result = workspaceProxyURL("http://remote:8080/base", url)
expect(result.searchParams.get("directory")).toBeNull()
expect(result.searchParams.get("keep")).toBe("yes")
})
test("preserves hash from request", () => {
const url = new URL("http://localhost/page#section")
const result = workspaceProxyURL("http://remote:8080", url)
@@ -604,6 +604,68 @@ it.live("session.processor effect tests retry recognized structured json errors"
),
)
it.live("session.processor effect tests retry empty responses with unknown finish reasons", () =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()
yield* llm.push(
raw({
chunks: [
{
id: "chatcmpl-test",
object: "chat.completion.chunk",
choices: [{ delta: { role: "assistant" }, finish_reason: null }],
},
{
id: "chatcmpl-test",
object: "chat.completion.chunk",
choices: [{ delta: {}, finish_reason: "unknown_reason" }],
},
],
}),
reply().text("after").stop(),
)
const chat = yield* session.create({})
const parent = yield* user(chat.id, "retry empty")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: mdl,
})
const value = yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies SessionV1.User,
sessionID: chat.id,
model: mdl,
agent: agent(),
system: [],
messages: [{ role: "user", content: "retry empty" }],
tools: {},
})
const parts = yield* MessageV2.parts(msg.id)
expect(value).toBe("continue")
expect(yield* llm.calls).toBe(2)
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
expect(handle.message.error).toBeUndefined()
}),
{ config: (url) => providerCfg(url) },
),
)
it.live("session.processor effect tests publish retry status updates", () =>
provideTmpdirServer(
({ dir, llm }) =>
+9 -34
View File
@@ -1,13 +1,10 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { tint, useTheme } from "../context/theme"
import { go, logo } from "../logo"
import { logo } from "../logo"
export function Logo() {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const variant = () => logoVariant(dimensions().width, dimensions().height)
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
const shadow = tint(theme.background, fg, 0.25)
@@ -51,36 +48,14 @@ export function Logo() {
return (
<box>
{variant() === "hidden" ? null : variant() === "compact" ? (
<For each={go.right.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text, true)}</box>}
</For>
) : variant() === "stacked" ? (
<>
<For each={logo.left.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>}
</For>
<For each={logo.right}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text, true)}</box>}
</For>
</>
) : (
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
</box>
)}
</For>
)}
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
</box>
)}
</For>
</box>
)
}
export function logoVariant(width: number, height: number) {
if (height < 12) return "hidden"
if (width < 22) return "compact"
if (width < 44) return "stacked"
return "full"
}
-11
View File
@@ -1,11 +0,0 @@
import { expect, test } from "bun:test"
import { logoVariant } from "../src/component/logo"
test("adapts the logo to constrained terminals", () => {
expect(logoVariant(19, 24)).toBe("compact")
expect(logoVariant(21, 24)).toBe("compact")
expect(logoVariant(22, 24)).toBe("stacked")
expect(logoVariant(43, 24)).toBe("stacked")
expect(logoVariant(44, 24)).toBe("full")
expect(logoVariant(80, 11)).toBe("hidden")
})