1use platonic_core::EffectClass;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::fmt;
5
6pub const PROTOCOL_VERSION: u32 = 1;
7
8pub const ERROR_DAEMON_SHUTTING_DOWN: &str = "daemon_shutting_down";
9pub const ERROR_MALFORMED_REQUEST: &str = "malformed_request";
10pub const ERROR_LAGGED: &str = "lagged";
11pub const ERROR_INTERNAL: &str = "internal_error";
12pub const ERROR_NOT_FOUND: &str = "not_found";
13pub const ERROR_OVERLOAD: &str = "overload";
14pub const ERROR_RUN_FAILED: &str = "run_failed";
15pub const ERROR_SESSIONS_LIST_FAILED: &str = "sessions_list_failed";
16pub const ERROR_UNSUPPORTED_METHOD: &str = "unsupported_method";
17pub const ERROR_UNSUPPORTED_VERSION: &str = "unsupported_version";
18pub const ERROR_WORKSPACE_MISMATCH: &str = "workspace_mismatch";
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum RunStateName {
23 Running,
24 Finished,
25 Failed,
26 Canceled,
27 CancelRequested,
28 Interrupted,
29}
30
31impl RunStateName {
32 pub const fn as_str(self) -> &'static str {
33 match self {
34 Self::Running => "running",
35 Self::Finished => "finished",
36 Self::Failed => "failed",
37 Self::Canceled => "canceled",
38 Self::CancelRequested => "cancel_requested",
39 Self::Interrupted => "interrupted",
40 }
41 }
42}
43
44impl fmt::Display for RunStateName {
45 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46 formatter.pad(self.as_str())
47 }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum EnvelopeKind {
53 Request,
54 Response,
55 Event,
56 Error,
57}
58
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct Envelope {
62 pub v: u32,
63 pub id: Option<String>,
64 pub kind: EnvelopeKind,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub method: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub params: Option<Value>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub result: Option<Value>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub error: Option<ProtocolError>,
73}
74
75impl Envelope {
76 pub fn response(id: Option<String>, method: Option<String>, result: Value) -> Self {
77 Self {
78 v: PROTOCOL_VERSION,
79 id,
80 kind: EnvelopeKind::Response,
81 method,
82 params: None,
83 result: Some(result),
84 error: None,
85 }
86 }
87
88 pub fn response_from<T: Serialize>(
89 id: Option<String>,
90 method: Option<String>,
91 result: T,
92 ) -> Self {
93 Self::response(
94 id,
95 method,
96 serde_json::to_value(result).expect("protocol result serializes"),
97 )
98 }
99
100 pub fn error(
101 id: Option<String>,
102 method: Option<String>,
103 code: impl Into<String>,
104 message: impl Into<String>,
105 ) -> Self {
106 Self {
107 v: PROTOCOL_VERSION,
108 id,
109 kind: EnvelopeKind::Error,
110 method,
111 params: None,
112 result: None,
113 error: Some(ProtocolError {
114 code: code.into(),
115 message: message.into(),
116 }),
117 }
118 }
119}
120
121#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct ProtocolError {
124 pub code: String,
125 pub message: String,
126}
127
128#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130pub struct HelloParams {
131 pub workspace_root: String,
132 pub workspace_id: String,
133}
134
135#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
136pub struct HelloResult {
137 pub daemon_version: String,
138 pub workspace_id: String,
139 pub ledger_path: String,
140 pub capabilities: Vec<String>,
141}
142
143#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
144#[serde(deny_unknown_fields)]
145pub struct RunStartParams {
146 pub question: String,
147 #[serde(default)]
148 pub config_path: Option<String>,
149 #[serde(default)]
150 pub wait: Option<bool>,
151}
152
153#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
154pub struct RunStartResult {
155 pub run_id: String,
156 pub session_id: String,
157 pub ledger_path: String,
158 pub status: RunStateName,
159 pub final_answer: Option<String>,
160}
161
162#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct MessageAppendParams {
165 pub message: String,
166 #[serde(default)]
167 pub session_id: Option<String>,
168 #[serde(default)]
169 pub config_path: Option<String>,
170 #[serde(default)]
171 pub wait: Option<bool>,
172}
173
174#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct EventsStreamParams {
177 pub run_id: String,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub from_offset: Option<u64>,
180 #[serde(default)]
181 pub limit: Option<usize>,
182}
183
184#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
185pub struct EventsStreamResult {
186 pub run_id: String,
187 pub from_offset: u64,
188 pub next_offset: u64,
189 pub status: RunStateName,
190 pub events: Vec<Value>,
191}
192
193#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct ApprovalDecideParams {
196 pub run_id: String,
197 pub tool_call_id: String,
198 pub decision: String,
199 #[serde(default)]
200 pub reason: Option<String>,
201}
202
203#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct RunCancelParams {
206 pub run_id: String,
207}
208
209#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
210pub struct CommandAcceptedResult {
211 pub run_id: String,
212 pub status: RunStateName,
213}
214
215#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum ShutdownIfIdleResultName {
218 Shutdown,
219 RefusedActive,
220}
221
222#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
223pub struct ShutdownIfIdleResult {
224 pub result: ShutdownIfIdleResultName,
225}
226
227#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
228pub struct SessionsListResult {
229 pub sessions: Vec<SessionSummary>,
230}
231
232#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
233pub struct SessionSummary {
234 pub session_id: String,
235 pub run_id: String,
236 pub status: RunStateName,
237 pub latest_question: String,
238 pub ledger_path: String,
239}
240
241#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
242#[serde(deny_unknown_fields)]
243pub struct TranscriptReadParams {
244 #[serde(default)]
245 pub run_id: Option<String>,
246 #[serde(default)]
247 pub session_id: Option<String>,
248}
249
250#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
251pub struct TranscriptReadResult {
252 pub run_id: String,
253 pub status: RunStateName,
254 pub final_answer: Option<String>,
255 pub transcript: String,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub typed: Option<TypedTranscript>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub pending_approval: Option<PendingApprovalSnapshot>,
260}
261
262#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
263pub struct PendingApprovalSnapshot {
264 pub run_id: String,
265 pub tool_call_id: String,
266 pub tool_name: String,
267 pub effect: EffectClass,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub reason: Option<String>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub input_preview: Option<String>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub approval_preview: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub diff_preview: Option<String>,
276}
277
278#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
279pub struct TypedTranscript {
280 pub runs: Vec<TypedRun>,
281}
282
283#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
284pub struct TypedRun {
285 pub run_id: String,
286 pub session_index: u64,
287 pub status: RunStateName,
288 pub entries: Vec<TypedTranscriptEntry>,
289}
290
291#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
292#[serde(rename_all = "snake_case")]
293pub enum ApprovalDecisionName {
294 Granted,
295 Denied,
296}
297
298#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
299#[serde(rename_all = "snake_case", tag = "kind")]
300pub enum TypedTranscriptEntry {
301 User {
302 text: String,
303 },
304 Assistant {
305 text: String,
306 },
307 ToolCall {
308 call_id: String,
309 tool: String,
310 input: Value,
311 },
312 ToolResult {
313 call_id: String,
314 summary: String,
315 },
316 Approval {
317 call_id: String,
318 decision: ApprovalDecisionName,
319 actor_id: String,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 reason: Option<String>,
322 },
323 PolicyDenied {
324 call_id: String,
325 reason: String,
326 },
327 ToolFailed {
328 call_id: String,
329 error: String,
330 },
331}
332
333pub fn decode_request(line: &str) -> Result<Envelope, Box<Envelope>> {
334 let envelope = serde_json::from_str::<Envelope>(line).map_err(|error| {
335 Box::new(Envelope::error(
336 None,
337 None,
338 ERROR_MALFORMED_REQUEST,
339 format!("request is not a valid protocol envelope: {error}"),
340 ))
341 })?;
342
343 if envelope.v != PROTOCOL_VERSION {
344 return Err(Box::new(Envelope::error(
345 envelope.id,
346 envelope.method,
347 ERROR_UNSUPPORTED_VERSION,
348 format!("unsupported protocol version: {}", envelope.v),
349 )));
350 }
351 if envelope.kind != EnvelopeKind::Request {
352 return Err(Box::new(Envelope::error(
353 envelope.id,
354 envelope.method,
355 ERROR_MALFORMED_REQUEST,
356 "envelope kind must be request",
357 )));
358 }
359 if envelope.method.is_none() {
360 return Err(Box::new(Envelope::error(
361 envelope.id,
362 None,
363 ERROR_MALFORMED_REQUEST,
364 "request method is required",
365 )));
366 }
367
368 Ok(envelope)
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use serde_json::json;
375
376 #[test]
377 fn run_state_names_keep_wire_values() {
378 let cases = [
379 (RunStateName::Running, "running"),
380 (RunStateName::Finished, "finished"),
381 (RunStateName::Failed, "failed"),
382 (RunStateName::Canceled, "canceled"),
383 (RunStateName::CancelRequested, "cancel_requested"),
384 (RunStateName::Interrupted, "interrupted"),
385 ];
386
387 for (state, wire_value) in cases {
388 assert_eq!(state.as_str(), wire_value);
389 assert_eq!(serde_json::to_value(state).unwrap(), wire_value);
390 assert_eq!(
391 serde_json::from_value::<RunStateName>(wire_value.into()).unwrap(),
392 state
393 );
394 }
395 }
396
397 #[test]
398 fn decodes_request_envelope() {
399 let envelope = decode_request(
400 r#"{"v":1,"id":"req_1","kind":"request","method":"hello","params":{"workspace_root":"/tmp/work","workspace_id":"work-1234"}}"#,
401 )
402 .unwrap();
403
404 assert_eq!(envelope.id.as_deref(), Some("req_1"));
405 assert_eq!(envelope.method.as_deref(), Some("hello"));
406 }
407
408 #[test]
409 fn rejects_unsupported_version_with_typed_error() {
410 let error =
411 decode_request(r#"{"v":2,"id":"req_1","kind":"request","method":"hello","params":{}}"#)
412 .unwrap_err();
413
414 assert_eq!(error.kind, EnvelopeKind::Error);
415 assert_eq!(
416 error.error.unwrap().code,
417 ERROR_UNSUPPORTED_VERSION.to_string()
418 );
419 }
420
421 #[test]
422 fn response_serializes_without_request_params() {
423 let response = Envelope::response(
424 Some("req_1".into()),
425 Some("hello".into()),
426 serde_json::json!({"workspace_id":"work-1234"}),
427 );
428
429 let raw = serde_json::to_string(&response).unwrap();
430
431 assert!(raw.contains(r#""kind":"response""#));
432 assert!(!raw.contains("params"));
433 }
434
435 #[test]
436 fn typed_transcript_keeps_exact_wire_shape_and_both_compat_directions() {
437 let current = TranscriptReadResult {
438 run_id: "run_1".into(),
439 status: RunStateName::Finished,
440 final_answer: Some("done".into()),
441 transcript: "legacy replay".into(),
442 typed: Some(TypedTranscript {
443 runs: vec![TypedRun {
444 run_id: "run_1".into(),
445 session_index: 0,
446 status: RunStateName::Finished,
447 entries: vec![
448 TypedTranscriptEntry::User {
449 text: "do work".into(),
450 },
451 TypedTranscriptEntry::Assistant {
452 text: "working".into(),
453 },
454 TypedTranscriptEntry::ToolCall {
455 call_id: "call_1".into(),
456 tool: "file.write".into(),
457 input: json!({"path": "out.txt", "content": "done"}),
458 },
459 TypedTranscriptEntry::ToolResult {
460 call_id: "call_1".into(),
461 summary: "wrote out.txt".into(),
462 },
463 TypedTranscriptEntry::Approval {
464 call_id: "call_1".into(),
465 decision: ApprovalDecisionName::Granted,
466 actor_id: "human_1".into(),
467 reason: None,
468 },
469 TypedTranscriptEntry::Approval {
470 call_id: "call_2".into(),
471 decision: ApprovalDecisionName::Denied,
472 actor_id: "human_2".into(),
473 reason: Some("not now".into()),
474 },
475 TypedTranscriptEntry::PolicyDenied {
476 call_id: "call_3".into(),
477 reason: "secret access denied".into(),
478 },
479 TypedTranscriptEntry::ToolFailed {
480 call_id: "call_4".into(),
481 error: "tool crashed".into(),
482 },
483 ],
484 }],
485 }),
486 pending_approval: None,
487 };
488
489 let wire = serde_json::to_value(¤t).unwrap();
490 assert_eq!(
491 wire,
492 json!({
493 "run_id": "run_1",
494 "status": "finished",
495 "final_answer": "done",
496 "transcript": "legacy replay",
497 "typed": {
498 "runs": [{
499 "run_id": "run_1",
500 "session_index": 0,
501 "status": "finished",
502 "entries": [
503 {"kind": "user", "text": "do work"},
504 {"kind": "assistant", "text": "working"},
505 {
506 "kind": "tool_call",
507 "call_id": "call_1",
508 "tool": "file.write",
509 "input": {"path": "out.txt", "content": "done"}
510 },
511 {
512 "kind": "tool_result",
513 "call_id": "call_1",
514 "summary": "wrote out.txt"
515 },
516 {
517 "kind": "approval",
518 "call_id": "call_1",
519 "decision": "granted",
520 "actor_id": "human_1"
521 },
522 {
523 "kind": "approval",
524 "call_id": "call_2",
525 "decision": "denied",
526 "actor_id": "human_2",
527 "reason": "not now"
528 },
529 {
530 "kind": "policy_denied",
531 "call_id": "call_3",
532 "reason": "secret access denied"
533 },
534 {
535 "kind": "tool_failed",
536 "call_id": "call_4",
537 "error": "tool crashed"
538 }
539 ]
540 }]
541 }
542 })
543 );
544
545 #[derive(Deserialize)]
546 struct LegacyTranscriptReadResult {
547 run_id: String,
548 status: RunStateName,
549 final_answer: Option<String>,
550 transcript: String,
551 }
552
553 let legacy_client: LegacyTranscriptReadResult =
554 serde_json::from_value(wire).expect("legacy clients ignore typed");
555 assert_eq!(legacy_client.run_id, "run_1");
556 assert_eq!(legacy_client.status, RunStateName::Finished);
557 assert_eq!(legacy_client.final_answer.as_deref(), Some("done"));
558 assert_eq!(legacy_client.transcript, "legacy replay");
559
560 let current_client: TranscriptReadResult = serde_json::from_value(json!({
561 "run_id": "run_1",
562 "status": "finished",
563 "final_answer": "done",
564 "transcript": "legacy replay"
565 }))
566 .expect("current clients decode typed-less daemon responses");
567 assert_eq!(current_client.typed, None);
568 assert_eq!(current_client.pending_approval, None);
569 }
570
571 #[test]
572 fn pending_approval_snapshot_keeps_exact_additive_wire_shape() {
573 let current = TranscriptReadResult {
574 run_id: "run_1".into(),
575 status: RunStateName::Running,
576 final_answer: None,
577 transcript: "partial replay".into(),
578 typed: None,
579 pending_approval: Some(PendingApprovalSnapshot {
580 run_id: "run_1".into(),
581 tool_call_id: "call_1".into(),
582 tool_name: "file.write".into(),
583 effect: EffectClass::WorkspaceWrite,
584 reason: Some("file.write requires approval".into()),
585 input_preview: Some(r#"{"path":"out.txt"}"#.into()),
586 approval_preview: Some("write out.txt".into()),
587 diff_preview: Some("--- a/out.txt\n+++ b/out.txt\n".into()),
588 }),
589 };
590
591 let wire = serde_json::to_value(¤t).unwrap();
592 assert_eq!(
593 wire,
594 json!({
595 "run_id": "run_1",
596 "status": "running",
597 "final_answer": null,
598 "transcript": "partial replay",
599 "pending_approval": {
600 "run_id": "run_1",
601 "tool_call_id": "call_1",
602 "tool_name": "file.write",
603 "effect": "workspace_write",
604 "reason": "file.write requires approval",
605 "input_preview": "{\"path\":\"out.txt\"}",
606 "approval_preview": "write out.txt",
607 "diff_preview": "--- a/out.txt\n+++ b/out.txt\n"
608 }
609 })
610 );
611
612 #[derive(Deserialize)]
613 struct LegacyTranscriptReadResult {
614 run_id: String,
615 status: RunStateName,
616 transcript: String,
617 }
618
619 let decoded: TranscriptReadResult = serde_json::from_value(wire.clone()).unwrap();
620 assert_eq!(decoded, current);
621
622 let legacy: LegacyTranscriptReadResult = serde_json::from_value(wire).unwrap();
623 assert_eq!(legacy.run_id, "run_1");
624 assert_eq!(legacy.status, RunStateName::Running);
625 assert_eq!(legacy.transcript, "partial replay");
626
627 let minimal = serde_json::to_value(PendingApprovalSnapshot {
628 run_id: "run_2".into(),
629 tool_call_id: "call_2".into(),
630 tool_name: "shell.exec".into(),
631 effect: EffectClass::ExternalSideEffect,
632 reason: None,
633 input_preview: None,
634 approval_preview: None,
635 diff_preview: None,
636 })
637 .unwrap();
638 assert_eq!(
639 minimal,
640 json!({
641 "run_id": "run_2",
642 "tool_call_id": "call_2",
643 "tool_name": "shell.exec",
644 "effect": "external_side_effect"
645 })
646 );
647 }
648}