Skip to main content

vtcode_acp_client/
session.rs

1//! ACP session types and lifecycle management
2//!
3//! This module implements the session lifecycle as defined by ACP:
4//! - Session creation (session/new)
5//! - Session loading (session/load)
6//! - Prompt handling (session/prompt)
7//! - Session updates (session/update notifications)
8//!
9//! Reference: https://agentclientprotocol.com/llms.txt
10
11use hashbrown::HashMap;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// Session state enumeration
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18#[derive(Default)]
19pub enum SessionState {
20    /// Session created but not yet active
21    #[default]
22    Created,
23    /// Session is active and processing
24    Active,
25    /// Session is waiting for user input
26    AwaitingInput,
27    /// Session completed successfully
28    Completed,
29    /// Session was cancelled
30    Cancelled,
31    /// Session failed with error
32    Failed,
33}
34
35/// ACP Session representation
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct AcpSession {
38    /// Unique session identifier
39    pub session_id: String,
40
41    /// Current session state
42    pub state: SessionState,
43
44    /// Session creation timestamp (ISO 8601)
45    pub created_at: String,
46
47    /// Last activity timestamp (ISO 8601)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub last_activity_at: Option<String>,
50
51    /// Session metadata
52    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
53    pub metadata: HashMap<String, Value>,
54
55    /// Turn counter for prompt/response cycles
56    #[serde(default)]
57    pub turn_count: u32,
58}
59
60impl AcpSession {
61    /// Create a new session with the given ID
62    pub fn new(session_id: impl Into<String>) -> Self {
63        Self {
64            session_id: session_id.into(),
65            state: SessionState::Created,
66            created_at: chrono::Utc::now().to_rfc3339(),
67            last_activity_at: None,
68            metadata: HashMap::new(),
69            turn_count: 0,
70        }
71    }
72
73    /// Update session state
74    pub fn set_state(&mut self, state: SessionState) {
75        self.state = state;
76        self.last_activity_at = Some(chrono::Utc::now().to_rfc3339());
77    }
78
79    /// Increment turn counter
80    pub fn increment_turn(&mut self) {
81        self.turn_count += 1;
82        self.last_activity_at = Some(chrono::Utc::now().to_rfc3339());
83    }
84}
85
86// ============================================================================
87// Session/New Request/Response
88// ============================================================================
89
90/// Parameters for session/new method
91#[derive(Debug, Clone, Serialize, Deserialize, Default)]
92pub struct SessionNewParams {
93    /// Optional session metadata
94    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
95    pub metadata: HashMap<String, Value>,
96
97    /// Optional workspace context
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub workspace: Option<WorkspaceContext>,
100
101    /// Optional model preferences
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub model_preferences: Option<ModelPreferences>,
104}
105
106/// Result of session/new method
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct SessionNewResult {
109    /// The created session ID
110    pub session_id: String,
111
112    /// Initial session state
113    #[serde(default)]
114    pub state: SessionState,
115}
116
117// ============================================================================
118// Session/Load Request/Response
119// ============================================================================
120
121/// Parameters for session/load method
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct SessionLoadParams {
124    /// Session ID to load
125    pub session_id: String,
126}
127
128/// Result of session/load method
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct SessionLoadResult {
131    /// The loaded session
132    pub session: AcpSession,
133
134    /// Conversation history (if available)
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub history: Vec<ConversationTurn>,
137}
138
139// ============================================================================
140// Session/Prompt Request/Response
141// ============================================================================
142
143/// Parameters for session/prompt method
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct SessionPromptParams {
146    /// Session ID
147    pub session_id: String,
148
149    /// Prompt content (can be text, images, etc.)
150    pub content: Vec<PromptContent>,
151
152    /// Optional turn-specific metadata
153    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
154    pub metadata: HashMap<String, Value>,
155}
156
157/// Prompt content types
158#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(tag = "type", rename_all = "snake_case")]
160pub enum PromptContent {
161    /// Plain text content
162    Text {
163        /// The text content
164        text: String,
165    },
166
167    /// Image content (base64 or URL)
168    Image {
169        /// Image data (base64) or URL
170        data: String,
171        /// MIME type (e.g., "image/png")
172        mime_type: String,
173        /// Whether data is a URL (false = base64)
174        #[serde(default)]
175        is_url: bool,
176    },
177
178    /// Embedded context (file contents, etc.)
179    Context {
180        /// Context identifier/path
181        path: String,
182        /// Context content
183        content: String,
184        /// Language hint for syntax highlighting
185        #[serde(skip_serializing_if = "Option::is_none")]
186        language: Option<String>,
187    },
188}
189
190impl PromptContent {
191    /// Create text content
192    pub fn text(text: impl Into<String>) -> Self {
193        Self::Text { text: text.into() }
194    }
195
196    /// Create context content
197    pub fn context(path: impl Into<String>, content: impl Into<String>) -> Self {
198        Self::Context {
199            path: path.into(),
200            content: content.into(),
201            language: None,
202        }
203    }
204}
205
206/// Result of session/prompt method
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct SessionPromptResult {
209    /// Turn ID for this prompt/response cycle
210    pub turn_id: String,
211
212    /// Final response content (may be streamed via notifications first)
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub response: Option<String>,
215
216    /// Tool calls made during this turn
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub tool_calls: Vec<ToolCallRecord>,
219
220    /// Turn completion status
221    pub status: TurnStatus,
222}
223
224/// Turn completion status
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum TurnStatus {
228    /// Turn completed successfully
229    Completed,
230    /// Turn was cancelled
231    Cancelled,
232    /// Turn failed with error
233    Failed,
234    /// Turn requires user input (e.g., permission approval)
235    AwaitingInput,
236}
237
238// ============================================================================
239// Session/RequestPermission (Client Method)
240// ============================================================================
241
242/// Parameters for session/request_permission method (client callable by agent)
243#[derive(Debug, Clone, Serialize, Deserialize)]
244#[serde(rename_all = "camelCase")]
245pub struct RequestPermissionParams {
246    /// Session ID
247    pub session_id: String,
248
249    /// Tool call requiring permission
250    pub tool_call: ToolCallRecord,
251
252    /// Available permission options
253    pub options: Vec<PermissionOption>,
254}
255
256/// A permission option presented to the user
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct PermissionOption {
259    /// Option ID
260    pub id: String,
261
262    /// Display label
263    pub label: String,
264
265    /// Detailed description
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub description: Option<String>,
268}
269
270/// Result of session/request_permission
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(tag = "outcome", rename_all = "snake_case")]
273pub enum RequestPermissionResult {
274    /// User selected an option
275    Selected {
276        /// The selected option ID
277        option_id: String,
278    },
279    /// User cancelled the request
280    Cancelled,
281}
282
283// ============================================================================
284// Session/Cancel Request
285// ============================================================================
286
287/// Parameters for session/cancel method
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct SessionCancelParams {
290    /// Session ID
291    pub session_id: String,
292
293    /// Optional turn ID to cancel (if not provided, cancels current turn)
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub turn_id: Option<String>,
296}
297
298// ============================================================================
299// Session/Update Notification (Streaming)
300// ============================================================================
301
302/// Session update notification payload
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct SessionUpdateNotification {
305    /// Session ID
306    pub session_id: String,
307
308    /// Turn ID this update belongs to
309    pub turn_id: String,
310
311    /// Update type
312    #[serde(flatten)]
313    pub update: SessionUpdate,
314}
315
316/// Session update types
317#[derive(Debug, Clone, Serialize, Deserialize)]
318#[serde(tag = "update_type", rename_all = "snake_case")]
319pub enum SessionUpdate {
320    /// Text delta (streaming response)
321    MessageDelta {
322        /// Incremental text content
323        delta: String,
324    },
325
326    /// Tool call started
327    ToolCallStart {
328        /// Tool call details
329        tool_call: ToolCallRecord,
330    },
331
332    /// Tool call completed
333    ToolCallEnd {
334        /// Tool call ID
335        tool_call_id: String,
336        /// Tool result
337        result: Value,
338    },
339
340    /// Turn completed
341    TurnComplete {
342        /// Final status
343        status: TurnStatus,
344    },
345
346    /// Error occurred
347    Error {
348        /// Error code
349        code: String,
350        /// Error message
351        message: String,
352    },
353
354    /// Server requests the client to execute a tool (bidirectional ACP protocol).
355    ///
356    /// Arrives via a `server/request` SSE event. After executing the tool,
357    /// send the result back with [`AcpClientV2::session_tool_response`].
358    ServerRequest {
359        /// The tool execution request from the agent.
360        request: ToolExecutionRequest,
361    },
362}
363
364// ============================================================================
365// Supporting Types
366// ============================================================================
367
368/// Workspace context for session initialization
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct WorkspaceContext {
371    /// Workspace root path
372    pub root_path: String,
373
374    /// Workspace name
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub name: Option<String>,
377
378    /// Active file paths
379    #[serde(default, skip_serializing_if = "Vec::is_empty")]
380    pub active_files: Vec<String>,
381}
382
383/// Model preferences for session
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct ModelPreferences {
386    /// Preferred model ID
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub model_id: Option<String>,
389
390    /// Temperature setting
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub temperature: Option<f32>,
393
394    /// Max tokens
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub max_tokens: Option<u32>,
397}
398
399/// Record of a tool call
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct ToolCallRecord {
402    /// Unique tool call ID
403    pub id: String,
404
405    /// Tool name
406    pub name: String,
407
408    /// Tool arguments
409    pub arguments: Value,
410
411    /// Tool result (if completed)
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub result: Option<Value>,
414
415    /// Timestamp
416    pub timestamp: String,
417}
418
419/// Server-to-client tool execution request (arrives via `server/request` SSE event).
420///
421/// When the ACP agent needs the client to run a tool on its behalf it emits a
422/// `server/request` SSE event containing this payload. The client must execute
423/// the tool and reply with [`ToolExecutionResult`] via `client/response`.
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct ToolExecutionRequest {
426    /// Unique request identifier used to correlate the response.
427    pub request_id: String,
428    /// The tool call the agent wants the client to execute.
429    pub tool_call: ToolCallRecord,
430}
431
432/// Result of a client-side tool execution, sent back via `client/response`.
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct ToolExecutionResult {
435    /// Must match the `request_id` from [`ToolExecutionRequest`].
436    pub request_id: String,
437    /// ID of the tool call that was executed.
438    pub tool_call_id: String,
439    /// Tool output (structured or text).
440    pub output: Value,
441    /// Whether execution succeeded.
442    pub success: bool,
443    /// Error message when `success` is false.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub error: Option<String>,
446}
447
448/// Wrapper for a `server/request` SSE event notification.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct ServerRequestNotification {
451    /// Session this request belongs to.
452    pub session_id: String,
453    /// The tool execution request.
454    pub request: ToolExecutionRequest,
455}
456
457/// A single turn in the conversation
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct ConversationTurn {
460    /// Turn ID
461    pub turn_id: String,
462
463    /// User prompt
464    pub prompt: Vec<PromptContent>,
465
466    /// Agent response
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub response: Option<String>,
469
470    /// Tool calls made during this turn
471    #[serde(default, skip_serializing_if = "Vec::is_empty")]
472    pub tool_calls: Vec<ToolCallRecord>,
473
474    /// Turn timestamp
475    pub timestamp: String,
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use serde_json::json;
482
483    #[test]
484    fn test_session_new_params() {
485        let params = SessionNewParams::default();
486        let json = serde_json::to_value(&params).unwrap();
487        assert_eq!(json, json!({}));
488    }
489
490    #[test]
491    fn test_prompt_content_text() {
492        let content = PromptContent::text("Hello, world!");
493        let json = serde_json::to_value(&content).unwrap();
494        assert_eq!(json["type"], "text");
495        assert_eq!(json["text"], "Hello, world!");
496    }
497
498    #[test]
499    fn test_session_update_message_delta() {
500        let update = SessionUpdate::MessageDelta {
501            delta: "Hello".to_string(),
502        };
503        let json = serde_json::to_value(&update).unwrap();
504        assert_eq!(json["update_type"], "message_delta");
505        assert_eq!(json["delta"], "Hello");
506    }
507
508    #[test]
509    fn test_session_state_transitions() {
510        let mut session = AcpSession::new("test-session");
511        assert_eq!(session.state, SessionState::Created);
512
513        session.set_state(SessionState::Active);
514        assert_eq!(session.state, SessionState::Active);
515        assert!(session.last_activity_at.is_some());
516    }
517
518    #[test]
519    fn server_request_update_serializes_correctly() {
520        let tool_call = ToolCallRecord {
521            id: "tc-1".to_string(),
522            name: "unified_search".to_string(),
523            arguments: json!({"query": "fn main"}),
524            result: None,
525            timestamp: "2025-01-01T00:00:00Z".to_string(),
526        };
527        let request = ToolExecutionRequest {
528            request_id: "req-1".to_string(),
529            tool_call,
530        };
531        let update = SessionUpdate::ServerRequest { request };
532        let json = serde_json::to_value(&update).unwrap();
533        assert_eq!(json["update_type"], "server_request");
534        assert_eq!(json["request"]["request_id"], "req-1");
535    }
536
537    #[test]
538    fn tool_execution_result_success_serializes() {
539        let result = ToolExecutionResult {
540            request_id: "req-1".to_string(),
541            tool_call_id: "tc-1".to_string(),
542            output: json!({"matches": []}),
543            success: true,
544            error: None,
545        };
546        let json = serde_json::to_value(&result).unwrap();
547        assert_eq!(json["success"], true);
548        assert!(json.get("error").is_none());
549    }
550
551    #[test]
552    fn tool_execution_result_failure_includes_error() {
553        let result = ToolExecutionResult {
554            request_id: "req-1".to_string(),
555            tool_call_id: "tc-1".to_string(),
556            output: Value::Null,
557            success: false,
558            error: Some("permission denied".to_string()),
559        };
560        let json = serde_json::to_value(&result).unwrap();
561        assert_eq!(json["success"], false);
562        assert_eq!(json["error"], "permission denied");
563    }
564}