From 87e94be7600ffab419136b7c67fee54ea13fbc13 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Sun, 31 May 2026 15:35:35 -0700 Subject: [PATCH 1/3] fix(tui): keep phone status context readable --- agent/conversation_loop.py | 8 + tests/test_tui_gateway_server.py | 41 +++++ tui_gateway/server.py | 35 ++++- .../__tests__/appChromeStatusRule.test.tsx | 42 ++++- .../createGatewayEventHandler.test.ts | 45 ++++++ ui-tui/src/app/createGatewayEventHandler.ts | 8 + ui-tui/src/components/appChrome.tsx | 145 ++++++++---------- ui-tui/src/gatewayTypes.ts | 4 +- ui-tui/src/types.ts | 1 + 9 files changed, 238 insertions(+), 91 deletions(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index c9954e4e90c8c..b7e65fec4fe01 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1101,6 +1101,14 @@ def run_conversation( approx_request_tokens = estimate_request_tokens_rough( api_messages, tools=agent.tools or None ) + # Some local/OpenAI-compatible lanes do not return usage on streamed + # responses. Keep a rough request-pressure estimate available for the + # TUI status bar so context does not stay pinned at 0 until a provider + # supplies real token usage. + try: + agent._last_request_context_tokens = approx_request_tokens + except Exception: + pass _runtime_context_error = _ollama_context_limit_error( agent, approx_request_tokens diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 364f9db6107d2..33e63260198f0 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -671,6 +671,47 @@ def test_status_callback_accepts_single_message_argument(): ) +def test_status_callbacks_include_live_usage_when_session_is_active(monkeypatch): + usage = {"context_used": 20900, "context_max": 262000, "context_percent": 8} + monkeypatch.setitem(server._sessions, "sid", {"agent": object()}) + monkeypatch.setattr(server, "_get_usage", lambda _agent: usage) + + with patch("tui_gateway.server._emit") as emit: + callbacks = server._agent_cbs("sid") + callbacks["thinking_callback"]("thinking...") + callbacks["status_callback"]("process", "running tool") + + assert emit.call_args_list[0].args == ( + "thinking.delta", + "sid", + {"text": "thinking...", "usage": usage}, + ) + assert emit.call_args_list[1].args == ( + "status.update", + "sid", + {"kind": "process", "text": "running tool", "usage": usage}, + ) + + +def test_get_usage_uses_rough_context_when_provider_usage_is_missing(): + agent = types.SimpleNamespace( + _last_request_context_tokens=20900, + context_compressor=types.SimpleNamespace( + compression_count=0, + context_length=262000, + last_prompt_tokens=0, + ), + model="dflash", + ) + + usage = server._get_usage(agent) + + assert usage["context_used"] == 20900 + assert usage["context_max"] == 262000 + assert usage["context_percent"] == 8 + assert usage["context_estimated"] is True + + def test_resolve_model_uses_inference_model_env(monkeypatch): monkeypatch.delenv("HERMES_MODEL", raising=False) monkeypatch.setenv("HERMES_INFERENCE_MODEL", " anthropic/claude-sonnet-4.6\n") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index fa93b666dd643..66e1916ded1db 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -391,14 +391,28 @@ def _emit(event: str, sid: str, payload: dict | None = None): write_json({"jsonrpc": "2.0", "method": "event", "params": params}) +def _usage_for_sid(sid: str) -> dict | None: + try: + agent = (_sessions.get(sid) or {}).get("agent") + if agent is None: + return None + return _get_usage(agent) + except Exception: + return None + + def _status_update(sid: str, kind: str, text: str | None = None): body = (text if text is not None else kind).strip() if not body: return + payload = {"kind": kind if text is not None else "status", "text": body} + usage = _usage_for_sid(sid) + if usage is not None: + payload["usage"] = usage _emit( "status.update", sid, - {"kind": kind if text is not None else "status", "text": body}, + payload, ) @@ -1328,6 +1342,11 @@ def _sync_session_key_after_compress( def _get_usage(agent) -> dict: g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) + rough_context = int( + getattr(agent, "_last_request_context_tokens", 0) + or getattr(agent, "_last_request_tokens_estimate", 0) + or 0 + ) usage = { "model": getattr(agent, "model", "") or "", "input": g("session_input_tokens", "session_prompt_tokens"), @@ -1342,12 +1361,15 @@ def _get_usage(agent) -> dict: } comp = getattr(agent, "context_compressor", None) if comp: - ctx_used = getattr(comp, "last_prompt_tokens", 0) or usage["total"] or 0 + ctx_real = getattr(comp, "last_prompt_tokens", 0) or 0 + ctx_used = ctx_real if ctx_real > 0 else rough_context or usage["total"] or 0 + ctx_estimated = ctx_real <= 0 and rough_context > 0 ctx_max = getattr(comp, "context_length", 0) or 0 if ctx_max: usage["context_used"] = ctx_used usage["context_max"] = ctx_max usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100))) + usage["context_estimated"] = ctx_estimated usage["compressions"] = getattr(comp, "compression_count", 0) or 0 try: from agent.usage_pricing import CanonicalUsage, estimate_usage_cost @@ -1768,6 +1790,13 @@ def _on_tool_progress( def _agent_cbs(sid: str) -> dict: + def _thinking_delta(text): + payload = {"text": text} + usage = _usage_for_sid(sid) + if usage is not None: + payload["usage"] = usage + _emit("thinking.delta", sid, payload) + return { "tool_start_callback": lambda tc_id, name, args: _on_tool_start( sid, tc_id, name, args @@ -1780,7 +1809,7 @@ def _agent_cbs(sid: str) -> dict: ), "tool_gen_callback": lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), - "thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}), + "thinking_callback": _thinking_delta, "reasoning_callback": lambda text: _emit( "reasoning.delta", sid, diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 83c8ad92573d0..102d723b2484f 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -110,9 +110,49 @@ describe('StatusRule compact phone layout', () => { const content = textContent(element) - expect(content).toContain('model dflash') + expect(content).toContain('dflash') expect(content).toContain('ctx 20.9k/262k 8%') expect(content).toContain('1 session') expect(content).toContain('~/Workspaces') }) + + it('does not spill compact busy status words across phone lines', () => { + const now = new Date('2026-05-31T22:30:00Z').getTime() + vi.useFakeTimers() + vi.setSystemTime(now) + + try { + const element = StatusRule({ + bgCount: 0, + busy: true, + cols: 58, + cwdLabel: '~/Workspaces', + liveSessionCount: 1, + model: 'dflash', + sessionStartedAt: now - 90_000, + showCost: false, + status: 'deliberating...', + statusColor: DEFAULT_THEME.color.ok, + t: DEFAULT_THEME, + turnStartedAt: now - 45_000, + usage: { + context_estimated: true, + context_max: 262000, + context_percent: 8, + context_used: 20900, + total: 20900 + }, + voiceLabel: 'voice off' + }) + + const content = textContent(element) + + expect(content).toContain('busy 45s | dflash | ctx ~20.9k/262k 8%') + expect(content).toContain('dur 1m 30s | voice off | 1 session | ~/Workspaces') + expect(content).not.toContain('deliberating') + expect(content).not.toContain('model dfla') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index afebc4d10aca7..1ef34a29a680d 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -319,6 +319,51 @@ describe('createGatewayEventHandler', () => { } }) + it('updates status-bar usage from live thinking and status events', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ + payload: { + text: 'thinking...', + usage: { + calls: 1, + context_estimated: true, + context_max: 262000, + context_percent: 8, + context_used: 20900, + input: 0, + output: 0, + total: 0 + } + }, + type: 'thinking.delta' + } as any) + + expect(getUiState().usage.context_used).toBe(20900) + expect(getUiState().usage.context_estimated).toBe(true) + + onEvent({ + payload: { + kind: 'process', + text: 'running tool', + usage: { + calls: 1, + context_max: 262000, + context_percent: 9, + context_used: 24000, + input: 0, + output: 0, + total: 0 + } + }, + type: 'status.update' + } as any) + + expect(getUiState().usage.context_used).toBe(24000) + expect(getUiState().usage.context_percent).toBe(9) + }) + it('ignores late thinking.delta after the turn has already completed', () => { vi.useFakeTimers() const appended: Msg[] = [] diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 987518a4460cb..55f4f792cf03b 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -406,6 +406,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } + if (ev.payload?.usage) { + patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } })) + } + const text = ev.payload?.text if (text !== undefined) { @@ -432,6 +436,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } + if (p.usage) { + patchUiState(state => ({ ...state, usage: { ...state.usage, ...p.usage } })) + } + if (p.kind === 'goal') { sys(p.text) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index a93d9683da004..cb768844afa26 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -268,9 +268,37 @@ const modelLabel = (model: string, effort?: string, fast?: boolean) => const COMPACT_STATUS_COLS = 92 +const fitCompactText = (text: string, width: number) => { + if (width <= 0) { + return '' + } + + if (text.length <= width) { + return text + } + + if (width <= 3) { + return text.slice(0, width) + } + + return `${text.slice(0, width - 3).trimEnd()}...` +} + +const compactModelLabel = (model: string, effort?: string, fast?: boolean) => { + const label = modelLabel(model, effort, fast) + + if (label.length <= 18) { + return label + } + + return fitCompactText(label, 18) +} + const contextStatusLabel = (usage: Usage) => { + const ctxUsed = usage.context_used && usage.context_used > 0 ? usage.context_used : usage.total + const ctxLabel = usage.context_max - ? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` + ? `${fmtK(ctxUsed ?? 0)}/${fmtK(usage.context_max)}` : usage.total > 0 ? `${fmtK(usage.total)} tok` : '' @@ -281,7 +309,9 @@ const contextStatusLabel = (usage: Usage) => { const pct = usage.context_percent - return usage.context_max && pct != null ? `ctx ${ctxLabel} ${pct}%` : `ctx ${ctxLabel}` + const prefix = usage.context_estimated ? 'ctx ~' : 'ctx ' + + return usage.context_max && pct != null ? `${prefix}${ctxLabel} ${pct}%` : `${prefix}${ctxLabel}` } export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { @@ -339,9 +369,9 @@ export function StatusRule({ const bar = usage.context_max ? ctxBar(pct) : '' const compact = cols > 0 && cols < COMPACT_STATUS_COLS - const compactBarWidth = cols < 70 ? 0 : cols < 82 ? 5 : 7 const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel) const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : '' + const now = Date.now() const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => { event.stopImmediatePropagation?.() @@ -360,92 +390,37 @@ export function StatusRule({ if (compact) { const width = Math.max(1, cols || 1) - const compactModelLabel = modelLabel(model, modelReasoningEffort, modelFast) const compactCtxLabel = contextStatusLabel(usage) - const compactBar = usage.context_max && compactBarWidth > 0 ? ctxBar(pct, compactBarWidth) : '' + + const primaryStatus = busy + ? `busy${turnStartedAt ? ` ${fmtDuration(now - turnStartedAt)}` : ''}` + : status || 'ready' + + const firstLine = fitCompactText( + ['-', primaryStatus, compactModelLabel(model, modelReasoningEffort, modelFast), compactCtxLabel] + .filter(Boolean) + .join(' | '), + width + ) + + const secondLine = fitCompactText( + [ + sessionStartedAt ? `dur ${fmtDuration(now - sessionStartedAt)}` : '', + voiceLabel || '', + sessionCountText, + bgCount > 0 ? `${bgCount} bg` : '', + showCost && typeof usage.cost_usd === 'number' ? `$${usage.cost_usd.toFixed(4)}` : '', + cwdLabel || '' + ] + .filter(Boolean) + .join(' | '), + width + ) return ( - - - {'─ '} - - {busy ? ( - - ) : ( - - {status} - - )} - {compactModelLabel ? ( - - {' │ model '} - {compactModelLabel} - - ) : null} - {compactCtxLabel ? ( - - {' │ '} - {compactCtxLabel} - - ) : null} - {compactBar ? ( - - {' '} - [{compactBar}] - - ) : null} - - - - - {sessionStartedAt ? ( - - dur - - ) : ( - - {cwdLabel ? 'cwd ' : ''} - - )} - {typeof usage.compressions === 'number' && usage.compressions > 0 ? ( - = 10 ? t.color.error : usage.compressions >= 5 ? t.color.warn : t.color.muted}> - {' │ cmp '} - {usage.compressions} - - ) : null} - - {voiceLabel ? ( - - {' │ '} - {voiceLabel} - - ) : null} - {sessionCountNode} - {bgCount > 0 ? ( - - {' │ '} - {bgCount} bg - - ) : null} - {showCost && typeof usage.cost_usd === 'number' ? ( - - {' │ $'} - {usage.cost_usd.toFixed(4)} - - ) : null} - {cwdLabel ? ( - - {' │ '} - {cwdLabel} - - ) : null} - + {firstLine} + {secondLine} ) } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 447dec3ea4920..7ecffc5a0b44c 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -504,9 +504,9 @@ export type GatewayEvent = | { payload?: { skin?: GatewaySkin }; session_id?: string; type: 'gateway.ready' } | { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' } | { payload: SessionInfo; session_id?: string; type: 'session.info' } - | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } + | { payload?: { text?: string; usage?: Usage }; session_id?: string; type: 'thinking.delta' } | { payload?: undefined; session_id?: string; type: 'message.start' } - | { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' } + | { payload?: { kind?: string; text?: string; usage?: Usage }; session_id?: string; type: 'status.update' } | { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } | { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' } | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 0bfab6c271da2..bbd075222fd96 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -168,6 +168,7 @@ export interface Usage { context_max?: number context_percent?: number context_used?: number + context_estimated?: boolean cost_status?: string cost_usd?: number input: number From f1edc20535b05c6128bc66345212802c725a4f28 Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Sun, 31 May 2026 15:39:29 -0700 Subject: [PATCH 2/3] fix(tui): polish compact status prefix --- tests/test_tui_gateway_server.py | 34 ++++++++++++++++ tui_gateway/server.py | 40 ++++++++++++++++--- .../__tests__/appChromeStatusRule.test.tsx | 2 +- ui-tui/src/components/appChrome.tsx | 2 +- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 33e63260198f0..ddf7e61c3d265 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -712,6 +712,40 @@ def test_get_usage_uses_rough_context_when_provider_usage_is_missing(): assert usage["context_estimated"] is True +def test_get_usage_estimates_initial_context_from_system_and_tools(monkeypatch): + from agent import model_metadata + + captured = {} + + def fake_estimate(messages, system_prompt="", tools=None): + captured["messages"] = messages + captured["system_prompt"] = system_prompt + captured["tools"] = tools + return 12000 + + monkeypatch.setattr(model_metadata, "estimate_request_tokens_rough", fake_estimate) + + tools = [{"function": {"name": "read_file", "parameters": {}}}] + agent = types.SimpleNamespace( + _cached_system_prompt="system", + context_compressor=types.SimpleNamespace( + compression_count=0, + context_length=262000, + last_prompt_tokens=0, + ), + model="dflash", + tools=tools, + ) + + usage = server._get_usage(agent) + + assert captured == {"messages": [], "system_prompt": "system", "tools": tools} + assert usage["context_used"] == 12000 + assert usage["context_percent"] == 5 + assert usage["context_estimated"] is True + assert agent._last_usage_context_estimate == 12000 + + def test_resolve_model_uses_inference_model_env(monkeypatch): monkeypatch.delenv("HERMES_MODEL", raising=False) monkeypatch.setenv("HERMES_INFERENCE_MODEL", " anthropic/claude-sonnet-4.6\n") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 66e1916ded1db..ccf34d97b8b80 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -401,6 +401,40 @@ def _usage_for_sid(sid: str) -> dict | None: return None +def _rough_context_estimate(agent) -> int: + for attr in ("_last_request_context_tokens", "_last_request_tokens_estimate"): + try: + value = int(getattr(agent, attr, 0) or 0) + except Exception: + value = 0 + if value > 0: + return value + + try: + cached = int(getattr(agent, "_last_usage_context_estimate", 0) or 0) + except Exception: + cached = 0 + if cached > 0: + return cached + + try: + from agent.model_metadata import estimate_request_tokens_rough + + estimate = estimate_request_tokens_rough( + [], + system_prompt=getattr(agent, "_cached_system_prompt", "") or "", + tools=getattr(agent, "tools", None) or None, + ) + except Exception: + estimate = 0 + if estimate > 0: + try: + agent._last_usage_context_estimate = estimate + except Exception: + pass + return estimate + + def _status_update(sid: str, kind: str, text: str | None = None): body = (text if text is not None else kind).strip() if not body: @@ -1342,11 +1376,7 @@ def _sync_session_key_after_compress( def _get_usage(agent) -> dict: g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) - rough_context = int( - getattr(agent, "_last_request_context_tokens", 0) - or getattr(agent, "_last_request_tokens_estimate", 0) - or 0 - ) + rough_context = _rough_context_estimate(agent) usage = { "model": getattr(agent, "model", "") or "", "input": g("session_input_tokens", "session_prompt_tokens"), diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 102d723b2484f..304eb0ce42a6f 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -147,7 +147,7 @@ describe('StatusRule compact phone layout', () => { const content = textContent(element) - expect(content).toContain('busy 45s | dflash | ctx ~20.9k/262k 8%') + expect(content).toContain('- busy 45s | dflash | ctx ~20.9k/262k 8%') expect(content).toContain('dur 1m 30s | voice off | 1 session | ~/Workspaces') expect(content).not.toContain('deliberating') expect(content).not.toContain('model dfla') diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index cb768844afa26..010cf135eb739 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -397,7 +397,7 @@ export function StatusRule({ : status || 'ready' const firstLine = fitCompactText( - ['-', primaryStatus, compactModelLabel(model, modelReasoningEffort, modelFast), compactCtxLabel] + [`- ${primaryStatus}`, compactModelLabel(model, modelReasoningEffort, modelFast), compactCtxLabel] .filter(Boolean) .join(' | '), width From f7a4cf89a9ec6c39ee0850831f728d35716edb4f Mon Sep 17 00:00:00 2001 From: Omar Baradei Date: Sun, 31 May 2026 15:50:26 -0700 Subject: [PATCH 3/3] fix(agent): honor global stall retry config --- agent/stall_retry.py | 22 +++++++++++++++++----- tests/agent/test_stall_retry.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/agent/stall_retry.py b/agent/stall_retry.py index ede9c985a8774..c5853900f8cd4 100644 --- a/agent/stall_retry.py +++ b/agent/stall_retry.py @@ -101,17 +101,29 @@ def _as_bool(value: Any, default: bool) -> bool: def _stall_retry_config(agent: Any | None = None) -> Mapping[str, Any]: - cfg = getattr(agent, "_stall_retry_config", None) - if isinstance(cfg, Mapping): - return cfg + loaded_cfg: Mapping[str, Any] = {} try: from hermes_cli.config import load_config loaded = load_config() except Exception: - return {} + loaded = {} cfg = loaded.get("stall_retry") if isinstance(loaded, Mapping) else None - return cfg if isinstance(cfg, Mapping) else {} + if isinstance(cfg, Mapping): + loaded_cfg = cfg + + agent_cfg = getattr(agent, "_stall_retry_config", None) + if not isinstance(agent_cfg, Mapping): + return loaded_cfg + if not agent_cfg: + return loaded_cfg + + merged = dict(loaded_cfg) + for key, value in agent_cfg.items(): + if value is None or value == "": + continue + merged[str(key)] = value + return merged def get_stall_retry_model(agent: Any | None = None) -> str: diff --git a/tests/agent/test_stall_retry.py b/tests/agent/test_stall_retry.py index f27737e0a26cd..0a21ad933668e 100644 --- a/tests/agent/test_stall_retry.py +++ b/tests/agent/test_stall_retry.py @@ -319,6 +319,33 @@ def interruptible_api_call(kwargs: dict[str, object]) -> object: assert captured["kwargs"]["stream"] is False +def test_stall_retry_empty_agent_config_falls_back_to_loaded_config(monkeypatch) -> None: + import hermes_cli.config as config_mod + + monkeypatch.delenv("HERMES_STALL_RETRY_MODEL", raising=False) + monkeypatch.delenv("HERMES_STALL_RETRY_PROVIDER", raising=False) + monkeypatch.delenv("HERMES_STALL_RETRY_MAX_PER_TURN", raising=False) + monkeypatch.setattr( + config_mod, + "load_config", + lambda: { + "stall_retry": { + "max_per_turn": 3, + "model": "qwen3.6-27b-256k", + "provider": "taro", + } + }, + ) + + empty_agent = SimpleNamespace(_stall_retry_config={}) + provider_only_agent = SimpleNamespace(_stall_retry_config={"provider": "ko-mac"}) + + assert get_stall_retry_model(empty_agent) == "qwen3.6-27b-256k" + assert get_stall_retry_max_per_turn(empty_agent) == 3 + assert get_stall_retry_model(provider_only_agent) == "qwen3.6-27b-256k" + assert get_stall_retry_max_per_turn(provider_only_agent) == 3 + + def test_retry_on_stall_uses_configured_retry_provider(monkeypatch) -> None: captured: dict[str, object] = {} tool_call = SimpleNamespace( @@ -496,7 +523,7 @@ def test_conversation_loop_retries_empty_post_tool_before_tool_branch() -> None: assert empty_retry_idx < generic_stall_idx assert empty_retry_idx < tool_branch_idx - assert "and not _empty_after_tool_result" in source + assert "not _empty_after_tool_result" in source assert "EMPTY_AFTER_TOOL_RETRY_NUDGE" in source assert "accept_content=True" in source