From 6aefc9d925957a5e0b8b4c4a75666e53d84ab4e0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:42:19 -0700 Subject: [PATCH] feat(gateway): show per-category context breakdown in /usage (#55204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel users get the same context split the desktop popover shows (PR #54907) — system prompt, tools, rules, skills, MCP, subagents, memory, conversation — under the existing Context line in /usage. Reuses agent.context_breakdown.compute_session_context_breakdown, so there is no new tool and no new engine. The slices are estimates (chars/4) and the block is labelled _(estimated)_; the headline Context line keeps using the provider-measured last_prompt_tokens. Rendering is fail-open: any engine error returns no breakdown and the rest of /usage is unaffected. - gateway/slash_commands.py: _context_breakdown_lines() helper + wire into _handle_usage_command - locales/*.yaml: breakdown_header, breakdown_line, and 8 category labels across all 16 locales (parity gate) - tests/gateway/test_usage_command.py: render + fail-open coverage --- gateway/slash_commands.py | 50 +++++++++++++++++++++++ locales/af.yaml | 10 +++++ locales/de.yaml | 10 +++++ locales/en.yaml | 10 +++++ locales/es.yaml | 10 +++++ locales/fr.yaml | 10 +++++ locales/ga.yaml | 10 +++++ locales/hu.yaml | 10 +++++ locales/it.yaml | 10 +++++ locales/ja.yaml | 10 +++++ locales/ko.yaml | 10 +++++ locales/pt.yaml | 10 +++++ locales/ru.yaml | 10 +++++ locales/tr.yaml | 10 +++++ locales/uk.yaml | 10 +++++ locales/zh-hant.yaml | 10 +++++ locales/zh.yaml | 10 +++++ tests/gateway/test_usage_command.py | 62 +++++++++++++++++++++++++++++ 18 files changed, 272 insertions(+) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 5b63e591a2c..ccb09811e11 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3455,6 +3455,47 @@ class GatewaySlashCommandsMixin: lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.") return "\n".join(lines) + def _context_breakdown_lines(self, agent, source) -> list[str]: + """Render the per-category context breakdown for /usage. + + Estimated (chars/4) — same engine the desktop popover uses. Returns an + empty list and never raises on failure so /usage stays robust. + """ + try: + from agent.context_breakdown import compute_session_context_breakdown + + history: list[dict] = [] + try: + entry = self.session_store.get_or_create_session(source) + history = self.session_store.load_transcript(entry.session_id) or [] + except Exception: + history = [] + + payload = compute_session_context_breakdown(agent, history) + categories = payload.get("categories") or [] + if not categories: + return [] + + total = payload.get("estimated_total") or 0 + out = [t("gateway.usage.breakdown_header")] + for cat in categories: + tokens = int(cat.get("tokens") or 0) + if tokens <= 0: + continue + cat_id = str(cat.get("id") or "") + label = t(f"gateway.usage.breakdown_cat_{cat_id}") + # Missing key → t() echoes the key back; fall back to the + # English label the engine already provides. + if label.endswith(f"breakdown_cat_{cat_id}"): + label = str(cat.get("label") or cat_id) + pct = round(tokens / total * 100) if total else 0 + out.append( + t("gateway.usage.breakdown_line", label=label, count=f"{tokens:,}", pct=pct) + ) + return out if len(out) > 1 else [] + except Exception: + return [] + async def _handle_usage_command(self, event: MessageEvent) -> str: """Handle /usage command -- show token usage for the current session. @@ -3554,6 +3595,15 @@ class GatewaySlashCommandsMixin: if ctx.compression_count: lines.append(t("gateway.usage.label_compressions", count=ctx.compression_count)) + # Per-category context breakdown (estimated — chars/4 heuristic). + # Same engine the desktop popover uses (PR #54907). The system + # prompt / tools / skills / memory slices read off the live agent; + # the conversation slice is estimated from the session transcript. + breakdown_lines = self._context_breakdown_lines(agent, source) + if breakdown_lines: + lines.append("") + lines.extend(breakdown_lines) + if account_lines: lines.append("") lines.extend(account_lines) diff --git a/locales/af.yaml b/locales/af.yaml index ece46799d98..e669e6c31cc 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Koste: ingesluit" label_context: "Konteks: {used} / {total} ({pct}%)" label_compressions: "Saamperserings: {count}" + breakdown_header: "🧩 **Konteksverdeling** _(geskat)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "Stelselopdrag" + breakdown_cat_tool_definitions: "Nutsmiddeldefinisies" + breakdown_cat_rules: "Reëls" + breakdown_cat_skills: "Vaardighede" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Subagent-definisies" + breakdown_cat_memory: "Geheue" + breakdown_cat_conversation: "Gesprek" header_session_info: "📊 **Sessie-inligting**" label_messages: "Boodskappe: {count}" label_estimated_context: "Geskatte konteks: ~{count} tokens" diff --git a/locales/de.yaml b/locales/de.yaml index 154268e60dd..8d469557b09 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Kosten: inbegriffen" label_context: "Kontext: {used} / {total} ({pct}%)" label_compressions: "Kompressionen: {count}" + breakdown_header: "🧩 **Kontextaufschlüsselung** _(geschätzt)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "System-Prompt" + breakdown_cat_tool_definitions: "Tool-Definitionen" + breakdown_cat_rules: "Regeln" + breakdown_cat_skills: "Fähigkeiten" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Subagent-Definitionen" + breakdown_cat_memory: "Speicher" + breakdown_cat_conversation: "Konversation" header_session_info: "📊 **Sitzungsinfo**" label_messages: "Nachrichten: {count}" label_estimated_context: "Geschätzter Kontext: ~{count} Tokens" diff --git a/locales/en.yaml b/locales/en.yaml index a8a132622f4..0fe93aba33e 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -344,6 +344,16 @@ gateway: label_cost_included: "Cost: included" label_context: "Context: {used} / {total} ({pct}%)" label_compressions: "Compressions: {count}" + breakdown_header: "🧩 **Context breakdown** _(estimated)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "System prompt" + breakdown_cat_tool_definitions: "Tool definitions" + breakdown_cat_rules: "Rules" + breakdown_cat_skills: "Skills" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Subagent definitions" + breakdown_cat_memory: "Memory" + breakdown_cat_conversation: "Conversation" header_session_info: "📊 **Session Info**" label_messages: "Messages: {count}" label_estimated_context: "Estimated context: ~{count} tokens" diff --git a/locales/es.yaml b/locales/es.yaml index 128f371fb1b..00403e98e4d 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -329,6 +329,16 @@ gateway: label_cost_included: "Costo: incluido" label_context: "Contexto: {used} / {total} ({pct}%)" label_compressions: "Compresiones: {count}" + breakdown_header: "🧩 **Desglose del contexto** _(estimado)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "Prompt del sistema" + breakdown_cat_tool_definitions: "Definiciones de herramientas" + breakdown_cat_rules: "Reglas" + breakdown_cat_skills: "Habilidades" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Definiciones de subagentes" + breakdown_cat_memory: "Memoria" + breakdown_cat_conversation: "Conversación" header_session_info: "📊 **Información de la sesión**" label_messages: "Mensajes: {count}" label_estimated_context: "Contexto estimado: ~{count} tokens" diff --git a/locales/fr.yaml b/locales/fr.yaml index 692c71221fb..f95ca39da6e 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Coût : inclus" label_context: "Contexte : {used} / {total} ({pct}%)" label_compressions: "Compressions : {count}" + breakdown_header: "🧩 **Répartition du contexte** _(estimée)_" + breakdown_line: "• {label} : ~{count} ({pct} %)" + breakdown_cat_system_prompt: "Invite système" + breakdown_cat_tool_definitions: "Définitions des outils" + breakdown_cat_rules: "Règles" + breakdown_cat_skills: "Compétences" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Définitions des sous-agents" + breakdown_cat_memory: "Mémoire" + breakdown_cat_conversation: "Conversation" header_session_info: "📊 **Infos de session**" label_messages: "Messages : {count}" label_estimated_context: "Contexte estimé : ~{count} jetons" diff --git a/locales/ga.yaml b/locales/ga.yaml index cdacf94312a..707fba0e2a6 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -336,6 +336,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Costas: san áireamh" label_context: "Comhthéacs: {used} / {total} ({pct}%)" label_compressions: "Dlúthuithe: {count}" + breakdown_header: "🧩 **Miondealú comhthéacs** _(measta)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "Leid an chórais" + breakdown_cat_tool_definitions: "Sainmhínithe uirlisí" + breakdown_cat_rules: "Rialacha" + breakdown_cat_skills: "Scileanna" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Sainmhínithe fo-ghníomhairí" + breakdown_cat_memory: "Cuimhne" + breakdown_cat_conversation: "Comhrá" header_session_info: "📊 **Eolas Seisiúin**" label_messages: "Teachtaireachtaí: {count}" label_estimated_context: "Comhthéacs measta: ~{count} comhartha" diff --git a/locales/hu.yaml b/locales/hu.yaml index fec8aac766f..70501c1947e 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Költség: belefoglalva" label_context: "Kontextus: {used} / {total} ({pct}%)" label_compressions: "Tömörítések: {count}" + breakdown_header: "🧩 **Kontextus-bontás** _(becsült)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "Rendszerüzenet" + breakdown_cat_tool_definitions: "Eszközdefiníciók" + breakdown_cat_rules: "Szabályok" + breakdown_cat_skills: "Készségek" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Alügynök-definíciók" + breakdown_cat_memory: "Memória" + breakdown_cat_conversation: "Beszélgetés" header_session_info: "📊 **Munkamenet-információ**" label_messages: "Üzenetek: {count}" label_estimated_context: "Becsült kontextus: ~{count} token" diff --git a/locales/it.yaml b/locales/it.yaml index 5e17a835f48..cf4e8044f0d 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Costo: incluso" label_context: "Contesto: {used} / {total} ({pct}%)" label_compressions: "Compressioni: {count}" + breakdown_header: "🧩 **Suddivisione del contesto** _(stimata)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "Prompt di sistema" + breakdown_cat_tool_definitions: "Definizioni degli strumenti" + breakdown_cat_rules: "Regole" + breakdown_cat_skills: "Competenze" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Definizioni dei subagenti" + breakdown_cat_memory: "Memoria" + breakdown_cat_conversation: "Conversazione" header_session_info: "📊 **Info sessione**" label_messages: "Messaggi: {count}" label_estimated_context: "Contesto stimato: ~{count} token" diff --git a/locales/ja.yaml b/locales/ja.yaml index b6d9a957588..6fa88062c12 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "コスト: 含まれています" label_context: "コンテキスト: {used} / {total} ({pct}%)" label_compressions: "圧縮回数: {count}" + breakdown_header: "🧩 **コンテキスト内訳** _(推定)_" + breakdown_line: "• {label}: 約{count} ({pct}%)" + breakdown_cat_system_prompt: "システムプロンプト" + breakdown_cat_tool_definitions: "ツール定義" + breakdown_cat_rules: "ルール" + breakdown_cat_skills: "スキル" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "サブエージェント定義" + breakdown_cat_memory: "メモリ" + breakdown_cat_conversation: "会話" header_session_info: "📊 **セッション情報**" label_messages: "メッセージ数: {count}" label_estimated_context: "推定コンテキスト: ~{count} トークン" diff --git a/locales/ko.yaml b/locales/ko.yaml index f07d22837ad..bc5ae7f9c06 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "비용: 포함됨" label_context: "컨텍스트: {used} / {total} ({pct}%)" label_compressions: "압축: {count}" + breakdown_header: "🧩 **컨텍스트 분석** _(추정치)_" + breakdown_line: "• {label}: 약 {count} ({pct}%)" + breakdown_cat_system_prompt: "시스템 프롬프트" + breakdown_cat_tool_definitions: "도구 정의" + breakdown_cat_rules: "규칙" + breakdown_cat_skills: "스킬" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "서브에이전트 정의" + breakdown_cat_memory: "메모리" + breakdown_cat_conversation: "대화" header_session_info: "📊 **세션 정보**" label_messages: "메시지: {count}" label_estimated_context: "예상 컨텍스트: 약 {count} 토큰" diff --git a/locales/pt.yaml b/locales/pt.yaml index 5be22d90b1e..d1a998fbe52 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Custo: incluído" label_context: "Contexto: {used} / {total} ({pct}%)" label_compressions: "Compressões: {count}" + breakdown_header: "🧩 **Detalhamento do contexto** _(estimado)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "Prompt do sistema" + breakdown_cat_tool_definitions: "Definições de ferramentas" + breakdown_cat_rules: "Regras" + breakdown_cat_skills: "Habilidades" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Definições de subagentes" + breakdown_cat_memory: "Memória" + breakdown_cat_conversation: "Conversa" header_session_info: "📊 **Informações da sessão**" label_messages: "Mensagens: {count}" label_estimated_context: "Contexto estimado: ~{count} tokens" diff --git a/locales/ru.yaml b/locales/ru.yaml index ca5617a4cc4..3d985394bfb 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Стоимость: включено" label_context: "Контекст: {used} / {total} ({pct}%)" label_compressions: "Сжатий: {count}" + breakdown_header: "🧩 **Разбивка контекста** _(оценка)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "Системный промпт" + breakdown_cat_tool_definitions: "Определения инструментов" + breakdown_cat_rules: "Правила" + breakdown_cat_skills: "Навыки" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Определения субагентов" + breakdown_cat_memory: "Память" + breakdown_cat_conversation: "Разговор" header_session_info: "📊 **Информация о сеансе**" label_messages: "Сообщений: {count}" label_estimated_context: "Ориентировочный контекст: ~{count} токенов" diff --git a/locales/tr.yaml b/locales/tr.yaml index 29bacf36ee4..609c35f290b 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Maliyet: dahil" label_context: "Bağlam: {used} / {total} ({pct}%)" label_compressions: "Sıkıştırmalar: {count}" + breakdown_header: "🧩 **Bağlam dökümü** _(tahmini)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "Sistem istemi" + breakdown_cat_tool_definitions: "Araç tanımları" + breakdown_cat_rules: "Kurallar" + breakdown_cat_skills: "Beceriler" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Alt aracı tanımları" + breakdown_cat_memory: "Bellek" + breakdown_cat_conversation: "Konuşma" header_session_info: "📊 **Oturum Bilgisi**" label_messages: "Mesajlar: {count}" label_estimated_context: "Tahmini bağlam: ~{count} token" diff --git a/locales/uk.yaml b/locales/uk.yaml index 1e20ec7b6ca..6d6c28fe717 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "Вартість: включено" label_context: "Контекст: {used} / {total} ({pct}%)" label_compressions: "Стиснень: {count}" + breakdown_header: "🧩 **Розбивка контексту** _(оцінка)_" + breakdown_line: "• {label}: ~{count} ({pct}%)" + breakdown_cat_system_prompt: "Системний промпт" + breakdown_cat_tool_definitions: "Визначення інструментів" + breakdown_cat_rules: "Правила" + breakdown_cat_skills: "Навички" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "Визначення субагентів" + breakdown_cat_memory: "Пам'ять" + breakdown_cat_conversation: "Розмова" header_session_info: "📊 **Інформація про сеанс**" label_messages: "Повідомлень: {count}" label_estimated_context: "Орієнтовний контекст: ~{count} токенів" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index a7aae1adb8a..dc1bd6778be 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "費用:已包含" label_context: "上下文:{used} / {total}({pct}%)" label_compressions: "壓縮次數:{count}" + breakdown_header: "🧩 **上下文明細** _(估算)_" + breakdown_line: "• {label}:約 {count} ({pct}%)" + breakdown_cat_system_prompt: "系統提示詞" + breakdown_cat_tool_definitions: "工具定義" + breakdown_cat_rules: "規則" + breakdown_cat_skills: "技能" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "子代理定義" + breakdown_cat_memory: "記憶" + breakdown_cat_conversation: "對話" header_session_info: "📊 **工作階段資訊**" label_messages: "訊息數:{count}" label_estimated_context: "預估上下文:~{count} 個 token" diff --git a/locales/zh.yaml b/locales/zh.yaml index 7f9789ee3be..888f432f41d 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -332,6 +332,16 @@ Future messages in this room will use that transcript until `/reset` or another label_cost_included: "费用:已包含" label_context: "上下文:{used} / {total}({pct}%)" label_compressions: "压缩次数:{count}" + breakdown_header: "🧩 **上下文明细** _(估算)_" + breakdown_line: "• {label}:约 {count} ({pct}%)" + breakdown_cat_system_prompt: "系统提示词" + breakdown_cat_tool_definitions: "工具定义" + breakdown_cat_rules: "规则" + breakdown_cat_skills: "技能" + breakdown_cat_mcp: "MCP" + breakdown_cat_subagent_definitions: "子代理定义" + breakdown_cat_memory: "记忆" + breakdown_cat_conversation: "对话" header_session_info: "📊 **会话信息**" label_messages: "消息数:{count}" label_estimated_context: "估计上下文:~{count} 个令牌" diff --git a/tests/gateway/test_usage_command.py b/tests/gateway/test_usage_command.py index 40cbe3192ff..e2a207cfbc7 100644 --- a/tests/gateway/test_usage_command.py +++ b/tests/gateway/test_usage_command.py @@ -243,3 +243,65 @@ class TestUsageAccountSection: assert account_call["kwargs"]["base_url"] == "https://chatgpt.com/backend-api/codex" assert "📊 **Session Info**" in result assert "📈 **Account limits**" in result + + +class TestUsageContextBreakdown: + """The /usage output includes the per-category context breakdown.""" + + @pytest.mark.asyncio + async def test_breakdown_lines_rendered_for_live_agent(self): + agent = _make_mock_agent() + runner = _make_runner(SK, cached_agent=agent) + session_entry = MagicMock() + session_entry.session_id = "sess-bd" + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.load_transcript.return_value = [ + {"role": "user", "content": "hi"}, + ] + event = MagicMock() + + fake_payload = { + "categories": [ + {"id": "system_prompt", "label": "System prompt", "tokens": 4000, "color": "x"}, + {"id": "tool_definitions", "label": "Tool definitions", "tokens": 6000, "color": "x"}, + {"id": "conversation", "label": "Conversation", "tokens": 0, "color": "x"}, + ], + "estimated_total": 10000, + "context_max": 200000, + "context_percent": 5, + "context_used": 30000, + "model": "anthropic/claude-sonnet-4.6", + } + + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \ + patch("agent.context_breakdown.compute_session_context_breakdown", return_value=fake_payload): + result = await runner._handle_usage_command(event) + + # Localized header + at least the two non-zero category labels appear, + # each labelled as a percentage of the estimated total. + assert "Context breakdown" in result + assert "System prompt" in result + assert "Tool definitions" in result + assert "4,000" in result # system prompt tokens, comma-formatted + assert "40%" in result # 4000 / 10000 + assert "60%" in result # 6000 / 10000 + # Zero-token category is dropped, not rendered. + assert "Conversation" not in result + + @pytest.mark.asyncio + async def test_breakdown_failure_is_non_fatal(self): + """A breakdown engine error must not break the rest of /usage.""" + agent = _make_mock_agent() + runner = _make_runner(SK, cached_agent=agent) + runner.session_store.get_or_create_session.side_effect = RuntimeError("boom") + event = MagicMock() + + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \ + patch("agent.context_breakdown.compute_session_context_breakdown", + side_effect=RuntimeError("engine down")): + result = await runner._handle_usage_command(event) + + # Core usage lines still render; no breakdown header. + assert "📊 **Session Token Usage**" in result + assert "50,000" in result # total tokens + assert "Context breakdown" not in result