Skip to main content

difflore_cli/hooks/
windsurf.rs

1//! Windsurf hook adapter.
2//!
3//! Windsurf wraps every hook payload in a common envelope keyed by
4//! `agent_action_name` with a `tool_info` object carrying per-event
5//! detail. The envelope also carries `trajectory_id` / `execution_id`
6//! as session IDs and (for command-like events) a `cwd`.
7//!
8//! Example stdin (`post_write_code`):
9//!
10//! ```json
11//! {
12//!   "agent_action_name": "post_write_code",
13//!   "trajectory_id": "...",
14//!   "execution_id": "...",
15//!   "timestamp": "...",
16//!   "tool_info": {
17//!     "file_path": "src/foo.ts",
18//!     "edits": [{ "old_string": "a", "new_string": "b" }]
19//!   }
20//! }
21//! ```
22//!
23//! Event mapping:
24//!
25//!   | Windsurf action          | Canonical event           |
26//!   |--------------------------|---------------------------|
27//!   | `pre_user_prompt`        | `UserPromptSubmit`        |
28//!   | `post_write_code`        | `PostToolUse { Write }`   |
29//!   | `post_run_command`       | `PostToolUse { Bash }`    |
30//!   | `post_mcp_tool_use`      | `PostToolUse { mcp_*  }`  |
31//!   | `post_cascade_response`  | `Stop`                    |
32//!   | `session_start` / `beforeAgentResponse` | `SessionStart` |
33//!   | anything else            | error (CLI no-ops)        |
34//!
35//! Windsurf exit codes: 0 = success, 2 = block (pre-hooks only). We
36//! never block, so the adapter output is always consumed with exit 0.
37//! Output contract: Windsurf ignores extra fields on advisory hooks,
38//! so we ship `{ "continue": true }` plus optional `context` / message
39//! fields for downstream builds that honour them.
40
41use serde::{Deserialize, Serialize};
42use serde_json::{Value, json};
43
44use super::synth;
45use super::types::{HookEvent, HookResult};
46use super::{PayloadAdapter, PlatformAdapter};
47
48/// Zero-sized marker — no adapter state.
49pub struct WindsurfAdapter;
50
51/// Typed view of Windsurf's stdin envelope. All fields optional; we
52/// reject only when `agent_action_name` is absent (malformed payload).
53#[derive(Debug, Clone, Deserialize, Serialize, Default)]
54#[serde(rename_all = "snake_case")]
55pub(crate) struct WindsurfHookPayload {
56    #[serde(default)]
57    agent_action_name: Option<String>,
58    #[serde(default)]
59    trajectory_id: Option<String>,
60    #[serde(default)]
61    execution_id: Option<String>,
62    #[serde(default)]
63    tool_info: Option<Value>,
64}
65
66impl WindsurfHookPayload {
67    fn into_canonical(self) -> Result<HookEvent, String> {
68        let action = self
69            .agent_action_name
70            .as_deref()
71            .ok_or_else(|| "missing agent_action_name".to_owned())?;
72        let info = self.tool_info.as_ref();
73        match action {
74            // SessionStart variants: Windsurf has historically used
75            // both names. Either triggers our warmup path.
76            "session_start" | "beforeAgentResponse" => Ok(HookEvent::SessionStart {
77                cwd: extract_cwd(info),
78                session_id: None,
79            }),
80            "pre_user_prompt" => Ok(HookEvent::UserPromptSubmit {
81                prompt: info
82                    .and_then(|v| v.get("user_prompt"))
83                    .and_then(|v| v.as_str())
84                    .unwrap_or_default()
85                    .to_owned(),
86                session_id: None,
87            }),
88            "post_write_code" => {
89                let new_text = info
90                    .and_then(|v| v.get("new_code").or_else(|| v.get("content")))
91                    .and_then(|v| v.as_str())
92                    .map(String::from);
93                let old_text = info
94                    .and_then(|v| v.get("old_code"))
95                    .and_then(|v| v.as_str())
96                    .map(String::from);
97                Ok(HookEvent::PostToolUse {
98                    tool_name: "Write".to_owned(),
99                    file_path: info
100                        .and_then(|v| v.get("file_path"))
101                        .and_then(|v| v.as_str())
102                        .map(String::from),
103                    diff: synthesise_write_diff(info),
104                    session_id: None,
105                    new_text,
106                    old_text,
107                })
108            }
109            "post_run_command" => Ok(HookEvent::PostToolUse {
110                tool_name: "Bash".to_owned(),
111                file_path: None,
112                diff: synthesise_command_diff(info),
113                session_id: None,
114                new_text: None,
115                old_text: None,
116            }),
117            "post_mcp_tool_use" => Ok(HookEvent::PostToolUse {
118                tool_name: info
119                    .and_then(|v| v.get("mcp_tool_name"))
120                    .and_then(|v| v.as_str())
121                    .unwrap_or("mcp_tool")
122                    .to_owned(),
123                file_path: None,
124                diff: synthesise_mcp_diff(info),
125                session_id: None,
126                new_text: None,
127                old_text: None,
128            }),
129            "post_cascade_response" => Ok(HookEvent::Stop {
130                session_id: None,
131                transcript_path: None,
132                cwd: None,
133            }),
134            other => Err(format!("unsupported Windsurf hook action: {other}")),
135        }
136    }
137}
138
139fn extract_cwd(info: Option<&Value>) -> String {
140    info.and_then(|v| v.get("cwd"))
141        .and_then(|v| v.as_str())
142        .unwrap_or_default()
143        .to_owned()
144}
145
146/// Synthesise a diff from `post_write_code`'s edits array. Falls
147/// back to `content` for whole-file writes if edits is missing.
148fn synthesise_write_diff(info: Option<&Value>) -> Option<String> {
149    let info = info?;
150    if let Some(edits) = info.get("edits").and_then(|v| v.as_array()) {
151        let mut out = String::new();
152        for edit in edits {
153            if let (Some(old), Some(new)) = (
154                edit.get("old_string").and_then(|v| v.as_str()),
155                edit.get("new_string").and_then(|v| v.as_str()),
156            ) {
157                synth::append_old_new(&mut out, old, new);
158            }
159        }
160        if !out.is_empty() {
161            return Some(out);
162        }
163    }
164    if let Some(content) = info.get("content").and_then(|v| v.as_str()) {
165        return Some(synth::diff_content(content));
166    }
167    None
168}
169
170/// Diff-like summary for `post_run_command`. Windsurf ships the
171/// command under `command_line` and no output, so this is just `$ cmd`.
172fn synthesise_command_diff(info: Option<&Value>) -> Option<String> {
173    let cmd = info?.get("command_line").and_then(|v| v.as_str())?;
174    synth::diff_shell(Some(cmd), None)
175}
176
177/// Summary for `post_mcp_tool_use`: we flatten the tool arguments and
178/// the result into a text blob so the retriever can match on keywords
179/// mentioned inside MCP tool I/O.
180fn synthesise_mcp_diff(info: Option<&Value>) -> Option<String> {
181    let info = info?;
182    let mut out = String::new();
183    if let Some(args) = info.get("mcp_tool_arguments") {
184        out.push_str("+ mcp_tool_arguments: ");
185        out.push_str(&args.to_string());
186        out.push('\n');
187    }
188    if let Some(res) = info.get("mcp_result") {
189        out.push_str("+ mcp_result: ");
190        out.push_str(&res.to_string());
191        out.push('\n');
192    }
193    if out.is_empty() { None } else { Some(out) }
194}
195
196impl PayloadAdapter for WindsurfAdapter {
197    type Raw = WindsurfHookPayload;
198    const PARSE_LABEL: &'static str = "Windsurf";
199
200    fn into_canonical(raw: Self::Raw) -> Result<HookEvent, String> {
201        raw.into_canonical()
202    }
203}
204
205impl PlatformAdapter for WindsurfAdapter {
206    fn name(&self) -> &'static str {
207        "windsurf"
208    }
209
210    fn parse_stdin(&self, raw: &str) -> Result<HookEvent, String> {
211        Self::parse_stdin_default(raw)
212    }
213
214    fn format_output(&self, result: HookResult) -> String {
215        // Advisory hooks in Windsurf ignore extra keys; we keep the
216        // body minimal and include `continue` so future Windsurf
217        // builds that treat absence as "block" still pass through.
218        let mut obj = json!({ "continue": result.continue_ });
219        if let Some(ctx) = result.additional_context {
220            // Matches the key surface we use for Cursor — if a future
221            // Windsurf build picks up `context`, we're already emitting it.
222            obj["context"] = Value::String(ctx);
223        }
224        if let Some(msg) = result.system_message {
225            obj["systemMessage"] = Value::String(msg);
226        }
227        crate::commands::util::json_compact_or(&obj, "{\"continue\":true}")
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn parse_before_agent_response_also_maps_to_session_start() {
237        let adapter = WindsurfAdapter;
238        let raw = r#"{"agent_action_name":"beforeAgentResponse","tool_info":{}}"#;
239        if let HookEvent::SessionStart { .. } = adapter.parse_stdin(raw).unwrap() {
240            // pass — cwd may be empty string, that's fine
241        } else {
242            panic!("expected SessionStart");
243        }
244    }
245
246    #[test]
247    fn parse_pre_user_prompt_extracts_prompt() {
248        let adapter = WindsurfAdapter;
249        let raw =
250            r#"{"agent_action_name":"pre_user_prompt","tool_info":{"user_prompt":"hi there"}}"#;
251        assert_eq!(
252            adapter.parse_stdin(raw).unwrap(),
253            HookEvent::UserPromptSubmit {
254                prompt: "hi there".into(),
255                session_id: None,
256            }
257        );
258    }
259
260    #[test]
261    fn parse_post_write_code_collects_edits_into_diff() {
262        let adapter = WindsurfAdapter;
263        let raw = r#"{
264            "agent_action_name": "post_write_code",
265            "tool_info": {
266                "file_path": "src/a.ts",
267                "edits": [
268                    { "old_string": "x", "new_string": "y" },
269                    { "old_string": "1", "new_string": "2" }
270                ]
271            }
272        }"#;
273        if let HookEvent::PostToolUse {
274            tool_name,
275            file_path,
276            diff,
277            ..
278        } = adapter.parse_stdin(raw).unwrap()
279        {
280            assert_eq!(tool_name, "Write");
281            assert_eq!(file_path.as_deref(), Some("src/a.ts"));
282            let d = diff.unwrap();
283            assert!(d.contains("-x") && d.contains("+y"));
284            assert!(d.contains("-1") && d.contains("+2"));
285        } else {
286            panic!("expected PostToolUse");
287        }
288    }
289
290    #[test]
291    fn parse_post_run_command_maps_to_bash() {
292        let adapter = WindsurfAdapter;
293        let raw = r#"{
294            "agent_action_name": "post_run_command",
295            "tool_info": { "command_line": "npm test", "cwd": "/w/p" }
296        }"#;
297        if let HookEvent::PostToolUse {
298            tool_name,
299            file_path,
300            diff,
301            ..
302        } = adapter.parse_stdin(raw).unwrap()
303        {
304            assert_eq!(tool_name, "Bash");
305            assert!(file_path.is_none());
306            assert_eq!(diff.as_deref(), Some("$ npm test\n"));
307        } else {
308            panic!("expected PostToolUse");
309        }
310    }
311
312    #[test]
313    fn parse_post_mcp_tool_use_preserves_tool_name() {
314        let adapter = WindsurfAdapter;
315        let raw = r#"{
316            "agent_action_name": "post_mcp_tool_use",
317            "tool_info": {
318                "mcp_server_name": "difflore",
319                "mcp_tool_name": "search_rules",
320                "mcp_tool_arguments": {"diff": "foo"},
321                "mcp_result": {"rules": []}
322            }
323        }"#;
324        if let HookEvent::PostToolUse {
325            tool_name, diff, ..
326        } = adapter.parse_stdin(raw).unwrap()
327        {
328            assert_eq!(tool_name, "search_rules");
329            let d = diff.unwrap();
330            assert!(d.contains("mcp_tool_arguments"));
331            assert!(d.contains("mcp_result"));
332        } else {
333            panic!("expected PostToolUse");
334        }
335    }
336
337    #[test]
338    fn parse_unknown_action_errors() {
339        let adapter = WindsurfAdapter;
340        let err = adapter
341            .parse_stdin(r#"{"agent_action_name":"post_future_thing","tool_info":{}}"#)
342            .unwrap_err();
343        assert!(err.contains("unsupported"), "got: {err}");
344    }
345
346    #[test]
347    fn parse_missing_action_errors() {
348        let adapter = WindsurfAdapter;
349        let err = adapter.parse_stdin(r"{}").unwrap_err();
350        assert!(err.contains("missing"), "got: {err}");
351    }
352
353    #[test]
354    fn format_output_noop_emits_continue() {
355        let adapter = WindsurfAdapter;
356        let out = adapter.format_output(HookResult::noop());
357        let v: Value = serde_json::from_str(&out).unwrap();
358        assert_eq!(v["continue"], true);
359    }
360
361    #[test]
362    fn format_output_with_context_adds_context_field() {
363        let adapter = WindsurfAdapter;
364        let out = adapter.format_output(HookResult::with_context("rule"));
365        let v: Value = serde_json::from_str(&out).unwrap();
366        assert_eq!(v["context"], "rule");
367    }
368}