mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
fix(opencode): surface upstream errors and retries (#1433)
* fix(opencode): report why a prompt failed instead of pointing at logs The provider's own explanation already reaches this process: the ACP transport rejects session/prompt with the JSON-RPC error message verbatim. The launcher caught it, logged it, and handed the user a fixed "OpenCode prompt failed. Check logs for details." — a remote user is by definition not at the machine holding those logs, so a rate-limited session simply stopped with no stated reason. Only the message is used; that channel carries no response headers, cookies or body. It is unbounded though (a 20KB provider body produced a 20,210-character message), so it is capped at the same 200 characters the compaction bridge already applies to provider text, and the JSON-RPC "Internal error: " wrapper is stripped. A failure with nothing readable to say still renders the sentence it always did. * feat(opencode): surface upstream retries from the agent event stream A provider rate limit leaves OpenCode retrying indefinitely, and it announces that on exactly one channel: its own server event stream. Measured against a provider stubbed to answer 429 — 40 minutes, 85 retries, zero ACP notifications, zero stderr bytes, session/prompt never settling. HAPI showed a session that looked like it was thinking and said nothing else. Subscribes to that stream once the ACP session id is known and reports retries as the same api_error system message Claude sessions already use, so the web timeline folds a run of them into one block whose attempt count climbs. The reason rides along in the payload, and the presentation now appends it to "Retrying..." when an agent supplies one; sessions that supply nothing render exactly as before. Not every attempt is announced. The backoff tops out at 30 seconds and OpenCode does not give up, so a session held against a daily quota would otherwise persist two messages a minute for as long as it is left running. The first few attempts are reported, then only attempt numbers that are powers of two, which needs no clock to decide. The subscription must be scoped with ?directory=: without it the endpoint delivers heartbeats and no session events at all, with neither an error nor a 404 to notice. Its session.error event is deliberately not read — it carries the Authorization header, cookies and the full response body verbatim, and the same failure already reaches the user stripped to a message through the ACP prompt error. Delegated turns are not covered: OpenCode's subagent tool runs them in a child session with its own id, and this follows only the one it was opened for. No countdown is rendered, though the payload offers one: a timeline block outlives the turn it describes. Turn state is left alone; a retrying session really is busy. * fix(opencode): only relay prompt failures OpenCode itself reported AcpStdioTransport flattens a JSON-RPC error response to new Error(response.error.message), so a rejected session/prompt is indistinguishable by shape from an error the transport built locally. Its process-close error appends up to 4KB of raw subprocess stderr to the message, which the formatter would then have published to the hub and every connected client. Requiring the "Internal error: " wrapper OpenCode puts on every service failure turns this into an allowlist: an unrecognised rejection renders the sentence this path rendered before rather than whatever it happened to contain. Retry text is collapsed to one line before it is judged, since a whitespace-only message is truthy and embedded newlines break the one-line contract the helper states.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { asString, isObject } from '@hapi/protocol';
|
||||
|
||||
/**
|
||||
* Longest run of provider-authored text this session puts in front of a user.
|
||||
*
|
||||
* OpenCode does not bound what it forwards: a provider that answers a rate
|
||||
* limit with a 20KB body produces a `session/prompt` error message of 20,210
|
||||
* characters verbatim (measured against opencode 1.18.15). That is not an
|
||||
* error report, it is a wall — it buries the one sentence that matters, and
|
||||
* every copy of it is persisted in the hub's message store and replayed to
|
||||
* every client that opens the session.
|
||||
*
|
||||
* 200 is not a fresh guess: it is the cap `safeErrorReason()` in
|
||||
* opencodeCompactBridge.ts already applies to the same kind of value (the
|
||||
* provider message OpenCode persists on a failed compaction), so the two
|
||||
* places this session shows provider text agree. Real messages are far
|
||||
* shorter — the rate-limit report this feature was built for is 79
|
||||
* characters.
|
||||
*/
|
||||
export const OPENCODE_PROVIDER_TEXT_MAX_LENGTH = 200;
|
||||
|
||||
/** Shown when the failure carries nothing readable; preserved verbatim from before this text was wired through, so that path is a strict no-op. */
|
||||
const OPENCODE_PROMPT_FAILED_FALLBACK = 'OpenCode prompt failed. Check logs for details.';
|
||||
|
||||
/**
|
||||
* The JSON-RPC layer's own wrapper, and the marker that a rejection carries
|
||||
* agent-authored text at all.
|
||||
*
|
||||
* OpenCode maps every unhandled service failure to code -32603 and prefixes
|
||||
* the provider's text with this, so it appears on messages that have nothing
|
||||
* internal about them ("Internal error: Rate limit exceeded…"). It describes
|
||||
* the transport, tells the reader nothing, and pushes the actual reason past
|
||||
* the fold on a phone — so it is stripped before display.
|
||||
*
|
||||
* Requiring it is what keeps this an allowlist. `AcpStdioTransport` flattens
|
||||
* a JSON-RPC error response to `new Error(response.error.message)`, losing
|
||||
* `code` and `data`, so by the time the launcher catches a rejection it
|
||||
* cannot tell one apart from an error the transport built itself — and those
|
||||
* are not agent-authored. The process-close error appends up to 4KB of raw
|
||||
* subprocess stderr to its message, which must never reach a remote
|
||||
* timeline; the timeout error names an internal method and duration. Neither
|
||||
* carries this wrapper. Anything without it falls back to
|
||||
* {@link OPENCODE_PROMPT_FAILED_FALLBACK}, which is what this path rendered
|
||||
* before, so an unrecognised shape degrades to the old behaviour rather than
|
||||
* publishing whatever it happened to contain.
|
||||
*/
|
||||
const JSON_RPC_INTERNAL_ERROR_PREFIX = /^internal error:\s*/i;
|
||||
|
||||
/**
|
||||
* One displayable line: every whitespace run (including newlines a provider
|
||||
* embedded) collapsed to a single space.
|
||||
*
|
||||
* Exported for the same reason {@link OPENCODE_PROVIDER_TEXT_MAX_LENGTH} is:
|
||||
* the event stream relays provider text to the same surfaces under the same
|
||||
* one-line contract, and two copies of this rule would drift.
|
||||
*/
|
||||
export function collapseWhitespace(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps provider-authored text at {@link OPENCODE_PROVIDER_TEXT_MAX_LENGTH}.
|
||||
*
|
||||
* The ellipsis is load-bearing rather than decoration: a rate-limit message
|
||||
* cut mid-sentence without one reads as a complete (and different) statement
|
||||
* from the provider.
|
||||
*/
|
||||
export function truncateOpencodeProviderText(text: string): string {
|
||||
if (text.length <= OPENCODE_PROVIDER_TEXT_MAX_LENGTH) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, OPENCODE_PROVIDER_TEXT_MAX_LENGTH - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent's own words behind a failed `session/prompt`, as one line, with
|
||||
* the JSON-RPC wrapper stripped — or null when the rejection is not one
|
||||
* OpenCode authored.
|
||||
*
|
||||
* Not truncated here; {@link formatOpencodePromptError} applies the cap at
|
||||
* the point of display.
|
||||
*
|
||||
* Only `message` is read, and only when it carries
|
||||
* {@link JSON_RPC_INTERNAL_ERROR_PREFIX}. That pairing is the whole safety
|
||||
* property: the wrapper says the text came back over the wire from OpenCode,
|
||||
* and that channel is sanitised — probed with a stub provider answering
|
||||
* `Authorization: Bearer …`, `Set-Cookie: …` and a 4KB body, the ACP error
|
||||
* carried none of them, only the provider's message text. (The same failure
|
||||
* also appears on OpenCode's `/event` stream as `session.error`, where all of
|
||||
* it *is* present verbatim — which is exactly why the event subscription does
|
||||
* not read that event.)
|
||||
*/
|
||||
function extractOpencodePromptErrorText(error: unknown): string | null {
|
||||
const raw = error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: isObject(error)
|
||||
? asString(error.message)
|
||||
: null;
|
||||
if (raw === null) return null;
|
||||
const collapsed = collapseWhitespace(raw);
|
||||
if (!JSON_RPC_INTERNAL_ERROR_PREFIX.test(collapsed)) return null;
|
||||
const text = collapsed.replace(JSON_RPC_INTERNAL_ERROR_PREFIX, '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
/** Prefix {@link formatOpencodePromptError} puts in front of the provider's text. */
|
||||
const OPENCODE_PROMPT_FAILED_PREFIX = 'OpenCode prompt failed: ';
|
||||
|
||||
/**
|
||||
* Renders the failure of an OpenCode `session/prompt` for the user.
|
||||
*
|
||||
* The reason was never missing — `AcpStdioTransport` rejects the pending
|
||||
* request with the JSON-RPC `error.message` verbatim, which is where the
|
||||
* provider's own explanation lives. The launcher used to catch that, log it,
|
||||
* and hand the user a fixed "check logs for details" string instead. A
|
||||
* remote user is by definition not at the machine holding those logs, so
|
||||
* that read as "it stopped, and you may not know why".
|
||||
*
|
||||
* The old wording is kept as the prefix rather than replaced, so an operator
|
||||
* who has learned to grep for "OpenCode prompt failed" still finds it, and
|
||||
* a failure with nothing readable to say renders exactly the sentence it
|
||||
* always did.
|
||||
*/
|
||||
export function formatOpencodePromptError(error: unknown): string {
|
||||
const message = extractOpencodePromptErrorText(error);
|
||||
return message
|
||||
? `${OPENCODE_PROMPT_FAILED_PREFIX}${truncateOpencodeProviderText(message)}`
|
||||
: OPENCODE_PROMPT_FAILED_FALLBACK;
|
||||
}
|
||||
Reference in New Issue
Block a user