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