1use super::{Capability, CapabilityLocalization, CapabilityStatus};
14use crate::session_task::{
15 NewTaskMessage, SessionTask, SessionTaskFilter, SessionTaskRegistry, SessionTaskState,
16 TaskMessage, find_task_executor,
17};
18use crate::tool_types::ToolHints;
19use crate::tools::{Tool, ToolExecutionResult};
20use crate::traits::ToolContext;
21use async_trait::async_trait;
22use serde_json::{Value, json};
23use std::sync::Arc;
24use std::time::Duration;
25use tokio::time::{Instant, sleep};
26
27pub const SESSION_TASKS_CAPABILITY_ID: &str = "session_tasks";
28
29const DEFAULT_WAIT_TIMEOUT_SECS: u64 = 300;
30const WAIT_POLL_INTERVAL: Duration = Duration::from_secs(1);
31const WAIT_RECONCILE_EVERY: u64 = 5;
33const GET_TASK_MESSAGE_LIMIT: u32 = 20;
35
36pub struct SessionTasksCapability;
38
39impl Capability for SessionTasksCapability {
40 fn id(&self) -> &str {
41 SESSION_TASKS_CAPABILITY_ID
42 }
43
44 fn name(&self) -> &str {
45 "Session Tasks"
46 }
47
48 fn description(&self) -> &str {
49 "Track, message, cancel, and wait on the session's background tasks (subagents, external agents, background tools)."
50 }
51
52 fn localizations(&self) -> Vec<CapabilityLocalization> {
53 vec![CapabilityLocalization::text(
54 "uk",
55 "Завдання сесії",
56 "Відстежуйте фонові завдання сесії (субагенти, зовнішні агенти, фонові інструменти), надсилайте їм повідомлення, скасовуйте їх та очікуйте на їхнє завершення.",
57 )]
58 }
59
60 fn status(&self) -> CapabilityStatus {
61 CapabilityStatus::Available
62 }
63
64 fn icon(&self) -> Option<&str> {
65 Some("list-checks")
66 }
67
68 fn category(&self) -> Option<&str> {
69 Some("Orchestration")
70 }
71
72 fn features(&self) -> Vec<&'static str> {
73 vec!["session_tasks"]
74 }
75
76 fn system_prompt_addition(&self) -> Option<&str> {
77 Some(SESSION_TASKS_SYSTEM_PROMPT)
78 }
79
80 fn tools(&self) -> Vec<Box<dyn Tool>> {
81 vec![
82 Box::new(ListTasksTool),
83 Box::new(GetTaskTool),
84 Box::new(MessageTaskTool),
85 Box::new(CancelTaskTool),
86 Box::new(WaitTaskTool),
87 ]
88 }
89}
90
91const SESSION_TASKS_SYSTEM_PROMPT: &str = "Every spawned background work item (subagent, external agent, background tool) is a task with a task_id. Use list_tasks/get_task to check status instead of re-spawning. Answer a task in awaiting_input with message_task (set in_reply_to to the pending input request id). Use wait_task only when you have nothing else to do until the task finishes.";
92
93fn require_task_registry(
98 context: &ToolContext,
99) -> Result<&Arc<dyn SessionTaskRegistry>, ToolExecutionResult> {
100 context.session_task_registry.as_ref().ok_or_else(|| {
101 ToolExecutionResult::tool_error(
102 "Session task tools require session_task_registry context (not available in this environment)",
103 )
104 })
105}
106
107use super::util::require_str_trimmed as require_str;
108
109async fn load_task(
110 context: &ToolContext,
111 task_id: &str,
112) -> Result<SessionTask, ToolExecutionResult> {
113 let registry = require_task_registry(context)?;
114 registry
115 .get(context.session_id, task_id)
116 .await
117 .map_err(ToolExecutionResult::internal_error)?
118 .ok_or_else(|| ToolExecutionResult::tool_error(format!("No task found with id: {task_id}")))
119}
120
121fn compact_task_json(task: &SessionTask) -> Value {
123 json!({
124 "id": task.id,
125 "kind": task.kind,
126 "display_name": task.display_name,
127 "state": task.state,
128 "state_detail": task.state_detail,
129 "progress": task.progress,
130 "summary": task.summary,
131 "created_at": task.created_at.to_rfc3339(),
132 "finished_at": task.finished_at.map(|t| t.to_rfc3339()),
133 })
134}
135
136fn message_json(message: &TaskMessage) -> Value {
137 serde_json::to_value(message).unwrap_or_else(|_| json!({}))
138}
139
140fn full_task_json(task: &SessionTask) -> Value {
141 serde_json::to_value(task).unwrap_or_else(|_| json!({}))
142}
143
144pub struct ListTasksTool;
149
150#[async_trait]
151impl Tool for ListTasksTool {
152 fn name(&self) -> &str {
153 "list_tasks"
154 }
155
156 fn display_name(&self) -> Option<&str> {
157 Some("List Tasks")
158 }
159
160 fn description(&self) -> &str {
161 "List this session's background tasks (subagents, external agents, background tools) with state, progress, and summary."
162 }
163
164 fn parameters_schema(&self) -> Value {
165 json!({
166 "type": "object",
167 "properties": {
168 "state": {
169 "type": "string",
170 "enum": ["queued", "running", "awaiting_input", "succeeded", "failed", "canceled"],
171 "description": "Filter by lifecycle state."
172 },
173 "kind": {
174 "type": "string",
175 "description": "Filter by task kind (e.g. 'subagent', 'external_agent', 'background_tool')."
176 }
177 },
178 "additionalProperties": false
179 })
180 }
181
182 fn hints(&self) -> ToolHints {
183 ToolHints::default()
184 .with_readonly(true)
185 .with_idempotent(true)
186 }
187
188 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
189 ToolExecutionResult::tool_error("list_tasks requires session context.")
190 }
191
192 async fn execute_with_context(
193 &self,
194 arguments: Value,
195 context: &ToolContext,
196 ) -> ToolExecutionResult {
197 list_tasks_impl(arguments, context)
198 .await
199 .unwrap_or_else(|e| e)
200 }
201
202 fn requires_context(&self) -> bool {
203 true
204 }
205}
206
207async fn list_tasks_impl(
208 arguments: Value,
209 context: &ToolContext,
210) -> Result<ToolExecutionResult, ToolExecutionResult> {
211 let registry = require_task_registry(context)?;
212 let state = match arguments.get("state").and_then(Value::as_str) {
213 Some(raw) => match SessionTaskState::parse(raw) {
214 Some(state) => Some(state),
215 None => {
216 return Ok(ToolExecutionResult::tool_error(format!(
217 "Unknown state filter \"{raw}\". Valid states: queued, running, \
218 awaiting_input, succeeded, failed, canceled."
219 )));
220 }
221 },
222 None => None,
223 };
224 let filter = SessionTaskFilter {
225 kind: arguments
226 .get("kind")
227 .and_then(Value::as_str)
228 .map(str::trim)
229 .filter(|s| !s.is_empty())
230 .map(ToString::to_string),
231 state,
232 };
233 let tasks = registry
234 .list(context.session_id, Some(&filter))
235 .await
236 .map_err(ToolExecutionResult::internal_error)?;
237 let entries = tasks.iter().map(compact_task_json).collect::<Vec<_>>();
238 Ok(ToolExecutionResult::success(json!({
239 "tasks": entries,
240 "count": entries.len(),
241 })))
242}
243
244pub struct GetTaskTool;
249
250#[async_trait]
251impl Tool for GetTaskTool {
252 fn name(&self) -> &str {
253 "get_task"
254 }
255
256 fn display_name(&self) -> Option<&str> {
257 Some("Get Task")
258 }
259
260 fn description(&self) -> &str {
261 "Get a task's full snapshot (state, progress, input request, result path, error) plus its recent message thread."
262 }
263
264 fn parameters_schema(&self) -> Value {
265 json!({
266 "type": "object",
267 "properties": {
268 "task_id": {
269 "type": "string",
270 "description": "Task ID (task_*)."
271 }
272 },
273 "required": ["task_id"],
274 "additionalProperties": false
275 })
276 }
277
278 fn hints(&self) -> ToolHints {
279 ToolHints::default()
280 .with_readonly(true)
281 .with_idempotent(true)
282 }
283
284 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
285 ToolExecutionResult::tool_error("get_task requires session context.")
286 }
287
288 async fn execute_with_context(
289 &self,
290 arguments: Value,
291 context: &ToolContext,
292 ) -> ToolExecutionResult {
293 get_task_impl(arguments, context)
294 .await
295 .unwrap_or_else(|e| e)
296 }
297
298 fn requires_context(&self) -> bool {
299 true
300 }
301}
302
303async fn get_task_impl(
304 arguments: Value,
305 context: &ToolContext,
306) -> Result<ToolExecutionResult, ToolExecutionResult> {
307 let task_id = require_str(&arguments, "task_id")?;
308 let task = load_task(context, task_id).await?;
309 let registry = require_task_registry(context)?;
310 let messages = registry
311 .list_messages(
312 context.session_id,
313 task_id,
314 Some(GET_TASK_MESSAGE_LIMIT),
315 None,
316 )
317 .await
318 .unwrap_or_default();
319 Ok(ToolExecutionResult::success(json!({
320 "task": full_task_json(&task),
321 "messages": messages.iter().map(message_json).collect::<Vec<_>>(),
322 })))
323}
324
325pub struct MessageTaskTool;
330
331#[async_trait]
332impl Tool for MessageTaskTool {
333 fn name(&self) -> &str {
334 "message_task"
335 }
336
337 fn display_name(&self) -> Option<&str> {
338 Some("Message Task")
339 }
340
341 fn description(&self) -> &str {
342 "Send an inbound message to a task. To answer a pending input request, set in_reply_to to the input request id."
343 }
344
345 fn parameters_schema(&self) -> Value {
346 json!({
347 "type": "object",
348 "properties": {
349 "task_id": {
350 "type": "string",
351 "description": "Task ID (task_*)."
352 },
353 "message": {
354 "type": "string",
355 "description": "Message to deliver to the task."
356 },
357 "in_reply_to": {
358 "type": "string",
359 "description": "ID of the pending input request this message answers."
360 }
361 },
362 "required": ["task_id", "message"],
363 "additionalProperties": false
364 })
365 }
366
367 fn hints(&self) -> ToolHints {
368 ToolHints::default().with_long_running(true)
369 }
370
371 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
372 ToolExecutionResult::tool_error("message_task requires session context.")
373 }
374
375 async fn execute_with_context(
376 &self,
377 arguments: Value,
378 context: &ToolContext,
379 ) -> ToolExecutionResult {
380 message_task_impl(arguments, context)
381 .await
382 .unwrap_or_else(|e| e)
383 }
384
385 fn requires_context(&self) -> bool {
386 true
387 }
388}
389
390async fn message_task_impl(
391 arguments: Value,
392 context: &ToolContext,
393) -> Result<ToolExecutionResult, ToolExecutionResult> {
394 let task_id = require_str(&arguments, "task_id")?.to_string();
395 let message = require_str(&arguments, "message")?.to_string();
396 let in_reply_to = arguments
397 .get("in_reply_to")
398 .and_then(Value::as_str)
399 .map(str::trim)
400 .filter(|s| !s.is_empty())
401 .map(ToString::to_string);
402
403 let task = load_task(context, &task_id).await?;
404 let registry = require_task_registry(context)?;
405
406 let mut new_message = NewTaskMessage::inbound_text(message);
407 new_message.in_reply_to = in_reply_to;
408 let recorded = registry
409 .record_message(context.session_id, &task_id, new_message)
410 .await
411 .map_err(ToolExecutionResult::internal_error)?;
412
413 let delivery = match find_task_executor(&task.kind) {
416 Some(executor) => {
417 let current = registry
420 .get(context.session_id, &task_id)
421 .await
422 .ok()
423 .flatten()
424 .unwrap_or(task);
425 match executor.deliver(¤t, &recorded, context).await {
426 Ok(()) => "delivered".to_string(),
427 Err(e) => format!("failed: {e}"),
428 }
429 }
430 None => format!(
431 "failed: no executor registered for task kind '{}'",
432 task.kind
433 ),
434 };
435
436 Ok(ToolExecutionResult::success(json!({
437 "task_id": task_id,
438 "message_id": recorded.id,
439 "recorded": true,
440 "delivery": delivery,
441 })))
442}
443
444pub struct CancelTaskTool;
449
450#[async_trait]
451impl Tool for CancelTaskTool {
452 fn name(&self) -> &str {
453 "cancel_task"
454 }
455
456 fn display_name(&self) -> Option<&str> {
457 Some("Cancel Task")
458 }
459
460 fn description(&self) -> &str {
461 "Request cooperative cancellation of a task. The task winds down and may still end succeeded or failed. For a detached `session` task this also cancels the peer session (not just the tracking chip)."
462 }
463
464 fn parameters_schema(&self) -> Value {
465 json!({
466 "type": "object",
467 "properties": {
468 "task_id": {
469 "type": "string",
470 "description": "Task ID (task_*)."
471 }
472 },
473 "required": ["task_id"],
474 "additionalProperties": false
475 })
476 }
477
478 fn hints(&self) -> ToolHints {
479 ToolHints::default().with_idempotent(true)
480 }
481
482 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
483 ToolExecutionResult::tool_error("cancel_task requires session context.")
484 }
485
486 async fn execute_with_context(
487 &self,
488 arguments: Value,
489 context: &ToolContext,
490 ) -> ToolExecutionResult {
491 cancel_task_impl(arguments, context)
492 .await
493 .unwrap_or_else(|e| e)
494 }
495
496 fn requires_context(&self) -> bool {
497 true
498 }
499}
500
501async fn cancel_task_impl(
502 arguments: Value,
503 context: &ToolContext,
504) -> Result<ToolExecutionResult, ToolExecutionResult> {
505 let task_id = require_str(&arguments, "task_id")?.to_string();
506 let registry = require_task_registry(context)?;
507 let task = match registry.request_cancel(context.session_id, &task_id).await {
508 Ok(Some(task)) => task,
509 Ok(None) => {
510 return Ok(ToolExecutionResult::tool_error(format!(
511 "No task found with id: {task_id}"
512 )));
513 }
514 Err(e) => return Err(ToolExecutionResult::internal_error(e)),
515 };
516
517 let executor_result = if task.state.is_terminal() {
519 "task already terminal".to_string()
520 } else {
521 match find_task_executor(&task.kind) {
522 Some(executor) => match executor.cancel(&task, context).await {
523 Ok(()) => "cancellation requested".to_string(),
524 Err(e) => format!("failed: {e}"),
525 },
526 None => format!(
527 "no executor registered for task kind '{}'; cancel intent recorded",
528 task.kind
529 ),
530 }
531 };
532
533 Ok(ToolExecutionResult::success(json!({
534 "task_id": task_id,
535 "state": task.state,
536 "cancel_requested": true,
537 "executor": executor_result,
538 })))
539}
540
541pub struct WaitTaskTool;
546
547#[async_trait]
548impl Tool for WaitTaskTool {
549 fn name(&self) -> &str {
550 "wait_task"
551 }
552
553 fn display_name(&self) -> Option<&str> {
554 Some("Wait Task")
555 }
556
557 fn description(&self) -> &str {
558 "Wait until a task reaches a terminal state or asks for input. Returns the latest task snapshot."
559 }
560
561 fn parameters_schema(&self) -> Value {
562 json!({
563 "type": "object",
564 "properties": {
565 "task_id": {
566 "type": "string",
567 "description": "Task ID (task_*)."
568 },
569 "timeout_seconds": {
570 "type": "integer",
571 "minimum": 1,
572 "maximum": 86400,
573 "default": 300,
574 "description": "Maximum seconds to wait before returning the current snapshot."
575 }
576 },
577 "required": ["task_id"],
578 "additionalProperties": false
579 })
580 }
581
582 fn hints(&self) -> ToolHints {
583 ToolHints::default().with_long_running(true)
584 }
585
586 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
587 ToolExecutionResult::tool_error("wait_task requires session context.")
588 }
589
590 async fn execute_with_context(
591 &self,
592 arguments: Value,
593 context: &ToolContext,
594 ) -> ToolExecutionResult {
595 wait_task_impl(arguments, context)
596 .await
597 .unwrap_or_else(|e| e)
598 }
599
600 fn requires_context(&self) -> bool {
601 true
602 }
603}
604
605async fn wait_task_impl(
606 arguments: Value,
607 context: &ToolContext,
608) -> Result<ToolExecutionResult, ToolExecutionResult> {
609 let task_id = require_str(&arguments, "task_id")?.to_string();
610 let timeout_secs = arguments
611 .get("timeout_seconds")
612 .and_then(Value::as_u64)
613 .unwrap_or(DEFAULT_WAIT_TIMEOUT_SECS);
614 let deadline = Instant::now() + Duration::from_secs(timeout_secs);
615 let mut polls: u64 = 0;
616
617 loop {
618 let task = load_task(context, &task_id).await?;
619 if task.state.is_terminal() || task.state == SessionTaskState::AwaitingInput {
620 return Ok(ToolExecutionResult::success(json!({
621 "task": full_task_json(&task),
622 "timed_out": false,
623 })));
624 }
625 if Instant::now() >= deadline {
626 return Ok(ToolExecutionResult::success(json!({
627 "task": full_task_json(&task),
628 "timed_out": true,
629 "message": format!("Task {task_id} still {} after {timeout_secs}s", task.state),
630 })));
631 }
632 polls += 1;
633 if polls.is_multiple_of(WAIT_RECONCILE_EVERY)
636 && let Some(executor) = find_task_executor(&task.kind)
637 {
638 let _ = executor.reconcile(&task, context).await;
639 }
640 sleep(WAIT_POLL_INTERVAL).await;
641 }
642}
643
644#[cfg(test)]
649pub(crate) mod tests {
650 use super::*;
651 use crate::session_task::{
652 CreateSessionTask, SessionTaskUpdate, TaskError, TaskExecutor, TaskExecutorPlugin,
653 TaskInputRequest, TaskLinks, TaskMessageDirection, TaskMessagePart, TaskWakePolicy,
654 apply_task_update, generate_task_message_id, new_session_task,
655 };
656 use crate::typed_id::SessionId;
657 use chrono::Utc;
658 use std::collections::HashMap;
659 use std::sync::Mutex;
660
661 #[derive(Default)]
664 pub(crate) struct InMemorySessionTaskRegistry {
665 tasks: Mutex<HashMap<String, SessionTask>>,
666 messages: Mutex<HashMap<String, Vec<TaskMessage>>>,
667 }
668
669 #[async_trait]
670 impl SessionTaskRegistry for InMemorySessionTaskRegistry {
671 async fn create(&self, input: CreateSessionTask) -> crate::error::Result<SessionTask> {
672 let mut tasks = self.tasks.lock().unwrap();
673 if let Some(id) = &input.id
674 && let Some(existing) = tasks.get(id)
675 {
676 return Ok(existing.clone());
677 }
678 let task = new_session_task(input, Utc::now());
679 tasks.insert(task.id.clone(), task.clone());
680 Ok(task)
681 }
682
683 async fn update(
684 &self,
685 _session_id: SessionId,
686 task_id: &str,
687 update: SessionTaskUpdate,
688 ) -> crate::error::Result<Option<SessionTask>> {
689 let mut tasks = self.tasks.lock().unwrap();
690 let Some(task) = tasks.get_mut(task_id) else {
691 return Ok(None);
692 };
693 apply_task_update(task, update, Utc::now());
694 Ok(Some(task.clone()))
695 }
696
697 async fn get(
698 &self,
699 _session_id: SessionId,
700 task_id: &str,
701 ) -> crate::error::Result<Option<SessionTask>> {
702 Ok(self.tasks.lock().unwrap().get(task_id).cloned())
703 }
704
705 async fn list(
706 &self,
707 session_id: SessionId,
708 filter: Option<&SessionTaskFilter>,
709 ) -> crate::error::Result<Vec<SessionTask>> {
710 let tasks = self.tasks.lock().unwrap();
711 Ok(tasks
712 .values()
713 .filter(|task| {
714 task.session_id == session_id
715 && filter.is_none_or(|f| {
716 f.kind.as_deref().is_none_or(|kind| task.kind == kind)
717 && f.state.is_none_or(|state| task.state == state)
718 })
719 })
720 .cloned()
721 .collect())
722 }
723
724 async fn request_cancel(
725 &self,
726 _session_id: SessionId,
727 task_id: &str,
728 ) -> crate::error::Result<Option<SessionTask>> {
729 let mut tasks = self.tasks.lock().unwrap();
730 let Some(task) = tasks.get_mut(task_id) else {
731 return Ok(None);
732 };
733 task.cancel_requested_at.get_or_insert_with(Utc::now);
734 task.updated_at = Utc::now();
735 Ok(Some(task.clone()))
736 }
737
738 async fn record_message(
739 &self,
740 session_id: SessionId,
741 task_id: &str,
742 message: NewTaskMessage,
743 ) -> crate::error::Result<TaskMessage> {
744 let stored = {
745 let tasks = self.tasks.lock().unwrap();
746 let Some(task) = tasks.get(task_id) else {
747 return Err(crate::error::AgentLoopError::tool(format!(
748 "no task {task_id}"
749 )));
750 };
751 task.clone()
752 };
753 if let Some(expected) = message.expected_attempt
755 && expected != stored.attempt
756 {
757 return Err(crate::error::AgentLoopError::store(format!(
758 "Stale attempt {expected} for task {task_id} (current attempt {})",
759 stored.attempt
760 )));
761 }
762 let recorded = TaskMessage {
763 id: generate_task_message_id(),
764 task_id: task_id.to_string(),
765 direction: message.direction,
766 content: message.content,
767 in_reply_to: message.in_reply_to,
768 created_at: Utc::now(),
769 };
770 if let Some(in_reply_to) = &recorded.in_reply_to
773 && stored
774 .input_request
775 .as_ref()
776 .is_some_and(|req| &req.id == in_reply_to)
777 {
778 self.update(
779 session_id,
780 task_id,
781 SessionTaskUpdate {
782 state: Some(SessionTaskState::Running),
783 ..Default::default()
784 },
785 )
786 .await?;
787 }
788 self.messages
789 .lock()
790 .unwrap()
791 .entry(task_id.to_string())
792 .or_default()
793 .push(recorded.clone());
794 Ok(recorded)
795 }
796
797 async fn list_messages(
798 &self,
799 _session_id: SessionId,
800 task_id: &str,
801 limit: Option<u32>,
802 after_id: Option<&str>,
803 ) -> crate::error::Result<Vec<TaskMessage>> {
804 let messages = self.messages.lock().unwrap();
805 let all = messages.get(task_id).cloned().unwrap_or_default();
806 let mut iter: Box<dyn Iterator<Item = TaskMessage>> = if let Some(cursor) = after_id {
807 Box::new(all.into_iter().skip_while(move |m| m.id != cursor).skip(1))
808 } else {
809 Box::new(all.into_iter())
810 };
811 let collected: Vec<_> = iter.by_ref().collect();
812 if let Some(limit) = limit {
813 if after_id.is_some() {
814 return Ok(collected.into_iter().take(limit as usize).collect());
815 }
816 let skip = collected.len().saturating_sub(limit as usize);
817 return Ok(collected.into_iter().skip(skip).collect());
818 }
819 Ok(collected)
820 }
821 }
822
823 const TEST_EXECUTOR_KIND: &str = "session_tasks_test";
828 static TEST_DELIVERED: Mutex<Vec<String>> = Mutex::new(Vec::new());
829 static TEST_CANCELED: Mutex<Vec<String>> = Mutex::new(Vec::new());
830
831 fn executor_invocations(log: &Mutex<Vec<String>>, task_id: &str) -> usize {
832 log.lock()
833 .unwrap()
834 .iter()
835 .filter(|id| *id == task_id)
836 .count()
837 }
838
839 struct TestTaskExecutor;
840
841 #[async_trait]
842 impl TaskExecutor for TestTaskExecutor {
843 fn kind(&self) -> &str {
844 TEST_EXECUTOR_KIND
845 }
846
847 async fn deliver(
848 &self,
849 task: &SessionTask,
850 _message: &TaskMessage,
851 _context: &ToolContext,
852 ) -> crate::error::Result<()> {
853 TEST_DELIVERED.lock().unwrap().push(task.id.clone());
854 Ok(())
855 }
856
857 async fn cancel(
858 &self,
859 task: &SessionTask,
860 _context: &ToolContext,
861 ) -> crate::error::Result<()> {
862 TEST_CANCELED.lock().unwrap().push(task.id.clone());
863 Ok(())
864 }
865 }
866
867 inventory::submit! {
868 TaskExecutorPlugin {
869 executor: || Arc::new(TestTaskExecutor),
870 }
871 }
872
873 fn test_context(registry: Arc<InMemorySessionTaskRegistry>) -> ToolContext {
874 ToolContext::new(SessionId::new()).with_session_task_registry(registry)
875 }
876
877 async fn create_task(
878 registry: &InMemorySessionTaskRegistry,
879 context: &ToolContext,
880 kind: &str,
881 state: SessionTaskState,
882 ) -> SessionTask {
883 registry
884 .create(CreateSessionTask {
885 session_id: context.session_id,
886 id: None,
887 kind: kind.to_string(),
888 display_name: "Test Task".to_string(),
889 spec: json!({}),
890 state,
891 links: TaskLinks::default(),
892 wake_policy: TaskWakePolicy::Silent,
893 })
894 .await
895 .unwrap()
896 }
897
898 #[tokio::test]
901 async fn tools_error_without_registry() {
902 let context = ToolContext::new(SessionId::new());
903 let result = ListTasksTool
904 .execute_with_context(json!({}), &context)
905 .await;
906 assert!(matches!(result, ToolExecutionResult::ToolError(_)));
907 let result = WaitTaskTool
908 .execute_with_context(json!({"task_id": "task_x"}), &context)
909 .await;
910 assert!(matches!(result, ToolExecutionResult::ToolError(_)));
911 }
912
913 #[tokio::test]
914 async fn list_tasks_returns_compact_entries_with_filters() {
915 let registry = Arc::new(InMemorySessionTaskRegistry::default());
916 let context = test_context(registry.clone());
917 let running = create_task(
918 ®istry,
919 &context,
920 TEST_EXECUTOR_KIND,
921 SessionTaskState::Running,
922 )
923 .await;
924 create_task(®istry, &context, "other_kind", SessionTaskState::Queued).await;
925
926 let result = ListTasksTool
927 .execute_with_context(json!({}), &context)
928 .await;
929 let ToolExecutionResult::Success(value) = result else {
930 panic!("expected success: {result:?}");
931 };
932 assert_eq!(value["count"], 2);
933
934 let result = ListTasksTool
935 .execute_with_context(
936 json!({"kind": TEST_EXECUTOR_KIND, "state": "running"}),
937 &context,
938 )
939 .await;
940 let ToolExecutionResult::Success(value) = result else {
941 panic!("expected success: {result:?}");
942 };
943 assert_eq!(value["count"], 1);
944 let entry = &value["tasks"][0];
945 assert_eq!(entry["id"], running.id);
946 assert_eq!(entry["state"], "running");
947 assert!(entry.get("display_name").is_some());
948 assert!(entry.get("created_at").is_some());
949 assert!(entry.get("spec").is_none());
951 }
952
953 #[tokio::test]
954 async fn get_task_returns_snapshot_and_recent_messages() {
955 let registry = Arc::new(InMemorySessionTaskRegistry::default());
956 let context = test_context(registry.clone());
957 let task = create_task(
958 ®istry,
959 &context,
960 TEST_EXECUTOR_KIND,
961 SessionTaskState::Running,
962 )
963 .await;
964 for i in 0..25 {
965 registry
966 .record_message(
967 context.session_id,
968 &task.id,
969 NewTaskMessage::outbound_text(format!("update {i}")),
970 )
971 .await
972 .unwrap();
973 }
974
975 let result = GetTaskTool
976 .execute_with_context(json!({"task_id": task.id}), &context)
977 .await;
978 let ToolExecutionResult::Success(value) = result else {
979 panic!("expected success: {result:?}");
980 };
981 assert_eq!(value["task"]["id"], task.id);
982 let messages = value["messages"].as_array().unwrap();
983 assert_eq!(messages.len(), GET_TASK_MESSAGE_LIMIT as usize);
984 assert_eq!(messages[0]["content"][0]["text"], "update 5");
986 assert_eq!(messages[19]["content"][0]["text"], "update 24");
987 }
988
989 #[tokio::test]
990 async fn get_task_unknown_id_errors() {
991 let registry = Arc::new(InMemorySessionTaskRegistry::default());
992 let context = test_context(registry);
993 let result = GetTaskTool
994 .execute_with_context(json!({"task_id": "task_missing"}), &context)
995 .await;
996 assert!(matches!(result, ToolExecutionResult::ToolError(_)));
997 }
998
999 #[tokio::test]
1000 async fn message_task_records_and_delivers() {
1001 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1002 let context = test_context(registry.clone());
1003 let task = create_task(
1004 ®istry,
1005 &context,
1006 TEST_EXECUTOR_KIND,
1007 SessionTaskState::Running,
1008 )
1009 .await;
1010
1011 let result = MessageTaskTool
1012 .execute_with_context(
1013 json!({"task_id": task.id, "message": "keep going"}),
1014 &context,
1015 )
1016 .await;
1017 let ToolExecutionResult::Success(value) = result else {
1018 panic!("expected success: {result:?}");
1019 };
1020 assert_eq!(value["delivery"], "delivered");
1021 assert_eq!(executor_invocations(&TEST_DELIVERED, &task.id), 1);
1022
1023 let messages = registry
1024 .list_messages(context.session_id, &task.id, None, None)
1025 .await
1026 .unwrap();
1027 assert_eq!(messages.len(), 1);
1028 assert_eq!(messages[0].direction, TaskMessageDirection::Inbound);
1029 assert_eq!(
1030 messages[0].content,
1031 vec![TaskMessagePart::text("keep going")]
1032 );
1033 }
1034
1035 #[tokio::test]
1036 async fn message_task_without_executor_still_records() {
1037 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1038 let context = test_context(registry.clone());
1039 let task = create_task(
1040 ®istry,
1041 &context,
1042 "kind_without_executor",
1043 SessionTaskState::Running,
1044 )
1045 .await;
1046
1047 let result = MessageTaskTool
1048 .execute_with_context(json!({"task_id": task.id, "message": "hello"}), &context)
1049 .await;
1050 let ToolExecutionResult::Success(value) = result else {
1051 panic!("expected success: {result:?}");
1052 };
1053 assert_eq!(value["recorded"], true);
1054 let delivery = value["delivery"].as_str().unwrap();
1055 assert!(delivery.starts_with("failed:"), "delivery: {delivery}");
1056 let messages = registry
1057 .list_messages(context.session_id, &task.id, None, None)
1058 .await
1059 .unwrap();
1060 assert_eq!(messages.len(), 1);
1061 }
1062
1063 #[tokio::test]
1064 async fn message_task_in_reply_to_resumes_awaiting_input() {
1065 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1066 let context = test_context(registry.clone());
1067 let task = create_task(
1068 ®istry,
1069 &context,
1070 TEST_EXECUTOR_KIND,
1071 SessionTaskState::Running,
1072 )
1073 .await;
1074 registry
1075 .update(
1076 context.session_id,
1077 &task.id,
1078 SessionTaskUpdate {
1079 input_request: Some(TaskInputRequest {
1080 id: "req_1".to_string(),
1081 prompt: "Approve?".to_string(),
1082 expected: None,
1083 }),
1084 ..Default::default()
1085 },
1086 )
1087 .await
1088 .unwrap();
1089
1090 let result = MessageTaskTool
1091 .execute_with_context(
1092 json!({"task_id": task.id, "message": "yes", "in_reply_to": "req_1"}),
1093 &context,
1094 )
1095 .await;
1096 assert!(matches!(result, ToolExecutionResult::Success(_)));
1097
1098 let current = registry
1099 .get(context.session_id, &task.id)
1100 .await
1101 .unwrap()
1102 .unwrap();
1103 assert_eq!(current.state, SessionTaskState::Running);
1104 assert!(current.input_request.is_none());
1105 }
1106
1107 #[tokio::test]
1108 async fn cancel_task_records_intent_and_calls_executor() {
1109 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1110 let context = test_context(registry.clone());
1111 let task = create_task(
1112 ®istry,
1113 &context,
1114 TEST_EXECUTOR_KIND,
1115 SessionTaskState::Running,
1116 )
1117 .await;
1118
1119 let result = CancelTaskTool
1120 .execute_with_context(json!({"task_id": task.id}), &context)
1121 .await;
1122 let ToolExecutionResult::Success(value) = result else {
1123 panic!("expected success: {result:?}");
1124 };
1125 assert_eq!(value["cancel_requested"], true);
1126 assert_eq!(value["executor"], "cancellation requested");
1127 assert_eq!(executor_invocations(&TEST_CANCELED, &task.id), 1);
1128
1129 let current = registry
1130 .get(context.session_id, &task.id)
1131 .await
1132 .unwrap()
1133 .unwrap();
1134 assert!(current.cancel_requested_at.is_some());
1135 }
1136
1137 #[tokio::test]
1138 async fn cancel_task_on_terminal_task_skips_executor() {
1139 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1140 let context = test_context(registry.clone());
1141 let task = create_task(
1142 ®istry,
1143 &context,
1144 TEST_EXECUTOR_KIND,
1145 SessionTaskState::Succeeded,
1146 )
1147 .await;
1148
1149 let result = CancelTaskTool
1150 .execute_with_context(json!({"task_id": task.id}), &context)
1151 .await;
1152 let ToolExecutionResult::Success(value) = result else {
1153 panic!("expected success: {result:?}");
1154 };
1155 assert_eq!(value["executor"], "task already terminal");
1156 assert_eq!(executor_invocations(&TEST_CANCELED, &task.id), 0);
1157 }
1158
1159 #[tokio::test]
1160 async fn wait_task_returns_immediately_when_terminal() {
1161 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1162 let context = test_context(registry.clone());
1163 let task = create_task(
1164 ®istry,
1165 &context,
1166 TEST_EXECUTOR_KIND,
1167 SessionTaskState::Running,
1168 )
1169 .await;
1170 registry
1171 .update(
1172 context.session_id,
1173 &task.id,
1174 SessionTaskUpdate {
1175 state: Some(SessionTaskState::Failed),
1176 error: Some(TaskError {
1177 kind: "error".to_string(),
1178 message: "boom".to_string(),
1179 }),
1180 ..Default::default()
1181 },
1182 )
1183 .await
1184 .unwrap();
1185
1186 let result = WaitTaskTool
1187 .execute_with_context(json!({"task_id": task.id}), &context)
1188 .await;
1189 let ToolExecutionResult::Success(value) = result else {
1190 panic!("expected success: {result:?}");
1191 };
1192 assert_eq!(value["timed_out"], false);
1193 assert_eq!(value["task"]["state"], "failed");
1194 assert_eq!(value["task"]["error"]["message"], "boom");
1195 }
1196
1197 #[tokio::test]
1198 async fn wait_task_returns_when_awaiting_input() {
1199 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1200 let context = test_context(registry.clone());
1201 let task = create_task(
1202 ®istry,
1203 &context,
1204 TEST_EXECUTOR_KIND,
1205 SessionTaskState::AwaitingInput,
1206 )
1207 .await;
1208
1209 let result = WaitTaskTool
1210 .execute_with_context(json!({"task_id": task.id}), &context)
1211 .await;
1212 let ToolExecutionResult::Success(value) = result else {
1213 panic!("expected success: {result:?}");
1214 };
1215 assert_eq!(value["timed_out"], false);
1216 assert_eq!(value["task"]["state"], "awaiting_input");
1217 }
1218
1219 #[tokio::test(start_paused = true)]
1220 async fn wait_task_times_out_with_snapshot() {
1221 let registry = Arc::new(InMemorySessionTaskRegistry::default());
1222 let context = test_context(registry.clone());
1223 let task = create_task(
1224 ®istry,
1225 &context,
1226 TEST_EXECUTOR_KIND,
1227 SessionTaskState::Running,
1228 )
1229 .await;
1230
1231 let result = WaitTaskTool
1232 .execute_with_context(json!({"task_id": task.id, "timeout_seconds": 3}), &context)
1233 .await;
1234 let ToolExecutionResult::Success(value) = result else {
1235 panic!("expected success: {result:?}");
1236 };
1237 assert_eq!(value["timed_out"], true);
1238 assert_eq!(value["task"]["state"], "running");
1239 }
1240}