Skip to content

fix(claude): keep the init handshake out of the error card - #25514

Closed
paoValle wants to merge 2 commits into
stablyai:mainfrom
paoValle:fix/claude-init-frame-not-an-error
Closed

paoValle wants to merge 2 commits into
stablyai:mainfrom
paoValle:fix/claude-init-frame-not-an-error

Conversation

@paoValle

@paoValle paoValle commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Author: @paolovalletta_

ELI5

Every Claude session starts with a "here is what I loaded" handshake from the CLI — its tools, model, and MCP servers. Orca was showing that handshake as a red error card titled claude · message:system:init, because Orca's own error detector looks anywhere inside a frame for a word like failed, and one MCP server that could not start is enough. The card said nothing a user could act on. Now the handshake stays out of the transcript, and a server the session could not use gets one ordinary warning row naming it — in the CLI's own words.

What Changed

Before, in the chat of a freshly started Claude session with one MCP server that failed to start:

⚠ claude · message:system:init                      [Details]

After:

⚠ MCP server github: auth expired

…and a handshake whose servers all connected produces no row at all, which is what the catalogue always intended.

Mechanism. unhandledProviderFrameJournalItem titles its row from the wire opcode (unhandled-provider-frame.ts:148), and message:system:init is catalogued status-chrome — no row — in provider-frame-disposition.ts:98. But classifyProviderFrame checks the payload first:

if (hasProviderError(payload)) return 'error-surface'

hasProviderError walks the whole payload recursively: any nested value under type | status | state | subtype | outcome starting with error/fail, or any non-empty error | failureReason | failure_reason, promotes the frame. The init frame carries its whole roster, so one failed MCP server outranked the catalogue and the handshake rendered as a failure row.

This gives the kind a dedicated translator, which is the mechanism the repository already uses for exactly this failure mode — message:system:task_* reached users as claude · message:system:task_notification for the same reason, and provider-frame-disposition.test.ts documents that coverage as the fix. The new arm runs before the generic fallback, so the opcode row cannot come back whatever the payload contains.

src/main/claude/claude-init-row.ts is the translator (same shape as the existing claude-informational-row.ts): claudeInitRowBody returns a tone: 'warning' status row naming every MCP server the frame reports unavailable, and null for a clean handshake.

Why

Why not just suppress the kind. unhandled-provider-frame.ts:43 records that a failed dependency reports itself there "and nowhere else". Muting the kind would delete the only report of a real failure — including the case this issue is about, if the trigger is a failed MCP server. Naming the server keeps the signal and removes the opcode.

Why tone: 'warning' and not a failure row. The failure vocabulary in agent-session-failure-words.ts is a typed, client-read contract (AgentSessionFailureFact); a per-server dependency fault is not a fact that vocabulary has, and a whole session's handshake is not what failed. A warning row is what the reporter asked for — "processed silently or shown as a normal informational entry, not as an error" — and it is the same shape the informational note already uses.

Why not narrow hasProviderError instead. It outranks the catalogue on purpose, for frames that genuinely report a failure under an innocuous name, and the containment the repository uses for the false positives is translator coverage. Changing the predicate would have re-ranked every provider's frames to fix one kind.

Linked Issue

Fixes #25477

Visual Proof

N/A — no new UI surface, so there is nothing to screenshot: the existing status-row component that already renders tone: 'warning' notes renders this one, and what changes is which rows exist. The before/after at the row level:

Row
Before { kind: 'status', text: 'claude · message:system:init', failure: { … } } — error surface, payload behind the disclosure
After { kind: 'status', tone: 'warning', text: 'MCP server github: auth expired' }

Testing

Check Result
claude-init-row.test.ts (new) — clean handshake, failed server with a sentence, failed server without one, status-as-failure, two servers at once 5 passed
Mutation check: the same tests with the init arm removed from the fallback 2 fail, printing the reported card verbatim: text: "claude · message:system:init"
claude-structured-provider-fallback.test.ts, claude-informational-row.test.ts passed, 12 across the three files
pnpm test src/main/claude/ 1396 passed, 17 skipped (131 files)
pnpm test src/main/native-chat/agent-session-wire/ 1432 passed (167 files)
ORCA_REAL_CLAUDE_CLI_TEST=1 pnpm test src/main/claude/claude-structured-real-cli.test.ts src/main/claude/claude-structured-real-cli-fold.test.ts 10 passed, 47s
pnpm tc (node + cli + web) exit 0
oxlint + oxfmt on the three files (pre-commit) clean

Platforms tested: macOS. The change is provider-wire translation with no platform branch and no filesystem or process dependency; the frames arrive the same way from a local or a remote execution host. Windows and Linux were not run.

AI Disclosure

Written with an AI coding agent, session model deepseek-v4.1-flash (provider opencode-go). All commands and results above were produced on this machine.

Review

  • Cross-platform: no platform branch, no path, no shell. Identical on macOS, Linux and Windows.
  • SSH / remote / local: the translator runs on the host that owns the journal, so a remote session gets the same row from the same frame; nothing here reads the local filesystem or a local process.
  • Agent and integration compatibility: Claude only — the arm matches the message:system:init kind the Claude schema declares. The Codex path and the shared hasProviderError predicate are untouched, so no other provider's rows change.
  • Performance risk: slightly cheaper. The recursive payload walk that promoted this frame no longer runs for this kind, and a clean handshake appends nothing.
  • UI quality: one ordinary warning row replaces a red error card with an opcode title. No new component, no new tone, no layout change.
  • Security: no new input, network, filesystem or credential path. The row text is the server's own sentence, already inside the frame Orca received, bounded by the shared boundInlineText limits.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Shape of a failed entry, and the one thing I could not verify. Every captured Claude fixture in this repository has mcp_servers: [], so I have no capture of a failed server and the row reads name, status and error as the frame's contract describes them. I asked the reporter on the issue for the payload behind their card's Details disclosure. If a real entry names its fields differently, the card is still gone — only the row text would need one line adjusted.
  • needs-auth is deliberately not treated as a failure. The repository has no spelling of that state anywhere (needs-auth appears nowhere in src/), so treating it as one would be vocabulary I cannot support. Say the word and it is one line.
  • Say no to the broader question. The same payload promotion can in principle hit any catalogued kind whose payload nests a failed-looking value. I did not generalise beyond the kind in the report: which other kinds legitimately carry nested per-item states is a decision I have no evidence for.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred) — tc, both affected suites, the real-CLI suite, oxlint and oxfmt pass locally; pnpm lint and build are left to CI

hasProviderError walks a frame's whole payload, so an MCP server this session
could not use promoted the entire system/init handshake to error-surface and
the roster reached users as `claude · message:system:init`.

Own the kind the way the background-task frames are owned: the handshake
writes no row, and an unavailable MCP server writes one warning row in the
CLI's own words instead (stablyai#25477).

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Init frames get a typed translator — src/main/claude/claude-init-row.ts returns a tone: 'warning' status row naming each unavailable MCP server (its own error sentence when present, else "MCP server <name> failed to start"), or null for a clean handshake.
  • The fallback owns the kind before the generic path — src/main/claude/claude-structured-provider-fallback.ts:170 short-circuits message:system:init, so hasProviderError can no longer promote the roster to an error-surface card titled after the opcode.
  • Tests pin the regression — src/main/claude/claude-init-row.test.ts covers the clean roster, the failed server with and without its own sentence, and the pure translator.

ℹ️ Failed-server mapping matches the pinned SDK contract

The PR body flags the one thing the author could not verify locally — that a failed mcp_servers entry is shaped as { name, status, error } with status: 'failed'. The pinned @anthropic-ai/claude-agent-sdk@0.3.284 type confirms it, so the warning row will render for the real failure and no field mapping is outstanding.

Technical details
# SDK init-frame schema (0.3.284)

## Evidence
- `SDKSystemMessage.mcp_servers`: `{ name: string; status: string; source?: string }[]`
- `McpServerStatus.status`: `'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled'`
- `McpServerStatus.error`: `string?` — documented "available when status is 'failed'"

## Why `needs-auth` / `disabled` are correctly excluded
Neither value starts with `error`/`fail` and `error` is only set for `'failed'`, so neither
promoted the frame to an error card before this change. Not handling them introduces no regression.

## Note (not a request)
Init frames are emitted at the start of each turn/cycle, so a persistently failed server now yields a
warning row per turn — the same cadence as the error card it replaces.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

🧰 Additional context used
📚 Code guidelines (1)
AGENTS.md — auto-discovered

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 689c63fa-07a7-42ba-923a-3689e56aca8a
📥 Commits

Reviewing files that changed from the base of the PR and between b515cc2 and 89e355b.

📒 Files selected for processing (2)
  • src/main/claude/claude-init-row.test.ts
  • src/main/claude/claude-init-row.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/claude/claude-init-row.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

The change adds a Claude init-frame translator that creates a warning journal row for unavailable MCP servers. The provider fallback handles init frames with this translator instead of the generic unhandled-frame path. Tests cover empty, connected, failed, and multiple-server cases.

Priority: ➖ Normal

Severity of issue fixed: Low

Merge Risk: ⚪ Minimal · up to 89e35

Healthy or empty server rosters produce no warning row, while unavailable servers are surfaced as bounded warnings. No material merge-blocking risk remains.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 89e35

The change preserves existing access and publication controls while replacing an unhelpful error card with server-specific warnings. No security regression was demonstrated, but handling of failures outside the server roster remains unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The changed path affects warning content visible through the already-established session journal. Server-supplied names and sentences do not select journal identity, acquire credentials, or authorize tool execution in this path.

Trust Boundaries and Controls

  • observed — The reader accepts only record-shaped entries and string-valued display fields. It bounds the aggregated warning using the existing 16 KiB inline-head limit and explicit truncation marker; the ordinary warning renderer displays text as React text content.

Resilience and Maintainability Implications

  • observed — The new warning uses the existing queued write and publication mechanism. Publication waits behind journal writes and is suppressed after sink closure or failure. Sequence-based repetition and admission handling are inherited from the base fallback, not new recovery guarantees.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: keeping Claude’s init handshake out of the error card.
Description check ✅ Passed The description covers the required sections, links issue #25477, explains the change and rationale, gives visual-proof status, and reports tests and platform coverage. The checklist is completed. The…
Linked Issues check ✅ Passed [#25477] The Claude fallback handles message:system:init before the generic fallback. A clean handshake produces no row. Unavailable MCP servers produce warning rows, including entries captured with…
Out of Scope Changes check ✅ Passed The init-frame translator, warning cases, and tests support [#25477]. The journal-opener change closes test database handles before directory cleanup and supports reliable test cleanup. No unrelated c…
  • Fix all pre-merge checks with AI
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: c2bea301-bc1c-4296-ad0a-6b415955d36a
📥 Commits

Reviewing files that changed from the base of the PR and between 1569e06 and b515cc2.

📒 Files selected for processing (3)
  • src/main/claude/claude-init-row.test.ts
  • src/main/claude/claude-init-row.ts
  • src/main/claude/claude-structured-provider-fallback.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

Comment thread src/main/claude/claude-init-row.test.ts Resolved
Comment thread src/main/claude/claude-init-row.ts Resolved
- Report an MCP server the CLI could not authenticate: it is a dependency the
  session cannot use, and unlike a crash the missing action is the user's.
- Close the test journal before afterEach removes its directory. The SQLite
  handle is still open there, and Windows refuses to remove an open one.
- Pin the captured shape (name/status/source, no sentence), which is the arm
  that actually fires.
@paoValle

paoValle commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Both CodeRabbit findings are addressed in 89e355b, and the second one is now pinned against real data.

1. needs-auth (functional) — added as its own sentence: an MCP server that needs a login is a dependency the session cannot use, and unlike a crash the missing action is the user's, so the row says MCP server jira needs authentication rather than that it failed. Provenance, so nobody has to guess: the status comes from the reviewer's statement about the SDK, not from a capture I have. The reporter's captured frame on the issue (Claude Code 2.1.289) shows only failed and connected, so this is the one arm in the change without a capture behind it. If the status is spelled differently in practice, that sentence simply never fires — it cannot misfire on the statuses we did capture.

2. Test journal (stability) — itemsFor now opens through createTrackedJournalOpener() and afterEach awaits closeAll() before removing the directory, so the SQLite handle is closed first. Good catch: on Windows rm on a directory whose database is still open is exactly the kind of failure that would only show up on the platform this change was written for. Worth noting the file I mirrored, claude-informational-row.test.ts, has the same pattern pre-existing; I left it alone to keep this PR to the reported bug and its review, but it is the same two-line fix if you want it folded in.

Third, from the reporter — they posted the mcp_servers entries from the session that produced the card, and the failed ones carry only name, status and source, with no error. That is now a test case of its own (reports the shape the CLI actually captured), and it confirms the no-sentence branch is the common path rather than an edge case.

claude-init-row.test.ts — 7 passed. pnpm test src/main/claude/ — 1398 passed, 17 skipped. pnpm tc — exit 0.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • needs-auth servers now get a warning row — claude-init-row.ts maps status: 'needs-auth' to MCP server <name> needs authentication, so a server the session cannot use without a login is named instead of silently dropped.
  • The test harness closes its journal database before cleanup — claude-init-row.test.ts now uses createTrackedJournalOpener and awaits closeAll() before rm, so the open SQLite handle no longer blocks removing the temp directory on Windows.
  • A captured-shape test pins the real frame — a new case reproduces the #25477 roster (name/status/source, no error field) and asserts both failed servers render as failed to start lines, which fails if the status arm regresses.

Both CodeRabbit threads from the prior review are addressed here: the Windows cleanup change and the needs-auth handling. The new tests pass.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@paoValle

Copy link
Copy Markdown
Contributor Author

Closing to keep my queue honest: the repository merges within about a day and this has sat five with no review, so it is not being picked up. #25477 stays open for anyone who wants to take it, and the branch stays in my fork.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Agent chat shows error card "claude · message:system:init" instead of session init message

1 participant