turul_a2a_patterns/sink.rs
1//! Progress sink trait used by handlers to emit non-terminal status updates
2//! and streaming artifacts. Terminal task states remain framework-owned and
3//! are deliberately not constructible through this surface.
4
5use async_trait::async_trait;
6
7use turul_a2a_types::{Artifact, Message};
8
9use crate::error::SinkError;
10
11/// Non-terminal task states a skill may emit during execution.
12/// Terminal states are framework-owned; not constructible here.
13#[non_exhaustive]
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ProgressState {
16 Working,
17 InputRequired,
18 AuthRequired,
19}
20
21/// Object-safe progress emission trait used by `SkillHandler::run`.
22///
23/// Implemented with `#[async_trait]` so adopters write standard
24/// `async fn` rather than hand-rolling `Pin<Box<dyn Future>>`. The
25/// macro produces an object-safe trait, verified by the const-fn
26/// assertion at the bottom of this module.
27#[async_trait]
28pub trait SkillProgressSink: Send + Sync {
29 async fn set_status(
30 &self,
31 state: ProgressState,
32 message: Option<Message>,
33 ) -> Result<(), SinkError>;
34
35 async fn emit_artifact(
36 &self,
37 artifact: Artifact,
38 append: bool,
39 last_chunk: bool,
40 ) -> Result<(), SinkError>;
41
42 fn is_closed(&self) -> bool {
43 false
44 }
45
46 /// Records that the skill is alive without emitting an event, so a
47 /// long silent step is not mistaken for a stalled executor. The
48 /// default does nothing; a sink backed by the server's event sink
49 /// delegates to its heartbeat.
50 fn heartbeat(&self) {}
51}
52
53// Compile-time object-safety assertion: `SkillProgressSink` must remain
54// usable behind `dyn`.
55const _: fn() = || {
56 fn assert<T: ?Sized + SkillProgressSink>() {}
57 assert::<dyn SkillProgressSink>();
58};
59
60#[cfg(test)]
61mod tests {
62 use super::ProgressState;
63
64 /// `ProgressState` carries exactly the non-terminal states. Inside the
65 /// defining crate `#[non_exhaustive]` does not force a wildcard arm,
66 /// so this match has none: adding any variant — in particular a
67 /// terminal state (Completed / Failed / Canceled / Rejected) — fails
68 /// to compile here until the binding contract is deliberately
69 /// amended.
70 #[test]
71 fn progress_state_variant_set_is_fixed_and_non_terminal() {
72 fn label(state: ProgressState) -> &'static str {
73 match state {
74 ProgressState::Working => "working",
75 ProgressState::InputRequired => "input-required",
76 ProgressState::AuthRequired => "auth-required",
77 }
78 }
79 assert_eq!(
80 [
81 ProgressState::Working,
82 ProgressState::InputRequired,
83 ProgressState::AuthRequired,
84 ]
85 .map(label),
86 ["working", "input-required", "auth-required"]
87 );
88 }
89}