Skip to content
Open
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
64 changes: 55 additions & 9 deletions bt-daemon/src/translate/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ struct HookContext {
model: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
permission_mode: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_effort")]
effort: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_string")]
system_prompt: Option<String>,
}
Expand Down Expand Up @@ -85,6 +87,17 @@ where
.and_then(value_as_nonempty_string))
}

/// Claude hooks report effort as `{"level": "high"}`.
fn deserialize_optional_effort<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<Value>::deserialize(deserializer)?
.as_ref()
.and_then(|effort| effort.get("level"))
.and_then(value_as_nonempty_string))
}

fn deserialize_nonempty_string<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
Expand Down Expand Up @@ -133,6 +146,7 @@ struct Turn {
cwd: Option<String>,
parent_id: String,
user_turn_id: Option<String>,
effort: Option<String>,
}

struct PendingPrompt {
Expand Down Expand Up @@ -287,6 +301,7 @@ struct ClaudeTranslator {
session_source: Option<String>,
session_model: Option<String>,
permission_mode: Option<String>,
effort: Option<String>,
system_prompt: Option<String>,
git: Arc<GitMetadataCache>,
current_cwd: Option<String>,
Expand Down Expand Up @@ -334,6 +349,7 @@ impl ClaudeTranslator {
session_source: None,
session_model: None,
permission_mode: None,
effort: None,
system_prompt: None,
git,
current_cwd: None,
Expand Down Expand Up @@ -427,6 +443,9 @@ impl ClaudeTranslator {
if let Some(permission_mode) = &hook.permission_mode {
self.permission_mode = Some(permission_mode.clone());
}
if let Some(effort) = &hook.effort {
self.effort = Some(effort.clone());
Comment on lines +446 to +447

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the active turn when effort first appears

Claude does not consistently include effort on UserPromptSubmit: for example, subagent-compact/events.ndjson line 2 omits it and the corresponding Stop on line 4 reports high. Because open_turn snapshots self.effort only when the turn opens, this assignment merely caches the late value for the next turn and never adds it to the turn that produced the hook. Consequently, the first turn loses known effort metadata, and after an effort change a later turn can retain the previous level until another turn opens; merge newly observed effort into the currently active turn as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 8e34e04. The recorded fixtures confirm that UserPromptSubmit never carries effort; it only arrives on later hooks. Each turn now tracks the level it has recorded, and a newly observed level is merged into the active turn (record_turn_effort). The real-fixture test now asserts that every turn gets xhigh.

}
if let Some(system_prompt) = &hook.system_prompt {
self.system_prompt = Some(system_prompt.clone());
}
Expand Down Expand Up @@ -501,6 +520,9 @@ impl ClaudeTranslator {
if let Some(permission_mode) = &self.permission_mode {
metadata.insert("permission_mode".into(), json!(permission_mode));
}
if let Some(effort) = &self.effort {
metadata.insert("effort".into(), json!(effort));
}
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 @@ -540,9 +562,29 @@ impl ClaudeTranslator {
cwd: self.current_cwd.clone(),
parent_id,
user_turn_id,
effort: self.effort.clone(),
});
}

/// Claude reports effort on later hooks, not reliably on UserPromptSubmit.
/// Merge a newly observed level into the active turn.
fn record_turn_effort(&mut self, ops: &mut Vec<SpanOp>) {
let Some(turn) = self.turn.as_mut() else {
return;
};
if self.effort.is_none() || turn.effort == self.effort {
return;
}
turn.effort = self.effort.clone();
ops.push(SpanOp::Merge(SpanRow {
span_id: turn.id.clone(),
root_span_id: self.root_span_id.clone(),
parent_span_ids: vec![turn.parent_id.clone()],
metadata: Some(json!({ "effort": turn.effort })),
..Default::default()
}));
}

// A stopped foreground response can still own background work. Retain its
// causal identity and extend its duration without replacing its response.
fn extend_user_turn(&mut self, id: &String, end_ms: i64, ops: &mut Vec<SpanOp>) {
Expand Down Expand Up @@ -754,7 +796,14 @@ impl ClaudeTranslator {
let error = (approval != ToolApproval::Denied)
.then(|| forced_error.or_else(|| tool_error(&event.payload)))
.flatten();
let metadata = tool_metadata(event, &tool_name, &call_id, Some(approval), &input);
let mut metadata = tool_metadata(event, &tool_name, &call_id, Some(approval), &input);
let duration = event.payload.get("duration_ms").and_then(Value::as_i64);
if let Some(duration) = duration {
metadata["native_duration_ms"] = json!(duration);
}
if let Some(interrupted) = event.payload.get("is_interrupt").and_then(Value::as_bool) {
metadata["is_interrupt"] = json!(interrupted);
}
if let Some(pending) = self.pending_tools.remove(&call_id) {
ops.push(SpanOp::Merge(SpanRow {
span_id: pending.span_id,
Expand All @@ -770,19 +819,14 @@ impl ClaudeTranslator {
let Some(parent_id) = self.parent_for(event, ops) else {
return;
};
let duration = event
.payload
.get("duration_ms")
.and_then(Value::as_i64)
.unwrap_or(0);
ops.push(SpanOp::Insert(SpanRow {
span_id: ids::span_id(&self.session_id, &format!("tool:{call_id}")),
root_span_id: self.root_span_id.clone(),
turn_span_id: self.owner_of(&parent_id),
parent_span_ids: vec![parent_id],
name: tool_span_name(&tool_name, &input),
span_type: SpanType::Tool,
start_ms: Some(event.ts_ms.saturating_sub(duration)),
start_ms: Some(event.ts_ms.saturating_sub(duration.unwrap_or(0))),
end_ms: Some(event.ts_ms),
input: Some(input),
output,
Expand Down Expand Up @@ -955,8 +999,9 @@ impl ClaudeTranslator {
self.main_record_turn = None;
}

// Native hooks omit prompt_id. Match their prompts in submission order,
// then retain transcript identities so repeats cannot consume another turn.
// Hooks carry prompt_id only after the first user input, and older Claude
// versions omit it. Without it, match prompts in submission order, then
// retain transcript identities so repeats cannot consume another turn.
fn user_record_turn(&mut self, record: &Value) -> Option<Arc<String>> {
let prompt_id = record.get("promptId").and_then(Value::as_str);
let mut owner = prompt_id
Expand Down Expand Up @@ -1283,6 +1328,7 @@ impl AgentTranslator for ClaudeTranslator {
}));
}
}
self.record_turn_effort(&mut ops);
// Root creation moved below the passive-hook gate. Enrich it before
// handling the event so a later prompt can retain the prior turn's
// cwd while flushing its deferred transcript rows.
Expand Down
Loading
Loading