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
47 changes: 43 additions & 4 deletions bt-daemon/src/translate/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ struct ClaudeTranslator {
pending_emission: Option<PendingEmission>,
claude_version: Option<String>,
claude_version_logged: bool,
session_source: Option<String>,
session_model: Option<String>,
git: Arc<GitMetadataCache>,
current_cwd: Option<String>,
last_turn_cwd: Option<String>,
Expand Down Expand Up @@ -198,6 +200,8 @@ impl ClaudeTranslator {
pending_emission: None,
claude_version: None,
claude_version_logged: false,
session_source: None,
session_model: None,
git,
current_cwd: None,
last_turn_cwd: None,
Expand Down Expand Up @@ -244,10 +248,14 @@ impl ClaudeTranslator {
if let Some(version) = &self.claude_version {
metadata.insert("claude_code_version".into(), json!(version));
}
if let Some(source) = string_field(&event.payload, "source") {
if let Some(source) =
string_field(&event.payload, "source").or_else(|| self.session_source.clone())
{
metadata.insert("session_source".into(), json!(source));
}
if let Some(model) = string_field(&event.payload, "model") {
if let Some(model) =
string_field(&event.payload, "model").or_else(|| self.session_model.clone())
{
metadata.insert("model".into(), json!(model));
}
ops.push(SpanOp::Insert(SpanRow {
Expand Down Expand Up @@ -278,6 +286,31 @@ impl ClaudeTranslator {
cursor.buffered.extend(rows);
}

fn observe_session_details(&mut self, event: &Envelope) {
if let Some(source) = string_field(&event.payload, "source") {
self.session_source = Some(source);
}
if let Some(model) = string_field(&event.payload, "model") {
self.session_model = Some(model);
}
}

/// Passive Claude hooks must not create a trace. In particular, Claude
/// emits idle notifications after a completed turn, and it may start or
/// resume a session long before the user submits a prompt.
fn starts_trace(event: &Envelope) -> bool {
matches!(
event.event.as_str(),
"UserPromptSubmit"
| "PreToolUse"
| "PostToolUse"
| "PostToolUseFailure"
| "PermissionDenied"
| "SubagentStart"
| "SubagentStop"
)
}

fn open_turn(&mut self, event: &Envelope, ops: &mut Vec<SpanOp>) {
if let Some(old) = self.turn.take() {
self.close_pending_tools(
Expand Down Expand Up @@ -786,8 +819,11 @@ impl AgentTranslator for ClaudeTranslator {
self.current_cwd = Some(cwd);
}
self.tail_main(event);
self.ensure_root(event, ctx, &mut ops);
if !self.claude_version_logged {
self.observe_session_details(event);
if Self::starts_trace(event) {
self.ensure_root(event, ctx, &mut ops);
}
if self.root_open && !self.claude_version_logged {
if let Some(version) = &self.claude_version {
self.claude_version_logged = true;
ops.push(SpanOp::Merge(SpanRow {
Expand All @@ -798,6 +834,9 @@ impl AgentTranslator for ClaudeTranslator {
}));
}
}
// 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.
self.git.enrich_rows(self.current_cwd.as_deref(), &mut ops);
let mut event_op_start = ops.len();
match event.event.as_str() {
Expand Down
67 changes: 67 additions & 0 deletions bt-daemon/tests/claude_translator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,73 @@ fn claude_additional_metadata_reaches_roots_without_overriding_session_fields()
assert_eq!(root.metadata.as_ref().unwrap()["source"], "claude-code");
}

#[test]
fn claude_passive_hooks_do_not_create_blank_session_traces() {
let registry = Registry::default_agents();
let mut translator = registry.create("claude-code", "idle-session");
let ctx = SessionCtx {
session_id: "idle-session".into(),
config: None,
};
let event = |name: &str, ts_ms: i64, payload: Value| Envelope {
source: "claude-code".into(),
source_version: None,
plugin_version: None,
session_id: "idle-session".into(),
event: name.into(),
ts_ms,
managed_run_id: None,
payload,
route: None,
config: None,
capture: None,
};

let mut ops = Vec::new();
for event in [
event(
"SessionStart",
1,
json!({"cwd":"/workspace/demo", "source":"resume", "model":"claude-test"}),
),
event(
"Notification",
2,
json!({"cwd":"/workspace/demo", "notification_type":"idle_prompt"}),
),
event(
"TeammateIdle",
3,
json!({"cwd":"/workspace/demo", "teammate_name":"researcher"}),
),
event("SessionEnd", 4, json!({"cwd":"/workspace/demo"})),
] {
ops.extend(translator.handle(&event, &ctx).unwrap());
}
assert!(ops.is_empty(), "passive Claude hooks must not emit a trace");

let ops = translator
.handle(
&event(
"UserPromptSubmit",
5,
json!({"cwd":"/workspace/demo", "prompt":"trace this"}),
),
&ctx,
)
.unwrap();
let root = ops
.into_iter()
.find_map(|op| match op {
SpanOp::Insert(row) if row.name == "Claude Code: demo" => Some(row),
_ => None,
})
.expect("a user prompt starts a trace");
let metadata = root.metadata.as_ref().unwrap();
assert_eq!(metadata["session_source"], "resume");
assert_eq!(metadata["model"], "claude-test");
}

#[test]
fn claude_subagent_fixture_builds_nested_subagent_llms() {
let rows = reduce(replay("subagent-compact"));
Expand Down
7 changes: 4 additions & 3 deletions bt-daemon/tests/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1167,12 +1167,13 @@ async fn identical_native_ids_from_different_sources_are_isolated() {
"source": "startup",
"permission_mode": "auto"
});
let mut claude = envelope("shared-native-id", "SessionStart", 2);
let mut claude = envelope("shared-native-id", "UserPromptSubmit", 2);
claude.source = "claude".into();
claude.payload = serde_json::json!({
"session_id": "shared-native-id",
"hook_event_name": "SessionStart",
"cwd": "/workspace/claude"
"hook_event_name": "UserPromptSubmit",
"cwd": "/workspace/claude",
"prompt": "trace this"
});

forward_envelope(&codex, &socket, &host, false)
Expand Down
Loading
Loading