everruns_core/capabilities/
session.rs1use super::{Capability, CapabilityStatus};
8use crate::events::TokenUsage;
9use crate::tool_types::ToolHints;
10use crate::tools::{Tool, ToolExecutionResult};
11use crate::traits::ToolContext;
12use async_trait::async_trait;
13use serde_json::{Value, json};
14
15pub struct SessionCapability;
17
18impl Capability for SessionCapability {
19 fn id(&self) -> &str {
20 "session"
21 }
22
23 fn name(&self) -> &str {
24 "Session"
25 }
26
27 fn description(&self) -> &str {
28 "Read and update current session metadata like title and agent info."
29 }
30
31 fn status(&self) -> CapabilityStatus {
32 CapabilityStatus::Available
33 }
34
35 fn icon(&self) -> Option<&str> {
36 Some("panel-left")
37 }
38
39 fn category(&self) -> Option<&str> {
40 Some("Session")
41 }
42
43 fn tools(&self) -> Vec<Box<dyn Tool>> {
44 vec![
45 Box::new(WriteSessionTitleTool),
46 Box::new(GetSessionInfoTool),
47 ]
48 }
49}
50
51pub struct WriteSessionTitleTool;
53
54#[async_trait]
55impl Tool for WriteSessionTitleTool {
56 fn name(&self) -> &str {
57 "write_session_title"
58 }
59
60 fn display_name(&self) -> Option<&str> {
61 Some("Write Session Title")
62 }
63
64 fn description(&self) -> &str {
65 "Update the current session title."
66 }
67
68 fn parameters_schema(&self) -> Value {
69 json!({
70 "type": "object",
71 "properties": {
72 "title": {
73 "type": "string",
74 "description": "New session title"
75 }
76 },
77 "required": ["title"],
78 "additionalProperties": false
79 })
80 }
81
82 fn hints(&self) -> ToolHints {
83 ToolHints::default().with_idempotent(true)
84 }
85
86 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
87 ToolExecutionResult::tool_error(
88 "write_session_title requires context. This tool must be executed with session context.",
89 )
90 }
91
92 async fn execute_with_context(
93 &self,
94 arguments: Value,
95 context: &ToolContext,
96 ) -> ToolExecutionResult {
97 let title = match arguments.get("title").and_then(|v| v.as_str()) {
98 Some(t) if !t.trim().is_empty() => t.trim().to_string(),
99 _ => return ToolExecutionResult::tool_error("Missing required parameter: title"),
100 };
101
102 let Some(mutator) = &context.session_mutator else {
103 return ToolExecutionResult::tool_error(
104 "Session mutator not available in this context",
105 );
106 };
107
108 match mutator
109 .update_session_title(context.session_id, title.clone())
110 .await
111 {
112 Ok(session) => ToolExecutionResult::success(json!({
113 "session_id": session.id.to_string(),
114 "title": session.title,
115 "updated": true,
116 })),
117 Err(e) => ToolExecutionResult::internal_error(e),
118 }
119 }
120}
121
122pub struct GetSessionInfoTool;
124
125#[async_trait]
126impl Tool for GetSessionInfoTool {
127 fn name(&self) -> &str {
128 "get_session_info"
129 }
130
131 fn display_name(&self) -> Option<&str> {
132 Some("Get Session Info")
133 }
134
135 fn description(&self) -> &str {
136 "Get current session metadata: id, title, locale, agent name, and cumulative token usage."
137 }
138
139 fn parameters_schema(&self) -> Value {
140 json!({
141 "type": "object",
142 "properties": {},
143 "additionalProperties": false
144 })
145 }
146
147 fn hints(&self) -> ToolHints {
148 ToolHints::default()
149 .with_readonly(true)
150 .with_idempotent(true)
151 }
152
153 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
154 ToolExecutionResult::tool_error(
155 "get_session_info requires context. This tool must be executed with session context.",
156 )
157 }
158
159 async fn execute_with_context(
160 &self,
161 _arguments: Value,
162 context: &ToolContext,
163 ) -> ToolExecutionResult {
164 let Some(session_store) = &context.session_store else {
165 return ToolExecutionResult::tool_error("Session store not available in this context");
166 };
167
168 let session = match session_store.get_session(context.session_id).await {
169 Ok(Some(session)) => session,
170 Ok(None) => return ToolExecutionResult::tool_error("Session not found"),
171 Err(e) => return ToolExecutionResult::internal_error(e),
172 };
173
174 let agent_name = if let (Some(agent_id), Some(agent_store)) =
175 (session.agent_id, &context.agent_store)
176 {
177 match agent_store.get_agent(agent_id).await {
178 Ok(Some(agent)) => Some(agent.display_name.unwrap_or_else(|| agent.name.clone())),
179 Ok(None) => None,
180 Err(e) => return ToolExecutionResult::internal_error(e),
181 }
182 } else {
183 None
184 };
185
186 ToolExecutionResult::success(json!({
187 "session_id": session.id.to_string(),
188 "title": session.title,
189 "locale": session.locale,
190 "agent_name": agent_name,
191 "usage": session.usage.as_ref().map(usage_json),
192 }))
193 }
194}
195
196fn usage_json(usage: &TokenUsage) -> Value {
197 json!({
198 "input_tokens": usage.input_tokens,
199 "output_tokens": usage.output_tokens,
200 "cache_read_tokens": usage.cache_read_tokens,
201 "cache_creation_tokens": usage.cache_creation_tokens,
202 "total_tokens": usage.total_tokens(),
203 })
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::agent::{Agent, AgentStatus};
210 use crate::error::Result;
211 use crate::session::{Session, SessionStatus};
212 use crate::typed_id::{AgentId, HarnessId, ModelId, SessionId};
213 use crate::{AgentCapabilityConfig, Tool};
214 use async_trait::async_trait;
215 use chrono::Utc;
216 use std::sync::{Arc, Mutex};
217
218 #[derive(Clone)]
219 struct MockSessionStore {
220 session: Arc<Mutex<Option<Session>>>,
221 }
222
223 #[async_trait]
224 impl crate::traits::SessionStore for MockSessionStore {
225 async fn get_session(&self, _session_id: SessionId) -> Result<Option<Session>> {
226 Ok(self.session.lock().expect("poisoned").clone())
227 }
228 }
229
230 #[derive(Clone)]
231 struct MockSessionMutator {
232 session: Arc<Mutex<Session>>,
233 }
234
235 #[async_trait]
236 impl crate::traits::SessionMutator for MockSessionMutator {
237 async fn update_session_title(
238 &self,
239 _session_id: SessionId,
240 title: String,
241 ) -> Result<Session> {
242 let mut session = self.session.lock().expect("poisoned");
243 session.title = Some(title);
244 Ok(session.clone())
245 }
246 }
247
248 struct MockAgentStore {
249 agent: Option<Agent>,
250 }
251
252 #[async_trait]
253 impl crate::traits::AgentStore for MockAgentStore {
254 async fn get_agent(&self, _agent_id: AgentId) -> Result<Option<Agent>> {
255 Ok(self.agent.clone())
256 }
257 }
258
259 fn build_session(agent_id: Option<AgentId>) -> Session {
260 Session {
261 id: SessionId::new(),
262 organization_id: "org_00000000000000000000000000000001".to_string(),
263 harness_id: HarnessId::new(),
264 agent_id,
265 agent_version_id: None,
266 agent_identity_id: None,
267 owner_principal_id: crate::PrincipalId::from_seed(1),
268 resolved_owner_user_id: None,
269 owner: None,
270 effective_owner: None,
271 title: Some("Old title".to_string()),
272 locale: None,
273 preview: None,
274 output_preview: None,
275 tags: vec![],
276 model_id: Some(ModelId::new()),
277 capabilities: vec![],
278 tools: vec![],
279 mcp_servers: Default::default(),
280 system_prompt: None,
281 initial_files: vec![],
282 hints: None,
283 network_access: None,
284 max_iterations: None,
285 status: SessionStatus::Idle,
286 created_at: Utc::now(),
287 updated_at: Utc::now(),
288 started_at: None,
289 finished_at: None,
290 usage: None,
291 is_pinned: None,
292 active_schedule_count: None,
293 features: vec![],
294 parent_session_id: None,
295 subagent_name: None,
296 subagent_task: None,
297 subagent_status: None,
298 blueprint_id: None,
299 blueprint_config: None,
300 }
301 }
302
303 #[tokio::test]
304 async fn write_session_title_updates_title() {
305 let session = build_session(None);
306 let session_id = session.id;
307 let mut context = ToolContext::new(session_id);
308 context.session_mutator = Some(Arc::new(MockSessionMutator {
309 session: Arc::new(Mutex::new(session)),
310 }));
311
312 let tool = WriteSessionTitleTool;
313 let result = tool
314 .execute_with_context(json!({"title": "New title"}), &context)
315 .await;
316
317 match result {
318 ToolExecutionResult::Success(value) => {
319 assert_eq!(value["title"], "New title");
320 assert_eq!(value["updated"], true);
321 }
322 _ => panic!("expected success"),
323 }
324 }
325
326 #[tokio::test]
327 async fn get_session_info_returns_agent_name_when_assigned() {
328 let agent_id = AgentId::new();
329 let session = build_session(Some(agent_id));
330 let session_id = session.id;
331
332 let agent = Agent {
333 public_id: agent_id,
334 internal_id: agent_id.uuid(),
335 name: "research-agent".to_string(),
336 display_name: Some("Research Agent".to_string()),
337 description: Some("desc".to_string()),
338 system_prompt: "prompt".to_string(),
339 default_model_id: None,
340 default_version_id: None,
341 forked_from_agent_id: None,
342 forked_from_version_id: None,
343 root_agent_id: None,
344 tags: vec![],
345 capabilities: vec![AgentCapabilityConfig::new("session")],
346 initial_files: vec![],
347 network_access: None,
348 max_iterations: None,
349 tools: vec![],
350 mcp_servers: Default::default(),
351 status: AgentStatus::Active,
352 created_at: Utc::now(),
353 updated_at: Utc::now(),
354 archived_at: None,
355 deleted_at: None,
356 usage: None,
357 };
358
359 let context = ToolContext::new(session_id)
360 .with_session_store(Arc::new(MockSessionStore {
361 session: Arc::new(Mutex::new(Some(session))),
362 }))
363 .with_agent_store(Arc::new(MockAgentStore { agent: Some(agent) }));
364
365 let tool = GetSessionInfoTool;
366 let result = tool.execute_with_context(json!({}), &context).await;
367
368 match result {
369 ToolExecutionResult::Success(value) => {
370 assert_eq!(value["title"], "Old title");
371 assert_eq!(value["agent_name"], "Research Agent");
372 assert!(value["usage"].is_null());
373 }
374 _ => panic!("expected success"),
375 }
376 }
377
378 #[tokio::test]
379 async fn get_session_info_returns_cumulative_usage() {
380 let mut session = build_session(None);
381 session.usage = Some(TokenUsage::with_cache(120, 45, Some(30), Some(10)));
382 let session_id = session.id;
383
384 let context = ToolContext::new(session_id).with_session_store(Arc::new(MockSessionStore {
385 session: Arc::new(Mutex::new(Some(session))),
386 }));
387
388 let tool = GetSessionInfoTool;
389 let result = tool.execute_with_context(json!({}), &context).await;
390
391 match result {
392 ToolExecutionResult::Success(value) => {
393 assert_eq!(value["usage"]["input_tokens"], 120);
394 assert_eq!(value["usage"]["output_tokens"], 45);
395 assert_eq!(value["usage"]["cache_read_tokens"], 30);
396 assert_eq!(value["usage"]["cache_creation_tokens"], 10);
397 assert_eq!(value["usage"]["total_tokens"], 165);
398 }
399 _ => panic!("expected success"),
400 }
401 }
402}