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