A TUI run against an unreachable endpoint failed with only 'fetch failed':
undici wraps transport failures in a bare TypeError whose diagnosis lives
on .cause, and every diagnostic seam rendered only error.message. The
readline front door additionally rendered failed turns as pure silence.
- dsh-llm: new errorChain(value) renders the full cause chain and
AggregateError members with circular/hostile-coercion containment.
- llm-deepseek: pre-response transport failures throw LlmError('NETWORK')
naming the endpoint and chaining the fetch TypeError; aborts keep their
DOMException so the loop still classifies them as cancellation.
- agent-loop: durable turn/end error messages and logger warnings render
through errorChain; local renderThrown copies removed.
- ui-stdio: failure turn/end reasons now render ([turn failed <code>],
[turn aborted], [turn rejected], output-token-limit); startup-failure
logs use errorChain.
- ui-tui: agent/error notices and the startup-failure line use errorChain.
4.5 KiB
Agent Note: Render error cause chains at every diagnostic seam
Status: implemented
English | 中文
Problem
A TUI run against an unreachable DeepSeek endpoint failed with the single notice fetch failed and no further detail. Two independent gaps produced that dead end:
- undici's
fetchwraps every transport failure (DNS, refused connection, TLS, proxy) in a bareTypeError: fetch failedwhose actionable detail —ECONNREFUSED,bad port, the Happy Eyeballs AggregateError — lives onerror.cause. Every diagnostic seam in the harness rendered onlyerror.message(orString(error), which is equivalent for Errors), so the wrapper masked the diagnosis in the TUI notice, the durableturn/endreason, and every logger line. - The readline front door (
dsh-stdio) rendered no failure reason at all: aturn/endwithreason.kind === 'error'printed nothing but the next>prompt, so the same failure indemo:replwas pure silence.
Decision
dsh-llmexportserrorChain(value): renders a thrown value with its fullcausechain (outer: inner: …) and AggregateError members (msg [m1; m2]), with circular-cause and hostile-coercion containment. It is a diagnostic-surface renderer only; routing stays onHarnessError.code.- The DeepSeek adapter wraps a pre-response transport failure in
LlmError('NETWORK')naming the configuredbaseURLand chaining the originalTypeErrorascause. An aborted request keeps itsDOMExceptionso the loop still classifies it as cancellation, not a provider failure. - Every diagnostic seam renders through
errorChaininstead oferror.message/String(error): the agent-loop's durableturn/enderror message (errorData), its logger warnings, the TUI'sagent/errornotice and startup-failure line, anddsh-stdio's startup-failure log lines. The per-packagerenderThrowncopies indsh-agent-loop,dsh-stdio, anddsh-tuiare deleted in favor of the one shared renderer. dsh-stdiorenders failureturn/endreasons:[turn failed <code>] <message>,[turn aborted] <reason>,[turn rejected] <reason>,[turn interrupted by a previous process exit], and the output-token-limit notice. Unknown merge-extended kinds fall through as ordinary turn ends.
errorChain lives in dsh-llm beside HarnessError for the same reason the base class does: it is the leaf package every consumer already imports, so sharing costs no new dependency edge.
Alternatives considered
Chain rendering inside each error's constructor (bake the cause into message). Rejected: it double-renders once consumers also walk cause (the first draft of the adapter fix produced … fetch failed: bad port: fetch failed: bad port), and it destroys the structured chain for consumers that want to route on the inner error.
A cause-aware logger exporter only. Rejected: the durable turn/end reason and the TUI notice are not logger lines; the masked message would persist in the session log — the single durable record of an in-turn failure — and in the primary UI surface.
Per-package renderThrown upgrades. Rejected: three packages already carried near-identical private copies; upgrading each separately entrenches the duplication the shared renderer removes.
Consequences
- A transport failure now reads
DeepSeek API request to <baseURL> failed: fetch failed: connect ECONNREFUSED …in the TUI notice, the readline transcript, and the persisted session log, at the cost of longer diagnostic strings. - Durable
turn/enderror messages include cause detail. Existing snapshot fixtures replay byte-identically because their scripted errors carry nocause(for such errorserrorChain(err)equalserr.message); only unit-test expectation strings changed. A fixture recorded from a real transport failure would carry the chain. errorChainrendersmessagewithout the class name (String(error)renderedError: <message>), so a bareTypeErrorin a log line loses its type label unless its message is empty (then the name is the fallback). The chain detail was judged worth more than the class name at these seams.dsh-stdiooutput for failed turns is no longer silent; piped consumers that parsed the transcript see new[turn …]lines.- Remaining
renderThrowncopies indsh-subagent,dsh-workflow,dsh-skill,dsh-workflow-workerthread, andcli-demostill render without the chain; they wrap package-local errors that carry their own messages, and can adopterrorChainwhen their diagnostics prove insufficient.