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///
52/// # Honest reporting convention (tri-state)
53///
54/// Tools that search, check, or otherwise produce results MUST follow this
55/// convention so agents can distinguish "did the work, found nothing" from
56/// "couldn't do the work" from "partially did the work":
57///
58/// 1. **`success: false`** — the requested work could not be performed.
59///    Includes a `code` (e.g., `"path_not_found"`, `"no_lsp_server"`,
60///    `"project_too_large"`) and a human-readable `message`. The agent
61///    should treat this as an error and read the message.
62///
63/// 2. **`success: true` + completion signaling** — the work was performed.
64///    Tools must report whether the result is *complete* OR which subset
65///    was actually performed. Conventional fields:
66///    - `complete: true` — full result, agent can trust absence of items
67///    - `complete: false` + `pending_files: [...]` / `unchecked_files: [...]`
68///      / `scope_warnings: [...]` — partial result, with named gaps
69///    - `removed: true|false` (for mutations) — did the file actually change
70///    - `skipped_files: [{file, reason}]` — files we couldn't process inside
71///      the requested scope
72///    - `no_files_matched_scope: bool` — the scope (path/glob) found zero
73///      candidates (distinct from "candidates found, no matches")
74///
75/// 3. **Side-effect skip codes** — when the main work succeeded but a
76///    non-essential side step was skipped (e.g., post-write formatting),
77///    use a `<step>_skipped_reason` field. Approved values:
78///    - `format_skipped_reason`: `"unsupported_language"` |
79///      `"no_formatter_configured"` | `"formatter_not_installed"` |
80///      `"timeout"` | `"error"`
81///    - `validate_skipped_reason`: `"unsupported_language"` |
82///      `"no_checker_configured"` | `"checker_not_installed"` |
83///      `"timeout"` | `"error"`
84///
85/// **Anti-patterns to avoid:**
86/// - Returning `success: true` with empty results when the scope didn't
87///   resolve to any files — agent reads as "all clear" but really nothing
88///   was checked. Use `no_files_matched_scope: true` or
89///   `success: false, code: "path_not_found"`.
90/// - Reusing `format_skipped_reason: "not_found"` for two different causes
91///   ("no formatter configured" vs "configured formatter binary missing").
92///   The agent can't act on the ambiguous code.
93///
94/// See ARCHITECTURE.md "Honest reporting convention" for the full rationale.
95#[derive(Debug, Serialize)]
96pub struct Response {
97    pub id: String,
98    pub success: bool,
99    #[serde(flatten)]
100    pub data: serde_json::Value,
101}
102
103/// Parameters for the `echo` command.
104#[derive(Debug, Deserialize)]
105pub struct EchoParams {
106    pub message: String,
107}
108
109impl Response {
110    /// Build a success response with arbitrary data merged at the top level.
111    pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
112        Response {
113            id: id.into(),
114            success: true,
115            data,
116        }
117    }
118
119    /// Build an error response with `code` and `message` fields.
120    pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
121        Response {
122            id: id.into(),
123            success: false,
124            data: serde_json::json!({
125                "code": code,
126                "message": message.into(),
127            }),
128        }
129    }
130
131    /// Build an error response with `code`, `message`, and additional structured data.
132    ///
133    /// The `extra` fields are merged into the top-level response alongside `code` and `message`.
134    pub fn error_with_data(
135        id: impl Into<String>,
136        code: &str,
137        message: impl Into<String>,
138        extra: serde_json::Value,
139    ) -> Self {
140        let mut data = serde_json::json!({
141            "code": code,
142            "message": message.into(),
143        });
144        if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
145            for (k, v) in ext {
146                base.insert(k.clone(), v.clone());
147            }
148        }
149        Response {
150            id: id.into(),
151            success: false,
152            data,
153        }
154    }
155}