Skip to main content

aft/
protocol.rs

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