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