aft/protocol.rs
1use serde::{Deserialize, Serialize};
2
3/// Fallback session identifier used when a request arrives without one.
4///
5/// Introduced alongside project-shared bridges (issue #14): one `aft` process
6/// can now serve many OpenCode sessions in the same project. Undo/checkpoint
7/// state is partitioned by session inside Rust, but callers that haven't been
8/// updated to pass `session_id` (older plugins, direct CLI usage, tests) still
9/// need to work — they share this default namespace.
10///
11/// Also used as the migration target for legacy pre-session backups on disk.
12pub const DEFAULT_SESSION_ID: &str = "__default__";
13
14/// Inbound request envelope.
15///
16/// Two-stage parse: deserialize this first to get `id` + `command`, then
17/// dispatch on `command` and pull specific params from the flattened `params`.
18#[derive(Debug, Deserialize)]
19pub struct RawRequest {
20 pub id: String,
21 pub command: String,
22 /// Optional LSP hints from the plugin (R031 forward compatibility).
23 #[serde(default)]
24 pub lsp_hints: Option<serde_json::Value>,
25 /// Optional session namespace for undo/checkpoint isolation.
26 ///
27 /// When the plugin passes `session_id`, Rust partitions backup/checkpoint
28 /// state by it so concurrent OpenCode sessions sharing one bridge can't
29 /// see or restore each other's snapshots. When absent, falls back to
30 /// [`DEFAULT_SESSION_ID`].
31 #[serde(default)]
32 pub session_id: Option<String>,
33 /// All remaining fields are captured here for per-command deserialization.
34 #[serde(flatten)]
35 pub params: serde_json::Value,
36}
37
38impl RawRequest {
39 /// Session namespace for this request, falling back to [`DEFAULT_SESSION_ID`]
40 /// when the plugin didn't supply one.
41 pub fn session(&self) -> &str {
42 self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
43 }
44}
45
46/// Outbound response envelope.
47///
48/// `data` is flattened into the top-level JSON object, so a response like
49/// `Response { id: "1", success: true, data: json!({"command": "pong"}) }`
50/// serializes to `{"id":"1","success":true,"command":"pong"}`.
51#[derive(Debug, Serialize)]
52pub struct Response {
53 pub id: String,
54 pub success: bool,
55 #[serde(flatten)]
56 pub data: serde_json::Value,
57}
58
59/// Parameters for the `echo` command.
60#[derive(Debug, Deserialize)]
61pub struct EchoParams {
62 pub message: String,
63}
64
65impl Response {
66 /// Build a success response with arbitrary data merged at the top level.
67 pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
68 Response {
69 id: id.into(),
70 success: true,
71 data,
72 }
73 }
74
75 /// Build an error response with `code` and `message` fields.
76 pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
77 Response {
78 id: id.into(),
79 success: false,
80 data: serde_json::json!({
81 "code": code,
82 "message": message.into(),
83 }),
84 }
85 }
86
87 /// Build an error response with `code`, `message`, and additional structured data.
88 ///
89 /// The `extra` fields are merged into the top-level response alongside `code` and `message`.
90 pub fn error_with_data(
91 id: impl Into<String>,
92 code: &str,
93 message: impl Into<String>,
94 extra: serde_json::Value,
95 ) -> Self {
96 let mut data = serde_json::json!({
97 "code": code,
98 "message": message.into(),
99 });
100 if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
101 for (k, v) in ext {
102 base.insert(k.clone(), v.clone());
103 }
104 }
105 Response {
106 id: id.into(),
107 success: false,
108 data,
109 }
110 }
111}