Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton c3111fe15f fix(tui): undo latest pending prompt 2026-08-08 21:18:27 -04:00
6 changed files with 82 additions and 200 deletions
+5 -39
View File
@@ -185,15 +185,15 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
// for any field name that looks sensitive) plus literal (replace any actual
// secret values we sent in the request, in case the response echoes one back).
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
Array.from(secrets).reduce(
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
Array.from(secretValues(request)).reduce(
(text, secret) => text.split(secret).join(REDACTED),
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
)
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
if (body === undefined) return {}
const redacted = redactBody(body, secrets)
const redacted = redactBody(body, request)
if (redacted.length <= BODY_LIMIT) return { body: redacted }
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
}
@@ -240,7 +240,7 @@ const statusError =
const headers = normalizedHeaders(response.headers)
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, secretValues(request))
const details = responseBody(body, request)
return yield* new AIError({
module: "RequestExecutor",
method: "execute",
@@ -261,40 +261,6 @@ const statusError =
})
})
// Classifies an HTTP failure captured outside the executor (for example by the
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that
// executor-driven requests produce. The originating request is not available on
// that path, so the method is assumed (language model calls are always POST),
// request headers are empty, and only structural body redaction applies.
export const classifyHttpFailure = (input: {
readonly message: string
readonly url: string
readonly status?: number | undefined
readonly responseHeaders?: Record<string, string> | undefined
readonly responseBody?: string | undefined
}) => {
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
return classifyProviderFailure({
message: input.message,
status: input.status,
retryAfterMs: retryAfter,
rateLimit,
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
response:
input.status === undefined
? undefined
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
...details,
requestId: requestId(headers),
rateLimit,
}),
})
}
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const transportError = (input: {
readonly message: string
+2 -40
View File
@@ -1,7 +1,6 @@
export * as AISDK from "./aisdk"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { APICallError } from "@ai-sdk/provider"
import type {
JSONSchema7,
JSONValue,
@@ -30,7 +29,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import type { ID, Info } from "./model"
@@ -724,15 +723,7 @@ function llmError(method: string, error: unknown) {
const reason =
error instanceof AIError
? new InvalidProviderOutputReason({ message: error.message })
: APICallError.isInstance(error)
? RequestExecutor.classifyHttpFailure({
message: unknownErrorMessage(error),
url: error.url,
status: error.statusCode,
responseHeaders: error.responseHeaders,
responseBody: error.responseBody,
})
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
return new AIError({
module: "AISDK",
method,
@@ -740,33 +731,4 @@ function llmError(method: string, error: unknown) {
})
}
const ProviderErrorBody = Schema.Struct({
message: Schema.optionalKey(Schema.String),
code: Schema.optionalKey(Schema.String),
error: Schema.optionalKey(
Schema.Struct({
message: Schema.optionalKey(Schema.String),
code: Schema.optionalKey(Schema.String),
}),
),
})
const decodeErrorData = Schema.decodeUnknownOption(ProviderErrorBody)
const decodeErrorBody = Schema.decodeUnknownOption(Schema.fromJsonString(ProviderErrorBody))
// AI SDK errors such as AI_APICallError can carry an empty message while still
// holding structured provider details. Derive a safe non-empty fallback from
// recognized error fields only; never surface the raw response payload.
function unknownErrorMessage(error: unknown) {
const message = error instanceof Error ? error.message : String(error)
if (message.trim() !== "") return message
if (!APICallError.isInstance(error)) return "Provider request failed"
const body =
Option.getOrUndefined(decodeErrorData(error.data)) ?? Option.getOrUndefined(decodeErrorBody(error.responseBody))
const detail = body?.error ?? body
if (detail?.message) return detail.message
const prefix =
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
return detail?.code ? `${prefix}: ${detail.code}` : prefix
}
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
-114
View File
@@ -1,7 +1,5 @@
import { APICallError } from "@ai-sdk/provider"
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { AISDK } from "@opencode-ai/core/aisdk"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { LLM, AIError, LLMEvent, Message } from "@opencode-ai/ai"
@@ -339,115 +337,3 @@ it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
}),
)
const failingModel = (failure: unknown): LanguageModelV3 => ({
specificationVersion: "v3",
provider: "test",
modelId: "test",
supportedUrls: {},
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
doStream: () => Promise.reject(failure),
})
const streamFailure = (failure: unknown) =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => failingModel(failure) }
})
const resolved = yield* aisdk.model(model("test-ai-sdk"))
return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
Effect.provide(client),
Effect.flip,
)
})
it.effect("preserves non-empty AI SDK error messages", () =>
Effect.gen(function* () {
const error = yield* streamFailure(new Error("Bad Request"))
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Bad Request" })
}),
)
const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
new APICallError({
message: "",
url: "https://api.example.com/chat",
requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
responseHeaders: { authorization: "Bearer secret-token" },
...input,
})
it.effect("derives status and code when the AI SDK error message is empty", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 404,
responseBody: '{"error":{"message":"","code":"not_found"}}',
data: { error: { message: "", code: "not_found" } },
}),
)
expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
expect(error.reason.message).not.toContain("secret-token")
expect(error.reason.message).not.toContain("private prompt")
const projected = toSessionError(error)
expect(projected.type).toBe("provider.invalid-request")
expect(projected.status).toBe(404)
expect(projected.message).not.toBe("")
}),
)
it.effect("persists redacted HTTP context from AI SDK call errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 404,
responseBody: '{"error":{"message":"","code":"not_found"}}',
}),
)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
const http = "http" in error.reason ? error.reason.http : undefined
expect(http?.request.url).toBe("https://api.example.com/chat")
expect(http?.response?.status).toBe(404)
expect(http?.response?.headers["authorization"]).toBe("<redacted>")
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
}),
)
it.effect("classifies retryable AI SDK failures with retry-after details", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 429,
responseHeaders: { "retry-after": "7" },
}),
)
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
}),
)
it.effect("prefers a structured provider message over the code fallback", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 404,
data: { error: { message: "The requested model does not exist", code: "not_found" } },
}),
)
expect(error.reason.message).toBe("The requested model does not exist")
}),
)
it.effect("falls back to the status alone for malformed response bodies", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
statusCode: 502,
responseBody: "<html>Bad Gateway</html>",
}),
)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 502 })
expect(error.reason.message).toBe("Provider request failed with HTTP 502")
}),
)
+13 -7
View File
@@ -84,6 +84,7 @@ import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { PluginSlot } from "../../plugin/render"
import { usePlugin } from "../../plugin/context"
import { undoMessage } from "./undo"
import {
cacheReuseDrop,
createSessionRows,
@@ -656,19 +657,24 @@ export function Session() {
group: "Session",
slash: { name: "undo" },
run: () => {
const admitted = pendingUsers().at(-1)
const boundary = session()?.revert?.messageID
const message = messages().findLast(
(message): message is SessionMessageUser =>
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
)
const message = admitted
? { id: admitted.id, ...admitted.data }
: messages().findLast(
(message): message is SessionMessageUser =>
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
)
if (!message) {
toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 })
dialog.clear()
return
}
void client.api.session.revert
.stage({ sessionID: route.sessionID, messageID: message.id })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
void undoMessage(client.api, {
sessionID: route.sessionID,
messageID: message.id,
pending: admitted !== undefined,
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
prompt()?.set({
...projectedPromptInput(message),
pasted: [],
+14
View File
@@ -0,0 +1,14 @@
import type { OpenCodeClient } from "@opencode-ai/client"
export async function undoMessage(
client: OpenCodeClient,
input: { readonly sessionID: string; readonly messageID: string; readonly pending: boolean },
) {
const revert = () => client.session.revert.stage(input).then(() => undefined)
if (!input.pending) return revert()
return client.session.pending.cancel({ sessionID: input.sessionID, inputID: input.messageID }).catch((error) => {
if (typeof error !== "object" || error === null || !("_tag" in error) || error._tag !== "ConflictError") throw error
return revert()
})
}
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client"
import { undoMessage } from "../../../src/routes/session/undo"
test.each([
{ name: "projected", pending: false, cancelStatus: 204, expected: ["revert"] },
{ name: "pending", pending: true, cancelStatus: 204, expected: ["cancel"] },
{ name: "promoted race", pending: true, cancelStatus: 409, expected: ["cancel", "revert"] },
])("undo routes $name messages", async ({ pending, cancelStatus, expected }) => {
const calls: string[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
const operation = request.method === "DELETE" ? "cancel" : "revert"
calls.push(operation)
if (operation === "cancel") {
if (cancelStatus === 409)
return Response.json({ _tag: "ConflictError", message: "Input was promoted" }, { status: 409 })
return new Response(null, { status: 204 })
}
return Response.json({ data: { messageID: "msg_user" } })
},
{ preconnect: fetch.preconnect },
),
})
await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending })
expect(calls).toEqual([...expected])
})
test("undo does not reinterpret transport failures as promotion races", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async () => {
throw new Error("offline")
},
{ preconnect: fetch.preconnect },
),
})
await expect(
undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending: true }),
).rejects.toMatchObject({ reason: "Transport" })
})