mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-05 01:43:27 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5562652f63 | |||
| 031510e1c3 | |||
| f0afb6750e | |||
| 703d09f306 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
Reference in New Issue
Block a user