sync from monorepo @ 2452e92e

This commit is contained in:
2026-05-08 01:59:04 +02:00
commit b03dc15371
459 changed files with 129586 additions and 0 deletions
+972
View File
@@ -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);
}
}