973 lines
35 KiB
Rust
973 lines
35 KiB
Rust
use crate::types::meta::Meta;
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct Session {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
pub metadata: SessionMetadata,
|
|
/// Working directory for this session (if known)
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub cwd: Option<String>,
|
|
/// ACP model state (available models and current model)
|
|
/// Populated from archivist for archived sessions, or from SSE events for live sessions.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub models: Option<SessionModelState>,
|
|
/// ACP mode state (available modes and current mode)
|
|
/// Populated from archivist for archived sessions, or from SSE events for live sessions.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub modes: Option<SessionModeState>,
|
|
/// ACP config options (replaces modes/models in future ACP versions)
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub config_options: Option<Vec<ConfigOption>>,
|
|
/// ACP client ID that owns this session.
|
|
/// For sessions created via ACP Server (incoming connections), this identifies
|
|
/// which connected client created/owns this session.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub acp_client_id: Option<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// ACP Session Mode/Model Types
|
|
// ============================================================================
|
|
// These types match the Agent-Client Protocol (ACP) specification exactly.
|
|
// They use camelCase serialization to match Claude-ACP's JSON format.
|
|
// See: docs/architecture/agent_client_protocol/schema.md
|
|
|
|
/// Type alias for session mode identifiers (e.g., "default", "plan", "bypassPermissions")
|
|
pub type SessionModeId = String;
|
|
|
|
/// Type alias for model identifiers (e.g., "default", "sonnet", "haiku", "opus")
|
|
pub type ModelId = String;
|
|
|
|
/// Session mode state from ACP `session/new` response
|
|
///
|
|
/// Contains the list of available modes and the currently active mode.
|
|
/// This is part of the stable ACP specification.
|
|
///
|
|
/// # Example (from Claude-ACP)
|
|
/// ```json
|
|
/// {
|
|
/// "currentModeId": "default",
|
|
/// "availableModes": [
|
|
/// {"id": "default", "name": "Always Ask", "description": "..."},
|
|
/// {"id": "plan", "name": "Plan Mode", "description": "..."}
|
|
/// ]
|
|
/// }
|
|
/// ```
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionModeState {
|
|
/// The currently active mode ID
|
|
pub current_mode_id: SessionModeId,
|
|
/// List of all available modes for this session
|
|
pub available_modes: Vec<SessionMode>,
|
|
}
|
|
|
|
/// A single session mode definition
|
|
///
|
|
/// Modes affect agent behavior, tool availability, and permission handling.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionMode {
|
|
/// Unique identifier for this mode
|
|
pub id: SessionModeId,
|
|
/// Human-readable display name
|
|
pub name: String,
|
|
/// Optional description of what this mode does
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
/// Session model state from ACP `session/new` response
|
|
///
|
|
/// Contains the list of available models and the currently selected model.
|
|
/// Note: This field is marked UNSTABLE in the ACP spec but is used by Claude-ACP.
|
|
///
|
|
/// # Example (from Claude-ACP)
|
|
/// ```json
|
|
/// {
|
|
/// "availableModels": [
|
|
/// {"modelId": "default", "name": "Default (recommended)", "description": "..."},
|
|
/// {"modelId": "sonnet", "name": "Sonnet", "description": "..."}
|
|
/// ],
|
|
/// "currentModelId": "default"
|
|
/// }
|
|
/// ```
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct SessionModelState {
|
|
/// List of all available models for this session
|
|
pub available_models: Vec<ModelInfo>,
|
|
/// The currently selected model ID
|
|
pub current_model_id: ModelId,
|
|
}
|
|
|
|
/// Information about a single model
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ModelInfo {
|
|
/// Unique identifier for this model
|
|
pub model_id: ModelId,
|
|
/// Human-readable display name
|
|
pub name: String,
|
|
/// Optional description of the model's capabilities
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// ACP Config Options (replaces modes/models in future ACP versions)
|
|
// ============================================================================
|
|
|
|
/// A configuration option for a session (ACP configOptions).
|
|
///
|
|
/// Agents provide config options in session/new and session/load responses.
|
|
/// Clients should use these instead of the legacy `modes`/`models` fields.
|
|
/// See: docs/architecture/agent_client_protocol/session-config-options.md
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ConfigOption {
|
|
/// Unique identifier (e.g., "mode", "model")
|
|
pub id: String,
|
|
/// Human-readable label
|
|
pub name: String,
|
|
/// Optional description
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
/// Semantic category for UX grouping (e.g., "mode", "model", "thought_level")
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub category: Option<String>,
|
|
/// Input type (currently only "select" is defined)
|
|
#[serde(rename = "type")]
|
|
pub option_type: ConfigOptionType,
|
|
/// Currently selected value
|
|
pub current_value: String,
|
|
/// Available values for select-type options
|
|
pub options: Vec<ConfigOptionValue>,
|
|
}
|
|
|
|
/// Type of configuration option input
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ConfigOptionType {
|
|
Select,
|
|
}
|
|
|
|
/// A single value choice within a config option
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ConfigOptionValue {
|
|
/// Value identifier (sent back when setting this option)
|
|
pub value: String,
|
|
/// Human-readable display name
|
|
pub name: String,
|
|
/// Optional description
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct SessionMetadata {
|
|
pub project_path: String,
|
|
pub model: Option<String>,
|
|
/// Total count of user and assistant messages in the session (excludes system messages).
|
|
/// A value of 0 may indicate either an empty session or that the count has not yet been calculated.
|
|
/// Counts are populated lazily when messages are loaded for a session.
|
|
/// See `docs/architecture/session_message_counts.md` for details.
|
|
pub total_messages: u32,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub system_message: Option<String>,
|
|
/// Current mode identifier for future mode tracking
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub current_mode_id: Option<String>,
|
|
/// Provider metadata for tracking original IDs and debugging information
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub _meta: Option<Meta>,
|
|
/// Optional project ID linking this session to a dirigent_projects Project.
|
|
/// When set, the session belongs to the specified project for organizational purposes.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub project_id: Option<uuid::Uuid>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Session Ownership Model
|
|
// ============================================================================
|
|
// These types define how sessions are owned and how tool execution is routed.
|
|
// See: docs/architecture/session_ownership.md (Phase 7)
|
|
|
|
/// The origin of a session - who initiated it
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
pub enum SessionOrigin {
|
|
/// Session created by Dirigent UI user
|
|
Internal,
|
|
|
|
/// Session forwarded from external ACP client
|
|
External {
|
|
/// The ACP client ID that owns this session
|
|
client_id: String,
|
|
/// Cached client capabilities (from initialization)
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
client_capabilities: Option<serde_json::Value>,
|
|
},
|
|
|
|
/// Session representing a subagent or internal task (future)
|
|
Subagent {
|
|
/// Parent session that spawned this subagent
|
|
parent_session_id: String,
|
|
/// Task identifier
|
|
task_id: String,
|
|
},
|
|
}
|
|
|
|
impl Default for SessionOrigin {
|
|
fn default() -> Self {
|
|
Self::Internal
|
|
}
|
|
}
|
|
|
|
/// Who handles tool execution for this session
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ToolHandler {
|
|
/// Agent handles its own tools (default)
|
|
#[default]
|
|
Agent,
|
|
|
|
/// Dirigent intercepts and handles tools via dirigent_tools (future)
|
|
Dirigent,
|
|
|
|
/// Forward tool requests to originating client (External sessions only)
|
|
ForwardToClient,
|
|
}
|
|
|
|
/// Complete ownership model for a session
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
pub struct SessionOwnership {
|
|
/// Where this session originated
|
|
#[serde(default)]
|
|
pub origin: SessionOrigin,
|
|
|
|
/// How tool requests are handled
|
|
#[serde(default)]
|
|
pub tool_handler: ToolHandler,
|
|
}
|
|
|
|
impl SessionOwnership {
|
|
/// Internal session with agent handling tools (default UI case)
|
|
pub fn internal() -> Self {
|
|
Self {
|
|
origin: SessionOrigin::Internal,
|
|
tool_handler: ToolHandler::Agent,
|
|
}
|
|
}
|
|
|
|
/// External session with tools forwarded to client
|
|
pub fn external_forwarded(client_id: String, capabilities: Option<serde_json::Value>) -> Self {
|
|
Self {
|
|
origin: SessionOrigin::External {
|
|
client_id,
|
|
client_capabilities: capabilities,
|
|
},
|
|
tool_handler: ToolHandler::ForwardToClient,
|
|
}
|
|
}
|
|
|
|
/// External session but Dirigent handles tools
|
|
pub fn external_handled(client_id: String, capabilities: Option<serde_json::Value>) -> Self {
|
|
Self {
|
|
origin: SessionOrigin::External {
|
|
client_id,
|
|
client_capabilities: capabilities,
|
|
},
|
|
tool_handler: ToolHandler::Dirigent,
|
|
}
|
|
}
|
|
|
|
/// Get capabilities to advertise to agent based on ownership
|
|
pub fn capabilities_for_agent(&self) -> serde_json::Value {
|
|
match (&self.origin, &self.tool_handler) {
|
|
// External + ForwardToClient: use client's capabilities
|
|
(
|
|
SessionOrigin::External {
|
|
client_capabilities: Some(caps),
|
|
..
|
|
},
|
|
ToolHandler::ForwardToClient,
|
|
) => caps.clone(),
|
|
// Dirigent handles tools: advertise dirigent_tools capabilities
|
|
(_, ToolHandler::Dirigent) => {
|
|
serde_json::json!({
|
|
"fs": { "readTextFile": true, "writeTextFile": true },
|
|
"terminal": true
|
|
})
|
|
}
|
|
// Agent handles tools or no client caps: empty (agent uses its own)
|
|
_ => serde_json::json!({}),
|
|
}
|
|
}
|
|
|
|
/// Get the client ID if this should forward requests to a client
|
|
pub fn forward_to_client(&self) -> Option<&str> {
|
|
match (&self.origin, &self.tool_handler) {
|
|
(SessionOrigin::External { client_id, .. }, ToolHandler::ForwardToClient) => {
|
|
Some(client_id.as_str())
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Check if this is an external (forwarded) session
|
|
pub fn is_external(&self) -> bool {
|
|
matches!(self.origin, SessionOrigin::External { .. })
|
|
}
|
|
|
|
/// Get the originating client ID if external
|
|
pub fn client_id(&self) -> Option<&str> {
|
|
match &self.origin {
|
|
SessionOrigin::External { client_id, .. } => Some(client_id.as_str()),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::meta::{Meta, ProviderMeta};
|
|
use std::collections::HashMap;
|
|
|
|
// ========================================================================
|
|
// ACP Session Mode/Model Type Tests
|
|
// ========================================================================
|
|
|
|
#[test]
|
|
fn test_session_mode_state_serialization_camel_case() {
|
|
// Verify camelCase serialization matches Claude-ACP format
|
|
let mode_state = SessionModeState {
|
|
current_mode_id: "default".to_string(),
|
|
available_modes: vec![
|
|
SessionMode {
|
|
id: "default".to_string(),
|
|
name: "Always Ask".to_string(),
|
|
description: Some(
|
|
"Prompts for permission on first use of each tool".to_string(),
|
|
),
|
|
},
|
|
SessionMode {
|
|
id: "plan".to_string(),
|
|
name: "Plan Mode".to_string(),
|
|
description: Some("Claude can analyze but not modify files".to_string()),
|
|
},
|
|
],
|
|
};
|
|
|
|
let json = serde_json::to_string(&mode_state).unwrap();
|
|
// Verify camelCase field names
|
|
assert!(json.contains("currentModeId"));
|
|
assert!(json.contains("availableModes"));
|
|
// Verify content
|
|
assert!(json.contains(r#""currentModeId":"default"#));
|
|
assert!(json.contains(r#""name":"Always Ask"#));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_mode_state_deserialization_from_claude_format() {
|
|
// Test deserialization of actual Claude-ACP format
|
|
let json = r#"{
|
|
"currentModeId": "default",
|
|
"availableModes": [
|
|
{
|
|
"id": "default",
|
|
"name": "Always Ask",
|
|
"description": "Prompts for permission on first use of each tool"
|
|
},
|
|
{
|
|
"id": "acceptEdits",
|
|
"name": "Accept Edits",
|
|
"description": "Automatically accepts file edit permissions for the session"
|
|
},
|
|
{
|
|
"id": "plan",
|
|
"name": "Plan Mode",
|
|
"description": "Claude can analyze but not modify files or execute commands"
|
|
},
|
|
{
|
|
"id": "bypassPermissions",
|
|
"name": "Bypass Permissions",
|
|
"description": "Skips all permission prompts"
|
|
}
|
|
]
|
|
}"#;
|
|
|
|
let mode_state: SessionModeState = serde_json::from_str(json).unwrap();
|
|
assert_eq!(mode_state.current_mode_id, "default");
|
|
assert_eq!(mode_state.available_modes.len(), 4);
|
|
assert_eq!(mode_state.available_modes[0].id, "default");
|
|
assert_eq!(mode_state.available_modes[0].name, "Always Ask");
|
|
assert_eq!(mode_state.available_modes[3].id, "bypassPermissions");
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_mode_state_roundtrip() {
|
|
let original = SessionModeState {
|
|
current_mode_id: "plan".to_string(),
|
|
available_modes: vec![
|
|
SessionMode {
|
|
id: "default".to_string(),
|
|
name: "Default".to_string(),
|
|
description: None,
|
|
},
|
|
SessionMode {
|
|
id: "plan".to_string(),
|
|
name: "Plan".to_string(),
|
|
description: Some("Planning mode".to_string()),
|
|
},
|
|
],
|
|
};
|
|
|
|
let json = serde_json::to_string(&original).unwrap();
|
|
let deserialized: SessionModeState = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(original, deserialized);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_mode_skip_none_description() {
|
|
// Test that None descriptions are not serialized
|
|
let mode = SessionMode {
|
|
id: "test".to_string(),
|
|
name: "Test".to_string(),
|
|
description: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&mode).unwrap();
|
|
assert!(!json.contains("description"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_model_state_serialization_camel_case() {
|
|
// Verify camelCase serialization matches Claude-ACP format
|
|
let model_state = SessionModelState {
|
|
available_models: vec![
|
|
ModelInfo {
|
|
model_id: "default".to_string(),
|
|
name: "Default (recommended)".to_string(),
|
|
description: Some("Opus 4.5 · Most capable for complex work".to_string()),
|
|
},
|
|
ModelInfo {
|
|
model_id: "sonnet".to_string(),
|
|
name: "Sonnet".to_string(),
|
|
description: Some("Sonnet 4.5 · Best for everyday tasks".to_string()),
|
|
},
|
|
],
|
|
current_model_id: "default".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&model_state).unwrap();
|
|
// Verify camelCase field names
|
|
assert!(json.contains("availableModels"));
|
|
assert!(json.contains("currentModelId"));
|
|
assert!(json.contains("modelId"));
|
|
// Verify content
|
|
assert!(json.contains(r#""currentModelId":"default"#));
|
|
assert!(json.contains(r#""name":"Sonnet"#));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_model_state_deserialization_from_claude_format() {
|
|
// Test deserialization of actual Claude-ACP format (from zed_claude_code_direct_acp_log.txt)
|
|
let json = r#"{
|
|
"availableModels": [
|
|
{
|
|
"modelId": "default",
|
|
"name": "Default (recommended)",
|
|
"description": "Opus 4.5 · Most capable for complex work"
|
|
},
|
|
{
|
|
"modelId": "sonnet",
|
|
"name": "Sonnet",
|
|
"description": "Sonnet 4.5 · Best for everyday tasks"
|
|
},
|
|
{
|
|
"modelId": "haiku",
|
|
"name": "Haiku",
|
|
"description": "Haiku 4.5 · Fastest for quick answers"
|
|
},
|
|
{
|
|
"modelId": "opus",
|
|
"name": "opus",
|
|
"description": "Custom model"
|
|
}
|
|
],
|
|
"currentModelId": "default"
|
|
}"#;
|
|
|
|
let model_state: SessionModelState = serde_json::from_str(json).unwrap();
|
|
assert_eq!(model_state.current_model_id, "default");
|
|
assert_eq!(model_state.available_models.len(), 4);
|
|
assert_eq!(model_state.available_models[0].model_id, "default");
|
|
assert_eq!(
|
|
model_state.available_models[0].name,
|
|
"Default (recommended)"
|
|
);
|
|
assert_eq!(model_state.available_models[2].model_id, "haiku");
|
|
assert_eq!(model_state.available_models[3].model_id, "opus");
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_model_state_roundtrip() {
|
|
let original = SessionModelState {
|
|
available_models: vec![ModelInfo {
|
|
model_id: "default".to_string(),
|
|
name: "Default".to_string(),
|
|
description: None,
|
|
}],
|
|
current_model_id: "default".to_string(),
|
|
};
|
|
|
|
let json = serde_json::to_string(&original).unwrap();
|
|
let deserialized: SessionModelState = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(original, deserialized);
|
|
}
|
|
|
|
#[test]
|
|
fn test_model_info_skip_none_description() {
|
|
// Test that None descriptions are not serialized
|
|
let model = ModelInfo {
|
|
model_id: "test".to_string(),
|
|
name: "Test".to_string(),
|
|
description: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&model).unwrap();
|
|
assert!(!json.contains("description"));
|
|
}
|
|
|
|
// ========================================================================
|
|
// SessionMetadata Tests (existing)
|
|
// ========================================================================
|
|
|
|
#[test]
|
|
fn test_session_metadata_backward_compatibility() {
|
|
// Test that existing SessionMetadata without new fields can be deserialized
|
|
let json = r#"{
|
|
"project_path": "/test/path",
|
|
"model": "gpt-4",
|
|
"total_messages": 10,
|
|
"system_message": "System prompt"
|
|
}"#;
|
|
|
|
let metadata: SessionMetadata = serde_json::from_str(json).unwrap();
|
|
assert_eq!(metadata.project_path, "/test/path");
|
|
assert_eq!(metadata.model, Some("gpt-4".to_string()));
|
|
assert_eq!(metadata.total_messages, 10);
|
|
assert_eq!(metadata.system_message, Some("System prompt".to_string()));
|
|
assert_eq!(metadata.current_mode_id, None);
|
|
assert_eq!(metadata._meta, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_metadata_skip_serializing_none() {
|
|
// Test that None values are skipped during serialization
|
|
let metadata = SessionMetadata {
|
|
project_path: "/test".to_string(),
|
|
model: Some("gpt-4".to_string()),
|
|
total_messages: 0,
|
|
system_message: None,
|
|
current_mode_id: None,
|
|
_meta: None,
|
|
project_id: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&metadata).unwrap();
|
|
// Should not contain system_message, current_mode_id, or _meta fields
|
|
assert!(!json.contains("system_message"));
|
|
assert!(!json.contains("current_mode_id"));
|
|
assert!(!json.contains("_meta"));
|
|
// Should contain the present fields
|
|
assert!(json.contains("project_path"));
|
|
assert!(json.contains("model"));
|
|
assert!(json.contains("total_messages"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_metadata_with_current_mode_id() {
|
|
// Test serialization/deserialization with current_mode_id
|
|
let metadata = SessionMetadata {
|
|
project_path: "/test".to_string(),
|
|
model: Some("gpt-4".to_string()),
|
|
total_messages: 5,
|
|
system_message: None,
|
|
current_mode_id: Some("code_mode".to_string()),
|
|
_meta: None,
|
|
project_id: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&metadata).unwrap();
|
|
assert!(json.contains("current_mode_id"));
|
|
assert!(json.contains("code_mode"));
|
|
|
|
let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(deserialized.current_mode_id, Some("code_mode".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_metadata_with_meta() {
|
|
// Test serialization/deserialization with provider metadata
|
|
let meta = Meta {
|
|
provider: Some(ProviderMeta {
|
|
name: "opencode".to_string(),
|
|
original_ids: Some(HashMap::from([(
|
|
"session_id".to_string(),
|
|
"ses_abc123".to_string(),
|
|
)])),
|
|
raw_excerpt: None,
|
|
}),
|
|
extra: HashMap::new(),
|
|
};
|
|
|
|
let metadata = SessionMetadata {
|
|
project_path: "/test".to_string(),
|
|
model: Some("gpt-4".to_string()),
|
|
total_messages: 5,
|
|
system_message: None,
|
|
current_mode_id: None,
|
|
_meta: Some(meta),
|
|
project_id: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&metadata).unwrap();
|
|
assert!(json.contains("_meta"));
|
|
assert!(json.contains("opencode"));
|
|
assert!(json.contains("ses_abc123"));
|
|
|
|
let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap();
|
|
assert!(deserialized._meta.is_some());
|
|
let deserialized_meta = deserialized._meta.unwrap();
|
|
assert!(deserialized_meta.provider.is_some());
|
|
assert_eq!(deserialized_meta.provider.unwrap().name, "opencode");
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_metadata_with_all_fields() {
|
|
// Test with all fields populated
|
|
let meta = Meta {
|
|
provider: Some(ProviderMeta {
|
|
name: "anthropic".to_string(),
|
|
original_ids: Some(HashMap::from([(
|
|
"conversation_id".to_string(),
|
|
"conv_xyz".to_string(),
|
|
)])),
|
|
raw_excerpt: Some(serde_json::json!({"version": "1.0"})),
|
|
}),
|
|
extra: HashMap::new(),
|
|
};
|
|
|
|
let metadata = SessionMetadata {
|
|
project_path: "/project".to_string(),
|
|
model: Some("claude-3".to_string()),
|
|
total_messages: 42,
|
|
system_message: Some("Be helpful".to_string()),
|
|
current_mode_id: Some("architect".to_string()),
|
|
_meta: Some(meta),
|
|
project_id: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&metadata).unwrap();
|
|
let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap();
|
|
|
|
assert_eq!(metadata, deserialized);
|
|
assert!(json.contains("system_message"));
|
|
assert!(json.contains("current_mode_id"));
|
|
assert!(json.contains("_meta"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_roundtrip_with_new_fields() {
|
|
// Test that a Session with new metadata fields survives roundtrip
|
|
let now = Utc::now();
|
|
let session = Session {
|
|
id: "ses_test123".to_string(),
|
|
title: "Test Session".to_string(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
metadata: SessionMetadata {
|
|
project_path: "/workspace".to_string(),
|
|
model: Some("gpt-4-turbo".to_string()),
|
|
total_messages: 7,
|
|
system_message: Some("You are a coding assistant".to_string()),
|
|
current_mode_id: Some("debug_mode".to_string()),
|
|
_meta: Some(Meta {
|
|
provider: Some(ProviderMeta {
|
|
name: "test_provider".to_string(),
|
|
original_ids: None,
|
|
raw_excerpt: None,
|
|
}),
|
|
extra: HashMap::new(),
|
|
}),
|
|
project_id: None,
|
|
},
|
|
cwd: None,
|
|
models: None,
|
|
modes: None,
|
|
config_options: None,
|
|
acp_client_id: None,
|
|
};
|
|
|
|
let json = serde_json::to_string(&session).unwrap();
|
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
|
|
|
assert_eq!(session, deserialized);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_with_models_and_modes() {
|
|
// Test Session with models and modes populated
|
|
let now = Utc::now();
|
|
let session = Session {
|
|
id: "ses_test456".to_string(),
|
|
title: "Test Session with ACP".to_string(),
|
|
created_at: now,
|
|
updated_at: now,
|
|
metadata: SessionMetadata {
|
|
project_path: "/workspace".to_string(),
|
|
model: Some("default".to_string()),
|
|
total_messages: 5,
|
|
system_message: None,
|
|
current_mode_id: Some("default".to_string()),
|
|
_meta: None,
|
|
project_id: None,
|
|
},
|
|
cwd: None,
|
|
models: Some(SessionModelState {
|
|
available_models: vec![
|
|
ModelInfo {
|
|
model_id: "default".to_string(),
|
|
name: "Default".to_string(),
|
|
description: Some("Default model".to_string()),
|
|
},
|
|
ModelInfo {
|
|
model_id: "sonnet".to_string(),
|
|
name: "Sonnet".to_string(),
|
|
description: None,
|
|
},
|
|
],
|
|
current_model_id: "default".to_string(),
|
|
}),
|
|
modes: Some(SessionModeState {
|
|
current_mode_id: "default".to_string(),
|
|
available_modes: vec![SessionMode {
|
|
id: "default".to_string(),
|
|
name: "Always Ask".to_string(),
|
|
description: None,
|
|
}],
|
|
}),
|
|
config_options: None,
|
|
acp_client_id: Some("test-client-123".to_string()),
|
|
};
|
|
|
|
let json = serde_json::to_string(&session).unwrap();
|
|
assert!(json.contains("models"));
|
|
assert!(json.contains("modes"));
|
|
assert!(json.contains("acp_client_id"));
|
|
assert!(json.contains("availableModels"));
|
|
assert!(json.contains("currentModeId"));
|
|
|
|
let deserialized: Session = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(session, deserialized);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_backward_compatibility_no_models_modes() {
|
|
// Test that old Session JSON without models/modes can be deserialized
|
|
let json = r#"{
|
|
"id": "ses_old",
|
|
"title": "Old Session",
|
|
"created_at": "2024-01-01T00:00:00Z",
|
|
"updated_at": "2024-01-01T00:00:00Z",
|
|
"metadata": {
|
|
"project_path": "/test",
|
|
"model": "gpt-4",
|
|
"total_messages": 10
|
|
}
|
|
}"#;
|
|
|
|
let session: Session = serde_json::from_str(json).unwrap();
|
|
assert_eq!(session.id, "ses_old");
|
|
assert!(session.models.is_none());
|
|
assert!(session.modes.is_none());
|
|
}
|
|
|
|
// ========================================================================
|
|
// Session Ownership Model Tests
|
|
// ========================================================================
|
|
|
|
#[test]
|
|
fn test_session_origin_default() {
|
|
// Verify Internal is the default
|
|
let origin = SessionOrigin::default();
|
|
assert_eq!(origin, SessionOrigin::Internal);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_origin_serialization() {
|
|
// Test Internal variant
|
|
let internal = SessionOrigin::Internal;
|
|
let json = serde_json::to_string(&internal).unwrap();
|
|
assert!(json.contains(r#""type":"internal"#));
|
|
|
|
// Test External variant
|
|
let external = SessionOrigin::External {
|
|
client_id: "client-123".to_string(),
|
|
client_capabilities: Some(serde_json::json!({"tools": ["bash"]})),
|
|
};
|
|
let json = serde_json::to_string(&external).unwrap();
|
|
assert!(json.contains(r#""type":"external"#));
|
|
assert!(json.contains(r#""client_id":"client-123"#));
|
|
assert!(json.contains("tools"));
|
|
|
|
// Test Subagent variant
|
|
let subagent = SessionOrigin::Subagent {
|
|
parent_session_id: "parent-456".to_string(),
|
|
task_id: "task-789".to_string(),
|
|
};
|
|
let json = serde_json::to_string(&subagent).unwrap();
|
|
assert!(json.contains(r#""type":"subagent"#));
|
|
assert!(json.contains(r#""parent_session_id":"parent-456"#));
|
|
assert!(json.contains(r#""task_id":"task-789"#));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_handler_default() {
|
|
// Verify Agent is the default
|
|
let handler = ToolHandler::default();
|
|
assert_eq!(handler, ToolHandler::Agent);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tool_handler_serialization() {
|
|
let agent = ToolHandler::Agent;
|
|
let json = serde_json::to_string(&agent).unwrap();
|
|
assert_eq!(json, r#""agent""#);
|
|
|
|
let dirigent = ToolHandler::Dirigent;
|
|
let json = serde_json::to_string(&dirigent).unwrap();
|
|
assert_eq!(json, r#""dirigent""#);
|
|
|
|
let forward = ToolHandler::ForwardToClient;
|
|
let json = serde_json::to_string(&forward).unwrap();
|
|
assert_eq!(json, r#""forward_to_client""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_ownership_internal() {
|
|
let ownership = SessionOwnership::internal();
|
|
assert_eq!(ownership.origin, SessionOrigin::Internal);
|
|
assert_eq!(ownership.tool_handler, ToolHandler::Agent);
|
|
assert!(!ownership.is_external());
|
|
assert_eq!(ownership.client_id(), None);
|
|
assert_eq!(ownership.forward_to_client(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_ownership_external_forwarded() {
|
|
let caps = serde_json::json!({"tools": ["bash", "edit"]});
|
|
let ownership =
|
|
SessionOwnership::external_forwarded("client-123".to_string(), Some(caps.clone()));
|
|
|
|
match &ownership.origin {
|
|
SessionOrigin::External {
|
|
client_id,
|
|
client_capabilities,
|
|
} => {
|
|
assert_eq!(client_id, "client-123");
|
|
assert_eq!(client_capabilities.as_ref().unwrap(), &caps);
|
|
}
|
|
_ => panic!("Expected External origin"),
|
|
}
|
|
|
|
assert_eq!(ownership.tool_handler, ToolHandler::ForwardToClient);
|
|
assert!(ownership.is_external());
|
|
assert_eq!(ownership.client_id(), Some("client-123"));
|
|
assert_eq!(ownership.forward_to_client(), Some("client-123"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_ownership_external_handled() {
|
|
let ownership = SessionOwnership::external_handled("client-456".to_string(), None);
|
|
|
|
match &ownership.origin {
|
|
SessionOrigin::External {
|
|
client_id,
|
|
client_capabilities,
|
|
} => {
|
|
assert_eq!(client_id, "client-456");
|
|
assert!(client_capabilities.is_none());
|
|
}
|
|
_ => panic!("Expected External origin"),
|
|
}
|
|
|
|
assert_eq!(ownership.tool_handler, ToolHandler::Dirigent);
|
|
assert!(ownership.is_external());
|
|
assert_eq!(ownership.client_id(), Some("client-456"));
|
|
assert_eq!(ownership.forward_to_client(), None); // Dirigent handles, not forwarded
|
|
}
|
|
|
|
#[test]
|
|
fn test_capabilities_for_agent_external_forwarded() {
|
|
let client_caps = serde_json::json!({"fs": true, "terminal": true});
|
|
let ownership = SessionOwnership::external_forwarded(
|
|
"client-123".to_string(),
|
|
Some(client_caps.clone()),
|
|
);
|
|
|
|
let caps = ownership.capabilities_for_agent();
|
|
assert_eq!(caps, client_caps);
|
|
}
|
|
|
|
#[test]
|
|
fn test_capabilities_for_agent_dirigent() {
|
|
let ownership = SessionOwnership {
|
|
origin: SessionOrigin::Internal,
|
|
tool_handler: ToolHandler::Dirigent,
|
|
};
|
|
|
|
let caps = ownership.capabilities_for_agent();
|
|
assert!(caps.is_object());
|
|
assert!(caps.get("fs").is_some());
|
|
assert!(caps.get("terminal").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_capabilities_for_agent_agent_handled() {
|
|
let ownership = SessionOwnership::internal();
|
|
let caps = ownership.capabilities_for_agent();
|
|
assert_eq!(caps, serde_json::json!({}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_ownership_serialization_roundtrip() {
|
|
let original = SessionOwnership::external_forwarded(
|
|
"test-client".to_string(),
|
|
Some(serde_json::json!({"test": true})),
|
|
);
|
|
|
|
let json = serde_json::to_string(&original).unwrap();
|
|
let deserialized: SessionOwnership = serde_json::from_str(&json).unwrap();
|
|
|
|
assert_eq!(deserialized.tool_handler, original.tool_handler);
|
|
assert_eq!(deserialized.client_id(), Some("test-client"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_session_ownership_default() {
|
|
let ownership = SessionOwnership::default();
|
|
assert_eq!(ownership.origin, SessionOrigin::Internal);
|
|
assert_eq!(ownership.tool_handler, ToolHandler::Agent);
|
|
}
|
|
}
|