Skip to main content

halter_protocol/
lib.rs

1// pattern: Functional Core
2
3use std::fmt;
4use std::path::PathBuf;
5
6use bytes::Bytes;
7use chrono::{DateTime, Utc};
8use indexmap::IndexMap;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use thiserror::Error;
13use uuid::Uuid;
14
15/// Shared string payloads stay as `String` for now; this is the swap point for any future `Arc<str>` migration.
16pub type SharedStr = String;
17
18/// Historical sampling temperature used by older config resolution. Provider
19/// requests now omit temperature unless `[providers.<name>].temperature` is
20/// configured explicitly.
21pub const DEFAULT_TEMPERATURE: f32 = 0.7;
22pub type MediaType = String;
23pub type ReplaySignature = String;
24pub type ContentHash = String;
25pub type Timestamp = DateTime<Utc>;
26
27macro_rules! id_type {
28    ($name:ident) => {
29        #[derive(
30            Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
31        )]
32        pub struct $name(pub String);
33
34        impl $name {
35            #[must_use]
36            pub fn new() -> Self {
37                Self(Uuid::new_v4().to_string())
38            }
39        }
40
41        impl Default for $name {
42            fn default() -> Self {
43                Self::new()
44            }
45        }
46
47        impl fmt::Display for $name {
48            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49                f.write_str(&self.0)
50            }
51        }
52
53        impl From<&str> for $name {
54            fn from(value: &str) -> Self {
55                Self(value.to_owned())
56            }
57        }
58
59        impl From<String> for $name {
60            fn from(value: String) -> Self {
61                Self(value)
62            }
63        }
64    };
65}
66
67macro_rules! string_wrapper {
68    ($name:ident) => {
69        #[derive(
70            Debug,
71            Clone,
72            PartialEq,
73            Eq,
74            PartialOrd,
75            Ord,
76            Hash,
77            Default,
78            Serialize,
79            Deserialize,
80            JsonSchema,
81        )]
82        pub struct $name(pub String);
83
84        impl fmt::Display for $name {
85            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86                f.write_str(&self.0)
87            }
88        }
89
90        impl From<&str> for $name {
91            fn from(value: &str) -> Self {
92                Self(value.to_owned())
93            }
94        }
95
96        impl From<String> for $name {
97            fn from(value: String) -> Self {
98                Self(value)
99            }
100        }
101    };
102}
103
104id_type!(MessageId);
105id_type!(BlockId);
106id_type!(ToolCallId);
107id_type!(PromptId);
108id_type!(PromptSegmentId);
109id_type!(SessionId);
110id_type!(TurnId);
111id_type!(SkillId);
112id_type!(PluginId);
113id_type!(AgentId);
114
115string_wrapper!(Revision);
116string_wrapper!(ModelId);
117string_wrapper!(ToolName);
118string_wrapper!(ToolAlias);
119string_wrapper!(SkillName);
120string_wrapper!(AgentName);
121string_wrapper!(ProviderName);
122
123#[derive(
124    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
125)]
126#[serde(rename_all = "snake_case")]
127pub enum ModelRole {
128    Default,
129    Plan,
130    Subagent,
131    Small,
132}
133
134impl ModelRole {
135    #[must_use]
136    pub const fn default_role() -> Self {
137        Self::Default
138    }
139
140    #[must_use]
141    pub const fn plan() -> Self {
142        Self::Plan
143    }
144
145    #[must_use]
146    pub const fn subagent() -> Self {
147        Self::Subagent
148    }
149
150    #[must_use]
151    pub const fn small() -> Self {
152        Self::Small
153    }
154
155    #[must_use]
156    pub const fn as_str(&self) -> &'static str {
157        match self {
158            Self::Default => "default",
159            Self::Plan => "plan",
160            Self::Subagent => "subagent",
161            Self::Small => "small",
162        }
163    }
164}
165
166impl Default for ModelRole {
167    fn default() -> Self {
168        Self::default_role()
169    }
170}
171
172impl std::str::FromStr for ModelRole {
173    type Err = String;
174
175    fn from_str(value: &str) -> Result<Self, Self::Err> {
176        match value {
177            "default" => Ok(Self::Default),
178            "plan" => Ok(Self::Plan),
179            "subagent" => Ok(Self::Subagent),
180            "small" => Ok(Self::Small),
181            other => Err(format!(
182                "unknown ModelRole '{other}'; expected one of: default, plan, subagent, small"
183            )),
184        }
185    }
186}
187
188impl fmt::Display for ModelRole {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        f.write_str(self.as_str())
191    }
192}
193
194#[derive(
195    Debug,
196    Clone,
197    Copy,
198    Default,
199    PartialEq,
200    Eq,
201    PartialOrd,
202    Ord,
203    Hash,
204    Serialize,
205    Deserialize,
206    JsonSchema,
207)]
208#[serde(rename_all = "snake_case")]
209pub enum SubagentEventForwarding {
210    #[default]
211    Off,
212    All,
213}
214
215impl SubagentEventForwarding {
216    #[must_use]
217    pub const fn is_enabled(self) -> bool {
218        matches!(self, Self::All)
219    }
220}
221
222#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
223#[serde(rename_all = "snake_case")]
224pub enum ProviderKind {
225    Anthropic,
226    OpenAi,
227    OpenRouter,
228    Fake,
229}
230
231#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
232#[serde(rename_all = "snake_case")]
233pub enum ApiKind {
234    AnthropicMessages,
235    OpenAiResponses,
236    OpenAiChat,
237    Fake,
238}
239
240#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
241#[serde(rename_all = "snake_case")]
242pub enum ReasoningEffort {
243    Low,
244    Medium,
245    High,
246    Xhigh,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
250pub struct Usage {
251    pub input_tokens: u64,
252    pub output_tokens: u64,
253    pub cache_creation_input_tokens: u64,
254    pub cache_read_input_tokens: u64,
255}
256
257#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
258#[serde(rename_all = "snake_case")]
259pub enum StopReason {
260    EndTurn,
261    ToolUse,
262    Interrupted,
263    MaxTokens,
264    Error,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
268pub struct ReplayMeta {
269    pub provider_name: Option<ProviderName>,
270    pub model: Option<ModelId>,
271}
272
273#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
274#[serde(rename_all = "snake_case")]
275pub enum HookWarningSeverity {
276    #[default]
277    Warning,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
281pub struct HookWarning {
282    pub severity: HookWarningSeverity,
283    pub category: SharedStr,
284    pub plugin_id: Option<PluginId>,
285    pub plugin_name: Option<SharedStr>,
286    pub source_path: Option<PathBuf>,
287    pub message: SharedStr,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
291pub struct SystemMessage {
292    pub id: MessageId,
293    pub created_at: Timestamp,
294    pub text: SharedStr,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
298pub struct UserMessage {
299    pub id: MessageId,
300    pub created_at: Timestamp,
301    pub parts: Vec<UserPart>,
302}
303
304impl UserMessage {
305    #[must_use]
306    pub fn text(text: impl Into<String>) -> Self {
307        Self {
308            id: MessageId::new(),
309            created_at: Utc::now(),
310            parts: vec![UserPart::Text { text: text.into() }],
311        }
312    }
313
314    #[must_use]
315    pub fn plain_text(&self) -> String {
316        self.parts
317            .iter()
318            .filter_map(|part| match part {
319                UserPart::Text { text } => Some(text.as_str()),
320                UserPart::Image { .. } | UserPart::Document { .. } => None,
321            })
322            .collect::<Vec<_>>()
323            .join("\n")
324    }
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
328#[serde(tag = "kind", rename_all = "snake_case")]
329pub enum UserPart {
330    Text {
331        text: SharedStr,
332    },
333    Image {
334        media_type: MediaType,
335        #[schemars(with = "Vec<u8>")]
336        data: Bytes,
337    },
338    Document {
339        media_type: MediaType,
340        #[schemars(with = "Vec<u8>")]
341        data: Bytes,
342    },
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
346pub struct AssistantMessage {
347    pub id: MessageId,
348    pub created_at: Timestamp,
349    pub parts: Vec<AssistantPart>,
350    pub stop_reason: Option<StopReason>,
351    pub usage: Option<Usage>,
352    pub replay_meta: ReplayMeta,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
356#[serde(tag = "kind", rename_all = "snake_case")]
357pub enum AssistantPart {
358    Text { text: SharedStr },
359    Thinking(ThinkingBlock),
360    ToolCall(ToolCall),
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
364pub struct ThinkingBlock {
365    pub text: SharedStr,
366    pub signature: Option<ReplaySignature>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
370pub struct ToolCall {
371    pub id: ToolCallId,
372    pub name: ToolName,
373    pub arguments: Value,
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
377pub struct ToolResultMessage {
378    pub id: MessageId,
379    pub call_id: ToolCallId,
380    pub content: ToolResult,
381    pub error: Option<ToolError>,
382    pub created_at: Timestamp,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
386#[serde(tag = "role", rename_all = "snake_case")]
387pub enum Message {
388    System(SystemMessage),
389    User(UserMessage),
390    Assistant(AssistantMessage),
391    Tool(ToolResultMessage),
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
395#[serde(tag = "kind", rename_all = "snake_case")]
396pub enum StreamEvent {
397    MessageStart {
398        id: MessageId,
399    },
400    TextStart {
401        id: BlockId,
402    },
403    TextDelta {
404        id: BlockId,
405        delta: SharedStr,
406    },
407    TextEnd {
408        id: BlockId,
409    },
410    ThinkingStart {
411        id: BlockId,
412    },
413    ThinkingDelta {
414        id: BlockId,
415        delta: SharedStr,
416    },
417    ThinkingEnd {
418        id: BlockId,
419        signature: Option<ReplaySignature>,
420    },
421    ToolCallStart {
422        id: BlockId,
423        tool_call_id: ToolCallId,
424        name: ToolName,
425    },
426    ToolArgsDelta {
427        id: BlockId,
428        delta: SharedStr,
429    },
430    ToolCallEnd {
431        id: BlockId,
432    },
433    UsageUpdate {
434        usage: Usage,
435    },
436    MessageEnd {
437        id: MessageId,
438        stop_reason: StopReason,
439        /// The provider's response ID, used for `previous_response_id` chaining.
440        #[serde(default, skip_serializing_if = "Option::is_none")]
441        response_id: Option<String>,
442    },
443    ProviderWarning {
444        message: SharedStr,
445    },
446    Error {
447        error: ProviderError,
448    },
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
452pub struct Turn {
453    pub id: TurnId,
454    pub user_message: UserMessage,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub default_model: Option<ModelId>,
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub subagent_model: Option<ModelId>,
459}
460
461impl Turn {
462    #[must_use]
463    pub fn user(text: impl Into<String>) -> Self {
464        Self {
465            id: TurnId::new(),
466            user_message: UserMessage::text(text),
467            default_model: None,
468            subagent_model: None,
469        }
470    }
471
472    #[must_use]
473    pub fn with_default_model(mut self, model: impl Into<ModelId>) -> Self {
474        self.default_model = Some(model.into());
475        self
476    }
477
478    #[must_use]
479    pub fn with_subagent_model(mut self, model: impl Into<ModelId>) -> Self {
480        self.subagent_model = Some(model.into());
481        self
482    }
483}
484
485#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
486#[serde(rename_all = "snake_case")]
487pub enum SubagentState {
488    Running,
489    Completed,
490    Failed,
491    Cancelled,
492    Closed,
493}
494
495impl SubagentState {
496    #[must_use]
497    pub fn is_terminal(self) -> bool {
498        matches!(
499            self,
500            Self::Completed | Self::Failed | Self::Cancelled | Self::Closed
501        )
502    }
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
506pub struct SpawnSubagentRequest {
507    pub message: String,
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub agent_type: Option<AgentName>,
510    #[serde(default)]
511    pub fork_context: bool,
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub model: Option<ModelId>,
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
517pub struct SendSubagentInputRequest {
518    pub target: AgentId,
519    pub message: String,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
523pub struct WaitSubagentRequest {
524    pub targets: Vec<AgentId>,
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub timeout_ms: Option<u64>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
530pub struct CloseSubagentRequest {
531    pub target: AgentId,
532}
533
534#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
535pub struct SubagentStatus {
536    pub agent_id: AgentId,
537    pub session_id: SessionId,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub agent_type: Option<AgentName>,
540    pub task: String,
541    pub state: SubagentState,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub last_message: Option<String>,
544    #[serde(default, skip_serializing_if = "Option::is_none")]
545    pub usage: Option<Usage>,
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    pub error: Option<String>,
548}
549
550impl SubagentStatus {
551    #[must_use]
552    pub fn is_terminal(&self) -> bool {
553        self.state.is_terminal()
554    }
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
558pub struct WaitSubagentResponse {
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub status: Option<SubagentStatus>,
561    pub timed_out: bool,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
565pub struct CloseSubagentResponse {
566    pub previous_status: SubagentStatus,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
570pub struct SubagentSpecWire {
571    pub role: Option<ModelRole>,
572    pub task: String,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
576#[serde(tag = "kind", rename_all = "snake_case")]
577pub enum SessionCommand {
578    SubmitTurn { turn: Turn },
579    InterruptTurn,
580    AppendSystemPrompt { id: PromptId, text: SharedStr },
581    SetModelRole { role: ModelRole },
582    SetModel { model: ModelId },
583    SpawnSubagent { spec: SubagentSpecWire },
584    ReloadResources,
585    Shutdown,
586}
587
588#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
589pub enum Delivery {
590    Lossless,
591    BestEffort,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
595pub struct DeltaItem {
596    pub text: String,
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
600pub struct ToolExecutionOutcome {
601    pub call: ToolCall,
602    pub result: Result<ToolResult, ToolError>,
603}
604
605#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
606#[serde(rename_all = "snake_case")]
607pub enum HookHandlerType {
608    Command,
609    Http,
610    Prompt,
611    Agent,
612    Callback,
613    Function,
614}
615
616#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
617#[serde(rename_all = "snake_case")]
618pub enum HookRunStatus {
619    Running,
620    Completed,
621    Failed,
622    Blocked,
623    Stopped,
624    Cancelled,
625}
626
627#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
628#[serde(rename_all = "snake_case")]
629pub enum HookOutputKind {
630    Warning,
631    Stop,
632    Feedback,
633    Context,
634    Error,
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
638pub struct HookOutputEntry {
639    pub kind: HookOutputKind,
640    pub text: String,
641}
642
643#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
644#[serde(rename_all = "snake_case")]
645pub enum HookSessionStartSource {
646    Startup,
647    Resume,
648    Clear,
649    Compact,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
653pub struct HookRunSummary {
654    pub run_id: String,
655    pub event_name: String,
656    pub handler_type: HookHandlerType,
657    pub plugin_id: PluginId,
658    pub plugin_root: PathBuf,
659    pub status: HookRunStatus,
660    pub status_message: Option<String>,
661    pub started_at: Timestamp,
662    pub completed_at: Option<Timestamp>,
663    pub duration_ms: Option<u64>,
664    pub entries: Vec<HookOutputEntry>,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
668#[serde(tag = "kind", rename_all = "snake_case")]
669pub enum SessionEventPayload {
670    SessionStarted,
671    Warning {
672        message: String,
673    },
674    TurnStarted {
675        turn_id: TurnId,
676    },
677    MessageItem {
678        message: Message,
679    },
680    DeltaItem {
681        delta: DeltaItem,
682    },
683    ToolExecutionStarted {
684        call: ToolCall,
685    },
686    ToolOutput {
687        call_id: ToolCallId,
688        tool_name: ToolName,
689        chunk: SharedStr,
690    },
691    HookStarted {
692        run: HookRunSummary,
693    },
694    HookCompleted {
695        run: HookRunSummary,
696    },
697    ToolExecutionCompleted {
698        outcome: ToolExecutionOutcome,
699    },
700    ApprovalRequested {
701        tool_name: ToolName,
702        reason: String,
703    },
704    ContextCompacted {
705        summary: String,
706    },
707    TurnCompleted {
708        turn_id: TurnId,
709        usage: Usage,
710    },
711    TurnFailed {
712        turn_id: TurnId,
713        error: String,
714        /// Whether the failure came from explicit user/runtime cancellation.
715        #[serde(default)]
716        cancelled: bool,
717        /// Whether the underlying provider error advertised itself as
718        /// retryable. Defaults to `false` so historical replays without this
719        /// field deserialize cleanly.
720        #[serde(default)]
721        retryable: bool,
722    },
723    Lagged {
724        dropped_events: u64,
725    },
726    SessionShutdownComplete,
727}
728
729/// An event that has been committed to the session store and therefore has
730/// been assigned a monotonic `sequence` by the commit boundary. Construct a
731/// `SessionEvent` only via `PendingEvent::into_committed`, `SessionEvent::new_committed`,
732/// or deserialization — the `sequence` field is intentionally not publicly
733/// settable.
734#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
735pub struct SessionEvent {
736    pub session_id: SessionId,
737    pub(crate) sequence: u64,
738    pub delivery: Delivery,
739    pub payload: SessionEventPayload,
740}
741
742impl SessionEvent {
743    /// Construct a committed event with an explicit sequence. This is the
744    /// only public constructor that sets the `sequence` field; call sites
745    /// outside commit boundaries must use `PendingEvent`.
746    #[must_use]
747    pub fn new_committed(
748        session_id: SessionId,
749        sequence: u64,
750        delivery: Delivery,
751        payload: SessionEventPayload,
752    ) -> Self {
753        Self {
754            session_id,
755            sequence,
756            delivery,
757            payload,
758        }
759    }
760
761    #[must_use]
762    pub fn sequence(&self) -> u64 {
763        self.sequence
764    }
765}
766
767/// An event produced during turn execution, before the session store has
768/// assigned a sequence. Convert to `SessionEvent` via `into_committed` once
769/// the store has allocated a sequence number. Holding `sequence`-less events
770/// until commit makes the commit-then-publish invariant type-enforced.
771#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
772pub struct PendingEvent {
773    pub session_id: SessionId,
774    pub delivery: Delivery,
775    pub payload: SessionEventPayload,
776}
777
778impl PendingEvent {
779    #[must_use]
780    pub fn new(session_id: SessionId, delivery: Delivery, payload: SessionEventPayload) -> Self {
781        Self {
782            session_id,
783            delivery,
784            payload,
785        }
786    }
787
788    #[must_use]
789    pub fn into_committed(self, sequence: u64) -> SessionEvent {
790        SessionEvent {
791            session_id: self.session_id,
792            sequence,
793            delivery: self.delivery,
794            payload: self.payload,
795        }
796    }
797}
798
799#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
800pub enum ToolConcurrency {
801    Exclusive,
802    ReadOnly,
803    ParallelSafe,
804}
805
806#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
807pub struct ToolCapabilities {
808    pub mutating: bool,
809    pub requires_approval: bool,
810    pub cancellable: bool,
811    pub long_running: bool,
812}
813
814#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
815pub struct ToolSpec {
816    pub name: ToolName,
817    pub description: SharedStr,
818    pub input_schema: Value,
819    pub concurrency: ToolConcurrency,
820    pub capabilities: ToolCapabilities,
821    pub provider_aliases: IndexMap<ProviderKind, ToolAlias>,
822}
823
824#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
825#[serde(tag = "kind", rename_all = "snake_case")]
826pub enum ToolResult {
827    Empty,
828    Text { text: String },
829    Json { value: Value },
830}
831
832#[derive(Debug, Clone, Error, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
833#[error("{message}")]
834pub struct ToolError {
835    pub message: String,
836}
837
838impl ToolError {
839    #[must_use]
840    pub fn new(message: impl Into<String>) -> Self {
841        Self {
842            message: message.into(),
843        }
844    }
845}
846
847#[derive(Debug, Clone, Error, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
848#[error("{message}")]
849pub struct ProviderError {
850    pub message: String,
851    pub retryable: bool,
852}
853
854impl ProviderError {
855    /// Sentinel message produced by `ProviderError::cancelled` and recognized
856    /// by `is_cancelled`. New consumers should prefer the constructor /
857    /// predicate over inline message comparison.
858    pub const CANCELLED_MESSAGE: &str = "failed to execute provider request: request cancelled";
859
860    #[must_use]
861    pub fn new(message: impl Into<String>, retryable: bool) -> Self {
862        Self {
863            message: message.into(),
864            retryable,
865        }
866    }
867
868    /// Construct a non-retryable cancellation error with the canonical
869    /// message. Existing consumers that match on message text continue to
870    /// work; new consumers should use `is_cancelled()` to distinguish.
871    #[must_use]
872    pub fn cancelled() -> Self {
873        Self {
874            message: Self::CANCELLED_MESSAGE.to_owned(),
875            retryable: false,
876        }
877    }
878
879    #[must_use]
880    pub fn is_cancelled(&self) -> bool {
881        self.message == Self::CANCELLED_MESSAGE
882    }
883}
884
885#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
886pub struct PromptSegment {
887    pub id: PromptSegmentId,
888    pub text: SharedStr,
889    pub volatility: Volatility,
890    pub cache_scope: CacheScope,
891    pub content_hash: ContentHash,
892    /// Logical section the segment belongs to. The prompt assembler groups
893    /// segments by kind so the wire layout (system, then skills, then the
894    /// turn) is independent of insertion order, and so codecs can emit
895    /// cache breakpoints on stable boundaries.
896    #[serde(default)]
897    pub kind: PromptSegmentKind,
898}
899
900#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
901#[serde(rename_all = "snake_case")]
902pub enum PromptSegmentKind {
903    #[default]
904    System,
905    Skill,
906    Append,
907}
908
909#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
910pub enum Volatility {
911    Static,
912    SessionStable,
913    TurnDynamic,
914    AlwaysDynamic,
915}
916
917#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
918pub enum CacheScope {
919    PrefixCacheable,
920    Dynamic,
921}
922
923/// Marks the four section boundaries the runtime asks codecs to expose as
924/// cache breakpoints when the underlying provider supports them.
925///
926/// The order is fixed: system prompt, tool descriptions, skills, then the
927/// most recent user prompt. The "rest of the session" follows the last
928/// breakpoint and is therefore the only window eligible for in-band
929/// compaction by non-dedicated providers.
930#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
931pub struct CacheBreakpoints {
932    pub after_system: bool,
933    pub after_tools: bool,
934    pub after_skills: bool,
935    pub after_user_prompt: bool,
936}
937
938impl CacheBreakpoints {
939    /// All four breakpoints active. The prompt assembler emits this layout
940    /// for any session that has a non-empty system prompt and at least one
941    /// user message; codecs may downgrade as needed.
942    #[must_use]
943    pub fn all() -> Self {
944        Self {
945            after_system: true,
946            after_tools: true,
947            after_skills: true,
948            after_user_prompt: true,
949        }
950    }
951
952    #[must_use]
953    pub fn count_active(&self) -> usize {
954        usize::from(self.after_system)
955            + usize::from(self.after_tools)
956            + usize::from(self.after_skills)
957            + usize::from(self.after_user_prompt)
958    }
959}
960
961pub type FileViewCache = IndexMap<PathBuf, FileViewEntry>;
962
963#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
964pub struct FileViewEntry {
965    pub path: PathBuf,
966    pub full_hash: ContentHash,
967    pub mtime: Timestamp,
968    pub size: u64,
969    pub viewed_ranges: Vec<ViewedRange>,
970    pub last_shown_turn: TurnId,
971}
972
973#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
974pub struct ViewedRange {
975    pub start_line: u32,
976    pub end_line: u32,
977    pub line_anchors: Vec<LineAnchor>,
978}
979
980#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
981pub struct LineAnchor {
982    pub line: u32,
983    pub anchor: [u8; 3],
984}
985
986#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
987pub struct PendingToolCall {
988    pub call: ToolCall,
989    pub submitted_at: Timestamp,
990}
991
992#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
993pub struct SummarySlice {
994    pub id: String,
995    pub text: String,
996}
997
998#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
999pub struct TranscriptWindow {
1000    pub messages: Vec<Message>,
1001    pub elided_message_count: u64,
1002}
1003
1004#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1005#[serde(transparent)]
1006pub struct CompactedContext(pub Vec<Value>);
1007
1008impl CompactedContext {
1009    #[must_use]
1010    pub fn new(items: Vec<Value>) -> Self {
1011        Self(items)
1012    }
1013
1014    #[must_use]
1015    pub fn items(&self) -> &[Value] {
1016        &self.0
1017    }
1018
1019    #[must_use]
1020    pub fn into_items(self) -> Vec<Value> {
1021        self.0
1022    }
1023
1024    #[must_use]
1025    pub fn is_empty(&self) -> bool {
1026        self.0.is_empty()
1027    }
1028
1029    #[must_use]
1030    pub fn len(&self) -> usize {
1031        self.0.len()
1032    }
1033}
1034
1035impl From<Vec<Value>> for CompactedContext {
1036    fn from(value: Vec<Value>) -> Self {
1037        Self(value)
1038    }
1039}
1040
1041impl AsRef<[Value]> for CompactedContext {
1042    fn as_ref(&self) -> &[Value] {
1043        self.items()
1044    }
1045}
1046
1047#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1048pub struct CompactionWindow {
1049    pub eligible_messages: Vec<Message>,
1050    pub preserved_messages: Vec<Message>,
1051    pub reserved_response_block: bool,
1052}
1053
1054impl CompactionWindow {
1055    /// Preserve the latest assistant response block and compact the older
1056    /// prefix. Providers with a first-class compaction endpoint use this
1057    /// broader window because the provider restores the compacted context as
1058    /// provider-native content.
1059    #[must_use]
1060    pub fn preserve_latest_assistant_response_block(messages: &[Message]) -> Self {
1061        let Some(last_assistant_index) = messages
1062            .iter()
1063            .rposition(|message| matches!(message, Message::Assistant(_)))
1064        else {
1065            return Self {
1066                eligible_messages: messages.to_vec(),
1067                preserved_messages: Vec::new(),
1068                reserved_response_block: false,
1069            };
1070        };
1071
1072        Self {
1073            eligible_messages: messages[..last_assistant_index].to_vec(),
1074            preserved_messages: messages[last_assistant_index..].to_vec(),
1075            reserved_response_block: true,
1076        }
1077    }
1078
1079    /// Preserve every message through the latest user message and compact
1080    /// only the post-user tail. Inline compaction providers use this narrower
1081    /// window so system, tool, skill, and latest-user cache anchors remain
1082    /// verbatim.
1083    #[must_use]
1084    pub fn preserve_through_latest_user(messages: &[Message]) -> Self {
1085        let Some(last_user_index) = messages
1086            .iter()
1087            .rposition(|message| matches!(message, Message::User(_)))
1088        else {
1089            return Self {
1090                eligible_messages: Vec::new(),
1091                preserved_messages: messages.to_vec(),
1092                reserved_response_block: false,
1093            };
1094        };
1095        let pivot = last_user_index + 1;
1096        Self {
1097            eligible_messages: messages[pivot..].to_vec(),
1098            preserved_messages: messages[..pivot].to_vec(),
1099            reserved_response_block: false,
1100        }
1101    }
1102}
1103
1104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1105pub struct FileViewSlice {
1106    pub path: PathBuf,
1107    pub full_hash: ContentHash,
1108    pub viewed_ranges: Vec<ViewedRange>,
1109    pub last_shown_turn: TurnId,
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1113pub struct ElisionMarker {
1114    pub kind: String,
1115    pub count: u64,
1116}
1117
1118#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1119pub struct MemoryItem {
1120    pub key: String,
1121    pub text: String,
1122}
1123
1124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1125pub struct SubagentRef {
1126    pub session_id: SessionId,
1127    pub task: String,
1128}
1129
1130#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1131pub struct SessionBlueprint {
1132    pub session_id: SessionId,
1133    pub parent_session_id: Option<SessionId>,
1134    pub default_model: ModelId,
1135    pub subagent_model: ModelId,
1136    #[serde(default)]
1137    pub subagent_event_forwarding: SubagentEventForwarding,
1138    pub snapshot_revision: Revision,
1139    pub working_dir: PathBuf,
1140    pub system_prompt_seed: Vec<PromptSegment>,
1141    pub max_turns: Option<u32>,
1142    pub subagent_depth: u32,
1143}
1144
1145#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1146pub struct SessionState {
1147    pub messages: Vec<Message>,
1148    #[serde(default)]
1149    pub compacted_prefix: Vec<Value>,
1150    pub file_view_cache: FileViewCache,
1151    pub appended_prompt_segments: Vec<PromptSegment>,
1152    pub pending_tool_calls: IndexMap<ToolCallId, PendingToolCall>,
1153    pub usage_so_far: Usage,
1154    pub summaries: Vec<SummarySlice>,
1155    pub lineage: Vec<SubagentRef>,
1156    pub fired_hook_ids: Vec<String>,
1157    pub pending_session_start_source: Option<HookSessionStartSource>,
1158    pub pending_warning_messages: Vec<HookWarning>,
1159    /// The OpenAI Responses API response ID from the last successful turn.
1160    /// Used for `previous_response_id` chaining to avoid re-sending full history.
1161    #[serde(default, skip_serializing_if = "Option::is_none")]
1162    pub last_response_id: Option<String>,
1163    /// Number of messages the model has already seen via `previous_response_id`.
1164    /// Messages at indices `[0..messages_seen_by_provider)` don't need re-sending.
1165    #[serde(default)]
1166    pub messages_seen_by_provider: usize,
1167}
1168
1169#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1170pub struct ObservedState {
1171    pub cwd: PathBuf,
1172    pub git_branch: Option<String>,
1173    pub git_dirty: Option<bool>,
1174    pub now_utc: Timestamp,
1175    pub env_facts: IndexMap<String, String>,
1176}
1177
1178#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1179pub struct InstructionFile {
1180    pub path: PathBuf,
1181    pub body: String,
1182}
1183
1184#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1185pub struct SkillDef {
1186    pub id: SkillId,
1187    pub name: String,
1188    pub description: String,
1189    pub body: String,
1190}
1191
1192#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1193pub struct AgentDef {
1194    pub id: AgentId,
1195    pub name: String,
1196    pub prompt: String,
1197}
1198
1199#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1200pub struct PluginManifest {
1201    pub name: String,
1202    pub version: String,
1203    pub skills: Vec<String>,
1204    pub agents: Vec<String>,
1205    pub hooks: Option<String>,
1206    pub mcp_servers: Option<String>,
1207    pub lsp_servers: Option<String>,
1208    pub allowed_http_hosts: Vec<String>,
1209    pub allowed_env_vars: Vec<String>,
1210}
1211
1212#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
1213pub struct PromptRegistry {
1214    pub prompts: IndexMap<String, Vec<PromptSegment>>,
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1218pub struct ResourceSnapshot {
1219    pub revision: Revision,
1220    pub tools: IndexMap<ToolName, ToolSpec>,
1221    pub skills: IndexMap<SkillName, SkillDef>,
1222    pub agents: IndexMap<AgentName, AgentDef>,
1223    pub prompts: PromptRegistry,
1224    pub plugins: IndexMap<PluginId, PluginManifest>,
1225    pub instruction_files: Vec<InstructionFile>,
1226}
1227
1228impl ResourceSnapshot {
1229    #[must_use]
1230    pub fn empty() -> Self {
1231        Self {
1232            revision: Revision("empty".to_owned()),
1233            tools: IndexMap::new(),
1234            skills: IndexMap::new(),
1235            agents: IndexMap::new(),
1236            prompts: PromptRegistry::default(),
1237            plugins: IndexMap::new(),
1238            instruction_files: Vec::new(),
1239        }
1240    }
1241}
1242
1243#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1244pub struct ProviderCapabilities {
1245    pub supports_tools: bool,
1246    pub supports_streaming: bool,
1247    pub supports_reasoning: bool,
1248    pub supports_interleaved_reasoning: bool,
1249    pub supports_images: bool,
1250    pub supports_documents: bool,
1251    pub supports_prompt_cache: bool,
1252    pub supports_compaction: bool,
1253    /// How the provider implements compaction. This remains exposed for
1254    /// diagnostics and external callers, but runtime planning asks the
1255    /// provider for a `CompactionWindow` instead of branching on this value.
1256    #[serde(default)]
1257    pub compaction_strategy: Option<ProviderCompactionStrategy>,
1258    pub supports_tool_result_media: bool,
1259    pub requires_non_empty_assistant_content: bool,
1260    pub tool_call_id_policy: ToolCallIdPolicy,
1261    pub max_input_tokens: u64,
1262    pub max_output_tokens: u64,
1263}
1264
1265#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1266#[serde(rename_all = "snake_case")]
1267pub enum ProviderCompactionStrategy {
1268    /// A first-class compaction endpoint (e.g. OpenAI Responses
1269    /// `/v1/responses/compact`) that returns encrypted content for safe
1270    /// reinjection. The runtime can compact aggressively because the
1271    /// provider preserves anchor invariants.
1272    Dedicated,
1273    /// In-band compaction via the regular completions endpoint
1274    /// (e.g. OpenRouter's responses passthrough). Lossy: the runtime
1275    /// only compacts the trailing window after the last cache breakpoint
1276    /// and wraps the result in explicit compaction tags so the model can
1277    /// distinguish it from authoritative system content.
1278    Inline,
1279}
1280
1281impl Default for ProviderCapabilities {
1282    fn default() -> Self {
1283        Self {
1284            supports_tools: true,
1285            supports_streaming: true,
1286            supports_reasoning: false,
1287            supports_interleaved_reasoning: false,
1288            supports_images: false,
1289            supports_documents: false,
1290            supports_prompt_cache: false,
1291            supports_compaction: false,
1292            compaction_strategy: None,
1293            supports_tool_result_media: false,
1294            requires_non_empty_assistant_content: false,
1295            tool_call_id_policy: ToolCallIdPolicy::ProviderSupplied,
1296            max_input_tokens: 0,
1297            max_output_tokens: 0,
1298        }
1299    }
1300}
1301
1302#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1303#[serde(rename_all = "snake_case")]
1304pub enum ToolCallIdPolicy {
1305    ProviderSupplied,
1306    RuntimeSynthesized,
1307    StableReplayNormalized,
1308}
1309
1310#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1311pub struct ResolvedModel {
1312    pub role: ModelRole,
1313    pub id: ModelId,
1314    pub provider: ProviderName,
1315    pub provider_kind: ProviderKind,
1316    pub api_kind: ApiKind,
1317    pub model: String,
1318    pub max_input_tokens: Option<u32>,
1319    pub max_output_tokens: Option<u32>,
1320    pub reasoning: Option<ReasoningEffort>,
1321    #[serde(default)]
1322    pub tokens_per_minute: Option<u64>,
1323}
1324
1325#[derive(
1326    Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
1327)]
1328#[serde(rename_all = "snake_case")]
1329pub enum MessageSignal {
1330    /// Compact first -- orientation commands, empty results, duplicate failures.
1331    VeryLow = 0,
1332    /// Low signal -- failed tool calls, stale reads.
1333    Low = 1,
1334    /// Default for most messages.
1335    Normal = 2,
1336    /// Active file reads and system guidance.
1337    High = 3,
1338    /// Assistant text or reasoning content.
1339    VeryHigh = 4,
1340    /// Never compact -- user messages.
1341    Anchor = 5,
1342}
1343
1344#[derive(
1345    Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq, PartialOrd, Ord,
1346)]
1347#[serde(rename_all = "snake_case")]
1348pub enum PruneSignalThreshold {
1349    VeryLow,
1350    Low,
1351    #[default]
1352    Normal,
1353    High,
1354}
1355
1356#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1357pub struct CompactionResult {
1358    /// Number of messages compacted into the raw prefix.
1359    pub compacted_count: usize,
1360    /// Human-readable summary for events and hooks.
1361    pub summary: String,
1362}
1363
1364#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1365pub struct ContextPlan {
1366    pub prompt_segments: Vec<PromptSegment>,
1367    pub transcript_window: TranscriptWindow,
1368    #[serde(default)]
1369    pub compacted_prefix: Vec<Value>,
1370    pub file_views: Vec<FileViewSlice>,
1371    pub carried_summaries: Vec<SummarySlice>,
1372    pub elided_tool_results: Vec<ElisionMarker>,
1373    pub memory_items: Vec<MemoryItem>,
1374    pub tool_specs: Vec<ToolSpec>,
1375    pub observed_state: ObservedState,
1376    pub projected_input_tokens: u64,
1377    pub cache_boundary_hash: ContentHash,
1378    pub messages: Vec<Message>,
1379    pub estimated_tokens: u64,
1380    /// If the planner compacted messages this turn, the result is here.
1381    /// The caller should apply it to `SessionState` after using the plan.
1382    pub compaction: Option<CompactionResult>,
1383    /// When set, the codec should chain via `previous_response_id`.
1384    #[serde(default, skip_serializing_if = "Option::is_none")]
1385    pub previous_response_id: Option<String>,
1386    /// Index into `messages` where new messages start (for chained requests).
1387    #[serde(default)]
1388    pub new_messages_start: usize,
1389}
1390
1391#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1392pub struct AssembledPrompt {
1393    pub segments: Vec<PromptSegment>,
1394    pub transcript: Vec<Message>,
1395    pub ordered_segments: Vec<PromptSegment>,
1396    pub prefix_cache_key: String,
1397    pub rendered_prefix: String,
1398    pub rendered_transcript: String,
1399    pub rendered: String,
1400    /// Section boundaries that the assembler asks the codec to expose as
1401    /// cache breakpoints. Codecs that do not support explicit breakpoints
1402    /// (e.g. OpenAI Responses, which uses prefix-prefix caching) ignore
1403    /// this; codecs that do (Anthropic) emit `cache_control` on the last
1404    /// content block of each marked section.
1405    #[serde(default)]
1406    pub cache_breakpoints: CacheBreakpoints,
1407    /// Index into `ordered_segments` after which the system-prompt
1408    /// breakpoint applies. `None` when there are no system segments.
1409    #[serde(default)]
1410    pub system_segment_count: usize,
1411    /// Number of segments at the head of `ordered_segments` that belong
1412    /// to the skills section. Always immediately follows the system block.
1413    #[serde(default)]
1414    pub skill_segment_count: usize,
1415}
1416
1417impl AssembledPrompt {
1418    /// Slice of segments that constitute the system-prompt section.
1419    #[must_use]
1420    pub fn system_segments(&self) -> &[PromptSegment] {
1421        let end = self.system_segment_count.min(self.ordered_segments.len());
1422        &self.ordered_segments[..end]
1423    }
1424
1425    /// Slice of segments that constitute the skills section.
1426    #[must_use]
1427    pub fn skill_segments(&self) -> &[PromptSegment] {
1428        let start = self.system_segment_count.min(self.ordered_segments.len());
1429        let end = (start + self.skill_segment_count).min(self.ordered_segments.len());
1430        &self.ordered_segments[start..end]
1431    }
1432
1433    /// Slice of segments that follow both the system and skills sections —
1434    /// hook-appended context, etc. These never receive a cache breakpoint
1435    /// because they may change turn-to-turn.
1436    #[must_use]
1437    pub fn append_segments(&self) -> &[PromptSegment] {
1438        let start =
1439            (self.system_segment_count + self.skill_segment_count).min(self.ordered_segments.len());
1440        &self.ordered_segments[start..]
1441    }
1442}
1443
1444#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1445pub struct ProviderRequest {
1446    pub session_id: SessionId,
1447    pub turn_id: TurnId,
1448    pub model: ResolvedModel,
1449    pub prompt: AssembledPrompt,
1450    #[serde(default)]
1451    pub compacted_prefix: Vec<Value>,
1452    pub messages: Vec<Message>,
1453    pub tools: Vec<ToolSpec>,
1454    /// When set, the provider can chain onto the previous response instead of
1455    /// re-sending the full conversation history. The codec should send only
1456    /// messages after `new_messages_start` when this is present.
1457    #[serde(default, skip_serializing_if = "Option::is_none")]
1458    pub previous_response_id: Option<String>,
1459    /// Index into `messages` where new (unseen-by-provider) messages begin.
1460    /// Only meaningful when `previous_response_id` is `Some`.
1461    #[serde(default)]
1462    pub new_messages_start: usize,
1463}
1464
1465#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1466pub struct ProviderCompactionRequest {
1467    pub session_id: SessionId,
1468    pub model: ResolvedModel,
1469    #[serde(default)]
1470    pub compacted_prefix: Vec<Value>,
1471    pub messages: Vec<Message>,
1472    pub tools: Vec<ToolSpec>,
1473    pub instructions: String,
1474}
1475
1476#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1477pub struct ProviderCompactionResponse {
1478    pub output: Vec<Value>,
1479    pub usage: Usage,
1480}
1481
1482#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1483pub struct SubagentResult {
1484    pub session_id: SessionId,
1485    pub output: String,
1486    pub usage: Usage,
1487}
1488
1489#[cfg(test)]
1490mod tests {
1491    use bytes::Bytes;
1492
1493    use super::*;
1494
1495    #[test]
1496    fn message_roundtrip() {
1497        let message = Message::User(UserMessage::text("hello"));
1498        let encoded = serde_json::to_string(&message).expect("serialize message");
1499        let decoded: Message = serde_json::from_str(&encoded).expect("deserialize message");
1500        assert_eq!(decoded, message);
1501    }
1502
1503    #[test]
1504    fn session_event_roundtrip() {
1505        let event = SessionEvent::new_committed(
1506            SessionId::new(),
1507            1,
1508            Delivery::Lossless,
1509            SessionEventPayload::TurnCompleted {
1510                turn_id: TurnId::new(),
1511                usage: Usage {
1512                    input_tokens: 10,
1513                    output_tokens: 5,
1514                    cache_creation_input_tokens: 0,
1515                    cache_read_input_tokens: 0,
1516                },
1517            },
1518        );
1519        let encoded = serde_json::to_string(&event).expect("serialize event");
1520        let decoded: SessionEvent = serde_json::from_str(&encoded).expect("deserialize event");
1521        assert_eq!(decoded, event);
1522    }
1523
1524    #[test]
1525    fn pending_event_into_committed_preserves_fields() {
1526        let session_id = SessionId::from("session-42");
1527        let payload = SessionEventPayload::ContextCompacted {
1528            summary: "summary".to_owned(),
1529        };
1530        let pending = PendingEvent::new(session_id.clone(), Delivery::Lossless, payload.clone());
1531
1532        let committed = pending.clone().into_committed(7);
1533
1534        assert_eq!(committed.session_id, session_id);
1535        assert_eq!(committed.sequence(), 7);
1536        assert_eq!(committed.delivery, Delivery::Lossless);
1537        assert_eq!(committed.payload, payload);
1538
1539        // PendingEvent is still unsequenced; we reject post-hoc mutation of
1540        // committed events by keeping the sequence field crate-private.
1541        let encoded = serde_json::to_string(&pending).expect("serialize pending");
1542        assert!(!encoded.contains("sequence"));
1543    }
1544
1545    #[test]
1546    fn turn_roundtrip_preserves_model_overrides() {
1547        let turn = Turn::user("hello")
1548            .with_default_model("default")
1549            .with_subagent_model("subagent");
1550
1551        let encoded = serde_json::to_string(&turn).expect("serialize turn");
1552        let decoded: Turn = serde_json::from_str(&encoded).expect("deserialize turn");
1553
1554        assert_eq!(decoded, turn);
1555    }
1556
1557    #[test]
1558    fn compacted_context_serializes_as_existing_prefix_array() {
1559        let context = CompactedContext::new(vec![
1560            serde_json::json!({"type": "reasoning", "encrypted_content": "summary"}),
1561        ]);
1562
1563        let encoded = serde_json::to_string(&context).expect("serialize compacted context");
1564        assert!(encoded.starts_with('['));
1565
1566        let decoded: CompactedContext =
1567            serde_json::from_str(&encoded).expect("deserialize compacted context");
1568        assert_eq!(decoded, context);
1569
1570        let state: SessionState = serde_json::from_value(serde_json::json!({
1571            "messages": [],
1572            "compacted_prefix": [
1573                {"type": "reasoning", "encrypted_content": "summary"}
1574            ],
1575            "file_view_cache": {},
1576            "appended_prompt_segments": [],
1577            "pending_tool_calls": {},
1578            "usage_so_far": {
1579                "input_tokens": 0,
1580                "output_tokens": 0,
1581                "cache_creation_input_tokens": 0,
1582                "cache_read_input_tokens": 0
1583            },
1584            "summaries": [],
1585            "lineage": [],
1586            "fired_hook_ids": [],
1587            "pending_session_start_source": null,
1588            "pending_warning_messages": [],
1589            "messages_seen_by_provider": 0
1590        }))
1591        .expect("deserialize existing session state");
1592        assert_eq!(state.compacted_prefix.len(), 1);
1593    }
1594
1595    #[test]
1596    fn compaction_window_preserves_latest_assistant_response_block() {
1597        let messages = vec![
1598            Message::User(UserMessage::text("first")),
1599            assistant_text("answer"),
1600            Message::User(UserMessage::text("follow up")),
1601        ];
1602
1603        let window = CompactionWindow::preserve_latest_assistant_response_block(&messages);
1604
1605        assert_eq!(window.eligible_messages.len(), 1);
1606        assert_eq!(window.preserved_messages.len(), 2);
1607        assert!(window.reserved_response_block);
1608    }
1609
1610    #[test]
1611    fn compaction_window_preserves_through_latest_user() {
1612        let messages = vec![
1613            Message::User(UserMessage::text("first")),
1614            assistant_text("answer"),
1615            Message::User(UserMessage::text("follow up")),
1616            assistant_text("tail"),
1617        ];
1618
1619        let window = CompactionWindow::preserve_through_latest_user(&messages);
1620
1621        assert_eq!(window.preserved_messages.len(), 3);
1622        assert!(matches!(
1623            window.preserved_messages.last(),
1624            Some(Message::User(_))
1625        ));
1626        assert_eq!(window.eligible_messages.len(), 1);
1627        assert!(!window.reserved_response_block);
1628    }
1629
1630    #[test]
1631    fn user_message_with_media_roundtrips() {
1632        let message = Message::User(UserMessage {
1633            id: MessageId::new(),
1634            created_at: Utc::now(),
1635            parts: vec![
1636                UserPart::Text {
1637                    text: "hello".to_owned(),
1638                },
1639                UserPart::Image {
1640                    media_type: "image/png".to_owned(),
1641                    data: Bytes::from_static(b"png"),
1642                },
1643                UserPart::Document {
1644                    media_type: "application/pdf".to_owned(),
1645                    data: Bytes::from_static(b"pdf"),
1646                },
1647            ],
1648        });
1649
1650        let encoded = serde_json::to_string(&message).expect("serialize message");
1651        let decoded: Message = serde_json::from_str(&encoded).expect("deserialize message");
1652        assert_eq!(decoded, message);
1653    }
1654
1655    #[test]
1656    fn stream_event_with_signature_roundtrips() {
1657        let event = StreamEvent::ThinkingEnd {
1658            id: BlockId::new(),
1659            signature: Some("sig-123".to_owned()),
1660        };
1661
1662        let encoded = serde_json::to_string(&event).expect("serialize event");
1663        let decoded: StreamEvent = serde_json::from_str(&encoded).expect("deserialize event");
1664        assert_eq!(decoded, event);
1665    }
1666
1667    fn assistant_text(text: &str) -> Message {
1668        Message::Assistant(AssistantMessage {
1669            id: MessageId::new(),
1670            created_at: Utc::now(),
1671            parts: vec![AssistantPart::Text {
1672                text: text.to_owned(),
1673            }],
1674            stop_reason: None,
1675            usage: None,
1676            replay_meta: ReplayMeta::default(),
1677        })
1678    }
1679}