fix: make command loading resilient to MCP/Skill failures#36
fix: make command loading resilient to MCP/Skill failures#36anandgupta42 merged 2 commits intomainfrom
Conversation
jontsai
left a comment
There was a problem hiding this comment.
LGTM
Clean resilience fix. The root cause is well-identified and the fix is minimal:
✓ safe() wrapper in sync.tsx — elegant pattern, each request fails independently
✓ try/catch around MCP.prompts() and Skill.all() — default commands always available
✓ Good test coverage (22 new tests) including the failure-mode contrasts
Ship it! 🚢
Multi-Model Code Review — 8/8 ModelsVerdict: APPROVE (with suggestions)
Major Issues1. Silent catch blocks lose diagnostic information — The catch blocks for MCP and Skill loading swallow all errors without logging. When MCP or Skills fail silently, debugging is impossible — operators have no visibility into why commands aren't appearing. Fix: } catch (e) {
Log.Default.warn("MCP prompt loading failed, continuing with defaults", {
error: e instanceof Error ? e.message : String(e),
})
}Same for the Skill catch block at line 157. Flagged by 7/8 models Minor Issues2. Status transitions to "complete" even when all non-blocking requests fail — The Flagged by 4/8 models 3. MCP commands can silently overwrite default commands — MCP prompts are applied after defaults and can overwrite Flagged by 3/8 models 4. Generic log message in All failed requests log the same "non-blocking sync request failed" message. Consider adding a label parameter so failures are distinguishable in logs. Flagged by 3/8 models Nits5. Non-null assertions ( Pre-existing pattern, but 6. Pre-existing async Promise executor anti-pattern —
Positive Observations
Reviewed by 8 models: Claude, Codex, Gemini, Kimi, Grok, MiniMax, GLM-5, Qwen |
There was a problem hiding this comment.
PR #36 Review Tour Guide
Summary
Clean resilience fix for a real bug: when MCP.prompts() or Skill.all() threw during command state initialization, the entire state promise rejected and default commands (/init, /discover, /review) disappeared from the TUI. Additionally, the fire-and-forget Promise.all in sync.tsx had no error handling, so setStore("status", "complete") never ran on failure.
File Order
| # | File | +/- | Purpose |
|---|---|---|---|
| 1 | src/command/index.ts |
+45/-35 | Core fix — wrap MCP/Skill loading in try/catch |
| 2 | src/cli/cmd/tui/context/sync.tsx |
+18/-12 | Core fix — safe() wrapper for non-blocking requests |
| 3 | test/command/command-resilience.test.ts |
+246 | Tests for command defaults and resilience pattern |
| 4 | test/command/sync-resilience.test.ts |
+137 | Tests for safe() wrapper pattern |
Review Assessment
Risk level: Low — defensive error handling only, no behavioral changes on happy path
Confidence: High
Considerations
-
MCP can overwrite defaults but Skills can't — this is by design (MCP loop runs before Skills loop with
if (result[skill.name]) continue), but it means an MCP prompt named "discover" would shadow the built-in. Worth noting in inline comment. -
Bare
catch {}blocks — the command/index.ts catch blocks swallow errors silently. The sync.tsxsafe()logs warnings, which is better. Consider adding logging to the command/index.ts catches too for debuggability. -
Test approach — the resilience tests in command-resilience.test.ts duplicate the loading logic rather than mocking the real module. This is pragmatic (avoids complex module mocks) but means the tests could drift from the real implementation. Acceptable tradeoff.
Verdict
LGTM — well-scoped fix with thorough test coverage. The root cause analysis in the PR description is excellent.
| description: prompt.description, | ||
| get template() { | ||
| // since a getter can't be async we need to manually return a promise here | ||
| return new Promise<string>(async (resolve, reject) => { |
There was a problem hiding this comment.
praise: Clean pattern — wrapping MCP and Skill loading separately means one can fail independently of the other. Default commands are always served regardless.
| const template = await MCP.getPrompt( | ||
| prompt.client, | ||
| prompt.name, | ||
| prompt.arguments |
There was a problem hiding this comment.
suggestion: The bare catch {} blocks silently swallow errors. Consider adding a Log.Default.warn() here (like you did with safe() in sync.tsx) so MCP/Skill failures are at least visible in debug logs.
} catch (e) {
Log.Default.warn("MCP prompt loading failed", {
error: e instanceof Error ? e.message : String(e),
})
}| }) | ||
| }, | ||
| hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [], | ||
| } |
There was a problem hiding this comment.
thought: Note that MCP prompts can overwrite default commands (same name = overwrite), but Skills can't (the if (result[skill.name]) continue guard). This asymmetry is probably intentional but worth a brief comment for future readers.
| error: e instanceof Error ? e.message : String(e), | ||
| }) | ||
| }) | ||
| Promise.all([ |
There was a problem hiding this comment.
praise: Nice safe() wrapper — elegant and minimal. The generic <T,> preserves type info, and logging the error message (with instanceof Error check) is the right level of detail for non-blocking failures.
| config: { | ||
| command: { | ||
| "my-custom": { | ||
| description: "Custom command", |
There was a problem hiding this comment.
nit: These resilience tests duplicate the loading logic from command/index.ts rather than testing the real module with mocked MCP/Skill dependencies. This is pragmatic (avoids complex module mocking) but means the test's loadCommands() could drift from the real implementation over time. A comment noting this tradeoff would help future maintainers.
| warnings.length = 0 | ||
| let statusSet = false | ||
|
|
||
| await Promise.all([ |
There was a problem hiding this comment.
praise: Great test — explicitly contrasts old behavior (Promise.all rejects, status never set) vs new behavior (safe() catches, status always reaches "complete"). This makes the bug fix crystal clear.
Two issues could cause /discover and other server commands to not
show up in the TUI:
1. In command/index.ts, if MCP.prompts() or Skill.all() threw during
command state initialization, the entire state promise rejected —
all default commands (init, discover, review) were lost. Now each
is wrapped in try/catch so defaults are always served.
2. In sync.tsx, the non-blocking Promise.all for loading commands, LSP,
MCP status etc. was fire-and-forget with no error handler. If any
request failed, errors were silently swallowed and setStore("status",
"complete") never ran. Now each request catches errors individually
and logs a warning, allowing the others to succeed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add Log.Default.warn() to MCP and Skill catch blocks so failures are visible in debug logs instead of being silently swallowed - Add comment documenting MCP/Skills overwrite asymmetry (MCP can overwrite defaults by name, skills cannot due to guard clause) - Add comment noting resilience tests duplicate loading logic and may need updating if the real implementation changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
a0c8b53 to
c8c1fe1
Compare
* fix: make command loading resilient to MCP/Skill failures
Two issues could cause /discover and other server commands to not
show up in the TUI:
1. In command/index.ts, if MCP.prompts() or Skill.all() threw during
command state initialization, the entire state promise rejected —
all default commands (init, discover, review) were lost. Now each
is wrapped in try/catch so defaults are always served.
2. In sync.tsx, the non-blocking Promise.all for loading commands, LSP,
MCP status etc. was fire-and-forget with no error handler. If any
request failed, errors were silently swallowed and setStore("status",
"complete") never ran. Now each request catches errors individually
and logs a warning, allowing the others to succeed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address PR review feedback — add logging and comments
- Add Log.Default.warn() to MCP and Skill catch blocks so failures
are visible in debug logs instead of being silently swallowed
- Add comment documenting MCP/Skills overwrite asymmetry (MCP can
overwrite defaults by name, skills cannot due to guard clause)
- Add comment noting resilience tests duplicate loading logic and
may need updating if the real implementation changes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Summary
try/catchblocks incommand/index.tsso that default commands (init, discover, review) are always served even when plugins failsafe()error wrapper around each non-blocking sync request insync.tsxso one failing request doesn't silently prevent all others (including commands) from populating the TUI storeRoot Cause
When
MCP.prompts()orSkill.all()threw during command state initialization, the entire state promise rejected — all default commands including/discoverdisappeared from the TUI. Additionally, the non-blockingPromise.allin sync.tsx was fire-and-forget with no error handler, so failures were silently swallowed andsetStore("status", "complete")never ran.Changes
src/command/index.tssrc/cli/cmd/tui/context/sync.tsxtest/command/command-resilience.test.tstest/command/sync-resilience.test.tsTest plan
🤖 Generated with Claude Code