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/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 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) + } }) } }