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