Skip to main content

everruns_core/
session.rs

1// Session domain types
2//
3// These types represent the Session entity and its status.
4// Used by both API and worker crates.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9use crate::capability_types::AgentCapabilityConfig;
10use crate::events::TokenUsage;
11use crate::mcp_server::{ScopedMcpServers, scoped_mcp_servers_is_empty};
12use crate::network_access::NetworkAccessList;
13use crate::principal::PrincipalSummary;
14use crate::tool_types::ToolDefinition;
15use crate::typed_id::{
16    AgentId, AgentIdentityId, AgentVersionId, HarnessId, ModelId, PrincipalId, SessionId,
17    SessionParticipantId, WorkspaceId,
18};
19
20#[cfg(feature = "openapi")]
21use utoipa::ToSchema;
22
23/// Subagent lifecycle status.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[cfg_attr(feature = "openapi", derive(ToSchema))]
26#[serde(rename_all = "snake_case")]
27pub enum SubagentStatus {
28    Spawning,
29    Running,
30    Completed,
31    Failed,
32    Cancelled,
33    MaxIterationsReached,
34    /// The durable engine deliberately stopped (sealed) the child's turn to
35    /// prevent further waste (no forward progress, or budget exhausted). This is
36    /// terminal and non-retryable, and is intentionally distinct from `Failed`
37    /// so the parent agent can decide what to do next (the seal reason is
38    /// carried in the child's final assistant message / spawn `result`).
39    Sealed,
40}
41
42impl std::fmt::Display for SubagentStatus {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            SubagentStatus::Spawning => write!(f, "spawning"),
46            SubagentStatus::Running => write!(f, "running"),
47            SubagentStatus::Completed => write!(f, "completed"),
48            SubagentStatus::Failed => write!(f, "failed"),
49            SubagentStatus::Cancelled => write!(f, "cancelled"),
50            SubagentStatus::MaxIterationsReached => write!(f, "max_iterations_reached"),
51            SubagentStatus::Sealed => write!(f, "sealed"),
52        }
53    }
54}
55
56impl From<&str> for SubagentStatus {
57    fn from(s: &str) -> Self {
58        match s {
59            "spawning" => SubagentStatus::Spawning,
60            "running" => SubagentStatus::Running,
61            "completed" => SubagentStatus::Completed,
62            "failed" => SubagentStatus::Failed,
63            "cancelled" => SubagentStatus::Cancelled,
64            "max_iterations_reached" => SubagentStatus::MaxIterationsReached,
65            "sealed" => SubagentStatus::Sealed,
66            _ => SubagentStatus::Spawning,
67        }
68    }
69}
70
71/// Session execution status.
72/// - `started`: Session just created, no turn executed yet
73/// - `active`: A turn is currently running
74/// - `idle`: Turn completed, session waiting for next input
75/// - `paused`: Budget limit reached, waiting for user to increase limit or resume
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[cfg_attr(feature = "openapi", derive(ToSchema))]
78#[serde(rename_all = "lowercase")]
79pub enum SessionStatus {
80    /// Session just created, no turn executed yet.
81    Started,
82    /// A turn is currently running (session is active).
83    Active,
84    /// Turn completed, session waiting for next input (idle).
85    Idle,
86    /// Waiting for client to submit tool results.
87    WaitingForToolResults,
88    /// Budget limit reached — session paused until user resumes or increases limit.
89    Paused,
90}
91
92impl std::fmt::Display for SessionStatus {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            SessionStatus::Started => write!(f, "started"),
96            SessionStatus::Active => write!(f, "active"),
97            SessionStatus::Idle => write!(f, "idle"),
98            SessionStatus::WaitingForToolResults => write!(f, "waiting_for_tool_results"),
99            SessionStatus::Paused => write!(f, "paused"),
100        }
101    }
102}
103
104impl From<&str> for SessionStatus {
105    fn from(s: &str) -> Self {
106        match s {
107            "active" => SessionStatus::Active,
108            "idle" => SessionStatus::Idle,
109            "waiting_for_tool_results" => SessionStatus::WaitingForToolResults,
110            "paused" => SessionStatus::Paused,
111            // Handle legacy values during migration
112            "running" => SessionStatus::Active,
113            "pending" | "completed" | "failed" => SessionStatus::Idle,
114            _ => SessionStatus::Started,
115        }
116    }
117}
118
119/// How a session came into existence.
120///
121/// Closed set: the sessions facet rail enumerates every variant, so the value
122/// is typed rather than a free-form string. Ingress paths set it server-side —
123/// clients may only declare the two variants they can legitimately be
124/// (`Chat` and `Api`), which keeps a facet like "started by a schedule"
125/// trustworthy. Rows that predate the column, or whose origin could not be
126/// inferred at backfill time, carry `Unknown`.
127#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
128#[cfg_attr(feature = "openapi", derive(ToSchema))]
129#[serde(rename_all = "snake_case")]
130pub enum SessionSource {
131    /// Interactive chat thread (UI chat surface, global chat, public chat).
132    Chat,
133    /// Direct `POST /v1/sessions` from the API, CLI, or an SDK.
134    Api,
135    /// Slack channel ingress.
136    Slack,
137    /// AG-UI channel ingress.
138    AgUi,
139    /// FCP channel ingress.
140    Fcp,
141    /// Fired by a schedule (app schedule channel or agent trigger).
142    Schedule,
143    /// Inbound webhook or app API endpoint.
144    Webhook,
145    /// Inbound A2A request.
146    A2a,
147    /// Created by an eval run.
148    Eval,
149    /// Spawned as a subagent / delegated peer of another session.
150    Subagent,
151    /// Origin not recorded (pre-migration rows that could not be inferred).
152    #[default]
153    Unknown,
154}
155
156impl SessionSource {
157    pub const ALL: &'static [SessionSource] = &[
158        SessionSource::Chat,
159        SessionSource::Api,
160        SessionSource::Slack,
161        SessionSource::AgUi,
162        SessionSource::Fcp,
163        SessionSource::Schedule,
164        SessionSource::Webhook,
165        SessionSource::A2a,
166        SessionSource::Eval,
167        SessionSource::Subagent,
168        SessionSource::Unknown,
169    ];
170
171    pub fn as_str(self) -> &'static str {
172        match self {
173            SessionSource::Chat => "chat",
174            SessionSource::Api => "api",
175            SessionSource::Slack => "slack",
176            SessionSource::AgUi => "ag_ui",
177            SessionSource::Fcp => "fcp",
178            SessionSource::Schedule => "schedule",
179            SessionSource::Webhook => "webhook",
180            SessionSource::A2a => "a2a",
181            SessionSource::Eval => "eval",
182            SessionSource::Subagent => "subagent",
183            SessionSource::Unknown => "unknown",
184        }
185    }
186
187    pub fn parse(s: &str) -> Option<Self> {
188        Self::ALL.iter().copied().find(|v| v.as_str() == s)
189    }
190
191    /// Whether a client may declare this source on `POST /v1/sessions`.
192    /// Everything else is server-owned so facets cannot be spoofed.
193    pub fn is_client_declarable(self) -> bool {
194        matches!(self, SessionSource::Chat | SessionSource::Api)
195    }
196}
197
198impl std::fmt::Display for SessionSource {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.write_str(self.as_str())
201    }
202}
203
204impl From<&str> for SessionSource {
205    fn from(s: &str) -> Self {
206        Self::parse(s).unwrap_or(SessionSource::Unknown)
207    }
208}
209
210/// Outcome-oriented view of a session, as the sessions list and facet rail
211/// present it. Derived from the session's execution status plus the outcome of
212/// its most recent turn — `SessionStatus` alone has no notion of failure.
213#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
214#[cfg_attr(feature = "openapi", derive(ToSchema))]
215#[serde(rename_all = "snake_case")]
216pub enum SessionActivity {
217    /// A turn is executing, or the session is waiting on client tool results.
218    Running,
219    /// Budget limit reached; waiting for the user to resume.
220    Paused,
221    /// Last completed turn failed or was cancelled.
222    Failed,
223    /// Last completed turn succeeded and nothing is running.
224    Completed,
225    /// Created but idle with no completed turn yet.
226    #[default]
227    Idle,
228}
229
230impl SessionActivity {
231    pub const ALL: &'static [SessionActivity] = &[
232        SessionActivity::Running,
233        SessionActivity::Paused,
234        SessionActivity::Failed,
235        SessionActivity::Completed,
236        SessionActivity::Idle,
237    ];
238
239    pub fn as_str(self) -> &'static str {
240        match self {
241            SessionActivity::Running => "running",
242            SessionActivity::Paused => "paused",
243            SessionActivity::Failed => "failed",
244            SessionActivity::Completed => "completed",
245            SessionActivity::Idle => "idle",
246        }
247    }
248
249    pub fn parse(s: &str) -> Option<Self> {
250        Self::ALL.iter().copied().find(|v| v.as_str() == s)
251    }
252
253    /// Single source of truth for the derivation, mirrored by
254    /// `session_activity_sql` in the sessions repository so the list, the facet
255    /// counts, and the in-memory backend cannot drift apart.
256    pub fn derive(status: &SessionStatus, last_turn_status: Option<&str>) -> Self {
257        match status {
258            SessionStatus::Active | SessionStatus::WaitingForToolResults => {
259                SessionActivity::Running
260            }
261            SessionStatus::Paused => SessionActivity::Paused,
262            _ => match last_turn_status {
263                Some("failed") | Some("cancelled") => SessionActivity::Failed,
264                Some("completed") => SessionActivity::Completed,
265                _ => SessionActivity::Idle,
266            },
267        }
268    }
269}
270
271impl std::fmt::Display for SessionActivity {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        f.write_str(self.as_str())
274    }
275}
276
277/// Kind of actor participating in a session.
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279#[cfg_attr(feature = "openapi", derive(ToSchema))]
280#[serde(rename_all = "snake_case")]
281pub enum SessionParticipantKind {
282    Agent,
283    User,
284}
285
286impl std::fmt::Display for SessionParticipantKind {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        match self {
289            SessionParticipantKind::Agent => write!(f, "agent"),
290            SessionParticipantKind::User => write!(f, "user"),
291        }
292    }
293}
294
295impl From<&str> for SessionParticipantKind {
296    fn from(s: &str) -> Self {
297        match s {
298            "agent" => SessionParticipantKind::Agent,
299            "user" => SessionParticipantKind::User,
300            _ => SessionParticipantKind::User,
301        }
302    }
303}
304
305/// Role a participant has inside a session.
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
307#[cfg_attr(feature = "openapi", derive(ToSchema))]
308#[serde(rename_all = "snake_case")]
309pub enum SessionParticipantRole {
310    Host,
311    Member,
312}
313
314impl std::fmt::Display for SessionParticipantRole {
315    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        match self {
317            SessionParticipantRole::Host => write!(f, "host"),
318            SessionParticipantRole::Member => write!(f, "member"),
319        }
320    }
321}
322
323impl From<&str> for SessionParticipantRole {
324    fn from(s: &str) -> Self {
325        match s {
326            "host" => SessionParticipantRole::Host,
327            "member" => SessionParticipantRole::Member,
328            _ => SessionParticipantRole::Member,
329        }
330    }
331}
332
333/// Session participant - an agent or user that has joined a session.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[cfg_attr(feature = "openapi", derive(ToSchema))]
336pub struct SessionParticipant {
337    /// Unique identifier for the participant row (format: part_{32-hex}).
338    #[cfg_attr(
339        feature = "openapi",
340        schema(
341            value_type = String,
342            example = "part_01933b5a00007000800000000000001"
343        )
344    )]
345    pub id: SessionParticipantId,
346    /// Session this participant belongs to.
347    #[cfg_attr(
348        feature = "openapi",
349        schema(
350            value_type = String,
351            example = "session_01933b5a00007000800000000000001"
352        )
353    )]
354    pub session_id: SessionId,
355    pub kind: SessionParticipantKind,
356    /// Present for agent participants.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    #[cfg_attr(
359        feature = "openapi",
360        schema(
361            value_type = Option<String>,
362            example = "agent_01933b5a00007000800000000000001"
363        )
364    )]
365    pub agent_id: Option<AgentId>,
366    /// Immutable agent version captured for an agent participant when known.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    #[cfg_attr(
369        feature = "openapi",
370        schema(
371            value_type = Option<String>,
372            example = "agentver_01933b5a00007000800000000000001"
373        )
374    )]
375    pub agent_version_id: Option<AgentVersionId>,
376    /// Principal that joined the session.
377    #[cfg_attr(
378        feature = "openapi",
379        schema(
380            value_type = String,
381            example = "principal_01933b5a000070008000000000000001"
382        )
383    )]
384    pub principal_id: PrincipalId,
385    /// Human-readable name captured for this participant.
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub display_name: Option<String>,
388    pub role: SessionParticipantRole,
389    pub joined_at: DateTime<Utc>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub left_at: Option<DateTime<Utc>>,
392}
393
394/// Session - instance of agentic loop execution.
395/// A session represents a single conversation with an agent.
396#[derive(Debug, Clone, Serialize, Deserialize)]
397#[cfg_attr(feature = "openapi", derive(ToSchema))]
398pub struct Session {
399    /// Unique identifier for the session (format: session_{32-hex}).
400    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "session_01933b5a00007000800000000000001"))]
401    pub id: SessionId,
402    /// Organization this session belongs to (format: org_{32-hex}).
403    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "org_00000000000000000000000000000001"))]
404    pub organization_id: String,
405    /// Workspace this session is attached to (format: wsp_{32-hex}). Owns the
406    /// session's virtual filesystem. For the default 1:1 case this mirrors the
407    /// session id, but clients should read it here rather than deriving it.
408    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "wsp_01933b5a00007000800000000000001"))]
409    pub workspace_id: WorkspaceId,
410    /// ID of the harness for this session (format: harness_{32-hex}).
411    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
412    pub harness_id: HarnessId,
413    /// ID of the agent working in this session (format: agent_{32-hex}). Optional.
414    #[serde(skip_serializing_if = "Option::is_none")]
415    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
416    pub agent_id: Option<AgentId>,
417    /// Immutable agent version captured when the session was created or rebound.
418    #[serde(skip_serializing_if = "Option::is_none")]
419    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
420    pub agent_version_id: Option<AgentVersionId>,
421    /// Optional resident agent identity for unattended/background execution.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "identity_01933b5a00007000800000000000001"))]
424    pub agent_identity_id: Option<AgentIdentityId>,
425    /// Owning principal for this session.
426    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
427    pub owner_principal_id: PrincipalId,
428    /// Denormalized effective human owner of the owning principal lineage.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    #[cfg_attr(
431        feature = "openapi",
432        schema(example = "550e8400-e29b-41d4-a716-446655440000")
433    )]
434    pub resolved_owner_user_id: Option<uuid::Uuid>,
435    /// Owning principal summary.
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub owner: Option<PrincipalSummary>,
438    /// Effective human owner summary.
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub effective_owner: Option<PrincipalSummary>,
441    /// Human-readable title for the session.
442    #[serde(skip_serializing_if = "Option::is_none")]
443    #[cfg_attr(feature = "openapi", schema(example = "Q3 marketing brief"))]
444    pub title: Option<String>,
445    /// Session objective visible to the runtime agent at system-prompt level.
446    #[serde(skip_serializing_if = "Option::is_none")]
447    #[cfg_attr(
448        feature = "openapi",
449        schema(example = "Investigate the queue latency regression")
450    )]
451    pub goal: Option<String>,
452    /// Locale for localized agent behavior and formatting (BCP 47, e.g. `uk-UA`).
453    #[serde(skip_serializing_if = "Option::is_none")]
454    #[cfg_attr(feature = "openapi", schema(example = "en-US"))]
455    pub locale: Option<String>,
456    /// Preview text from the first user message (truncated).
457    #[serde(skip_serializing_if = "Option::is_none")]
458    #[cfg_attr(
459        feature = "openapi",
460        schema(example = "Help me draft the Q3 marketing plan")
461    )]
462    pub preview: Option<String>,
463    /// Preview text from the last assistant response (truncated).
464    #[serde(skip_serializing_if = "Option::is_none")]
465    #[cfg_attr(
466        feature = "openapi",
467        schema(example = "Here is a Q3 plan covering the three pillars we discussed...")
468    )]
469    pub output_preview: Option<String>,
470    /// Tags for organizing and filtering sessions.
471    #[serde(default)]
472    #[cfg_attr(feature = "openapi", schema(example = json!(["marketing", "q3", "draft"])))]
473    pub tags: Vec<String>,
474    /// LLM model ID to use for this session (format: model_{32-hex}).
475    /// Overrides the agent's default model if set.
476    #[serde(skip_serializing_if = "Option::is_none")]
477    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
478    pub model_id: Option<ModelId>,
479    /// Session-level capabilities (additive to agent capabilities).
480    /// Applied after agent capabilities when building RuntimeAgent.
481    #[serde(default, skip_serializing_if = "Vec::is_empty")]
482    pub capabilities: Vec<AgentCapabilityConfig>,
483    /// Client-side tools for this session (additive to agent tools).
484    #[serde(default, skip_serializing_if = "Vec::is_empty")]
485    pub tools: Vec<ToolDefinition>,
486    /// Remote MCP servers scoped to this session only.
487    #[serde(
488        default,
489        rename = "mcpServers",
490        alias = "mcp_servers",
491        skip_serializing_if = "scoped_mcp_servers_is_empty"
492    )]
493    pub mcp_servers: ScopedMcpServers,
494    /// Session-level system prompt override.
495    /// Prepended to the agent's system prompt when building RuntimeAgent.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub system_prompt: Option<String>,
498    /// Session-level initial files (additive to agent initial_files).
499    /// Files with matching paths override agent/harness files; new paths are appended.
500    #[serde(default, skip_serializing_if = "Vec::is_empty")]
501    pub initial_files: Vec<crate::session_file::InitialFile>,
502    /// Session-level client hints — arbitrary key-value pairs declared by the
503    /// client at session creation time. These are defaults for every turn;
504    /// per-message `controls.hints` override these key-by-key (shallow merge).
505    ///
506    /// Examples: `{"setup_connection": true, "rich_media": true}`
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub hints: Option<std::collections::HashMap<String, serde_json::Value>>,
509    /// Network access list controlling which hosts/URLs this session can reach.
510    /// Merged with harness and agent layers (allowed: intersect, blocked: union).
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub network_access: Option<NetworkAccessList>,
513    /// Maximum number of LLM iterations per turn for this session.
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    #[cfg_attr(feature = "openapi", schema(example = 50))]
516    pub max_iterations: Option<usize>,
517    /// Request-level parallel tool calling preference (EVE-598).
518    ///
519    /// `None` (default) preserves provider defaults. `Some(true)` signals the
520    /// provider that parallel tool calls are wanted; `Some(false)` requests at
521    /// most one tool call per turn and forces serial execution. Merged across
522    /// harness/agent/session layers (overlay wins).
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    #[cfg_attr(feature = "openapi", schema(example = true))]
525    pub parallel_tool_calls: Option<bool>,
526    /// Current execution status of the session.
527    pub status: SessionStatus,
528    /// How this session was started. Server-owned for every ingress path.
529    #[serde(default)]
530    pub source: SessionSource,
531    /// Outcome-oriented status derived from `status` and the last turn result.
532    /// This is the value the sessions list groups by and the facet rail counts.
533    #[serde(default)]
534    pub activity: SessionActivity,
535    /// Timestamp when the session was created.
536    #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:00:00Z"))]
537    pub created_at: DateTime<Utc>,
538    /// Timestamp when the session was last updated.
539    #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:14:32Z"))]
540    pub updated_at: DateTime<Utc>,
541    /// Timestamp when the session started executing.
542    #[serde(skip_serializing_if = "Option::is_none")]
543    #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:00:01Z"))]
544    pub started_at: Option<DateTime<Utc>>,
545    /// Timestamp when the session finished (completed or failed).
546    #[serde(skip_serializing_if = "Option::is_none")]
547    #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:14:32Z"))]
548    pub finished_at: Option<DateTime<Utc>>,
549    /// Cumulative token usage for all LLM calls in this session.
550    #[serde(skip_serializing_if = "Option::is_none")]
551    pub usage: Option<TokenUsage>,
552    /// Whether this session is pinned by the current user.
553    /// Only populated when the request has an authenticated user context.
554    #[serde(skip_serializing_if = "Option::is_none")]
555    #[cfg_attr(feature = "openapi", schema(example = false))]
556    pub is_pinned: Option<bool>,
557    /// Number of active (enabled) schedules for this session.
558    /// Populated when the session is fetched for API responses.
559    #[serde(skip_serializing_if = "Option::is_none")]
560    #[cfg_attr(feature = "openapi", schema(example = 2))]
561    pub active_schedule_count: Option<u32>,
562    /// Aggregated UI features from all active capabilities (harness + agent + session).
563    /// Computed at read time from the capability registry.
564    /// Known features: "file_system", "schedules", "secrets", "key_value",
565    /// "sql_database", "leased_resources".
566    #[serde(default, skip_serializing_if = "Vec::is_empty")]
567    #[cfg_attr(feature = "openapi", schema(example = json!(["file_system", "secrets"])))]
568    pub features: Vec<String>,
569
570    // -- Subagent nesting fields --
571    /// Parent session that spawned this subagent. NULL for top-level sessions.
572    /// Used to compute governed subagent delegation depth.
573    #[serde(skip_serializing_if = "Option::is_none")]
574    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
575    pub parent_session_id: Option<SessionId>,
576
577    // -- Fork lineage fields (knowledge/runtime-resources/forking-sessions.md) --
578    /// Session this one was forked from. NULL for sessions that were not forked.
579    /// Distinct from `parent_session_id` (subagent nesting): forking is a
580    /// user-initiated "branch from here" relationship.
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
583    pub forked_from_session_id: Option<SessionId>,
584    /// Parent event sequence the fork was taken at (the fork point). NULL unless
585    /// this session is a fork.
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    #[cfg_attr(feature = "openapi", schema(example = 42))]
588    pub forked_from_sequence: Option<i32>,
589
590    // -- Blueprint fields (only set when this session runs a blueprint agent) --
591    /// Blueprint ID. When set, reason_activity and act_activity build RuntimeAgent
592    /// from the blueprint definition instead of from harness_id/agent_id.
593    #[serde(skip_serializing_if = "Option::is_none")]
594    #[cfg_attr(feature = "openapi", schema(example = "blueprint_research_pack"))]
595    pub blueprint_id: Option<String>,
596    /// Validated config passed by host at blueprint spawn time.
597    /// Example: `{"target_repo": "acme/everruns"}`.
598    #[serde(skip_serializing_if = "Option::is_none")]
599    #[cfg_attr(feature = "openapi", schema(value_type = Option<Object>))]
600    pub blueprint_config: Option<serde_json::Value>,
601}
602
603/// Seed mode used when creating a peer session from an existing session.
604#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
605#[cfg_attr(feature = "openapi", derive(ToSchema))]
606#[serde(rename_all = "snake_case")]
607pub enum SessionSeedMode {
608    /// Create an empty session and only record lineage when provided.
609    #[default]
610    Fresh,
611    /// Copy conversation events, workspace files, and durable session storage.
612    Fork,
613    /// Copy workspace files only.
614    Workspace,
615}
616
617impl SessionSeedMode {
618    pub fn as_str(self) -> &'static str {
619        match self {
620            Self::Fresh => "fresh",
621            Self::Fork => "fork",
622            Self::Workspace => "workspace",
623        }
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    /// The list filters activity in SQL and the in-memory backend filters it in
632    /// Rust, so this truth table is the contract both sides implement. It is
633    /// duplicated verbatim as a comment beside `ACTIVITY_SQL` in
634    /// `crates/server/src/storage/repositories/sessions.rs`.
635    #[test]
636    fn activity_derivation_truth_table() {
637        use SessionActivity as A;
638        use SessionStatus as S;
639
640        let cases: &[(SessionStatus, Option<&str>, SessionActivity)] = &[
641            // Execution state wins: a running turn is running whatever the
642            // previous turn did.
643            (S::Active, None, A::Running),
644            (S::Active, Some("failed"), A::Running),
645            (S::WaitingForToolResults, Some("completed"), A::Running),
646            (S::Paused, Some("failed"), A::Paused),
647            // Otherwise the last terminal turn decides.
648            (S::Idle, Some("completed"), A::Completed),
649            (S::Idle, Some("failed"), A::Failed),
650            (S::Idle, Some("cancelled"), A::Failed),
651            (S::Started, Some("completed"), A::Completed),
652            // No completed turn yet, or an unrecognized outcome.
653            (S::Started, None, A::Idle),
654            (S::Idle, None, A::Idle),
655            (S::Idle, Some("weird"), A::Idle),
656        ];
657
658        for (status, last_turn, expected) in cases {
659            assert_eq!(
660                SessionActivity::derive(status, *last_turn),
661                *expected,
662                "status={status} last_turn={last_turn:?}"
663            );
664        }
665    }
666
667    #[test]
668    fn session_source_round_trips_through_its_wire_string() {
669        for source in SessionSource::ALL {
670            assert_eq!(SessionSource::parse(source.as_str()), Some(*source));
671        }
672        // Unknown text degrades to the explicit unknown bucket rather than
673        // inventing a facet.
674        assert_eq!(SessionSource::from("not_a_source"), SessionSource::Unknown);
675    }
676
677    #[test]
678    fn only_chat_and_api_are_client_declarable() {
679        let declarable: Vec<_> = SessionSource::ALL
680            .iter()
681            .filter(|s| s.is_client_declarable())
682            .copied()
683            .collect();
684        assert_eq!(declarable, vec![SessionSource::Chat, SessionSource::Api]);
685    }
686
687    #[test]
688    fn session_activity_round_trips_through_its_wire_string() {
689        for activity in SessionActivity::ALL {
690            assert_eq!(SessionActivity::parse(activity.as_str()), Some(*activity));
691        }
692        assert_eq!(SessionActivity::parse("nope"), None);
693    }
694}