@@ -1222,7 +1222,7 @@ fn execute_subagent_observer_hook(
12221222
12231223fn execute_turn_end_observer_hook(
12241224 app: &App,
1225- turn: &ActiveTurnMetadata,
1225+ turn: Option< &ActiveTurnMetadata> ,
12261226 usage: &Usage,
12271227 billing_surface: Option<&str>,
12281228 duration: Duration,
@@ -1232,15 +1232,16 @@ fn execute_turn_end_observer_hook(
12321232 return;
12331233 }
12341234
1235+ let metadata = turn_end_observer_metadata(turn);
12351236 let context = app.base_hook_context();
12361237 let payload = crate::hooks::turn_end_payload(TurnEndPayloadInput {
12371238 context: &context,
1238- created_at: turn .created_at,
1239- model_backed: turn .route.is_some(),
1240- provider: turn .route.as_ref() .map(|route| route.provider.as_str()),
1241- billing_surface,
1242- model: turn .route.as_ref() .map(|route| route.model.as_str()),
1243- turn_id: &turn .turn_id,
1239+ created_at: metadata .created_at,
1240+ model_backed: metadata .route.is_some(),
1241+ provider: metadata .route.map(|route| route.provider.as_str()),
1242+ billing_surface: metadata.route.and(billing_surface) ,
1243+ model: metadata .route.map(|route| route.model.as_str()),
1244+ turn_id: metadata .turn_id.as_ref() ,
12441245 status: app.runtime_turn_status.as_deref().unwrap_or("unknown"),
12451246 error,
12461247 duration,
@@ -1262,6 +1263,31 @@ fn execute_turn_end_observer_hook(
12621263 });
12631264}
12641265
1266+ struct TurnEndObserverMetadata<'a> {
1267+ turn_id: std::borrow::Cow<'a, str>,
1268+ created_at: chrono::DateTime<chrono::Utc>,
1269+ route: Option<&'a crate::core::events::TurnRoute>,
1270+ }
1271+
1272+ fn turn_end_observer_metadata(turn: Option<&ActiveTurnMetadata>) -> TurnEndObserverMetadata<'_> {
1273+ turn.map_or_else(
1274+ || TurnEndObserverMetadata {
1275+ // Manual compaction, purge, and shell-only completions predate the
1276+ // TurnStarted lifecycle event. Preserve their observer contract
1277+ // with a distinct non-model identity instead of borrowing a stale
1278+ // model turn id.
1279+ turn_id: std::borrow::Cow::Owned(format!("lifecycle_{}", uuid::Uuid::new_v4())),
1280+ created_at: chrono::Utc::now(),
1281+ route: None,
1282+ },
1283+ |turn| TurnEndObserverMetadata {
1284+ turn_id: std::borrow::Cow::Borrowed(&turn.turn_id),
1285+ created_at: turn.created_at,
1286+ route: turn.route.as_ref(),
1287+ },
1288+ )
1289+ }
1290+
12651291fn bounded_subagent_hook_preview(text: &str) -> (String, bool) {
12661292 if text.len() <= SUBAGENT_HOOK_PREVIEW_LIMIT {
12671293 return (text.to_string(), false);
@@ -2950,16 +2976,14 @@ async fn run_event_loop(
29502976 }
29512977 }
29522978
2953- if let Some(completed_turn) = completed_turn.as_ref() {
2954- execute_turn_end_observer_hook(
2955- app,
2956- completed_turn,
2957- &usage,
2958- billing_surface,
2959- turn_elapsed,
2960- error.as_deref(),
2961- );
2962- }
2979+ execute_turn_end_observer_hook(
2980+ app,
2981+ completed_turn.as_ref(),
2982+ &usage,
2983+ billing_surface,
2984+ turn_elapsed,
2985+ error.as_deref(),
2986+ );
29632987
29642988 if queued_to_send.is_none() {
29652989 queued_to_send = app.pop_queued_message();
0 commit comments