Skip to main content

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}