From b2403133f552bf66e55ac3d2f2dd99c29cc3c0b4 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 16 Aug 2026 16:19:40 +0000 Subject: [PATCH 1/2] feat(desktop): composer model/mode pills, and the three switches that never worked (R6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pills beside the context ring, in the composer's own row: which model is about to answer, and which permission mode it runs under. An agent that advertises its choices gets a menu; claude, which advertises none, gets a text entry. A switch on the respawn route is previewed before it fires — it restarts the agent, and the pill says so rather than letting the director find out. Building the consumer measured the producer, and three of the four (family, field) pairs the Companion drives could never have switched anything: claude-code/model --model in every claude template → works claude-code/mode --permission-mode real flag, in NO template → 422 codex/model --model `codex app-server` has none codex/mode --approval-policy not a codex flag at all codex-cli 0.147.0: "error: unexpected argument '--approval-policy' found; tip: a similar argument exists: '--approve-for-me'" — the real one is `-a, --ask-for-approval`. `respawn` rewrites a flag that must already be in backend.cmd, so the last three answered a 422 reading "pick a fresh template that exposes it" — advice for a template that cannot exist. The routing table could not express this: one token answered for two independent capabilities. Families now declare `runtime_switch_fields: {mode, model}` alongside the route, the hub checks it before routing, and `GET /agent-families` publishes it so the client hides a control instead of discovering the refusal from an error. This is NOT the `agents.kind` defect fixed in df24415c — that gate matched no family at all; this one matched the right family and asked it a question it could not express. Claude's mode pill is read-only on purpose, not deferred: L3a measured that `--permission-mode plan` does not stop Bash under `--print`, so an M2 pill offering it would name a boundary the engine does not enforce. Codex's real path is `thread/start.config` (measured in L4c), which is driver work — task #243; flipping the registry bit lights the pill up with no UI change. Porting mobile's picker exactly would have hidden the pill whenever the agent advertises nothing, leaving a claude session with no model indicator at all. The desktop adds a read-only state mobile has no equivalent for, so "cannot be changed" and "unknown" stop looking identical. Tests: 10 new state tests, both mutation-checked — same-event list capture and a route-only gate each fail them. The Go case set separates the mask from the route (one family, one route, two different answers). Known gap, filed as #242: a respawn mints a new agent id. SessionsPanel follows it (keyed on session:agent from the digest); FocusRegion and ProjectBoard mount a fixed id and stay pointed at the terminated agent — the same exposure the pause/stop actions already have. Co-Authored-By: Claude Opus 5 --- .../resources/agent_families.generated.json | 16 ++ desktop/src/hub/client.ts | 19 ++ desktop/src/i18n/index.ts | 25 +++ desktop/src/state/runtimeSwitch.test.ts | 174 +++++++++++++++ desktop/src/state/runtimeSwitch.ts | 200 ++++++++++++++++++ .../styles/partials/05-transcript-boards.css | 113 ++++++++++ desktop/src/surfaces/AgentTranscript.tsx | 55 ++++- desktop/src/ui/SwitchPills.tsx | 179 ++++++++++++++++ docs/changelog-desktop.md | 30 +++ docs/plans/desktop-companion-vision-parity.md | 50 ++++- docs/reference/openapi.yaml | 16 ++ .../agentfamilies/agent_families.schema.json | 6 + .../agentfamilies/agent_families.yaml | 44 ++++ hub/internal/agentfamilies/families.go | 38 ++++ hub/internal/hostrunner/client.go | 6 + hub/internal/hostrunner/runner.go | 17 +- .../server/handlers_agent_families.go | 26 ++- hub/internal/server/handlers_agent_input.go | 48 +++-- .../server/respawn_with_spec_mutation_test.go | 34 ++- 19 files changed, 1055 insertions(+), 41 deletions(-) create mode 100644 desktop/src/state/runtimeSwitch.test.ts create mode 100644 desktop/src/state/runtimeSwitch.ts create mode 100644 desktop/src/ui/SwitchPills.tsx diff --git a/desktop/electron/resources/agent_families.generated.json b/desktop/electron/resources/agent_families.generated.json index 315fe727..907b5229 100644 --- a/desktop/electron/resources/agent_families.generated.json +++ b/desktop/electron/resources/agent_families.generated.json @@ -276,6 +276,10 @@ "M1": "respawn", "M2": "respawn" }, + "runtime_switch_fields": { + "mode": false, + "model": true + }, "prompt_image": { "M1": true, "M2": true, @@ -391,6 +395,10 @@ "M1": "rpc", "M2": "per_turn_argv" }, + "runtime_switch_fields": { + "mode": true, + "model": true + }, "prompt_image": { "M1": true, "M2": false, @@ -705,6 +713,10 @@ "M1": "respawn", "M2": "respawn" }, + "runtime_switch_fields": { + "mode": false, + "model": false + }, "prompt_image": { "M1": true, "M2": true, @@ -734,6 +746,10 @@ "runtime_mode_switch": { "M1": "rpc" }, + "runtime_switch_fields": { + "mode": true, + "model": true + }, "prompt_image": { "M1": true, "M4": false diff --git a/desktop/src/hub/client.ts b/desktop/src/hub/client.ts index 52f34965..213bd117 100644 --- a/desktop/src/hub/client.ts +++ b/desktop/src/hub/client.ts @@ -553,6 +553,25 @@ export class HubClient { body, }); } + /** Switch the agent's model or permission mode at runtime (vision-parity R6; + * `handlers_agent_input.go` `case "set_mode"` / `"set_model"`). + * + * The hub picks the wire path from the family registry, so one call covers + * all of them: `rpc` forwards an ACP `session/set_model`, `per_turn_argv` + * stashes the value for the next subprocess, and `respawn` terminates the + * agent and spawns a replacement on the same session row — answering + * `202 {"routed":"respawn"}`. The caller must have previewed that restart; + * `switchPills().respawns` is the flag for it. + * + * A 422 here means the engine cannot carry this field, which the registry + * publishes as `runtime_switch_fields` — check it before offering the + * control rather than discovering it from the error. */ + switchAgentRuntime(id: string, field: 'mode' | 'model', value: string): Promise { + return this.transport.post(this.transport.team(`/agents/${id}/input`), { + kind: field === 'mode' ? 'set_mode' : 'set_model', + ...(field === 'mode' ? { mode_id: value } : { model_id: value }), + }); + } /** Interrupt the agent's current turn (parity — mobile agents_api `_cancel`: * `postAgentInput(kind:'cancel')`). Lands in agent_events as a `producer:'user'` * cancel input the driver acts on — distinct from the `/stop` lifecycle, which diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index ef2e617d..6c3c3fda 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -394,6 +394,19 @@ const en: Dict = { 'ctx.label': 'ctx', 'ctx.title': 'Context window: {used} / {total} tokens ({pct}). Past ~90% the next response spills — a good moment to compact or branch a fresh thread.', 'ctx.compactHint': 'Click to put {cmd} in the composer — it is not sent for you.', + // R6 — runtime model / permission-mode pills beside the composer. + 'pills.model': 'Model', + 'pills.mode': 'Mode', + 'pills.unset': 'not reported', + 'pills.locked': + 'This engine cannot change its {field} while running — shown for reference. Switching it means spawning a new agent.', + 'pills.respawnHint': 'Changing the {field} restarts the agent on this session.', + 'pills.respawnWarn': + 'Switching {field} to "{value}" restarts the agent. The session and its transcript continue; anything the current turn has not finished is lost.', + 'pills.restartAndSwitch': 'Restart and switch', + 'pills.typeHint': 'model id or alias', + 'pills.apply': 'Switch', + 'pills.failed': 'Switch refused: {err}', 'ctx.costTitle': 'Cost this session, as reported by the engine. Engines that report none show nothing here rather than an estimate.', 'tx.new': 'new', 'tx.latest': 'Latest', @@ -2714,6 +2727,18 @@ const zh: Dict = { 'ctx.label': '上下文', 'ctx.title': '上下文窗口:{used} / {total} tokens({pct})。超过约 90% 后下一次回复会溢出——此时适合压缩上下文或另开一个会话。', 'ctx.compactHint': '点击将 {cmd} 填入输入框——不会自动发送。', + // R6 — 输入框旁的运行时模型 / 权限模式标签。 + 'pills.model': '模型', + 'pills.mode': '模式', + 'pills.unset': '未报告', + 'pills.locked': '该引擎在运行时无法更改{field}——此处仅作参考。要更改需要重新启动一个新的 agent。', + 'pills.respawnHint': '更改{field}会在当前会话上重启 agent。', + 'pills.respawnWarn': + '将{field}切换为「{value}」会重启 agent。会话及其记录会保留;当前这一轮尚未完成的内容会丢失。', + 'pills.restartAndSwitch': '重启并切换', + 'pills.typeHint': '模型 id 或别名', + 'pills.apply': '切换', + 'pills.failed': '切换被拒绝:{err}', 'ctx.costTitle': '本会话费用,由引擎自行上报。不上报费用的引擎此处留空,而不是给出估算值。', 'tx.new': '新', 'tx.latest': '最新', diff --git a/desktop/src/state/runtimeSwitch.test.ts b/desktop/src/state/runtimeSwitch.test.ts new file mode 100644 index 00000000..442cc3a4 --- /dev/null +++ b/desktop/src/state/runtimeSwitch.test.ts @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { Entity } from '../hub/types.ts'; +import { + anyPillVisible, + modeModelStateFromEvents, + switchOptions, + switchPills, + type SwitchPill, +} from './runtimeSwitch.ts'; + +/// The registry rows the desktop reads, matching what the hub publishes at +/// `GET /agent-families` (and what `agent_families.generated.json` carries). +const FAMILIES: Entity[] = [ + { + family: 'claude-code', + runtime_mode_switch: { M1: 'respawn', M2: 'respawn' }, + runtime_switch_fields: { model: true, mode: false }, + }, + { + family: 'codex', + runtime_mode_switch: { M1: 'respawn', M2: 'respawn' }, + runtime_switch_fields: { model: false, mode: false }, + }, + { + family: 'gemini-cli', + runtime_mode_switch: { M1: 'rpc', M2: 'per_turn_argv' }, + runtime_switch_fields: { model: true, mode: true }, + }, +]; + +const sys = (payload: Entity): Entity => ({ kind: 'system', payload }); +const pill = (pills: SwitchPill[], field: 'mode' | 'model'): SwitchPill => { + const p = pills.find((x) => x.field === field); + assert.ok(p !== undefined, `no ${field} pill`); + return p; +}; + +test('each of the four fields is captured from the latest event carrying it', () => { + // The separating input, and the reason this function exists. The hub posts + // a synthetic system event after a successful set_model that carries ONLY + // the new currentModelId; the list lives on the older session/new event. + // A reducer that took the list from the same event as the id would end up + // with a current model and an empty list — and the picker would vanish the + // moment the director first used it. + const events: Entity[] = [ + sys({ + currentModeId: 'default', + availableModes: [{ id: 'default', name: 'Default' }, { id: 'yolo', name: 'YOLO' }], + currentModelId: 'gemini-2', + availableModels: [{ modelId: 'gemini-2', name: 'Gemini 2' }, { modelId: 'gemini-3', name: 'Gemini 3' }], + }), + sys({ currentModelId: 'gemini-3' }), + ]; + const st = modeModelStateFromEvents(events); + assert.equal(st.currentModel, 'gemini-3', 'the newer id must win'); + assert.equal(st.availableModels.length, 2, 'the older list must survive the id-only event'); + assert.equal(st.currentMode, 'default'); + assert.equal(st.availableModes.length, 2); +}); + +test('a feed with no advertisement yields nothing rather than empty strings', () => { + const st = modeModelStateFromEvents([{ kind: 'text', payload: { body: 'hi' } }]); + assert.equal(st.currentMode, undefined); + assert.equal(st.currentModel, undefined); + assert.deepEqual(st.availableModes, []); +}); + +test('model entries key on modelId, mode entries on id', () => { + // kimi ships models with only `modelId`. An id-only reader collapsed every + // option to '' and the press guard swallowed them all, silently. + const models = switchOptions([{ modelId: 'kimi-code/k3', name: 'K3' }, { modelId: 'k2' }]); + assert.deepEqual(models.map((o) => o.id), ['kimi-code/k3', 'k2']); + assert.equal(models[1].label, 'k2', 'label falls back to the id'); + const modes = switchOptions([{ id: 'plan', name: 'Plan', description: 'read only' }]); + assert.deepEqual(modes, [{ id: 'plan', label: 'Plan', description: 'read only' }]); + assert.deepEqual(switchOptions([{ name: 'no id at all' }]), [], 'an option with no id is not offerable'); +}); + +test('claude M2: model is typeable and restarts the agent; mode is read-only', () => { + // The pair that separates the field mask from the route. Both fields route + // "respawn" for this family, so a mask that mirrored the route would give + // the same answer twice. `--model` is in every claude template's cmd; + // `--permission-mode` is in none, so the hub refuses it — and the pill must + // not offer a button whose only outcome is a 422. + const pills = switchPills( + 'claude-code', + 'M2', + FAMILIES, + { model: 'claude-opus-5', permission_mode: 'acceptEdits' }, + [], + ); + const model = pill(pills, 'model'); + assert.equal(model.kind, 'type', 'switchable, but claude advertises no model list'); + assert.equal(model.current, 'claude-opus-5'); + assert.equal(model.respawns, true, 'the respawn route must reach the confirm step'); + + const mode = pill(pills, 'mode'); + assert.equal(mode.kind, 'readonly'); + assert.equal(mode.current, 'acceptEdits', 'still worth showing what is in effect'); + assert.equal(mode.options.length, 0); +}); + +test('codex: model is read-only from session.init, mode is hidden entirely', () => { + // codex's session.init carries a model and no permission mode, and neither + // field is switchable (`--approval-policy` is not a codex flag; `codex + // app-server` takes no --model). So: show the one we know, offer neither. + const pills = switchPills('codex', 'M2', FAMILIES, { model: 'gpt-5-codex' }, []); + assert.equal(pill(pills, 'model').kind, 'readonly'); + assert.equal(pill(pills, 'model').current, 'gpt-5-codex'); + assert.equal(pill(pills, 'mode').kind, 'hidden'); + assert.equal(anyPillVisible(pills), true); +}); + +test('an ACP agent that advertises lists gets a picker, with no respawn warning', () => { + const events: Entity[] = [ + sys({ + currentModeId: 'default', + availableModes: [{ id: 'default', name: 'Default' }], + currentModelId: 'gemini-3', + availableModels: [{ modelId: 'gemini-3', name: 'Auto (Gemini 3)' }], + }), + ]; + const pills = switchPills('gemini-cli', 'M1', FAMILIES, undefined, events); + const model = pill(pills, 'model'); + assert.equal(model.kind, 'pick'); + assert.equal(model.currentLabel, 'Auto (Gemini 3)', 'the advertised name, not the raw id'); + assert.equal(model.respawns, false, 'rpc switches in place — no restart to preview'); + assert.equal(pill(pills, 'mode').kind, 'pick'); +}); + +test('the advertisement outranks session.init for what is in effect now', () => { + // session.init is the handshake frame and never changes again; the + // advertisement is what moves mid-session. Reading init first would pin the + // pill to the launch value forever. + const pills = switchPills( + 'gemini-cli', + 'M1', + FAMILIES, + { model: 'stale-from-handshake' }, + [sys({ currentModelId: 'gemini-3', availableModels: [{ modelId: 'gemini-3', name: 'G3' }] })], + ); + assert.equal(pill(pills, 'model').current, 'gemini-3'); +}); + +test('an unknown engine and an empty registry grant nothing', () => { + for (const [engine, families] of [ + ['no-such-engine', FAMILIES], + ['claude-code', [] as Entity[]], + [undefined, FAMILIES], + ] as const) { + const pills = switchPills(engine, 'M2', families, { model: 'x' }, []); + assert.equal(pill(pills, 'model').kind, 'readonly', 'a known value still shows'); + assert.equal(pill(pills, 'mode').kind, 'hidden'); + } + assert.equal(anyPillVisible(switchPills('no-such-engine', 'M2', FAMILIES, undefined, [])), false); +}); + +test('a driving mode the family never declared switches nothing', () => { + // claude-code declares M1 and M2 only. An M4 agent must not inherit M2's + // route by proximity — the hub answers "unsupported" for the missing key + // and the pill has to agree. + const pills = switchPills('claude-code', 'M4', FAMILIES, { model: 'claude-opus-5' }, []); + assert.equal(pill(pills, 'model').kind, 'readonly'); +}); + +test('a family that declares a route but no field mask offers no switch', () => { + // The no-affordance-by-default rule: a new family cannot inherit a + // capability by omitting the declaration. + const families: Entity[] = [{ family: 'mystery', runtime_mode_switch: { M2: 'rpc' } }]; + const pills = switchPills('mystery', 'M2', families, { model: 'm' }, []); + assert.equal(pill(pills, 'model').kind, 'readonly'); + assert.equal(pill(pills, 'mode').kind, 'hidden'); +}); diff --git a/desktop/src/state/runtimeSwitch.ts b/desktop/src/state/runtimeSwitch.ts new file mode 100644 index 00000000..53eb9902 --- /dev/null +++ b/desktop/src/state/runtimeSwitch.ts @@ -0,0 +1,200 @@ +import { obj, str, type Entity } from '../hub/types.ts'; + +/// R6 — what the model / permission-mode pills may offer, and for which of +/// them a click can actually succeed. +/// +/// Two independent questions decide a pill, and conflating them is what kept +/// this feature dead: +/// +/// 1. **What is in effect now?** Two sources, in priority order: the agent's +/// own ACP advertisement (`currentModelId` / `currentModeId` system +/// events, M1 engines) and the merged `session.init` frame (`model`, +/// `permission_mode` — what claude M2 reports). The advertisement wins +/// when both are present because it is the one that changes mid-session. +/// +/// 2. **Can it be changed?** The family registry answers, in two parts: +/// `runtime_mode_switch[drivingMode]` is HOW a switch travels, and +/// `runtime_switch_fields[field]` is WHETHER this field can travel at +/// all. The second was added in R6 after measuring that three of the four +/// (family, field) pairs the Companion drives could never succeed — +/// claude's `--permission-mode` is in no spawn template, and codex's +/// `--approval-policy` is not a codex flag. Before that, every one of +/// those clicks was a 422 the UI would have had no way to predict. +/// +/// The two answers are deliberately not merged: knowing the current model is +/// worth showing even when it cannot be changed, and mobile's picker — which +/// hides itself whenever the agent advertises nothing — leaves a claude +/// session with no model indicator at all. A read-only pill is the honest +/// middle state, and the one this port adds. + +/// The four fields an ACP agent advertises, captured independently. +export interface ModeModelState { + currentMode?: string; + availableModes: readonly Entity[]; + currentModel?: string; + availableModels: readonly Entity[]; +} + +/// Scan the feed backwards for the most recent value of each of the four +/// fields, INDEPENDENTLY — a port of mobile's `modeModelStateFromEvents` +/// (`feed_reducer.dart:548`) including the W7c fix that is the whole point of +/// the function. +/// +/// The hub posts a synthetic `system` event carrying only the new +/// `currentModeId` / `currentModelId` after a set_mode/set_model RPC +/// succeeds (`driver_acp.go:1969`); the `available*` lists live on the older +/// session/new event. Capturing the list from the same event branch as the id +/// — the obvious reading — means the first switch leaves `currentModel` set +/// with `availableModels` empty, and the picker hides itself immediately +/// after the director uses it. +export function modeModelStateFromEvents(events: readonly Entity[]): ModeModelState { + const out: ModeModelState = { availableModes: [], availableModels: [] }; + let haveModes = false; + let haveModels = false; + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if (str(e, 'kind') !== 'system') continue; + const p = obj(e, 'payload'); + if (p === undefined) continue; + if (out.currentMode === undefined && typeof p['currentModeId'] === 'string') { + out.currentMode = p['currentModeId']; + } + if (!haveModes && Array.isArray(p['availableModes'])) { + out.availableModes = (p['availableModes'] as unknown[]).filter(isEntity); + haveModes = true; + } + if (out.currentModel === undefined && typeof p['currentModelId'] === 'string') { + out.currentModel = p['currentModelId']; + } + if (!haveModels && Array.isArray(p['availableModels'])) { + out.availableModels = (p['availableModels'] as unknown[]).filter(isEntity); + haveModels = true; + } + if (out.currentMode !== undefined && out.currentModel !== undefined && haveModes && haveModels) { + break; + } + } + return out; +} + +function isEntity(v: unknown): v is Entity { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/// One option in a pill's menu. `id` is what rides the wire as +/// `mode_id` / `model_id`. +export interface SwitchOption { + id: string; + label: string; + description?: string; +} + +/// ACP spells the two lists differently: model entries carry `modelId`, mode +/// entries carry `id`. Mobile learned this the hard way — kimi's models ship +/// only `modelId`, so an `id`-only reader collapsed every option to the empty +/// string and the tap handler's non-empty guard swallowed every press with no +/// hub-side log to show for it (`session_details_sheet.dart:806`). +export function switchOptions(raw: readonly Entity[]): SwitchOption[] { + const out: SwitchOption[] = []; + for (const o of raw) { + const id = str(o, 'modelId') ?? str(o, 'id') ?? ''; + if (id === '') continue; + const label = str(o, 'name') ?? id; + const description = str(o, 'description'); + out.push(description === undefined ? { id, label } : { id, label, description }); + } + return out; +} + +/// How a pill behaves. +/// - `hidden` — nothing known and nothing offerable; render no pill. +/// - `readonly` — we know what is in effect but the hub would refuse a +/// change. Shown, not clickable, with the reason on hover. +/// - `pick` — the agent advertised options; a menu. +/// - `type` — switchable, but nobody advertised a vocabulary (claude's +/// `--model` takes an alias or a full name and lists neither), +/// so the director types the value. +export type PillKind = 'hidden' | 'readonly' | 'pick' | 'type'; + +export interface SwitchPill { + field: 'mode' | 'model'; + kind: PillKind; + /// The id in effect, when anything reported one. + current?: string; + /// Display text for `current` — an advertised option's `name` when the id + /// matches one, else the id itself. + currentLabel?: string; + options: SwitchOption[]; + /// True when committing this change restarts the agent (the `respawn` + /// route). The click must preview that, not just do it — a model flip + /// terminating the agent is exactly the kind of consequence IAA exists to + /// put in front of the director first. + respawns: boolean; +} + +const NO_FIELDS: Entity = {}; + +/// Resolve both pills for one agent. `families` is `GET /agent-families`; +/// `sessionInit` is the merged session.init frame; `events` is the feed. +/// +/// Keyed on the engine FAMILY (`agentEngine`, i.e. `backend.kind`) for the +/// same reason F3's capabilities are: a template-spawned steward carries its +/// persona in `agent.kind`, matches no family, and would silently lose every +/// affordance. +export function switchPills( + engine: string | undefined, + drivingMode: string, + families: readonly Entity[], + sessionInit: Entity | undefined, + events: readonly Entity[], +): SwitchPill[] { + const family = engine === undefined || engine === '' ? undefined : families.find((f) => str(f, 'family') === engine); + const routeRaw = family === undefined ? undefined : obj(family, 'runtime_mode_switch')?.[drivingMode]; + const route = typeof routeRaw === 'string' ? routeRaw : ''; + const fields = (family === undefined ? undefined : obj(family, 'runtime_switch_fields')) ?? NO_FIELDS; + const advertised = modeModelStateFromEvents(events); + + // A family that declares no route for this driving mode, or declares + // "unsupported", switches nothing — the same answer the hub gives. + const routed = route === 'rpc' || route === 'respawn' || route === 'per_turn_argv'; + const respawns = route === 'respawn'; + + const build = ( + field: 'mode' | 'model', + current: string | undefined, + rawOptions: readonly Entity[], + ): SwitchPill => { + const options = switchOptions(rawOptions); + const switchable = routed && fields[field] === true; + let kind: PillKind; + if (switchable && options.length > 0) kind = 'pick'; + else if (switchable) kind = 'type'; + else if (current !== undefined && current !== '') kind = 'readonly'; + else kind = 'hidden'; + const matched = current === undefined ? undefined : options.find((o) => o.id === current); + const pill: SwitchPill = { field, kind, options, respawns }; + if (current !== undefined && current !== '') { + pill.current = current; + pill.currentLabel = matched?.label ?? current; + } + return pill; + }; + + return [ + build( + 'model', + advertised.currentModel ?? str(sessionInit ?? {}, 'model'), + advertised.availableModels, + ), + build( + 'mode', + advertised.currentMode ?? str(sessionInit ?? {}, 'permission_mode'), + advertised.availableModes, + ), + ]; +} + +/// Whether the row renders at all. +export function anyPillVisible(pills: readonly SwitchPill[]): boolean { + return pills.some((p) => p.kind !== 'hidden'); +} diff --git a/desktop/src/styles/partials/05-transcript-boards.css b/desktop/src/styles/partials/05-transcript-boards.css index bdbba970..da5530e7 100644 --- a/desktop/src/styles/partials/05-transcript-boards.css +++ b/desktop/src/styles/partials/05-transcript-boards.css @@ -1084,9 +1084,122 @@ state to style. */ .composer-gauge { display: flex; + align-items: center; justify-content: flex-end; padding: var(--spacing-s4) var(--spacing-s12) 0; } + +/* R6 — model / permission-mode pills, sharing the gauge row. `margin-right: + auto` rather than `justify-content: space-between` on the parent so the ring + stays hard right when no pill renders — the row predates the pills and its + layout must not shift for engines that report neither. */ +.switch-pills { + display: flex; + align-items: center; + gap: var(--spacing-s4); + margin-right: auto; + min-width: 0; + flex-wrap: wrap; +} +.switch-pill-wrap { + position: relative; + display: inline-flex; +} +.switch-pill { + display: inline-flex; + align-items: center; + gap: var(--spacing-s4); + max-width: 22ch; + padding: 1px var(--spacing-s8); + border: 1px solid var(--border); + border-radius: var(--radius-stadium); + background: transparent; + color: var(--text-muted); + font-size: var(--font-size-caption); + line-height: 1.6; + cursor: pointer; +} +button.switch-pill:hover, +.switch-pill.open { + border-color: var(--border-strong); + color: var(--text); +} +/* A value the hub would refuse to change: no hover affordance, no pointer — + it is a readout, and it must not look like a control that failed. */ +.switch-pill.locked { + cursor: default; + border-style: dashed; +} +.switch-pill-key { + color: var(--text-muted); + opacity: 0.75; +} +.switch-pill-val { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.switch-pill-err { + color: var(--danger); + font-size: var(--font-size-caption); +} +.switch-pop { + position: absolute; + bottom: calc(100% + var(--spacing-s4)); + left: 0; + z-index: 20; + display: flex; + flex-direction: column; + min-width: 16rem; + max-height: 18rem; + overflow-y: auto; + padding: var(--spacing-s4); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + box-shadow: var(--sh-3); +} +.switch-pop.typed { + flex-direction: row; + align-items: center; + gap: var(--spacing-s4); + min-width: 18rem; +} +.switch-input { + flex: 1; + min-width: 0; + padding: var(--spacing-s4) var(--spacing-s8); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font-size: var(--font-size-caption); +} +.switch-opt { + display: flex; + flex-direction: column; + gap: 1px; + padding: var(--spacing-s4) var(--spacing-s8); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + font-size: var(--font-size-caption); + text-align: left; + cursor: pointer; +} +.switch-opt:hover { + background: var(--surface-hover); +} +.switch-opt.current { + color: var(--accent); +} +.switch-opt-desc { + color: var(--text-muted); + font-size: var(--font-size-caption); + opacity: 0.8; +} .ctx-ring { display: inline-flex; align-items: center; diff --git a/desktop/src/surfaces/AgentTranscript.tsx b/desktop/src/surfaces/AgentTranscript.tsx index 95f1a227..e713177a 100644 --- a/desktop/src/surfaces/AgentTranscript.tsx +++ b/desktop/src/surfaces/AgentTranscript.tsx @@ -10,6 +10,8 @@ import { agentEngine } from '../state/agentEngine'; import { drivingModeOf, promptCapabilities } from '../state/promptCapabilities'; import { compactCommandFor, contextFill, foldTranscriptStats } from '../state/transcriptStats'; import { ContextRing } from '../ui/ContextRing'; +import { SwitchPills } from '../ui/SwitchPills'; +import { anyPillVisible, switchPills } from '../state/runtimeSwitch'; import type { InputAttachments } from '../hub/client'; import { Composer } from '../ui/Composer'; import { ConfirmButton } from '../ui/ConfirmButton'; @@ -458,6 +460,25 @@ export function AgentTranscript({ agentId, sessionId }: { agentId: string; sessi : promptCapabilities(agentEngine(agentQ.data), drivingModeOf(agentQ.data), familiesQ.data), [agentQ.data, familiesQ.data], ); + // R6 — the model / permission-mode pills, resolved against the same + // registry for the same reason: which fields can actually be switched is + // published data (`runtime_switch_fields`), not a guess from the engine + // name. Three of the four (family, field) pairs the Companion drives cannot + // switch at all, and every one of those clicks used to be a 422 the UI had + // no way to see coming. Unlike `capabilities`, an unresolved registry is + // not a reason to abstain: a pill with no switch is still the honest + // readout of what is in effect, which is what a loading registry yields. + const pills = useMemo( + () => + switchPills( + agentEngine(agentQ.data), + drivingModeOf(agentQ.data), + familiesQ.data ?? [], + sessionInit, + events, + ), + [agentQ.data, familiesQ.data, sessionInit, events], + ); // A tool_result folded into its matching tool_call — not rendered on its own. const isFolded = (ev: FeedEvent): boolean => { @@ -1012,6 +1033,31 @@ export function AgentTranscript({ agentId, sessionId }: { agentId: string; sessi } } + /// R6 — commit a model / permission-mode switch. Deliberately does NOT + /// catch: SwitchPills renders the refusal beside the pill that caused it, + /// where the surface-level error banner would separate the sentence from + /// the control it is about. + /// + /// The invalidation is the half that is easy to forget. On the `respawn` + /// route the hub terminates this agent and spawns a REPLACEMENT with a new + /// id on the same session row, so `agentId` — the id the composer posts to + /// and the stream reads from — is stale the moment the call returns. + /// Surfaces that resolve their agent from the session (SessionsPanel keys + /// the transcript on `session:agent` from the digest's `current_agent_id`) + /// re-resolve and remount once these caches drop; the ones opened on a + /// fixed agent id (FocusRegion, ProjectBoard) do not, and stay pointed at + /// the terminated agent until reopened. Following the swap from a + /// fixed-id mount needs a session→current-agent hop those surfaces do not + /// have yet — filed rather than half-built here. + async function switchRuntime(field: 'mode' | 'model', value: string): Promise { + if (client === null) return; + await client.switchAgentRuntime(agentId, field, value); + await qc.invalidateQueries({ queryKey: ['agents'] }); + await qc.invalidateQueries({ queryKey: ['agent', agentId] }); + await qc.invalidateQueries({ queryKey: ['sessions'] }); + await qc.invalidateQueries({ queryKey: ['session-digest'] }); + } + const modes: { v: Mode; label: string }[] = [ { v: 'live', label: t('tx.live') }, { v: 'insight', label: t('tx.insight') }, @@ -1273,9 +1319,14 @@ export function AgentTranscript({ agentId, sessionId }: { agentId: string; sessi strip, because its question ("is there room for this prompt?") is asked while typing. The strip keeps the same numbers as text for anyone reading rather than glancing. */} - {fill !== undefined && ( + {/* One row: pills left (R6), ring right (R2). Both are composer-time + questions — what will answer this, and is there room for it. */} + {(fill !== undefined || anyPillVisible(pills)) && (
- + + {fill !== undefined && ( + + )}
)} Promise; +}): JSX.Element | null { + const t = useT(); + const { ask, node: confirmNode } = useConfirm(); + const [open, setOpen] = useState<'mode' | 'model' | null>(null); + const [typed, setTyped] = useState(''); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const rowRef = useRef(null); + + // Escape closes the menu, and a click anywhere else does too — a popover + // that only closes on re-click strands itself over the transcript. + useEffect(() => { + if (open === null) return; + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') setOpen(null); + }; + const onDown = (e: MouseEvent): void => { + if (rowRef.current !== null && !rowRef.current.contains(e.target as Node)) setOpen(null); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onDown); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('mousedown', onDown); + }; + }, [open]); + + if (!anyPillVisible(pills)) return null; + + const fieldLabel = (field: 'mode' | 'model'): string => + field === 'model' ? t('pills.model') : t('pills.mode'); + + async function commit(pill: SwitchPill, value: string): Promise { + if (value === '' || value === pill.current) { + setOpen(null); + return; + } + if (pill.respawns) { + const okToRestart = await ask({ + message: t('pills.respawnWarn') + .replace('{field}', fieldLabel(pill.field).toLowerCase()) + .replace('{value}', value), + confirmLabel: t('pills.restartAndSwitch'), + }); + if (!okToRestart) return; + } + setBusy(true); + setErr(null); + try { + await onPick(pill.field, value); + setOpen(null); + setTyped(''); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
+ {pills.map((pill) => { + if (pill.kind === 'hidden') return null; + const label = pill.currentLabel ?? t('pills.unset'); + const name = fieldLabel(pill.field); + if (pill.kind === 'readonly') { + return ( + + {name} + {label} + + ); + } + const isOpen = open === pill.field; + return ( + + + {isOpen && pill.kind === 'pick' && ( +
+ {pill.options.map((o) => ( + + ))} +
+ )} + {isOpen && pill.kind === 'type' && ( +
{ + e.preventDefault(); + void commit(pill, typed.trim()); + }} + > + setTyped(e.target.value)} + /> + +
+ )} +
+ ); + })} + {err !== null && {t('pills.failed').replace('{err}', err)}} + {confirmNode} +
+ ); +} diff --git a/docs/changelog-desktop.md b/docs/changelog-desktop.md index de8ed7fb..5b71f76c 100644 --- a/docs/changelog-desktop.md +++ b/docs/changelog-desktop.md @@ -42,6 +42,36 @@ This complements: ## Unreleased ### Added +- **The transcript now shows which model is about to answer — and lets you + change it where that is actually possible.** Two pills sit beside the + context ring, in the composer's own row, because these are questions asked + while typing rather than facts looked up in a details panel. An agent that + advertises its choices (the ACP engines) gets a menu; claude, which + advertises none, gets a text entry taking an alias or a full model name. + Switching a model on claude or codex **restarts the agent** — the hub spawns + a replacement on the same session, so the transcript continues — and the + pill says so and asks first, rather than doing it and letting you find out. + (vision-parity R6) + + **A pill that cannot change anything says so instead of disappearing.** + Mobile's picker hides itself whenever the agent advertises no options, which + on a claude session means no model indicator at all. Here the value still + shows, greyed, with the reason on hover: "cannot be changed while running" + and "unknown" are different facts and should not look the same. + +### Fixed +- **Three of the four runtime switches could never have worked, and nothing + said so.** The registry recorded *how* a mode/model switch travels but not + *whether* a given field can travel at all, and a switch that rewrites a + launch flag can only work if the flag is in the spawn command to begin with. + Measured against the real binaries: claude's `--permission-mode` is a real + flag that no template ships, `codex app-server` takes no `--model`, and + `--approval-policy` is not a codex flag at all (0.147.0 answers *"unexpected + argument"*; it is `--ask-for-approval`). All three answered a 422 advising + the director to pick a template that exposes the flag — a template that + cannot exist. Families now declare `runtime_switch_fields` per field, the + hub checks it before routing, and the UI hides a control it knows would be + refused. Only claude's model switch was ever real; it still is. - **The Companion can now run a *codex* session on this machine too.** Pick `codex` in the local picker and the dock drives it through the vendor's own `app-server` — a JSON-RPC thread that streams as it writes, folds tool calls diff --git a/docs/plans/desktop-companion-vision-parity.md b/docs/plans/desktop-companion-vision-parity.md index ec881e2b..848ef700 100644 --- a/docs/plans/desktop-companion-vision-parity.md +++ b/docs/plans/desktop-companion-vision-parity.md @@ -3,8 +3,8 @@ > **Type:** plan > **Status:** In flight (2026-08-05) — **W1 + W2 + W3 complete** > (F1 F2 L1 E1 R1 · L2 E2 R2 R3 F3 · L3a L3b E3 E4 R4; L3c deferrable). -> **W4 in flight (2026-08-16)**: F4, L4a, L4b and L4c shipped, so -> **lane L4 is complete** — codex is drivable locally. Left in W4: R5, R6. +> **W4 in flight (2026-08-16)**: F4, L4a–L4c and R6 shipped — codex is +> drivable locally and the composer carries model/mode pills. Left: R5. > **Audience:** principal · contributors · maintainers > **Last verified vs code:** 2026.730.1231-alpha (`cea267fa`) — every > anchor below re-verified against that tip by the authoring audit @@ -1076,6 +1076,52 @@ Audit ground truth: claude M2 = `driver_stdio.go`, codex M2 = carry `modelId` (ACP spec), and the picker hides itself entirely when no agent has advertised the lists — degrade honestly (D-4). + ★★ *Measured while building the UI (2026-08-16, shipped with it).* + **Three of the four (family, field) pairs the Companion drives could + never have switched anything**, and the routing table could not say + so, because one token answered for two independent capabilities: + + | family | field | flag the table wants | reality | + |---|---|---|---| + | claude-code | model | `--model` | in every claude template's cmd — **works** | + | claude-code | mode | `--permission-mode` | real flag (2.1.220: `acceptEdits\|auto\|bypassPermissions\|manual\|dontAsk\|plan`), in **no** template | + | codex | model | `--model` | `codex app-server` (the M2 argv) takes none | + | codex | mode | `--approval-policy` | **not a codex flag** — 0.147.0 says *"unexpected argument … a similar argument exists: `--approve-for-me`"*; the real one is `-a, --ask-for-approval` | + + `respawn` rewrites a flag that must already be in `backend.cmd`, so + the last three answered a 422 reading *"backend.cmd does not carry the + expected flag; pick a fresh template that exposes it"* — advice + pointing at a template that cannot exist. Fixed by splitting the + capability from the route: `runtime_switch_fields: {mode, model}` on + the family, published on `GET /agent-families`, enforced ahead of the + route, so the client can hide a control instead of discovering the + refusal from an error. Note this is **not** the same defect as the + `agents.kind` one above — that gate matched no family at all; this one + matched the right family and asked it a question it could not express. + + *Two consequences worth keeping:* + + - **Claude's mode pill is deliberately read-only, not deferred work.** + Shipping a template that carries `--permission-mode` is necessary + but not sufficient: L3a MEASURED that `--permission-mode plan` does + not stop Bash under `--print` (`claudewire.test.ts:56`), so an M2 + pill offering `plan` would name a boundary the engine does not + enforce — the failure mode + [a-safety-boundary-must-be-measured-not-named] exists to prevent. + Making it actionable needs a per-mode, per-driving-mode measurement + first. + - **Codex has a real path that is not respawn.** L4c measured + `thread/start.config` accepting a map of config overrides, and + `-c model=…` does the same from argv. Both are driver work; the + registry now says "no" honestly until one lands. + + *And one correction to bullet 3 above:* porting mobile's hide-when- + unadvertised rule **exactly** would leave a claude session with no + model indicator at all, since claude advertises no lists. The desktop + adds a third state mobile has no equivalent for — a read-only pill + showing what `session.init` reported — so "cannot be changed" and + "unknown" stop looking identical. + ### Lane D — design-system enforcement (desktop) - **D1 — desktop UI reference doc.** `ui-guidelines.md` is diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 06cb44ab..0aafd902 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -4130,6 +4130,10 @@ components: picker. The hub routes by `family.runtime_mode_switch[mode]`: rpc → forward to driver, respawn → mutate spec + DoSpawn, per_turn_argv → stash for next argv, unsupported → 422. + vision-parity R6 added a second gate ahead of the route: + `family.runtime_switch_fields[field]` must be true, else 422. + A route alone was not enough — it cannot express that claude-code + can switch its model but not its permission mode. - W4.1 added optional `images: [{mime_type, data}]` alongside `body` on text inputs. Validated mime allowlist (image/png|jpeg|webp|gif), ≤5 MiB decoded per image, ≤3 @@ -4216,6 +4220,18 @@ components: additionalProperties: type: string enum: [rpc, respawn, per_turn_argv, unsupported] + runtime_switch_fields: + type: object + description: | + vision-parity R6 — keyed by field (mode | model); true when the + route above can actually carry that field. Separate from the + route because they are separate questions: `respawn` rewrites a + flag that must already be in the spawn spec's backend.cmd, so a + family can declare a route and still be unable to switch one of + the two. Clients gate the picker on this — a false field means + /agents/{id}/input would answer 422. Missing key = false. + additionalProperties: + type: boolean prompt_image: type: object description: | diff --git a/hub/internal/agentfamilies/agent_families.schema.json b/hub/internal/agentfamilies/agent_families.schema.json index e7fad827..17c37b64 100644 --- a/hub/internal/agentfamilies/agent_families.schema.json +++ b/hub/internal/agentfamilies/agent_families.schema.json @@ -71,6 +71,12 @@ "enum": ["rpc", "respawn", "per_turn_argv", "unsupported"] } }, + "runtime_switch_fields": { + "type": "object", + "description": "Which of the two picker fields the route above can actually carry (vision-parity R6). Keyed by field because one routing token was answering for two independent capabilities and got three of four (family, field) pairs wrong: the `respawn` route rewrites a flag that must already be in the spec's backend.cmd, and claude-code's `--permission-mode` is in no spawn template while codex's `--approval-policy` is not a codex flag at all. Missing key = not switchable, so a new family cannot inherit the capability by omission.", + "propertyNames": { "enum": ["mode", "model"] }, + "additionalProperties": { "type": "boolean" } + }, "prompt_image": { "$ref": "#/$defs/ModalitySupport" }, "prompt_pdf": { "$ref": "#/$defs/ModalitySupport" }, "prompt_audio": { "$ref": "#/$defs/ModalitySupport" }, diff --git a/hub/internal/agentfamilies/agent_families.yaml b/hub/internal/agentfamilies/agent_families.yaml index 100fcc29..1e21f932 100644 --- a/hub/internal/agentfamilies/agent_families.yaml +++ b/hub/internal/agentfamilies/agent_families.yaml @@ -40,6 +40,19 @@ families: runtime_mode_switch: M1: respawn M2: respawn + # vision-parity R6 — which fields that route can actually carry. + # `--model` is in every claude template's cmd, so respawn rewrites + # it. `--permission-mode` is a real claude flag (2.1.220: + # acceptEdits|auto|bypassPermissions|manual|dontAsk|plan) that NO + # template ships, so the switch answered a 422 telling the director + # to pick a template that exposes it — and no such template exists. + # Shipping one is a separate decision, not an oversight: L3a + # MEASURED that `--permission-mode plan` does not stop Bash under + # `--print`, so a pill offering it on M2 would name a boundary the + # engine does not enforce. Full reasoning in families.go. + runtime_switch_fields: + model: true + mode: false # ADR-021 D5 / W4.6 — image content block input support per mode. # claude's stream-json (M2) accepts a content array with image # blocks; the driver wire shape lands in W4.2. M1 (claude-code SDK @@ -365,6 +378,15 @@ families: runtime_mode_switch: M1: rpc M2: per_turn_argv + # vision-parity R6 — both fields ride the ACP RPC (M1) / next-argv + # (M2) route, so neither depends on a flag already being in the + # spawn cmd. Whether a given agent offers options is a second, + # runtime gate: the picker hydrates from the agent's own + # availableModes / availableModels advertisement and hides when it + # advertises neither. + runtime_switch_fields: + model: true + mode: true # ADR-021 D5 / W4.5–W4.6 — gemini's M1 (--acp) accepts ACP image # content blocks (driver wire shape in W4.4); M2 (gemini -p) has # no inline-image affordance, so the driver strips and warns @@ -548,6 +570,20 @@ families: runtime_mode_switch: M1: respawn M2: respawn + # vision-parity R6 — BOTH false, measured against codex-cli 0.147.0. + # The respawn table asked for `--approval-policy`, which codex does + # not have ("error: unexpected argument '--approval-policy' found; + # tip: a similar argument exists: '--approve-for-me'" — the real + # flag is `-a, --ask-for-approval`). And `--model` is a flag of the + # interactive/exec rungs, not of `codex app-server`, which is the + # M2 argv — so mutating one into the cmd would yield a spec that + # fails to launch. Codex's real runtime override is the app-server's + # own `thread/start.config` map (measured in L4c) or `-c model=…`; + # both are driver work. Until then the hub says so honestly instead + # of routing a respawn that cannot succeed. + runtime_switch_fields: + model: false + mode: false # ADR-021 D5 / W4.3 — codex's app-server JSON-RPC `turn/start.input` # accepts an image content block. MEASURED against codex-cli # 0.147.0 (vision-parity L4c): the accepted shape is @@ -973,6 +1009,14 @@ families: # See ADR-054 D6 and the follow-up configOptions wedge. runtime_mode_switch: M1: rpc + # vision-parity R6 — the ACP RPC route carries both fields. Note + # the caveat above still applies: kimi advertises via `configOptions`, + # which ACPDriver does not yet read, so the picker stays hidden until + # that wedge lands. That is the runtime gate doing its job, not this + # declaration being wrong. + runtime_switch_fields: + model: true + mode: true # Verified from the initialize reply's promptCapabilities: # image: true, audio: false; no PDF capability is advertised. # M4 (tmux pane) has no inline-multimodal affordance. diff --git a/hub/internal/agentfamilies/families.go b/hub/internal/agentfamilies/families.go index b1010743..689045de 100644 --- a/hub/internal/agentfamilies/families.go +++ b/hub/internal/agentfamilies/families.go @@ -133,6 +133,44 @@ type Family struct { // per-family token couldn't disambiguate. RuntimeModeSwitch map[string]string `yaml:"runtime_mode_switch,omitempty" json:"runtime_mode_switch,omitempty"` + // RuntimeSwitchFields declares WHICH of the two picker fields the + // route above can actually carry. Keys are "mode" and "model"; + // a missing key reads as false (vision-parity R6). + // + // Split out because one routing token was answering for two + // independent capabilities, and it was wrong for three of the four + // (family, field) pairs the desktop Companion drives. `respawn` + // rewrites a flag that must already be in the spec's backend.cmd + // (respawn_with_spec_mutation.go), so a field is switchable only if + // the engine HAS such a flag and a spawn template ships it: + // + // - claude-code / model → `--model`, present in every claude + // template's cmd. Works. + // - claude-code / mode → `--permission-mode`. The flag is real + // (claude 2.1.220: acceptEdits|auto|bypassPermissions|manual| + // dontAsk|plan) but NO template carries it, so every switch + // answered 422 "backend.cmd does not carry the expected flag … + // pick a fresh template that exposes it" — advice pointing at a + // template that does not exist. Adding one is not enough on its + // own: `--permission-mode plan` was MEASURED not to gate Bash + // under `--print`, so an M2 pill offering it would assert a + // safety boundary the engine does not enforce. + // - codex / mode → the table asked for `--approval-policy`, which + // codex-cli 0.147.0 does not have at all ("error: unexpected + // argument '--approval-policy' found"; the real flag is + // `-a, --ask-for-approval`). + // - codex / model → `codex app-server`, the M2 argv, accepts no + // `-m/--model` (it is a flag of the interactive/exec rungs), so + // mutating one in would produce a spec that fails to launch. + // Codex's real runtime override is `thread/start.config` (a map + // of config overrides, measured in L4c) or `-c model=…`; both + // are driver work, not a spec mutation. + // + // Declared explicitly by every family that declares a route, so a + // new family cannot inherit a capability by omission — the same + // no-affordance-by-default rule the prompt_* maps follow. + RuntimeSwitchFields map[string]bool `yaml:"runtime_switch_fields,omitempty" json:"runtime_switch_fields,omitempty"` + // PromptImage declares image-content-block support per driving_mode // (ADR-021 D5 / W4.6). Mobile composer reads this map keyed by the // active agent's driving_mode to gate the inline image attach diff --git a/hub/internal/hostrunner/client.go b/hub/internal/hostrunner/client.go index c9b6b516..e2cdf5ac 100644 --- a/hub/internal/hostrunner/client.go +++ b/hub/internal/hostrunner/client.go @@ -136,6 +136,12 @@ type AgentFamilyFromHub struct { // (ADR-021 D4 / W2.1) over the wire so probe sweeps see the same // declaration the hub-server consults at /agents/{id}/input time. RuntimeModeSwitch map[string]string `json:"runtime_mode_switch,omitempty"` + // RuntimeSwitchFields mirrors agentfamilies.Family.RuntimeSwitchFields + // (vision-parity R6) — which of mode/model the route above can carry. + // Mirrored for the same reason as the route itself: the sentence above + // promises a probe sweep sees what the input handler consults, and the + // handler now consults both. + RuntimeSwitchFields map[string]bool `json:"runtime_switch_fields,omitempty"` // PromptImage mirrors agentfamilies.Family.PromptImage (ADR-021 D5 // / W4.6). Mobile composer reads it to gate inline image attach // per active driving_mode. diff --git a/hub/internal/hostrunner/runner.go b/hub/internal/hostrunner/runner.go index 856398ac..bfeaea07 100644 --- a/hub/internal/hostrunner/runner.go +++ b/hub/internal/hostrunner/runner.go @@ -1114,14 +1114,15 @@ func (a *Runner) fetchFamilies(ctx context.Context) []agentfamilies.Family { }) } out = append(out, agentfamilies.Family{ - Family: f.Family, - Bin: f.Bin, - VersionFlag: f.VersionFlag, - Supports: f.Supports, - Incompatibilities: incompat, - DefaultAuthMethod: f.DefaultAuthMethod, - RuntimeModeSwitch: f.RuntimeModeSwitch, - PromptImage: f.PromptImage, + Family: f.Family, + Bin: f.Bin, + VersionFlag: f.VersionFlag, + Supports: f.Supports, + Incompatibilities: incompat, + DefaultAuthMethod: f.DefaultAuthMethod, + RuntimeModeSwitch: f.RuntimeModeSwitch, + RuntimeSwitchFields: f.RuntimeSwitchFields, + PromptImage: f.PromptImage, }) } return out diff --git a/hub/internal/server/handlers_agent_families.go b/hub/internal/server/handlers_agent_families.go index 03bc233b..bc53a243 100644 --- a/hub/internal/server/handlers_agent_families.go +++ b/hub/internal/server/handlers_agent_families.go @@ -86,6 +86,15 @@ func (s *Server) handleListAgentFamilies(w http.ResponseWriter, r *http.Request) "incompatibilities": v.Family.Incompatibilities, "source": string(v.Source), "runtime_mode_switch": v.Family.RuntimeModeSwitch, + // vision-parity R6 — the route above says how a switch travels; + // this says which of the two fields it can carry at all. The + // desktop's pill row renders an actionable control only where + // this is true, so a switch the hub would refuse never gets a + // button. Published for the same reason the four modality maps + // below are: an unpublished capability and an absent one are + // the same thing on the wire, and the composers read this + // endpoint and nothing else. + "runtime_switch_fields": v.Family.RuntimeSwitchFields, // All four modality maps, not just images. Publishing only // `prompt_image` meant every client's PDF / audio / video gate // resolved false for every family — the affordances @@ -120,14 +129,15 @@ func (s *Server) handleGetAgentFamily(w http.ResponseWriter, r *http.Request) { for _, v := range views { if v.Family.Family == name { writeJSON(w, http.StatusOK, map[string]any{ - "family": v.Family.Family, - "bin": v.Family.Bin, - "version_flag": v.Family.VersionFlag, - "supports": v.Family.Supports, - "incompatibilities": v.Family.Incompatibilities, - "source": string(v.Source), - "runtime_mode_switch": v.Family.RuntimeModeSwitch, - "prompt_image": v.Family.PromptImage, + "family": v.Family.Family, + "bin": v.Family.Bin, + "version_flag": v.Family.VersionFlag, + "supports": v.Family.Supports, + "incompatibilities": v.Family.Incompatibilities, + "source": string(v.Source), + "runtime_mode_switch": v.Family.RuntimeModeSwitch, + "runtime_switch_fields": v.Family.RuntimeSwitchFields, + "prompt_image": v.Family.PromptImage, }) return } diff --git a/hub/internal/server/handlers_agent_input.go b/hub/internal/server/handlers_agent_input.go index 4e00c08f..956b84c0 100644 --- a/hub/internal/server/handlers_agent_input.go +++ b/hub/internal/server/handlers_agent_input.go @@ -154,7 +154,8 @@ func validateAttachments( } // resolveRuntimeModeSwitch returns the routing token for the given -// agent's family + driving_mode (ADR-021 D4 / W2.1). Returns: +// agent's family + driving_mode (ADR-021 D4 / W2.1), plus the family's +// per-field switchability mask (vision-parity R6). Returns: // - "rpc" / "respawn" / "per_turn_argv" — declared route the handler // dispatches on. // - "unsupported" — declared explicitly, OR the family didn't declare @@ -163,16 +164,25 @@ func validateAttachments( // - error — only on unexpected SQL failures; agent-not-found is folded // into "unsupported" because the agent_belongs_to_team check has // already ruled it out at the call site. -func (s *Server) resolveRuntimeModeSwitch(ctx context.Context, agentID string) (string, error) { +// +// The fields mask is returned alongside rather than resolved separately +// because both answers come from one family lookup, and a second +// resolver would be a second place for the `agents.kind` vs +// `backend_json.kind` distinction below to be got wrong — the exact +// duplication respawn_with_spec_mutation.go was refactored to remove. +// A nil mask (family declares none) grants nothing. +func (s *Server) resolveRuntimeModeSwitch( + ctx context.Context, agentID string, +) (string, map[string]bool, error) { var kind, drivingMode, backendJSON sql.NullString err := s.db.QueryRowContext(ctx, `SELECT kind, driving_mode, COALESCE(backend_json, '{}') FROM agents WHERE id = ?`, agentID).Scan(&kind, &drivingMode, &backendJSON) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return "unsupported", nil + return "unsupported", nil, nil } - return "", err + return "", nil, err } mode := drivingMode.String if mode == "" { @@ -191,13 +201,13 @@ func (s *Server) resolveRuntimeModeSwitch(ctx context.Context, agentID string) ( } fam, ok := s.agentFamilies.ByName(engine) if !ok { - return "unsupported", nil + return "unsupported", nil, nil } route := fam.RuntimeModeSwitch[mode] if route == "" { - return "unsupported", nil + return "unsupported", nil, nil } - return route, nil + return route, fam.RuntimeSwitchFields, nil } // P1.8: structured user input sink. Writes land in agent_events with @@ -476,19 +486,34 @@ func (s *Server) handlePostAgentInput(w http.ResponseWriter, r *http.Request) { // orchestration (W2.3) — currently a stub returning 501. unsupported // is the explicit "this engine path can't switch at runtime" signal. if in.Kind == "set_mode" || in.Kind == "set_model" { - route, routeErr := s.resolveRuntimeModeSwitch(r.Context(), agent) + route, switchFields, routeErr := s.resolveRuntimeModeSwitch(r.Context(), agent) if routeErr != nil { s.writeDBErr(w, routeErr) return } + // vision-parity R6 — the route says HOW a switch travels; this says + // WHETHER this field can travel at all. They are separate questions + // and the single token got three of four (family, field) pairs wrong: + // claude-code's `--permission-mode` is in no spawn template, and + // codex's `--approval-policy` is not a codex flag. Both answered a + // respawn 422 whose text told the director to go find a template + // that exposes the flag — advice for a template that cannot exist. + // Refusing here, before the route, gives the client one honest + // sentence and lets it hide the control instead of offering a + // button that always fails. + field := strings.TrimPrefix(in.Kind, "set_") + if !switchFields[field] { + writeErr(w, http.StatusUnprocessableEntity, + "engine does not support runtime "+field+ + " switching for this driving_mode") + return + } switch route { case "rpc", "per_turn_argv": // Fall through to the standard event-emit path below. case "respawn": - field := "mode" value := in.ModeID if in.Kind == "set_model" { - field = "model" value = in.ModelID } if err := s.respawnWithSpecMutation(r.Context(), agent, field, value); err != nil { @@ -513,8 +538,7 @@ func (s *Server) handlePostAgentInput(w http.ResponseWriter, r *http.Request) { return case "unsupported", "": writeErr(w, http.StatusUnprocessableEntity, - "engine does not support runtime "+ - strings.TrimPrefix(in.Kind, "set_")+ + "engine does not support runtime "+field+ " switching for this driving_mode") return default: diff --git a/hub/internal/server/respawn_with_spec_mutation_test.go b/hub/internal/server/respawn_with_spec_mutation_test.go index 2bb905b8..a0373af4 100644 --- a/hub/internal/server/respawn_with_spec_mutation_test.go +++ b/hub/internal/server/respawn_with_spec_mutation_test.go @@ -225,18 +225,28 @@ backend: // handler answered 422 "engine does not support runtime mode switching". So // the feature was dead for stewards at BOTH the routing gate and the executor // — fixing either alone leaves it broken. +// The fields mask (vision-parity R6) rides along, and the cases below are +// chosen to separate it from the route: every claude/codex row routes +// "respawn", so a mask that simply mirrored the route would pass. The +// distinguishing pairs are (claude-code, model) = true against (claude-code, +// mode) = false — one family, one route, two different answers. That is the +// whole reason the mask exists: `--model` is in every claude spawn cmd and +// `--permission-mode` is in none, and codex's `--approval-policy` is not a +// codex flag at all. func TestResolveRuntimeModeSwitch_StewardRoutes(t *testing.T) { srv, _ := newTestServer(t) for i, tc := range []struct { - name string - kind string - backend string - want string + name string + kind string + backend string + want string + wantModel bool + wantMode bool }{ - {"steward over claude-code", "steward.claude-m4", "claude-code", "respawn"}, - {"steward over codex", "steward.codex", "codex", "respawn"}, - {"direct spawn still works", "claude-code", "", "respawn"}, - {"unknown engine stays unsupported", "steward.mystery", "no-such-engine", "unsupported"}, + {"steward over claude-code", "steward.claude-m4", "claude-code", "respawn", true, false}, + {"steward over codex", "steward.codex", "codex", "respawn", false, false}, + {"direct spawn still works", "claude-code", "", "respawn", true, false}, + {"unknown engine stays unsupported", "steward.mystery", "no-such-engine", "unsupported", false, false}, } { t.Run(tc.name, func(t *testing.T) { // The handle must be unique per case: `agents` has a live-handle @@ -248,13 +258,19 @@ func TestResolveRuntimeModeSwitch_StewardRoutes(t *testing.T) { Handle: fmt.Sprintf("route-case-%d", i), Spec: "kind: steward\n", }) - got, err := srv.resolveRuntimeModeSwitch(context.Background(), agentID) + got, fields, err := srv.resolveRuntimeModeSwitch(context.Background(), agentID) if err != nil { t.Fatalf("resolve: %v", err) } if got != tc.want { t.Errorf("route = %q; want %q", got, tc.want) } + if fields["model"] != tc.wantModel { + t.Errorf("switch field model = %v; want %v", fields["model"], tc.wantModel) + } + if fields["mode"] != tc.wantMode { + t.Errorf("switch field mode = %v; want %v", fields["mode"], tc.wantMode) + } }) } } From 4c51670377e0c5dd91911d5ffa099e11ecb13076 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 00:41:41 +0000 Subject: [PATCH 2/2] test(hub): pin the R6 field-mask gate at the handler, not just the resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolver test pins the mask's VALUES — (claude-code, mode)=false and both codex fields false — but nothing exercised the handler actually consulting them: deleting the `!switchFields[field]` gate left the whole server suite green while turning every masked switch back into the doomed respawn attempt (a 500 "no live session" in the test rig; the misleading 422-with-template-advice in production) that R6 exists to remove. Three cases, one per wrong (family, field) pair, each asserting the typed 422 lands BEFORE the route and that a refusal writes no input event row. Verified by mutation: neutralizing the gate fails all three with `status = 500 want 422`. Co-Authored-By: Claude Fable 5 --- .../server/handlers_agent_input_test.go | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/hub/internal/server/handlers_agent_input_test.go b/hub/internal/server/handlers_agent_input_test.go index 549687aa..3f001505 100644 --- a/hub/internal/server/handlers_agent_input_test.go +++ b/hub/internal/server/handlers_agent_input_test.go @@ -443,6 +443,50 @@ func TestPostAgentInput_SetModeRouting_Unsupported(t *testing.T) { } } +// TestPostAgentInput_SetSwitch_FieldMaskRefusal — vision-parity R6. +// The route alone would send these to the respawn helper (and, with no +// live session, a 500) — the field mask must refuse them FIRST with the +// typed 422. These are the exact (family, field) pairs the mask exists +// for: claude-code routes respawn for both fields but only `--model` is +// in any spawn cmd, and codex can carry neither. The resolver test pins +// the mask's VALUES; this pins that the handler actually consults it — +// deleting the gate leaves that test green and turns every one of these +// into a respawn attempt. +func TestPostAgentInput_SetSwitch_FieldMaskRefusal(t *testing.T) { + s, _ := newTestServer(t) + h := newInputRouter(s) + cases := []struct { + name string + kind string + body map[string]any + }{ + {"claude_mode", "claude-code", map[string]any{"kind": "set_mode", "mode_id": "plan"}}, + {"codex_mode", "codex", map[string]any{"kind": "set_mode", "mode_id": "never"}}, + {"codex_model", "codex", map[string]any{"kind": "set_model", "model_id": "gpt-5-codex"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + agentID := seedAgentWithKindMode(t, s, tc.kind, "M2") + status, raw := postInput(t, h, defaultTeamID, agentID, tc.body) + if status != http.StatusUnprocessableEntity { + t.Fatalf("status = %d want 422, body=%s", status, raw) + } + if !bytes.Contains(raw, []byte("does not support")) { + t.Errorf("body missing typed marker: %s", raw) + } + // A refusal writes nothing: no input event row, and no respawn + // side effects (the agent row is untouched). + var n int + _ = evRForTeam(t, s, defaultTeamID).QueryRow( + `SELECT COUNT(1) FROM agent_events WHERE agent_id = ? AND kind LIKE 'input.set_%'`, + agentID).Scan(&n) + if n != 0 { + t.Errorf("refused switch emitted %d input rows; want 0", n) + } + }) + } +} + // TestPostAgentInput_SetMode_MissingFields — mode_id required for // set_mode, model_id required for set_model. Validation fires before // routing so an unknown family combined with a missing field still