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}