Skip to main content

everruns_core/capabilities/
session_tasks.rs

1// Session Tasks Capability
2//
3// Generic agent-facing tools over the session task registry
4// (specs/session-tasks.md): list_tasks / get_task / message_task /
5// cancel_task / wait_task. Spawning stays with each creation surface
6// (spawn_agent, spawn_background) — every spawn creates a task and returns its
7// task_id; these tools provide the uniform query/messaging/cancel/wait plane.
8//
9// Decision: tools degrade gracefully when `context.session_task_registry` is
10// absent (e.g. embedders without a registry) — they return a tool error
11// instead of panicking, matching other registry-backed capabilities.
12
13use 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);
31/// Reconcile polled task kinds (e.g. remote A2A tasks) every Nth wait poll.
32const WAIT_RECONCILE_EVERY: u64 = 5;
33/// Recent-thread size returned by get_task.
34const GET_TASK_MESSAGE_LIMIT: u32 = 20;
35
36/// Session tasks capability — uniform tracking of background work.
37pub 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
93// =============================================================================
94// Helpers
95// =============================================================================
96
97fn 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
121/// Compact list entry: enough to decide whether to drill in with get_task.
122fn 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
144// =============================================================================
145// Tool: list_tasks
146// =============================================================================
147
148pub 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
244// =============================================================================
245// Tool: get_task
246// =============================================================================
247
248pub 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
325// =============================================================================
326// Tool: message_task
327// =============================================================================
328
329pub 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    // Best-effort delivery: the message is durably recorded either way;
414    // report delivery outcome instead of failing the whole call.
415    let delivery = match find_task_executor(&task.kind) {
416        Some(executor) => {
417            // Re-read: recording an input-request answer returns the task
418            // to running before the executor sees it.
419            let current = registry
420                .get(context.session_id, &task_id)
421                .await
422                .ok()
423                .flatten()
424                .unwrap_or(task);
425            match executor.deliver(&current, &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
444// =============================================================================
445// Tool: cancel_task
446// =============================================================================
447
448pub 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    // Best-effort executor wind-down; the intent is recorded regardless.
518    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
541// =============================================================================
542// Tool: wait_task
543// =============================================================================
544
545pub 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        // Refresh polled kinds (e.g. remote A2A tasks) periodically so the
634        // registry snapshot converges even when nothing pushes updates.
635        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// =============================================================================
645// Tests
646// =============================================================================
647
648#[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    /// In-memory `SessionTaskRegistry` test double. Updates route through
662    /// `apply_task_update` so lifecycle invariants match real backends.
663    #[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            // Stale-attempt fence (mirrors DbSessionTaskRegistry).
754            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            // Answering messages clear a matching pending input request and
771            // return the task to running.
772            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    /// Test executor kind. `deliver`/`cancel` succeed and log invocations.
824    /// The executor is process-global (inventory), so invocations are logged
825    /// per task id; task ids are unique per test, which keeps assertions
826    /// race-free under parallel test execution.
827    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    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
899
900    #[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            &registry,
919            &context,
920            TEST_EXECUTOR_KIND,
921            SessionTaskState::Running,
922        )
923        .await;
924        create_task(&registry, &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        // Compact entries omit the full spec.
950        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            &registry,
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        // Most recent messages, oldest first.
985        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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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            &registry,
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}