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/// Pushed after configure has completed, when the deferred file walk and
63/// language detection produce warnings (missing formatter/checker/LSP binaries,
64/// or "project too large" file-count exceeded). The walk runs in a background
65/// thread so configure itself returns in <100 ms even on huge directories
66/// (e.g. user's $HOME). When the walk finishes, AFT pushes one frame with
67/// the merged warnings — the plugin delivers them through the same path as
68/// the synchronous warnings that configure used to return.
69#[derive(Debug, Clone, Serialize)]
70pub struct ConfigureWarningsFrame {
71    #[serde(rename = "type")]
72    pub frame_type: &'static str,
73    /// Session id from the configure request that spawned the deferred walk.
74    /// Project-shared bridges can serve multiple sessions, so plugins need this
75    /// to route async warning notifications back to the initiating session.
76    #[serde(default)]
77    pub session_id: Option<String>,
78    /// Project root the warnings refer to. Plugins use this to scope the
79    /// session-id deduplication of repeated identical warnings.
80    pub project_root: String,
81    /// Source-file count discovered by the bounded walk (may stop short of
82    /// the full count if `max_callgraph_files` is exceeded).
83    pub source_file_count: usize,
84    /// `true` when the walk hit the configured `max_callgraph_files` cap;
85    /// in that case `source_file_count` is `cap + 1`.
86    pub source_file_count_exceeds_max: bool,
87    /// Configured callgraph file cap, echoed for plugin display.
88    pub max_callgraph_files: usize,
89    /// Merged formatter/checker/LSP missing-binary warnings.
90    pub warnings: Vec<serde_json::Value>,
91}
92
93#[derive(Debug, Clone, Serialize)]
94#[serde(untagged)]
95pub enum PushFrame {
96    Progress(ProgressFrame),
97    BashCompleted(BashCompletedFrame),
98    ConfigureWarnings(ConfigureWarningsFrame),
99}
100
101impl PermissionAskFrame {
102    pub fn new(request_id: impl Into<String>, asks: serde_json::Value) -> Self {
103        Self {
104            frame_type: "permission_ask",
105            request_id: request_id.into(),
106            asks,
107        }
108    }
109}
110
111impl ProgressFrame {
112    pub fn new(
113        request_id: impl Into<String>,
114        kind: ProgressKind,
115        chunk: impl Into<String>,
116    ) -> Self {
117        Self {
118            frame_type: "progress",
119            request_id: request_id.into(),
120            kind,
121            chunk: chunk.into(),
122        }
123    }
124}
125
126impl ConfigureWarningsFrame {
127    pub fn new(
128        project_root: impl Into<String>,
129        source_file_count: usize,
130        source_file_count_exceeds_max: bool,
131        max_callgraph_files: usize,
132        warnings: Vec<serde_json::Value>,
133    ) -> Self {
134        Self::new_with_session_id(
135            None,
136            project_root,
137            source_file_count,
138            source_file_count_exceeds_max,
139            max_callgraph_files,
140            warnings,
141        )
142    }
143
144    pub fn new_with_session_id(
145        session_id: Option<String>,
146        project_root: impl Into<String>,
147        source_file_count: usize,
148        source_file_count_exceeds_max: bool,
149        max_callgraph_files: usize,
150        warnings: Vec<serde_json::Value>,
151    ) -> Self {
152        Self {
153            frame_type: "configure_warnings",
154            session_id,
155            project_root: project_root.into(),
156            source_file_count,
157            source_file_count_exceeds_max,
158            max_callgraph_files,
159            warnings,
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use serde::Deserialize;
168    use serde_json::json;
169
170    #[derive(Debug, Deserialize)]
171    struct ConfigureWarningsFrameRoundTrip {
172        #[serde(rename = "type")]
173        frame_type: String,
174        session_id: Option<String>,
175        project_root: String,
176        source_file_count: usize,
177        max_callgraph_files: usize,
178        warnings: Vec<serde_json::Value>,
179    }
180
181    #[test]
182    fn configure_warnings_frame_serializes_null_session_id_by_default() {
183        let frame = ConfigureWarningsFrame::new(
184            "/repo",
185            42,
186            false,
187            5_000,
188            vec![json!({
189                "kind": "formatter_not_installed",
190                "tool": "biome",
191                "hint": "Install biome."
192            })],
193        );
194
195        let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
196        let decoded: ConfigureWarningsFrameRoundTrip =
197            serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
198
199        assert_eq!(decoded.session_id, None);
200    }
201
202    #[test]
203    fn configure_warnings_frame_serializes_session_id() {
204        let frame = ConfigureWarningsFrame::new_with_session_id(
205            Some("session-1".to_string()),
206            "/repo",
207            42,
208            false,
209            5_000,
210            vec![json!({
211                "kind": "formatter_not_installed",
212                "tool": "biome",
213                "hint": "Install biome."
214            })],
215        );
216
217        let json = serde_json::to_string(&frame).expect("serialize ConfigureWarningsFrame");
218        let decoded: ConfigureWarningsFrameRoundTrip =
219            serde_json::from_str(&json).expect("deserialize ConfigureWarningsFrame JSON");
220
221        assert_eq!(decoded.frame_type, "configure_warnings");
222        assert_eq!(decoded.session_id.as_deref(), Some("session-1"));
223        assert_eq!(decoded.project_root, "/repo");
224        assert_eq!(decoded.source_file_count, 42);
225        assert_eq!(decoded.max_callgraph_files, 5_000);
226        assert_eq!(decoded.warnings[0]["tool"], "biome");
227    }
228}
229
230impl BashCompletedFrame {
231    pub fn new(
232        task_id: impl Into<String>,
233        session_id: impl Into<String>,
234        status: BgTaskStatus,
235        exit_code: Option<i32>,
236        command: impl Into<String>,
237        output_preview: impl Into<String>,
238        output_truncated: bool,
239    ) -> Self {
240        Self {
241            frame_type: "bash_completed",
242            task_id: task_id.into(),
243            session_id: session_id.into(),
244            status,
245            exit_code,
246            command: command.into(),
247            output_preview: output_preview.into(),
248            output_truncated,
249        }
250    }
251}
252
253/// Fallback session identifier used when a request arrives without one.
254///
255/// Introduced alongside project-shared bridges (issue #14): one `aft` process
256/// can now serve many OpenCode sessions in the same project. Undo/checkpoint
257/// state is partitioned by session inside Rust, but callers that haven't been
258/// updated to pass `session_id` (older plugins, direct CLI usage, tests) still
259/// need to work — they share this default namespace.
260///
261/// Also used as the migration target for legacy pre-session backups on disk.
262pub const DEFAULT_SESSION_ID: &str = "__default__";
263
264/// Inbound request envelope.
265///
266/// Two-stage parse: deserialize this first to get `id` + `command`, then
267/// dispatch on `command` and pull specific params from the flattened `params`.
268#[derive(Debug, Deserialize)]
269pub struct RawRequest {
270    pub id: String,
271    #[serde(alias = "method")]
272    pub command: String,
273    /// Optional LSP hints from the plugin (R031 forward compatibility).
274    #[serde(default)]
275    pub lsp_hints: Option<serde_json::Value>,
276    /// Optional session namespace for undo/checkpoint isolation.
277    ///
278    /// When the plugin passes `session_id`, Rust partitions backup/checkpoint
279    /// state by it so concurrent OpenCode sessions sharing one bridge can't
280    /// see or restore each other's snapshots. When absent, falls back to
281    /// [`DEFAULT_SESSION_ID`].
282    #[serde(default)]
283    pub session_id: Option<String>,
284    /// All remaining fields are captured here for per-command deserialization.
285    #[serde(flatten)]
286    pub params: serde_json::Value,
287}
288
289impl RawRequest {
290    /// Session namespace for this request, falling back to [`DEFAULT_SESSION_ID`]
291    /// when the plugin didn't supply one.
292    pub fn session(&self) -> &str {
293        self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
294    }
295}
296
297/// Outbound response envelope.
298///
299/// `data` is flattened into the top-level JSON object, so a response like
300/// `Response { id: "1", success: true, data: json!({"command": "pong"}) }`
301/// serializes to `{"id":"1","success":true,"command":"pong"}`.
302///
303/// # Honest reporting convention (tri-state)
304///
305/// Tools that search, check, or otherwise produce results MUST follow this
306/// convention so agents can distinguish "did the work, found nothing" from
307/// "couldn't do the work" from "partially did the work":
308///
309/// 1. **`success: false`** — the requested work could not be performed.
310///    Includes a `code` (e.g., `"path_not_found"`, `"no_lsp_server"`,
311///    `"project_too_large"`) and a human-readable `message`. The agent
312///    should treat this as an error and read the message.
313///
314/// 2. **`success: true` + completion signaling** — the work was performed.
315///    Tools must report whether the result is *complete* OR which subset
316///    was actually performed. Conventional fields:
317///    - `complete: true` — full result, agent can trust absence of items
318///    - `complete: false` + `pending_files: [...]` / `unchecked_files: [...]`
319///      / `scope_warnings: [...]` — partial result, with named gaps
320///    - `removed: true|false` (for mutations) — did the file actually change
321///    - `skipped_files: [{file, reason}]` — files we couldn't process inside
322///      the requested scope
323///    - `no_files_matched_scope: bool` — the scope (path/glob) found zero
324///      candidates (distinct from "candidates found, no matches")
325///
326/// 3. **Side-effect skip codes** — when the main work succeeded but a
327///    non-essential side step was skipped (e.g., post-write formatting),
328///    use a `<step>_skipped_reason` field. Approved values:
329///    - `format_skipped_reason`: `"unsupported_language"` |
330///      `"no_formatter_configured"` | `"formatter_not_installed"` |
331///      `"formatter_excluded_path"` | `"timeout"` | `"error"`
332///    - `validate_skipped_reason`: `"unsupported_language"` |
333///      `"no_checker_configured"` | `"checker_not_installed"` |
334///      `"timeout"` | `"error"`
335///
336/// **Anti-patterns to avoid:**
337/// - Returning `success: true` with empty results when the scope didn't
338///   resolve to any files — agent reads as "all clear" but really nothing
339///   was checked. Use `no_files_matched_scope: true` or
340///   `success: false, code: "path_not_found"`.
341/// - Reusing `format_skipped_reason: "not_found"` for two different causes
342///   ("no formatter configured" vs "configured formatter binary missing").
343///   The agent can't act on the ambiguous code.
344///
345/// See ARCHITECTURE.md "Honest reporting convention" for the full rationale.
346#[derive(Debug, Serialize)]
347pub struct Response {
348    pub id: String,
349    pub success: bool,
350    #[serde(flatten)]
351    pub data: serde_json::Value,
352}
353
354/// Parameters for the `echo` command.
355#[derive(Debug, Deserialize)]
356pub struct EchoParams {
357    pub message: String,
358}
359
360impl Response {
361    /// Build a success response with arbitrary data merged at the top level.
362    pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
363        Response {
364            id: id.into(),
365            success: true,
366            data,
367        }
368    }
369
370    /// Build an error response with `code` and `message` fields.
371    pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
372        Response {
373            id: id.into(),
374            success: false,
375            data: serde_json::json!({
376                "code": code,
377                "message": message.into(),
378            }),
379        }
380    }
381
382    /// Build an error response with `code`, `message`, and additional structured data.
383    ///
384    /// The `extra` fields are merged into the top-level response alongside `code` and `message`.
385    pub fn error_with_data(
386        id: impl Into<String>,
387        code: &str,
388        message: impl Into<String>,
389        extra: serde_json::Value,
390    ) -> Self {
391        let mut data = serde_json::json!({
392            "code": code,
393            "message": message.into(),
394        });
395        if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
396            for (k, v) in ext {
397                base.insert(k.clone(), v.clone());
398            }
399        }
400        Response {
401            id: id.into(),
402            success: false,
403            data,
404        }
405    }
406}