fix(web): surface failed resource subscribe/unsubscribe - #2179
Conversation
Both handlers discarded their promise with a bare `void`. Nothing owned those failures: `subscribeToResource` throws when the client is disconnected, when the server declares no subscription support, and (wrapped) on a failed request — so a subscribe that failed did nothing visible and surfaced only as an unhandled browser rejection, while every neighbouring command in the hook already routed through the shared recovery. Both now go through `runCommandInBackground`, so a mid-session 401 recovers the way it does on a refresh and any other failure toasts. The source is `ambient`, not `resource`: a `resource` step-up failure is routed into `readResourceState` — the read-preview panel — which describes a read of whatever is selected rather than this subscribe, so marking it errored would contradict a read that succeeded. Subscribing has no panel of its own, the same position the refreshes and the pagination toggle are in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Routes resource subscription commands through shared error reporting and OAuth recovery. However, real auth errors are wrapped by InspectorClient, preventing the intended recovery.
Changes:
- Adds failure toasts for subscribe/unsubscribe.
- Adds tests for errors and intended auth recovery.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
clients/web/src/hooks/useServerCommands.tsx |
Routes subscription operations through the background command wrapper. |
clients/web/src/hooks/useServerCommands.test.tsx |
Tests notifications and recovery behavior. |
Suppressed comments (1)
clients/web/src/hooks/useServerCommands.tsx:602
- The unsubscribe recovery path has the same contract mismatch:
InspectorClient.unsubscribeFromResourcewraps the typed auth failure in a genericError(core/mcp/inspectorClient.ts:6295-6299), butrunWithCommandAuthRecoveryonly recognizes a top-levelAuthRecoveryRequiredError. The test's direct typed rejection therefore proves behavior the real method cannot produce. Preserve the auth error or unwrap its cause before recovery, and test the wrapped production shape.
runCommandInBackground(
() => inspectorClient.unsubscribeFromResource(uri),
"ambient",
"Failed to unsubscribe from resource",
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
|
You are right, and this was the important catch — the PR claimed a recovery that could not have run. Fixed in 35b0607. Confirmed the whole chain rather than taking it on faith:
So a mid-session 401 on a subscribe produced Fix: preserve the typed error at the sourceOf the two options you offered I took the first. An interactive auth recovery is a control-flow signal, not a failure to describe, and the wrapping catch exists to add context to a generic failure — so it should not consume it: } catch (error) {
if (error instanceof AuthRecoveryRequiredError) throw error;
throw new Error(`Failed to subscribe to resource: …`, { cause: error });
}Unwrapping the And the tests now exercise the production shapeYour point about the mocks was the reason this passed review-by-testing: they rejected the typed error directly from the client method, which is a shape production cannot produce. Two integration tests in Verified by mutation, not just by going green: removing the two One note for anyone reading later: the subscribe test needs an HTTP server advertising |
Closes #2174
#2173 merged while this was being written, so this targets
v2/maindirectly rather than stacking on it.Raised by Copilot on #2173 and deliberately deferred out of it: that PR is a decomposition step with a "no behavior change" contract, and this is a behavior change.
The bug
Both handlers discarded their promise with a bare
void:Nothing owned those failures.
InspectorClient.subscribeToResourcethrows when the client is disconnected, when the server declares no subscription support, and rethrows a wrapped error after rolling its subscription state back on a failed request;unsubscribeFromResourceis the same shape. So a failed subscribe did nothing visible — the button just didn't work — and surfaced only as an unhandled rejection in the console.AGENTS.mdpermits a barevoidonly "when the callee already owns its failures", which is not the case here.These were also the only two commands in the hook skipping the shared recovery, so a mid-session 401 on a subscribe did not trigger re-auth the way a 401 on a refresh does.
The fix
Both now route through
runCommandInBackground, like every neighbouring command.The source is
ambient, notresource— the issue proposedresource, and that turns out to be wrong. Aresourcestep-up failure is routed bysetSourceScopedErrorintoreadResourceState, which is the read-preview panel; it describes a read of whatever resource is selected, not this subscribe. Marking it errored would contradict a read that actually succeeded, and it would do so by mutating a panel the user is looking at. Subscribing has no panel of its own — the tile only flips its button label — which is exactly the position the refreshes, the load-mores and the pagination toggle are in, and they areambientfor the same reason.Both pass an
errorTitle, per the guidance onrunCommandInBackground: nothing else records these failures, so without one the button goes on silently doing nothing.Tests
Five new cases (80 total in the file):
source: "ambient"and theretryOperationCoverage on the hook is unchanged: 100% statements / lines / functions, 96.58% branches.
Verification
npm run local:gategreen end to end.One caveat worth stating plainly: the gate initially failed three runs in a row on an unrelated flake in
scripts/lib/render-smoke.test.mjs, which gives itself a 150ms budget that has to cover PTY + Node startup. That is filed as #2177 and fixed by #2178. The gate run backing this PR was done with that fix cherry-picked in locally; the borrowed commit was then dropped, so the diff here is only the two files above.🤖 Generated with Claude Code
https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev