use std::path::PathBuf;
use std::sync::Arc;
use seher::sdk::{SeherTool, ToolHandler};
use serde_json::json;
use crate::ask_handler::AskHandler;
pub const ASK_USER_TOOL: &str = "ask_user";
pub const SUBMIT_PLAN_TOOL: &str = "submit_plan";
pub const UPDATE_PLAN_TOOL: &str = "update_plan";
pub const GENERATE_TITLE_TOOL: &str = "generate_title";
pub const SUBMIT_PR_METADATA_TOOL: &str = "submit_pr_metadata";
#[must_use]
pub fn planning_tools(
plan_path: PathBuf,
ask: Arc<dyn AskHandler>,
interactive: bool,
) -> Vec<SeherTool> {
let mut tools = Vec::new();
if interactive {
tools.push(ask_user_tool(ask));
}
tools.push(submit_plan_tool(plan_path.clone()));
if interactive {
tools.push(update_plan_tool(plan_path));
}
tools
}
#[must_use]
pub fn ask_user_tool(ask: Arc<dyn AskHandler>) -> SeherTool {
let handler: ToolHandler = Arc::new(move |input: serde_json::Value| {
let question = require_str(&input, "question")?;
ask.ask_user(question).map_err(|e| e.to_string())
});
SeherTool::new(
ASK_USER_TOOL,
"Ask the user a clarifying question and get their answer. Use this whenever a \
requirement is ambiguous instead of guessing.",
json!({
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user."
}
},
"required": ["question"]
}),
handler,
)
}
#[must_use]
pub fn submit_plan_tool(plan_path: PathBuf) -> SeherTool {
let handler: ToolHandler = Arc::new(move |input: serde_json::Value| {
let content = require_str(&input, "content")?;
write_plan(&plan_path, content)
});
SeherTool::new(
SUBMIT_PLAN_TOOL,
"Submit the complete implementation plan as markdown. Call this once the plan is \
ready; it overwrites the plan document.",
json!({
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The full plan, as markdown."
}
},
"required": ["content"]
}),
handler,
)
}
#[must_use]
pub fn update_plan_tool(plan_path: PathBuf) -> SeherTool {
let handler: ToolHandler = Arc::new(move |input: serde_json::Value| {
let old = require_str(&input, "old")?;
let new = require_str(&input, "new")?;
let current = std::fs::read_to_string(&plan_path)
.map_err(|e| format!("failed to read plan at {}: {e}", plan_path.display()))?;
let updated = apply_update(¤t, old, new)?;
std::fs::write(&plan_path, &updated)
.map_err(|e| format!("failed to write plan at {}: {e}", plan_path.display()))?;
Ok("Plan updated.".to_string())
});
SeherTool::new(
UPDATE_PLAN_TOOL,
"Revise the existing plan by replacing an exact snippet. `old` must match a unique \
span of the current plan verbatim; if it does not match, re-read the plan and retry.",
json!({
"type": "object",
"properties": {
"old": {
"type": "string",
"description": "Exact text to replace (must occur exactly once)."
},
"new": {
"type": "string",
"description": "Replacement text."
}
},
"required": ["old", "new"]
}),
handler,
)
}
#[must_use]
pub fn generate_title_tool(title_store: Arc<std::sync::Mutex<Option<String>>>) -> SeherTool {
let handler: ToolHandler = Arc::new(move |input: serde_json::Value| {
let title = require_str(&input, "title")?;
let truncated: String = title.chars().take(80).collect();
*title_store
.lock()
.map_err(|e| format!("title store lock poisoned: {e}"))? =
Some(truncated.trim().to_string());
Ok("Title saved.".to_string())
});
SeherTool::new(
GENERATE_TITLE_TOOL,
"Submit a concise session title (maximum 80 characters). Call this exactly once.",
json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "A concise session title (max 80 characters)."
}
},
"required": ["title"]
}),
handler,
)
}
#[derive(Debug, Clone)]
pub struct PrMetadata {
pub title: String,
pub body: String,
}
#[must_use]
pub fn submit_pr_metadata_tool(store: Arc<std::sync::Mutex<Option<PrMetadata>>>) -> SeherTool {
let handler: ToolHandler = Arc::new(move |input: serde_json::Value| {
let title = require_str(&input, "title")?;
let body = require_str(&input, "body")?;
*store
.lock()
.map_err(|e| format!("PR metadata store lock poisoned: {e}"))? = Some(PrMetadata {
title: title.to_string(),
body: body.to_string(),
});
Ok("PR metadata saved.".to_string())
});
SeherTool::new(
SUBMIT_PR_METADATA_TOOL,
"Submit the PR title and description. Call this exactly once after reviewing the changes.",
json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "A concise PR title."
},
"body": {
"type": "string",
"description": "The PR description in markdown."
}
},
"required": ["title", "body"]
}),
handler,
)
}
fn require_str<'a>(input: &'a serde_json::Value, field: &str) -> Result<&'a str, String> {
input
.get(field)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("missing or non-string `{field}` argument"))
}
fn write_plan(plan_path: &std::path::Path, content: &str) -> Result<String, String> {
std::fs::write(plan_path, content)
.map(|()| "Plan saved.".to_string())
.map_err(|e| format!("failed to write plan at {}: {e}", plan_path.display()))
}
fn apply_update(current: &str, old: &str, new: &str) -> Result<String, String> {
if old.is_empty() {
return Err("`old` must not be empty".to_string());
}
let count = current.matches(old).count();
match count {
0 => Err(
"`old` text was not found in the current plan; re-read the plan and retry".to_string(),
),
1 => Ok(current.replacen(old, new, 1)),
n => Err(format!(
"`old` text matched {n} times; provide a longer, unique snippet"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ask_handler::ScriptedAskHandler;
use tempfile::TempDir;
fn invoke(tool: &SeherTool, input: serde_json::Value) -> Result<String, String> {
(tool.handler)(input)
}
#[test]
fn apply_update_replaces_unique_snippet() {
let out = apply_update("# Plan\nUse JWT auth.\n", "JWT", "session")
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(out, "# Plan\nUse session auth.\n");
}
#[test]
fn apply_update_errors_when_not_found() {
match apply_update("# Plan\n", "missing", "x") {
Err(err) => assert!(err.contains("not found"), "got: {err}"),
Ok(_) => panic!("expected error for stale snippet"),
}
}
#[test]
fn apply_update_errors_when_ambiguous() {
match apply_update("a a", "a", "b") {
Err(err) => assert!(err.contains("matched"), "got: {err}"),
Ok(_) => panic!("expected error for ambiguous snippet"),
}
}
#[test]
fn apply_update_errors_on_empty_old() {
assert!(apply_update("x", "", "y").is_err());
}
#[test]
fn submit_plan_writes_content_to_file() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let plan = tmp.path().join("plan.md");
let tool = submit_plan_tool(plan.clone());
let res = invoke(&tool, json!({"content": "# My Plan\nstep 1"}));
assert!(res.is_ok(), "got: {res:?}");
let written = std::fs::read_to_string(&plan).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(written, "# My Plan\nstep 1");
}
#[test]
fn submit_plan_errors_without_content() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let tool = submit_plan_tool(tmp.path().join("plan.md"));
assert!(invoke(&tool, json!({})).is_err());
}
#[test]
fn update_plan_edits_existing_file() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let plan = tmp.path().join("plan.md");
std::fs::write(&plan, "# Plan\nUse JWT.\n").unwrap_or_else(|e| panic!("{e:?}"));
let tool = update_plan_tool(plan.clone());
let res = invoke(&tool, json!({"old": "JWT", "new": "sessions"}));
assert!(res.is_ok(), "got: {res:?}");
let written = std::fs::read_to_string(&plan).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(written, "# Plan\nUse sessions.\n");
}
#[test]
fn update_plan_errors_on_stale_old() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let plan = tmp.path().join("plan.md");
std::fs::write(&plan, "# Plan\n").unwrap_or_else(|e| panic!("{e:?}"));
let tool = update_plan_tool(plan);
let res = invoke(&tool, json!({"old": "nope", "new": "x"}));
assert!(
res.is_err(),
"stale old should error so the agent can retry"
);
}
#[test]
fn ask_user_delegates_to_handler() {
let ask = Arc::new(ScriptedAskHandler::new(["the answer".to_string()]));
let tool = ask_user_tool(ask);
let res = invoke(&tool, json!({"question": "what?"}));
assert_eq!(res.unwrap_or_else(|e| panic!("{e}")), "the answer");
}
#[test]
fn ask_user_errors_without_question() {
let ask = Arc::new(ScriptedAskHandler::new(["x".to_string()]));
let tool = ask_user_tool(ask);
assert!(invoke(&tool, json!({})).is_err());
}
#[test]
fn planning_tools_interactive_has_three() {
let ask = Arc::new(ScriptedAskHandler::new(std::iter::empty()));
let tools = planning_tools(PathBuf::from("/tmp/plan.md"), ask, true);
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
vec![ASK_USER_TOOL, SUBMIT_PLAN_TOOL, UPDATE_PLAN_TOOL]
);
}
#[test]
fn planning_tools_noninteractive_has_submit_only() {
let ask = Arc::new(ScriptedAskHandler::new(std::iter::empty()));
let tools = planning_tools(PathBuf::from("/tmp/plan.md"), ask, false);
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec![SUBMIT_PLAN_TOOL]);
}
#[test]
fn generate_title_stores_title() {
let store = Arc::new(std::sync::Mutex::new(None::<String>));
let tool = generate_title_tool(Arc::clone(&store));
let res = invoke(&tool, json!({"title": "Add session titles"}));
assert!(res.is_ok(), "got: {res:?}");
assert_eq!(
store.lock().unwrap_or_else(|e| panic!("{e:?}")).as_deref(),
Some("Add session titles")
);
}
#[test]
fn generate_title_truncates_long_title() {
let store = Arc::new(std::sync::Mutex::new(None::<String>));
let tool = generate_title_tool(Arc::clone(&store));
let long = "a".repeat(100);
let res = invoke(&tool, json!({"title": long}));
assert!(res.is_ok(), "got: {res:?}");
assert_eq!(
store
.lock()
.unwrap_or_else(|e| panic!("{e:?}"))
.as_ref()
.unwrap_or_else(|| panic!("expected Some"))
.len(),
80
);
}
#[test]
fn generate_title_errors_without_title() {
let store = Arc::new(std::sync::Mutex::new(None::<String>));
let tool = generate_title_tool(store);
assert!(invoke(&tool, json!({})).is_err());
}
#[test]
fn submit_pr_metadata_stores_title_and_body() {
let store = Arc::new(std::sync::Mutex::new(None::<PrMetadata>));
let tool = submit_pr_metadata_tool(Arc::clone(&store));
let res = invoke(&tool, json!({"title": "fix: bug", "body": "Fixes #42"}));
assert!(res.is_ok(), "got: {res:?}");
let meta = store
.lock()
.unwrap_or_else(|e| panic!("{e:?}"))
.clone()
.unwrap_or_else(|| panic!("expected Some"));
assert_eq!(meta.title, "fix: bug");
assert_eq!(meta.body, "Fixes #42");
}
#[test]
fn submit_pr_metadata_errors_without_title() {
let store = Arc::new(std::sync::Mutex::new(None::<PrMetadata>));
let tool = submit_pr_metadata_tool(store);
assert!(invoke(&tool, json!({"body": "x"})).is_err());
}
#[test]
fn submit_pr_metadata_errors_without_body() {
let store = Arc::new(std::sync::Mutex::new(None::<PrMetadata>));
let tool = submit_pr_metadata_tool(store);
assert!(invoke(&tool, json!({"title": "x"})).is_err());
}
}