Skip to main content

aft/
protocol.rs

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