From 2d67ca872a72b2250a3dd5710d9981d38f8f5b50 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:51:41 -0400 Subject: [PATCH] feat(web): make the config editors consult the agent and a provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-profile editor offered inputs the selected agent silently ignores, and its model picker was non-functional exactly where it is named. Both come from the same root: the editor did not consult what it already holds — the selected agent's capabilities, and a provider to browse. This fixes that, and the same class of mismatch on the neighbouring screens. MODEL SELECTION STAYS ON THE AGENT PROFILE — the v3 split is correct. Model ids are bare, provider-agnostic strings so one setup can back an OpenRouter account AND a z.ai one without redefinition; moving the pins to Profiles would re-couple model choice to the credential and still not resolve which catalog a multi- account profile browses. What was actually wrong: browsing needs a provider and the setup names none, so the editor BORROWED one from a downstream profile — null on the "New agent profile" screen, where the Browse buttons then vanished. Replaced with an explicit "Browse models against" provider lens: any provider, defaulting to the borrowed/default one, used only for the Browse buttons and the default-model placeholders, never stored. No schema change. CAPABILITY-AWARE EDITORS (AgentCapabilities was modelled but unused by the UI): - Model slots follow capabilities.models: Kilo ('single') shows one slot labelled "model", OpenCode ('primary+small') shows primary + small, Claude Code ('tiers') shows the four. Mapped onto the tier keys the adapters read (opus; opus+haiku; all four), so the four-tier storage is unchanged and a hidden slot keeps its value. The listing counts only the slots the agent uses. - Gateway-compat section shows only for agents with capabilities.compatFlags (Claude Code). For Kilo/OpenCode it is hidden; flags a previous Claude Code setup left behind are kept and explained, not dropped. - The Models copy is agent-aware — the [1m] extended-context sentence appears only for the agent that sends it. - Skip-permissions no longer hardcodes --dangerously-skip-permissions; the concrete flag shows only for Claude Code (OpenCode lowers to --auto, Kilo to a config block). PROVIDER-AWARE where the provider is known: - Accounts offers session mode only for the first-party Anthropic endpoint (baseUrl null, not the custom "ask" provider) — a ~/.claude login the other providers cannot read. Switching to a gateway clears a stale configDir so the subscription token can never be shipped to a foreign host. - Profiles no longer badges a session account "no key" — it reuses core's credentialSource(), so a login counts as a credential and only a truly empty account warns. THE ONE MISMATCH WITH NO NET, closed: a compat flag on a Kilo/OpenCode profile was inert at edit, launch AND doctor. `compatIgnoredWarning` (shared.ts) now fires from both non-Claude adapters, so the doctor's agentWarnings pass surfaces a hand-edited or agent-switched config the UI's hiding cannot catch — the capability-gap-never-silently-dropped rule the tier and session warnings follow. Verified in a browser against a config with all three agents and a session account: each agent shows its own slot count, compat hides for the two that ignore it, session mode is offered only for Anthropic, and the session account is no longer mislabelled. 785 tests, 140.6 kB packed. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/agents/kilo/index.ts | 5 +- src/adapters/agents/opencode/index.ts | 5 +- src/adapters/agents/shared.ts | 29 ++++ test/adapters/agents/kilo.test.ts | 32 +++++ web/src/routes/Accounts.tsx | 53 ++++++-- web/src/routes/AgentProfiles.tsx | 187 ++++++++++++++++++++------ web/src/routes/Profiles.tsx | 12 +- 7 files changed, 266 insertions(+), 57 deletions(-) diff --git a/src/adapters/agents/kilo/index.ts b/src/adapters/agents/kilo/index.ts index 44c4960..8cdf2b7 100644 --- a/src/adapters/agents/kilo/index.ts +++ b/src/adapters/agents/kilo/index.ts @@ -19,6 +19,7 @@ import { ambientUnset, anthropicOptions, collapsedTierWarning, + compatIgnoredWarning, sessionUnavailableWarning, extendedContextWarning, modelRef, @@ -57,7 +58,7 @@ export const kilo = { }, binary, translate(input: TranslateInput): Translation { - const { intent, passthrough } = input + const { intent, passthrough, profile } = input const primary = intent.models.opus const config: Record = { @@ -82,6 +83,8 @@ export const kilo = { if (collapse) warnings.push(collapse) const ext = extendedContextWarning(intent, primary, 'Kilo') if (ext) warnings.push(ext) + const compat = compatIgnoredWarning(profile.compat, 'Kilo') + if (compat) warnings.push(compat) return { plan: { set, unset: ambientUnset(intent) }, args: [...passthrough], warnings } }, diff --git a/src/adapters/agents/opencode/index.ts b/src/adapters/agents/opencode/index.ts index 17336f0..0b9976b 100644 --- a/src/adapters/agents/opencode/index.ts +++ b/src/adapters/agents/opencode/index.ts @@ -16,6 +16,7 @@ import { ambientUnset, anthropicOptions, collapsedTierWarning, + compatIgnoredWarning, sessionUnavailableWarning, extendedContextWarning, modelRef, @@ -56,7 +57,7 @@ export const opencode = { }, binary, translate(input: TranslateInput): Translation { - const { intent, passthrough } = input + const { intent, passthrough, profile } = input const primary = intent.models.opus const small = intent.models.haiku @@ -87,6 +88,8 @@ export const opencode = { if (collapse) warnings.push(collapse) const ext = extendedContextWarning(intent, primary, 'OpenCode') if (ext) warnings.push(ext) + const compat = compatIgnoredWarning(profile.compat, 'OpenCode') + if (compat) warnings.push(compat) return { plan: { set, unset: ambientUnset(intent) }, args, warnings } }, diff --git a/src/adapters/agents/shared.ts b/src/adapters/agents/shared.ts index a41da85..593dffb 100644 --- a/src/adapters/agents/shared.ts +++ b/src/adapters/agents/shared.ts @@ -103,6 +103,35 @@ export function collapsedTierWarning( } } +/** + * Warn when a profile carries gateway compatibility flags that this agent does + * not consume. + * + * The compat mechanism is Claude-Code-only (capabilities.compatFlags). A flag + * toggled on for a Kilo or OpenCode setup changes nothing and, until this + * existed, said so NOWHERE — not at edit, not at launch, not in the doctor. + * That is the one capability mismatch with no net, and the + * capability-gap-never-silently-dropped rule the tier and session warnings + * already follow says it should have one. The flags are kept, not stripped, so + * the setup still works when it runs Claude Code again. + */ +export function compatIgnoredWarning( + compat: Record | undefined, + agentLabel: string, +): EnvWarning | null { + const active = Object.entries(compat ?? {}) + .filter(([, on]) => on) + .map(([id]) => id) + if (active.length === 0) return null + return { + severity: 'medium', + code: 'compat-ignored', + message: + `${agentLabel} does not use gateway compatibility flags; these are set but ignored: ` + + `${active.join(', ')}.`, + } +} + /** * Warn when a 1M-capable provider is reached without Claude Code's `[1m]` * signal, which only Claude Code sends. The model still runs; its window is diff --git a/test/adapters/agents/kilo.test.ts b/test/adapters/agents/kilo.test.ts index 1ddba9a..5e9eb9f 100644 --- a/test/adapters/agents/kilo.test.ts +++ b/test/adapters/agents/kilo.test.ts @@ -68,3 +68,35 @@ test('capabilities describe a single model slot', () => { assert.equal(kilo.binary.name, 'kilo') assert.equal(kilo.binary.overrideEnv, 'SWISSCODE_KILO_BIN') }) + +test('a gateway compat flag on a Kilo profile is flagged as ignored — the safety net', () => { + // The UI now hides the compat section for Kilo, but a hand-edited or + // agent-switched config can still carry a flag. Compat is Claude-Code-only, + // so Kilo warns rather than silently doing nothing — the one mismatch that + // previously had no net at edit, launch, or doctor. + const input: TranslateInput = { + intent: intent(), + profile: makeProfile({ provider: 'zai', compat: { disableAdaptiveThinking: true } }), + provider: null, + passthrough: [], + ambient: {}, + } + const w = kilo.translate(input).warnings.find((x) => x.code === 'compat-ignored') + assert.ok(w, 'Kilo should warn that a compat flag is ignored') + assert.match(w.message, /disableAdaptiveThinking/) + assert.match(w.message, /Kilo/) +}) + +test('a Kilo profile with no compat flags emits no compat-ignored warning', () => { + const input: TranslateInput = { + intent: intent(), + profile: makeProfile({ provider: 'zai' }), + provider: null, + passthrough: [], + ambient: {}, + } + assert.equal( + kilo.translate(input).warnings.find((x) => x.code === 'compat-ignored'), + undefined, + ) +}) diff --git a/web/src/routes/Accounts.tsx b/web/src/routes/Accounts.tsx index 1c2b39d..791de27 100644 --- a/web/src/routes/Accounts.tsx +++ b/web/src/routes/Accounts.tsx @@ -223,7 +223,12 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom const isNew = !data.state.providerAccounts?.[editing] const provider = data.providers.find((p) => p.id === draft.provider) const stored = data.state.providerAccounts?.[editing] - const mode: 'key' | 'session' = draft.configDir ? 'session' : 'key' + // Session mode belongs to the first-party Anthropic endpoint: a provider + // with no baseUrl of its own that is not the custom "ask me" one. A gateway + // (baseUrl set) or a custom endpoint cannot read a ~/.claude login. + const sessionCapable = Boolean(provider && provider.baseUrl === null && !provider.askBaseUrl) + const mode: 'key' | 'session' = + draft.configDir && sessionCapable ? 'session' : 'key' return ( <> Prom setBrowseProviderId(e.target.value)} + > + {data.providers.map((p) => ( + + ))} + + + {/* The label is not wrapped around its input the way `Field` wraps one, because the two live in different grid columns — hence the explicit htmlFor, which buys back the click target. */}
- {data.tiers.map((tier) => ( + {visibleSlots.map(({ tier, label }) => ( ))}
- {!provider ? ( - - No profile uses this setup yet, so there is no provider to browse a catalog from. - Type ids by hand, or attach it to a profile first. - + {provider && !browsable ? ( + {provider.label} publishes no browsable catalog — type ids by hand. ) : null} @@ -258,29 +331,49 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => put('skipPermissions', v)} + // Neutral wording: every agent supports skipping prompts, but each + // lowers it differently (Claude Code's --dangerously-skip-permissions, + // OpenCode's --auto, Kilo's config permission block), so the concrete + // flag is shown only for the agent that actually receives it. label={ - <> - Skip permission prompts --dangerously-skip-permissions - + agentId === 'claude-code' ? ( + <> + Skip permission prompts --dangerously-skip-permissions + + ) : ( + <>Skip permission prompts + ) } /> - - Gateway compatibility - {/* A flag that trades something away says what it costs, here as - well as on stderr — as the checkbox's note, so the price reads - as belonging to that flag rather than competing with its name. */} - {data.compatFlags.map((flag) => ( - put('compat', { ...compat, [flag.id]: v })} - label={{flag.id}} - note={flag.consequence ? `costs: ${flag.consequence}` : undefined} - noteTone="warn" - /> - ))} - + {/* Gateway compat is a Claude-Code-only mechanism (capabilities.compatFlags). + For an agent that does not consume it, a checked box would be a + pure silent no-op — nothing catches it at launch or in the doctor — + so the section is hidden, and a note explains any flags a previous + Claude Code setup left behind rather than dropping them. */} + {supportsCompat ? ( + + Gateway compatibility + {/* A flag that trades something away says what it costs, here as + well as on stderr — as the checkbox's note, so the price reads + as belonging to that flag rather than competing with its name. */} + {data.compatFlags.map((flag) => ( + put('compat', { ...compat, [flag.id]: v })} + label={{flag.id}} + note={flag.consequence ? `costs: ${flag.consequence}` : undefined} + noteTone="warn" + /> + ))} + + ) : Object.values(compat).some(Boolean) ? ( + + {agentInfo?.label ?? 'This agent'} does not use gateway compatibility flags; the ones + set here are kept but ignored until this setup runs Claude Code again. + + ) : null} @@ -320,7 +413,21 @@ export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => {agentProfiles.map(([name, ap]) => { const users = usersOf(name) - const pinned = data.tiers.filter((t) => ap.models?.[t]).length + // Count only the slots this agent actually reads — a Kilo setup + // with an opus id is "1 model", not "1 of 4 tiers". + const shape = data.agents.find((a) => a.id === (ap.agent ?? 'claude-code'))?.capabilities + .models + const slots = + shape === 'single' ? ['opus'] : shape === 'primary+small' ? ['opus', 'haiku'] : data.tiers + const pinned = slots.filter((t) => ap.models?.[t]).length + const modelsLabel = + shape === 'single' + ? pinned > 0 + ? '1 model' + : 'provider default' + : pinned > 0 + ? `${pinned} of ${slots.length} ${shape === 'primary+small' ? 'models' : 'tiers'} pinned` + : 'provider defaults' return ( {ap.agent ?? 'claude-code'} - - {pinned > 0 - ? `${pinned} of ${data.tiers.length} tiers pinned` - : 'provider defaults'} - + {modelsLabel} 0 ? 'default' : 'neutral'}> {users.length > 0 ? users.join(', ') : 'nothing yet'} diff --git a/web/src/routes/Profiles.tsx b/web/src/routes/Profiles.tsx index 5f27a8a..bc4f697 100644 --- a/web/src/routes/Profiles.tsx +++ b/web/src/routes/Profiles.tsx @@ -26,6 +26,7 @@ import { selectStyle, } from '../ui' import { EmptyState } from '../Brand' +import { credentialSource } from '../../../src/core/account' /** * Profiles — the pairing, and the only screen that expresses MULTIPLE accounts. @@ -229,7 +230,16 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom {a.provider} - {a.hasKey || a.apiKeyFromEnv ? null : no key} + {/* A session account is authenticated by its login, not a + key, so "no key" is only a warning for an account that + carries no credential of any kind. `credentialSource` + reads the redacted `hasKey`, so the browser and the + server agree on what counts. */} + {credentialSource(a) === 'none' ? ( + no credential + ) : credentialSource(a) === 'conflict' ? ( + key + login + ) : null} } />