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
52 changes: 42 additions & 10 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9598,14 +9598,16 @@ def test_respond_unpacks_sid_tuple_correctly():
# /model switch and other agent-mutating commands must reject while the
# session is running. agent.switch_model() mutates self.model, self.provider,
# self.base_url, self.client etc. in place — the worker thread running
# agent.run_conversation is reading those on every iteration. Same class of
# bug as the session.undo / session.compress mid-run silent-drop; same fix
# pattern: reject with 4009 while running.
# agent.run_conversation is reading those on every iteration. So a mid-turn
# config.set model must NOT switch in place; instead it queues the pick
# (session["pending_model_switch"]) and _apply_pending_model_switch applies it
# on the turn thread at the next turn start, where nothing is in flight.
# ---------------------------------------------------------------------------


def test_config_set_model_rejects_while_running(monkeypatch):
"""/model via config.set must reject during an in-flight turn."""
def test_config_set_model_defers_while_running(monkeypatch):
"""/model via config.set queues the pick during an in-flight turn instead
of rejecting or racing the worker thread."""
seen = {"called": False}

def _fake_apply(sid, session, raw, **_kwargs):
Expand All @@ -9627,17 +9629,47 @@ def _fake_apply(sid, session, raw, **_kwargs):
},
}
)
assert resp.get("error")
assert resp["error"]["code"] == 4009
assert "session busy" in resp["error"]["message"]
assert not resp.get("error")
result = resp["result"]
assert result["deferred"] is True
assert result["value"] == "anthropic/claude-sonnet-4.6"
assert not seen["called"], (
"_apply_model_switch was called mid-turn — would race with "
"the worker thread reading agent.model / agent.client"
"_apply_model_switch ran mid-turn — would race the worker thread "
"reading agent.model / agent.client; it must defer to turn start"
)
pending = server._sessions["sid"].get("pending_model_switch")
assert pending and pending["raw"] == "anthropic/claude-sonnet-4.6"
finally:
server._sessions.pop("sid", None)


def test_apply_pending_model_switch_runs_queued_pick(monkeypatch):
"""The queued pick is consumed once, on the turn thread, via
_apply_model_switch — and cleared so it can't re-fire next turn."""
calls = []

def _fake_apply(sid, session, raw, **kwargs):
calls.append(raw)
return {"value": raw, "warning": "", "confirm_required": False}

monkeypatch.setattr(server, "_apply_model_switch", _fake_apply)

session = _session(running=False)
session["agent"] = object()
session["pending_model_switch"] = {
"raw": "anthropic/claude-sonnet-4.6",
"confirm_expensive_model": False,
}

server._apply_pending_model_switch("sid", session)
assert calls == ["anthropic/claude-sonnet-4.6"]
assert "pending_model_switch" not in session

# Idempotent: a second turn start with nothing queued is a no-op.
server._apply_pending_model_switch("sid", session)
assert calls == ["anthropic/claude-sonnet-4.6"]


def test_config_set_model_allowed_when_idle(monkeypatch):
"""Regression guard: idle sessions can still switch models."""
seen = {"called": False}
Expand Down
88 changes: 75 additions & 13 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4294,6 +4294,44 @@ def _sync_agent_model_with_config(sid: str, session: dict) -> None:
)


def _apply_pending_model_switch(sid: str, session: dict) -> None:
"""Apply a model switch queued while a turn was running.

``config.set model`` on a busy session doesn't mutate the live agent (the
worker thread is reading model/client mid-request); it stashes the pick in
``session["pending_model_switch"]``. This runs on the TURN thread at turn
start — before the first model call, nothing in flight — so the in-place
swap (client rebuild, the slow part) is safe here. A failed switch keeps
the current model and never blocks the turn, matching
``_sync_agent_model_with_config``.
"""
pending = session.pop("pending_model_switch", None)
if not pending or session.get("agent") is None:
return
try:
result = _apply_model_switch(
sid,
session,
pending["raw"],
confirm_expensive_model=bool(pending.get("confirm_expensive_model")),
)
# A queued pick is a deliberate user action; honour the expensive-model
# confirm by NOT applying it silently — surface the warning and drop the
# switch rather than spend on a pricey model the user never confirmed.
if result.get("confirm_required"):
_emit(
"error",
sid,
{"message": result.get("confirm_message") or result.get("warning") or ""},
)
except Exception as e:
_emit(
"error",
sid,
{"message": f"Could not switch model: {e}"},
)


class CompressionLockHeld(Exception):
"""Raised by _compress_session_history when compression skipped due
to a concurrent lock on the session's compression_locks row."""
Expand Down Expand Up @@ -8989,6 +9027,11 @@ def run():
# (#29923 review defect). Any config.yaml change is adopted on
# the NEXT turn, after the finally-restore below.
if not one_turn_restore:
# A model picked mid-turn was queued (not applied in-place) —
# apply it now, on the turn thread before the first model call,
# so this turn runs on the model the user chose. Runs before the
# config sync so an explicit pick wins over a config.yaml change.
_apply_pending_model_switch(sid, session)
_sync_agent_model_with_config(sid, session)
cwd = _session_cwd(session)
_register_session_cwd(session)
Expand Down Expand Up @@ -9956,22 +9999,41 @@ def _(rid, params: dict) -> dict:
if not value:
return _err(rid, 4002, "model value required")
if session:
# Reject during an in-flight turn. agent.switch_model()
# mutates self.model / self.provider / self.base_url /
# self.client in place; the worker thread running
# agent.run_conversation is reading those on every
# iteration. A mid-turn swap can send an HTTP request
# with the new base_url but old model (or vice versa),
# producing 400/404s the user never asked for. Parity
# with the gateway's running-agent /model guard.
from hermes_cli.model_switch import parse_model_switch_args

# A live swap can't run in-place while a turn streams:
# agent.switch_model() mutates self.model / self.provider /
# self.base_url / self.client, and the worker thread running
# agent.run_conversation reads those every iteration — a
# mid-turn swap can fire an HTTP request with the new base_url
# but old model (400/404s). So instead of rejecting the pick
# (the old 4009), stash it and apply it at the NEXT turn start
# (_apply_pending_model_switch), where nothing is in flight.
# The user gets to pick, keep typing, and send the next turn on
# the new model without waiting for the swap or interrupting.
if session.get("running"):
return _err(
try:
pending_model = parse_model_switch_args(value).model_input
except Exception:
pending_model = str(value)
session["pending_model_switch"] = {
"raw": value,
"confirm_expensive_model": bool(
params.get("confirm_expensive_model", False)
),
}
return _ok(
rid,
4009,
"session busy — /interrupt the current turn before switching models",
{
"key": key,
"value": pending_model,
"warning": "",
"confirm_required": False,
"confirm_message": "",
"scope": "session",
"deferred": True,
},
)
from hermes_cli.model_switch import parse_model_switch_args

parsed_flags = parse_model_switch_args(value)
explicit_provider = parsed_flags.explicit_provider
if session.get("agent") is None and not explicit_provider.strip():
Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/__tests__/textInputPassThrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe('shouldPassThroughToGlobalHandler', () => {
it('always passes through non-voice global control keys', () => {
expect(shouldPassThroughToGlobalHandler('c', key({ ctrl: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('x', key({ ctrl: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('o', key({ ctrl: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ escape: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ tab: true }))).toBe(true)
expect(shouldPassThroughToGlobalHandler('', key({ pageUp: true }))).toBe(true)
Expand Down
11 changes: 6 additions & 5 deletions ui-tui/src/app/slash/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,11 @@ export const sessionCommands: SlashCommand[] = [
help: 'change or show model',
name: 'model',
run: (arg, ctx) => {
if (ctx.session.guardBusySessionSwitch('change models')) {
return
}

// No busy guard here (unlike session switching). A model change is a
// session-scoped config.set: idle it switches immediately; mid-turn the
// gateway QUEUES it and applies it at the next turn start (returning
// deferred:true) instead of rejecting. Either way the pick sticks without
// interrupting the stream or waiting on the swap.
if (!arg.trim()) {
return patchOverlayState({ modelPicker: true })
}
Expand Down Expand Up @@ -145,7 +146,7 @@ export const sessionCommands: SlashCommand[] = [
return ctx.transcript.sys('error: invalid response: model switch')
}

ctx.transcript.sys(`model → ${r.value}`)
ctx.transcript.sys(r.deferred ? `model → ${r.value} (applies next turn)` : `model → ${r.value}`)
ctx.local.maybeWarn(r)

patchUiState(state => ({
Expand Down
9 changes: 9 additions & 0 deletions ui-tui/src/app/useInputHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,15 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return patchOverlayState({ sessions: true })
}

// Ctrl+O opens the model picker without disturbing a typed draft — the
// same overlay `/model` opens, but reachable without clearing what you've
// typed to run the command. Works mid-stream: picking a model writes the
// session model (config.set), which the next turn reads while the in-flight
// turn keeps streaming.
if (isCtrl(key, ch, 'o')) {
return patchOverlayState({ modelPicker: true })
}

if (key.ctrl && ch.toLowerCase() === 'c') {
if (live.busy && live.sid) {
return turnController.interruptTurn({
Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/components/textInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,7 @@ export const shouldPassThroughToGlobalHandler = (
): boolean =>
(key.ctrl && input === 'c') ||
(key.ctrl && input === 'x') ||
(key.ctrl && input === 'o') ||
key.tab ||
(key.shift && key.tab) ||
key.pageUp ||
Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/content/hotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const HOTKEYS: [string, string][] = [
['Tab', 'apply completion'],
['↑/↓', 'completions / queue edit / history'],
['Ctrl+X', 'open live session switcher (deletes queued message while editing)'],
['Ctrl+O', 'open model picker (keeps your draft; applies to next turn mid-stream)'],
[action + '+A/E', 'home / end of line'],
[action + '+Z / ' + action + '+Y', 'undo / redo input edits'],
[action + '+W', 'delete word'],
Expand Down
3 changes: 3 additions & 0 deletions ui-tui/src/gatewayTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ export interface ConfigSetResponse {
confirm_message?: string
confirm_required?: boolean
credential_warning?: string
// A model pick made mid-turn is queued and applied at the next turn start,
// not live yet — the handler says "next turn" instead of "model → X".
deferred?: boolean
history_reset?: boolean
info?: SessionInfo
value?: string
Expand Down
Loading