Skip to main content

aft/
protocol.rs

1use serde::{Deserialize, Serialize};
2
3use crate::bash_background::BgTaskStatus;
4
5/// v0.18 streaming semantics for hoisted bash.
6///
7/// Foreground `bash` execution may emit zero or more `progress` frames before
8/// its final `Response`. Each progress frame is NDJSON on stdout with the same
9/// `request_id` as the original request and a `kind` of `stdout` or `stderr`.
10/// The final response remains the existing `{ id, success, ... }` envelope so
11/// older callers can ignore streaming frames. Bash permission prompts use the
12/// recognized `permission_required` error code; Phase 1 Track C will attach the
13/// full permission ask payload and retry loop.
14pub const ERROR_PERMISSION_REQUIRED: &str = "permission_required";
15
16#[derive(Debug, Clone, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ProgressKind {
19    Stdout,
20    Stderr,
21}
22
23#[derive(Debug, Clone, Serialize)]
24pub struct ProgressFrame {
25    #[serde(rename = "type")]
26    pub frame_type: &'static str,
27    pub request_id: String,
28    pub kind: ProgressKind,
29    pub chunk: String,
30}
31
32#[derive(Debug, Clone, Serialize)]
33pub struct PermissionAskFrame {
34    #[serde(rename = "type")]
35    pub frame_type: &'static str,
36    pub request_id: String,
37    pub asks: serde_json::Value,
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct BashCompletedFrame {
42    #[serde(rename = "type")]
43    pub frame_type: &'static str,
44    pub task_id: String,
45    pub session_id: String,
46    pub status: BgTaskStatus,
47    pub exit_code: Option<i32>,
48    pub command: String,
49    /// Tail of stdout+stderr (≤300 bytes), already decoded as lossy UTF-8.
50    /// Empty string when no output was captured. Used by plugins to inline
51    /// short results in the system-reminder so agents don't need a follow-up
52    /// `bash_status` round-trip for typical short commands.
53    #[serde(default)]
54    pub output_preview: String,
55    /// True when the task produced more output than `output_preview` shows
56    /// (rotated buffer, file > 300 bytes, etc). Plugins use this to render a
57    /// `…` prefix and signal that `bash_status` would return more.
58    #[serde(default)]
59    pub output_truncated: bool,
60}
61
62#[derive(Debug, Clone, Serialize)]
63#[serde(untagged)]
64pub enum PushFrame {
65    Progress(ProgressFrame),
66    BashCompleted(BashCompletedFrame),
67}
68
69impl PermissionAskFrame {
70    pub fn new(request_id: impl Into<String>, asks: serde_json::Value) -> Self {
71        Self {
72            frame_type: "permission_ask",
73            request_id: request_id.into(),
74            asks,
75        }
76    }
77}
78
79impl ProgressFrame {
80    pub fn new(
81        request_id: impl Into<String>,
82        kind: ProgressKind,
83        chunk: impl Into<String>,
84    ) -> Self {
85        Self {
86            frame_type: "progress",
87            request_id: request_id.into(),
88            kind,
89            chunk: chunk.into(),
90        }
91    }
92}
93
94impl BashCompletedFrame {
95    pub fn new(
96        task_id: impl Into<String>,
97        session_id: impl Into<String>,
98        status: BgTaskStatus,
99        exit_code: Option<i32>,
100        command: impl Into<String>,
101        output_preview: impl Into<String>,
102        output_truncated: bool,
103    ) -> Self {
104        Self {
105            frame_type: "bash_completed",
106            task_id: task_id.into(),
107            session_id: session_id.into(),
108            status,
109            exit_code,
110            command: command.into(),
111            output_preview: output_preview.into(),
112            output_truncated,
113        }
114    }
115}
116
117/// Fallback session identifier used when a request arrives without one.
118///
119/// Introduced alongside project-shared bridges (issue #14): one `aft` process
120/// can now serve many OpenCode sessions in the same project. Undo/checkpoint
121/// state is partitioned by session inside Rust, but callers that haven't been
122/// updated to pass `session_id` (older plugins, direct CLI usage, tests) still
123/// need to work — they share this default namespace.
124///
125/// Also used as the migration target for legacy pre-session backups on disk.
126pub const DEFAULT_SESSION_ID: &str = "__default__";
127
128/// Inbound request envelope.
129///
130/// Two-stage parse: deserialize this first to get `id` + `command`, then
131/// dispatch on `command` and pull specific params from the flattened `params`.
132#[derive(Debug, Deserialize)]
133pub struct RawRequest {
134    pub id: String,
135    #[serde(alias = "method")]
136    pub command: String,
137    /// Optional LSP hints from the plugin (R031 forward compatibility).
138    #[serde(default)]
139    pub lsp_hints: Option<serde_json::Value>,
140    /// Optional session namespace for undo/checkpoint isolation.
141    ///
142    /// When the plugin passes `session_id`, Rust partitions backup/checkpoint
143    /// state by it so concurrent OpenCode sessions sharing one bridge can't
144    /// see or restore each other's snapshots. When absent, falls back to
145    /// [`DEFAULT_SESSION_ID`].
146    #[serde(default)]
147    pub session_id: Option<String>,
148    /// All remaining fields are captured here for per-command deserialization.
149    #[serde(flatten)]
150    pub params: serde_json::Value,
151}
152
153impl RawRequest {
154    /// Session namespace for this request, falling back to [`DEFAULT_SESSION_ID`]
155    /// when the plugin didn't supply one.
156    pub fn session(&self) -> &str {
157        self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
158    }
159}
160
161/// Outbound response envelope.
162///
163/// `data` is flattened into the top-level JSON object, so a response like
164/// `Response { id: "1", success: true, data: json!({"command": "pong"}) }`
165/// serializes to `{"id":"1","success":true,"command":"pong"}`.
166///
167/// # Honest reporting convention (tri-state)
168///
169/// Tools that search, check, or otherwise produce results MUST follow this
170/// convention so agents can distinguish "did the work, found nothing" from
171/// "couldn't do the work" from "partially did the work":
172///
173/// 1. **`success: false`** — the requested work could not be performed.
174///    Includes a `code` (e.g., `"path_not_found"`, `"no_lsp_server"`,
175///    `"project_too_large"`) and a human-readable `message`. The agent
176///    should treat this as an error and read the message.
177///
178/// 2. **`success: true` + completion signaling** — the work was performed.
179///    Tools must report whether the result is *complete* OR which subset
180///    was actually performed. Conventional fields:
181///    - `complete: true` — full result, agent can trust absence of items
182///    - `complete: false` + `pending_files: [...]` / `unchecked_files: [...]`
183///      / `scope_warnings: [...]` — partial result, with named gaps
184///    - `removed: true|false` (for mutations) — did the file actually change
185///    - `skipped_files: [{file, reason}]` — files we couldn't process inside
186///      the requested scope
187///    - `no_files_matched_scope: bool` — the scope (path/glob) found zero
188///      candidates (distinct from "candidates found, no matches")
189///
190/// 3. **Side-effect skip codes** — when the main work succeeded but a
191///    non-essential side step was skipped (e.g., post-write formatting),
192///    use a `<step>_skipped_reason` field. Approved values:
193///    - `format_skipped_reason`: `"unsupported_language"` |
194///      `"no_formatter_configured"` | `"formatter_not_installed"` |
195///      `"formatter_excluded_path"` | `"timeout"` | `"error"`
196///    - `validate_skipped_reason`: `"unsupported_language"` |
197///      `"no_checker_configured"` | `"checker_not_installed"` |
198///      `"timeout"` | `"error"`
199///
200/// **Anti-patterns to avoid:**
201/// - Returning `success: true` with empty results when the scope didn't
202///   resolve to any files — agent reads as "all clear" but really nothing
203///   was checked. Use `no_files_matched_scope: true` or
204///   `success: false, code: "path_not_found"`.
205/// - Reusing `format_skipped_reason: "not_found"` for two different causes
206///   ("no formatter configured" vs "configured formatter binary missing").
207///   The agent can't act on the ambiguous code.
208///
209/// See ARCHITECTURE.md "Honest reporting convention" for the full rationale.
210#[derive(Debug, Serialize)]
211pub struct Response {
212    pub id: String,
213    pub success: bool,
214    #[serde(flatten)]
215    pub data: serde_json::Value,
216}
217
218/// Parameters for the `echo` command.
219#[derive(Debug, Deserialize)]
220pub struct EchoParams {
221    pub message: String,
222}
223
224impl Response {
225    /// Build a success response with arbitrary data merged at the top level.
226    pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
227        Response {
228            id: id.into(),
229            success: true,
230            data,
231        }
232    }
233
234    /// Build an error response with `code` and `message` fields.
235    pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
236        Response {
237            id: id.into(),
238            success: false,
239            data: serde_json::json!({
240                "code": code,
241                "message": message.into(),
242            }),
243        }
244    }
245
246    /// Build an error response with `code`, `message`, and additional structured data.
247    ///
248    /// The `extra` fields are merged into the top-level response alongside `code` and `message`.
249    pub fn error_with_data(
250        id: impl Into<String>,
251        code: &str,
252        message: impl Into<String>,
253        extra: serde_json::Value,
254    ) -> Self {
255        let mut data = serde_json::json!({
256            "code": code,
257            "message": message.into(),
258        });
259        if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
260            for (k, v) in ext {
261                base.insert(k.clone(), v.clone());
262            }
263        }
264        Response {
265            id: id.into(),
266            success: false,
267            data,
268        }
269    }
270}