1use serde::{Deserialize, Serialize};
19use serde_json::Value;
20
21use rpi_agent::events::AgentEvent;
22use rpi_agent::types::AgentToolResult;
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum RemoteEvent {
31 UiDialogRequest {
32 #[serde(rename = "requestId")]
33 request_id: String,
34 #[serde(rename = "toolCallId", default)]
35 tool_call_id: Option<String>,
36 ui: Value,
37 },
38 UiDialogClosed {
39 #[serde(rename = "requestId")]
40 request_id: String,
41 },
42 AgentStart,
43 AgentEnd {
44 #[serde(rename = "messageCount")]
45 message_count: usize,
46 messages: Value,
47 },
48 RetryScheduled {
49 attempt: u32,
50 #[serde(rename = "maxRetries")]
51 max_retries: u32,
52 #[serde(rename = "delayMs")]
53 delay_ms: u64,
54 error: String,
55 },
56 TurnStart,
57 TurnEnd {
58 message: Value,
59 #[serde(rename = "toolResultCount")]
60 tool_result_count: usize,
61 #[serde(rename = "toolResults")]
62 tool_results: Value,
63 #[serde(default, rename = "pendingMessages")]
66 pending_messages: usize,
67 },
68 MessageStart {
69 #[serde(rename = "messageId", default, skip_serializing_if = "Option::is_none")]
70 message_id: Option<String>,
71 #[serde(default)]
72 message: Value,
73 },
74 MessageEnd {
75 #[serde(rename = "messageId", default, skip_serializing_if = "Option::is_none")]
76 message_id: Option<String>,
77 #[serde(default)]
78 message: Value,
79 },
80 MessageUpdate {
81 #[serde(rename = "messageId", default, skip_serializing_if = "Option::is_none")]
82 message_id: Option<String>,
83 #[serde(default)]
84 message: Value,
85 #[serde(rename = "assistantMessageEvent", default)]
86 assistant_message_event: Value,
87 #[serde(rename = "eventType", default)]
88 event_type: String,
89 #[serde(
93 rename = "contentIndex",
94 default,
95 skip_serializing_if = "Option::is_none"
96 )]
97 content_index: Option<u64>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 delta: Option<String>,
101 },
102 ToolExecutionStart {
103 #[serde(rename = "toolCallId")]
104 tool_call_id: String,
105 #[serde(rename = "toolName")]
106 tool_name: String,
107 #[serde(default)]
108 args: Value,
109 },
110 ToolExecutionUpdate {
111 #[serde(rename = "toolCallId")]
112 tool_call_id: String,
113 #[serde(rename = "toolName")]
114 tool_name: String,
115 #[serde(default)]
116 args: Value,
117 #[serde(rename = "partialResult")]
118 partial_result: Value,
119 },
120 ToolExecutionEnd {
121 #[serde(rename = "toolCallId")]
122 tool_call_id: String,
123 #[serde(rename = "toolName")]
124 tool_name: String,
125 #[serde(rename = "isError")]
126 is_error: bool,
127 result: Value,
128 },
129}
130
131impl RemoteEvent {
132 pub fn type_tag(&self) -> &'static str {
134 match self {
135 RemoteEvent::UiDialogRequest { .. } => "ui_dialog_request",
136 RemoteEvent::UiDialogClosed { .. } => "ui_dialog_closed",
137 RemoteEvent::AgentStart => "agent_start",
138 RemoteEvent::AgentEnd { .. } => "agent_end",
139 RemoteEvent::RetryScheduled { .. } => "retry_scheduled",
140 RemoteEvent::TurnStart => "turn_start",
141 RemoteEvent::TurnEnd { .. } => "turn_end",
142 RemoteEvent::MessageStart { .. } => "message_start",
143 RemoteEvent::MessageUpdate { .. } => "message_update",
144 RemoteEvent::MessageEnd { .. } => "message_end",
145 RemoteEvent::ToolExecutionStart { .. } => "tool_execution_start",
146 RemoteEvent::ToolExecutionUpdate { .. } => "tool_execution_update",
147 RemoteEvent::ToolExecutionEnd { .. } => "tool_execution_end",
148 }
149 }
150
151 pub fn is_terminal(&self) -> bool {
153 matches!(self, RemoteEvent::AgentEnd { .. })
154 }
155
156 pub fn from_agent_event(event: &AgentEvent) -> Self {
158 use rpi_ai::types::AssistantMessageEvent;
159 match event {
160 AgentEvent::AgentStart => RemoteEvent::AgentStart,
161 AgentEvent::AgentEnd { messages } => RemoteEvent::AgentEnd {
162 message_count: messages.len(),
163 messages: serde_json::to_value(messages).unwrap_or(Value::Null),
164 },
165 AgentEvent::RetryScheduled {
166 attempt,
167 max_retries,
168 delay_ms,
169 error,
170 } => RemoteEvent::RetryScheduled {
171 attempt: *attempt,
172 max_retries: *max_retries,
173 delay_ms: *delay_ms,
174 error: error.clone(),
175 },
176 AgentEvent::TurnStart => RemoteEvent::TurnStart,
177 AgentEvent::TurnEnd {
178 message,
179 tool_results,
180 pending_messages,
181 ..
182 } => RemoteEvent::TurnEnd {
183 message: serde_json::to_value(message).unwrap_or(Value::Null),
184 tool_result_count: tool_results.len(),
185 tool_results: serde_json::to_value(tool_results).unwrap_or(Value::Null),
186 pending_messages: *pending_messages,
187 },
188 AgentEvent::MessageStart {
189 message,
190 message_id,
191 } => RemoteEvent::MessageStart {
192 message_id: message_id.clone(),
193 message: serde_json::to_value(message).unwrap_or(Value::Null),
194 },
195 AgentEvent::MessageEnd {
196 message,
197 message_id,
198 } => RemoteEvent::MessageEnd {
199 message_id: message_id.clone(),
200 message: serde_json::to_value(message).unwrap_or(Value::Null),
201 },
202 AgentEvent::MessageUpdate {
203 message_id,
204 message,
205 assistant_message_event,
206 } => {
207 let (content_index, delta) = match assistant_message_event {
208 AssistantMessageEvent::TextDelta {
209 content_index,
210 delta,
211 ..
212 }
213 | AssistantMessageEvent::ThinkingDelta {
214 content_index,
215 delta,
216 ..
217 }
218 | AssistantMessageEvent::ToolCallDelta {
219 content_index,
220 delta,
221 ..
222 } => (Some(*content_index as u64), Some(delta.clone())),
223 _ => (None, None),
224 };
225 RemoteEvent::MessageUpdate {
226 message_id: message_id.clone(),
227 message: rpi_agent::message::assistant_json(message),
230 assistant_message_event: serde_json::to_value(assistant_message_event)
231 .unwrap_or(Value::Null),
232 event_type: assistant_message_event.type_tag().to_string(),
233 content_index,
234 delta,
235 }
236 }
237 AgentEvent::ToolExecutionStart {
238 tool_call_id,
239 tool_name,
240 args,
241 } => RemoteEvent::ToolExecutionStart {
242 tool_call_id: tool_call_id.clone(),
243 tool_name: tool_name.clone(),
244 args: args.clone(),
245 },
246 AgentEvent::ToolExecutionUpdate {
247 tool_call_id,
248 tool_name,
249 args,
250 partial_result,
251 } => RemoteEvent::ToolExecutionUpdate {
252 tool_call_id: tool_call_id.clone(),
253 tool_name: tool_name.clone(),
254 args: args.clone(),
255 partial_result: tool_result_json(partial_result),
256 },
257 AgentEvent::ToolExecutionEnd {
258 tool_call_id,
259 tool_name,
260 result,
261 is_error,
262 } => RemoteEvent::ToolExecutionEnd {
263 tool_call_id: tool_call_id.clone(),
264 tool_name: tool_name.clone(),
265 is_error: *is_error,
266 result: tool_result_json(result),
267 },
268 }
269 }
270}
271
272pub fn tool_result_json(result: &AgentToolResult) -> Value {
275 serde_json::to_value(result)
276 .expect("AgentToolResult contains only JSON-serializable native content, details and usage")
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284#[serde(tag = "type", rename_all = "snake_case")]
285pub enum RemoteCommand {
286 UiDialogResponse {
287 #[serde(rename = "requestId")]
288 request_id: String,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 answer: Option<Value>,
291 #[serde(default)]
292 cancelled: bool,
293 },
294 Prompt { content: String },
296 Abort,
298 GetState,
300 SetModel { model: String },
302 SetThinkingLevel { level: String },
304 SetActiveTools { tools: Vec<String> },
306 Ping,
308 Stop,
310}
311
312impl RemoteCommand {
313 pub fn with_id(&self, id: impl Into<Value>) -> Value {
315 let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
316 if let Some(object) = value.as_object_mut() {
317 object.insert("id".to_string(), id.into());
318 }
319 value
320 }
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
325#[serde(rename_all = "lowercase")]
326pub enum ResponseStatus {
327 Ok,
328 Error,
329}
330
331#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333pub struct RemoteResponse {
334 #[serde(default)]
335 pub id: Value,
336 pub status: ResponseStatus,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub result: Option<Value>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub error: Option<String>,
341}
342
343impl RemoteResponse {
344 pub fn ok(id: Value, result: Value) -> Self {
346 Self {
347 id,
348 status: ResponseStatus::Ok,
349 result: Some(result),
350 error: None,
351 }
352 }
353
354 pub fn error(id: Value, error: impl Into<String>) -> Self {
356 Self {
357 id,
358 status: ResponseStatus::Error,
359 result: None,
360 error: Some(error.into()),
361 }
362 }
363
364 pub fn is_ok(&self) -> bool {
366 matches!(self.status, ResponseStatus::Ok)
367 }
368
369 pub fn to_json(&self) -> Value {
371 let mut value = serde_json::to_value(self).unwrap_or(Value::Null);
372 if let Some(object) = value.as_object_mut() {
373 object.insert("type".to_string(), Value::String("response".to_string()));
374 }
375 value
376 }
377}
378
379#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
381pub struct SessionState {
382 #[serde(default)]
383 pub model: Option<String>,
384 #[serde(rename = "thinkingLevel", default)]
385 pub thinking_level: Option<Value>,
386 #[serde(rename = "activeTools", default)]
387 pub active_tools: Vec<String>,
388 #[serde(rename = "leafId", default)]
389 pub leaf_id: Option<String>,
390 #[serde(rename = "sessionId", default)]
391 pub session_id: Option<String>,
392}
393
394#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
396pub struct PromptOutcome {
397 #[serde(default)]
398 pub outcome: Option<String>,
399 #[serde(rename = "finalText", default)]
400 pub final_text: Option<String>,
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use rpi_agent::types::AgentToolResult;
407
408 fn signed_native_result() -> AgentToolResult {
409 use rpi_agent::types::TextContentOrImage;
410 AgentToolResult {
411 content: vec![
412 TextContentOrImage::Text(
413 serde_json::from_value(serde_json::json!({
414 "type":"text", "text":"signed 界😀", "textSignature":
415 "{ \"version\": 1, \"id\": \"sig-😀\" }",
416 }))
417 .unwrap(),
418 ),
419 TextContentOrImage::Image(
420 serde_json::from_value(serde_json::json!({
421 "type":"image", "data":"AAEC+/==", "mimeType":"image/png",
422 }))
423 .unwrap(),
424 ),
425 ],
426 details: serde_json::json!({"runtimeKey":"business", "runtimeHandle":{"id":7},
427 "__rpi":{"owner":"customer"}, "__rpiRuntimeKey":"unchanged"}),
428 usage: Some(rpi_ai::types::Usage::zero()),
429 added_tool_names: vec!["read".into(), "read".into()],
430 terminate: true,
431 }
432 }
433
434 #[test]
435 fn tool_update_preserves_canonical_native_result_and_text_signature() {
436 let result = signed_native_result();
437 let expected = serde_json::to_value(&result).unwrap();
438 let event = AgentEvent::ToolExecutionUpdate {
439 tool_call_id: "call".into(),
440 tool_name: "read".into(),
441 args: serde_json::json!({"path":"file"}),
442 partial_result: result.into(),
443 };
444 let wire = serde_json::to_value(RemoteEvent::from_agent_event(&event)).unwrap();
445 assert_eq!(wire["type"], "tool_execution_update");
446 assert_eq!(wire["partialResult"], expected);
447 assert_eq!(
448 wire["partialResult"]["content"][0]["textSignature"],
449 "{ \"version\": 1, \"id\": \"sig-😀\" }"
450 );
451 assert!(wire["partialResult"].get("isError").is_none());
452 }
453
454 #[test]
455 fn tool_end_preserves_canonical_native_result_and_separate_error_authority() {
456 let result = signed_native_result();
457 let expected = serde_json::to_value(&result).unwrap();
458 let event = AgentEvent::ToolExecutionEnd {
459 tool_call_id: "call".into(),
460 tool_name: "read".into(),
461 result,
462 is_error: true,
463 };
464 let wire = serde_json::to_value(RemoteEvent::from_agent_event(&event)).unwrap();
465 assert_eq!(wire["type"], "tool_execution_end");
466 assert_eq!(wire["result"], expected);
467 assert!(wire["result"]["content"][0]["textSignature"].is_string());
468 assert_eq!(wire["isError"], true);
469 assert!(wire["result"].get("isError").is_none());
470 let plain = tool_result_json(&AgentToolResult::text("unsigned"));
471 assert!(plain["content"][0].get("textSignature").is_none());
472 assert!(plain["usage"].is_null());
473 }
474
475 #[test]
478 fn turn_end_projects_pending_messages() {
479 let event = AgentEvent::TurnEnd {
480 message: rpi_agent::AgentMessage::Assistant(Box::new(
481 rpi_ai::types::AssistantMessage::empty(
482 rpi_ai::types::Api::AnthropicMessages,
483 "p",
484 "m",
485 0,
486 ),
487 )),
488 tool_results: vec![],
489 pending_messages: 3,
490 };
491 let projected = RemoteEvent::from_agent_event(&event);
492 assert_eq!(projected.type_tag(), "turn_end");
493 let wire = serde_json::to_value(&projected).unwrap();
494 assert_eq!(wire["pendingMessages"], 3, "{wire}");
495
496 let zero = AgentEvent::TurnEnd {
498 message: rpi_agent::AgentMessage::Assistant(Box::new(
499 rpi_ai::types::AssistantMessage::empty(
500 rpi_ai::types::Api::AnthropicMessages,
501 "p",
502 "m",
503 0,
504 ),
505 )),
506 tool_results: vec![],
507 pending_messages: 0,
508 };
509 let wire = serde_json::to_value(RemoteEvent::from_agent_event(&zero)).unwrap();
510 assert_eq!(wire["pendingMessages"], 0, "{wire}");
511
512 let legacy: RemoteEvent = serde_json::from_value(serde_json::json!({
514 "type":"turn_end", "message":null, "toolResultCount":0, "toolResults":[],
515 }))
516 .unwrap();
517 assert!(matches!(
518 legacy,
519 RemoteEvent::TurnEnd {
520 pending_messages: 0,
521 ..
522 }
523 ));
524 }
525
526 #[test]
527 fn dialog_wire_shapes_round_trip_with_request_ids() {
528 let event: RemoteEvent = serde_json::from_value(serde_json::json!({
529 "type":"ui_dialog_request", "requestId":"q", "toolCallId":"c",
530 "ui":{"question":"Allow?", "options":["Yes","No"]},
531 }))
532 .unwrap();
533 assert_eq!(event.type_tag(), "ui_dialog_request");
534 assert_eq!(serde_json::to_value(&event).unwrap()["requestId"], "q");
535 let answer = RemoteCommand::UiDialogResponse {
536 request_id: "q".into(),
537 answer: Some(serde_json::json!({"values":["No"]})),
538 cancelled: false,
539 }
540 .with_id(7);
541 assert_eq!(answer["type"], "ui_dialog_response");
542 assert_eq!(answer["requestId"], "q");
543 assert_eq!(answer["answer"]["values"][0], "No");
544 let cancelled = RemoteCommand::UiDialogResponse {
545 request_id: "q".into(),
546 answer: None,
547 cancelled: true,
548 }
549 .with_id(8);
550 assert_eq!(cancelled["cancelled"], true);
551 assert!(cancelled.get("answer").is_none());
552 assert!(
553 matches!(serde_json::from_value::<RemoteEvent>(serde_json::json!({
554 "type":"ui_dialog_closed", "requestId":"q",
555 })).unwrap(), RemoteEvent::UiDialogClosed { request_id } if request_id == "q")
556 );
557 }
558
559 #[test]
560 fn event_projection_matches_wire_shape() {
561 let end = serde_json::to_value(RemoteEvent::from_agent_event(&AgentEvent::AgentEnd {
562 messages: vec![],
563 }))
564 .unwrap();
565 assert_eq!(end["type"], "agent_end");
566 assert_eq!(end["messages"], serde_json::json!([]));
567 assert_eq!(end["messageCount"], 0);
568
569 let tool = serde_json::to_value(RemoteEvent::from_agent_event(
570 &AgentEvent::ToolExecutionEnd {
571 tool_call_id: "call-1".into(),
572 tool_name: "read".into(),
573 result: AgentToolResult::text("hello"),
574 is_error: false,
575 },
576 ))
577 .unwrap();
578 assert_eq!(tool["type"], "tool_execution_end");
579 assert_eq!(tool["result"]["content"][0]["text"], "hello");
580 assert_eq!(tool["result"]["terminate"], false);
581
582 let retry =
583 serde_json::to_value(RemoteEvent::from_agent_event(&AgentEvent::RetryScheduled {
584 attempt: 3,
585 max_retries: 10,
586 delay_ms: 8_000,
587 error: "503 service unavailable".into(),
588 }))
589 .unwrap();
590 assert_eq!(retry["type"], "retry_scheduled");
591 assert_eq!(retry["attempt"], 3);
592 assert_eq!(retry["maxRetries"], 10);
593 assert_eq!(retry["delayMs"], 8_000);
594 }
595
596 #[test]
597 fn native_message_ids_round_trip_and_legacy_messages_remain_optional() {
598 for kind in ["message_start", "message_update", "message_end"] {
599 let legacy = serde_json::json!({"type": kind, "message": {"role":"assistant"}});
600 let parsed: RemoteEvent = serde_json::from_value(legacy).unwrap();
601 assert!(serde_json::to_value(parsed)
602 .unwrap()
603 .get("messageId")
604 .is_none());
605 let value = serde_json::json!({"type": kind, "messageId": "native-\"🐈", "message": {"role":"assistant"}});
606 let parsed: RemoteEvent = serde_json::from_value(value).unwrap();
607 assert_eq!(
608 serde_json::to_value(parsed).unwrap()["messageId"],
609 "native-\"🐈"
610 );
611 }
612 }
613
614 #[test]
615 fn events_round_trip_over_the_wire() {
616 for event in [
617 RemoteEvent::AgentStart,
618 RemoteEvent::MessageUpdate {
619 message_id: Some("entry-1".into()),
620 message: serde_json::json!({"role": "assistant"}),
621 assistant_message_event: serde_json::json!({"type": "text_delta"}),
622 event_type: "text_delta".into(),
623 content_index: Some(0),
624 delta: Some("hi".into()),
625 },
626 RemoteEvent::ToolExecutionEnd {
627 tool_call_id: "c1".into(),
628 tool_name: "read".into(),
629 is_error: true,
630 result: tool_result_json(&AgentToolResult::text("nope")),
631 },
632 ] {
633 let json = serde_json::to_value(&event).unwrap();
634 let back: RemoteEvent = serde_json::from_value(json).unwrap();
635 assert_eq!(event, back);
636 }
637 }
638
639 #[test]
640 fn command_and_response_round_trip() {
641 let cmd = RemoteCommand::Prompt {
642 content: "hi".into(),
643 };
644 let value = cmd.with_id("r1");
645 assert_eq!(value["type"], "prompt");
646 assert_eq!(value["content"], "hi");
647 assert_eq!(value["id"], "r1");
648
649 let response =
650 RemoteResponse::ok(serde_json::json!("r1"), serde_json::json!({"pong": true}));
651 let json = response.to_json();
652 assert_eq!(json["type"], "response");
653 assert_eq!(json["id"], "r1");
654 assert_eq!(json["status"], "ok");
655 let parsed: RemoteResponse = serde_json::from_value(json).unwrap();
656 assert!(parsed.is_ok());
657
658 let err = RemoteResponse::error(Value::Null, "boom").to_json();
659 assert_eq!(err["status"], "error");
660 assert_eq!(err["error"], "boom");
661 }
662}