sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,972 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user