1use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::sync::Arc;
19
20use crate::error::Result;
21use crate::typed_id::SessionId;
22
23#[cfg(feature = "openapi")]
24use utoipa::ToSchema;
25
26pub type TaskProgress = crate::background::BackgroundProgress;
28
29pub const TASK_KIND_SUBAGENT: &str = "subagent";
32pub const TASK_KIND_EXTERNAL_AGENT: &str = "external_agent";
33pub const TASK_KIND_BACKGROUND_TOOL: &str = "background_tool";
34pub const TASK_KIND_MONITOR: &str = "monitor";
37
38pub fn generate_task_id() -> String {
40 format!("task_{}", uuid::Uuid::now_v7().simple())
41}
42
43pub fn generate_task_message_id() -> String {
45 format!("tmsg_{}", uuid::Uuid::now_v7().simple())
46}
47
48#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
54#[cfg_attr(feature = "openapi", derive(ToSchema))]
55#[serde(rename_all = "snake_case")]
56pub enum SessionTaskState {
57 Queued,
58 Running,
59 AwaitingInput,
60 Succeeded,
61 Failed,
62 Canceled,
63}
64
65impl SessionTaskState {
66 pub fn is_terminal(&self) -> bool {
67 matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
68 }
69
70 pub fn parse(s: &str) -> Option<Self> {
75 match s {
76 "queued" => Some(Self::Queued),
77 "running" => Some(Self::Running),
78 "awaiting_input" => Some(Self::AwaitingInput),
79 "succeeded" => Some(Self::Succeeded),
80 "failed" => Some(Self::Failed),
81 "canceled" => Some(Self::Canceled),
82 _ => None,
83 }
84 }
85}
86
87impl std::fmt::Display for SessionTaskState {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 let s = match self {
90 Self::Queued => "queued",
91 Self::Running => "running",
92 Self::AwaitingInput => "awaiting_input",
93 Self::Succeeded => "succeeded",
94 Self::Failed => "failed",
95 Self::Canceled => "canceled",
96 };
97 write!(f, "{s}")
98 }
99}
100
101impl From<&str> for SessionTaskState {
102 fn from(s: &str) -> Self {
103 match s {
104 "running" => Self::Running,
105 "awaiting_input" => Self::AwaitingInput,
106 "succeeded" => Self::Succeeded,
107 "failed" => Self::Failed,
108 "canceled" => Self::Canceled,
109 _ => Self::Queued,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
116#[cfg_attr(feature = "openapi", derive(ToSchema))]
117#[serde(rename_all = "snake_case")]
118pub enum TaskWakePolicy {
119 #[default]
121 Silent,
122 OnTerminal,
124 OnActivity,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130#[cfg_attr(feature = "openapi", derive(ToSchema))]
131pub struct TaskInputRequest {
132 pub id: String,
134 pub prompt: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
139 pub expected: Option<Value>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
144#[cfg_attr(feature = "openapi", derive(ToSchema))]
145pub struct TaskError {
146 pub kind: String,
147 pub message: String,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[cfg_attr(feature = "openapi", derive(ToSchema))]
153pub struct TaskArtifact {
154 pub name: String,
155 #[serde(rename = "type")]
157 pub artifact_type: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub path: Option<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub url: Option<String>,
164}
165
166#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
168#[cfg_attr(feature = "openapi", derive(ToSchema))]
169pub struct TaskLinks {
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
173 pub child_session_id: Option<SessionId>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub remote_task_id: Option<String>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 pub resource_ids: Vec<String>,
180}
181
182impl TaskLinks {
183 pub fn is_empty(&self) -> bool {
184 self.child_session_id.is_none()
185 && self.remote_task_id.is_none()
186 && self.resource_ids.is_empty()
187 }
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192#[cfg_attr(feature = "openapi", derive(ToSchema))]
193pub struct SessionTask {
194 pub id: String,
196 #[cfg_attr(feature = "openapi", schema(value_type = String))]
198 pub session_id: SessionId,
199 pub kind: String,
201 pub display_name: String,
203 #[serde(default)]
205 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
206 pub spec: Value,
207 pub state: SessionTaskState,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub state_detail: Option<String>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub progress: Option<TaskProgress>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub input_request: Option<TaskInputRequest>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub cancel_requested_at: Option<DateTime<Utc>>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub summary: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub result_path: Option<String>,
225 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub artifacts: Vec<TaskArtifact>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub error: Option<TaskError>,
229 #[serde(default = "default_attempt")]
231 pub attempt: i32,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub worker_id: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub heartbeat_at: Option<DateTime<Utc>>,
236 #[serde(default, skip_serializing_if = "TaskLinks::is_empty")]
237 pub links: TaskLinks,
238 #[serde(default)]
239 pub wake_policy: TaskWakePolicy,
240 pub created_at: DateTime<Utc>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub started_at: Option<DateTime<Utc>>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub finished_at: Option<DateTime<Utc>>,
245 pub updated_at: DateTime<Utc>,
246}
247
248fn default_attempt() -> i32 {
249 1
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct CreateSessionTask {
255 pub session_id: SessionId,
256 #[serde(default)]
258 pub id: Option<String>,
259 pub kind: String,
260 pub display_name: String,
261 #[serde(default)]
262 pub spec: Value,
263 #[serde(default = "default_queued")]
265 pub state: SessionTaskState,
266 #[serde(default)]
267 pub links: TaskLinks,
268 #[serde(default)]
269 pub wake_policy: TaskWakePolicy,
270}
271
272fn default_queued() -> SessionTaskState {
273 SessionTaskState::Queued
274}
275
276#[derive(Debug, Clone, Default, Serialize, Deserialize)]
278pub struct SessionTaskUpdate {
279 pub state: Option<SessionTaskState>,
280 pub state_detail: Option<String>,
281 pub progress: Option<TaskProgress>,
282 pub input_request: Option<TaskInputRequest>,
284 pub summary: Option<String>,
285 pub result_path: Option<String>,
286 pub artifacts: Option<Vec<TaskArtifact>>,
288 pub error: Option<TaskError>,
289 pub links: Option<TaskLinks>,
291 pub worker_id: Option<String>,
292 pub heartbeat_at: Option<DateTime<Utc>>,
294}
295
296#[derive(Debug, Clone, Default)]
298pub struct SessionTaskFilter {
299 pub kind: Option<String>,
300 pub state: Option<SessionTaskState>,
301}
302
303pub fn apply_task_update(task: &mut SessionTask, update: SessionTaskUpdate, now: DateTime<Utc>) {
314 let was_terminal = task.state.is_terminal();
315
316 if was_terminal
323 && let Some(state) = update.state
324 && state != task.state
325 {
326 return;
327 }
328
329 let mut next_state = update.state;
330 if update.input_request.is_some() && !was_terminal {
331 next_state = Some(SessionTaskState::AwaitingInput);
332 }
333
334 if let Some(input_request) = update.input_request
335 && !was_terminal
336 {
337 task.input_request = Some(input_request);
338 }
339
340 if let Some(state) = next_state
341 && !was_terminal
342 && task.state != state
343 {
344 if task.state == SessionTaskState::Queued && state != SessionTaskState::Queued {
345 task.started_at.get_or_insert(now);
346 }
347 if state.is_terminal() {
348 task.finished_at.get_or_insert(now);
349 }
350 if state != SessionTaskState::AwaitingInput {
351 task.input_request = None;
352 }
353 task.state = state;
354 }
355
356 if let Some(detail) = update.state_detail {
357 task.state_detail = Some(detail);
358 }
359 if let Some(progress) = update.progress {
360 task.progress = Some(progress);
361 }
362 if let Some(summary) = update.summary {
363 task.summary = Some(summary);
364 }
365 if let Some(result_path) = update.result_path {
366 task.result_path = Some(result_path);
367 }
368 if let Some(artifacts) = update.artifacts {
369 task.artifacts = artifacts;
370 }
371 if let Some(error) = update.error {
372 task.error = Some(error);
373 }
374 if let Some(links) = update.links {
375 if links.child_session_id.is_some() {
376 task.links.child_session_id = links.child_session_id;
377 }
378 if links.remote_task_id.is_some() {
379 task.links.remote_task_id = links.remote_task_id;
380 }
381 for id in links.resource_ids {
382 if !task.links.resource_ids.contains(&id) {
383 task.links.resource_ids.push(id);
384 }
385 }
386 }
387 if let Some(worker_id) = update.worker_id {
388 task.worker_id = Some(worker_id);
389 }
390 if let Some(heartbeat_at) = update.heartbeat_at {
391 task.heartbeat_at = Some(heartbeat_at);
392 }
393
394 task.updated_at = now;
395}
396
397pub fn new_session_task(input: CreateSessionTask, now: DateTime<Utc>) -> SessionTask {
399 let state = input.state;
400 SessionTask {
401 id: input.id.unwrap_or_else(generate_task_id),
402 session_id: input.session_id,
403 kind: input.kind,
404 display_name: input.display_name,
405 spec: input.spec,
406 state,
407 state_detail: None,
408 progress: None,
409 input_request: None,
410 cancel_requested_at: None,
411 summary: None,
412 result_path: None,
413 artifacts: Vec::new(),
414 error: None,
415 attempt: 1,
416 worker_id: None,
417 heartbeat_at: None,
418 links: input.links,
419 wake_policy: input.wake_policy,
420 created_at: now,
421 started_at: if state == SessionTaskState::Queued {
422 None
423 } else {
424 Some(now)
425 },
426 finished_at: if state.is_terminal() { Some(now) } else { None },
427 updated_at: now,
428 }
429}
430
431#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
437#[cfg_attr(feature = "openapi", derive(ToSchema))]
438#[serde(rename_all = "snake_case")]
439pub enum TaskMessageDirection {
440 Inbound,
441 Outbound,
442}
443
444impl std::fmt::Display for TaskMessageDirection {
445 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
446 match self {
447 Self::Inbound => write!(f, "inbound"),
448 Self::Outbound => write!(f, "outbound"),
449 }
450 }
451}
452
453impl From<&str> for TaskMessageDirection {
454 fn from(s: &str) -> Self {
455 match s {
456 "outbound" => Self::Outbound,
457 _ => Self::Inbound,
458 }
459 }
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
464#[cfg_attr(feature = "openapi", derive(ToSchema))]
465#[serde(tag = "type", rename_all = "snake_case")]
466pub enum TaskMessagePart {
467 Text {
468 text: String,
469 },
470 Data {
471 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
472 data: Value,
473 },
474}
475
476impl TaskMessagePart {
477 pub fn text(text: impl Into<String>) -> Self {
478 Self::Text { text: text.into() }
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "openapi", derive(ToSchema))]
485pub struct TaskMessage {
486 pub id: String,
488 pub task_id: String,
489 pub direction: TaskMessageDirection,
490 pub content: Vec<TaskMessagePart>,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
493 pub in_reply_to: Option<String>,
494 pub created_at: DateTime<Utc>,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct NewTaskMessage {
500 pub direction: TaskMessageDirection,
501 pub content: Vec<TaskMessagePart>,
502 #[serde(default)]
503 pub in_reply_to: Option<String>,
504}
505
506impl NewTaskMessage {
507 pub fn inbound_text(text: impl Into<String>) -> Self {
508 Self {
509 direction: TaskMessageDirection::Inbound,
510 content: vec![TaskMessagePart::text(text)],
511 in_reply_to: None,
512 }
513 }
514
515 pub fn outbound_text(text: impl Into<String>) -> Self {
516 Self {
517 direction: TaskMessageDirection::Outbound,
518 content: vec![TaskMessagePart::text(text)],
519 in_reply_to: None,
520 }
521 }
522}
523
524pub fn task_message_text(content: &[TaskMessagePart]) -> String {
526 content
527 .iter()
528 .filter_map(|part| match part {
529 TaskMessagePart::Text { text } => Some(text.as_str()),
530 TaskMessagePart::Data { .. } => None,
531 })
532 .collect::<Vec<_>>()
533 .join("\n")
534}
535
536#[async_trait]
544pub trait SessionTaskRegistry: Send + Sync {
545 async fn create(&self, input: CreateSessionTask) -> Result<SessionTask>;
548
549 async fn update(
551 &self,
552 session_id: SessionId,
553 task_id: &str,
554 update: SessionTaskUpdate,
555 ) -> Result<Option<SessionTask>>;
556
557 async fn get(&self, session_id: SessionId, task_id: &str) -> Result<Option<SessionTask>>;
558
559 async fn list(
560 &self,
561 session_id: SessionId,
562 filter: Option<&SessionTaskFilter>,
563 ) -> Result<Vec<SessionTask>>;
564
565 async fn request_cancel(
568 &self,
569 session_id: SessionId,
570 task_id: &str,
571 ) -> Result<Option<SessionTask>>;
572
573 async fn record_message(
577 &self,
578 session_id: SessionId,
579 task_id: &str,
580 message: NewTaskMessage,
581 ) -> Result<TaskMessage>;
582
583 async fn list_messages(
585 &self,
586 session_id: SessionId,
587 task_id: &str,
588 limit: Option<u32>,
589 ) -> Result<Vec<TaskMessage>>;
590}
591
592#[async_trait]
602pub trait TaskExecutor: Send + Sync {
603 fn kind(&self) -> &str;
604
605 async fn start(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()> {
607 let _ = (task, context);
608 Err(crate::error::AgentLoopError::tool(format!(
609 "task kind '{}' does not support start via the registry",
610 self.kind()
611 )))
612 }
613
614 async fn deliver(
616 &self,
617 task: &SessionTask,
618 message: &TaskMessage,
619 context: &crate::traits::ToolContext,
620 ) -> Result<()> {
621 let _ = (task, message, context);
622 Err(crate::error::AgentLoopError::tool(format!(
623 "task kind '{}' does not accept inbound messages",
624 self.kind()
625 )))
626 }
627
628 async fn cancel(&self, task: &SessionTask, context: &crate::traits::ToolContext) -> Result<()>;
630
631 async fn reconcile(
634 &self,
635 task: &SessionTask,
636 context: &crate::traits::ToolContext,
637 ) -> Result<()> {
638 let _ = (task, context);
639 Ok(())
640 }
641}
642
643pub struct TaskExecutorPlugin {
646 pub executor: fn() -> Arc<dyn TaskExecutor>,
647}
648
649inventory::collect!(TaskExecutorPlugin);
650
651pub fn find_task_executor(kind: &str) -> Option<Arc<dyn TaskExecutor>> {
653 inventory::iter::<TaskExecutorPlugin>
654 .into_iter()
655 .map(|plugin| (plugin.executor)())
656 .find(|executor| executor.kind() == kind)
657}
658
659#[async_trait]
667pub trait TaskSink: Send + Sync {
668 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()>;
669
670 async fn progress(&self, progress: TaskProgress) -> Result<()>;
671
672 async fn output(&self, stream: &str, delta: &str) -> Result<()>;
674
675 async fn post(&self, message: NewTaskMessage) -> Result<()>;
677
678 async fn request_input(&self, request: TaskInputRequest) -> Result<()>;
680
681 async fn artifact(&self, artifact: TaskArtifact) -> Result<()>;
682}
683
684pub struct RegistryTaskSink {
687 registry: Arc<dyn SessionTaskRegistry>,
688 session_id: SessionId,
689 task_id: String,
690}
691
692impl RegistryTaskSink {
693 pub fn new(
694 registry: Arc<dyn SessionTaskRegistry>,
695 session_id: SessionId,
696 task_id: String,
697 ) -> Self {
698 Self {
699 registry,
700 session_id,
701 task_id,
702 }
703 }
704}
705
706#[async_trait]
707impl TaskSink for RegistryTaskSink {
708 async fn state(&self, state: SessionTaskState, detail: Option<String>) -> Result<()> {
709 self.registry
710 .update(
711 self.session_id,
712 &self.task_id,
713 SessionTaskUpdate {
714 state: Some(state),
715 state_detail: detail,
716 ..Default::default()
717 },
718 )
719 .await?;
720 Ok(())
721 }
722
723 async fn progress(&self, progress: TaskProgress) -> Result<()> {
724 self.registry
725 .update(
726 self.session_id,
727 &self.task_id,
728 SessionTaskUpdate {
729 progress: Some(progress),
730 ..Default::default()
731 },
732 )
733 .await?;
734 Ok(())
735 }
736
737 async fn output(&self, _stream: &str, _delta: &str) -> Result<()> {
738 Ok(())
739 }
740
741 async fn post(&self, message: NewTaskMessage) -> Result<()> {
742 self.registry
743 .record_message(self.session_id, &self.task_id, message)
744 .await?;
745 Ok(())
746 }
747
748 async fn request_input(&self, request: TaskInputRequest) -> Result<()> {
749 self.registry
750 .update(
751 self.session_id,
752 &self.task_id,
753 SessionTaskUpdate {
754 input_request: Some(request),
755 ..Default::default()
756 },
757 )
758 .await?;
759 Ok(())
760 }
761
762 async fn artifact(&self, artifact: TaskArtifact) -> Result<()> {
763 let Some(task) = self.registry.get(self.session_id, &self.task_id).await? else {
764 return Ok(());
765 };
766 let mut artifacts = task.artifacts;
767 artifacts.push(artifact);
768 self.registry
769 .update(
770 self.session_id,
771 &self.task_id,
772 SessionTaskUpdate {
773 artifacts: Some(artifacts),
774 ..Default::default()
775 },
776 )
777 .await?;
778 Ok(())
779 }
780}
781
782pub fn task_vfs_dir(task_id: &str) -> String {
784 format!("/.tasks/{task_id}")
785}
786
787pub fn task_result_path(task_id: &str) -> String {
789 format!("/.tasks/{task_id}/result.json")
790}
791
792#[cfg(test)]
793mod tests {
794 use super::*;
795
796 fn task() -> SessionTask {
797 new_session_task(
798 CreateSessionTask {
799 session_id: SessionId::new(),
800 id: None,
801 kind: TASK_KIND_BACKGROUND_TOOL.to_string(),
802 display_name: "Test".to_string(),
803 spec: serde_json::json!({}),
804 state: SessionTaskState::Queued,
805 links: TaskLinks::default(),
806 wake_policy: TaskWakePolicy::Silent,
807 },
808 Utc::now(),
809 )
810 }
811
812 #[test]
813 fn create_generates_prefixed_id() {
814 let t = task();
815 assert!(t.id.starts_with("task_"));
816 assert_eq!(t.state, SessionTaskState::Queued);
817 assert!(t.started_at.is_none());
818 }
819
820 #[test]
821 fn first_transition_out_of_queued_stamps_started_at() {
822 let mut t = task();
823 let now = Utc::now();
824 apply_task_update(
825 &mut t,
826 SessionTaskUpdate {
827 state: Some(SessionTaskState::Running),
828 ..Default::default()
829 },
830 now,
831 );
832 assert_eq!(t.state, SessionTaskState::Running);
833 assert_eq!(t.started_at, Some(now));
834 assert!(t.finished_at.is_none());
835 }
836
837 #[test]
838 fn terminal_transition_stamps_finished_at_and_is_final() {
839 let mut t = task();
840 let now = Utc::now();
841 apply_task_update(
842 &mut t,
843 SessionTaskUpdate {
844 state: Some(SessionTaskState::Succeeded),
845 summary: Some("done".to_string()),
846 ..Default::default()
847 },
848 now,
849 );
850 assert_eq!(t.state, SessionTaskState::Succeeded);
851 assert_eq!(t.finished_at, Some(now));
852
853 apply_task_update(
858 &mut t,
859 SessionTaskUpdate {
860 state: Some(SessionTaskState::Failed),
861 error: Some(TaskError {
862 kind: "orphaned".to_string(),
863 message: "stale".to_string(),
864 }),
865 ..Default::default()
866 },
867 Utc::now(),
868 );
869 assert_eq!(t.state, SessionTaskState::Succeeded);
870 assert!(t.error.is_none());
871
872 apply_task_update(
874 &mut t,
875 SessionTaskUpdate {
876 state: Some(SessionTaskState::Succeeded),
877 result_path: Some("/.tasks/x/result.json".to_string()),
878 ..Default::default()
879 },
880 Utc::now(),
881 );
882 assert_eq!(t.result_path.as_deref(), Some("/.tasks/x/result.json"));
883
884 apply_task_update(
886 &mut t,
887 SessionTaskUpdate {
888 summary: Some("enriched".to_string()),
889 ..Default::default()
890 },
891 Utc::now(),
892 );
893 assert_eq!(t.summary.as_deref(), Some("enriched"));
894 }
895
896 #[test]
897 fn input_request_forces_awaiting_input_and_clears_on_resume() {
898 let mut t = task();
899 apply_task_update(
900 &mut t,
901 SessionTaskUpdate {
902 input_request: Some(TaskInputRequest {
903 id: "req_1".to_string(),
904 prompt: "Approve?".to_string(),
905 expected: None,
906 }),
907 ..Default::default()
908 },
909 Utc::now(),
910 );
911 assert_eq!(t.state, SessionTaskState::AwaitingInput);
912 assert!(t.input_request.is_some());
913
914 apply_task_update(
915 &mut t,
916 SessionTaskUpdate {
917 state: Some(SessionTaskState::Running),
918 ..Default::default()
919 },
920 Utc::now(),
921 );
922 assert_eq!(t.state, SessionTaskState::Running);
923 assert!(t.input_request.is_none());
924 }
925
926 #[test]
927 fn links_merge_without_duplicates() {
928 let mut t = task();
929 let child = SessionId::new();
930 apply_task_update(
931 &mut t,
932 SessionTaskUpdate {
933 links: Some(TaskLinks {
934 child_session_id: Some(child),
935 remote_task_id: None,
936 resource_ids: vec!["res_1".to_string()],
937 }),
938 ..Default::default()
939 },
940 Utc::now(),
941 );
942 apply_task_update(
943 &mut t,
944 SessionTaskUpdate {
945 links: Some(TaskLinks {
946 child_session_id: None,
947 remote_task_id: Some("rt_1".to_string()),
948 resource_ids: vec!["res_1".to_string(), "res_2".to_string()],
949 }),
950 ..Default::default()
951 },
952 Utc::now(),
953 );
954 assert_eq!(t.links.child_session_id, Some(child));
955 assert_eq!(t.links.remote_task_id.as_deref(), Some("rt_1"));
956 assert_eq!(t.links.resource_ids, vec!["res_1", "res_2"]);
957 }
958
959 #[test]
960 fn message_text_rendering() {
961 let msg = NewTaskMessage::outbound_text("hello");
962 assert_eq!(task_message_text(&msg.content), "hello");
963 }
964}