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