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}