aft/protocol.rs
1use serde::{Deserialize, Serialize};
2
3/// v0.18 streaming semantics for hoisted bash.
4///
5/// Foreground `bash` execution may emit zero or more `progress` frames before
6/// its final `Response`. Each progress frame is NDJSON on stdout with the same
7/// `request_id` as the original request and a `kind` of `stdout` or `stderr`.
8/// The final response remains the existing `{ id, success, ... }` envelope so
9/// older callers can ignore streaming frames. Bash permission prompts use the
10/// recognized `permission_required` error code; Phase 1 Track C will attach the
11/// full permission ask payload and retry loop.
12pub const ERROR_PERMISSION_REQUIRED: &str = "permission_required";
13
14#[derive(Debug, Clone, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ProgressKind {
17 Stdout,
18 Stderr,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct ProgressFrame {
23 #[serde(rename = "type")]
24 pub frame_type: &'static str,
25 pub request_id: String,
26 pub kind: ProgressKind,
27 pub chunk: String,
28}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct PermissionAskFrame {
32 #[serde(rename = "type")]
33 pub frame_type: &'static str,
34 pub request_id: String,
35 pub asks: serde_json::Value,
36}
37
38impl PermissionAskFrame {
39 pub fn new(request_id: impl Into<String>, asks: serde_json::Value) -> Self {
40 Self {
41 frame_type: "permission_ask",
42 request_id: request_id.into(),
43 asks,
44 }
45 }
46}
47
48impl ProgressFrame {
49 pub fn new(
50 request_id: impl Into<String>,
51 kind: ProgressKind,
52 chunk: impl Into<String>,
53 ) -> Self {
54 Self {
55 frame_type: "progress",
56 request_id: request_id.into(),
57 kind,
58 chunk: chunk.into(),
59 }
60 }
61}
62
63/// Fallback session identifier used when a request arrives without one.
64///
65/// Introduced alongside project-shared bridges (issue #14): one `aft` process
66/// can now serve many OpenCode sessions in the same project. Undo/checkpoint
67/// state is partitioned by session inside Rust, but callers that haven't been
68/// updated to pass `session_id` (older plugins, direct CLI usage, tests) still
69/// need to work — they share this default namespace.
70///
71/// Also used as the migration target for legacy pre-session backups on disk.
72pub const DEFAULT_SESSION_ID: &str = "__default__";
73
74/// Inbound request envelope.
75///
76/// Two-stage parse: deserialize this first to get `id` + `command`, then
77/// dispatch on `command` and pull specific params from the flattened `params`.
78#[derive(Debug, Deserialize)]
79pub struct RawRequest {
80 pub id: String,
81 #[serde(alias = "method")]
82 pub command: String,
83 /// Optional LSP hints from the plugin (R031 forward compatibility).
84 #[serde(default)]
85 pub lsp_hints: Option<serde_json::Value>,
86 /// Optional session namespace for undo/checkpoint isolation.
87 ///
88 /// When the plugin passes `session_id`, Rust partitions backup/checkpoint
89 /// state by it so concurrent OpenCode sessions sharing one bridge can't
90 /// see or restore each other's snapshots. When absent, falls back to
91 /// [`DEFAULT_SESSION_ID`].
92 #[serde(default)]
93 pub session_id: Option<String>,
94 /// All remaining fields are captured here for per-command deserialization.
95 #[serde(flatten)]
96 pub params: serde_json::Value,
97}
98
99impl RawRequest {
100 /// Session namespace for this request, falling back to [`DEFAULT_SESSION_ID`]
101 /// when the plugin didn't supply one.
102 pub fn session(&self) -> &str {
103 self.session_id.as_deref().unwrap_or(DEFAULT_SESSION_ID)
104 }
105}
106
107/// Outbound response envelope.
108///
109/// `data` is flattened into the top-level JSON object, so a response like
110/// `Response { id: "1", success: true, data: json!({"command": "pong"}) }`
111/// serializes to `{"id":"1","success":true,"command":"pong"}`.
112///
113/// # Honest reporting convention (tri-state)
114///
115/// Tools that search, check, or otherwise produce results MUST follow this
116/// convention so agents can distinguish "did the work, found nothing" from
117/// "couldn't do the work" from "partially did the work":
118///
119/// 1. **`success: false`** — the requested work could not be performed.
120/// Includes a `code` (e.g., `"path_not_found"`, `"no_lsp_server"`,
121/// `"project_too_large"`) and a human-readable `message`. The agent
122/// should treat this as an error and read the message.
123///
124/// 2. **`success: true` + completion signaling** — the work was performed.
125/// Tools must report whether the result is *complete* OR which subset
126/// was actually performed. Conventional fields:
127/// - `complete: true` — full result, agent can trust absence of items
128/// - `complete: false` + `pending_files: [...]` / `unchecked_files: [...]`
129/// / `scope_warnings: [...]` — partial result, with named gaps
130/// - `removed: true|false` (for mutations) — did the file actually change
131/// - `skipped_files: [{file, reason}]` — files we couldn't process inside
132/// the requested scope
133/// - `no_files_matched_scope: bool` — the scope (path/glob) found zero
134/// candidates (distinct from "candidates found, no matches")
135///
136/// 3. **Side-effect skip codes** — when the main work succeeded but a
137/// non-essential side step was skipped (e.g., post-write formatting),
138/// use a `<step>_skipped_reason` field. Approved values:
139/// - `format_skipped_reason`: `"unsupported_language"` |
140/// `"no_formatter_configured"` | `"formatter_not_installed"` |
141/// `"timeout"` | `"error"`
142/// - `validate_skipped_reason`: `"unsupported_language"` |
143/// `"no_checker_configured"` | `"checker_not_installed"` |
144/// `"timeout"` | `"error"`
145///
146/// **Anti-patterns to avoid:**
147/// - Returning `success: true` with empty results when the scope didn't
148/// resolve to any files — agent reads as "all clear" but really nothing
149/// was checked. Use `no_files_matched_scope: true` or
150/// `success: false, code: "path_not_found"`.
151/// - Reusing `format_skipped_reason: "not_found"` for two different causes
152/// ("no formatter configured" vs "configured formatter binary missing").
153/// The agent can't act on the ambiguous code.
154///
155/// See ARCHITECTURE.md "Honest reporting convention" for the full rationale.
156#[derive(Debug, Serialize)]
157pub struct Response {
158 pub id: String,
159 pub success: bool,
160 #[serde(flatten)]
161 pub data: serde_json::Value,
162}
163
164/// Parameters for the `echo` command.
165#[derive(Debug, Deserialize)]
166pub struct EchoParams {
167 pub message: String,
168}
169
170impl Response {
171 /// Build a success response with arbitrary data merged at the top level.
172 pub fn success(id: impl Into<String>, data: serde_json::Value) -> Self {
173 Response {
174 id: id.into(),
175 success: true,
176 data,
177 }
178 }
179
180 /// Build an error response with `code` and `message` fields.
181 pub fn error(id: impl Into<String>, code: &str, message: impl Into<String>) -> Self {
182 Response {
183 id: id.into(),
184 success: false,
185 data: serde_json::json!({
186 "code": code,
187 "message": message.into(),
188 }),
189 }
190 }
191
192 /// Build an error response with `code`, `message`, and additional structured data.
193 ///
194 /// The `extra` fields are merged into the top-level response alongside `code` and `message`.
195 pub fn error_with_data(
196 id: impl Into<String>,
197 code: &str,
198 message: impl Into<String>,
199 extra: serde_json::Value,
200 ) -> Self {
201 let mut data = serde_json::json!({
202 "code": code,
203 "message": message.into(),
204 });
205 if let (Some(base), Some(ext)) = (data.as_object_mut(), extra.as_object()) {
206 for (k, v) in ext {
207 base.insert(k.clone(), v.clone());
208 }
209 }
210 Response {
211 id: id.into(),
212 success: false,
213 data,
214 }
215 }
216}