Skip to content

Commit 95c0c48

Browse files
committed
fix(inspector): round 2 — type names, session id, pech, tracing log, details
- Event::type_str() returns the real wire-side `type` for Unknown variants (pech.ledger.appended, lifecycle.*, mcp.tool.*, ...) instead of the static "unknown" placeholder. Events table column now reads usefully. type_tag() preserved for the &'static str callers (events/security/ replay/plugins views). - AppState::apply() latches session.session_id on the first event that carries one, then locks it. ACTIVE SESSION's "Session" row stops showing "-" the moment any wire event lands. - pech.ledger.appended in apply_unknown now bumps pech.last_event explicitly. The runtime stamps these events with `plugin: "mcp-client"`, so the generic ev.plugin() refresh credits mcp-client; without the explicit bump pech's "last seen" never ticked. - fmt_log_size: 0 renders as "0 B" (was "-"); ladder boundaries verified at 0 / 999 / 1024 / 1MB / 1GB. Path resolution to %LOCALAPPDATA%/ enchanter/inspector.log confirmed correct — the displayed 38 GB is honest (real log is 40 GB on this machine; rotation is a separate bug). - event_detail / unknown_event_detail: priority chain pulls message → extras["message"] → extras["reason"] → type-specific synthesis (pech.ledger.appended → cost+tokens, mcp.tool.call.requested → tool+path, lifecycle.* → phase+elapsed, *.veto.* → policy/reason) → JSON-stringify fallback truncated to 60 chars. Details column populated for every row. +6 tests: type_str x2, session_id_latches, pech_ledger_last_event, unknown_event_detail_priority_chain, event_detail_pech_typed.
1 parent 6344cce commit 95c0c48

3 files changed

Lines changed: 393 additions & 50 deletions

File tree

inspector/src/event.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
//! parser keeps making progress when the runtime adds new fields.
88
99
use serde::{Deserialize, Serialize};
10+
use std::borrow::Cow;
1011
use std::collections::BTreeMap;
1112

1213
/// Severity ladder shared by veto / review / drift events.
@@ -369,6 +370,22 @@ impl Event {
369370
}
370371
}
371372

373+
/// Real wire-format type string. Identical to `type_tag()` for typed
374+
/// variants, but for `Event::Unknown` returns the original `type` field
375+
/// pulled from `GenericPayload.extra` (preserved by `parse_line`'s
376+
/// fallback path) rather than the static `"unknown"` placeholder. This
377+
/// is what the events-table renderer wants — `pech.ledger.appended`
378+
/// is more useful to a human reader than `unknown`.
379+
pub fn type_str(&self) -> Cow<'_, str> {
380+
match self {
381+
Event::Unknown(p) => match p.extra.get("type").and_then(|v| v.as_str()) {
382+
Some(s) if !s.is_empty() => Cow::Borrowed(s),
383+
_ => Cow::Borrowed("unknown"),
384+
},
385+
_ => Cow::Borrowed(self.type_tag()),
386+
}
387+
}
388+
372389
/// Plugin attribution if the event carries one.
373390
pub fn plugin(&self) -> Option<&str> {
374391
match self {
@@ -759,6 +776,24 @@ mod tests {
759776
}
760777
}
761778

779+
#[test]
780+
fn type_str_returns_real_type_for_unknown_variant() {
781+
// Round-2 fix: Event::Unknown's type_tag() returns "unknown", which
782+
// makes the events table column useless. type_str() must pull the
783+
// real wire-side `type` field from the flattened payload extras.
784+
let json = r#"{"type": "pech.ledger.appended", "time": 1.0, "plugin": "mcp-client"}"#;
785+
let evt = parse_line(json).expect("expected fallback to Event::Unknown");
786+
assert_eq!(evt.type_tag(), "unknown");
787+
assert_eq!(evt.type_str(), "pech.ledger.appended");
788+
}
789+
790+
#[test]
791+
fn type_str_matches_type_tag_for_typed_variants() {
792+
let evt = Event::sample_runtime_metrics_with_open_sessions(0);
793+
assert_eq!(evt.type_str(), "runtime.metrics");
794+
assert_eq!(evt.type_str(), evt.type_tag());
795+
}
796+
762797
#[test]
763798
fn pech_ledger_roundtrip() {
764799
let json = r#"{

inspector/src/state.rs

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,23 @@ impl AppState {
11391139

11401140
/// Apply a single event to the state.
11411141
pub fn apply(&mut self, ev: Event) {
1142+
// Lock-on-first-set: the inspector tracks one session at a time. As
1143+
// soon as any event arrives carrying a non-empty `session_id`, latch
1144+
// it onto the session pane so ACTIVE SESSION's "Session" row stops
1145+
// showing "-". Subsequent events (which may belong to other sessions
1146+
// when the runtime fans out) do not overwrite — multi-session display
1147+
// is a future concern.
1148+
if self.session.session_id.is_empty()
1149+
|| self.session.session_id == "-"
1150+
|| self.session.session_id == "unknown"
1151+
{
1152+
if let Some(sid) = ev.session_id() {
1153+
if !sid.is_empty() {
1154+
self.session.session_id = sid.to_string();
1155+
}
1156+
}
1157+
}
1158+
11421159
match &ev {
11431160
// ---- runtime.metrics ----------------------------------------
11441161
Event::RuntimeMetrics {
@@ -1626,16 +1643,23 @@ impl AppState {
16261643
.get("daily_cost_usd")
16271644
.and_then(|v| v.as_f64())
16281645
.unwrap_or(0.0);
1629-
self.metrics.spent_session_usd = session_cost;
1646+
if session_cost > 0.0 {
1647+
self.metrics.spent_session_usd = session_cost;
1648+
}
16301649
if daily > 0.0 {
16311650
self.budgets.daily_spend_usd = daily;
16321651
}
1633-
self.set_plugin_display("pech", format!("${:.2}", session_cost));
1652+
self.set_plugin_display("pech", format!("${:.2}", self.metrics.spent_session_usd));
16341653
let cents = (cost_usd * 100.0).round().max(0.0) as u64;
16351654
self.push_plugin_usage("pech", cents);
1655+
// Bump pech's call counter and last_event regardless of the
1656+
// wire-side `plugin` field — the runtime emits these events
1657+
// with `plugin: "mcp-client"`, so the generic ev.plugin()
1658+
// refresh below would credit mcp-client, not pech.
16361659
if let Some(idx) = self.plugin_index_by_name("pech") {
16371660
let plug = &mut self.plugins[idx];
16381661
plug.calls = plug.calls.saturating_add(1);
1662+
plug.last_event = Some(p.time);
16391663
}
16401664
}
16411665
"hydra.veto.fired" => {
@@ -2158,6 +2182,96 @@ mod tests {
21582182
assert_eq!(s.session.current_phase, Some(crate::event::Phase::TrustGate));
21592183
}
21602184

2185+
#[test]
2186+
fn session_id_latches_on_first_event_carrying_one() {
2187+
// Round-2 fix #2: ACTIVE SESSION shows "-" until any event lands a
2188+
// non-empty session_id. After the latch, subsequent events do NOT
2189+
// overwrite (lock-on-first-set) so multi-session fan-out doesn't
2190+
// corrupt the displayed session.
2191+
let mut s = AppState::new();
2192+
assert!(s.session.session_id.is_empty());
2193+
2194+
// Event 1 carries session_id=abc123 — latch.
2195+
let mut p1 = crate::event::GenericPayload {
2196+
time: 1.0,
2197+
session_id: Some("abc123".into()),
2198+
task_id: None,
2199+
plugin: Some("orchestrator".into()),
2200+
phase: None,
2201+
severity: None,
2202+
message: None,
2203+
extra: std::collections::BTreeMap::new(),
2204+
};
2205+
p1.extra.insert("type".into(), serde_json::json!("lifecycle.anchor"));
2206+
s.apply(Event::Unknown(p1));
2207+
assert_eq!(s.session.session_id, "abc123");
2208+
2209+
// Event 2 carries session_id=def456 — must NOT overwrite.
2210+
let mut p2 = crate::event::GenericPayload {
2211+
time: 2.0,
2212+
session_id: Some("def456".into()),
2213+
task_id: None,
2214+
plugin: Some("orchestrator".into()),
2215+
phase: None,
2216+
severity: None,
2217+
message: None,
2218+
extra: std::collections::BTreeMap::new(),
2219+
};
2220+
p2.extra.insert("type".into(), serde_json::json!("lifecycle.dispatch"));
2221+
s.apply(Event::Unknown(p2));
2222+
assert_eq!(s.session.session_id, "abc123", "session_id must lock on first set");
2223+
2224+
// Event 3: an event with no session_id is fine — latched value stays.
2225+
let mut p3 = crate::event::GenericPayload {
2226+
time: 3.0,
2227+
session_id: None,
2228+
task_id: None,
2229+
plugin: None,
2230+
phase: None,
2231+
severity: None,
2232+
message: None,
2233+
extra: std::collections::BTreeMap::new(),
2234+
};
2235+
p3.extra.insert("type".into(), serde_json::json!("totally.fake"));
2236+
s.apply(Event::Unknown(p3));
2237+
assert_eq!(s.session.session_id, "abc123");
2238+
}
2239+
2240+
#[test]
2241+
fn pech_ledger_appended_updates_last_event_even_when_wire_plugin_is_mcp_client() {
2242+
// Round-2 fix #3: the runtime emits pech.ledger.appended with
2243+
// `plugin: "mcp-client"`, not `plugin: "pech"`. The generic
2244+
// ev.plugin() refresh credits mcp-client's last_event, so pech's
2245+
// own last_event would never tick. Verify the explicit pech-side
2246+
// last_event update fires.
2247+
let mut s = AppState::new();
2248+
let mut extra = std::collections::BTreeMap::new();
2249+
extra.insert("type".into(), serde_json::json!("pech.ledger.appended"));
2250+
extra.insert("session_cost_usd".into(), serde_json::json!(0.42));
2251+
extra.insert("cost_usd".into(), serde_json::json!(0.05));
2252+
extra.insert("daily_cost_usd".into(), serde_json::json!(3.21));
2253+
extra.insert("input_tokens".into(), serde_json::json!(1200));
2254+
extra.insert("output_tokens".into(), serde_json::json!(380));
2255+
let ev = Event::Unknown(crate::event::GenericPayload {
2256+
time: 1_778_086_945.5,
2257+
session_id: Some("sess-x".into()),
2258+
task_id: None,
2259+
plugin: Some("mcp-client".into()), // <-- the live wire shape
2260+
phase: Some("post-response".into()),
2261+
severity: None,
2262+
message: None,
2263+
extra,
2264+
});
2265+
s.apply(ev);
2266+
2267+
assert!((s.metrics.spent_session_usd - 0.42).abs() < 1e-9);
2268+
assert!((s.budgets.daily_spend_usd - 3.21).abs() < 1e-9);
2269+
let pech = s.plugins.iter().find(|p| p.name == "pech").unwrap();
2270+
assert_eq!(pech.display_value, "$0.42");
2271+
assert_eq!(pech.calls, 1);
2272+
assert_eq!(pech.last_event, Some(1_778_086_945.5));
2273+
}
2274+
21612275
#[test]
21622276
fn unknown_event_with_plugin_refreshes_last_event() {
21632277
let mut s = AppState::new();

0 commit comments

Comments
 (0)