everruns_core/capabilities/
btw.rs1use super::{Capability, CapabilityLocalization, CapabilityStatus};
13use crate::command::{
14 CommandArg, CommandDescriptor, CommandExecutionContext, CommandResult, CommandSource,
15 ExecuteCommandRequest,
16};
17use crate::command_host::SessionCompletionRequest;
18use crate::error::AgentLoopError;
19use crate::message::Message;
20use async_trait::async_trait;
21use std::collections::HashMap;
22
23pub const BTW_CAPABILITY_ID: &str = "btw";
24
25const BTW_COMMAND_NAME: &str = "btw";
26const BTW_SYSTEM_PROMPT: &str = "You are answering an ephemeral side question about the current session. Use the existing conversation as context, answer exactly once, and do not call tools or ask follow-up questions.";
27
28pub struct BtwCapability;
29
30#[async_trait]
31impl Capability for BtwCapability {
32 fn id(&self) -> &str {
33 BTW_CAPABILITY_ID
34 }
35
36 fn name(&self) -> &str {
37 "BTW"
38 }
39
40 fn description(&self) -> &str {
41 "Ephemeral side-question command for the current session."
42 }
43
44 fn localizations(&self) -> Vec<CapabilityLocalization> {
45 vec![CapabilityLocalization::text(
46 "uk",
47 "BTW",
48 "Ефемерна команда для побічних запитань у поточній сесії.",
49 )]
50 }
51
52 fn status(&self) -> CapabilityStatus {
53 CapabilityStatus::Available
54 }
55
56 fn icon(&self) -> Option<&str> {
57 Some("message-circle")
58 }
59
60 fn category(&self) -> Option<&str> {
61 Some("System")
62 }
63
64 fn commands(&self) -> Vec<CommandDescriptor> {
65 vec![CommandDescriptor {
66 name: BTW_COMMAND_NAME.to_string(),
67 description:
68 "Ask a side question about the current session without interrupting the main task."
69 .to_string(),
70 source: CommandSource::System,
71 args: vec![CommandArg {
72 name: "question".to_string(),
73 description: "The side question to answer.".to_string(),
74 required: true,
75 suggestions: vec![],
76 }],
77 }]
78 }
79
80 async fn execute_command(
81 &self,
82 request: &ExecuteCommandRequest,
83 ctx: &CommandExecutionContext,
84 ) -> crate::error::Result<CommandResult> {
85 if request.name != BTW_COMMAND_NAME {
86 return Err(AgentLoopError::config(format!(
87 "{} cannot execute /{}",
88 self.id(),
89 request.name
90 )));
91 }
92 let question = request
93 .arguments
94 .as_deref()
95 .map(str::trim)
96 .filter(|value| !value.is_empty())
97 .ok_or_else(|| AgentLoopError::config("/btw requires a question"))?;
98
99 let turn = ctx.host.turn_context().await?;
100
101 let mut messages = turn.messages;
102 let mut side_question = Message::user(question.to_string());
103 side_question.controls = request.controls.clone();
104 messages.push(side_question);
105
106 let completion_request = SessionCompletionRequest {
107 system_prompts: vec![turn.system_prompt, BTW_SYSTEM_PROMPT.to_string()],
108 messages,
109 controls: request.controls.clone(),
110 metadata: HashMap::from([("command".to_string(), BTW_COMMAND_NAME.to_string())]),
111 };
112
113 match ctx.host.completion(completion_request).await {
117 Ok(completion) => Ok(CommandResult {
118 success: true,
119 message: completion.text,
120 error_code: None,
121 error_fields: None,
122 }),
123 Err(error) => error.into_command_result(),
124 }
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::command_host::{
132 CommandHost, CommandTurnContext, SessionCompletion, SessionCompletionError,
133 };
134 use crate::session::{Session, SessionStatus};
135 use crate::typed_id::{HarnessId, SessionId};
136 use crate::user_facing_error::UserFacingErrorContext;
137 use std::sync::{Arc, Mutex};
138
139 #[test]
142 fn test_btw_capability_registers_command() {
143 let cap = BtwCapability;
144 let commands = cap.commands();
145 assert_eq!(commands.len(), 1);
146 assert_eq!(commands[0].name, "btw");
147 assert_eq!(commands[0].source, CommandSource::System);
148 assert_eq!(commands[0].args.len(), 1);
149 assert_eq!(commands[0].args[0].name, "question");
150 assert!(commands[0].args[0].required);
151 }
152
153 fn test_session(session_id: SessionId) -> Session {
154 Session {
155 id: session_id,
156 workspace_id: crate::WorkspaceId::from_uuid((session_id).uuid()),
157 organization_id: crate::DEFAULT_ORG_PUBLIC_ID.to_string(),
158 harness_id: HarnessId::new(),
159 agent_id: None,
160 agent_version_id: None,
161 agent_identity_id: None,
162 owner_principal_id: crate::PrincipalId::from_seed(1),
163 resolved_owner_user_id: None,
164 owner: None,
165 effective_owner: None,
166 title: None,
167 goal: None,
168 locale: None,
169 preview: None,
170 output_preview: None,
171 tags: vec![],
172 model_id: None,
173 capabilities: vec![],
174 tools: vec![],
175 mcp_servers: Default::default(),
176 system_prompt: None,
177 initial_files: vec![],
178 hints: None,
179 network_access: None,
180 max_iterations: None,
181 parallel_tool_calls: None,
182 status: SessionStatus::Started,
183 created_at: chrono::Utc::now(),
184 updated_at: chrono::Utc::now(),
185 started_at: None,
186 finished_at: None,
187 usage: None,
188 is_pinned: None,
189 active_schedule_count: None,
190 features: vec![],
191 parent_session_id: None,
192 forked_from_session_id: None,
193 forked_from_sequence: None,
194 blueprint_id: None,
195 blueprint_config: None,
196 }
197 }
198
199 struct StubHost {
200 completion_result:
201 Mutex<Option<std::result::Result<SessionCompletion, SessionCompletionError>>>,
202 seen_request: Mutex<Option<SessionCompletionRequest>>,
203 }
204
205 impl StubHost {
206 fn returning(
207 result: std::result::Result<SessionCompletion, SessionCompletionError>,
208 ) -> Arc<Self> {
209 Arc::new(Self {
210 completion_result: Mutex::new(Some(result)),
211 seen_request: Mutex::new(None),
212 })
213 }
214 }
215
216 #[async_trait]
217 impl CommandHost for StubHost {
218 async fn turn_context(&self) -> crate::error::Result<CommandTurnContext> {
219 let session_id = SessionId::new();
220 Ok(CommandTurnContext {
221 session: test_session(session_id),
222 messages: vec![Message::user("earlier message")],
223 system_prompt: "merged system prompt".to_string(),
224 model: "llmsim-model".to_string(),
225 provider_type: "llmsim".to_string(),
226 resolved_locale: None,
227 })
228 }
229
230 async fn completion(
231 &self,
232 request: SessionCompletionRequest,
233 ) -> std::result::Result<SessionCompletion, SessionCompletionError> {
234 *self.seen_request.lock().unwrap() = Some(request);
235 self.completion_result.lock().unwrap().take().unwrap()
236 }
237 }
238
239 fn execution_context(host: Arc<StubHost>) -> CommandExecutionContext {
240 CommandExecutionContext::new(SessionId::new(), host)
241 }
242
243 fn btw_request(arguments: Option<&str>) -> ExecuteCommandRequest {
244 ExecuteCommandRequest {
245 name: "btw".to_string(),
246 arguments: arguments.map(str::to_string),
247 controls: None,
248 }
249 }
250
251 #[tokio::test]
252 async fn execute_command_answers_with_session_context() {
253 let host = StubHost::returning(Ok(SessionCompletion {
254 text: "the side answer".to_string(),
255 }));
256 let ctx = execution_context(host.clone());
257
258 let result = BtwCapability
259 .execute_command(&btw_request(Some("what changed?")), &ctx)
260 .await
261 .unwrap();
262
263 assert!(result.success);
264 assert_eq!(result.message, "the side answer");
265
266 let request = host.seen_request.lock().unwrap().take().unwrap();
267 assert_eq!(request.system_prompts.len(), 2);
269 assert_eq!(request.system_prompts[0], "merged system prompt");
270 assert!(request.system_prompts[1].contains("ephemeral side question"));
271 assert_eq!(request.messages.len(), 2);
273 assert_eq!(request.messages[1].text(), Some("what changed?"));
274 assert_eq!(
275 request.metadata.get("command").map(String::as_str),
276 Some("btw")
277 );
278 }
279
280 #[tokio::test]
281 async fn execute_command_requires_a_question() {
282 let host = StubHost::returning(Ok(SessionCompletion {
283 text: "unused".to_string(),
284 }));
285 let ctx = execution_context(host);
286
287 for arguments in [None, Some(""), Some(" ")] {
288 let error = BtwCapability
289 .execute_command(&btw_request(arguments), &ctx)
290 .await
291 .unwrap_err();
292 assert!(error.to_string().contains("requires a question"));
293 }
294 }
295
296 #[tokio::test]
297 async fn execute_command_classifies_provider_errors() {
298 let host = StubHost::returning(Err(SessionCompletionError::Completion {
299 error: "OpenAI API error (429): rate limit exceeded".to_string(),
300 context: UserFacingErrorContext::default().with_provider("openai"),
301 }));
302 let ctx = execution_context(host);
303
304 let result = BtwCapability
305 .execute_command(&btw_request(Some("what changed?")), &ctx)
306 .await
307 .unwrap();
308
309 assert!(!result.success);
310 assert_eq!(result.error_code.as_deref(), Some("provider_rate_limited"));
311 }
312
313 #[tokio::test]
314 async fn execute_command_bubbles_invalid_completion_requests() {
315 let host = StubHost::returning(Err(SessionCompletionError::InvalidRequest(
316 AgentLoopError::config("Model not found: model_123"),
317 )));
318 let ctx = execution_context(host);
319
320 let error = BtwCapability
321 .execute_command(&btw_request(Some("what changed?")), &ctx)
322 .await
323 .unwrap_err();
324 assert!(error.to_string().contains("Model not found"));
325 }
326
327 #[tokio::test]
328 async fn execute_command_fails_without_host_facilities() {
329 let ctx = CommandExecutionContext::without_host(SessionId::new());
330
331 let error = BtwCapability
332 .execute_command(&btw_request(Some("what changed?")), &ctx)
333 .await
334 .unwrap_err();
335 assert!(error.to_string().contains("turn-context"));
336 }
337}