Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions desktop/electron/resources/agent_families.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,10 @@
"M1": "respawn",
"M2": "respawn"
},
"runtime_switch_fields": {
"mode": false,
"model": true
},
"prompt_image": {
"M1": true,
"M2": true,
Expand Down Expand Up @@ -391,6 +395,10 @@
"M1": "rpc",
"M2": "per_turn_argv"
},
"runtime_switch_fields": {
"mode": true,
"model": true
},
"prompt_image": {
"M1": true,
"M2": false,
Expand Down Expand Up @@ -705,6 +713,10 @@
"M1": "respawn",
"M2": "respawn"
},
"runtime_switch_fields": {
"mode": false,
"model": false
},
"prompt_image": {
"M1": true,
"M2": true,
Expand Down Expand Up @@ -734,6 +746,10 @@
"runtime_mode_switch": {
"M1": "rpc"
},
"runtime_switch_fields": {
"mode": true,
"model": true
},
"prompt_image": {
"M1": true,
"M4": false
Expand Down
19 changes: 19 additions & 0 deletions desktop/src/hub/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
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
Expand Down
25 changes: 25 additions & 0 deletions desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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': '最新',
Expand Down
174 changes: 174 additions & 0 deletions desktop/src/state/runtimeSwitch.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
Loading
Loading