M7 #2: non-retryable ACP errors surface as chat error banner

Pass-1 hit HTTP 404 from Nous Portal (misconfigured model), the
agent reported it via ACP stderr + stopReason="error", and ScarfGo
showed nothing — users saw only the perpetual working spinner. Mac
had an errorBanner for this pattern; ScarfGo didn't.

Promotes the error-banner state and helpers from Mac's ChatViewModel
(Mac target) into RichChatViewModel (ScarfCore) so both apps share:

- `acpError`, `acpErrorHint`, `acpErrorDetails` — the banner triplet.
- `clearACPErrorState()` — called on reset() and addUserMessage()
  so stale errors don't linger across prompts.
- `recordACPFailure(_:client:)` — populate triplet from a thrown
  error + stderr tail, using the existing `ACPErrorHint.classify`.
- `recordPromptStopFailure(stopReason:client:)` — populate triplet
  from a non-retryable ACP `promptComplete` stopReason. Provides a
  fallback hint per stopReason when classify doesn't match.
- `acpStderrProvider: () async -> String` — closure the controller
  sets once so `handlePromptComplete` (called from the event stream)
  can pull recent stderr without the VM holding a direct ACPClient
  reference.

Mac ChatViewModel's local triplet becomes forwarding properties to
richChatViewModel.* — call sites (~15 in ChatViewModel) stay
unchanged. `recordACPFailure` + `clearACPErrorState` become one-line
forwarders.

ScarfGo ChatView gains an `errorBanner` modeled on the Mac one:
- Orange triangle + hint + raw error
- Expand/collapse "Details" button showing stderr tail (monospaced,
  scrollable, max ~140pt tall)
- Copy-all button via `UIPasteboard.general.string` (Mac uses
  NSPasteboard; same structure otherwise)
- Rendered above the message list so it's always visible

ChatController wires `acpStderrProvider` to
`{ await client?.recentStderr ?? "" }` before the handshake and
calls `recordACPFailure` on ACP client start / newSession /
sendPrompt failure paths. `handlePromptComplete` already handles
the common provider-404 case via `recordPromptStopFailureUsingProvider`.

Both schemes build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 13:17:51 +02:00
parent 8e14e0e776
commit 96f60a176d
3 changed files with 207 additions and 23 deletions
@@ -79,6 +79,94 @@ public final class RichChatViewModel {
return last.isAssistant && !last.content.isEmpty
}
// MARK: - Error banner state (shared macOS + iOS)
/// Human-readable error message shown in the chat's error banner.
/// Nil = no active error. Populated from `recordACPFailure(...)`
/// (throws from ACP ops) and from `handlePromptComplete` when the
/// response's `stopReason` is `"error"` (non-retryable provider
/// failures like Nous Portal HTTP 404 for an unknown model
/// pass-1 M7 #2).
public var acpError: String?
/// Short hint derived from the error + stderr tail (e.g.
/// "set ANTHROPIC_API_KEY" or "pick a different model this
/// one isn't in the provider's catalog"). Shown above the raw
/// error in the banner when present. Classified by
/// `ACPErrorHint.classify(errorMessage:stderrTail:)`.
public var acpErrorHint: String?
/// Tail of stderr captured from `hermes acp` at the time of the
/// failure. Shown in a collapsible "Show details" section so
/// users can copy-paste the raw output into a bug report.
public var acpErrorDetails: String?
/// Optional stderr-tail provider the controller can hook up when it
/// creates the ACPClient. Used by `handlePromptComplete` to enrich
/// the error banner on non-retryable stopReasons. The closure is
/// called async so callers can await `ACPClient.recentStderr`
/// without blocking the MainActor. Defaults to nil (no stderr in
/// banner, just the hint fallback).
public var acpStderrProvider: (@Sendable () async -> String)?
/// Clear the error triplet. Call on session reset / new chat /
/// successful new prompt so stale errors don't linger.
public func clearACPErrorState() {
acpError = nil
acpErrorHint = nil
acpErrorDetails = nil
}
/// Populate the error triplet from a thrown Error + the ACPClient
/// we can query for recent stderr. Safe to call from anywhere
/// that catches an ACP op failure.
public func recordACPFailure(_ error: Error, client: ACPClient?) async {
let msg = error.localizedDescription
let stderrTail = await client?.recentStderr ?? ""
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
acpError = msg
acpErrorHint = hint
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
}
/// Populate the error triplet when `handlePromptComplete` sees a
/// non-`end_turn` stopReason (i.e. the provider rejected the
/// prompt and Hermes correctly surfaced it via ACP). The hint
/// classifier reads the stderr tail; for stopReason="error" cases
/// the tail typically contains the provider's HTTP status + reason.
public func recordPromptStopFailure(stopReason: String, client: ACPClient?) async {
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
let stderrTail = await client?.recentStderr ?? ""
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
?? Self.fallbackHint(for: stopReason)
acpError = msg
acpErrorHint = hint
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
}
/// Same as `recordPromptStopFailure` but pulls stderr from the
/// `acpStderrProvider` closure the controller registered. Used by
/// `handlePromptComplete` where we don't have direct ACPClient
/// access.
private func recordPromptStopFailureUsingProvider(stopReason: String) async {
let msg = "Prompt ended without a response (stopReason: \(stopReason))."
let stderrTail = await acpStderrProvider?() ?? ""
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
?? Self.fallbackHint(for: stopReason)
acpError = msg
acpErrorHint = hint
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
}
private static func fallbackHint(for stopReason: String) -> String? {
switch stopReason {
case "error": return "The provider returned an error. Check the details below — often the configured model isn't in the provider's catalog."
case "refusal": return "The session may have been cleared on the server. Start a new chat to continue."
case "max_tokens": return "The response was cut off before any content was produced. Try a shorter prompt or raise the max-tokens limit in Settings."
default: return nil
}
}
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
public private(set) var acpInputTokens = 0
public private(set) var acpOutputTokens = 0
@@ -165,6 +253,9 @@ public final class RichChatViewModel {
acpInputTokens = 0
acpOutputTokens = 0
acpThoughtTokens = 0
acpError = nil
acpErrorHint = nil
acpErrorDetails = nil
acpCachedReadTokens = 0
acpCommands = []
pendingPermission = nil
@@ -197,6 +288,10 @@ public final class RichChatViewModel {
/// Add a user message immediately (before DB write) for instant UI feedback.
public func addUserMessage(text: String) {
// Fresh prompt clear any stale error banner from a prior
// failed attempt so we don't show "old error" + "still thinking"
// simultaneously. Matches the Mac ChatViewModel pattern.
clearACPErrorState()
let id = nextLocalId
nextLocalId -= 1
let message = HermesMessage(
@@ -408,6 +503,13 @@ public final class RichChatViewModel {
finishReason: response.stopReason,
reasoning: nil
))
// Pass-1 M7 #2: surface the same failure as a top-of-chat
// error banner with the stderr tail, so users don't have
// to rely solely on the system-message to understand why
// nothing happened. The controller registers
// `acpStderrProvider`; if absent, the banner still shows
// with the hint fallback.
Task { await self.recordPromptStopFailureUsingProvider(stopReason: response.stopReason) }
}
// Accumulate token usage from this prompt