From 4fe2fecf54e376641b7bc157cd4b4613debfa08f Mon Sep 17 00:00:00 2001 From: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:47:05 -0500 Subject: [PATCH] feat(gateway): add /context command for a detailed context-window view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated /context (alias /ctx) gateway slash command that gives a full context-window view with: - Usage gauge: visual bar + fraction + percentage + headroom - Auto-compression threshold and how far away it is - Compression count and how much the last one freed - Cumulative session throughput (explicitly labelled as throughput, NOT context size — each call re-sends the window) - Cascading fallback: running agent → cached agent → SessionStore metadata → rough transcript estimate Not included (per current-main design): - Cache reporting removed: commit 446b8e239 intentionally removed cache reporting from user-facing surfaces because providers that omit cached-token details produce misleading values - Sync DB calls replaced with async_session_store (current main requires AsyncSessionStore with await) Also rewords the /status tokens line from 'Cumulative API tokens (re-sent each call)' to 'Lifetime tokens billed: ... (not your current context size; use /context)' to reduce the recurring confusion that the cumulative figure is the current context window. Fixes salvation of PR #52184 (salvage commit replaces a 12K-commit-behind fork branch with a fresh implementation against current main, incorporating reviewer feedback from @whoislikemiha and the hermes-sweeper). --- gateway/run.py | 6 + gateway/slash_commands.py | 187 +++++++++++++++++++++++++++ hermes_cli/commands.py | 2 + locales/af.yaml | 20 ++- locales/de.yaml | 20 ++- locales/en.yaml | 21 ++- locales/es.yaml | 20 ++- locales/fr.yaml | 20 ++- locales/ga.yaml | 20 ++- locales/hu.yaml | 20 ++- locales/it.yaml | 20 ++- locales/ja.yaml | 20 ++- locales/ko.yaml | 20 ++- locales/pt.yaml | 20 ++- locales/ru.yaml | 20 ++- locales/tr.yaml | 20 ++- locales/uk.yaml | 20 ++- locales/zh-hant.yaml | 20 ++- locales/zh.yaml | 20 ++- tests/gateway/test_status_command.py | 160 ++++++++++++++++++++++- 20 files changed, 654 insertions(+), 22 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index dbf5eee34d96..a8dcca69554d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -11168,6 +11168,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if event.get_command() == "status": return await self._handle_status_command(event) + if event.get_command() in {"context", "ctx"}: + return await self._handle_context_command(event) + # Resolve the command once for all early-intercept checks below. from hermes_cli.commands import ( ACTIVE_SESSION_BYPASS_COMMANDS as _DEDICATED_HANDLERS, @@ -11743,6 +11746,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew return format_status_text() + if canonical == "context": + return await self._handle_context_command(event) + if canonical == "agents": return await self._handle_agents_command(event) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 7c7035abf2d1..047e1003563e 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -56,6 +56,19 @@ logger = logging.getLogger("gateway.run") _RESET_CLEANUP_TIMEOUT_S = 30.0 +def _clean_str(value: Any) -> str: + """Strip and return a non-empty string value, or empty string.""" + return value.strip() if isinstance(value, str) and value.strip() else "" + + +def _int_value(value: Any) -> int: + """Safely coerce to int.""" + try: + return int(value) + except (TypeError, ValueError): + return 0 + + def _model_switch_skew_guard() -> Optional[str]: """Refuse a model switch when the gateway is running stale code. @@ -701,6 +714,180 @@ class GatewaySlashCommandsMixin: digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12] return f"sha256:{digest}" + async def _handle_context_command(self, event: MessageEvent) -> str: + """Handle /context — the dedicated context-window view. + + /status shows a one-line ``used / total`` summary; this command is the + deep view: a usage gauge, auto-compression threshold and headroom, + compression count and last savings, and cumulative throughput — the last + clearly labelled as throughput, NOT context size. + + Resolves from the running agent (mid-turn), then the cached agent + (between turns), then the SessionStore/SessionDB metadata for a gauge + even when no agent is resident. Falls back to a transcript estimate only + as a last resort. + """ + from gateway.run import _AGENT_PENDING_SENTINEL + + source = event.source + session_key = self._session_key_for_source(source) + session_entry = await self.async_session_store.get_or_create_session(source) + + # Try running agent first (mid-turn), then cached agent (between turns). + agent = self._running_agents.get(session_key) + if not agent or agent is _AGENT_PENDING_SENTINEL: + cache_lock = getattr(self, "_agent_cache_lock", None) + cache = getattr(self, "_agent_cache", None) + if cache_lock is not None and cache is not None: + try: + with cache_lock: + cached = cache.get(session_key) + if cached: + agent = cached[0] + except Exception: + agent = None + has_agent = bool(agent) and agent is not _AGENT_PENDING_SENTINEL + + ctx = getattr(agent, "context_compressor", None) if has_agent else None + + # Resolve current-context size + window with cascading fallbacks. + # used : compressor.last_prompt_tokens → SessionStore.last_prompt_tokens + # model : agent.model → SessionDB row model + # window: compressor.context_length → get_model_context_length(model) + used = 0 + context_length = 0 + if ctx is not None: + used = getattr(ctx, "last_prompt_tokens", 0) or 0 + context_length = getattr(ctx, "context_length", 0) or 0 + + model_name = _clean_str(getattr(agent, "model", "")) if has_agent else "" + + if not used: + used = _int_value(getattr(session_entry, "last_prompt_tokens", 0)) + + if not model_name and self._session_db: + try: + row = await self._session_db.get_session(session_entry.session_id) or {} + if isinstance(row, dict): + model_name = _clean_str(row.get("model", "")) + except Exception: + model_name = "" + + if not context_length and model_name: + try: + from agent.model_metadata import get_model_context_length + + context_length = _int_value( + await asyncio.to_thread(get_model_context_length, model_name) + ) + except Exception: + context_length = 0 + + # Gauge path: real current-context figure + if used > 0 and context_length > 0: + pct = min(100.0, used / context_length * 100) + headroom = max(0, context_length - used) + BAR_WIDTH = 24 + filled = int(round(pct / 100 * BAR_WIDTH)) + bar = "█" * max(0, filled) + "░" * max(0, BAR_WIDTH - filled) + + lines = [ + t("gateway.context.header"), + "", + t("gateway.context.model", model=model_name or "?"), + t("gateway.context.window", total=f"{context_length:,}"), + t( + "gateway.context.in_use", + used=f"{used:,}", + total=f"{context_length:,}", + pct=f"{pct:.0f}", + ), + t("gateway.context.bar", bar=bar), + t("gateway.context.headroom", headroom=f"{headroom:,}"), + ] + + # Full view — compression / throughput need the live agent. + if ctx is not None: + threshold = getattr(ctx, "threshold_tokens", 0) or 0 + threshold_pct = (getattr(ctx, "threshold_percent", 0) or 0) * 100 + lines.append("") + if threshold > 0: + if used >= threshold: + lines.append( + t( + "gateway.context.over_threshold", + threshold=f"{threshold:,}", + threshold_pct=f"{threshold_pct:.0f}", + ) + ) + else: + lines.append( + t( + "gateway.context.threshold", + threshold=f"{threshold:,}", + threshold_pct=f"{threshold_pct:.0f}", + to_go=f"{threshold - used:,}", + ) + ) + compressions = getattr(ctx, "compression_count", 0) or 0 + lines.append(t("gateway.context.compressions", count=compressions)) + if compressions: + savings = getattr(ctx, "_last_compression_savings_pct", None) + if savings is not None: + lines.append( + t("gateway.context.last_savings", savings=f"{savings:.0f}") + ) + + api_calls = getattr(agent, "session_api_calls", 0) or 0 + input_tokens = getattr(agent, "session_input_tokens", 0) or 0 + output_tokens = getattr(agent, "session_output_tokens", 0) or 0 + reasoning_tokens = getattr(agent, "session_reasoning_tokens", 0) or 0 + total_tokens = getattr(agent, "session_total_tokens", 0) or 0 + lines.append("") + lines.append( + t("gateway.context.totals_header", calls=api_calls) + ) + lines.append( + t( + "gateway.context.totals_line", + input=f"{input_tokens:,}", + output=f"{output_tokens:,}", + reasoning=f"{reasoning_tokens:,}", + ) + ) + lines.append(t("gateway.context.total_billed", total=f"{total_tokens:,}")) + lines.append(t("gateway.context.throughput_note")) + else: + lines.append("") + lines.append(t("gateway.context.detail_after_first")) + + return "\n".join(lines) + + # Last resort: rough estimate from transcript + history = await self.async_session_store.load_transcript(session_entry.session_id) + if history: + from agent.model_metadata import estimate_messages_tokens_rough + + msgs = [ + m + for m in history + if m.get("role") in {"user", "assistant"} and m.get("content") + ] + approx = estimate_messages_tokens_rough(msgs) + return "\n".join( + [ + t("gateway.context.header"), + "", + t( + "gateway.context.estimated", + count=f"{approx:,}", + messages=len(msgs), + ), + t("gateway.context.detail_after_first"), + ] + ) + return t("gateway.context.no_data") + def _gateway_session_origin_for_id(self, session_id: str) -> Optional[SessionSource]: """Best-effort origin lookup for gateway session IDs.""" lookup = getattr(type(self.session_store), "lookup_by_session_id", None) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 87c20087013a..c1c6f37b3893 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -120,6 +120,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("status", "Show session, model, token, and context info", "Session"), CommandDef("egress", "Show Docker egress proxy status", "Session", args_hint="[status]", subcommands=("status",)), + CommandDef("context", "Show detailed context window view with usage gauge, compression stats, and throughput", "Session", + aliases=("ctx",)), CommandDef("whoami", "Show your slash command access (admin / user)", "Info"), CommandDef("profile", "Show active profile name and home directory", "Info"), CommandDef("sethome", "Set this chat as the home channel", "Session", diff --git a/locales/af.yaml b/locales/af.yaml index 4069477de2aa..525d30db5b74 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Kon nie tuiste-kanaal stoor nie: {error}" success: "✅ Tuiste-kanaal gestel op **{name}** (ID: {chat_id}).\nKron-take en kruisplatform-boodskappe sal hier afgelewer word." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Hermes Gateway Status**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Kumulatiewe API-tokens (elke oproep weer gestuur):** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Agent loop:** {state}" state_yes: "Ja ⚡" state_no: "Nee" diff --git a/locales/de.yaml b/locales/de.yaml index 06a6f1931cbb..693b72d5c109 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Home-Kanal konnte nicht gespeichert werden: {error}" success: "✅ Home-Kanal auf **{name}** (ID: {chat_id}) gesetzt.\nCron-Jobs und plattformübergreifende Nachrichten werden hierher geliefert." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Hermes-Gateway-Status**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Modell:** `{model}` ({provider})" context: "**Kontext:** {used} / {total} ({pct}%)" context_used: "**Kontext:** ~{used} Tokens" - tokens: "**Kumulierte API-Tokens (bei jedem Aufruf erneut gesendet):** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Agent läuft:** {state}" state_yes: "Ja ⚡" state_no: "Nein" diff --git a/locales/en.yaml b/locales/en.yaml index d027257c0bdb..0ab28192c341 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -295,6 +295,25 @@ gateway: save_failed: "Failed to save home channel: {error}" success: "✅ Home channel set to **{name}** (ID: {chat_id}).\nCron jobs and cross-platform messages will be delivered here." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." + status: header: "📊 **Hermes Gateway Status**" matrix_scope_header: "**Matrix scope:**" @@ -311,7 +330,7 @@ gateway: model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Cumulative API tokens (re-sent each call):** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Agent Running:** {state}" state_yes: "Yes ⚡" state_no: "No" diff --git a/locales/es.yaml b/locales/es.yaml index 074cfa9f278c..f69e94cdfaca 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -279,6 +279,24 @@ gateway: set_home: save_failed: "No se pudo guardar el canal principal: {error}" success: "✅ Canal principal establecido en **{name}** (ID: {chat_id}).\nLas tareas cron y los mensajes entre plataformas se entregarán aquí." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Estado de Hermes Gateway**" @@ -296,7 +314,7 @@ gateway: model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Tokens de API acumulados (reenviados en cada llamada):** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Agente activo:** {state}" state_yes: "Sí ⚡" state_no: "No" diff --git a/locales/fr.yaml b/locales/fr.yaml index 166f1fba8741..a4248af6f7ce 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Impossible d'enregistrer le canal principal : {error}" success: "✅ Canal principal défini sur **{name}** (ID : {chat_id}).\nLes tâches cron et les messages multi-plateformes seront livrés ici." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **État de Hermes Gateway**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Jetons :** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Agent en cours :** {state}" state_yes: "Oui ⚡" state_no: "Non" diff --git a/locales/ga.yaml b/locales/ga.yaml index a1522e4215c0..ccc0a6287544 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -286,6 +286,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Theip ar shábháil chainéil bhaile: {error}" success: "✅ Cainéal baile socraithe go **{name}** (ID: {chat_id}).\nSeachadfar tascanna cron agus teachtaireachtaí trasardáin anseo." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Stádas Hermes Gateway**" @@ -303,7 +321,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Comharthaí:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Gníomhaire ag rith:** {state}" state_yes: "Tá ⚡" state_no: "Níl" diff --git a/locales/hu.yaml b/locales/hu.yaml index 2fec63848446..8bbcafe7c2b3 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Nem sikerült menteni a kezdőcsatornát: {error}" success: "✅ Kezdőcsatorna beállítva: **{name}** (ID: {chat_id}).\nA cron-feladatok és a platformok közötti üzenetek ide érkeznek." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Hermes Gateway állapot**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Tokenek:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Ügynök fut:** {state}" state_yes: "Igen ⚡" state_no: "Nem" diff --git a/locales/it.yaml b/locales/it.yaml index eafcbb1e29d8..2b0a0677accb 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Salvataggio del canale home non riuscito: {error}" success: "✅ Canale home impostato su **{name}** (ID: {chat_id}).\nI cron job e i messaggi cross-platform verranno consegnati qui." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Stato del Gateway Hermes**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Token:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Agente in esecuzione:** {state}" state_yes: "Sì ⚡" state_no: "No" diff --git a/locales/ja.yaml b/locales/ja.yaml index ea78e7aa6f7b..0b40dc1f614a 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "ホームチャンネルを保存できませんでした: {error}" success: "✅ ホームチャンネルを **{name}** (ID: {chat_id}) に設定しました。\nCron ジョブとプラットフォーム間メッセージはここに配信されます。" + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Hermes ゲートウェイ状態**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**トークン:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**エージェント実行中:** {state}" state_yes: "はい ⚡" state_no: "いいえ" diff --git a/locales/ko.yaml b/locales/ko.yaml index 2411c9f1d179..db38ba98e8d6 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "홈 채널 저장에 실패했습니다: {error}" success: "✅ 홈 채널이 **{name}**(ID: {chat_id})(으)로 설정되었습니다.\n크론 작업과 플랫폼 간 메시지가 여기로 전달됩니다." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Hermes 게이트웨이 상태**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**토큰:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**에이전트 실행 중:** {state}" state_yes: "예 ⚡" state_no: "아니오" diff --git a/locales/pt.yaml b/locales/pt.yaml index ada4682708fb..0f32bf53df26 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Falha ao guardar o canal principal: {error}" success: "✅ Canal principal definido como **{name}** (ID: {chat_id}).\nAs tarefas cron e mensagens entre plataformas serão entregues aqui." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Estado do Hermes Gateway**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Tokens de API cumulativos (reenviados a cada chamada):** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Agente em execução:** {state}" state_yes: "Sim ⚡" state_no: "Não" diff --git a/locales/ru.yaml b/locales/ru.yaml index c83483eae799..51347514d052 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Не удалось сохранить главный канал: {error}" success: "✅ Главный канал установлен на **{name}** (ID: {chat_id}).\nCron-задачи и межплатформенные сообщения будут доставляться сюда." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Состояние Hermes Gateway**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Токены:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Агент активен:** {state}" state_yes: "Да ⚡" state_no: "Нет" diff --git a/locales/tr.yaml b/locales/tr.yaml index 33d2c9383f1a..d1a9808f79c3 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Ana kanal kaydedilemedi: {error}" success: "✅ Ana kanal **{name}** (ID: {chat_id}) olarak ayarlandı.\nCron işleri ve platformlar arası mesajlar buraya iletilecek." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Hermes Gateway Durumu**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Token:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Aracı çalışıyor:** {state}" state_yes: "Evet ⚡" state_no: "Hayır" diff --git a/locales/uk.yaml b/locales/uk.yaml index f9e5cfd7d450..0c67278b775d 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "Не вдалося зберегти головний канал: {error}" success: "✅ Головний канал встановлено на **{name}** (ID: {chat_id}).\nCron-завдання та міжплатформні повідомлення доставлятимуться сюди." + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Стан Hermes Gateway**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Токени:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**Агент активний:** {state}" state_yes: "Так ⚡" state_no: "Ні" diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index 8df92cc23b22..e5ef7b82ddbb 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "無法儲存主頻道:{error}" success: "✅ 主頻道已設定為 **{name}**(ID:{chat_id})。\n排程任務和跨平台訊息將傳送至此處。" + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Hermes 閘道狀態**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Token 數:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**代理執行中:** {state}" state_yes: "是 ⚡" state_no: "否" diff --git a/locales/zh.yaml b/locales/zh.yaml index d13d8cdfa185..f16f330028ab 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -282,6 +282,24 @@ Future messages in this room will use that transcript until `/reset` or another set_home: save_failed: "无法保存主频道:{error}" success: "✅ 主频道已设置为 **{name}**(ID:{chat_id})。\n定时任务和跨平台消息将发送到此处。" + context: + header: "🧠 **Context Window**" + model: "Model: `{model}`" + window: "Window: {total} tokens" + in_use: "In use: {used} / {total} ({pct}%)" + bar: "{bar}" + headroom: "Headroom to limit: {headroom} tokens" + threshold: "Auto-compresses at: {threshold} ({threshold_pct}%) — {to_go} to go" + over_threshold: "⚠️ **Over auto-compression threshold ({threshold}, {threshold_pct}%)**" + compressions: "Compressions this session: {count}" + last_savings: "Last compression freed: {savings}% of context" + totals_header: "Session totals (cumulative across {calls} API calls)" + totals_line: "Input {input} · Output {output} · Reasoning {reasoning}" + total_billed: "Total billed: {total}" + throughput_note: "_Throughput, not context size — each call re-sends the window above._" + estimated: "Estimated context: ~{count} tokens across {messages} messages" + detail_after_first: "_(Full compression and throughput stats available after the first agent response)_" + no_data: "No context data available yet. Send a message to start a session." status: header: "📊 **Hermes 网关状态**" @@ -299,7 +317,7 @@ Future messages in this room will use that transcript until `/reset` or another model_provider: "**Model:** `{model}` ({provider})" context: "**Context:** {used} / {total} ({pct}%)" context_used: "**Context:** ~{used} tokens" - tokens: "**Token 数:** {tokens}" + tokens: "**Lifetime tokens billed:** {tokens} _(not your current context size; use `/context`)_" agent_running: "**代理运行中:** {state}" state_yes: "是 ⚡" state_no: "否" diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index 2da89d16dc8f..a4e864985a18 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -100,7 +100,7 @@ async def test_status_command_reports_running_agent_without_interrupt(monkeypatc result = await runner._handle_message(_make_event("/status")) assert "**Session ID:** `sess-1`" in result - assert "**Cumulative API tokens (re-sent each call):** 321" in result + assert "**Lifetime tokens billed:** 321" in result assert "**Agent Running:** Yes ⚡" in result assert "**Title:**" not in result running_agent.interrupt.assert_not_called() @@ -153,7 +153,7 @@ async def test_status_command_reads_token_totals_from_session_db(): result = await runner._handle_message(_make_event("/status")) # 1000 + 250 + 500 + 100 + 50 = 1,900 - assert "**Cumulative API tokens (re-sent each call):** 1,900" in result + assert "**Lifetime tokens billed:** 1,900" in result @pytest.mark.asyncio @@ -174,7 +174,7 @@ async def test_status_command_tokens_zero_when_session_db_row_missing(): result = await runner._handle_message(_make_event("/status")) - assert "**Cumulative API tokens (re-sent each call):** 0" in result + assert "**Lifetime tokens billed:** 0" in result @pytest.mark.asyncio @@ -212,8 +212,7 @@ async def test_status_command_includes_live_agent_model_and_context(): assert "**Model:** `openai/gpt-test` (openai)" in result assert "**Context:** 12,345 / 100,000 (12%)" in result - assert "**Cumulative API tokens (re-sent each call):** 1,250" in result - assert "1,250 (cumulative)" not in result + assert "**Lifetime tokens billed:** 1,250" in result @pytest.mark.asyncio @@ -245,7 +244,7 @@ async def test_status_command_includes_persisted_model_and_context_when_agent_no assert "**Model:** `openai/gpt-persisted` (openai-codex)" in result assert "**Context:** 24,000 / 272,000 (9%)" in result - assert "**Cumulative API tokens (re-sent each call):** 2,500" in result + assert "**Lifetime tokens billed:** 2,500" in result @pytest.mark.asyncio @@ -821,3 +820,152 @@ async def test_post_delivery_callback_generation_snapshot_happens_after_bind(): assert fired == [] assert session_key in adapter._post_delivery_callbacks assert adapter._post_delivery_callbacks[session_key][0] == 2 + + +# ── /context command tests ──────────────────────────────────────────────── + +def _stub_agent(**overrides) -> SimpleNamespace: + """Build a stub agent with the attributes _handle_context_command reads.""" + props = dict( + model="openai/gpt-test", + context_compressor=SimpleNamespace( + last_prompt_tokens=47_231, + context_length=200_000, + threshold_tokens=100_000, + threshold_percent=0.5, + compression_count=2, + _last_compression_savings_pct=63.0, + ), + session_api_calls=47, + session_input_tokens=410_000, + session_output_tokens=38_000, + session_reasoning_tokens=12_000, + session_total_tokens=3_158_641, + session_cache_read_tokens=2_900_000, + session_cache_write_tokens=48_000, + ) + props.update(overrides) + return SimpleNamespace(**props) + + +@pytest.mark.asyncio +async def test_context_command_live_agent(): + """/context with a live running agent shows the full view: gauge, + compression, and throughput — but NOT cache stats.""" + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner = _make_runner(session_entry) + session_key = session_entry.session_key + agent = _stub_agent() + runner._running_agents[session_key] = agent + + result = await runner._handle_context_command(_make_event("/context")) + + assert "🧠 **Context Window**" in result + assert "Model: `openai/gpt-test`" in result + assert "Window: 200,000 tokens" in result + assert "In use: 47,231 / 200,000 (24%)" in result + assert "Headroom to limit: 152,769 tokens" in result + # Compression section + assert "Auto-compresses at: 100,000 (50%)" in result + assert "Compressions this session: 2" in result + assert "Last compression freed: 63% of context" in result + # Throughput — NOT cache + assert "Session totals (cumulative across 47 API calls)" in result + assert "Input 410,000" in result + assert "Output 38,000" in result + assert "Reasoning 12,000" in result + assert "Total billed: 3,158,641" in result + assert "each call re-sends the window above" in result + # Cache stats must NOT appear (removed per design) + assert "Cache read" not in result + assert "Cache write" not in result + assert "Cache hit" not in result + assert "Hit rate" not in result + + +@pytest.mark.asyncio +async def test_context_command_over_threshold(): + """When used >= threshold, the over-threshold warning is shown.""" + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-2", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner = _make_runner(session_entry) + session_key = session_entry.session_key + agent = _stub_agent( + context_compressor=SimpleNamespace( + last_prompt_tokens=150_000, + context_length=200_000, + threshold_tokens=100_000, + threshold_percent=0.5, + compression_count=5, + _last_compression_savings_pct=40.0, + ) + ) + runner._running_agents[session_key] = agent + + result = await runner._handle_context_command(_make_event("/context")) + + assert "⚠️" in result + assert "Over auto-compression threshold" in result + + +@pytest.mark.asyncio +async def test_context_command_no_agent_transcript_fallback(): + """When no agent is resident and session_entry has no last_prompt_tokens, + /context falls back to a transcript estimate.""" + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-3", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + last_prompt_tokens=0, # No live context data + ) + runner = _make_runner(session_entry) + # Stub the transcript so estimate_messages_tokens_rough has something to work with + runner.session_store.load_transcript.return_value = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "What's my balance?"}, + {"role": "assistant", "content": "Your balance is $1,000."}, + ] + + result = await runner._handle_context_command(_make_event("/context")) + + assert "🧠 **Context Window**" in result + assert "Estimated context:" in result + assert "4 messages" in result + + +@pytest.mark.asyncio +async def test_context_command_no_data(): + """When there's no agent, no session data, and no transcript, + /context returns the no-data message.""" + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-4", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + last_prompt_tokens=0, + ) + runner = _make_runner(session_entry) + runner.session_store.load_transcript.return_value = [] + + result = await runner._handle_context_command(_make_event("/context")) + + assert "No context data available yet" in result