Skip to content

Commit bfa59ed

Browse files
AlexMikhalevAlex
authored andcommitted
feat(agent): wire user-prompt-submit hook into Terraphim AI and OpenCode plugins Refs #674
1 parent fc7167d commit bfa59ed

6 files changed

Lines changed: 441 additions & 42 deletions

File tree

crates/terraphim_agent/src/learnings/hook.rs

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,11 @@ fn process_user_prompt_submit(json: &str) {
202202
None => return,
203203
};
204204

205-
// Look for correction patterns: "use X instead of Y", "don't use X", "prefer X over Y"
205+
// Look for correction patterns: "use X instead of Y", "use X not Y", "prefer X over Y"
206206
if let Some((original, corrected)) = parse_correction_pattern(prompt) {
207207
let config = LearningCaptureConfig::default();
208208
if let Err(e) = crate::learnings::capture_correction(
209-
crate::learnings::CorrectionType::Other("user-prompt".to_string()),
209+
crate::learnings::CorrectionType::ToolPreference,
210210
&original,
211211
&corrected,
212212
&format!("Auto-captured from user prompt: {}", prompt),
@@ -221,36 +221,59 @@ fn process_user_prompt_submit(json: &str) {
221221
///
222222
/// Supports patterns:
223223
/// - "use X instead of Y" -> (Y, X)
224+
/// - "use X not Y" -> (Y, X)
224225
/// - "prefer X over Y" -> (Y, X)
225226
///
226227
/// Returns None if no pattern matches.
227228
fn parse_correction_pattern(text: &str) -> Option<(String, String)> {
228229
let lower = text.to_lowercase();
229-
230-
// "use X instead of Y"
231-
if let Some(use_idx) = lower.find("use ") {
232-
if let Some(instead_idx) = lower.find(" instead of ") {
233-
let corrected = text[use_idx + 4..instead_idx].trim().to_string();
234-
let original = text[instead_idx + 12..]
235-
.trim()
236-
.trim_end_matches('.')
237-
.to_string();
238-
if !corrected.is_empty() && !original.is_empty() {
239-
return Some((original, corrected));
230+
let trimmed = lower.trim_start();
231+
232+
// "use X instead of Y" (must start with "use")
233+
if let Some(use_idx) = trimmed.find("use ") {
234+
if use_idx == 0 {
235+
let text_after_use =
236+
&text[text.to_lowercase().trim_start().find("use ").unwrap() + 4..];
237+
let lower_after_use = text_after_use.to_lowercase();
238+
if let Some(instead_idx) = lower_after_use.find(" instead of ") {
239+
let corrected = text_after_use[..instead_idx].trim().to_string();
240+
let original = text_after_use[instead_idx + 12..]
241+
.trim()
242+
.trim_end_matches('.')
243+
.to_string();
244+
if !corrected.is_empty() && !original.is_empty() {
245+
return Some((original, corrected));
246+
}
247+
}
248+
// "use X not Y"
249+
if let Some(not_idx) = lower_after_use.find(" not ") {
250+
let corrected = text_after_use[..not_idx].trim().to_string();
251+
let original = text_after_use[not_idx + 5..]
252+
.trim()
253+
.trim_end_matches('.')
254+
.to_string();
255+
if !corrected.is_empty() && !original.is_empty() {
256+
return Some((original, corrected));
257+
}
240258
}
241259
}
242260
}
243261

244-
// "prefer X over Y"
245-
if let Some(prefer_idx) = lower.find("prefer ") {
246-
if let Some(over_idx) = lower.find(" over ") {
247-
let corrected = text[prefer_idx + 7..over_idx].trim().to_string();
248-
let original = text[over_idx + 6..]
249-
.trim()
250-
.trim_end_matches('.')
251-
.to_string();
252-
if !corrected.is_empty() && !original.is_empty() {
253-
return Some((original, corrected));
262+
// "prefer X over Y" (must start with "prefer")
263+
if let Some(prefer_idx) = trimmed.find("prefer ") {
264+
if prefer_idx == 0 {
265+
let text_after_prefer =
266+
&text[text.to_lowercase().trim_start().find("prefer ").unwrap() + 7..];
267+
let lower_after_prefer = text_after_prefer.to_lowercase();
268+
if let Some(over_idx) = lower_after_prefer.find(" over ") {
269+
let corrected = text_after_prefer[..over_idx].trim().to_string();
270+
let original = text_after_prefer[over_idx + 6..]
271+
.trim()
272+
.trim_end_matches('.')
273+
.to_string();
274+
if !corrected.is_empty() && !original.is_empty() {
275+
return Some((original, corrected));
276+
}
254277
}
255278
}
256279
}
@@ -763,10 +786,18 @@ mod tests {
763786
);
764787
}
765788

789+
#[test]
790+
fn test_parse_correction_pattern_use_not() {
791+
let result = parse_correction_pattern("use uv not pip");
792+
assert_eq!(result, Some(("pip".to_string(), "uv".to_string())));
793+
}
794+
766795
#[test]
767796
fn test_parse_correction_pattern_no_match() {
768797
assert!(parse_correction_pattern("hello world").is_none());
769798
assert!(parse_correction_pattern("this is fine").is_none());
799+
// "I prefer tea over coffee" is a preference, not a tool correction
800+
assert!(parse_correction_pattern("I prefer tea over coffee").is_none());
770801
}
771802

772803
#[test]
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
//! Integration tests for user-prompt-submit hook patterns.
2+
//!
3+
//! Tests that `terraphim-agent learn hook --learn-hook-type user-prompt-submit`
4+
//! correctly captures tool preference corrections from user prompts and writes
5+
//! `CorrectionType::ToolPreference` files.
6+
7+
use std::path::Path;
8+
use std::process::{Command, Stdio};
9+
10+
fn agent_binary() -> String {
11+
let output = Command::new("cargo")
12+
.args(["build", "-p", "terraphim_agent"])
13+
.output()
14+
.expect("cargo build should succeed");
15+
if !output.status.success() {
16+
panic!(
17+
"cargo build failed: {}",
18+
String::from_utf8_lossy(&output.stderr)
19+
);
20+
}
21+
22+
let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
23+
.parent()
24+
.unwrap()
25+
.parent()
26+
.unwrap();
27+
workspace_root
28+
.join("target/debug/terraphim-agent")
29+
.to_string_lossy()
30+
.to_string()
31+
}
32+
33+
/// Run the user-prompt-submit hook with a JSON payload, returning whether it succeeded.
34+
fn run_user_prompt_submit(binary: &str, prompt: &str, env_home: &str) -> bool {
35+
let json = format!(r#"{{"user_prompt":"{}"}}"#, prompt);
36+
let output = Command::new(binary)
37+
.args(["learn", "hook", "--learn-hook-type", "user-prompt-submit"])
38+
.env("HOME", env_home)
39+
.env("XDG_DATA_HOME", format!("{}/data", env_home))
40+
.stdin(Stdio::piped())
41+
.stdout(Stdio::piped())
42+
.stderr(Stdio::piped())
43+
.spawn()
44+
.expect("should spawn hook process")
45+
.communicate(json.into_bytes())
46+
.expect("should communicate with hook process");
47+
48+
output.status.success()
49+
}
50+
51+
trait Communicate {
52+
fn communicate(self, stdin: Vec<u8>) -> std::io::Result<std::process::Output>;
53+
}
54+
55+
impl Communicate for std::process::Child {
56+
fn communicate(mut self, stdin: Vec<u8>) -> std::io::Result<std::process::Output> {
57+
use std::io::Write;
58+
if let Some(mut child_stdin) = self.stdin.take() {
59+
child_stdin.write_all(&stdin)?;
60+
}
61+
self.wait_with_output()
62+
}
63+
}
64+
65+
/// Return all correction markdown files in the learnings directory.
66+
fn find_correction_files(home: &str) -> Vec<std::path::PathBuf> {
67+
let learnings_dir = Path::new(home)
68+
.join("data")
69+
.join("terraphim")
70+
.join("learnings");
71+
if !learnings_dir.exists() {
72+
return vec![];
73+
}
74+
std::fs::read_dir(&learnings_dir)
75+
.expect("should read learnings dir")
76+
.filter_map(|entry| entry.ok().map(|e| e.path()))
77+
.filter(|path| {
78+
path.file_name()
79+
.and_then(|n| n.to_str())
80+
.map_or(false, |name| {
81+
name.starts_with("correction-") && name.ends_with(".md")
82+
})
83+
})
84+
.collect()
85+
}
86+
87+
/// Clear all correction files from a previous test run.
88+
fn clear_correction_files(home: &str) {
89+
for path in find_correction_files(home) {
90+
let _ = std::fs::remove_file(path);
91+
}
92+
}
93+
94+
#[test]
95+
fn user_prompt_submit_use_instead_of_creates_tool_preference() {
96+
let binary = agent_binary();
97+
let tmp = tempfile::tempdir().expect("create temp dir");
98+
let home = tmp.path().to_string_lossy().to_string();
99+
100+
clear_correction_files(&home);
101+
let success = run_user_prompt_submit(&binary, "use uv instead of pip", &home);
102+
assert!(success, "hook should exit 0");
103+
104+
let files = find_correction_files(&home);
105+
assert_eq!(
106+
files.len(),
107+
1,
108+
"expected exactly one correction file, found: {:?}",
109+
files
110+
);
111+
112+
let content = std::fs::read_to_string(&files[0]).expect("should read correction file");
113+
assert!(
114+
content.contains("tool-preference"),
115+
"correction should be ToolPreference, got:\n{}",
116+
content
117+
);
118+
assert!(
119+
content.contains("uv"),
120+
"correction should contain corrected tool 'uv', got:\n{}",
121+
content
122+
);
123+
assert!(
124+
content.contains("pip"),
125+
"correction should contain original tool 'pip', got:\n{}",
126+
content
127+
);
128+
}
129+
130+
#[test]
131+
fn user_prompt_submit_use_not_creates_tool_preference() {
132+
let binary = agent_binary();
133+
let tmp = tempfile::tempdir().expect("create temp dir");
134+
let home = tmp.path().to_string_lossy().to_string();
135+
136+
clear_correction_files(&home);
137+
let success = run_user_prompt_submit(&binary, "use cargo not make", &home);
138+
assert!(success, "hook should exit 0");
139+
140+
let files = find_correction_files(&home);
141+
assert_eq!(
142+
files.len(),
143+
1,
144+
"expected exactly one correction file, found: {:?}",
145+
files
146+
);
147+
148+
let content = std::fs::read_to_string(&files[0]).expect("should read correction file");
149+
assert!(
150+
content.contains("tool-preference"),
151+
"correction should be ToolPreference, got:\n{}",
152+
content
153+
);
154+
assert!(
155+
content.contains("cargo"),
156+
"correction should contain corrected tool 'cargo', got:\n{}",
157+
content
158+
);
159+
assert!(
160+
content.contains("make"),
161+
"correction should contain original tool 'make', got:\n{}",
162+
content
163+
);
164+
}
165+
166+
#[test]
167+
fn user_prompt_submit_prefer_over_creates_tool_preference() {
168+
let binary = agent_binary();
169+
let tmp = tempfile::tempdir().expect("create temp dir");
170+
let home = tmp.path().to_string_lossy().to_string();
171+
172+
clear_correction_files(&home);
173+
let success = run_user_prompt_submit(&binary, "prefer bunx over npx", &home);
174+
assert!(success, "hook should exit 0");
175+
176+
let files = find_correction_files(&home);
177+
assert_eq!(
178+
files.len(),
179+
1,
180+
"expected exactly one correction file, found: {:?}",
181+
files
182+
);
183+
184+
let content = std::fs::read_to_string(&files[0]).expect("should read correction file");
185+
assert!(
186+
content.contains("tool-preference"),
187+
"correction should be ToolPreference, got:\n{}",
188+
content
189+
);
190+
assert!(
191+
content.contains("bunx"),
192+
"correction should contain corrected tool 'bunx', got:\n{}",
193+
content
194+
);
195+
assert!(
196+
content.contains("npx"),
197+
"correction should contain original tool 'npx', got:\n{}",
198+
content
199+
);
200+
}
201+
202+
#[test]
203+
fn user_prompt_submit_personal_preference_does_not_capture() {
204+
let binary = agent_binary();
205+
let tmp = tempfile::tempdir().expect("create temp dir");
206+
let home = tmp.path().to_string_lossy().to_string();
207+
208+
clear_correction_files(&home);
209+
let success = run_user_prompt_submit(&binary, "I prefer tea over coffee", &home);
210+
assert!(success, "hook should exit 0 (fail-open)");
211+
212+
let files = find_correction_files(&home);
213+
assert!(
214+
files.is_empty(),
215+
"personal preference should NOT create a correction file, found: {:?}",
216+
files
217+
);
218+
}

0 commit comments

Comments
 (0)