diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs index adb0b6fe..c64ff758 100644 --- a/bt-daemon/src/translate/claude.rs +++ b/bt-daemon/src/translate/claude.rs @@ -293,6 +293,7 @@ struct ClaudeTranslator { current_cwd: Option, last_turn_cwd: Option, last_turn_output: Option, + session_status: Option, last_ts_ms: i64, } @@ -340,6 +341,7 @@ impl ClaudeTranslator { current_cwd: None, last_turn_cwd: None, last_turn_output: None, + session_status: None, last_ts_ms: 0, } } @@ -444,12 +446,14 @@ impl ClaudeTranslator { | "PostToolUse" | "PostToolUseFailure" | "PermissionDenied" + | "PostCompact" | "SubagentStart" | "SubagentStop" ) } fn open_turn(&mut self, event: &Envelope, ops: &mut Vec) { + self.session_status = Some("running".into()); if let Some(old) = self.turn.take() { self.close_pending_tools( &old.id, @@ -502,6 +506,9 @@ impl ClaudeTranslator { if let Some(permission_mode) = &self.permission_mode { metadata.insert("permission_mode".into(), json!(permission_mode)); } + if let Some(kind) = turn_input_kind(&event.payload) { + metadata.insert("input_kind".into(), json!(kind)); + } if let Some((trigger, task_id)) = continuation { metadata.insert("turn_trigger".into(), json!(trigger)); metadata.insert("task_id".into(), json!(task_id)); @@ -1133,6 +1140,16 @@ impl ClaudeTranslator { let Some(turn_id) = self.turn.as_ref().map(|turn| turn.id.clone()) else { return; }; + let interrupted = error.as_deref().is_some_and(is_interrupt_reason); + let outcome = if interrupted { + "interrupted" + } else if error.is_some() { + "errored" + } else { + "completed" + }; + self.session_status = Some(outcome.to_string()); + let error = (!interrupted).then_some(error).flatten(); self.emit_main(&turn_id, ops); self.close_pending_tools( &turn_id, @@ -1158,6 +1175,7 @@ impl ClaudeTranslator { late_merge_key: Some(format!("execution:stop:{}", event.ts_ms)), output, error, + metadata: Some(json!({"outcome": outcome})), ..Default::default() })); self.last_turn_cwd = turn.cwd; @@ -1210,12 +1228,21 @@ impl ClaudeTranslator { parent_span_ids: self.session_parent_span_ids.clone(), end_ms: Some(end_ms), output: self.last_turn_output.clone(), + metadata: self + .session_status + .as_ref() + .map(|status| json!({"session_status": status})), late_merge_key: Some(format!("session:stop:{end_ms}")), ..Default::default() })); } fn end_session(&mut self, event: &Envelope, ops: &mut Vec) { + if self.turn.is_some() { + self.session_status = Some("interrupted".into()); + } else if self.session_status.is_none() { + self.session_status = Some("completed".into()); + } if let Some(turn_id) = self .turn .as_ref() @@ -1238,6 +1265,7 @@ impl ClaudeTranslator { parent_span_ids: vec![turn.parent_id], end_ms: Some(end_ms), late_merge_key: Some(format!("execution:stop:{}", event.ts_ms)), + metadata: Some(json!({"outcome":"interrupted"})), ..Default::default() })); self.last_turn_id = Some(turn.id); @@ -1318,17 +1346,32 @@ impl AgentTranslator for ClaudeTranslator { ), "PermissionDenied" => self.finish_tool(event, ToolApproval::Denied, None, &mut ops), "PostCompact" => { - if let Some(parent) = self + let turn_span_id = self .turn .as_ref() .map(|turn| turn.id.clone()) - .or_else(|| self.last_turn_id.clone()) - { + .or_else(|| self.last_turn_id.clone()); + if let Some(parent) = &turn_span_id { // PostCompact is the durable point at which Claude's // synthetic compact-summary transcript record becomes the // first message in the active history window. - self.emit_main(&parent, &mut ops); + self.emit_main(parent, &mut ops); } + ops.push(SpanOp::Insert(SpanRow { + span_id: ids::span_id(&self.session_id, &format!("compaction:{}", event.ts_ms)), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![turn_span_id + .clone() + .unwrap_or_else(|| self.session_span_id.clone())], + turn_span_id, + name: "Compaction".into(), + span_type: SpanType::Task, + start_ms: Some(event.ts_ms), + end_ms: Some(event.ts_ms), + metadata: Some(json!({"event":"compaction"})), + tags: Some(vec!["compaction".into()]), + ..Default::default() + })); } "SubagentStart" => { if let Some(hook) = decode::(&event.payload) { @@ -1346,12 +1389,14 @@ impl AgentTranslator for ClaudeTranslator { } } "Stop" => self.stop_turn(event, None, &mut ops), - "StopFailure" => self.stop_turn( - event, - tool_failure_text(&event.payload) - .or_else(|| Some("Claude Code turn failed".into())), - &mut ops, - ), + "StopFailure" => { + self.stop_turn( + event, + tool_failure_text(&event.payload) + .or_else(|| Some("Claude Code turn failed".into())), + &mut ops, + ); + } "SessionEnd" => self.end_session(event, &mut ops), _ => {} } @@ -1371,6 +1416,9 @@ impl AgentTranslator for ClaudeTranslator { fn finalize(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { let end_ms = self.last_ts_ms; + if self.turn.is_some() && self.session_status.as_deref() == Some("running") { + self.session_status = Some("interrupted".into()); + } let mut ops = Vec::new(); if let Some(parent) = self .turn @@ -1398,7 +1446,11 @@ impl AgentTranslator for ClaudeTranslator { parent_span_ids: vec![turn.parent_id], end_ms: Some(turn_end_ms), late_merge_key: Some(format!("execution:stop:{end_ms}")), - error: Some("Session ended before turn completion".into()), + metadata: Some(json!({ + "incomplete": true, + "outcome": "interrupted", + "termination_reason": "session_finalized_before_turn_completion" + })), ..Default::default() })); } @@ -2476,6 +2528,50 @@ fn tool_failure_text(payload: &Value) -> Option { .find_map(nonempty_error_text) } +fn is_interrupt_reason(reason: &str) -> bool { + let reason = reason + .trim() + .trim_end_matches(['.', '!', '?']) + .to_ascii_lowercase(); + matches!( + reason.as_str(), + "request interrupted by user" + | "interrupted by user" + | "user interrupted" + | "request cancelled by user" + | "request canceled by user" + | "cancelled by user" + | "canceled by user" + | "user cancelled" + | "user canceled" + | "user aborted" + | "aborted by user" + ) +} + +fn turn_input_kind(payload: &Value) -> Option<&'static str> { + let source = payload + .get("prompt_source") + .or_else(|| payload.get("promptSource")) + .or_else(|| payload.get("input_kind")) + .and_then(Value::as_str) + .map(str::to_ascii_lowercase); + match source.as_deref() { + Some("human" | "typed" | "user") => Some("human"), + Some("quick_reply" | "quick-reply") => Some("quick_reply"), + Some("task_notification" | "task-notification") => Some("task_notification"), + Some("scheduled" | "scheduled_wake" | "scheduled-wake") => Some("scheduled_wake"), + _ if payload + .get("prompt") + .and_then(Value::as_str) + .is_some_and(|prompt| prompt.trim_start().starts_with(" + { + Some("task_notification") + } + _ => None, + } +} + #[cfg(test)] mod tests { use super::{ diff --git a/bt-daemon/src/translate/pi.rs b/bt-daemon/src/translate/pi.rs index aea46afa..196b1abf 100644 --- a/bt-daemon/src/translate/pi.rs +++ b/bt-daemon/src/translate/pi.rs @@ -25,6 +25,8 @@ impl TranslatorFactory for crate::agents::Pi { external_parent: None, opened: false, legacy_root_adopted: false, + root_model: None, + root_cwd: None, turn: None, turn_output: None, last_turn_output: None, @@ -329,6 +331,8 @@ struct PiTranslator { // are initialization input only. Later reopen/replay events must retain // the daemon's accumulated counters and deterministic turn sequence. legacy_root_adopted: bool, + root_model: Option, + root_cwd: Option, /// The open turn's span id and the skills its prompt explicitly requested. turn: Option<(String, Vec)>, turn_output: Option, @@ -351,6 +355,9 @@ impl AgentTranslator for PiTranslator { self.last_ts = self.last_ts.max(envelope.ts_ms); let event = envelope.payload.get("event").unwrap_or(&envelope.payload); let mut ops = self.ensure_root(envelope, ctx); + if let Some(cwd) = envelope.payload.get("cwd").and_then(Value::as_str) { + self.root_cwd = Some(cwd.to_string()); + } match envelope.event.as_str() { "before_agent_start" => { if let Some(event) = decode(event) { @@ -359,7 +366,13 @@ impl AgentTranslator for PiTranslator { } "context" => { if let Some(event) = decode(event) { - let selected = envelope.payload.get("model").and_then(decode); + let selected = envelope + .payload + .get("model") + .and_then(decode::); + if let Some(model) = &selected { + self.root_model = Some(format!("{}/{}", model.provider, model.id)); + } self.capture_context(event, selected, envelope.ts_ms); } } @@ -446,6 +459,9 @@ impl AgentTranslator for PiTranslator { } _ => {} } + if let Some(update) = self.root_context_update() { + ops.push(update); + } let cwd = envelope.payload.get("cwd").and_then(Value::as_str); self.git.enrich_rows(cwd, &mut ops); Ok(ops) @@ -543,6 +559,19 @@ impl PiTranslator { metadata.insert("source".into(), json!("pi")); metadata.insert("_bt_defer_root".into(), json!(true)); metadata.insert("username".into(), json!(local_username())); + if let Some(cwd) = envelope.payload.get("cwd").and_then(Value::as_str) { + self.root_cwd = Some(cwd.to_string()); + metadata.insert("cwd".into(), json!(cwd)); + } + if let Some(model) = envelope + .payload + .get("model") + .and_then(decode::) + { + let model = format!("{}/{}", model.provider, model.id); + self.root_model = Some(model.clone()); + metadata.insert("model".into(), json!(model)); + } metadata.insert("pi_version".into(), json!(envelope.source_version)); metadata.insert( "extension_version".into(), @@ -581,6 +610,30 @@ impl PiTranslator { })] } + fn root_context_update(&self) -> Option { + if !self.opened + || self.legacy_root_adopted + || (self.root_model.is_none() && self.root_cwd.is_none()) + { + return None; + } + let mut metadata = Map::new(); + if let Some(model) = &self.root_model { + metadata.insert("model".into(), json!(model)); + } + if let Some(cwd) = &self.root_cwd { + metadata.insert("cwd".into(), json!(cwd)); + } + Some(SpanOp::Merge(SpanRow { + span_id: self.root_span_id.clone(), + root_span_id: self.effective_root_span_id.clone(), + parent_span_ids: self.external_parent.clone().into_iter().collect(), + input: Some(json!({"model": self.root_model, "cwd": self.root_cwd})), + metadata: Some(Value::Object(metadata)), + ..Default::default() + })) + } + fn start_turn(&mut self, event: BeforeAgentStart, ts: i64) -> Vec { let mut ops = self.close_turn(ts, None); self.turn_seq += 1; diff --git a/bt-daemon/tests/claude_translator.rs b/bt-daemon/tests/claude_translator.rs index 93c7fc39..fd328950 100644 --- a/bt-daemon/tests/claude_translator.rs +++ b/bt-daemon/tests/claude_translator.rs @@ -147,8 +147,13 @@ fn reduce(ops: Vec) -> HashMap { if update.output.is_some() { row.output = update.output; } - if update.metadata.is_some() { - row.metadata = update.metadata; + if let Some(update_metadata) = update.metadata { + match (row.metadata.as_mut(), update_metadata) { + (Some(Value::Object(existing)), Value::Object(update)) => { + existing.extend(update); + } + (_, update) => row.metadata = Some(update), + } } if update.error.is_some() { row.error = update.error; @@ -1115,7 +1120,7 @@ fn claude_pairs_tool_lifecycle_and_marks_explicit_skills_and_stop_failures() { event( "StopFailure", 40, - json!({"session_id":"lifecycle","message":"model process exited"}), + json!({"session_id":"lifecycle","message":"provider stream interrupted unexpectedly"}), ), ] { ops.extend(translator.handle(&envelope, &ctx).unwrap()); @@ -1133,7 +1138,81 @@ fn claude_pairs_tool_lifecycle_and_marks_explicit_skills_and_stop_failures() { json!("explicit") ); let turn = rows.values().find(|row| row.name == "Turn 1").unwrap(); - assert_eq!(turn.error.as_deref(), Some("model process exited")); + assert_eq!( + turn.error.as_deref(), + Some("provider stream interrupted unexpectedly") + ); + assert_eq!(turn.metadata.as_ref().unwrap()["outcome"], "errored"); +} + +#[test] +fn claude_post_compact_starts_a_root_when_it_is_the_first_trace_event() { + let mut translator = Registry::default_agents().create("claude-code", "compact-only"); + let ctx = SessionCtx { + session_id: "compact-only".into(), + config: None, + }; + let ops = translator + .handle( + &claude_event( + "compact-only", + "PostCompact", + 10, + json!({"cwd":"/workspace/demo"}), + ), + &ctx, + ) + .unwrap(); + + let rows = reduce(ops); + let root = rows + .values() + .find(|row| row.name == "Claude Code: demo") + .expect("PostCompact creates the session root"); + let compaction = rows + .values() + .find(|row| row.name == "Compaction") + .expect("PostCompact creates a compaction span"); + assert_eq!(compaction.root_span_id, root.root_span_id); + assert_eq!(compaction.parent_span_ids.first(), Some(&root.span_id)); +} + +#[test] +fn claude_user_cancellation_reason_is_not_recorded_as_an_error() { + let mut translator = Registry::default_agents().create("claude-code", "user-cancel"); + let ctx = SessionCtx { + session_id: "user-cancel".into(), + config: None, + }; + let mut ops = translator + .handle( + &claude_event( + "user-cancel", + "UserPromptSubmit", + 10, + json!({"prompt":"go"}), + ), + &ctx, + ) + .unwrap(); + ops.extend( + translator + .handle( + &claude_event( + "user-cancel", + "StopFailure", + 20, + json!({"message":"Request interrupted by user"}), + ), + &ctx, + ) + .unwrap(), + ); + + let rows = reduce(ops); + let turn = rows.values().find(|row| row.name == "Turn 1").unwrap(); + assert_eq!(turn.error, None); + assert_eq!(turn.metadata.as_ref().unwrap()["outcome"], "interrupted"); } /// A PreToolUse hook may rewrite a call's arguments with `updatedInput`, and @@ -2254,6 +2333,15 @@ fn claude_post_compact_replaces_old_prefix_and_preserves_recent_window() { } let rows = reduce(ops); + let turn = rows.values().find(|row| row.name == "Turn 1").unwrap(); + let compaction = rows + .values() + .find(|row| row.name == "Compaction") + .expect("PostCompact emits a compaction span"); + assert_eq!( + compaction.turn_span_id.as_deref(), + Some(turn.span_id.as_str()) + ); let llm = rows .values() .find(|row| {