The panel tests stopped at the disabled button, which leaves the wire claim
unverified: a screen that flagged the draft and still fired its callback
would have satisfied every one of them.
`ToolsScreen` and `AppsScreen` now assert `onCallTool` / `onOpenApp` are not
called for a refused draft, and ARE called once the value is rewritten with
the declared type — so the draft is demonstrably what blocks the dispatch
rather than an inert screen.
The plain and "Run as task" paths need no separate case: the panel has one
Execute button whose disabled state does not depend on the toggle, and the
callTool/callToolStream split happens later in App.tsx, downstream of the
callback a refused draft never reaches.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
`<InspectorView>` took ~130 flat props, which made ~170 of App.tsx's JSX
lines a prop wall rather than a component tree. It now takes 12 domain
props and the call site is 14 lines.
Two conventions keep the change reviewable. No field is renamed — every
field inside a bundle carries the exact name it had as a flat prop, so
this is a regrouping rather than a rename. And the bundles stop at
`InspectorView`: it destructures each one back into the same locals its
body already used and passes the same individual props down, so nothing
below the view knows they exist.
Three props have no single screen and are placed deliberately:
`onCompleteArgument` / `completionsSupported` are read by both the
Prompts and Resources screens, so they sit in `connection` rather than
being duplicated; `malformedListItems` is one array three screens filter
for their own entries, so it sits in `shell`; `erroredServerId` /
`connectedServerId` are connection outcomes the Servers screen renders,
so they sit in `connection`.
Every multi-line closure declared inline in the JSX is now a named
`useCallback` above the return, including `onServerReorder`'s `.catch`
and the seven `void`-discarding wrappers, which carry their
`no-floating-promises` justification once rather than at each call site.
The `InspectorView` double in App.test.tsx is now typed with the real
`InspectorViewProps` instead of a hand-written structural mirror, so it
cannot drift from the component it stands in for. Stories keep one
constant per bundle, since Storybook merges args only at the top level.
No behavior change.
Signed-off-by: cliffhall <cliff@futurescale.com>
The Tools and Apps tabs' "Edit as JSON" editor sent its draft through the
string-to-declared-type conversion `callTool` applies for the widget form, so
`{"count":"01"}` against a numeric schema left as `{"count":1}` — the editor
showed one payload and the wire carried another.
Both JSON-authoring surfaces now refuse such a draft and name the value to
rewrite. Edit-and-replay already did; this makes the Tools and Apps forms
agree with it, which is the consistency #2171's acceptance asks for.
`coercedArgumentNames` and `coercedArgumentsError` move into core/json so the
two surfaces share one predicate and one sentence — the check runs the real
conversion and compares rather than restating its rules, so it cannot drift
from what would actually be sent. Enforcement is opt-in
(`SchemaFormProps.enforceToolArgumentTypes`): an elicitation renders through
the same form and its values are never converted, so enforcing there would
refuse a draft for a reason that is not true of it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
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>
Copilot review on #2176. All three findings were the same gap seen from
three angles: `"after-first"` counts *resource-subscription* listens, not
listens, and both the README and the option's own doc comment said
"the first listen".
That distinction is load-bearing rather than incidental — the Inspector
opens a listen at connect time whenever a list-change opt-in is live
(#1920), so counting listens would spend the allowance there and refuse
the user's very first Subscribe, leaving the documented repro describing
a trace nobody sees. It is also what a reader of the option would
otherwise get wrong.
- README: say "the first listen that subscribes to a resource" in both
places, and name the connect-time listen as the reason.
- `ServerConfig.modern.neverAcknowledgeSubscriptions`: document the
list-change exemption and contrast it with `true`.
- Integration test for the mode itself, with every list-change opt-in on
so the connect-time listen really is opened: it must be acknowledged
without consuming the allowance, the first resource subscription
acknowledged, the next refused with the stream left `active`.
Closes#2097
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 3, from its suppressed set: "a paginated paginated fetch".
The line came across verbatim with `onLoadMoreTools`, so the typo
predates this PR — fixed here since the comment is the thing being
moved.
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>
Copilot round 2. The stand-in `runWithCommandAuthRecovery` was a bare
pass-through while its comment claimed it reproduced the real one, so
`onCompleteArgument` and `onCancelTask` — whose only auth handling *is*
that wrapper — would have passed every test here while bypassing the
shared recovery entirely. That routing is the integration this
extraction exists to preserve.
The stand-in now matches `useOAuthRecovery`'s: it catches an
`AuthRecoveryRequiredError`, hands it to `handleCommandScopedAuthRecovery`
with the call site's `source` and a `retryOperation`, retries once when
the recovery reports satisfied, and rethrows anything else.
`runCommandInBackground` is built on it, as the real one is. Three tests
pin the source and the retry.
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>
A modern-era `subscriptions/listen` is a long-lived request: the first
message on its stream must be `notifications/subscriptions/acknowledged`,
and the JSON-RPC `result` for the listen id is reserved as the
graceful-closure marker. A server that answers the listen request with a
bare `result` is saying "acknowledged and closed in the same breath" — the
SDK settles the subscription `graceful` and rejects the pending `listen()`.
The Inspector read that rejection as an ordinary drop: eight re-lists over
roughly a minute, the badge flickering `Reconnecting…`, and a final bare
`Stream ended` that said nothing about why. Accepting the message was
never wrong — it is a well-formed graceful-closure response. The silence
was.
- `core/mcp/subscriptionAck.ts` owns the predicate. The SDK gives this
close no code of its own — it is the same `SdkError(ConnectionClosed)`
any pre-ack close carries — so the predicate reads the message, since
the alternative is refusing to retry a genuinely transient drop.
- The condition is deterministic, so the stream ends on the first
occurrence instead of spending the whole backoff run. Both routes are
covered: the one a subscribe/unsubscribe reaches, and the one a
reconnect reaches when an acknowledged stream drops and the server has
since started refusing.
- A `"never-acknowledged"` status of its own, not `"ended"` — `ended`
means an expected close. It surfaces as the thrown error's message, an
orange "Not acknowledged" badge, and a notice in the panel.
- `subscriptions-never-acknowledged-http.json` reproduces it. It
acknowledges the first listen that subscribes to a resource and refuses
every one after, which is what makes the badge reachable by hand.
Closes#2097
Signed-off-by: cliffhall <cliff@futurescale.com>
Give the test's paginated-list fixture a precise return type instead of
an unjustified double cast. The cast was hiding that the fixture omitted
`error`, so an interface change would not have been type-checked there.
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>
Phase-2 step 4 of the App.tsx decomposition (#2155, under #2129/#2126).
Every command the screens issue against the live server moves into
`useServerCommands`: calling a tool, getting a prompt, reading and
subscribing to a resource, completing an argument, cancelling a task or
a call, setting either log level, refreshing or paging a list, and
flipping the pagination mode. All of them route through the
command-scoped auth recovery `useOAuthRecovery` publishes, which is why
this cluster cannot precede the OAuth one.
The three in-flight result panels move with them, behind a small
`useResultPanels` in the same file — split out only because of call
order, the same reason `useHandshakeTelemetry` sits beside
`useConnectionLifecycle`: `useOAuthRecovery` drops those panels on a
session reset and routes a step-up failure into whichever one issued
the command, and it runs before the commands hook.
The move is inert — no behavior change, #2095's per-server pagination
override included.
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>
Copilot's thirteenth pass, two comments, both real.
- `1e-400` has a nonzero mantissa and parses to `0`. The digit comparison skips
it for being exponent-form and it is not negative zero, so nothing saw a
document that says one number and sends none of it. Caught now by pairing
`Number(literal) === 0` with a nonzero digit in the mantissa, which leaves a
literal written as zero (`0e-400`) and a subnormal that still parses to
something (`1e-320`) alone.
- The `resource` content-block branch rendered every embedded resource in a
plain code block, ignoring `resource.mimeType`, so a prompt message carrying
JSON did not inherit this migration even though the text-block branch did. An
embedded *text* resource declaring JSON now goes through the same dispatch.
Narrowed to JSON, as the Network tab's bodies are: routing every declared type
through would also put an embedded `text/html` resource into the sandboxed
frame and a `text/csv` one into a table. The Resources screen already renders
resource contents that way, so doing it here too is defensible — but it is a
change to what a tool result *is*, and belongs to whoever decides that.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's twelfth pass. The suppressed comment is right and is fixed here; the
inline one is real too, but needs a core API change and is filed as #2171.
`-0` is represented exactly, so the digit comparison had nothing to say about
it — but `JSON.stringify` writes it as `0`, so the loss happens on the way
*out* rather than on the way in. The editor showed `-0` and the wire carried
`0`, and the viewer rewrote a received `-0` while pretty-printing.
`hasImpreciseIntegerLiteral` becomes `findUnsendableNumberLiteral`, checking
`Object.is(Number(literal), -0)` for every numeric spelling — `-0`, `-0.0`,
`-0e1` — before the integer-only digit check. Returning the offending literal
rather than a boolean lets the message name it, which is more use than either
of the two rules behind it and matches how the duplicate-key message reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's eleventh pass: three comments, one defect in three places.
`JSON.parse` accepts duplicate member names and keeps the last without
complaint, so `{"role":"user","role":"admin"}` becomes `{"role":"admin"}` —
a document that renders, and submits, as *less* than it says. In the read-only
viewer that is the Inspector hiding what the server sent; in a draft it is the
editor showing more than the wire will carry. Nothing downstream could catch
it, because by the time `JSON.parse` returns the evidence is gone.
New `findDuplicateObjectKey` scans the source, scoped per object so
`{"a":{"a":1}}` and `[{"a":1},{"a":2}]` are fine, and compares names by decoded
value so `"a"` and `"a"` collide as they should. It shares its
escape-handling with the number-literal scanner, which now reads strings
through the same helper rather than skipping them inline.
Wired into all four JSON-draft parsers and into the viewer's faithfulness
check, so the same document is refused, or shown unindented, everywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's tenth pass generated no new inline comments. Of its two suppressed
ones, this fixes the first and declines the second.
`ContentViewer` pretty-prints by `JSON.parse` then `JSON.stringify`, which is a
round trip through JavaScript's number type and is not lossless. A server
sending `{"id":9007199254740993}` was shown `…992`, and one sending
`{"limit":1e400}` was shown `null`. Neither is a formatting difference: both
are the viewer telling the reader something untrue about the payload it
received, which for an inspector is the worst kind of bug. It predates this PR
— `formatJson` has always round-tripped — but this PR is where the read-only
JSON path was rebuilt, and the same two predicates the draft parsers use answer
it exactly.
Both formatters now display the payload as it arrived when reindenting would
alter a value: unindented, but honest. The copy button already copied the
original text either way, so this makes the screen agree with it.
Not changed: a literal whose *spelling* is normalized but whose value is not.
`0.10000000000000001` and `0.1` are the same double, as are `1e2` and `100`, so
those still format. Rejecting them would mean refusing any literal not already
in JS's shortest form — `1.50`, `1e2`, `0.5000` — which blocks drafts that send
exactly what they mean. The integer guard stays because there the digits name a
value the double is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's ninth pass, three comments, two of them consequences of round 8.
- The coercion check resolved the tool from the *entry's* name while the modal
lets `name` be edited, so retargeting an `echo` replay at `add` validated
against `echo`. The modal now takes the whole tool list and resolves from the
draft's current name, which is the one the dispatch will look up.
- `IMPRECISE_INTEGER_ERROR` (was `ROUNDED_INTEGER_ERROR`) no longer names the
safe-integer range, because the range is not the rule: a whole number past
±(2^53 − 1) that is exactly representable round-trips and is accepted. It
talks about exact representability instead, and its doc links the helper that
actually exists.
- `SchemaJsonField` carries the parser's reason instead of flattening every
case to "Not valid JSON", which misdiagnosed a document that parses fine and
holds a number this client cannot send — leaving the user reading about
syntax while looking at syntactically fine JSON.
The last one had a bug the gate caught rather than the tests: empty text is not
a *problem*, so routing the value through the reason check let `JSON.parse("")`
throw out of the change handler. It surfaced as five unhandled errors across
four suites with every assertion still green, which is why `npm run ci` and not
`npm run test` is the gate. Excluded explicitly, with a regression test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's eighth pass, three comments, all real.
- The precision check now reads the **source text**. Inferring the literal from
`String(value)` was wrong in both directions: it accepted every full-form
integer at or above 1e21 (`1000000000000000000001` parses to `1e21` and is
written back as `1e+21`), and refused `2^54`, which is written out in full
and exactly representable. `hasImpreciseIntegerLiteral` scans the draft for
numeric tokens — skipping strings, so a digit-string value is untouched — and
compares `BigInt(literal)` against `BigInt(Number(literal))`, which is exact.
- `""` is now reported as a surviving cursor for `tools/list` only. It builds
its params with `cursor !== undefined` and carries the empty string
deliberately; the other four list adapters use a truthiness check and drop
it, so the editor showed `{"cursor":""}` while `{}` went out. The asymmetry
looks like a latent bug in those four, but this function's job is to describe
what the dispatch does.
- `callTool` runs every string-valued argument through `convertToolParameters`,
so `{"count":"2"}` against a numeric schema is sent as `{"count":2}`.
`reshapedReplayParam` now takes the tool and detects that by running the same
conversion and comparing, rather than restating its rules — the two cannot
drift. `tools` is threaded to `ProtocolEntry` for it, optional throughout: an
unknown tool skips the check rather than blocking a replay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's seventh pass, three comments, all the same class: the editor showing
one value while the wire carries another.
- `JSON.parse` rounds a whole number past 2^53−1 to the nearest double, so a
draft reading `{"id":9007199254740993}` submits `…992`. New
`hasRoundedInteger`, applied by all four JSON-draft parsers — the raw
arguments editor, `SchemaJsonField`, the Edit-and-replay modal, and
`parseJsonObjectDraft`, which Copilot did not flag but shares the defect.
It is narrower than "unsafe integer": it also requires the number's shortest
form to be written out in full, because JS stringifies from 1e21 upward in
exponent form and such a value was typed that way — `{"n":1e308}` is a
legitimate `_meta` value that `parseJsonObjectDraft`'s tests already pin as
accepted. It still refuses the rare full-form integer that happens to be
exactly representable, erring toward refusing a sendable value rather than
misreporting an unsendable one — the trade `toNumericValue` already makes.
- `getPrompt` runs `convertPromptArguments`, which JSON-stringifies every
non-string argument, so `{"count":2}` leaves as `{"count":"2"}` while the
editor still shows a number. `reshapedReplayParam` now rejects a non-string
prompt argument. Not a workaround: the spec types
`GetPromptRequest.params.arguments` as `Record<string, string>`, so a string
is the only thing a prompt argument can be. `tools/call` is untouched —
`callTool` sends its arguments as given.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's sixth pass generated no new inline comments; both suppressed ones
were right.
- `arguments` surviving the key check is not the same as being sent as
written. `replayProtocolRequest` applies `arguments ?? {}`, so a `null` shown
in the editor reaches the wire as `{}`; an array or scalar is not nullish and
rides the cast into a call whose type says it is a named-argument record.
`reshapedReplayParam` rejects both before Send. `name` and `uri` are
deliberately not checked there — the dispatch already refuses those with a
reason the caller shows as a toast, so they fail visibly rather than
silently.
- `NetworkEntry` never forwarded the body's content type, so the Network tab
could not reach the declared-JSON branch at all and a malformed
`application/json` response read as plain text with no sign it was meant to
be JSON. Forwarded — but only when the declared type *is* JSON: passing it
wholesale would also route an HTML body into the sandboxed frame and a CSV
one into a table, which is a change to what the Network tab is and not one
this makes.
The `NetworkEntry > Auth Success` play function moved off `textContent` for
body assertions. Ace repaints its rows on an animation frame and virtualizes
them, so reading the DOM straight after Reveal returned the previous rows
under load — it passed in isolation and failed in the full run. It now reads
the editors' documents alongside the DOM text, and waits.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's fifth pass: one comment, and the completion of the previous round's
fix. Seeding the editor from `replayableParams` stopped it *offering* a key
the dispatcher would drop, but nothing stopped one being typed in — adding
`_meta` to a `tools/list` draft still let Send close the modal and discard it.
`parseParamsDraft` now takes the method and rejects any key the dispatch would
not read, so Send is disabled with the offending names rather than silently
dropping them. It asks the same `replayableParams` the seed is built from, so
the editor and the dispatcher cannot disagree about which keys survive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's fourth pass, one inline plus two suppressed. All three were right.
- The modal presented the whole captured frame as editable, but replay does
not re-send a frame: it dispatches through the typed InspectorClient
methods, whose signatures have no room for `_meta` (a captured `tools/call`
carries `_meta.progressToken`) or for any list-request key but `cursor`. So
editing `_meta` and pressing Send transmitted something other than what was
on screen. `replayableParams` now projects the frame down to what the
dispatcher actually reads, the modal seeds from that, and it names what was
left out rather than leaving the difference to be discovered. The projection
lives beside the dispatch switch because the two are halves of one contract.
- `ContentViewer` gains `jsonLabel`, so a read-only JSON editor can say what
it holds. Every one of them announced "JSON content", and an expanded
Protocol entry has two while the list has many pairs — leaving a screen
reader with indistinguishable textboxes and only the visible headings to
tell them apart. Protocol entries now name theirs per method and direction,
Network entries per request/response body.
- `isJsonDocument` became `formatJsonDocument`, returning the formatted text
instead of a boolean. The heuristic path parsed the payload to decide and
then parsed it again to render; Protocol and Network hand over serialized
bodies as untyped text and are not virtualized, so a long history paid twice
per payload per render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's third pass generated no new inline comments; all three of its
suppressed ones were right.
- `ariaLabel` is now re-applied from the wiring effect, not only at mount.
`setOptions` updates Ace's option when the prop changes, but Ace recomputes
the textarea's `aria-label` from it inside `TextInput.setAriaLabel()`, which
it calls when the cursor moves — so a name that changed while the editor
stayed mounted (a tool refreshed in place under a new field title, which
`SchemaJsonField` passes straight through) left the visible label and the
accessible name disagreeing until the user clicked into the box. `onLoad`
keeps doing it too, since an effect runs after paint and this is the
control's only name.
- The README claimed this editor is on "every surface where JSON is typed or
displayed", which is false in two places it now names instead:
`ExperimentalFeaturesPanel`'s JSON-RPC *response* is still a read-only
`Textarea` (only its request box moved), and `OAuthTokenField` renders a
decoded JWT in a `Code` block beside the raw token. Both bypass
`ContentViewer` and so are not reached by its JSON branch. Documenting them
as exceptions is what keeps the section from sending a maintainer to the
wrong abstraction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Two more from Copilot's second pass, both real.
- Every JSON-draft parser now rejects a value `JSON.stringify` cannot write
back. `JSON.parse("1e400")` yields `Infinity`, which serializes as `null` —
so an editor showing `1e400` would have sent `null`, which is exactly the
misreport `isSerializableJson` was written for. `parseJsonObjectDraft`
already guarded; the raw-arguments editor and the Edit-and-replay modal did
not, and neither did `SchemaJsonField`'s `parseJsonDraft` — the last
predates this PR but is the same defect in the same family, so it is fixed
with them rather than left as the odd one out.
- Editing a root union's discriminator in the raw document now moves the
Variant picker. Nothing in `values` names a branch, so the index is held by
the form and was only re-derived on `resetKey` or a rewritten union; a raw
edit had no other signal, so `{"kind":"email"}` → `{"kind":"sms",…}` left
the picker on Email and switching back to the widgets rendered the Email
branch over SMS values. The current branch is kept when the values identify
none, since the discriminator is absent every time it is retyped.
The raw editor is keyed by entity only, not by entity-and-branch like the
per-field editors: it holds the whole arguments object, so a branch move must
re-sync it rather than remount it — a remount would reformat the text being
typed and drop the caret to the top.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, eleventh review:
- `Set-Item Env:SMOKE_BROWSER firefox` — PowerShell's Env: DRIVE, the other
standard way to write the environment there — matched none of the patterns.
Covered now, positional or via -Path/-Value, for Set-Item/New-Item and their
-ItemProperty forms. A `Get-Item` READ is still not a finding, which has its
own negative case: reading the variable is not setting it, the same
distinction as round 5's `echo "SMOKE_BROWSER: firefox"`.
- Two scope summaries had drifted behind the traversal, both saying the guard
reads only `run` and `env`/`with`: the module header's "ONLY EXECUTABLE
POSITIONS" paragraph, and the copilot-instructions mirror. Both list the real
set now — `run`, the `shell` command template at all three levels, and
`env`/`container.env`/`with`. The mirror one is the AGENTS.md rule this repo
states about itself, so it is a rule violation rather than a typo.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
- Cap the read-only editor's `maxLines` at 200. Uncapped, Ace renders every
line of a payload and its viewport virtualization never engages — which
matters because the Protocol and Network lists are not virtualized and keep
each entry's payload mounted inside its `Collapse`. 200 is chosen so no real
payload nests a scrollbar inside its host's while the pathological one stays
bounded; the StructuredOutputPanel `Large` story now pins both halves (full
payload in the document, only part of it in the DOM).
- Take a disabled editor out of the tab order and give it `aria-disabled`.
Ace has no disabled state, so its hidden textarea kept `tabindex="0"` and a
keyboard user could tab into a field every neighbouring control had dropped
— landing somewhere that looks editable and swallows what they type. A
merely read-only editor stays focusable, since a payload has to be
keyboard-scrollable (WCAG SC 2.1.1).
- Reword a confusing comment in the EditReplayModal test.
The `Disabled` story additionally scopes the axe `color-contrast` rule off:
WCAG 1.4.3 exempts inactive components, and axe cannot see that the dimmed
gutter belongs to one because the disabled state lives on Ace's hidden
textarea. Scoped to that one story so the rule still covers everything else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, ninth and tenth reviews:
- A custom `shell:` is a COMMAND TEMPLATE that Actions runs around every
script (`{0}` is the script path), so `shell: npm run local:gate && bash {0}`
invoked the gate while the `run:` value beside it looked harmless. Step
`shell`, and `defaults.run.shell` at both workflow and job level, are
command regions now. An ordinary `bash`/`pwsh`/`cmd` matches no rule, so
naming them costs nothing.
- GitHub's multiline environment-file syntax (`SMOKE_BROWSER<<EOF`, value,
delimiter) bypassed the assignment patterns. It is reported as an override
whose value the guard cannot READ rather than parsed: the value can arrive
through any number of shapes — a brace group redirected to $GITHUB_ENV, a
heredoc nested in another — and a parser handling some of them would be a
coverage claim that holds only for the shapes someone thought of. Nobody
spells `chromium` that way, and if they did, `SMOKE_BROWSER=chromium` is the
fix.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Extracts the Ace integration #2094 carried inline into a shared
`elements/JsonEditor`, and renders it on every surface where the web
client types or displays JSON.
The element is deliberately text in, text out. The two editing contracts
above it disagree about what an unparseable draft means — JsonObjectInput
keeps the last valid object and says nothing to its parent, SchemaJsonField
reports `undefined` and blocks submission (#2020) — so parsing, and the
draft/value split that goes with it, stays with each caller.
Surfaces: SchemaJsonField (inherited by all four SchemaForm consumers), a
new "Edit as JSON" switch for the whole arguments object, the server.json
File Contents box, the raw JSON-RPC request box, ContentViewer's JSON
branch (read-only, inherited by its eleven consumers), and a new "Edit and
replay" modal on a Protocol entry.
Ace read-only replaces the CodeHighlight JSON path and the Prism `json`
grammar loader is dropped: folding, not colorization, is what it adds, and
two highlighters for one language drift. `wrap={false}` callers and untyped
text that only looks like JSON keep the plain renderer.
Closes#2151
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, ninth review: the .NET arm required a QUOTED second argument, so
`SetEnvironmentVariable("SMOKE_BROWSER", $engine)` — or a ${{ }} expression
there — set the environment with the guard silent. An engine the guard cannot
read is forbidden for the same reason `smoke:web:engine` is, so the arm now
accepts an unquoted value too. Bare values stop at `,` or `)`, so a third
argument ("Process") is not swallowed — pinned by an allowed three-argument
case beside the dynamic one.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, eighth review: the bare-value pattern ran to whitespace, so a QUOTED
assignment swallowed the closing quote — `echo "SMOKE_BROWSER=chromium" >>
$GITHUB_ENV` captured `chromium"`, which is not `chromium`, and the guard
rejected a PERMITTED assignment. A false positive on the allowed value is the
worse direction to be wrong in: it fails a workflow that is doing the right
thing, and there is no way to satisfy the guard except to stop using the
supported spelling.
The value is now a quoted string or a bare run stopping at a quote or a `;`
(PowerShell's separator), so all three dialects unwrap the same way.
Regression cases for the permitted quoted `chromium` in $GITHUB_ENV, in a
cmd-style `set`, and single-quoted in PowerShell — each paired with the
firefox spelling that must still fail.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, seventh review:
- The unreadable-name rules only knew GitHub expression syntax, so
`ENGINE: firefox` plus `npm run smoke:web:$ENGINE` ran a forbidden pass with
the guard silent — the shell resolves the name, which makes it exactly as
unreadable as a ${{ }} expression and forbidden for the same reason. The
pattern keys off a leading $ or %, covering $VAR, ${VAR}, $env:VAR and cmd's
%VAR% in one, for both the engine and `local:` families.
- The env-key comparison was case-sensitive, but Windows environment variables
are not: `env: { smoke_browser: firefox }` on a windows-latest job sets the
very variable Node reads as SMOKE_BROWSER. Compared case-insensitively now,
as is the POSIX assignment pattern. On a POSIX runner that spelling would be
a different variable and harmless, so this is stricter there than it needs to
be — the right direction for a guard, and the alternative is a verdict that
depends on a `runs-on` which may itself be an expression.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, sixth review: the run-script rule matched POSIX `NAME=value` only.
PowerShell is the DEFAULT shell on a Windows runner, so
`$env:SMOKE_BROWSER = "firefox"; npm run smoke` redirected the suite with the
guard silent — on exactly the workflow least likely to be read closely.
Three patterns now, one per dialect: POSIX (which also covers cmd's
`set NAME=…` and the GitHub-native `>> $GITHUB_ENV` append, since both carry
the same substring), PowerShell's `$env:NAME =` and `${env:NAME} =`, and the
.NET SetEnvironmentVariable call reachable from PowerShell. Regression cases
for all five spellings, plus a negative for a PowerShell assignment of
chromium.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 2: the "swallows the already-exists race and still
connects" test only asserted that nothing was recorded, so a deep link
that swallowed the 409 and then stopped forever would have stayed green.
Rerender with the hydrated row and wait for the connect.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, fifth review:
- The SMOKE_BROWSER rule was a blanket text match across every region, so a
script that merely PRINTS the variable — `echo "SMOKE_BROWSER: firefox"` —
or a `with:` input mentioning it was reported as an override. It is
kind-aware now: an `env:` entry counts only when SMOKE_BROWSER is literally
its key (recognized structurally, not by regex), a `run:` script only on a
shell assignment, and a `with:` value not at all — whatever it names is
already covered by the script rules. A shell assignment inside an `echo` is
indistinguishable without parsing the shell, and inside `run:` erring toward
the finding is the safe direction; that is stated in the header.
- `jobs.<id>.container.env` is job-wide too — every step in the container sees
it — so an override there redirected `npm run smoke` with the guard silent.
Now traversed, and a `container:` given as a bare image string is ignored
rather than tripping over.
- Nothing asserted the FIRST acceptance criterion: reintroducing a `ci` or
`ci:*` root script left test:scripts green. Asserted directly over the
manifest's keys now, and checked to actually bite by reviving `ci` and
watching the suite fail.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
- Terminate the two banner-path `onToggleConnection` calls through a
`retryConnect` helper. The toggle handles every handshake failure, but
client construction runs ahead of its try/catch, so a throw there
escaped into a discarded promise with the banner already dismissed.
Recorded on `connectErrorMessage` rather than toasted, matching the
deep-link phases' own catch, so the move stays inert.
- Add the unsettled-`/api/config` race test: a connect that starts before
the sandbox answer is held at the gate and builds from the URL as of the
release, which is what `sandboxUrlRef` exists for.
- Correct the harness's `client` doc, which described a
last-constructed fallback the harness never implemented.
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, fourth review — four findings, all real:
- Matching pairs by KEY NAME anywhere in the tree scanned things that execute
nothing. Workflow input ids are user-defined, so an input legitimately named
`env` put its own description and default in front of the rules — failing
the gate on documentation OF the gate. The walk is structural now: workflow
`env`, job `env`/`with`, step `run`/`env`/`with`, and nothing else.
- YAML aliases were read as scalars, which have no `.value` on an alias node —
so `run: *command`, with the command anchored in a metadata scalar the walk
never visits, invoked anything at all while the guard saw an empty string.
Aliases are resolved, and reported at the line where they are USED.
- `file` was accepted for diagnostics and then not forwarded, so a malformed
real workflow was reported as `<workflow>`.
- One negative fixture was a YAML comment, which the parser strips — so it
asserted nothing. Inside a `run:` block the comment text really does survive
into the scalar, so the "a comment invokes nothing" rule is now applied
there, where it actually has to be, and the fixture is a real block scalar.
The table's fixtures are schema-valid workflows now rather than bare `- run:`
fragments, which a structural walk correctly cannot see.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot, third review — and the second finding is really a verdict on the
approach, so the fix is the approach:
- `SMOKE_BROWSER:` with no value is valid YAML that sets the variable to the
empty string, which `resolveBrowserName` rejects as present-but-empty rather
than treating as unset. The value pattern required \S+, so the guard said
nothing. It is \S* now, with its own message.
- The hand-rolled opener could not see `run: | # explanation` or the flow
mapping `env: { SMOKE_BROWSER: firefox }`. That is the third spelling it has
missed in three rounds, each one a forbidden invocation that would have
passed while test:scripts stayed green — worse than no guard, because it
reports a coverage that is not there. YAML has too many spellings to
recognize by indentation, so extractExecutableRegions now parses with the
`yaml` package. It is already a root dependency, and this file is not in the
published files allowlist, so nothing about the tarball changes.
A parse error now throws rather than yielding no regions: a workflow the guard
cannot read must not pass as a workflow with nothing in it.
Verified by injecting a flow-mapping SMOKE_BROWSER override into the real
main.yml — the suite fails, and goes green again when removed.
Signed-off-by: Cliff Hall <cliff@futurescale.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Phase 2 step 3 of the App.tsx decomposition (#2154, under #2129/#2126).
Moves the whole connection cluster out of App.tsx and into
clients/web/src/hooks/useConnectionLifecycle.ts: setupClientForServer
(the single place a connection is configured), onToggleConnection,
onDisconnect, onReauthenticateFromBanner, the deep-link auto-connect
effect and its three phase latches, the connected/teardown/disconnect
effects, connectErrorMessage, and the session-scoped reset a disconnect
triggers. Handshake telemetry rides along as useHandshakeTelemetry,
split out only because useOAuthRecovery runs first and stamps the same
ref.
No behavior change. App.tsx goes 3,039 -> 2,412 lines.
Per #2129, resetSessionScopedUiState takes a narrow SessionResetSurface
- one reset() per owner - rather than every phase-1 setter, so the hook
does not end up depending on everything phase 1 extracted. The two
log-level operations stay App-owned because the connect path seeds the
same state.
Closes#2154
Signed-off-by: cliffhall <cliff@futurescale.com>