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