Skip to main content

tea_session/
state.rs

1use std::collections::BTreeMap;
2
3use serde_json::Value;
4use tea_protocol::{
5    ApprovalDecision, ApprovalId, BranchId, CanonicalMessage, ContentBlock, ExecutionTarget,
6    MessageId, ModelRef, PolicyDecision, ProfileId, ProtocolMetadata, ProtocolTimestamp,
7    ReasoningEffort, RecordId, RunId, SessionId, SessionSequence, ToolCallId, ToolFailure,
8    ToolIdempotency, ToolPresentation, TurnId,
9};
10
11/// Active model and product-profile configuration derived from durable records.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct SessionConfiguration {
14    model: Option<ModelRef>,
15    profile_id: ProfileId,
16    reasoning_effort: Option<ReasoningEffort>,
17}
18
19impl SessionConfiguration {
20    /// Returns the selected model, when one has been configured.
21    #[must_use]
22    pub const fn model_ref(&self) -> Option<&ModelRef> {
23        self.model.as_ref()
24    }
25
26    /// Returns the provider-local model selector, when one is configured.
27    #[must_use]
28    pub const fn model_id(&self) -> Option<&tea_protocol::ModelId> {
29        match &self.model {
30            Some(model) => Some(model.model_id()),
31            None => None,
32        }
33    }
34
35    /// Returns the selected provider, when one is configured.
36    #[must_use]
37    pub const fn provider_id(&self) -> Option<&tea_protocol::ProviderId> {
38        match &self.model {
39            Some(model) => Some(model.provider_id()),
40            None => None,
41        }
42    }
43
44    /// Returns the selected product profile.
45    #[must_use]
46    pub const fn profile_id(&self) -> &ProfileId {
47        &self.profile_id
48    }
49
50    /// Returns the explicit session reasoning effort, when configured.
51    #[must_use]
52    pub const fn reasoning_effort(&self) -> Option<ReasoningEffort> {
53        self.reasoning_effort
54    }
55}
56
57/// Durable pending approval reconstructed from canonical records.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PendingApproval {
60    approval_id: ApprovalId,
61    tool_call_id: ToolCallId,
62    expires_at: ProtocolTimestamp,
63    requested_at: ProtocolTimestamp,
64}
65
66impl PendingApproval {
67    /// Returns the approval identity.
68    #[must_use]
69    pub const fn approval_id(&self) -> ApprovalId {
70        self.approval_id
71    }
72
73    /// Returns the tool call awaiting a decision.
74    #[must_use]
75    pub const fn tool_call_id(&self) -> ToolCallId {
76        self.tool_call_id
77    }
78
79    /// Returns the caller-clock expiry boundary.
80    #[must_use]
81    pub const fn expires_at(&self) -> ProtocolTimestamp {
82        self.expires_at
83    }
84
85    /// Returns when the canonical request became durable.
86    #[must_use]
87    pub const fn requested_at(&self) -> ProtocolTimestamp {
88        self.requested_at
89    }
90}
91
92/// Durable execution/recovery state for a tool call.
93#[derive(Debug, Clone, PartialEq)]
94pub enum ToolExecutionState {
95    /// The tool call exists but execution has not started.
96    NotStarted,
97    /// Execution crossed its durable start boundary.
98    Started {
99        /// Selected execution boundary.
100        execution_target: ExecutionTarget,
101        /// Recovery semantics declared at start.
102        idempotency: ToolIdempotency,
103    },
104    /// Execution reached a durable terminal result.
105    Finished {
106        /// Whether the terminal result is an error.
107        is_error: bool,
108        /// Canonical terminal content committed before a tool-result message.
109        content: Vec<ContentBlock>,
110        /// Machine-readable failure when terminal result is an error.
111        error: Option<ToolFailure>,
112        /// Optional UI-only presentation retained out of model context.
113        presentation: Option<ToolPresentation>,
114    },
115    /// Execution was interrupted after start and has uncertain outcome.
116    Interrupted {
117        /// English technical recovery diagnostic.
118        reason: String,
119        /// Selected execution boundary.
120        execution_target: ExecutionTarget,
121        /// Recovery semantics declared at start.
122        idempotency: ToolIdempotency,
123    },
124}
125
126/// Materialized lifecycle of one requested tool call.
127#[derive(Debug, Clone, PartialEq)]
128pub struct ToolCallState {
129    tool_call_id: ToolCallId,
130    tool_name: String,
131    arguments: Value,
132    policy_decision: Option<PolicyDecision>,
133    approval_id: Option<ApprovalId>,
134    approval_decision: Option<ApprovalDecision>,
135    execution: ToolExecutionState,
136    result_message_id: Option<MessageId>,
137}
138
139impl ToolCallState {
140    /// Returns the stable tool-call identity.
141    #[must_use]
142    pub const fn tool_call_id(&self) -> ToolCallId {
143        self.tool_call_id
144    }
145
146    /// Returns the registered tool name.
147    #[must_use]
148    pub fn tool_name(&self) -> &str {
149        &self.tool_name
150    }
151
152    /// Returns validated provider-neutral arguments.
153    #[must_use]
154    pub const fn arguments(&self) -> &Value {
155        &self.arguments
156    }
157
158    /// Returns the durable policy decision, when evaluated.
159    #[must_use]
160    pub const fn policy_decision(&self) -> Option<PolicyDecision> {
161        self.policy_decision
162    }
163
164    /// Returns the associated approval identity, when requested.
165    #[must_use]
166    pub const fn approval_id(&self) -> Option<ApprovalId> {
167        self.approval_id
168    }
169
170    /// Returns the terminal approval decision, when resolved.
171    #[must_use]
172    pub const fn approval_decision(&self) -> Option<ApprovalDecision> {
173        self.approval_decision
174    }
175
176    /// Returns durable execution/recovery state.
177    #[must_use]
178    pub const fn execution(&self) -> &ToolExecutionState {
179        &self.execution
180    }
181
182    /// Returns the sole committed result message, when present.
183    #[must_use]
184    pub const fn result_message_id(&self) -> Option<MessageId> {
185        self.result_message_id
186    }
187}
188
189/// Provider-run state required for restart diagnostics.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub enum RunRecoveryState {
192    /// Provider streaming was interrupted before durable terminal output.
193    Interrupted {
194        /// Active turn at interruption.
195        turn_id: TurnId,
196        /// English technical diagnostic.
197        reason: String,
198    },
199    /// The run was explicitly cancelled.
200    Cancelled,
201}
202
203/// Durable turn boundary reconstructed during replay.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct TurnCheckpoint {
206    run_id: RunId,
207    turn_id: TurnId,
208    record_id: RecordId,
209    sequence: SessionSequence,
210    next_action: tea_protocol::NextTurnAction,
211}
212
213impl TurnCheckpoint {
214    pub(crate) const fn new(
215        run_id: RunId,
216        turn_id: TurnId,
217        record_id: RecordId,
218        sequence: SessionSequence,
219        next_action: tea_protocol::NextTurnAction,
220    ) -> Self {
221        Self {
222            run_id,
223            turn_id,
224            record_id,
225            sequence,
226            next_action,
227        }
228    }
229
230    /// Returns the checkpointed run.
231    #[must_use]
232    pub const fn run_id(&self) -> RunId {
233        self.run_id
234    }
235
236    /// Returns the checkpointed turn.
237    #[must_use]
238    pub const fn turn_id(&self) -> TurnId {
239        self.turn_id
240    }
241
242    /// Returns the record establishing this checkpoint.
243    #[must_use]
244    pub const fn record_id(&self) -> RecordId {
245        self.record_id
246    }
247
248    /// Returns the authoritative sequence.
249    #[must_use]
250    pub const fn sequence(&self) -> SessionSequence {
251        self.sequence
252    }
253
254    /// Returns the next action allowed by the checkpoint.
255    #[must_use]
256    pub const fn next_action(&self) -> tea_protocol::NextTurnAction {
257        self.next_action
258    }
259}
260
261/// Latest durable compaction summary and its source provenance.
262#[derive(Debug, Clone, PartialEq)]
263pub struct SessionCompaction {
264    summary: CanonicalMessage,
265    compacted_through_record_id: RecordId,
266}
267
268impl SessionCompaction {
269    /// Returns the canonical summary message used for future model context.
270    #[must_use]
271    pub const fn summary(&self) -> &CanonicalMessage {
272        &self.summary
273    }
274
275    /// Returns the last source record replaced in model context.
276    #[must_use]
277    pub const fn compacted_through_record_id(&self) -> RecordId {
278        self.compacted_through_record_id
279    }
280}
281
282/// Summary of one durable branch.
283#[derive(Debug, Clone, PartialEq, Eq)]
284#[allow(clippy::struct_field_names)] // Explicit `_id` fields retain strong identifier semantics.
285pub struct BranchSummary {
286    branch_id: BranchId,
287    source_branch_id: Option<BranchId>,
288    from_record_id: RecordId,
289    leaf_record_id: RecordId,
290}
291
292impl BranchSummary {
293    pub(crate) const fn new(
294        branch_id: BranchId,
295        source_branch_id: Option<BranchId>,
296        from_record_id: RecordId,
297        leaf_record_id: RecordId,
298    ) -> Self {
299        Self {
300            branch_id,
301            source_branch_id,
302            from_record_id,
303            leaf_record_id,
304        }
305    }
306
307    pub(crate) fn set_leaf(&mut self, record_id: RecordId) {
308        self.leaf_record_id = record_id;
309    }
310
311    /// Returns branch identity.
312    #[must_use]
313    pub const fn branch_id(&self) -> BranchId {
314        self.branch_id
315    }
316
317    /// Returns source branch for a fork, or `None` for the root branch.
318    #[must_use]
319    pub const fn source_branch_id(&self) -> Option<BranchId> {
320        self.source_branch_id
321    }
322
323    /// Returns the durable source position used to create this branch.
324    #[must_use]
325    pub const fn from_record_id(&self) -> RecordId {
326        self.from_record_id
327    }
328
329    /// Returns the current durable leaf record.
330    #[must_use]
331    pub const fn leaf_record_id(&self) -> RecordId {
332        self.leaf_record_id
333    }
334}
335
336/// Deterministic session projection derived only from the append-only record log.
337#[derive(Debug, Clone, PartialEq)]
338pub struct MaterializedSessionState {
339    pub(crate) session_id: SessionId,
340    pub(crate) tail_sequence: SessionSequence,
341    pub(crate) tail_record_id: RecordId,
342    pub(crate) metadata: ProtocolMetadata,
343    pub(crate) configuration: SessionConfiguration,
344    pub(crate) messages: Vec<CanonicalMessage>,
345    pub(crate) pending_approvals: BTreeMap<ApprovalId, PendingApproval>,
346    pub(crate) tool_calls: BTreeMap<ToolCallId, ToolCallState>,
347    pub(crate) active_branch_id: Option<BranchId>,
348    pub(crate) branches: BTreeMap<BranchId, BranchSummary>,
349    pub(crate) run_recovery: BTreeMap<RunId, RunRecoveryState>,
350    pub(crate) latest_checkpoint: Option<TurnCheckpoint>,
351    pub(crate) latest_compaction: Option<SessionCompaction>,
352}
353
354impl MaterializedSessionState {
355    /// Returns session identity.
356    #[must_use]
357    pub const fn session_id(&self) -> SessionId {
358        self.session_id
359    }
360
361    /// Returns the current authoritative tail sequence.
362    #[must_use]
363    pub const fn tail_sequence(&self) -> SessionSequence {
364        self.tail_sequence
365    }
366
367    /// Returns the current tail record identity.
368    #[must_use]
369    pub const fn tail_record_id(&self) -> RecordId {
370        self.tail_record_id
371    }
372
373    /// Returns bounded creation metadata.
374    #[must_use]
375    pub const fn metadata(&self) -> &ProtocolMetadata {
376        &self.metadata
377    }
378
379    /// Returns active configuration.
380    #[must_use]
381    pub const fn configuration(&self) -> &SessionConfiguration {
382        &self.configuration
383    }
384
385    /// Returns the active durable transcript.
386    #[must_use]
387    pub fn messages(&self) -> &[CanonicalMessage] {
388        &self.messages
389    }
390
391    /// Returns pending approvals in stable ID order.
392    #[must_use]
393    pub const fn pending_approvals(&self) -> &BTreeMap<ApprovalId, PendingApproval> {
394        &self.pending_approvals
395    }
396
397    /// Returns tool-call lifecycle projections in stable ID order.
398    #[must_use]
399    pub const fn tool_calls(&self) -> &BTreeMap<ToolCallId, ToolCallState> {
400        &self.tool_calls
401    }
402
403    /// Returns active branch, or `None` for a legacy unbranched Protocol 1.0 log.
404    #[must_use]
405    pub const fn active_branch_id(&self) -> Option<BranchId> {
406        self.active_branch_id
407    }
408
409    /// Returns durable branch summaries in stable ID order.
410    #[must_use]
411    pub const fn branches(&self) -> &BTreeMap<BranchId, BranchSummary> {
412        &self.branches
413    }
414
415    /// Returns provider-run restart diagnostics.
416    #[must_use]
417    pub const fn run_recovery(&self) -> &BTreeMap<RunId, RunRecoveryState> {
418        &self.run_recovery
419    }
420
421    /// Returns the latest durable turn checkpoint.
422    #[must_use]
423    pub const fn latest_checkpoint(&self) -> Option<&TurnCheckpoint> {
424        self.latest_checkpoint.as_ref()
425    }
426
427    /// Returns the latest compaction summary without removing original messages.
428    #[must_use]
429    pub const fn latest_compaction(&self) -> Option<&SessionCompaction> {
430        self.latest_compaction.as_ref()
431    }
432}
433
434pub(crate) fn message_id(message: &CanonicalMessage) -> MessageId {
435    match message {
436        CanonicalMessage::User { id, .. }
437        | CanonicalMessage::Assistant { id, .. }
438        | CanonicalMessage::ToolResult { id, .. } => *id,
439    }
440}
441
442pub(crate) fn declared_tool_calls(
443    message: &CanonicalMessage,
444) -> impl Iterator<Item = (ToolCallId, &str, &Value)> {
445    let content = match message {
446        CanonicalMessage::Assistant { content, .. } => Some(content.as_slice()),
447        CanonicalMessage::User { .. } | CanonicalMessage::ToolResult { .. } => None,
448    };
449    content
450        .into_iter()
451        .flatten()
452        .filter_map(|block| match block {
453            ContentBlock::ToolCall {
454                tool_call_id,
455                tool_name,
456                arguments,
457                ..
458            } => Some((*tool_call_id, tool_name.as_str(), arguments)),
459            ContentBlock::Text { .. }
460            | ContentBlock::Thinking { .. }
461            | ContentBlock::Image { .. }
462            | ContentBlock::HostedTool { .. }
463            | ContentBlock::Citation { .. } => None,
464        })
465}
466
467pub(crate) fn new_state(
468    record_id: RecordId,
469    session_id: SessionId,
470    sequence: SessionSequence,
471    profile_id: ProfileId,
472    metadata: ProtocolMetadata,
473    root_branch_id: Option<BranchId>,
474) -> MaterializedSessionState {
475    let branches = root_branch_id.map_or_else(BTreeMap::new, |branch_id| {
476        BTreeMap::from([(
477            branch_id,
478            BranchSummary {
479                branch_id,
480                source_branch_id: None,
481                from_record_id: record_id,
482                leaf_record_id: record_id,
483            },
484        )])
485    });
486    MaterializedSessionState {
487        session_id,
488        tail_sequence: sequence,
489        tail_record_id: record_id,
490        metadata,
491        configuration: SessionConfiguration {
492            model: None,
493            profile_id,
494            reasoning_effort: None,
495        },
496        messages: Vec::new(),
497        pending_approvals: BTreeMap::new(),
498        tool_calls: BTreeMap::new(),
499        active_branch_id: root_branch_id,
500        branches,
501        run_recovery: BTreeMap::new(),
502        latest_checkpoint: None,
503        latest_compaction: None,
504    }
505}
506
507pub(crate) fn new_pending_approval(
508    approval_id: ApprovalId,
509    tool_call_id: ToolCallId,
510    expires_at: ProtocolTimestamp,
511    requested_at: ProtocolTimestamp,
512) -> PendingApproval {
513    PendingApproval {
514        approval_id,
515        tool_call_id,
516        expires_at,
517        requested_at,
518    }
519}
520
521pub(crate) fn new_tool_call(
522    tool_call_id: ToolCallId,
523    tool_name: String,
524    arguments: Value,
525) -> ToolCallState {
526    ToolCallState {
527        tool_call_id,
528        tool_name,
529        arguments,
530        policy_decision: None,
531        approval_id: None,
532        approval_decision: None,
533        execution: ToolExecutionState::NotStarted,
534        result_message_id: None,
535    }
536}
537
538pub(crate) fn set_model(configuration: &mut SessionConfiguration, model: ModelRef) {
539    configuration.model = Some(model);
540}
541
542pub(crate) fn set_profile(configuration: &mut SessionConfiguration, profile_id: ProfileId) {
543    configuration.profile_id = profile_id;
544}
545
546pub(crate) fn set_reasoning_effort(
547    configuration: &mut SessionConfiguration,
548    reasoning_effort: ReasoningEffort,
549) {
550    configuration.reasoning_effort = Some(reasoning_effort);
551}
552
553pub(crate) fn set_policy(tool: &mut ToolCallState, decision: PolicyDecision) {
554    tool.policy_decision = Some(decision);
555}
556
557pub(crate) fn set_approval(tool: &mut ToolCallState, approval_id: ApprovalId) {
558    tool.approval_id = Some(approval_id);
559}
560
561pub(crate) fn resolve_approval(tool: &mut ToolCallState, decision: ApprovalDecision) {
562    tool.approval_decision = Some(decision);
563}
564
565pub(crate) fn start_tool(
566    tool: &mut ToolCallState,
567    execution_target: ExecutionTarget,
568    idempotency: ToolIdempotency,
569) {
570    tool.execution = ToolExecutionState::Started {
571        execution_target,
572        idempotency,
573    };
574}
575
576pub(crate) fn finish_tool(
577    tool: &mut ToolCallState,
578    is_error: bool,
579    content: Vec<ContentBlock>,
580    error: Option<ToolFailure>,
581    presentation: Option<ToolPresentation>,
582) {
583    tool.execution = ToolExecutionState::Finished {
584        is_error,
585        content,
586        error,
587        presentation,
588    };
589}
590
591pub(crate) fn set_compaction(
592    state: &mut MaterializedSessionState,
593    summary: CanonicalMessage,
594    compacted_through_record_id: RecordId,
595) {
596    // Replace the compacted prefix with the summary message so the model-visible
597    // transcript begins with the summary followed by later uncompacted records.
598    // Original records remain in the durable log for audit and replay.
599    state.messages.clear();
600    state.messages.push(summary.clone());
601    state.latest_compaction = Some(SessionCompaction {
602        summary,
603        compacted_through_record_id,
604    });
605}
606
607pub(crate) fn commit_tool_result(tool: &mut ToolCallState, message_id: MessageId) {
608    tool.result_message_id = Some(message_id);
609}
610
611pub(crate) fn interrupt_tool(tool: &mut ToolCallState, reason: String) {
612    if let ToolExecutionState::Started {
613        execution_target,
614        idempotency,
615    } = tool.execution
616    {
617        tool.execution = ToolExecutionState::Interrupted {
618            reason,
619            execution_target,
620            idempotency,
621        };
622    }
623}