feat(acp): add a dormant Codex ACP backend seam - #6910
Conversation
89b6853 to
33d402f
Compare
33d402f to
48d4c41
Compare
48d4c41 to
57666db
Compare
62d86c1 to
6293b61
Compare
6293b61 to
800062e
Compare
800062e to
cf4033b
Compare
cf4033b to
2b74ae0
Compare
2b74ae0 to
4eb6f16
Compare
UX Review (Fable 5, fork) — ✅ PASSUX-level review of UX-Verdict: PASS No rendered change on any shipped build; the edition-only fallback path keeps loading floors, stable ordering, honest status lines, and readable [UX-REVIEWED] c593891 |
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of The problem claim checks out: Design-Verdict: CONCERNS Sound dormant seam done by the book; two unrelated test hunks ride along undocumented, and the codex resolver is a near-verbatim copy of the claude one. Watch
Suggestions
[DESIGN-REVIEWED] c593891 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed BLOCKING -- src/kiro_crew/acp/client.py:3701 -- H13 changes the Kiro session path FINDING -- src/kiro_crew/acp_backends.py:362 -- |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All evidence gathered — the contract's counts check out: First-Principles-Verdict: CONCERNS The codex seam's only support is "a plausible next adapted harness" — no registrant is named anywhere — and shipping it probe-less creates a baseline-exception concept that ordering the work the other way never would. What this change shipsIntent: let a downstream edition register and run a Codex ACP agent, while the public build offers nothing new. ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c593891 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've independently verified the load-bearing claims:
The candidate file recorded no candidates, and I found nothing reachable to add under the Step 2 bar. No findings. [OPUS-REVIEWED] c593891 |
4eb6f16 to
1adae96
Compare
Adds the Codex adapter as an adapted harness: known and driveable, but absent
from BASELINE_SELECTABLE_BACKENDS, so no build offers it until an edition
registers it with register_selectable_backend.
Vocabulary and identifier
- acp_backends.py: ACP_BACKEND_CODEX in ACP_BACKENDS_KNOWN and in
POLICY_ID_BY_BACKEND, so a governance rule can name the id before an edition
registers it. ACP_BACKENDS_CONFIG_OPTION_TUNING is ONE set covering both the
model and the effort config options: whether a given adapter build advertises a
specific option is a runtime question that AcpClient.supports_config_option
already answers, so a second frozenset with identical membership would restate
that check statically and less accurately. Split it when a harness genuinely
takes one option and not the other.
- platform/defaults.py: DefaultProviderRegistry.register_acp_backends said "the
baseline covers every id in ACP_BACKENDS_KNOWN, so there is no id it accepts
that is not already selectable". This change makes that false -- codex is the
first known-but-unshipped id -- so the comment now records that the seam is
live and that calling it is how an edition activates such an id. Absent from BASELINE_SELECTABLE_BACKENDS: the spawn path is
complete, but backend_install.py has no probe for the adapter, so the switch
would render with nothing to say about a session that failed to start.
test_agent_backend_editable.py names the exception in NOT_SHIPPED_SELECTABLE
rather than relaxing the baseline == known assertion.
- acp/types.py: PROVIDER_LABEL_CODEX, plus an explicit non-membership decision
with its reason for every Group B capability set (H6/H8): one process per
session, no _session/steer, a Node harness so Crew's sandbox is the only OS
confinement it gets, and its own credentials store so a kiro logout says
nothing about it.
Spawn path
- acp/client.py: _is_codex; PROTOCOL_VERSION_CODEX as its own literal (H10, so a
future divergence is a one-line edit rather than a silent downgrade of
whichever harness moved first); _resolve_codex_acp_bin mirroring the claude
resolver's order and node-resolution rules; a _spawn branch; the
_codex_session_mcp_servers override seam; per-harness log labels; and a
session/load arm -- the adapter keeps its own session records, so gating
file_ok on a kiro transcript would make an activated codex session start fresh
every time instead of resuming.
- acp/client.py: each per-harness mcpServers hook is now spliced only for its
own backend. Both defaults return [], so the previous shared splice was inert,
but an edition overriding both hooks would hand a claude session codex's
entries and vice versa -- and an entry whose transport the adapter does not
advertise fails the whole session/new, not just that server.
- acp/client.py: _vendored_claude_acp_roots -> _vendored_acp_roots. The roots
are plain node_modules directories and each resolver joins its own package
path onto them, so both adapters share it; the claude name would have made the
next reader think codex had its own copy.
Tuning channels stated as membership (H6)
Six sites in providers/acp.py read `if is_claude_backend: <adapter> else:
<kiro>`. That is correct with two harnesses and wrong with three: codex took the
kiro arm at every one of them. ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION and
ACP_BACKENDS_KIRO_SLASH_COMMANDS replace the negation.
- stream_command and change_effort routed codex into
_kiro.dev/commands/execute, a kiro extension the adapter does not implement,
so every slash command and every effort push would have returned -32601 and
reset the session.
- _apply_effort_overlay and _apply_tool_search_overlay wrote the kiro workspace
cli.json overlay for codex, which never reads it. The companion clear
(_clear_cli_overlay_effort) was ALREADY membership-gated, so those writes left
a stale effort level in the user's workspace that no later clear could reach.
- clear_effort took the kiro arm: overlay write, then a /effort push.
- The effort telemetry label was literally "claude" or "kiro", so a codex
session logged backend=kiro. It now resolves through POLICY_ID_BY_BACKEND.
- change_effort now reports unsupported for a harness in NEITHER set instead of
defaulting to a channel. _set_claude_effort -> _set_effort_config_option: the
method is the channel, not the harness, and codex now arrives there too.
Dashboard switch renders from the registry
The Agent Backend panel could never have offered a registered agent. `visible` was
built from a hard-coded [KIRO, CLAUDE, KAS] and then FILTERED by the live schema
enum -- narrowing works, widening is impossible -- so an id an edition registered
through register_selectable_backend was selectable on the wire, valid to PATCH,
present in the probe payload, and absent from the only control that sets
agent.acp_backend. That literal was the last surviving copy of the selectable list,
which is what the registry exists to retire, and the panel's own module note already
promised the opposite ("a build that ships another agent lights it up here with no
frontend change").
- AgentBackendTab.tsx: `candidates` is the union of the schema enum, the probe
payload, `current`, and the ids this frontend has names for, sorted KIRO-first then
by policy_id -- the order the probe endpoint already uses, so the control does not
reshuffle as queries resolve. `unavailable()` still narrows it, so an agent this
deployment may not select stays hidden exactly as before.
- The named trio is now a FLOOR, not a ceiling. As a ceiling it capped the panel at
three ids permanently; as a floor it only guarantees the core agents keep rows
while the schema and probe queries are in flight. Dropping it outright regressed
the "every option visible while the schema is still loading" case to a
single-option control on a slow load.
- nameOf/iconOf fall back to the server's policy_id and a generic icon for an id
with no translated entry. Untranslated on purpose: policy_id is the name a
governance rule spells, so it is already a word rather than a token, and the
alternative is a chip labelled undefined. A core agent that ships selectable earns
a real i18n entry instead of the fallback.
- Corrected a stale claim in the same module note: it said this build does not
include Claude Code, which stopped being true when claude joined the baseline.
Nothing renders differently today -- codex is not selectable, so it is filtered out
exactly as before. The change is what makes the seam this commit ships reachable.
Two test-isolation fixes this change's own sharding exposes
Adding tests here moves later tests between CI shards, and two files depended on
which co-tenants they happened to share a worker with. No .test_durations is
committed, so pytest-split falls back to an even split by test COUNT -- the 9 tests
added above shift every later test's index and reshuffle that placement. Neither
test fails on mainline; both fail on this branch. The defects are pre-existing, the
trigger is this change, so they are fixed here rather than left for a follow-up.
- test_session_control.py: add the _fresh_create_budget autouse fixture.
create_rate_limit._buckets is a module global keyed (verb, caller_key) over a
five-minute window, and caller_key is the caller's SLOT NAME -- "chat-1" for these
tests, one of the most common names in the suite. Any co-tenant creating a session
from a chat-1 slot spends the same 20-per-window budget, after which the create
path refuses before reaching the behaviour under test and the failure reads "too
many sessions created recently" from an assertion that never ran. The file already
had the identical fixture for stop_retry's process-wide state (_fresh_stop_windows);
this is the sibling that was missed. Verified by pre-spending the chat-1 budget in
the same process: 2 failed without the fixture, 3 passed with it.
- test_design_tweak_relay_paths.py: make the 101-handshake relay test deterministic.
It fed the upstream socket from a daemon thread and used time.sleep(0.05) to order
the close after the read, which held on an idle machine and lost on a loaded runner
-- the pump saw EOF first and the assertion read an empty relay ("assert '101
Switching Protocols' in ''"). Now the handshake is written and the write half shut
down BEFORE the relay runs: a socket delivers buffered bytes ahead of the EOF that
follows them, so there is nothing to race. Also drops the dead scaffolding the
sleep was hiding -- an unused socketpair, a feeder thread writing to a socket the
relay never reads, and an empty `with patch.object(...): pass` block -- left behind
by an abandoned first attempt in the same test.
Tests
- test_harness_parity.py: codex is known but not shipped-selectable; codex
carries its own provider label; the model-switch channel is opt-in; the effort
channel is opt-in and the two channels are disjoint; the overlay is written
only for its readers; each mcpServers seam is spliced only for its own
harness; codex keeps its own spawn branch and protocol literal.
- test_acp_backend_kas.py: is_codex_backend in the mutual-exclusion pin, plus an
ACP_BACKENDS_ACP_RUNTIME assertion. The is_acp_runtime_backend == not
is_claude_backend equivalence is scoped to the three backends conversion
covered rather than ACP_BACKENDS_KNOWN: that identity holds only while claude
is the SOLE non-runtime harness, and codex is the first additional one, so
widening it would require every future harness to be claude-shaped -- the
negation reasoning H5/H6 exist to retire.
- test_agent_sdk_backend_install.py: the codex install row reads unknown and
names nothing to install, which is the gap that keeps it out of the baseline.
- AgentBackendTab.test.tsx: an agent this frontend has no name for renders under
its policy_id AND saves the id the wire accepts; and a known-but-unselectable
agent stays hidden even though the probe lists it. The probe returns a row per id
the CORE knows, a wider set than a deployment may select -- codex is exactly that
case -- so widening candidates to the payload had to be paired with a test that it
cannot smuggle in an option PATCH answers 400 for.
Not verified locally: vitest, tsc -b and eslint. Frontend dependencies could not be
installed in this environment, so the two frontend lanes are unproven here and CI
gives them their first signal. Checked without them: declaration order in the
component, that Boxes is already imported from lucide-react elsewhere in the tree,
and a hand-trace of all 15 pre-existing AgentBackendTab cases against the new
candidates (the default fixture yields ['', 'claude', 'kas'] in the same order as
before, so none should change behaviour).
Deliberately not changed: a failed adapter resolution stays cached for the
process lifetime. That is pre-existing, documented behaviour for the claude
resolver, and codex now matches it. Fixing it for codex alone would reintroduce
the asymmetry the rest of this change removes -- it should be both or neither,
in its own PR.
No .github/** file is touched. An earlier revision reformatted one call in
test_acp_backend_kas.py, which graduated the file off .github/black-baseline.txt
and forced a prune there -- and any .github change from a fork trips the
fork-workflow-change guard, which then needs a maintainer label. The reformat was
incidental to this change, so it is reverted instead: the file stays as the
baseline already describes it, and the guard has nothing to flag.
1adae96 to
c593891
Compare
Closing — a duplicate of #7813, which has since mergedVerified relationship: This is one piece of work submitted twice, and I verified the identity rather than inferring it: #7813's first commit f3dbcdb and #6910's sole commit c593891 carry the same author (Vamil Gandhi 13998000+vamgan@users.noreply.github.com), the same author date (Sun Aug 30 03:28:41 2026 +0000) and the same subject, so f3dbcdb is a rebase of c593891. #7813 has since merged: f51e659 is an ancestor of origin/main 1a765b8 (the issue/PR reference check still says 'open' and is stale). I then checked every line #6910 adds against main's file contents, file by file. The whole seam is landed: ACP_BACKEND_CODEX in ACP_BACKENDS_KNOWN (acp_backends.py:59), out of BASELINE_SELECTABLE_BACKENDS (:86), POLICY_ID_BY_BACKEND row (:107), the complete _resolve_codex_acp_bin ladder, PROTOCOL_VERSION_CODEX, the _is_codex spawn branch, the session/load arm, and both per-harness mcpServers splices (main client.py:3722-3723 is byte-identical to pr/6910:3701-3702). The frontend AgentBackendTab.tsx fix is landed too - the only diff between main and #6910 there is comment prose plus a cosmetic comparator rewrite, zero behavioural delta - and test_session_control.py's _fresh_create_budget fixture is already on main at line 58 with identical semantics. What is left of a 738-add PR is about 32 lines, and one of #6910's remaining pieces is not even harvestable: its single combined ACP_BACKENDS_CONFIG_OPTION_TUNING (pr/6910:acp_backends.py:362) is the design GPT 5.6 blocked on #6910's own head and that main resolved the other way, shipping ACP_BACKENDS_MODEL_VIA_CONFIG_OPTION (:355) and ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION (:364). Merging #6910 now would revert a landed decision, not add a capability. The contributor's work is not being deleted by this closure - his commit is on main; the closing note should say so. Why this one and not the other#7813 is your own commit, rebased and landed. Carry this over firstThis closure is about redundancy, and these items are the exception: they are not on Two hunks, both from pr/6910 (c593891), neither Codex-related and neither carried by #7813's merge; best landed as one or two small follow-up PRs. (1) test/test_design_tweak_relay_paths.py :: TestRelayWsHandshakeSanitization.test_set_cookie_stripped_from_101 (+17/-30) - replaces the sleep-ordered daemon feeder thread with a pre-written socketpair plus Current state#7813 merged as f51e659 (verified ancestor of origin/main 1a765b8) and PARTIALLY covers #6910 - substantively it covers all of it, since #7813's first commit is a rebase of #6910's only commit, so the entire Codex seam, the six providers/acp.py negation-to-membership conversions, the AgentBackendTab.tsx candidates fix and all seven new harness-parity tests are already on main. The remainder is two hunks #7813's rebase dropped: the test_design_tweak_relay_paths.py::test_set_cookie_stripped_from_101 deflake and the platform/defaults.py DefaultProviderRegistry comment correction (which #7813's own merge is what made necessary). #6910's combined ACP_BACKENDS_CONFIG_OPTION_TUNING is not a remainder but a superseded design: main carries the split ACP_BACKENDS_MODEL_VIA_CONFIG_OPTION + ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION, so #6910 cannot land that half at all. From a repository-wide duplicate/overlap audit of every pull request open against |
Problem / Motivation
Kiro Crew drives one first-class harness (
kiro-cli) and adapts others through theACP seam. The Codex ACP adapter is a plausible next adapted harness, but its
identifier does not exist yet:
register_selectable_backend()refuses any idoutside
ACP_BACKENDS_KNOWN, andAcpProvider.__init__refuses it a second time,so there is currently no way for an edition to register a Codex provider at all —
not even to evaluate one.
Why it matters
This lands the identifier and the per-harness branches it needs, and stops
there:
ACP_BACKEND_CODEXis deliberately absent fromBASELINE_SELECTABLE_BACKENDS, so no build offers it, a persisted"codex"degrades to kiro through the existing single gate (H3), and nothing spawns the
adapter until something calls
register_selectable_backend.The reason for that absence is narrow and worth stating precisely, because it is
not the reason claude used to be excluded. Claude is baseline-selectable
today, and by the same argument (
acp/client.pyowns the spawn path, the adapteris a public npm package) codex qualifies too. What codex still lacks is the other
half claude has:
agent_sdk/backend_install.pyprobes claude's two binaries andnames the command that installs them, and there is no such probe for
codex-acp.Without it the install row can only read
unknown, so an operator whose sessionfailed to start would get a switch with nothing to act on.
What changed (motivation → approach → change)
Vocabulary and identifier
acp_backends.py—ACP_BACKEND_CODEXinACP_BACKENDS_KNOWNand inPOLICY_ID_BY_BACKEND, so a governance rule can name the id before anedition registers it (main added a completeness gate over that mapping; an id
missing from it would be ungovernable while looking governed).
test_agent_backend_editable.py— main now pinsBASELINE_SELECTABLE_BACKENDS == ACP_BACKENDS_KNOWN. Rather than relax that toan inequality, the exception is named in
NOT_SHIPPED_SELECTABLEwith itsreason, so a plain
baseline != knownstill fails and adding a secondexception is a deliberate edit.
acp/types.py—PROVIDER_LABEL_CODEX, plus an explicit non-membershipdecision with its reason for every Group B capability set (H6/H8): one
process per session, no
_session/steer, a Node harness so Crew's sandbox isthe only OS confinement it gets, and its own credentials store so a kiro
logout says nothing about it.
Spawn path
acp/client.py—_is_codex;PROTOCOL_VERSION_CODEXas its own literal(H10, so a future divergence is a one-line edit rather than a silent downgrade
of whichever harness moved first);
_resolve_codex_acp_binmirroring theclaude resolver's order and node-resolution rules; a
_spawnbranch; the_codex_session_mcp_servers()default-[]override seam; per-harness loglabels; and a
session/loadarm — the adapter keeps its own session records,so gating
file_okon a kiro transcript would make an activated codex sessionstart fresh every time instead of resuming.
acp/client.py— each per-harnessmcpServershook is now spliced onlyfor its own backend. Both defaults return
[], so the previous shared splicewas inert, but an edition overriding both hooks would hand a claude session
codex's entries and vice versa — and an entry whose transport the adapter does
not advertise fails the whole
session/new, not just that server.acp/client.py—_vendored_claude_acp_roots→_vendored_acp_roots. Theroots are plain
node_modulesdirectories and each resolver joins its ownpackage path onto them, so both adapters legitimately share it; the claude name
would have suggested codex had a copy of its own.
Tuning channels stated as membership (H6)
Six sites in
providers/acp.pyreadif is_claude_backend: <adapter> else: <kiro>. That reads correctly with two harnesses and is wrong with three — codextook the kiro arm at every one. Two new sets replace the negation:
ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTIONandACP_BACKENDS_KIRO_SLASH_COMMANDS.Kept separate from
ACP_BACKENDS_MODEL_VIA_CONFIG_OPTIONdespite identicalmembership today, because
AcpClient.supports_config_optionexists preciselybecause adapter builds ship one config option without the other — collapsing them
would let a harness that gained model-switching inherit an effort channel it never
advertised.
What was actually broken:
stream_commandandchange_effortrouted codex into_kiro.dev/commands/execute, a kiro extension the adapter does not implement,so every slash command and every effort push would have returned
-32601and reset the session.
_apply_effort_overlayand_apply_tool_search_overlaywrote the kiroworkspace
cli.jsonoverlay for codex, which never reads it. The companionclear (
_clear_cli_overlay_effort) was already membership-gated, so thosewrites left a stale effort level in the user's workspace that no later clear
could reach.
clear_efforttook the kiro arm: overlay write, then a/effortpush."claude"or"kiro", so a codexsession logged
backend=kiro. It now resolves throughPOLICY_ID_BY_BACKEND,which already owns that translation (and handles
ACP_BACKEND_KIRObeing theempty string).
change_effortnow reports unsupported for a harness in neither set ratherthan defaulting to a channel — an
if/elsewould still have meant "everythingthat isn't an adapter is kiro".
_set_claude_effort→_set_effort_config_option: the method is the channel, not the harness.Docs
docs/system-specs/modules/harness-parity.md— the intro records codex asthe one named baseline exception and why; the H6 row names the new sets and the
two new pins.
Dashboard
The Agent Backend switch could not have offered a registered agent.
visiblewasbuilt from a hard-coded
[KIRO, CLAUDE, KAS]and then filtered by the live schemaenum — narrowing works, widening is impossible — so an id an edition registered
through
register_selectable_backendwas selectable on the wire, valid toPATCH,present in the probe payload, and absent from the only control that sets
agent.acp_backend. That literal was the last copy of the selectable list, which isthe thing the registry exists to retire, and the panel's own module note already
promised the opposite ("a build that ships another agent lights it up here with no
frontend change").
website/src/pages/developer/AgentBackendTab.tsx—candidatesis now theunion of the schema enum, the probe payload,
current, and the ids this frontendhas names for, sorted KIRO-first then by
policy_id(the order the probe endpointalready uses, so the control does not reshuffle as queries resolve).
unavailable()still narrows it, so an agent this deployment may not select ishidden exactly as before.
at three ids permanently; as a floor it only guarantees the core agents keep rows
while the schema and probe queries are in flight — dropping it outright regressed
the "every option visible while the schema is still loading" case to a
single-option control on a slow load.
nameOf/iconOffall back to the server'spolicy_idand a generic icon foran id with no translated entry. Untranslated deliberately:
policy_idis the namea governance rule spells, so it is already a word rather than a token, and a chip
labelled
undefinedis the alternative. A core agent that ships selectable earnsa real i18n entry instead of the fallback.
include Claude Code, which stopped being true when claude joined the baseline.
Nothing renders differently today: codex is not selectable, so it is filtered out
exactly as before. The change is what makes the seam this PR ships reachable at all.
Why no screenshot: On a public build the panel renders the identical three agents
in the identical order — the probe payload now carries a
codexrow (it lists everyid
ACP_BACKENDS_KNOWNholds),unavailable()drops it exactly as the oldhard-coded list did, and the
policy_idlabel/icon fallback is unreachable untilsomething calls
register_selectable_backend, so there is no rendered state tocapture that differs from
main.Notes for review
test/test_acp_backend_kas.py: theis_acp_runtime_backend == not is_claude_backendequivalence is scoped to the three backends that conversioncovered, rather than
ACP_BACKENDS_KNOWN. That identity holds only while claude isthe sole non-runtime harness; codex is the first additional one, so widening it
would require every future harness to be claude-shaped — the negation reasoning
H5/H6 exist to retire. The durable form (codex's absence from
ACP_BACKENDS_ACP_RUNTIME) is pinned by its own assertion in the same file, so anedit routing codex onto
AcpRuntimecannot land green.Deliberately not changed: a failed adapter resolution stays cached for the
process lifetime. That is pre-existing, documented behaviour for the claude
resolver, and codex now matches it. Fixing it for codex alone would reintroduce the
asymmetry the rest of this change removes — it should be both or neither, in its own
PR.
Follow-up, not in scope:
_probe_codexinagent_sdk/backend_install.pyisthe single remaining thing between this seam and codex being baseline-selectable.
When it lands, the
NOT_SHIPPED_SELECTABLEentry and theunknown-row assertionboth come out.
Tests
test/test_harness_parity.py— codex is known but not shipped-selectable;codex carries its own provider label; the model-switch channel is opt-in; the
effort channel is opt-in and the two channels are disjoint; the overlay is
written only for its readers; each
mcpServersseam is spliced only for its ownharness; codex keeps its own spawn branch and protocol literal.
test/test_acp_backend_kas.py—is_codex_backendadded to themutual-exclusion pin, plus the new
ACP_BACKENDS_ACP_RUNTIMEassertion.test/test_agent_sdk_backend_install.py— the codex install row readsunknownand names nothing to install, which is the gap that keeps it out ofthe baseline.
website/src/test/AgentBackendTab.test.tsx— two cases the old candidate listcould not express: an agent this frontend has no name for renders under its
policy_idand saves the id the wire accepts; and a known-but-unselectableagent stays hidden even though the probe payload lists it (the probe returns a
row per id the CORE knows, which is wider than what a deployment may select —
codex is exactly that case, so widening
candidatesto the payload must notsmuggle in an option
PATCHanswers 400 for).unmodified
origin/mainworktree on this host (they need user-namespacesandbox sealing, which this machine does not permit).
mypyclean on all four changed source files.origin/main: harness-parity (self-test + diff-scoped),black, isort, flake8, docs-lint, scrub-lint, loop-bound-locks.
vitest,tsc -bandeslint. Frontend dependencies couldnot be installed in my environment, so the two frontend lanes are unverified here
and CI gives them their first real signal. What I could check without them:
declaration order in the component, that
Boxesis already imported fromlucide-reactelsewhere in the tree, and a hand-trace of all 15 pre-existingAgentBackendTabcases against the newcandidates(the default fixture yields['', 'claude', 'kas']in the same order as before, so none should changebehaviour).