Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 107 additions & 11 deletions bt-daemon/src/translate/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ struct ClaudeTranslator {
current_cwd: Option<String>,
last_turn_cwd: Option<String>,
last_turn_output: Option<Value>,
session_status: Option<String>,
last_ts_ms: i64,
}

Expand Down Expand Up @@ -340,6 +341,7 @@ impl ClaudeTranslator {
current_cwd: None,
last_turn_cwd: None,
last_turn_output: None,
session_status: None,
last_ts_ms: 0,
}
}
Expand Down Expand Up @@ -444,12 +446,14 @@ impl ClaudeTranslator {
| "PostToolUse"
| "PostToolUseFailure"
| "PermissionDenied"
| "PostCompact"
| "SubagentStart"
| "SubagentStop"
)
}

fn open_turn(&mut self, event: &Envelope, ops: &mut Vec<SpanOp>) {
self.session_status = Some("running".into());
if let Some(old) = self.turn.take() {
self.close_pending_tools(
&old.id,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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})),
Comment thread
Qard marked this conversation as resolved.
late_merge_key: Some(format!("session:stop:{end_ms}")),
..Default::default()
}));
}

fn end_session(&mut self, event: &Envelope, ops: &mut Vec<SpanOp>) {
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()
Expand All @@ -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);
Expand Down Expand Up @@ -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::<SubagentHook>(&event.payload) {
Expand All @@ -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),
_ => {}
}
Expand All @@ -1371,6 +1416,9 @@ impl AgentTranslator for ClaudeTranslator {

fn finalize(&mut self, _ctx: &SessionCtx) -> anyhow::Result<Vec<SpanOp>> {
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
Expand Down Expand Up @@ -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()
}));
}
Expand Down Expand Up @@ -2476,6 +2528,50 @@ fn tool_failure_text(payload: &Value) -> Option<String> {
.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("<task-notification")) =>
{
Some("task_notification")
}
_ => None,
}
}

#[cfg(test)]
mod tests {
use super::{
Expand Down
55 changes: 54 additions & 1 deletion bt-daemon/src/translate/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>,
root_cwd: Option<String>,
/// The open turn's span id and the skills its prompt explicitly requested.
turn: Option<(String, Vec<String>)>,
turn_output: Option<Value>,
Expand All @@ -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) {
Expand All @@ -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::<SelectedModel>);
if let Some(model) = &selected {
self.root_model = Some(format!("{}/{}", model.provider, model.id));
}
self.capture_context(event, selected, envelope.ts_ms);
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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::<SelectedModel>)
{
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(),
Expand Down Expand Up @@ -581,6 +610,30 @@ impl PiTranslator {
})]
}

fn root_context_update(&self) -> Option<SpanOp> {
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<SpanOp> {
let mut ops = self.close_turn(ts, None);
self.turn_seq += 1;
Expand Down
Loading
Loading