Skip to main content

pipecrab_core/frame/
dispatch.rs

1//! Native asynchronous-dispatch protocol frames: commands that drive
2//! long-running external tasks and events reporting their state. Every dispatch
3//! frame survives an interrupt flush — external task state outlives the turn.
4
5use std::sync::Arc;
6
7/// A command driving an asynchronous external task. `tool_call_id` names the
8/// model invocation; `task_id` names the task and exists only once a
9/// [`Create`](DispatchCommand::Create) is accepted (see [`DispatchEvent::Accepted`]).
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum DispatchCommand {
12    /// Start a new task; no task ID exists yet.
13    Create {
14        /// The [`ToolCall::id`](crate::ToolCall::id) that requested the task.
15        tool_call_id: Arc<str>,
16        /// The work to perform.
17        task: Arc<str>,
18        /// Optional extra context.
19        context: Option<Arc<str>>,
20    },
21    /// Send a follow-up to an already-accepted task.
22    Update {
23        /// The [`ToolCall::id`](crate::ToolCall::id) that requested the update.
24        tool_call_id: Arc<str>,
25        /// The accepted task's identifier (from [`DispatchEvent::Accepted`]).
26        task_id: Arc<str>,
27        /// The follow-up message.
28        message: Arc<str>,
29    },
30}
31
32/// An event reporting an asynchronous task's state.
33/// [`Rejected`](DispatchEvent::Rejected) is a pre-task failure (carries
34/// `tool_call_id`); [`Failure`](DispatchEvent::Failure) is a
35/// post-[`Accepted`](DispatchEvent::Accepted) failure (carries `task_id`).
36/// [`Progress`](DispatchEvent::Progress) is informational.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum DispatchEvent {
39    /// A [`DispatchCommand::Create`] was accepted; the task now has an ID.
40    Accepted {
41        /// The [`ToolCall::id`](crate::ToolCall::id) from the create command.
42        tool_call_id: Arc<str>,
43        /// The new task's identifier.
44        task_id: Arc<str>,
45    },
46    /// A command failed before a task ID was assigned.
47    Rejected {
48        /// The [`ToolCall::id`](crate::ToolCall::id) from the rejected command.
49        tool_call_id: Arc<str>,
50        /// Human-readable reason for the rejection.
51        message: Arc<str>,
52    },
53    /// An informational progress update from an accepted task.
54    Progress {
55        /// The task this update concerns.
56        task_id: Arc<str>,
57        /// The progress detail.
58        message: Arc<str>,
59    },
60    /// An accepted task is asking a question.
61    Question {
62        /// The task asking the question.
63        task_id: Arc<str>,
64        /// The question text.
65        message: Arc<str>,
66    },
67    /// An accepted task finished successfully.
68    Completion {
69        /// The task that completed.
70        task_id: Arc<str>,
71        /// The completion detail.
72        message: Arc<str>,
73    },
74    /// A previously accepted task later failed.
75    Failure {
76        /// The task that failed.
77        task_id: Arc<str>,
78        /// Human-readable reason for the failure.
79        message: Arc<str>,
80        /// Whether retrying the task might succeed.
81        retryable: bool,
82    },
83}
84
85/// A dispatch protocol frame: a [`Command`](DispatchFrame::Command) requests
86/// work, an [`Event`](DispatchFrame::Event) reports what happened.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub enum DispatchFrame {
89    /// A task-state update; see [`DispatchEvent`].
90    Event(DispatchEvent),
91    /// A request that drives a task; see [`DispatchCommand`].
92    Command(DispatchCommand),
93}
94
95impl From<DispatchEvent> for DispatchFrame {
96    /// Wrap a [`DispatchEvent`] as [`DispatchFrame::Event`].
97    fn from(event: DispatchEvent) -> Self {
98        DispatchFrame::Event(event)
99    }
100}
101
102impl From<DispatchCommand> for DispatchFrame {
103    /// Wrap a [`DispatchCommand`] as [`DispatchFrame::Command`].
104    fn from(command: DispatchCommand) -> Self {
105        DispatchFrame::Command(command)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::DataFrame;
113
114    #[test]
115    fn subtypes_construct_and_compare() {
116        let create = DispatchCommand::Create {
117            tool_call_id: Arc::from("call-1"),
118            task: Arc::from("book flight"),
119            context: None,
120        };
121        assert_eq!(create.clone(), create);
122        // A differing `context` makes two otherwise-equal creates distinct.
123        assert_ne!(
124            create,
125            DispatchCommand::Create {
126                tool_call_id: Arc::from("call-1"),
127                task: Arc::from("book flight"),
128                context: Some(Arc::from("window seat")),
129            }
130        );
131
132        // `retryable` participates in equality.
133        let fail = DispatchEvent::Failure {
134            task_id: Arc::from("task-7"),
135            message: Arc::from("timeout"),
136            retryable: true,
137        };
138        assert_ne!(
139            fail,
140            DispatchEvent::Failure {
141                task_id: Arc::from("task-7"),
142                message: Arc::from("timeout"),
143                retryable: false,
144            }
145        );
146    }
147
148    #[test]
149    fn tool_call_id_and_task_id_are_distinct_fields() {
150        // Accepted is the hinge from a pre-task `tool_call_id` to the assigned
151        // `task_id`; they are independent values that need not match.
152        let accepted = DispatchEvent::Accepted {
153            tool_call_id: Arc::from("call-1"),
154            task_id: Arc::from("task-42"),
155        };
156        let DispatchEvent::Accepted {
157            tool_call_id,
158            task_id,
159        } = &accepted
160        else {
161            unreachable!()
162        };
163        assert_ne!(tool_call_id, task_id);
164
165        // Rejected fails before a task id exists, so it carries only the call id.
166        let rejected = DispatchEvent::Rejected {
167            tool_call_id: Arc::from("call-1"),
168            message: Arc::from("quota exceeded"),
169        };
170        assert_ne!(accepted, rejected);
171    }
172
173    #[test]
174    fn events_and_commands_convert_into_dispatch_frame() {
175        let event = DispatchEvent::Progress {
176            task_id: Arc::from("task-1"),
177            message: Arc::from("50%"),
178        };
179        assert_eq!(
180            DispatchFrame::from(event.clone()),
181            DispatchFrame::Event(event)
182        );
183
184        let command = DispatchCommand::Update {
185            tool_call_id: Arc::from("call-2"),
186            task_id: Arc::from("task-1"),
187            message: Arc::from("hurry"),
188        };
189        assert_eq!(
190            DispatchFrame::from(command.clone()),
191            DispatchFrame::Command(command)
192        );
193    }
194
195    #[test]
196    fn dispatch_frame_rides_the_data_lane() {
197        let frame = DispatchFrame::Event(DispatchEvent::Completion {
198            task_id: Arc::from("task-1"),
199            message: Arc::from("done"),
200        });
201        match DataFrame::from(frame.clone()) {
202            DataFrame::Dispatch(f) => assert_eq!(f, frame),
203            other => panic!("expected Dispatch, got {other:?}"),
204        }
205    }
206
207    #[test]
208    fn every_dispatch_frame_survives_flush() {
209        // External task state must outlive a barge-in — commands and every event
210        // variant survive the data-lane flush.
211        let events = [
212            DispatchEvent::Accepted {
213                tool_call_id: Arc::from("c"),
214                task_id: Arc::from("t"),
215            },
216            DispatchEvent::Rejected {
217                tool_call_id: Arc::from("c"),
218                message: Arc::from("no"),
219            },
220            DispatchEvent::Progress {
221                task_id: Arc::from("t"),
222                message: Arc::from("p"),
223            },
224            DispatchEvent::Question {
225                task_id: Arc::from("t"),
226                message: Arc::from("q"),
227            },
228            DispatchEvent::Completion {
229                task_id: Arc::from("t"),
230                message: Arc::from("c"),
231            },
232            DispatchEvent::Failure {
233                task_id: Arc::from("t"),
234                message: Arc::from("f"),
235                retryable: false,
236            },
237        ];
238        for event in events {
239            assert!(DataFrame::from(DispatchFrame::from(event)).survives_flush());
240        }
241
242        let commands = [
243            DispatchCommand::Create {
244                tool_call_id: Arc::from("c"),
245                task: Arc::from("do"),
246                context: None,
247            },
248            DispatchCommand::Update {
249                tool_call_id: Arc::from("c"),
250                task_id: Arc::from("t"),
251                message: Arc::from("m"),
252            },
253        ];
254        for command in commands {
255            assert!(DataFrame::from(DispatchFrame::from(command)).survives_flush());
256        }
257    }
258}