diff --git a/kora_cli/web_server.py b/kora_cli/web_server.py index c3fbde742540..b1df2a715d9a 100644 --- a/kora_cli/web_server.py +++ b/kora_cli/web_server.py @@ -5309,6 +5309,156 @@ async def list_recent_email(): } +# --------------------------------------------------------------------------- +# Kora reasoning activity lens (KR-REASONING-PANEL) +# --------------------------------------------------------------------------- +# +# Operator-facing view of Kora's recent ReasoningEngine calls. +# Pairs with CC#3's KR-FEAT-AI-RESPONSE-LOOP ST2 (in flight) — +# ST2 extends the slack_dm_log.jsonl outbound entries with the +# model_used / input_tokens / output_tokens / reasoning_duration_ms +# / reasoning_error fields, and a small follow-on bucket reads +# those into this endpoint. +# +# v1 stub: 4 representative calls per bucket §3(a), spanning +# * ok @ NORMAL on opus — happy path +# * ok @ WARN_75 on sonnet — cost-downshift in action +# * halted @ HARD_STOP_100 — budget-locked refusal +# * failed sdk_timeout — transport failure +# +# K-DG drift caught (bucket spec used uppercase enum NAMES but +# the wire format is the lowercase Enum VALUES per +# ``agent/cost_state_holder.py:114-117``: NORMAL = "normal" etc): +# stub uses the lowercase ``.value`` strings to match what CC#3 +# real data will emit. cost_rung_at_call is a literal value +# string; the FE pill-color map keys on these. +# +# 4-layer SECURITY contract (extending the established pattern +# with reasoning-specific guards): +# 1. response_text_truncated_200 rendered as PLAIN TEXT by the +# FE — React's default child escaping defangs any HTML / +# markdown / script in Kora's generated text. FE pins via +# dangerouslySetInnerHTML grep. +# 2. NO Anthropic-key shapes anywhere in payload — walk-payload +# regex sweeps for ``sk-ant-`` prefix + base64-like 32+ char +# runs. Catches a future log-entry edit or error-projection +# bug that leaks credential material into the operator view. +# 3. NO PII from message context: response_text_truncated_200 +# must never contain the inbound user's identifying patterns +# (email regex / Slack user-ID regex). Backend test sweeps. +# 4. TS interface declares all fields with documented contracts; +# no ``raw_prompt`` / ``auth_token`` companion fields exist. + + +@app.get("/api/reasoning/recent") +async def list_recent_reasoning(): + """Return recent Kora ReasoningEngine calls for the operator lens. + + v1 stub — pinned shape so CC#3's KR-FEAT-AI-RESPONSE-LOOP ST2 + follow-on can swap the body without touching the FE. + + Per-call fields: + id — opaque id + triggered_by — "slack_dm" (only one in v1) + started_at — ISO-8601 + duration_ms — int (>= 0) + model_used — claude-opus-4-7 / sonnet-4-6 / + haiku-4-5-20251001 / null when + halted (no SDK call made) + cost_rung_at_call — lowercase CostRung.value string: + "normal" / "warn_75" / + "downshift_90" / "hard_stop_100" + (matches engine.py:47-49 literal) + input_tokens / output_tokens — ints; 0 when halted/failed-pre-call + status — ok | failed | halted | paused + error_code — null when ok; ReasoningEngine + taxonomy otherwise (PR #126): + sdk_auth | sdk_rate_limited | + sdk_5xx | sdk_4xx_ | + sdk_timeout | sdk_transport | + sdk_unknown_ | + cost_ladder_halted | + operational_state_paused | + response_projection_failed + response_text_truncated_200 — plain-text response excerpt + capped at 200 chars; null when + no response produced + """ + return { + "calls": [ + { + "id": "stub-1", + "triggered_by": "slack_dm", + "started_at": "2026-05-22T17:58:42Z", + "duration_ms": 1247, + "model_used": "claude-opus-4-7", + "cost_rung_at_call": "normal", + "input_tokens": 842, + "output_tokens": 127, + "status": "ok", + "error_code": None, + "response_text_truncated_200": ( + "Daemon is RUNNING. Health rollup green. " + "2 sea tickets active." + ), + }, + { + "id": "stub-2", + "triggered_by": "slack_dm", + "started_at": "2026-05-22T17:42:11Z", + "duration_ms": 894, + "model_used": "claude-sonnet-4-6", + "cost_rung_at_call": "warn_75", + "input_tokens": 612, + "output_tokens": 84, + "status": "ok", + "error_code": None, + "response_text_truncated_200": ( + "Got it. Quieter responses since we're at " + "78% of monthly budget." + ), + }, + { + "id": "stub-3", + "triggered_by": "slack_dm", + "started_at": "2026-05-22T17:30:55Z", + "duration_ms": 32, + "model_used": None, + "cost_rung_at_call": "hard_stop_100", + "input_tokens": 0, + "output_tokens": 0, + "status": "halted", + "error_code": "cost_ladder_halted", + "response_text_truncated_200": None, + }, + { + "id": "stub-4", + "triggered_by": "slack_dm", + "started_at": "2026-05-22T17:20:18Z", + "duration_ms": 5821, + "model_used": "claude-opus-4-7", + "cost_rung_at_call": "normal", + "input_tokens": 423, + "output_tokens": 0, + "status": "failed", + "error_code": "sdk_timeout", + "response_text_truncated_200": None, + }, + ], + "stub": True, + "generated_at": "2026-05-22T18:00:00Z", + "total_recent_24h": 47, + "by_model_24h": { + "claude-opus-4-7": 31, + "claude-sonnet-4-6": 14, + "claude-haiku-4-5-20251001": 0, + "halted_no_model": 2, + }, + "by_status_24h": {"ok": 41, "failed": 4, "halted": 2}, + "tokens_total_24h": {"input": 18420, "output": 3104}, + } + + # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- diff --git a/tests/kora_cli/test_web_server_reasoning.py b/tests/kora_cli/test_web_server_reasoning.py new file mode 100644 index 000000000000..4f5311617ccf --- /dev/null +++ b/tests/kora_cli/test_web_server_reasoning.py @@ -0,0 +1,451 @@ +"""Tests for the KR-REASONING-PANEL stub endpoint. + +Bucket §4 scenarios: + 1. GET /api/reasoning/recent returns 200 + 2. Top-level shape (calls + stub:true + generated_at + + total_recent_24h + by_model_24h + by_status_24h + + tokens_total_24h) + 3. 4 representative stub calls present + 4. Stub spans ok @ normal + ok @ warn_75 + halted at + hard_stop_100 + failed sdk_timeout so the operator's + first look surfaces the cost-ladder behaviour + error + taxonomy + 5. Per-entry shape + valid status + valid cost_rung + valid + error_code (when status != ok) + 6. cost_rung_at_call values match the lowercase + CostLadderRungName literal (engine.py:47-49) — NOT the + uppercase Enum NAMES — so real data and stub agree + 7. SECURITY: no Anthropic key shapes (sk-ant- prefix + + 32+ char base64-like) anywhere in payload + 8. SECURITY: response_text_truncated_200 contains no PII + (email regex / Slack user-ID regex) — Kora must not + leak the inbound user's content into its response + 9. SECURITY: companion FE pin — ReasoningPanel.tsx never + uses dangerouslySetInnerHTML for the response text + 10. response_text capped at 200 chars at the API edge + 11. tokens_total_24h sum reconciliation + 12. by_status_24h sum reconciles to total_recent_24h + 13. Cron-regression sanity +""" + +import re +from pathlib import Path + +import pytest + + +_VALID_STATUS = {"ok", "failed", "halted", "paused"} +# Per agent/cost_state_holder.py:114-117 — the wire format is the +# lowercase Enum VALUES, NOT the uppercase Enum NAMES the spec +# example payload used. Pin the lowercase shape so real CC#3 +# data + this stub agree at flip time. +_VALID_COST_RUNG = { + "normal", + "warn_75", + "downshift_90", + "hard_stop_100", + "unknown", +} +_VALID_MODEL_OR_NULL = { + "claude-opus-4-7", + "claude-sonnet-4-6", + "claude-haiku-4-5-20251001", + None, +} +# ReasoningEngine error code taxonomy per PR #126 + engine.py +# docstring (lines 154-156). The 4xx / unknown variants encode +# a code class, so we accept the prefix. +_VALID_ERROR_CODE_OR_NULL_PREFIXES = ( + "sdk_auth", + "sdk_rate_limited", + "sdk_5xx", + "sdk_4xx_", + "sdk_timeout", + "sdk_transport", + "sdk_unknown_", + "cost_ladder_halted", + "operational_state_paused", + "response_projection_failed", +) + +# Anthropic API key shape: sk-ant- prefix + base64-like body. +# The real format is sk-ant-api03- for Console keys +# and sk-ant-oat01- for OAuth tokens; both have a +# multi-char marker + a long body. Match conservatively to +# catch any future leak shape. +_ANTHROPIC_KEY_SHAPE = re.compile(r"\bsk-ant-[A-Za-z0-9_-]{16,}\b") + +# Email-address PII (same shape as KR-EMAIL-PANEL guard) — Kora's +# generated response must NOT contain identifying email addresses +# from the inbound user's context. +_EMAIL_ADDRESS = re.compile( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b" +) +# Raw Slack user ID PII (same shape as KR-SLACK-DM-PANEL guard). +_RAW_SLACK_USER_ID = re.compile(r"\bU[A-Z0-9]{8,}\b") + + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PANEL_PATH = _REPO_ROOT / "web" / "src" / "pages" / "ReasoningPanel.tsx" + + +def _strip_ts_comments(src: str) -> str: + src = re.sub(r"\{/\*.*?\*/\}", "", src, flags=re.DOTALL) + src = re.sub(r"/\*.*?\*/", "", src, flags=re.DOTALL) + src = re.sub(r"(^|[^:])//[^\n]*", r"\1", src) + return src + + +@pytest.fixture(autouse=True) +def _isolate_config(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr("kora_cli.config.get_kora_home", lambda: tmp_path) + monkeypatch.setattr( + "kora_cli.config.get_config_path", lambda: tmp_path / "config.yaml" + ) + monkeypatch.setattr( + "kora_cli.config.get_env_path", lambda: tmp_path / ".env" + ) + return tmp_path + + +# ---- 1. 200 ----------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_endpoint_returns_200(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + assert isinstance(result, dict) + + +# ---- 2. Top-level shape --------------------------------------------- + + +@pytest.mark.asyncio +async def test_response_shape_has_required_keys(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + assert set(result.keys()) == { + "calls", + "stub", + "generated_at", + "total_recent_24h", + "by_model_24h", + "by_status_24h", + "tokens_total_24h", + } + assert isinstance(result["calls"], list) + assert isinstance(result["generated_at"], str) + assert isinstance(result["total_recent_24h"], int) + assert isinstance(result["by_model_24h"], dict) + assert isinstance(result["by_status_24h"], dict) + assert isinstance(result["tokens_total_24h"], dict) + assert result["stub"] is True + + +# ---- 3. Expected stub calls ---------------------------------------- + + +@pytest.mark.asyncio +async def test_stub_returns_four_representative_calls(_isolate_config): + """Pin the bucket §3(a) canonical 4-call stub list. CC#3's + KR-FEAT-AI-RESPONSE-LOOP ST2 follow-on will swap the body to + read reasoning entries from slack_dm_log.jsonl, but the shape + stays stable so the FE keeps rendering during cut-over.""" + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + assert len(result["calls"]) == 4 + ids = {c["id"] for c in result["calls"]} + assert ids == {"stub-1", "stub-2", "stub-3", "stub-4"} + + +@pytest.mark.asyncio +async def test_stub_spans_ok_warn_halted_failed(_isolate_config): + """The 4 stub calls deliberately span the cost-ladder behaviour + (normal opus + warn_75 sonnet + hard_stop_100 halted) AND the + error taxonomy (sdk_timeout) so the operator's first look + shows the four most-important states. Pin so a future stub + edit can't homogenize.""" + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + statuses = {c["status"] for c in result["calls"]} + rungs = {c["cost_rung_at_call"] for c in result["calls"]} + error_codes = {c["error_code"] for c in result["calls"]} + assert "ok" in statuses + assert "halted" in statuses + assert "failed" in statuses + assert "normal" in rungs + assert "warn_75" in rungs + assert "hard_stop_100" in rungs + assert "cost_ladder_halted" in error_codes + assert "sdk_timeout" in error_codes + + +# ---- 4. Per-entry shape + enums ------------------------------------ + + +@pytest.mark.asyncio +async def test_each_call_has_required_keys_and_valid_enums(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + required = { + "id", + "triggered_by", + "started_at", + "duration_ms", + "model_used", + "cost_rung_at_call", + "input_tokens", + "output_tokens", + "status", + "error_code", + "response_text_truncated_200", + } + for call in result["calls"]: + assert set(call.keys()) == required, ( + f"{call.get('id', '?')}: keys mismatch {set(call.keys())}" + ) + assert call["status"] in _VALID_STATUS + assert call["cost_rung_at_call"] in _VALID_COST_RUNG, ( + f"{call['id']}: cost_rung={call['cost_rung_at_call']!r} not in " + f"{_VALID_COST_RUNG} (must be lowercase CostRung.value, not " + f"the uppercase Enum name)" + ) + assert call["model_used"] in _VALID_MODEL_OR_NULL, ( + f"{call['id']}: model_used={call['model_used']!r}" + ) + assert isinstance(call["started_at"], str) and call["started_at"].endswith("Z") + assert isinstance(call["duration_ms"], int) and call["duration_ms"] >= 0 + assert isinstance(call["input_tokens"], int) and call["input_tokens"] >= 0 + assert isinstance(call["output_tokens"], int) and call["output_tokens"] >= 0 + # error_code is null when status == "ok"; otherwise must + # match the ReasoningEngine taxonomy (PR #126). + if call["status"] == "ok": + assert call["error_code"] is None, ( + f"{call['id']}: ok status must have null error_code" + ) + else: + assert call["error_code"] is not None, ( + f"{call['id']}: non-ok status must surface an error_code" + ) + assert any( + call["error_code"].startswith(p) + for p in _VALID_ERROR_CODE_OR_NULL_PREFIXES + ), ( + f"{call['id']}: error_code={call['error_code']!r} doesn't " + f"match the ReasoningEngine taxonomy" + ) + # response_text is plain string or null (capped at 200 chars + # at the API edge — checked separately) + assert call["response_text_truncated_200"] is None or isinstance( + call["response_text_truncated_200"], str + ) + + +# ---- 5. cost_rung wire-format pin (K-DG catch) --------------------- + + +@pytest.mark.asyncio +async def test_cost_rung_uses_lowercase_value_strings_not_enum_names(_isolate_config): + """K-DG drift catch: the bucket spec example payload used the + uppercase Enum NAMES (NORMAL / WARN_75 / DOWNSHIFT_90 / + HARD_STOP_100), but the canonical wire format per + agent/cost_state_holder.py:114-117 is the lowercase Enum + VALUES ("normal" / "warn_75" / ...). CC#3's real data will + emit lowercase via CostLadderRungName literal in + engine.py:47-49. Pin lowercase so a future stub edit + revertingto the spec's uppercase doesn't masquerade as + working until the flip.""" + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + for call in result["calls"]: + rung = call["cost_rung_at_call"] + assert rung == rung.lower(), ( + f"{call['id']}: cost_rung_at_call={rung!r} must be lowercase " + f"(CostRung.value wire format), not the uppercase Enum NAME" + ) + + +# ---- 6. SECURITY: no Anthropic key shapes anywhere ---------------- + + +@pytest.mark.asyncio +async def test_no_anthropic_key_shapes_in_payload(_isolate_config): + """Bucket §3(a) SECURITY layer 2: walk-payload regex catches + Anthropic key shapes (sk-ant- prefix + base64-like body) + anywhere in the response. A future error-projection bug or + log-entry edit that leaks credential material into the + operator's view gets caught at the API edge, not in their + browser (where it could end up in diag bundles or screenshots). + """ + from kora_cli import web_server + import json as _json + + result = await web_server.list_recent_reasoning() + blob = _json.dumps(result) + leaks = _ANTHROPIC_KEY_SHAPE.findall(blob) + assert leaks == [], ( + f"payload contains Anthropic key shape(s): {leaks} — credential " + f"material must never appear in API responses (bucket §3(a) " + f"SECURITY layer 2)" + ) + + +# ---- 7. SECURITY: no PII in response_text ------------------------- + + +@pytest.mark.asyncio +async def test_response_text_contains_no_pii(_isolate_config): + """Bucket §3(a) SECURITY layer 3: response_text_truncated_200 + must not leak identifying patterns from the inbound user's + message context — no email addresses (KR-EMAIL-PANEL shape) + nor raw Slack user IDs (KR-SLACK-DM-PANEL shape). Kora's + generated text is operator-visible; the inbound message + content lives in SLACK-DM-PANEL, not here.""" + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + for call in result["calls"]: + text = call["response_text_truncated_200"] + if text is None: + continue + email_leaks = _EMAIL_ADDRESS.findall(text) + slack_leaks = _RAW_SLACK_USER_ID.findall(text) + assert email_leaks == [], ( + f"{call['id']}: response_text contains email address(es): " + f"{email_leaks}" + ) + assert slack_leaks == [], ( + f"{call['id']}: response_text contains raw Slack user " + f"ID(s): {slack_leaks}" + ) + + +@pytest.mark.asyncio +async def test_no_pii_anywhere_in_payload(_isolate_config): + """Belt+braces walk-payload sweep — any field in the response + (not just response_text) that contains email/Slack-ID PII + fails. Catches a future drift like adding a "context_summary" + diagnostic that leaks the user's address.""" + from kora_cli import web_server + import json as _json + + result = await web_server.list_recent_reasoning() + blob = _json.dumps(result) + assert _EMAIL_ADDRESS.findall(blob) == [], ( + "reasoning payload contains email address PII anywhere" + ) + assert _RAW_SLACK_USER_ID.findall(blob) == [], ( + "reasoning payload contains raw Slack user ID PII anywhere" + ) + + +# ---- 8. SECURITY: companion FE plain-text rendering pin ----------- + + +def test_panel_uses_no_dangerously_set_inner_html_for_response_text(): + """Bucket §3(a) SECURITY layer 1: response_text rendered as + PLAIN TEXT via React's default child escaping. Real responses + may contain anything Kora generates (HTML / markdown / script + fragments). This guard catches a future edit that switches to + dangerouslySetInnerHTML for 'richer rendering'.""" + code = _strip_ts_comments(_PANEL_PATH.read_text()) + assert "dangerouslySetInnerHTML" not in code, ( + "ReasoningPanel.tsx must not use dangerouslySetInnerHTML — " + "response text is model-generated content" + ) + + +def test_panel_renders_response_text_as_child_expression(): + """Belt+braces companion: response_text_truncated_200 rendered + as a JSX child expression (escaped), via the pure truncateText + helper for the collapsed view.""" + src = _PANEL_PATH.read_text() + assert "call.response_text_truncated_200" in src, ( + "ReasoningPanel.tsx should reference response_text_truncated_200" + ) + assert re.search( + r"\{[^{}]*call\.response_text_truncated_200[^{}]*\}", + src, + ), ( + "response_text should appear inside a JSX expression container " + "(rendered as a child, not an attribute)" + ) + + +# ---- 9. 200-char cap pinning -------------------------------------- + + +@pytest.mark.asyncio +async def test_response_text_capped_at_200_chars(_isolate_config): + """Bucket §3(a): backend caps response_text at 200 chars at the + API edge (field-name encodes the cap). FE then truncates further + for the collapsed-view excerpt. Pin so a future backend edit + can't accidentally start sending unbounded text.""" + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + for call in result["calls"]: + text = call["response_text_truncated_200"] + if text is None: + continue + assert len(text) <= 200, ( + f"{call['id']}: response_text length {len(text)} exceeds the " + f"200-char API-edge cap" + ) + + +# ---- 10. Aggregate reconciliation -------------------------------- + + +@pytest.mark.asyncio +async def test_by_status_24h_sum_reconciles_to_total(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + status_sum = sum(result["by_status_24h"].values()) + assert status_sum == result["total_recent_24h"], ( + f"by_status_24h sums to {status_sum} but total_recent_24h is " + f"{result['total_recent_24h']}" + ) + + +@pytest.mark.asyncio +async def test_by_status_24h_keys_are_valid(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + invalid = set(result["by_status_24h"].keys()) - _VALID_STATUS + assert not invalid, ( + f"by_status_24h has unknown status key(s): {invalid}" + ) + + +@pytest.mark.asyncio +async def test_tokens_total_24h_has_input_and_output(_isolate_config): + from kora_cli import web_server + + result = await web_server.list_recent_reasoning() + assert set(result["tokens_total_24h"].keys()) == {"input", "output"} + assert all( + isinstance(v, int) and v >= 0 + for v in result["tokens_total_24h"].values() + ) + + +# ---- 11. Cron-regression sanity ----------------------------------- + + +@pytest.mark.asyncio +async def test_cron_endpoint_still_works_with_reasoning_registered(_isolate_config): + from kora_cli import web_server + + jobs = await web_server.list_cron_jobs(profile="all") + assert isinstance(jobs, list) diff --git a/web/src/App.tsx b/web/src/App.tsx index d8786d8c3e12..76ad6a7f91a6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -19,6 +19,7 @@ import { BarChart3, BookOpen, BookOpenCheck, + Brain, Clock, Code, Cpu, @@ -91,6 +92,7 @@ import WebhookEventsPanel from "@/pages/WebhookEventsPanel"; import AgentActivityPanel from "@/pages/AgentActivityPanel"; import SlackDMPanel from "@/pages/SlackDMPanel"; import EmailPanel from "@/pages/EmailPanel"; +import ReasoningPanel from "@/pages/ReasoningPanel"; import BootStatusPage from "@/pages/BootStatusPage"; import DRStatePage from "@/pages/DRStatePage"; import CostStatePage from "@/pages/CostStatePage"; @@ -148,6 +150,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/mcp-clients": MCPClientsPanel, "/webhook-events": WebhookEventsPanel, "/agent-activity": AgentActivityPanel, + "/reasoning": ReasoningPanel, "/slack-dm": SlackDMPanel, "/email": EmailPanel, "/boot-status": BootStatusPage, @@ -224,6 +227,12 @@ const BUILTIN_NAV_REST: NavItem[] = [ label: "Agent Activity", icon: Workflow, }, + { + path: "/reasoning", + labelKey: "reasoning", + label: "Reasoning", + icon: Brain, + }, { path: "/slack-dm", labelKey: "slackDM", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1f1c702212b5..c505bdf4f33a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -130,6 +130,8 @@ export const api = { fetchJSON("/api/slack-dm/recent"), getRecentEmail: () => fetchJSON("/api/email/recent"), + getRecentReasoning: () => + fetchJSON("/api/reasoning/recent"), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => @@ -1665,3 +1667,73 @@ export interface EmailResponse { by_direction_24h: Record; by_status_24h: Record; } + +// Kora reasoning activity lens (KR-REASONING-PANEL). +// 4-layer SECURITY CONTRACT (extending the established pattern +// with reasoning-specific guards): +// 1. response_text_truncated_200 rendered as PLAIN TEXT — React's +// default child escaping defangs any HTML / markdown / script +// in Kora's generated text. FE pins via dangerouslySetInnerHTML +// grep. Real responses may contain anything the model emits. +// 2. NO Anthropic-key shapes (sk-ant- prefix) anywhere in payload +// — backend test sweeps. There are no token fields on this +// type; the walk-payload guard catches a future log-entry edit +// that leaks credential material into the operator view. +// 3. NO PII (email regex / Slack user-ID regex) leaked from the +// inbound user's message into response_text_truncated_200 — +// backend test sweeps the response field. +// 4. This TS type enforces shape; no raw_prompt / auth_token / +// response_html fields exist on ReasoningCall. + +// CostRung.value wire strings per agent/cost_state_holder.py:114-117. +// The enum class members are uppercase NAMES (NORMAL, WARN_75, etc.) +// but the wire format / FE pill-color map keys on the lowercase +// `.value` strings — that's what real CC#3 data will emit. +export type ReasoningCostRung = + | "normal" + | "warn_75" + | "downshift_90" + | "hard_stop_100" + | "unknown"; + +export type ReasoningStatus = "ok" | "failed" | "halted" | "paused"; + +// Model strings match kora_cli/reasoning/anthropic_engine.py's +// cost-ladder model selection. +export type ReasoningModel = + | "claude-opus-4-7" + | "claude-sonnet-4-6" + | "claude-haiku-4-5-20251001"; + +export interface ReasoningCall { + id: string; + triggered_by: string; // "slack_dm" only in v1; future: email/mcp/cron + started_at: string; + duration_ms: number; + model_used: ReasoningModel | null; // null when halted (no SDK call) + cost_rung_at_call: ReasoningCostRung; + input_tokens: number; + output_tokens: number; + status: ReasoningStatus; + // ReasoningEngine error code taxonomy (PR #126): + // sdk_auth | sdk_rate_limited | sdk_5xx | sdk_4xx_ | + // sdk_timeout | sdk_transport | sdk_unknown_ | + // cost_ladder_halted | operational_state_paused | + // response_projection_failed + error_code: string | null; + // Plain-text response excerpt capped at 200 chars at the API + // edge. FE renders verbatim — NEVER via dangerouslySetInnerHTML. + response_text_truncated_200: string | null; +} + +export interface ReasoningResponse { + calls: ReasoningCall[]; + stub: boolean; + generated_at: string; + total_recent_24h: number; + // Keys are model strings PLUS "halted_no_model" for the halted + // bucket (where model_used is null). + by_model_24h: Record; + by_status_24h: Record; + tokens_total_24h: { input: number; output: number }; +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index baede927744e..0b386b04ffe8 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -7,6 +7,7 @@ import { Archive, ArrowRight, BookOpenCheck, + Brain, CheckCircle2, DollarSign, Cable, @@ -61,6 +62,7 @@ import type { SlackDMHandledStatus, EmailResponse, EmailHandledStatus, + ReasoningResponse, } from "@/lib/api"; type LoadStatus = @@ -94,6 +96,8 @@ interface DashboardData { slackDM: LoadStatus; // KR-EMAIL-PANEL — Kora ↔ Joshua email inbox/outbox (stub) email: LoadStatus; + // KR-REASONING-PANEL — Kora ReasoningEngine activity (stub) + reasoning: LoadStatus; } const INITIAL_DATA: DashboardData = { @@ -114,6 +118,7 @@ const INITIAL_DATA: DashboardData = { agentActivity: { state: "loading" }, slackDM: { state: "loading" }, email: { state: "loading" }, + reasoning: { state: "loading" }, }; const HEALTH_TONE: Record = { @@ -832,6 +837,49 @@ function EmailCardBody({ data }: { data: EmailResponse }) { ); } +function ReasoningCardBody({ data }: { data: ReasoningResponse }) { + // Operator-attention contract per bucket §3(c): headline goes + // destructive when `halted > 0` in 24h — Kora was budget-locked, + // operator should investigate cost-ladder rung. failed > 0 is + // worth flagging but not as loudly (transport/SDK noise happens). + const okCount = data.by_status_24h["ok"] ?? 0; + const failedCount = data.by_status_24h["failed"] ?? 0; + const haltedCount = data.by_status_24h["halted"] ?? 0; + const tokensTotal = + data.tokens_total_24h.input + data.tokens_total_24h.output; + const alert = haltedCount > 0; + const headlineClass = alert + ? "text-destructive" + : failedCount > 0 + ? "text-warning" + : "text-foreground"; + return ( +
+
+ {okCount} + + /{data.total_recent_24h} + + + call{data.total_recent_24h === 1 ? "" : "s"} ·{" "} + {tokensTotal.toLocaleString()} tok + +
+
+ {haltedCount > 0 && ( + {haltedCount} halted + )} + {failedCount > 0 && ( + {failedCount} failed + )} + {haltedCount === 0 && failedCount === 0 && okCount > 0 && ( + healthy + )} +
+
+ ); +} + // ── Hero ───────────────────────────────────────────────────────────────── interface HealthHeroProps { @@ -982,6 +1030,8 @@ export default function DashboardPage() { loadOne("slackDM", () => api.getRecentSlackDM()), // KR-EMAIL-PANEL loadOne("email", () => api.getRecentEmail()), + // KR-REASONING-PANEL + loadOne("reasoning", () => api.getRecentReasoning()), ]); if (isManual) { setRefreshing(false); @@ -1017,6 +1067,7 @@ export default function DashboardPage() { data.agentActivity, data.slackDM, data.email, + data.reasoning, ]; const anyStubbed = ALL_SOURCES.some((s) => isStubbed(s)); @@ -1302,6 +1353,21 @@ export default function DashboardPage() { )} + + + void loadOne("reasoning", () => api.getRecentReasoning()) + } + > + {data.reasoning.state === "ready" && ( + + )} + {/* ── Bottom strip: links to other (non-admin-panel) pages ─────── */} diff --git a/web/src/pages/ReasoningPanel.tsx b/web/src/pages/ReasoningPanel.tsx new file mode 100644 index 000000000000..361a2b12dc76 --- /dev/null +++ b/web/src/pages/ReasoningPanel.tsx @@ -0,0 +1,635 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertOctagon, + AlertTriangle, + Ban, + Brain, + CheckCircle2, + ChevronDown, + ChevronRight, + Clock, + Coins, + PauseCircle, + RefreshCw, + Timer, + XCircle, + Zap, +} from "lucide-react"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { H2 } from "@/components/NouiTypography"; +import { Card, CardContent } from "@/components/ui/card"; +import { Toast } from "@/components/Toast"; +import { useToast } from "@/hooks/useToast"; +import { api } from "@/lib/api"; +import type { + ReasoningCall, + ReasoningCostRung, + ReasoningResponse, + ReasoningStatus, +} from "@/lib/api"; + +type Filter = "all" | "ok" | "failed" | "halted"; + +// Cost-rung pill colours per bucket §3(b): gray/yellow/orange/red. +// Keys are the lowercase CostRung.value wire strings +// (agent/cost_state_holder.py:114-117). +const COST_RUNG_TONE: Record< + ReasoningCostRung, + "success" | "warning" | "destructive" | "outline" +> = { + normal: "outline", + warn_75: "warning", + downshift_90: "warning", + hard_stop_100: "destructive", + unknown: "outline", +}; + +const COST_RUNG_LABEL: Record = { + normal: "normal", + warn_75: "warn 75%", + downshift_90: "downshift 90%", + hard_stop_100: "hard-stop 100%", + unknown: "unknown", +}; + +// Visual cue: cost-rung text color so the rung itself pops +// at-a-glance even without the badge tone. +const COST_RUNG_TEXT_COLOR: Record = { + normal: "text-muted-foreground", + warn_75: "text-warning", + downshift_90: "text-orange-500", + hard_stop_100: "text-destructive", + unknown: "text-muted-foreground", +}; + +const STATUS_TONE: Record< + ReasoningStatus, + "success" | "warning" | "destructive" +> = { + ok: "success", + failed: "destructive", + halted: "destructive", + paused: "warning", +}; + +const STATUS_LABEL: Record = { + ok: "ok", + failed: "failed", + halted: "halted", + paused: "paused", +}; + +// Model badges color-coded by tier (cost ladder rung mapping): +// opus = top tier (blue), sonnet = downshift mid (purple), +// haiku = downshift deep (gray), null = halted (red). +function ModelBadge({ model }: { model: ReasoningCall["model_used"] }) { + if (model === null) { + return ( + + + no model · halted + + ); + } + // Tier-tinted text + outline rather than full destructive on every + // sonnet/haiku call — those are normal downshifts, not failures. + const tierClass = + model === "claude-opus-4-7" + ? "text-blue-500" + : model === "claude-sonnet-4-6" + ? "text-purple-500" + : "text-muted-foreground"; + return ( + + {model} + + ); +} + +function StatusIcon({ status }: { status: ReasoningStatus }) { + switch (status) { + case "ok": + return ; + case "failed": + return ; + case "halted": + return ; + case "paused": + return ; + } +} + +function formatTimestamp(iso: string): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +function formatRelative(iso: string): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const deltaMs = d.getTime() - Date.now(); + const absSec = Math.abs(deltaMs) / 1000; + if (absSec < 60) { + const n = Math.round(absSec); + return deltaMs < 0 ? `${n}s ago` : `in ${n}s`; + } + const absMin = absSec / 60; + if (absMin < 60) { + const n = Math.round(absMin); + return deltaMs < 0 ? `${n}m ago` : `in ${n}m`; + } + const absHr = absMin / 60; + if (absHr < 24) { + const n = Math.round(absHr); + return deltaMs < 0 ? `${n}h ago` : `in ${n}h`; + } + const absDay = absHr / 24; + const n = Math.round(absDay); + return deltaMs < 0 ? `${n}d ago` : `in ${n}d`; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(2)} s`; +} + +// Cap visual bar at 5s — anything beyond is "long" regardless of +// exact value; the operator just needs the "this took real time" cue. +function durationBarWidth(ms: number): string { + const capped = Math.min(ms, 5000); + return `${(capped / 5000) * 100}%`; +} + +const COLLAPSED_TEXT_MAX = 80; + +function truncateText(s: string, max: number = COLLAPSED_TEXT_MAX): string { + if (s.length <= max) return s; + return s.slice(0, max) + "…"; +} + +interface CallRowProps { + call: ReasoningCall; + expanded: boolean; + onToggle: () => void; +} + +function CallRow({ call, expanded, onToggle }: CallRowProps) { + const isSlow = call.duration_ms >= 2000; + const hasText = call.response_text_truncated_200 !== null; + const needsExpand = + hasText && call.response_text_truncated_200!.length > COLLAPSED_TEXT_MAX; + return ( + + + + + {expanded && ( +
+
+ + started_at + + {formatTimestamp(call.started_at)} +
+
+ + triggered_by + + {call.triggered_by} +
+
+ + model_used + + + {call.model_used ?? ( + + null (no SDK call) + + )} + +
+
+ + cost_rung_at_call + + + {call.cost_rung_at_call} + +
+
+ + tokens + + + {call.input_tokens} in → {call.output_tokens} out + +
+
+ + duration + + {call.duration_ms} ms +
+
+ + status + + + + {STATUS_LABEL[call.status]} + +
+ {call.error_code && ( +
+ + error_code + + + {call.error_code} + +
+ )} +
+ id + {call.id} +
+
+ )} +
+
+ ); +} + +function matchesFilter(call: ReasoningCall, filter: Filter): boolean { + if (filter === "all") return true; + return call.status === filter; +} + +export default function ReasoningPanel() { + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [filter, setFilter] = useState("all"); + const { toast, showToast } = useToast(); + + const loadReasoning = useCallback( + (isManual: boolean) => { + if (isManual) setRefreshing(true); + setLoadError(null); + api + .getRecentReasoning() + .then((resp) => setData(resp)) + .catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + setLoadError(msg); + showToast(`Failed to load reasoning activity: ${msg}`, "error"); + }) + .finally(() => { + if (isManual) setRefreshing(false); + }); + }, + [showToast], + ); + + useEffect(() => { + loadReasoning(false); + }, [loadReasoning]); + + const toggleExpand = useCallback((id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const orderedCalls = useMemo(() => { + if (!data) return []; + return [...data.calls].sort((a, b) => { + const ta = new Date(a.started_at).getTime(); + const tb = new Date(b.started_at).getTime(); + if (ta !== tb) return tb - ta; + return a.id < b.id ? 1 : -1; + }); + }, [data]); + + const filterCounts = useMemo(() => { + if (!data) return { all: 0, ok: 0, failed: 0, halted: 0 }; + return { + all: data.calls.length, + ok: data.calls.filter((c) => c.status === "ok").length, + failed: data.calls.filter((c) => c.status === "failed").length, + halted: data.calls.filter((c) => c.status === "halted").length, + }; + }, [data]); + + const visibleCalls = useMemo( + () => orderedCalls.filter((c) => matchesFilter(c, filter)), + [orderedCalls, filter], + ); + + if (data === null && !loadError) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Kora Reasoning Activity

+

+ Recent ReasoningEngine calls — model, tokens, cost rung, + errors. +

+
+ +
+ + {loadError && ( + + + +
+
+ Failed to load reasoning activity +
+
{loadError}
+
+
+
+ )} + + {data?.stub && ( + + + +
+
+ STUB — real data wires in via CC#3's + KR-FEAT-AI-RESPONSE-LOOP ST2 follow-on +
+
+ Values shown are hardcoded sample calls (deliberately + spanning ok @ normal / ok @ warn_75 / halted at + hard_stop_100 / sdk_timeout failure so the operator + sees the cost-ladder behaviour + error taxonomy). + Real data flips once ST2 extends{" "} + + ${"{HERMES_HOME}"}/slack_dm_log.jsonl + {" "} + with the reasoning fields. +
+
+
+
+ )} + + {data && ( + <> + {/* ── Stats strip (4 columns per spec §3(b)) ─────────── */} +
+ + + + Total calls / 24h + + + {data.total_recent_24h} + + + + + + + + Token spend / 24h + + + {data.tokens_total_24h.input.toLocaleString()} →{" "} + {data.tokens_total_24h.output.toLocaleString()} + + + input → output + + + + + + + Model distribution / 24h + +
+ {Object.entries(data.by_model_24h) + .filter(([, count]) => count > 0) + .map(([model, count]) => ( + + + {model === "halted_no_model" ? "halted" : model} + + + {count} + + + ))} +
+
+
+ + + + Status distribution / 24h + +
+ {Object.entries(data.by_status_24h).map(([status, count]) => { + const s = status as ReasoningStatus; + return ( + + {STATUS_LABEL[s] ?? status} {count} + + ); + })} +
+
+
+
+ + {/* ── Filter pills ─────────────────────────────────── */} + + + + view + +
+ {( + [ + ["all", `all (${filterCounts.all})`], + ["ok", `ok (${filterCounts.ok})`], + ["failed", `failed (${filterCounts.failed})`], + ["halted", `halted (${filterCounts.halted})`], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+
+ + {/* ── Timeline (newest first) ──────────────────────── */} + {data.calls.length === 0 ? ( + + + + No reasoning activity yet. Once Joshua DMs Kora, + reasoning calls will appear here. + + + ) : visibleCalls.length === 0 ? ( + + + + No calls match the current filter. + + + ) : ( +
+ {visibleCalls.map((c) => ( + toggleExpand(c.id)} + /> + ))} +
+ )} + + )} +
+ ); +}