Skip to main content

lash_core/runtime/
turn_loop.rs

1#[cfg(test)]
2use super::logical_turn::agent_frame_follow_turn_id;
3use super::logical_turn::{
4    LogicalTurnClaims, LogicalTurnStart, PhysicalTurnExecution, PreparedLogicalTurn,
5};
6use super::*;
7
8fn trace_fields_from_outcome(
9    outcome: &TurnOutcome,
10) -> (
11    &'static str,
12    &'static str,
13    Option<lash_trace::TraceAgentFrameSwitch>,
14) {
15    match outcome {
16        TurnOutcome::Finished(TurnFinish::AssistantMessage { .. }) => {
17            ("completed", "assistant_message", None)
18        }
19        TurnOutcome::Finished(TurnFinish::FinalValue { .. }) => ("completed", "final_value", None),
20        TurnOutcome::Finished(TurnFinish::ToolValue { .. }) => ("completed", "tool_value", None),
21        TurnOutcome::AgentFrameSwitch { frame_id, .. } => (
22            "completed",
23            "agent_frame_switch",
24            Some(lash_trace::TraceAgentFrameSwitch {
25                frame_id: frame_id.clone(),
26            }),
27        ),
28        TurnOutcome::Stopped(stop) => ("failed", trace_stop_reason(stop), None),
29    }
30}
31
32fn trace_stop_reason(stop: &TurnStop) -> &'static str {
33    match stop {
34        TurnStop::Cancelled => "cancelled",
35        TurnStop::Incomplete => "incomplete",
36        TurnStop::InvalidInput => "invalid_input",
37        TurnStop::MaxTurns => "max_turns",
38        TurnStop::ToolFailure => "tool_failure",
39        TurnStop::ProviderError => "provider_error",
40        TurnStop::PluginAbort => "plugin_abort",
41        TurnStop::RuntimeError => "runtime_error",
42        TurnStop::SubmittedError { .. } => "submitted_error",
43        TurnStop::ToolError { .. } => "tool_error",
44    }
45}
46
47fn session_head_refresh_error(err: SessionError) -> RuntimeError {
48    RuntimeError::new(
49        RuntimeErrorCode::Other("session_head_refresh".to_string()),
50        err.to_string(),
51    )
52}
53
54#[derive(Clone, Copy)]
55pub(super) enum SessionExecutionLeaseReleasePolicy {
56    KeepOnAgentFrameSwitch,
57}
58
59impl SessionExecutionLeaseReleasePolicy {
60    fn should_release(self, outcome: &TurnOutcome) -> bool {
61        match self {
62            Self::KeepOnAgentFrameSwitch => {
63                !matches!(outcome, TurnOutcome::AgentFrameSwitch { .. })
64            }
65        }
66    }
67}
68
69fn queued_work_payload_type(payload: &crate::QueuedWorkPayload) -> &'static str {
70    match payload {
71        crate::QueuedWorkPayload::ProcessWake { .. } => "process_wake",
72        crate::QueuedWorkPayload::AgentFrameTask { .. } => "agent_frame_task",
73        crate::QueuedWorkPayload::SessionCommand { command } => command.kind(),
74    }
75}
76
77fn queued_work_batch_ids(claim: &crate::QueuedWorkClaim) -> Vec<String> {
78    claim
79        .batches
80        .iter()
81        .map(|batch| batch.batch_id.clone())
82        .collect()
83}
84
85/// Measures the whole host-visible turn.
86///
87/// Opened before the runtime claims the turn (session-execution lease and
88/// queued-work/turn-input claims) and stamped onto the assembled turn after
89/// the final commit and post-persist hooks complete, so
90/// [`ExecutionSummary`](crate::ExecutionSummary) timing covers
91/// claim → final commit. Reads only the injected [`Clock`](crate::Clock):
92/// `started_at_ms` comes from the wall-clock source and the duration from the
93/// monotonic source, so deterministic clocks produce deterministic timing.
94#[derive(Clone, Copy)]
95pub(super) struct TurnStopwatch {
96    started: std::time::Instant,
97    started_at_ms: u64,
98}
99
100impl TurnStopwatch {
101    pub(super) fn start(clock: &dyn crate::Clock) -> Self {
102        Self {
103            started: clock.now(),
104            started_at_ms: clock.timestamp_ms(),
105        }
106    }
107
108    pub(super) fn stamp(&self, turn: &mut AssembledTurn, clock: &dyn crate::Clock) {
109        turn.execution.started_at_ms = self.started_at_ms;
110        turn.execution.duration_ms = clock
111            .now()
112            .saturating_duration_since(self.started)
113            .as_millis() as u64;
114    }
115}
116
117fn turn_phase_id(parent_turn_id: &str, phase: &str) -> String {
118    format!("{parent_turn_id}:{phase}")
119}
120
121fn scoped_child_turn_controller<'run>(
122    scoped_effect_controller: &'run ScopedEffectController<'_>,
123    session_id: &str,
124    turn_id: &str,
125) -> Result<ScopedEffectController<'run>, RuntimeError> {
126    ScopedEffectController::borrowed(
127        scoped_effect_controller.controller(),
128        ExecutionScope::turn(session_id, turn_id),
129    )
130}
131
132pub(in crate::runtime) fn queued_work_trace_payload(
133    boundary: crate::QueuedWorkClaimBoundary,
134    claim: &crate::QueuedWorkClaim,
135    causes: &[crate::TurnCause],
136) -> serde_json::Value {
137    serde_json::json!({
138        "boundary": boundary,
139        "claim_id": claim.claim_id,
140        "owner_id": claim.owner.owner_id,
141        "incarnation_id": claim.owner.incarnation_id,
142        "batch_ids": queued_work_batch_ids(claim),
143        "payload_types": claim.batches.iter()
144            .flat_map(|batch| batch.items.iter())
145            .map(|item| queued_work_payload_type(&item.payload))
146            .collect::<Vec<_>>(),
147        "causes": causes,
148    })
149}
150
151pub(in crate::runtime) fn queued_work_completion_trace_payload(
152    completions: &[crate::QueuedWorkCompletion],
153) -> serde_json::Value {
154    serde_json::json!({
155        "claims": completions.iter().map(|completion| {
156            serde_json::json!({
157                "session_id": completion.session_id,
158                "claim_id": completion.claim_id,
159                "batch_ids": completion.batch_ids,
160            })
161        }).collect::<Vec<_>>(),
162    })
163}
164
165pub(in crate::runtime) fn turn_input_completion_trace_payload(
166    completions: &[crate::TurnInputCompletion],
167) -> serde_json::Value {
168    serde_json::json!({
169        "claims": completions.iter().map(|completion| {
170            serde_json::json!({
171                "session_id": completion.session_id,
172                "claim_id": completion.claim_id,
173                "input_ids": completion.input_ids,
174            })
175        }).collect::<Vec<_>>(),
176    })
177}
178
179async fn emit_queued_work_started_to_sink(
180    events: &dyn TurnActivitySink,
181    boundary: crate::QueuedWorkClaimBoundary,
182    claim: &crate::QueuedWorkClaim,
183    causes: Vec<crate::TurnCause>,
184) {
185    emit_turn_activity_to_sink(
186        events,
187        TurnActivity::independent(TurnEvent::QueuedWorkStarted {
188            boundary,
189            batch_ids: queued_work_batch_ids(claim),
190            causes,
191        }),
192    )
193    .await;
194}
195
196pub(in crate::runtime) async fn send_queued_work_started_event(
197    event_tx: &mpsc::Sender<RuntimeStreamEvent>,
198    boundary: crate::QueuedWorkClaimBoundary,
199    claim: &crate::QueuedWorkClaim,
200    causes: Vec<crate::TurnCause>,
201) {
202    send_turn_activity(
203        event_tx,
204        TurnActivityId::fresh(),
205        TurnEvent::QueuedWorkStarted {
206            boundary,
207            batch_ids: queued_work_batch_ids(claim),
208            causes,
209        },
210    )
211    .await;
212}
213
214struct TurnFinishInput {
215    turn_pipeline: TurnBoundary,
216    assembler: TurnAssembler,
217    new_messages: crate::MessageSequence,
218    policy: RuntimeSessionPolicy,
219    turn_index: usize,
220    queued_work_claims: Vec<crate::QueuedWorkClaim>,
221    turn_input_claims: Vec<crate::TurnInputClaim>,
222    trace_turn_id: String,
223}
224
225impl LashRuntime {
226    fn max_context_tokens(&self) -> usize {
227        self.state.effective_policy().context_window_tokens()
228    }
229
230    async fn claim_session_execution_lease(
231        &self,
232        cancel: CancellationToken,
233        busy_is_error: bool,
234    ) -> Result<Option<SessionExecutionLeaseGuard>, RuntimeError> {
235        let Some(store) = self
236            .session
237            .as_ref()
238            .and_then(|session| session.history_store())
239        else {
240            return Ok(None);
241        };
242        match SessionExecutionLeaseGuard::try_acquire(
243            store,
244            &self.state.session_id,
245            &self.runtime_lease_owner,
246            self.host.core.control.lease_timings,
247            Arc::clone(&self.host.core.clock),
248            cancel,
249        )
250        .await
251        .map_err(|err| RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string()))?
252        {
253            Some(lease) => Ok(Some(lease)),
254            None if busy_is_error => Err(RuntimeError::new(
255                RuntimeErrorCode::SessionExecutionBusy,
256                format!(
257                    "session `{}` is already executing on another runtime owner",
258                    self.state.session_id
259                ),
260            )),
261            None => Ok(None),
262        }
263    }
264
265    async fn settle_session_execution_lease<T>(
266        &self,
267        guard: Option<&SessionExecutionLeaseGuard>,
268        result: Result<T, RuntimeError>,
269    ) -> Result<T, RuntimeError> {
270        match result {
271            Ok(value) => {
272                if let Some(guard) = guard {
273                    guard.release_if_live().await.map_err(|err| {
274                        RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string())
275                    })?;
276                }
277                Ok(value)
278            }
279            Err(err) => {
280                if err.code != RuntimeErrorCode::StoreCommitFailed
281                    && let Some(guard) = guard
282                    && let Err(release_err) = guard.release_if_live().await
283                {
284                    tracing::warn!(
285                        error = %release_err,
286                        "failed to release session execution lease after runtime error"
287                    );
288                }
289                Err(err)
290            }
291        }
292    }
293
294    async fn ensure_session_execution_lease_live(
295        &self,
296        guard: Option<&SessionExecutionLeaseGuard>,
297    ) -> Result<(), RuntimeError> {
298        let Some(guard) = guard else {
299            return Ok(());
300        };
301        guard.refresh_or_mark_lost().await.map_err(|err| {
302            RuntimeError::new(
303                RuntimeErrorCode::SessionExecutionLeaseLost,
304                format!(
305                    "session execution lease for session `{}` was lost before commit: {err}",
306                    self.state.session_id
307                ),
308            )
309        })
310    }
311
312    // Prompt handback on lease loss. This is no longer load-bearing for
313    // correctness: a claim is generation-fenced under the session lease, so once
314    // this owner has lost the lease its claims are already superseded and the
315    // next acquirer reclaims them by generation regardless (ADR 0029). Abandoning
316    // eagerly just lets a peer reclaim the rows without waiting to observe the
317    // generation bump.
318    async fn abandon_queued_work_claims_after_lease_loss(
319        &self,
320        err: &RuntimeError,
321        claims: &[crate::QueuedWorkClaim],
322    ) {
323        if err.code != RuntimeErrorCode::SessionExecutionLeaseLost || claims.is_empty() {
324            return;
325        }
326        let Some(store) = self
327            .session
328            .as_ref()
329            .and_then(|session| session.history_store())
330        else {
331            return;
332        };
333        for claim in claims {
334            if let Err(abandon_err) = store.abandon_queued_work_claim(claim).await {
335                tracing::warn!(
336                    error = %abandon_err,
337                    session_id = %claim.session_id,
338                    claim_id = %claim.claim_id,
339                    "failed to abandon queued work claim after session execution lease loss"
340                );
341            }
342        }
343    }
344
345    async fn abandon_turn_input_claims_after_lease_loss(
346        &self,
347        err: &RuntimeError,
348        claims: &[crate::TurnInputClaim],
349    ) {
350        if err.code != RuntimeErrorCode::SessionExecutionLeaseLost || claims.is_empty() {
351            return;
352        }
353        let Some(store) = self
354            .session
355            .as_ref()
356            .and_then(|session| session.history_store())
357        else {
358            return;
359        };
360        for claim in claims {
361            if let Err(abandon_err) = store.abandon_turn_input_claim(claim).await {
362                tracing::warn!(
363                    error = %abandon_err,
364                    session_id = %claim.session_id,
365                    claim_id = %claim.claim_id,
366                    "failed to abandon turn input claim after session execution lease loss"
367                );
368            }
369        }
370    }
371
372    #[doc(hidden)]
373    pub fn set_turn_phase_probe(&mut self, probe: Arc<dyn RuntimeTurnPhaseProbe>) {
374        self.turn_phase_probe = Some(probe);
375    }
376
377    fn mark_phase_begin(&self, phase: RuntimeTurnPhase) {
378        if let Some(probe) = self.turn_phase_probe.as_ref() {
379            probe.begin(phase);
380        }
381    }
382
383    fn mark_phase_end(&self, phase: RuntimeTurnPhase) {
384        if let Some(probe) = self.turn_phase_probe.as_ref() {
385            probe.end(phase);
386        }
387    }
388
389    async fn finish_turn(
390        &mut self,
391        finish: TurnFinishInput,
392        events: &dyn EventSink,
393        scoped_effect_controller: &ScopedEffectController<'_>,
394        cancel_state: &CancellationToken,
395        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
396        session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
397    ) -> Result<PhysicalTurnExecution, RuntimeError> {
398        let TurnFinishInput {
399            mut turn_pipeline,
400            assembler,
401            new_messages,
402            policy,
403            turn_index,
404            queued_work_claims,
405            turn_input_claims,
406            trace_turn_id,
407        } = finish;
408        self.policy = self.state.effective_policy().clone();
409        turn_pipeline.state_mut().policy = self.policy.clone();
410        turn_pipeline.state_mut().turn_index = turn_index;
411
412        let mut turn_usage_delta = {
413            let mut ledger = self.shared_token_ledger.lock().expect("token ledger lock");
414            std::mem::take(&mut *ledger)
415        };
416        if assembler.token_usage.total() > 0 {
417            turn_usage_delta.push(TokenLedgerEntry {
418                source: "turn".to_string(),
419                model: policy.model.id.clone(),
420                usage: assembler.token_usage.clone(),
421            });
422        }
423        let turn_usage_delta = merge_usage_delta_entries(turn_usage_delta);
424
425        turn_pipeline.finalize_turn_read_state(new_messages, cancel_state.is_cancelled());
426        if assembler.token_usage.total() > 0 {
427            turn_pipeline.state_mut().token_usage = assembler.token_usage.clone();
428        }
429
430        let last_prompt_usage = assembler.last_llm_usage().and_then(normalize_prompt_usage);
431        turn_pipeline.state_mut().last_prompt_usage = last_prompt_usage;
432        let assembled_state = turn_pipeline.export_state_for_assembly();
433        let assembled = assembler.finish(
434            assembled_state,
435            cancel_state.is_cancelled(),
436            None,
437            &self.host.core.control.termination,
438        );
439
440        let Some(session) = self.session.as_ref() else {
441            self.state.apply_snapshot(&assembled.state);
442            self.emit_completed_turn_trace(&assembled.state, &assembled.outcome, &trace_turn_id);
443            return Ok(PhysicalTurnExecution {
444                turn: assembled,
445                enqueued_queue_batches: Vec::new(),
446            });
447        };
448        self.ensure_session_execution_lease_live(session_execution_lease)
449            .await?;
450
451        let plugins = Arc::clone(session.plugins());
452        let manager = match self.runtime_session_services_for_turn(None) {
453            Ok(manager) => manager,
454            Err(err) => {
455                return Err(RuntimeError::new(
456                    RuntimeErrorCode::PluginSessionManager,
457                    err.to_string(),
458                ));
459            }
460        };
461
462        self.mark_phase_begin(RuntimeTurnPhase::FinalizeTurn);
463        let finalized = match plugins
464            .finalize_turn_with_phase_probe(
465                assembled,
466                manager.state_service(),
467                manager.lifecycle_service(),
468                manager.graph_service(),
469                self.turn_phase_probe.clone(),
470            )
471            .await
472        {
473            Ok(finalized) => finalized,
474            Err(err) => {
475                self.mark_phase_end(RuntimeTurnPhase::FinalizeTurn);
476                return Err(RuntimeError::new(
477                    RuntimeErrorCode::PluginFinalizeTurn,
478                    err.to_string(),
479                ));
480            }
481        };
482        self.mark_phase_end(RuntimeTurnPhase::FinalizeTurn);
483        self.ensure_session_execution_lease_live(session_execution_lease)
484            .await?;
485
486        let mut returned_turn = finalized.turn;
487        let release_session_execution_lease =
488            session_execution_lease_release_policy.should_release(&returned_turn.outcome);
489        let commit_effects = LogicalTurnClaims::new(queued_work_claims, turn_input_claims)
490            .into_commit_effects(
491                &returned_turn.outcome,
492                &self.state.session_id,
493                &trace_turn_id,
494                Some(self.state.effective_protocol_turn_options().clone()),
495            );
496        self.mark_phase_begin(RuntimeTurnPhase::PersistTurn);
497        self.mark_phase_begin(RuntimeTurnPhase::FinalCommit);
498        let queued_work_completion_trace = commit_effects.completed_queue_claims.clone();
499        let turn_input_completion_trace = commit_effects.completed_turn_input_claims.clone();
500        let pending_attachment_ids = self
501            .host
502            .core
503            .durability
504            .attachment_store
505            .pending_manifest_commit_ids();
506        let enqueued_queue_batches = match turn_pipeline
507            .final_commit(
508                &mut returned_turn,
509                self.session.as_mut(),
510                &turn_usage_delta,
511                Some(&trace_turn_id),
512                commit_effects.originating_queue_claims,
513                commit_effects.originating_turn_input_claims,
514                commit_effects.completed_queue_claims,
515                commit_effects.completed_turn_input_claims,
516                commit_effects.enqueued_queue_batches,
517                cancel_state.is_cancelled().then(|| trace_turn_id.clone()),
518                pending_attachment_ids.clone(),
519                release_session_execution_lease
520                    .then(|| session_execution_lease.map(SessionExecutionLeaseGuard::completion))
521                    .flatten(),
522            )
523            .await
524        {
525            Ok(batches) => batches,
526            Err(err) => {
527                self.mark_phase_end(RuntimeTurnPhase::FinalCommit);
528                self.mark_phase_end(RuntimeTurnPhase::PersistTurn);
529                return Err(err);
530            }
531        };
532        if release_session_execution_lease && let Some(lease) = session_execution_lease {
533            lease.mark_released();
534        }
535        self.host
536            .core
537            .durability
538            .attachment_store
539            .mark_manifest_committed(&pending_attachment_ids);
540        self.mark_phase_end(RuntimeTurnPhase::FinalCommit);
541
542        emit_session_events_to_sink(events, finalized.events).await;
543        self.state = turn_pipeline.into_final_state();
544        if matches!(returned_turn.outcome, TurnOutcome::AgentFrameSwitch { .. })
545            && let Some(session) = self.session.as_mut()
546        {
547            let protocol_session = Arc::clone(session.plugins().protocol_session());
548            let session_id = self.state.session_id.clone();
549            protocol_session
550                .restore_session(
551                    crate::plugin::ProtocolSessionContext::new(session, &session_id),
552                    &self.state,
553                )
554                .await
555                .map_err(|err| {
556                    RuntimeError::new(
557                        RuntimeErrorCode::Other("protocol_restore_session".to_string()),
558                        err.to_string(),
559                    )
560                })?;
561        }
562        if !queued_work_completion_trace.is_empty() {
563            crate::trace::emit_trace(
564                &self.host.core.tracing.trace_sink,
565                &self.host.core.tracing.trace_context,
566                lash_trace::TraceContext::default()
567                    .for_session(returned_turn.state.session_id.clone())
568                    .for_turn_index(returned_turn.state.turn_index)
569                    .for_turn(trace_turn_id.clone()),
570                lash_trace::TraceEvent::Custom {
571                    name: "queued_work.completed".to_string(),
572                    payload: queued_work_completion_trace_payload(&queued_work_completion_trace),
573                },
574                self.host.core.clock.as_ref(),
575            );
576        }
577        if !turn_input_completion_trace.is_empty() {
578            crate::trace::emit_trace(
579                &self.host.core.tracing.trace_sink,
580                &self.host.core.tracing.trace_context,
581                lash_trace::TraceContext::default()
582                    .for_session(returned_turn.state.session_id.clone())
583                    .for_turn_index(returned_turn.state.turn_index)
584                    .for_turn(trace_turn_id.clone()),
585                lash_trace::TraceEvent::Custom {
586                    name: "turn_input.completed".to_string(),
587                    payload: turn_input_completion_trace_payload(&turn_input_completion_trace),
588                },
589                self.host.core.clock.as_ref(),
590            );
591        }
592        self.mark_phase_begin(RuntimeTurnPhase::PostPersistHooks);
593        self.emit_turn_persisted_event(&returned_turn, scoped_effect_controller, &trace_turn_id)
594            .await?;
595        self.mark_phase_end(RuntimeTurnPhase::PostPersistHooks);
596        self.mark_phase_end(RuntimeTurnPhase::PersistTurn);
597
598        self.emit_completed_turn_trace(
599            &returned_turn.state,
600            &returned_turn.outcome,
601            &trace_turn_id,
602        );
603        Ok(PhysicalTurnExecution {
604            turn: returned_turn,
605            enqueued_queue_batches,
606        })
607    }
608
609    fn emit_completed_turn_trace(
610        &self,
611        state: &SessionSnapshot,
612        outcome: &TurnOutcome,
613        trace_turn_id: &str,
614    ) {
615        if self.host.core.tracing.trace_sink.is_none() {
616            return;
617        }
618
619        let (status, done_reason, agent_frame_switch) = trace_fields_from_outcome(outcome);
620        crate::trace::emit_trace(
621            &self.host.core.tracing.trace_sink,
622            &self.host.core.tracing.trace_context,
623            lash_trace::TraceContext::default()
624                .for_session(state.session_id.clone())
625                .for_turn_index(state.turn_index)
626                .for_turn(trace_turn_id.to_string()),
627            lash_trace::TraceEvent::TurnCompleted {
628                status: status.to_string(),
629                done_reason: done_reason.to_string(),
630                agent_frame_switch,
631            },
632            self.host.core.clock.as_ref(),
633        );
634    }
635
636    #[allow(clippy::too_many_arguments)]
637    pub(super) async fn finish_logical_turn_error(
638        &mut self,
639        message: String,
640        trace_turn_id: String,
641        events: &dyn EventSink,
642        turn_events: &dyn TurnActivitySink,
643        scoped_effect_controller: ScopedEffectController<'_>,
644        cancel: CancellationToken,
645        claims: LogicalTurnClaims,
646        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
647    ) -> Result<PhysicalTurnExecution, RuntimeError> {
648        let mut assembler = TurnAssembler::default();
649        let error_event = SessionStreamEvent::Error {
650            message: message.clone(),
651            envelope: Some(crate::session_model::ErrorEnvelope {
652                kind: "runtime".to_string(),
653                code: Some("agent_frame_switch_limit".to_string()),
654                terminal_reason: None,
655                user_message: message.clone(),
656                raw: None,
657                retryable: Some(false),
658                provider_failure_kind: None,
659            }),
660        };
661        assembler.push(&error_event);
662        emit_turn_activity_to_sink(
663            turn_events,
664            TurnActivity::independent(TurnEvent::Error {
665                message: message.clone(),
666            }),
667        )
668        .await;
669        emit_session_event_to_sink(events, error_event).await;
670        let outcome_event = SessionStreamEvent::TurnOutcome {
671            outcome: TurnOutcome::Stopped(TurnStop::RuntimeError),
672        };
673        assembler.push(&outcome_event);
674        emit_session_event_to_sink(events, outcome_event).await;
675        assembler.push(&SessionStreamEvent::Done);
676        emit_session_event_to_sink(events, SessionStreamEvent::Done).await;
677
678        let messages = crate::MessageSequence::from_base(self.state.read_model().messages);
679        let mut turn_pipeline = TurnBoundary::from_state_with_clock(
680            self.state.clone(),
681            Arc::clone(&self.host.core.clock),
682        )
683        .with_session_execution_lease(
684            session_execution_lease.map(SessionExecutionLeaseGuard::fence),
685        );
686        turn_pipeline.apply_prepared_messages(&messages);
687        self.finish_turn(
688            TurnFinishInput {
689                turn_pipeline,
690                assembler,
691                new_messages: messages,
692                policy: RuntimeSessionPolicy::new(
693                    self.state.effective_policy().clone(),
694                    Default::default(),
695                ),
696                turn_index: self.state.turn_index + 1,
697                queued_work_claims: claims.queued,
698                turn_input_claims: claims.turn_inputs,
699                trace_turn_id,
700            },
701            events,
702            &scoped_effect_controller,
703            &cancel,
704            session_execution_lease,
705            SessionExecutionLeaseReleasePolicy::KeepOnAgentFrameSwitch,
706        )
707        .await
708    }
709
710    async fn emit_turn_persisted_event(
711        &self,
712        returned_turn: &AssembledTurn,
713        scoped_effect_controller: &ScopedEffectController<'_>,
714        trace_turn_id: &str,
715    ) -> Result<(), RuntimeError> {
716        let Some(session) = self.session.as_ref() else {
717            return Ok(());
718        };
719        let Ok(manager) = self.runtime_session_services() else {
720            return Ok(());
721        };
722        let phase_turn_id = turn_phase_id(trace_turn_id, "turn-persisted");
723        let phase_controller = scoped_child_turn_controller(
724            scoped_effect_controller,
725            &self.state.session_id,
726            &phase_turn_id,
727        )?;
728        let direct_completions = manager.direct_completion_client(
729            RuntimeEffectControllerHandle::borrowed(phase_controller),
730            Some(phase_turn_id),
731        );
732
733        session
734            .plugins()
735            .emit_runtime_event_with_phase_probe(
736                crate::PluginLifecycleEvent::TurnPersisted(Box::new(
737                    crate::SessionStateChangedContext {
738                        session_id: self.state.session_id.clone(),
739                        state: crate::SessionReadView::from_snapshot(&returned_turn.state),
740                        sessions: manager.state_service(),
741                        session_graph: manager.graph_service(),
742                        direct_completions,
743                    },
744                )),
745                self.turn_phase_probe.clone(),
746            )
747            .await;
748        Ok(())
749    }
750
751    /// Run one logical turn and stream every physical frame to the host sink.
752    pub async fn stream_turn(
753        &mut self,
754        input: TurnInput,
755        opts: TurnOptions<'_>,
756    ) -> Result<AssembledTurn, RuntimeError> {
757        let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
758        let cancel = opts.cancel.clone();
759        let session_execution_lease = self
760            .claim_session_execution_lease(cancel.clone(), true)
761            .await?;
762        let scoped_effect_controller = opts.scoped_effect_controller();
763        let result = Box::pin(self.drive_logical_turn(
764            LogicalTurnStart::Input(input),
765            opts.events_or_noop(),
766            opts.turn_events_or_noop(),
767            scoped_effect_controller,
768            cancel,
769            LogicalTurnClaims::new(Vec::new(), Vec::new()),
770            session_execution_lease.as_ref(),
771            stopwatch,
772        ))
773        .await
774        .map(|run| {
775            run.into_final_turn()
776                .expect("logical turn always contains a terminal physical turn")
777        });
778        self.settle_session_execution_lease(session_execution_lease.as_ref(), result)
779            .await
780    }
781
782    pub async fn stream_next_queued_work(
783        &mut self,
784        opts: TurnOptions<'_>,
785    ) -> Result<Option<AssembledTurn>, RuntimeError> {
786        self.stream_queued_work(opts, None).await
787    }
788
789    pub async fn stream_selected_queued_work(
790        &mut self,
791        opts: TurnOptions<'_>,
792        batch_ids: &[String],
793    ) -> Result<Option<AssembledTurn>, RuntimeError> {
794        self.stream_queued_work(opts, Some(batch_ids)).await
795    }
796
797    async fn stream_queued_work(
798        &mut self,
799        opts: TurnOptions<'_>,
800        selected_batch_ids: Option<&[String]>,
801    ) -> Result<Option<AssembledTurn>, RuntimeError> {
802        let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
803        let cancel = opts.cancel.clone();
804        let Some(session_execution_lease) = self
805            .claim_session_execution_lease(cancel.clone(), false)
806            .await?
807        else {
808            return Ok(None);
809        };
810        let session_execution_fence = session_execution_lease.fence();
811        let Some(store) = self
812            .session
813            .as_ref()
814            .and_then(|session| session.history_store())
815        else {
816            session_execution_lease
817                .release_if_live()
818                .await
819                .map_err(|err| {
820                    RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string())
821                })?;
822            return Ok(None);
823        };
824        let drain_commands_before_turn_input = if selected_batch_ids.is_some() {
825            true
826        } else {
827            self.session_commands_precede_pending_turn_input(store.as_ref())
828                .await?
829        };
830        if drain_commands_before_turn_input {
831            loop {
832                match self
833                    .drain_next_session_command(&session_execution_fence)
834                    .await
835                {
836                    Ok(Some(_)) => {}
837                    Ok(None) => break,
838                    Err(err) => {
839                        let _ = session_execution_lease.release_if_live().await;
840                        return Err(err);
841                    }
842                }
843            }
844        }
845        if selected_batch_ids.is_none() {
846            let input_claim = store
847                .claim_next_turn_inputs(
848                    &self.state.session_id,
849                    &session_execution_fence,
850                    &self.runtime_lease_owner,
851                    64,
852                )
853                .await
854                .map_err(super::runtime_error_from_store_commit)?;
855            if let Some(input_claim) = input_claim {
856                let mut input = input_claim.materialize_for_turn();
857                let turn_id = input
858                    .trace_turn_id
859                    .clone()
860                    .or_else(|| Some(opts.execution_scope_id().to_owned()))
861                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
862                input.trace_turn_id = Some(turn_id.clone());
863                crate::trace::emit_trace(
864                    &self.host.core.tracing.trace_sink,
865                    &self.host.core.tracing.trace_context,
866                    lash_trace::TraceContext::default()
867                        .for_session(self.state.session_id.clone())
868                        .for_turn_index(self.state.turn_index + 1)
869                        .for_turn(turn_id.clone()),
870                    lash_trace::TraceEvent::Custom {
871                        name: "turn_input.claimed".to_string(),
872                        payload: serde_json::json!({
873                            "claim_id": &input_claim.claim_id,
874                            "input_ids": input_claim.inputs.iter().map(|input| input.input_id.clone()).collect::<Vec<_>>(),
875                        }),
876                    },
877                    self.host.core.clock.as_ref(),
878                );
879                let claim_for_abandon = input_claim.clone();
880                let scoped_effect_controller = opts.scoped_effect_controller();
881                let result = Box::pin(self.drive_logical_turn(
882                    LogicalTurnStart::Input(input),
883                    opts.events_or_noop(),
884                    opts.turn_events_or_noop(),
885                    scoped_effect_controller,
886                    cancel,
887                    LogicalTurnClaims::new(Vec::new(), vec![input_claim]),
888                    Some(&session_execution_lease),
889                    stopwatch,
890                ))
891                .await
892                .map(AgentFrameRun::into_final_turn);
893                if let Err(err) = &result {
894                    self.abandon_turn_input_claims_after_lease_loss(
895                        err,
896                        std::slice::from_ref(&claim_for_abandon),
897                    )
898                    .await;
899                }
900                return self
901                    .settle_session_execution_lease(Some(&session_execution_lease), result)
902                    .await;
903            }
904        }
905        let claim = if let Some(batch_ids) = selected_batch_ids {
906            store
907                .claim_ready_queued_work_by_batch_ids(
908                    &self.state.session_id,
909                    &session_execution_fence,
910                    &self.runtime_lease_owner,
911                    crate::QueuedWorkClaimBoundary::Idle,
912                    batch_ids,
913                )
914                .await
915        } else {
916            store
917                .claim_ready_queued_work(
918                    &self.state.session_id,
919                    &session_execution_fence,
920                    &self.runtime_lease_owner,
921                    crate::QueuedWorkClaimBoundary::Idle,
922                    64,
923                )
924                .await
925        }
926        .map_err(super::runtime_error_from_store_commit)?;
927        let Some(claim) = claim else {
928            session_execution_lease
929                .release_if_live()
930                .await
931                .map_err(|err| {
932                    RuntimeError::new(RuntimeErrorCode::StoreCommitFailed, err.to_string())
933                })?;
934            return Ok(None);
935        };
936        let mut work = claim.materialize_for_turn();
937        let turn_id = work
938            .input
939            .trace_turn_id
940            .clone()
941            .or_else(|| Some(opts.execution_scope_id().to_owned()))
942            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
943        work.input.trace_turn_id = Some(turn_id.clone());
944        let causes = work.turn_causes.clone();
945        emit_queued_work_started_to_sink(
946            opts.turn_events_or_noop(),
947            crate::QueuedWorkClaimBoundary::Idle,
948            &claim,
949            causes.clone(),
950        )
951        .await;
952        crate::trace::emit_trace(
953            &self.host.core.tracing.trace_sink,
954            &self.host.core.tracing.trace_context,
955            lash_trace::TraceContext::default()
956                .for_session(self.state.session_id.clone())
957                .for_turn_index(self.state.turn_index + 1)
958                .for_turn(turn_id.clone()),
959            lash_trace::TraceEvent::Custom {
960                name: "queued_work.claimed".to_string(),
961                payload: queued_work_trace_payload(
962                    crate::QueuedWorkClaimBoundary::Idle,
963                    &claim,
964                    &causes,
965                ),
966            },
967            self.host.core.clock.as_ref(),
968        );
969        let claim_for_abandon = claim.clone();
970        let scoped_effect_controller = opts.scoped_effect_controller();
971        let result = Box::pin(self.drive_logical_turn(
972            LogicalTurnStart::Input(work.input),
973            opts.events_or_noop(),
974            opts.turn_events_or_noop(),
975            scoped_effect_controller,
976            cancel,
977            LogicalTurnClaims::new(vec![claim], Vec::new()),
978            Some(&session_execution_lease),
979            stopwatch,
980        ))
981        .await
982        .map(AgentFrameRun::into_final_turn);
983        if let Err(err) = &result {
984            self.abandon_queued_work_claims_after_lease_loss(
985                err,
986                std::slice::from_ref(&claim_for_abandon),
987            )
988            .await;
989        }
990        self.settle_session_execution_lease(Some(&session_execution_lease), result)
991            .await
992    }
993
994    async fn session_commands_precede_pending_turn_input(
995        &self,
996        store: &dyn crate::RuntimePersistence,
997    ) -> Result<bool, RuntimeError> {
998        let pending_inputs = store
999            .list_pending_turn_inputs(&self.state.session_id)
1000            .await
1001            .map_err(super::runtime_error_from_store_commit)?;
1002        let earliest_input = pending_inputs
1003            .iter()
1004            .filter(|input| input.state.is_next_turn_pending())
1005            .min_by_key(|input| (input.enqueued_at_ms, input.enqueue_seq));
1006        let queued_work = store
1007            .list_pending_queued_work(&self.state.session_id)
1008            .await
1009            .map_err(super::runtime_error_from_store_commit)?;
1010        let earliest_command = queued_work
1011            .iter()
1012            .filter(|batch| batch.is_session_command_work())
1013            .min_by_key(|batch| (batch.enqueued_at_ms, batch.enqueue_seq));
1014        Ok(match (earliest_command, earliest_input) {
1015            (Some(command), Some(input)) => command.enqueued_at_ms < input.enqueued_at_ms,
1016            (Some(_), None) => true,
1017            _ => false,
1018        })
1019    }
1020
1021    /// Enforce the durable-first wiring invariant at a turn-scope boundary: when
1022    /// the host wired a durable effect host, every store reachable from this
1023    /// scope must also be durable. A durable host running against any ephemeral
1024    /// store fails loudly here rather than silently degrading.
1025    ///
1026    /// Inline controllers (the default tier) impose no requirement, so
1027    /// inline/in-memory hosts pass unchanged.
1028    fn ensure_durable_store_facets_for_scope(
1029        &self,
1030        scoped_effect_controller: &ScopedEffectController<'_>,
1031    ) -> Result<(), RuntimeError> {
1032        if scoped_effect_controller.controller().durability_tier() != crate::DurabilityTier::Durable
1033        {
1034            return Ok(());
1035        }
1036        if self
1037            .host
1038            .core
1039            .durability
1040            .attachment_store
1041            .persistence()
1042            .durability_tier()
1043            != crate::DurabilityTier::Durable
1044        {
1045            return Err(RuntimeError::durable_store_required(
1046                crate::DurableStoreFacet::AttachmentStore,
1047            ));
1048        }
1049        if self
1050            .host
1051            .core
1052            .durability
1053            .process_env_store
1054            .durability_tier()
1055            != crate::DurabilityTier::Durable
1056        {
1057            return Err(RuntimeError::durable_store_required(
1058                crate::DurableStoreFacet::ProcessEnvStore,
1059            ));
1060        }
1061        if let Some(store) = self
1062            .session
1063            .as_ref()
1064            .and_then(|session| session.history_store())
1065            && store.durability_tier() != crate::DurabilityTier::Durable
1066        {
1067            return Err(RuntimeError::durable_store_required(
1068                crate::DurableStoreFacet::SessionStore,
1069            ));
1070        }
1071        if let Some(process_registry) = self.host.process_registry.as_ref()
1072            && process_registry.durability_tier() != crate::DurabilityTier::Durable
1073        {
1074            return Err(RuntimeError::durable_store_required(
1075                crate::DurableStoreFacet::ProcessRegistry,
1076            ));
1077        }
1078        Ok(())
1079    }
1080
1081    #[allow(clippy::too_many_arguments)]
1082    pub(super) async fn stream_turn_with_scoped_effect_controller_inner(
1083        &mut self,
1084        mut input: TurnInput,
1085        events: &dyn EventSink,
1086        turn_events: &dyn TurnActivitySink,
1087        scoped_effect_controller: ScopedEffectController<'_>,
1088        cancel: CancellationToken,
1089        queued_claims: Vec<crate::QueuedWorkClaim>,
1090        turn_input_claims: Vec<crate::TurnInputClaim>,
1091        materialize_initial_claims: bool,
1092        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
1093        session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
1094    ) -> Result<PhysicalTurnExecution, RuntimeError> {
1095        if queued_claims.is_empty() && turn_input_claims.is_empty() {
1096            if let Some(lease) = session_execution_lease {
1097                while self
1098                    .drain_next_session_command(&lease.fence())
1099                    .await?
1100                    .is_some()
1101                {}
1102            } else if self
1103                .session
1104                .as_ref()
1105                .and_then(|session| session.history_store())
1106                .is_some()
1107            {
1108                return Err(RuntimeError::new(
1109                    RuntimeErrorCode::StoreCommitFailed,
1110                    "session command drain requires a session execution lease",
1111                ));
1112            }
1113        }
1114        if let Some(input_turn_id) = input.trace_turn_id.as_deref()
1115            && scoped_effect_controller
1116                .execution_scope()
1117                .validates_turn_trace_id()
1118            && input_turn_id != scoped_effect_controller.scope_id()
1119        {
1120            return Err(RuntimeError::new(
1121                RuntimeErrorCode::ExecutionScopeTurnIdMismatch,
1122                format!(
1123                    "input trace_turn_id `{input_turn_id}` does not match execution scope id `{}`",
1124                    scoped_effect_controller.scope_id()
1125                ),
1126            ));
1127        }
1128        self.ensure_durable_store_facets_for_scope(&scoped_effect_controller)?;
1129        input
1130            .trace_turn_id
1131            .get_or_insert_with(|| scoped_effect_controller.scope_id().to_string());
1132        self.stream_turn_inner(
1133            input.clone(),
1134            events,
1135            turn_events,
1136            scoped_effect_controller,
1137            cancel.clone(),
1138            queued_claims,
1139            turn_input_claims,
1140            materialize_initial_claims,
1141            session_execution_lease,
1142            session_execution_lease_release_policy,
1143        )
1144        .await
1145    }
1146
1147    /// Stream one logical host turn, following foreground AgentFrame switches
1148    /// until a terminal outcome is reached.
1149    ///
1150    /// A protocol continuation creates a new frame in the same session. Hosts
1151    /// that only care about the benchmark/app answer should not need to
1152    /// special-case that intermediate outcome; this helper keeps driving the
1153    /// same session through each frame's task with the normal runtime turn
1154    /// guards.
1155    pub async fn stream_turn_with_agent_frames(
1156        &mut self,
1157        input: TurnInput,
1158        opts: TurnOptions<'_>,
1159    ) -> Result<AgentFrameRun, RuntimeError> {
1160        let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
1161        let cancel = opts.cancel.clone();
1162        let session_execution_lease = self
1163            .claim_session_execution_lease(cancel.clone(), true)
1164            .await?;
1165        let scoped_effect_controller = opts.scoped_effect_controller();
1166        let result = Box::pin(self.drive_logical_turn(
1167            LogicalTurnStart::Input(input),
1168            opts.events_or_noop(),
1169            opts.turn_events_or_noop(),
1170            scoped_effect_controller,
1171            cancel,
1172            LogicalTurnClaims::new(Vec::new(), Vec::new()),
1173            session_execution_lease.as_ref(),
1174            stopwatch,
1175        ))
1176        .await;
1177        self.settle_session_execution_lease(session_execution_lease.as_ref(), result)
1178            .await
1179    }
1180
1181    #[allow(clippy::too_many_arguments)]
1182    async fn stream_turn_inner(
1183        &mut self,
1184        mut input: TurnInput,
1185        events: &dyn EventSink,
1186        turn_events: &dyn TurnActivitySink,
1187        scoped_effect_controller: ScopedEffectController<'_>,
1188        cancel: CancellationToken,
1189        queued_claims: Vec<crate::QueuedWorkClaim>,
1190        turn_input_claims: Vec<crate::TurnInputClaim>,
1191        materialize_initial_claims: bool,
1192        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
1193        session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
1194    ) -> Result<PhysicalTurnExecution, RuntimeError> {
1195        self.refresh_session_graph_from_store()
1196            .await
1197            .map_err(session_head_refresh_error)?;
1198        let input_trace_turn_id = input.trace_turn_id.clone();
1199        let queued_turn_work = materialize_initial_claims
1200            .then(|| queued_claims.first())
1201            .flatten()
1202            .map(crate::QueuedWorkClaim::materialize_for_turn);
1203        let pending_turn_input = materialize_initial_claims
1204            .then(|| turn_input_claims.first())
1205            .flatten()
1206            .map(crate::TurnInputClaim::materialize_for_turn);
1207        if let Some(work) = pending_turn_input.as_ref()
1208            && input.items.is_empty()
1209            && input.image_blobs.is_empty()
1210        {
1211            input = work.clone();
1212            if input.trace_turn_id.is_none() {
1213                input.trace_turn_id = input_trace_turn_id.clone();
1214            }
1215        }
1216        if let Some(work) = queued_turn_work.as_ref()
1217            && input.items.is_empty()
1218            && input.image_blobs.is_empty()
1219        {
1220            input = work.input.clone();
1221            if input.trace_turn_id.is_none() {
1222                input.trace_turn_id = input_trace_turn_id;
1223            }
1224        }
1225        if self
1226            .session
1227            .as_ref()
1228            .and_then(|session| session.history_store())
1229            .is_some()
1230        {
1231            ensure_durable_effect_input(&input)?;
1232        }
1233        if let Some(extension) = &input.protocol_extension
1234            && let Some(session) = self.session.as_ref()
1235        {
1236            let protocol_session = std::sync::Arc::clone(session.plugins().protocol_session());
1237            protocol_session
1238                .validate_turn_extension(extension)
1239                .await
1240                .map_err(|err| {
1241                    RuntimeError::new(RuntimeErrorCode::ProtocolTurnExtension, err.to_string())
1242                })?;
1243        }
1244        let previous_prompt_usage = self.state.last_prompt_usage.clone();
1245        let normalized = match self
1246            .normalize_input_items(&input.items, &input.image_blobs)
1247            .await
1248        {
1249            Ok(items) => items,
1250            Err(e) => {
1251                self.state.last_prompt_usage = None;
1252                let mut assembler = TurnAssembler::default();
1253                let error_event = SessionStreamEvent::Error {
1254                    message: e.clone(),
1255                    envelope: Some(crate::session_model::ErrorEnvelope {
1256                        kind: "input_validation".to_string(),
1257                        code: Some("invalid_turn_input".to_string()),
1258                        terminal_reason: None,
1259                        user_message: e.clone(),
1260                        raw: None,
1261                        retryable: Some(false),
1262                        provider_failure_kind: None,
1263                    }),
1264                };
1265                assembler.push(&error_event);
1266                emit_turn_activity_to_sink(
1267                    turn_events,
1268                    TurnActivity::independent(TurnEvent::Error { message: e }),
1269                )
1270                .await;
1271                emit_session_event_to_sink(events, error_event).await;
1272                let outcome_event = SessionStreamEvent::TurnOutcome {
1273                    outcome: TurnOutcome::Stopped(TurnStop::InvalidInput),
1274                };
1275                assembler.push(&outcome_event);
1276                emit_session_event_to_sink(events, outcome_event).await;
1277                assembler.push(&SessionStreamEvent::Done);
1278                emit_session_event_to_sink(events, SessionStreamEvent::Done).await;
1279                let turn_index = self.state.turn_index + 1;
1280                let trace_turn_id = input
1281                    .trace_turn_id
1282                    .clone()
1283                    .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1284                let messages = crate::MessageSequence::from_base(self.state.read_model().messages);
1285                let mut turn_pipeline = TurnBoundary::from_state_with_clock(
1286                    self.state.clone(),
1287                    Arc::clone(&self.host.core.clock),
1288                )
1289                .with_session_execution_lease(
1290                    session_execution_lease.map(SessionExecutionLeaseGuard::fence),
1291                );
1292                turn_pipeline.apply_prepared_messages(&messages);
1293                return self
1294                    .finish_turn(
1295                        TurnFinishInput {
1296                            turn_pipeline,
1297                            assembler,
1298                            new_messages: messages,
1299                            policy: RuntimeSessionPolicy::new(
1300                                self.state.effective_policy().clone(),
1301                                Default::default(),
1302                            ),
1303                            turn_index,
1304                            queued_work_claims: queued_claims,
1305                            turn_input_claims,
1306                            trace_turn_id,
1307                        },
1308                        events,
1309                        &scoped_effect_controller,
1310                        &cancel,
1311                        session_execution_lease,
1312                        session_execution_lease_release_policy,
1313                    )
1314                    .await;
1315            }
1316        };
1317        let turn_index = self.state.turn_index + 1;
1318        let trace_turn_id = input
1319            .trace_turn_id
1320            .clone()
1321            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1322        if self.host.core.tracing.trace_sink.is_some() {
1323            let mut trace_metadata = std::collections::BTreeMap::new();
1324            trace_metadata.insert(
1325                "input_item_count".to_string(),
1326                serde_json::json!(normalized.len()),
1327            );
1328            crate::trace::emit_trace(
1329                &self.host.core.tracing.trace_sink,
1330                &self.host.core.tracing.trace_context,
1331                lash_trace::TraceContext::default()
1332                    .for_session(self.state.session_id.clone())
1333                    .for_turn_index(turn_index)
1334                    .for_turn(trace_turn_id.clone()),
1335                lash_trace::TraceEvent::TurnStarted {
1336                    metadata: trace_metadata,
1337                },
1338                self.host.core.clock.as_ref(),
1339            );
1340        }
1341
1342        let base_read_model = self.state.read_model();
1343        let base_messages = base_read_model.messages;
1344        let base_render_cache = base_read_model.prompt_render_cache;
1345        let mut turn_delta = Vec::new();
1346        let initial_turn_causes = queued_turn_work
1347            .as_ref()
1348            .map(|work| work.turn_causes.clone())
1349            .unwrap_or_default();
1350        turn_delta.extend(
1351            initial_turn_causes
1352                .iter()
1353                .map(crate::TurnCause::to_event_message),
1354        );
1355
1356        let user_id = fresh_message_id();
1357        let mut user_parts: Vec<Part> = Vec::new();
1358        for item in normalized {
1359            match item {
1360                NormalizedItem::Text(text) => {
1361                    if text.is_empty() {
1362                        continue;
1363                    }
1364                    user_parts.push(Part {
1365                        id: format!("{}.p{}", user_id, user_parts.len()),
1366                        kind: PartKind::Text,
1367                        content: text,
1368                        attachment: None,
1369                        tool_call_id: None,
1370                        tool_name: None,
1371                        tool_replay: None,
1372                        prune_state: PruneState::Intact,
1373                        reasoning_meta: None,
1374                        response_meta: None,
1375                    });
1376                }
1377                NormalizedItem::Image(reference) => {
1378                    user_parts.push(Part {
1379                        id: format!("{}.p{}", user_id, user_parts.len()),
1380                        kind: PartKind::Image,
1381                        content: String::new(),
1382                        attachment: Some(crate::session_model::message::PartAttachment {
1383                            reference,
1384                        }),
1385                        tool_call_id: None,
1386                        tool_name: None,
1387                        tool_replay: None,
1388                        prune_state: PruneState::Intact,
1389                        reasoning_meta: None,
1390                        response_meta: None,
1391                    });
1392                }
1393            }
1394        }
1395        if user_parts.is_empty() && initial_turn_causes.is_empty() {
1396            user_parts.push(Part {
1397                id: format!("{}.p0", user_id),
1398                kind: PartKind::Text,
1399                content: String::new(),
1400                attachment: None,
1401                tool_call_id: None,
1402                tool_name: None,
1403                tool_replay: None,
1404                prune_state: PruneState::Intact,
1405                reasoning_meta: None,
1406                response_meta: None,
1407            });
1408        }
1409        if !user_parts.is_empty() {
1410            reassign_part_ids(&user_id, &mut user_parts);
1411            turn_delta.push(Message {
1412                id: user_id.clone(),
1413                role: MessageRole::User,
1414                parts: shared_parts(user_parts),
1415                origin: None,
1416            });
1417        }
1418
1419        let manager = self
1420            .runtime_session_services_for_turn(None)
1421            .map_err(|err| {
1422                RuntimeError::new(RuntimeErrorCode::PluginSessionManager, err.to_string())
1423            })?;
1424        let plugin_session = self
1425            .session
1426            .as_ref()
1427            .map(|s| Arc::clone(s.plugins()))
1428            .ok_or_else(|| {
1429                RuntimeError::new(
1430                    RuntimeErrorCode::ContextPrepareTurn,
1431                    "runtime session not available",
1432                )
1433            })?;
1434        let prepare_phase_turn_id = turn_phase_id(&trace_turn_id, "prepare-turn");
1435        let prepare_phase_controller = scoped_child_turn_controller(
1436            &scoped_effect_controller,
1437            &self.state.session_id,
1438            &prepare_phase_turn_id,
1439        )?;
1440        let turn_ctx = crate::TurnTransformContext {
1441            session_id: self.state.session_id.clone(),
1442            state: self.read_view(),
1443            prompt_usage: previous_prompt_usage.clone(),
1444            max_context_tokens: Some(LashRuntime::max_context_tokens(self)),
1445            sessions: manager.state_service(),
1446            session_lifecycle: manager.lifecycle_service(),
1447            session_graph: manager.graph_service(),
1448            scoped_effect_controller: scoped_effect_controller.clone(),
1449            direct_completions: manager.direct_completion_client(
1450                RuntimeEffectControllerHandle::borrowed(prepare_phase_controller),
1451                Some(prepare_phase_turn_id),
1452            ),
1453        };
1454        self.mark_phase_begin(RuntimeTurnPhase::ContextTransform);
1455        let prepared_context = plugin_session
1456            .prepare_turn_context(
1457                &turn_ctx,
1458                crate::session_model::context::PreparedContext {
1459                    messages: crate::MessageSequence::from_base_and_delta(
1460                        base_messages,
1461                        turn_delta,
1462                    )
1463                    .with_base_render_cache(base_render_cache),
1464                    ..Default::default()
1465                },
1466                self.turn_phase_probe.clone(),
1467            )
1468            .await
1469            .map_err(|err| {
1470                RuntimeError::new(RuntimeErrorCode::ContextPrepareTurn, err.to_string())
1471            })?;
1472        self.mark_phase_end(RuntimeTurnPhase::ContextTransform);
1473        // Release the read-view's graph clone before the rest of the turn
1474        // runs. Keeping it alive into `stream_prepared_turn` forces the
1475        // post-turn `append_active_read_delta` to deep-clone the session
1476        // graph (Arc::make_mut with refcount > 1).
1477        drop(turn_ctx);
1478        let messages = prepared_context.messages;
1479        if let Some(session) = self.session.as_mut() {
1480            session
1481                .set_context_overlay(
1482                    prepared_context.tool_providers,
1483                    prepared_context.prompt_contributions,
1484                    prepared_context.include_base_tools,
1485                )
1486                .map_err(|err| {
1487                    RuntimeError::new(
1488                        RuntimeErrorCode::Other("session_tool_registry".to_string()),
1489                        err.to_string(),
1490                    )
1491                })?;
1492        }
1493
1494        self.state.last_prompt_usage = None;
1495        Box::pin(self.stream_prepared_turn_inner(
1496            messages,
1497            previous_prompt_usage,
1498            input.protocol_turn_options.clone(),
1499            input.protocol_extension.clone(),
1500            input.turn_context.clone(),
1501            initial_turn_causes,
1502            trace_turn_id,
1503            turn_index,
1504            events,
1505            turn_events,
1506            scoped_effect_controller,
1507            cancel,
1508            queued_claims,
1509            turn_input_claims,
1510            session_execution_lease,
1511            session_execution_lease_release_policy,
1512        ))
1513        .await
1514    }
1515
1516    /// Run one logical turn and return only its assembled terminal result.
1517    pub async fn run_turn_assembled(
1518        &mut self,
1519        input: TurnInput,
1520        cancel: CancellationToken,
1521        scoped_effect_controller: ScopedEffectController<'_>,
1522    ) -> Result<AssembledTurn, RuntimeError> {
1523        self.stream_turn(input, TurnOptions::new(cancel, scoped_effect_controller))
1524            .await
1525    }
1526
1527    /// Run one logical turn using host-prepared message history.
1528    #[allow(clippy::too_many_arguments)]
1529    pub async fn stream_prepared_turn(
1530        &mut self,
1531        messages: crate::MessageSequence,
1532        previous_prompt_usage: Option<PromptUsage>,
1533        protocol_turn_options: Option<crate::ProtocolTurnOptions>,
1534        protocol_extension: Option<crate::ProtocolTurnExtensionHandle>,
1535        turn_context: crate::TurnContext,
1536        initial_turn_causes: Vec<crate::TurnCause>,
1537        trace_turn_id: String,
1538        turn_index: usize,
1539        events: &dyn EventSink,
1540        turn_events: &dyn TurnActivitySink,
1541        scoped_effect_controller: ScopedEffectController<'_>,
1542        cancel: CancellationToken,
1543        initial_queue_claim: Option<crate::QueuedWorkClaim>,
1544        initial_turn_input_claim: Option<crate::TurnInputClaim>,
1545    ) -> Result<AssembledTurn, RuntimeError> {
1546        let stopwatch = TurnStopwatch::start(self.host.core.clock.as_ref());
1547        let session_execution_lease = self
1548            .claim_session_execution_lease(cancel.clone(), true)
1549            .await?;
1550        let result = Box::pin(self.drive_logical_turn(
1551            LogicalTurnStart::Prepared(PreparedLogicalTurn {
1552                messages,
1553                previous_prompt_usage,
1554                protocol_turn_options,
1555                protocol_extension,
1556                turn_context,
1557                initial_turn_causes,
1558                trace_turn_id,
1559                turn_index,
1560            }),
1561            events,
1562            turn_events,
1563            scoped_effect_controller,
1564            cancel,
1565            LogicalTurnClaims::new(
1566                initial_queue_claim.into_iter().collect(),
1567                initial_turn_input_claim.into_iter().collect(),
1568            ),
1569            session_execution_lease.as_ref(),
1570            stopwatch,
1571        ))
1572        .await
1573        .map(|run| {
1574            run.into_final_turn()
1575                .expect("logical turn always contains a terminal physical turn")
1576        });
1577        self.settle_session_execution_lease(session_execution_lease.as_ref(), result)
1578            .await
1579    }
1580
1581    #[allow(clippy::too_many_arguments)]
1582    pub(super) async fn stream_prepared_turn_inner(
1583        &mut self,
1584        messages: crate::MessageSequence,
1585        _previous_prompt_usage: Option<PromptUsage>,
1586        protocol_turn_options: Option<crate::ProtocolTurnOptions>,
1587        protocol_extension: Option<crate::ProtocolTurnExtensionHandle>,
1588        turn_context: crate::TurnContext,
1589        initial_turn_causes: Vec<crate::TurnCause>,
1590        trace_turn_id: String,
1591        turn_index: usize,
1592        events: &dyn EventSink,
1593        turn_events: &dyn TurnActivitySink,
1594        scoped_effect_controller: ScopedEffectController<'_>,
1595        cancel: CancellationToken,
1596        initial_queue_claims: Vec<crate::QueuedWorkClaim>,
1597        initial_turn_input_claims: Vec<crate::TurnInputClaim>,
1598        session_execution_lease: Option<&SessionExecutionLeaseGuard>,
1599        session_execution_lease_release_policy: SessionExecutionLeaseReleasePolicy,
1600    ) -> Result<PhysicalTurnExecution, RuntimeError> {
1601        if session_execution_lease.is_none()
1602            && self
1603                .session
1604                .as_ref()
1605                .and_then(|session| session.history_store())
1606                .is_some()
1607        {
1608            return Err(RuntimeError::new(
1609                RuntimeErrorCode::StoreCommitFailed,
1610                "prepared turn requires a session execution lease",
1611            ));
1612        }
1613        let session_execution_fence =
1614            session_execution_lease.map(SessionExecutionLeaseGuard::fence);
1615        let (event_tx, mut event_rx) = mpsc::channel::<RuntimeStreamEvent>(100);
1616        let child_usage_event_relay = ChildUsageEventRelay::new(event_tx.clone());
1617        let mut turn_policy = self.state.effective_policy().clone();
1618        let turn_provider_override = turn_context.provider().cloned();
1619        if let Some(provider) = turn_provider_override.as_ref() {
1620            turn_policy.provider_id = provider.kind().to_string();
1621        }
1622        let session_protocol_turn_options = self.state.effective_protocol_turn_options().clone();
1623        let effective_protocol_turn_options = protocol_turn_options
1624            .clone()
1625            .map(|options| session_protocol_turn_options.merged_with_override(&options))
1626            .unwrap_or(session_protocol_turn_options);
1627        let manager = self
1628            .runtime_session_services_for_turn(Some(child_usage_event_relay.clone()))
1629            .map_err(|err| {
1630                RuntimeError::new(RuntimeErrorCode::PluginSessionManager, err.to_string())
1631            })?;
1632        let plugins = {
1633            let session = self
1634                .session
1635                .as_ref()
1636                .expect("lash runtime session must be available");
1637            Arc::clone(session.plugins())
1638        };
1639        let mut assembler = TurnAssembler::new();
1640        self.mark_phase_begin(RuntimeTurnPhase::BeforeTurnHooks);
1641        // Block-scope the pinned future so it (and its captured
1642        // `SessionReadView` clone of the session graph) drops before the
1643        // post-turn `append_active_read_delta` mutation. Keeping it alive
1644        // across the turn forces `Arc::make_mut` to deep-clone
1645        // `SessionGraphData`.
1646        let prepared = {
1647            let prepare_turn = plugins.prepare_turn_with_phase_probe(
1648                PrepareTurnRequest {
1649                    session_id: self.state.session_id.clone(),
1650                    state: crate::SessionReadView::from_runtime_state(
1651                        &self.state,
1652                        turn_policy.clone(),
1653                        effective_protocol_turn_options.clone(),
1654                    ),
1655                    messages,
1656                    sessions: manager.state_service(),
1657                    session_lifecycle: manager.lifecycle_service(),
1658                    session_graph: manager.graph_service(),
1659                    turn_context: turn_context.clone(),
1660                },
1661                self.turn_phase_probe.clone(),
1662            );
1663            let mut prepare_turn = Box::pin(prepare_turn);
1664
1665            loop {
1666                tokio::select! {
1667                    prepared = prepare_turn.as_mut() => {
1668                        let prepared = prepared.map_err(|err| {
1669                            RuntimeError::new(RuntimeErrorCode::PluginPrepareTurn, err.to_string())
1670                        })?;
1671                        self.mark_phase_end(RuntimeTurnPhase::BeforeTurnHooks);
1672                        break prepared;
1673                    }
1674                    maybe_event = event_rx.recv() => {
1675                        if let Some(event) = maybe_event {
1676                            emit_runtime_stream_event_to_sinks(
1677                                events,
1678                                turn_events,
1679                                event,
1680                                &mut assembler,
1681                            )
1682                            .await;
1683                        }
1684                    }
1685                }
1686            }
1687        };
1688        for event in &prepared.events {
1689            assembler.push(event);
1690        }
1691        emit_session_events_to_sink(events, prepared.events).await;
1692        if let Some(abort) = prepared.abort {
1693            drop(event_tx);
1694
1695            let mut turn_pipeline = TurnBoundary::from_state_with_clock(
1696                self.state.clone(),
1697                Arc::clone(&self.host.core.clock),
1698            )
1699            .with_session_execution_lease(session_execution_fence.clone());
1700            turn_pipeline.apply_prepared_messages(&prepared.messages);
1701            let issue = TurnIssue {
1702                kind: "plugin".to_string(),
1703                code: Some(abort.code),
1704                terminal_reason: None,
1705                message: abort.message.clone(),
1706                raw: None,
1707                retryable: None,
1708                provider_failure_kind: None,
1709            };
1710            let error_event = SessionStreamEvent::Error {
1711                message: abort.message,
1712                envelope: Some(crate::session_model::ErrorEnvelope {
1713                    kind: "plugin".to_string(),
1714                    code: issue.code.clone(),
1715                    terminal_reason: None,
1716                    user_message: issue.message.clone(),
1717                    raw: None,
1718                    retryable: None,
1719                    provider_failure_kind: None,
1720                }),
1721            };
1722            assembler.push(&error_event);
1723            emit_turn_activity_to_sink(
1724                turn_events,
1725                TurnActivity::independent(TurnEvent::Error {
1726                    message: issue.message.clone(),
1727                }),
1728            )
1729            .await;
1730            emit_session_event_to_sink(events, error_event).await;
1731            let outcome_event = SessionStreamEvent::TurnOutcome {
1732                outcome: TurnOutcome::Stopped(TurnStop::PluginAbort),
1733            };
1734            assembler.push(&outcome_event);
1735            emit_session_event_to_sink(events, outcome_event).await;
1736            assembler.push(&SessionStreamEvent::Done);
1737            emit_session_event_to_sink(events, SessionStreamEvent::Done).await;
1738            return self
1739                .finish_turn(
1740                    TurnFinishInput {
1741                        turn_pipeline,
1742                        assembler,
1743                        new_messages: prepared.messages,
1744                        policy: RuntimeSessionPolicy::new(
1745                            self.state.effective_policy().clone(),
1746                            Default::default(),
1747                        ),
1748                        turn_index,
1749                        queued_work_claims: initial_queue_claims,
1750                        turn_input_claims: initial_turn_input_claims,
1751                        trace_turn_id,
1752                    },
1753                    events,
1754                    &scoped_effect_controller,
1755                    &cancel,
1756                    session_execution_lease,
1757                    session_execution_lease_release_policy,
1758                )
1759                .await;
1760        }
1761        let mut turn_pipeline = TurnBoundary::from_state_with_clock(
1762            self.state.clone(),
1763            Arc::clone(&self.host.core.clock),
1764        )
1765        .with_session_execution_lease(session_execution_fence.clone());
1766        let store = self
1767            .session
1768            .as_ref()
1769            .and_then(|session| session.history_store());
1770        // Durable controllers, like Restate, own in-flight replay. Writing
1771        // progress checkpoints directly to the shared store would make handler
1772        // replay observe a newer partial turn and change effect replay keys.
1773        let progress_store = if scoped_effect_controller.controller().durability_tier()
1774            == crate::DurabilityTier::Durable
1775        {
1776            None
1777        } else {
1778            store.as_ref().map(|store| store.as_ref())
1779        };
1780        turn_pipeline
1781            .prepared_checkpoint(
1782                progress_store,
1783                turn_policy.clone(),
1784                turn_index,
1785                &prepared.messages,
1786                self.session.as_mut(),
1787            )
1788            .await
1789            .map_err(super::runtime_error_from_store_commit)?;
1790        let resolved_turn_policy = if let Some(provider) = turn_provider_override {
1791            RuntimeSessionPolicy::from_provider(
1792                turn_policy.clone(),
1793                provider.with_clock(Arc::clone(&self.host.core.clock)),
1794            )
1795            .map_err(|err| RuntimeError::new("llm_provider", err.to_string()))?
1796        } else {
1797            self.host
1798                .resolve_session_policy(&self.state.session_id, turn_policy.clone())
1799                .map_err(|err| RuntimeError::new("llm_provider", err.to_string()))?
1800        };
1801        let manager = self
1802            .runtime_session_services_for_turn(Some(child_usage_event_relay.clone()))
1803            .map_err(|err| {
1804                RuntimeError::new(RuntimeErrorCode::PluginSessionManager, err.to_string())
1805            })?;
1806        let cancel_state = cancel.clone();
1807        let finish_scoped_effect_controller = scoped_effect_controller.clone();
1808        let session = self
1809            .session
1810            .take()
1811            .expect("lash runtime session must be available");
1812        let mut driver = RuntimeTurnDriver {
1813            session,
1814            policy: resolved_turn_policy,
1815            host: self.host.clone(),
1816            turn_id: scoped_effect_controller.scope_id().to_string(),
1817            scoped_effect_controller,
1818            session_id: self.state.session_id.clone(),
1819            turn_index,
1820            turn_pipeline,
1821            llm_stream_summaries: HashMap::new(),
1822            llm_calls: Vec::new(),
1823            next_llm_ordinal: 0,
1824            session_services: manager,
1825            protocol_turn_options: effective_protocol_turn_options,
1826            protocol_extension,
1827            turn_context,
1828            turn_causes: initial_turn_causes,
1829            pending_queue_claims: initial_queue_claims,
1830            pending_turn_input_claims: initial_turn_input_claims,
1831            checkpoint_messages: crate::tool_dispatch::CheckpointMessageBuffer::default(),
1832            session_execution_lease: session_execution_fence,
1833            runtime_lease_owner: self.runtime_lease_owner.clone(),
1834            turn_phase_probe: self.turn_phase_probe.clone(),
1835        };
1836        let protocol_run_offset = 0;
1837        self.mark_phase_begin(RuntimeTurnPhase::EffectLoop);
1838        let run_result = drive_turn_to_completion(
1839            driver.run(prepared.messages, event_tx, cancel, protocol_run_offset),
1840            &mut event_rx,
1841            &mut assembler,
1842            &child_usage_event_relay,
1843            events,
1844            turn_events,
1845        )
1846        .await;
1847        let (new_messages, _new_protocol_iteration) = match run_result {
1848            Ok(result) => result,
1849            Err(err) => {
1850                self.mark_phase_end(RuntimeTurnPhase::EffectLoop);
1851                let RuntimeTurnDriver {
1852                    session,
1853                    pending_queue_claims,
1854                    pending_turn_input_claims,
1855                    ..
1856                } = driver;
1857                self.session = Some(session);
1858                self.abandon_queued_work_claims_after_lease_loss(&err, &pending_queue_claims)
1859                    .await;
1860                self.abandon_turn_input_claims_after_lease_loss(&err, &pending_turn_input_claims)
1861                    .await;
1862                return Err(err);
1863            }
1864        };
1865        self.mark_phase_end(RuntimeTurnPhase::EffectLoop);
1866        tracing::debug!(
1867            new_message_count = new_messages.len(),
1868            tool_call_count = assembler.tool_calls.len(),
1869            "runtime post-run_task"
1870        );
1871
1872        let RuntimeTurnDriver {
1873            session,
1874            policy,
1875            turn_pipeline,
1876            llm_calls,
1877            pending_queue_claims,
1878            pending_turn_input_claims,
1879            ..
1880        } = driver;
1881        self.session = Some(session);
1882        let pending_queue_claims_for_abandon = pending_queue_claims.clone();
1883        let pending_turn_input_claims_for_abandon = pending_turn_input_claims.clone();
1884        let finish_result = self
1885            .finish_turn(
1886                TurnFinishInput {
1887                    turn_pipeline,
1888                    assembler: assembler.with_llm_calls(llm_calls),
1889                    new_messages,
1890                    policy,
1891                    turn_index,
1892                    queued_work_claims: pending_queue_claims,
1893                    turn_input_claims: pending_turn_input_claims,
1894                    trace_turn_id,
1895                },
1896                events,
1897                &finish_scoped_effect_controller,
1898                &cancel_state,
1899                session_execution_lease,
1900                session_execution_lease_release_policy,
1901            )
1902            .await;
1903        if let Err(err) = &finish_result {
1904            self.abandon_queued_work_claims_after_lease_loss(
1905                err,
1906                &pending_queue_claims_for_abandon,
1907            )
1908            .await;
1909            self.abandon_turn_input_claims_after_lease_loss(
1910                err,
1911                &pending_turn_input_claims_for_abandon,
1912            )
1913            .await;
1914        }
1915        finish_result
1916    }
1917    async fn normalize_input_items(
1918        &self,
1919        items: &[InputItem],
1920        image_blobs: &HashMap<String, Vec<u8>>,
1921    ) -> Result<Vec<NormalizedItem>, String> {
1922        normalize_input_items(
1923            items,
1924            image_blobs,
1925            self.host.core.durability.attachment_store.as_ref(),
1926        )
1927        .await
1928    }
1929}
1930
1931pub fn ensure_durable_effect_input(input: &TurnInput) -> Result<(), RuntimeError> {
1932    if input.protocol_extension.is_some() {
1933        return Err(RuntimeError::new(
1934            RuntimeErrorCode::DurableEffectLiveProtocolExtension,
1935            "durable effect hosts do not support live protocol_extension inputs; encode replayable data in protocol_turn_options or persisted plugin state",
1936        ));
1937    }
1938    input
1939        .turn_context
1940        .live_plugin_inputs()
1941        .durable_effect_rejection()?;
1942    Ok(())
1943}
1944
1945async fn emit_turn_activity_to_sink(events: &dyn TurnActivitySink, activity: TurnActivity) {
1946    if !events.is_noop() {
1947        events.emit(activity).await;
1948    }
1949}
1950
1951/// Pump the turn driver's event channel into the host sinks while the run
1952/// future executes, then drain any events emitted between completion and the
1953/// sender dropping.
1954///
1955/// Both the fresh and resumed turn entry points construct a
1956/// `RuntimeTurnDriver`, kick off its run future, and need identical
1957/// event-pump/drain behavior before tearing the driver down. Only the driver
1958/// construction and post-run teardown differ, so each caller owns those and
1959/// shares this loop.
1960async fn drive_turn_to_completion<F>(
1961    run_future: F,
1962    event_rx: &mut mpsc::Receiver<RuntimeStreamEvent>,
1963    assembler: &mut TurnAssembler,
1964    child_usage_event_relay: &ChildUsageEventRelay,
1965    events: &dyn EventSink,
1966    turn_events: &dyn TurnActivitySink,
1967) -> Result<(crate::MessageSequence, usize), RuntimeError>
1968where
1969    F: std::future::Future<Output = Result<(crate::MessageSequence, usize), RuntimeError>>,
1970{
1971    let run_result = {
1972        let mut run_future = Box::pin(run_future);
1973        loop {
1974            tokio::select! {
1975                maybe_event = event_rx.recv() => {
1976                    if let Some(event) = maybe_event {
1977                        emit_runtime_stream_event_to_sinks(
1978                            events,
1979                            turn_events,
1980                            event,
1981                            assembler,
1982                        )
1983                        .await;
1984                    }
1985                }
1986                completed = run_future.as_mut() => {
1987                    child_usage_event_relay.clear();
1988                    break completed;
1989                }
1990            }
1991        }
1992    };
1993    while let Some(event) = event_rx.recv().await {
1994        emit_runtime_stream_event_to_sinks(events, turn_events, event, assembler).await;
1995    }
1996    run_result
1997}
1998
1999async fn emit_runtime_stream_event_to_sinks(
2000    events: &dyn EventSink,
2001    turn_events: &dyn TurnActivitySink,
2002    event: RuntimeStreamEvent,
2003    assembler: &mut TurnAssembler,
2004) {
2005    match event {
2006        RuntimeStreamEvent::Session(event) => {
2007            assembler.push(&event);
2008            emit_session_event_to_sink(events, event).await;
2009        }
2010        RuntimeStreamEvent::Turn(activity) => {
2011            emit_turn_activity_to_sink(turn_events, activity).await;
2012        }
2013    }
2014}
2015
2016#[cfg(test)]
2017mod tests {
2018    use super::agent_frame_follow_turn_id;
2019
2020    #[test]
2021    fn agent_frame_follow_turn_ids_are_distinct_and_deterministic() {
2022        assert_eq!(agent_frame_follow_turn_id("root-turn", 0), "root-turn");
2023        assert_eq!(
2024            agent_frame_follow_turn_id("root-turn", 1),
2025            "root-turn:agent-frame:1"
2026        );
2027        assert_eq!(
2028            agent_frame_follow_turn_id("root-turn", 2),
2029            "root-turn:agent-frame:2"
2030        );
2031    }
2032}