Skip to content

Commit 9264067

Browse files
committed
fix(hooks): retain completion-only observers
Always run turn_end observers for TurnComplete events that have no matching TurnStarted, including manual compaction, purge, and shell-only lifecycle completions. Give those records a unique lifecycle id, observed timestamp, and no route so they remain visible without inheriting stale model provenance or cost. Validation: CI-equivalent workspace/all-features clippy; completion-only and TurnStarted metadata regressions; turn_end payload regression.
1 parent 0ab0717 commit 9264067

5 files changed

Lines changed: 69 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
unpriced, and route-scoped cache and recorded-time pricing replace model-only
1616
guesses. StepFun PAYG and Step Plan usage now stay distinct without persisting
1717
raw endpoint URLs, so subscription quota is never reported as token spend
18-
(#4335). This builds on the scorecard introduced by @findshan in #3388.
18+
(#4335). Completion-only shell, manual-compaction, and purge events remain
19+
visible to `turn_end` observers as explicitly non-model lifecycle records.
20+
This builds on the scorecard introduced by @findshan in #3388.
1921

2022
## [0.8.68] - 2026-07-12
2123

crates/tui/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
unpriced, and route-scoped cache and recorded-time pricing replace model-only
1616
guesses. StepFun PAYG and Step Plan usage now stay distinct without persisting
1717
raw endpoint URLs, so subscription quota is never reported as token spend
18-
(#4335). This builds on the scorecard introduced by @findshan in #3388.
18+
(#4335). Completion-only shell, manual-compaction, and purge events remain
19+
visible to `turn_end` observers as explicitly non-model lifecycle records.
20+
This builds on the scorecard introduced by @findshan in #3388.
1921

2022
## [0.8.68] - 2026-07-12
2123

crates/tui/src/tui/ui.rs

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,7 +1222,7 @@ fn execute_subagent_observer_hook(
12221222

12231223
fn 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+
12651291
fn 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();

crates/tui/src/tui/ui/tests.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6718,6 +6718,25 @@ fn turn_started_route_is_captured_before_cancel_suppression() {
67186718
assert!(route.auto_model);
67196719
assert!(app.pending_turn_route.is_none());
67206720
assert!(app.ocean_completion_started_at.is_none());
6721+
6722+
let observer = turn_end_observer_metadata(Some(active_turn));
6723+
assert_eq!(observer.turn_id.as_ref(), "turn_cancel_race");
6724+
assert_eq!(observer.created_at, created_at);
6725+
assert_eq!(observer.route, Some(route));
6726+
}
6727+
6728+
#[test]
6729+
fn completion_only_hook_metadata_is_synthetic_and_non_model() {
6730+
let observed_after = chrono::Utc::now();
6731+
let first = turn_end_observer_metadata(None);
6732+
let second = turn_end_observer_metadata(None);
6733+
6734+
assert!(first.turn_id.starts_with("lifecycle_"));
6735+
assert!(second.turn_id.starts_with("lifecycle_"));
6736+
assert_ne!(first.turn_id, second.turn_id);
6737+
assert!(first.created_at >= observed_after);
6738+
assert!(first.route.is_none());
6739+
assert!(second.route.is_none());
67216740
}
67226741

67236742
#[test]

docs/CONFIGURATION.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1017,7 +1017,9 @@ usage. Unrecognized and custom endpoints remain `null` and unpriced.
10171017

10181018
Shell-only lifecycle completions set `model_backed` to `false` and may report a
10191019
`null` provider; offline scorecards exclude those records from model token and
1020-
cost totals.
1020+
cost totals. Completion-only shell, manual-compaction, and purge events that do
1021+
not have a matching `TurnStarted` retain the observer notification with a
1022+
synthetic `lifecycle_<uuid>` turn id and the time the completion was observed.
10211023

10221024
For `interrupted` or `failed` turns, `status` reflects that terminal
10231025
state and `error` carries the engine error string when one is available.

0 commit comments

Comments
 (0)