Tighten tool parity for agent handoffs and notebook edits
Normalize Agent subagent aliases to Claude Code style built-in names, expose richer handoff metadata, teach ToolSearch to match canonical tool aliases, and polish NotebookEdit so delete does not require source and insert without a target appends cleanly. These are small parity-oriented behavior fixes confined to the tools crate.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Rework Agent into a real scheduler | outside this slice and not a small parity polish\nRejected: Add broad new tool surface area | request calls for small real parity improvements only\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep Agent built-in type normalization aligned with upstream naming aliases before expanding execution semantics\nTested: cargo test -p tools\nNot-tested: integration against a real upstream Claude Code runtime
This commit is contained in:
@@ -259,7 +259,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||||||
"cell_type": { "type": "string", "enum": ["code", "markdown"] },
|
"cell_type": { "type": "string", "enum": ["code", "markdown"] },
|
||||||
"edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] }
|
"edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] }
|
||||||
},
|
},
|
||||||
"required": ["notebook_path", "new_source"],
|
"required": ["notebook_path"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -481,7 +481,7 @@ struct ToolSearchInput {
|
|||||||
struct NotebookEditInput {
|
struct NotebookEditInput {
|
||||||
notebook_path: String,
|
notebook_path: String,
|
||||||
cell_id: Option<String>,
|
cell_id: Option<String>,
|
||||||
new_source: String,
|
new_source: Option<String>,
|
||||||
cell_type: Option<NotebookCellType>,
|
cell_type: Option<NotebookCellType>,
|
||||||
edit_mode: Option<NotebookEditMode>,
|
edit_mode: Option<NotebookEditMode>,
|
||||||
}
|
}
|
||||||
@@ -565,12 +565,17 @@ struct AgentOutput {
|
|||||||
status: String,
|
status: String,
|
||||||
#[serde(rename = "outputFile")]
|
#[serde(rename = "outputFile")]
|
||||||
output_file: String,
|
output_file: String,
|
||||||
|
#[serde(rename = "manifestFile")]
|
||||||
|
manifest_file: String,
|
||||||
|
#[serde(rename = "createdAt")]
|
||||||
|
created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct ToolSearchOutput {
|
struct ToolSearchOutput {
|
||||||
matches: Vec<String>,
|
matches: Vec<String>,
|
||||||
query: String,
|
query: String,
|
||||||
|
normalized_query: String,
|
||||||
#[serde(rename = "total_deferred_tools")]
|
#[serde(rename = "total_deferred_tools")]
|
||||||
total_deferred_tools: usize,
|
total_deferred_tools: usize,
|
||||||
#[serde(rename = "pending_mcp_servers")]
|
#[serde(rename = "pending_mcp_servers")]
|
||||||
@@ -581,7 +586,7 @@ struct ToolSearchOutput {
|
|||||||
struct NotebookEditOutput {
|
struct NotebookEditOutput {
|
||||||
new_source: String,
|
new_source: String,
|
||||||
cell_id: Option<String>,
|
cell_id: Option<String>,
|
||||||
cell_type: NotebookCellType,
|
cell_type: Option<NotebookCellType>,
|
||||||
language: String,
|
language: String,
|
||||||
edit_mode: String,
|
edit_mode: String,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
@@ -1101,21 +1106,27 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
|
|||||||
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
|
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
|
||||||
let output_file = output_dir.join(format!("{agent_id}.md"));
|
let output_file = output_dir.join(format!("{agent_id}.md"));
|
||||||
let manifest_file = output_dir.join(format!("{agent_id}.json"));
|
let manifest_file = output_dir.join(format!("{agent_id}.json"));
|
||||||
|
let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
|
||||||
let agent_name = input
|
let agent_name = input
|
||||||
.name
|
.name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| slugify_agent_name(&input.description));
|
.unwrap_or_else(|| slugify_agent_name(&input.description));
|
||||||
|
let created_at = iso8601_now();
|
||||||
|
|
||||||
let output_contents = format!(
|
let output_contents = format!(
|
||||||
"# Agent Task\n\n- id: {}\n- name: {}\n- description: {}\n- subagent_type: {}\n\n## Prompt\n\n{}\n",
|
"# Agent Task
|
||||||
agent_id,
|
|
||||||
agent_name,
|
- id: {}
|
||||||
input.description,
|
- name: {}
|
||||||
input
|
- description: {}
|
||||||
.subagent_type
|
- subagent_type: {}
|
||||||
.clone()
|
- created_at: {}
|
||||||
.unwrap_or_else(|| String::from("general-purpose")),
|
|
||||||
input.prompt
|
## Prompt
|
||||||
|
|
||||||
|
{}
|
||||||
|
",
|
||||||
|
agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
|
||||||
);
|
);
|
||||||
std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
|
std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
@@ -1123,10 +1134,12 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
|
|||||||
agent_id,
|
agent_id,
|
||||||
name: agent_name,
|
name: agent_name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
subagent_type: input.subagent_type,
|
subagent_type: Some(normalized_subagent_type),
|
||||||
model: input.model,
|
model: input.model,
|
||||||
status: String::from("queued"),
|
status: String::from("queued"),
|
||||||
output_file: output_file.display().to_string(),
|
output_file: output_file.display().to_string(),
|
||||||
|
manifest_file: manifest_file.display().to_string(),
|
||||||
|
created_at,
|
||||||
};
|
};
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&manifest_file,
|
&manifest_file,
|
||||||
@@ -1141,11 +1154,13 @@ fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
|
|||||||
let deferred = deferred_tool_specs();
|
let deferred = deferred_tool_specs();
|
||||||
let max_results = input.max_results.unwrap_or(5).max(1);
|
let max_results = input.max_results.unwrap_or(5).max(1);
|
||||||
let query = input.query.trim().to_string();
|
let query = input.query.trim().to_string();
|
||||||
|
let normalized_query = normalize_tool_search_query(&query);
|
||||||
let matches = search_tool_specs(&query, max_results, &deferred);
|
let matches = search_tool_specs(&query, max_results, &deferred);
|
||||||
|
|
||||||
ToolSearchOutput {
|
ToolSearchOutput {
|
||||||
matches,
|
matches,
|
||||||
query,
|
query,
|
||||||
|
normalized_query,
|
||||||
total_deferred_tools: deferred.len(),
|
total_deferred_tools: deferred.len(),
|
||||||
pending_mcp_servers: None,
|
pending_mcp_servers: None,
|
||||||
}
|
}
|
||||||
@@ -1171,9 +1186,10 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|part| !part.is_empty())
|
.filter(|part| !part.is_empty())
|
||||||
.filter_map(|wanted| {
|
.filter_map(|wanted| {
|
||||||
|
let wanted = canonical_tool_token(wanted);
|
||||||
specs
|
specs
|
||||||
.iter()
|
.iter()
|
||||||
.find(|spec| spec.name.eq_ignore_ascii_case(wanted))
|
.find(|spec| canonical_tool_token(spec.name) == wanted)
|
||||||
.map(|spec| spec.name.to_string())
|
.map(|spec| spec.name.to_string())
|
||||||
})
|
})
|
||||||
.take(max_results)
|
.take(max_results)
|
||||||
@@ -1201,13 +1217,20 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|spec| {
|
.filter_map(|spec| {
|
||||||
let name = spec.name.to_lowercase();
|
let name = spec.name.to_lowercase();
|
||||||
let haystack = format!("{name} {}", spec.description.to_lowercase());
|
let canonical_name = canonical_tool_token(spec.name);
|
||||||
|
let normalized_description = normalize_tool_search_query(spec.description);
|
||||||
|
let haystack = format!(
|
||||||
|
"{name} {} {canonical_name}",
|
||||||
|
spec.description.to_lowercase()
|
||||||
|
);
|
||||||
|
let normalized_haystack = format!("{canonical_name} {normalized_description}");
|
||||||
if required.iter().any(|term| !haystack.contains(term)) {
|
if required.iter().any(|term| !haystack.contains(term)) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut score = 0_i32;
|
let mut score = 0_i32;
|
||||||
for term in &terms {
|
for term in &terms {
|
||||||
|
let canonical_term = canonical_tool_token(term);
|
||||||
if haystack.contains(term) {
|
if haystack.contains(term) {
|
||||||
score += 2;
|
score += 2;
|
||||||
}
|
}
|
||||||
@@ -1217,6 +1240,12 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
|
|||||||
if name.contains(term) {
|
if name.contains(term) {
|
||||||
score += 4;
|
score += 4;
|
||||||
}
|
}
|
||||||
|
if canonical_name == canonical_term {
|
||||||
|
score += 12;
|
||||||
|
}
|
||||||
|
if normalized_haystack.contains(&canonical_term) {
|
||||||
|
score += 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if score == 0 && !lowered.is_empty() {
|
if score == 0 && !lowered.is_empty() {
|
||||||
@@ -1226,7 +1255,7 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
scored.sort_by(|left, right| right.cmp(left));
|
scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
|
||||||
scored
|
scored
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(_, name)| name)
|
.map(|(_, name)| name)
|
||||||
@@ -1234,6 +1263,28 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_tool_search_query(query: &str) -> String {
|
||||||
|
query
|
||||||
|
.trim()
|
||||||
|
.split(|ch: char| ch.is_whitespace() || ch == ',')
|
||||||
|
.filter(|term| !term.is_empty())
|
||||||
|
.map(canonical_tool_token)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_tool_token(value: &str) -> String {
|
||||||
|
let mut canonical = value
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||||
|
.flat_map(char::to_lowercase)
|
||||||
|
.collect::<String>();
|
||||||
|
if let Some(stripped) = canonical.strip_suffix("tool") {
|
||||||
|
canonical = stripped.to_string();
|
||||||
|
}
|
||||||
|
canonical
|
||||||
|
}
|
||||||
|
|
||||||
fn agent_store_dir() -> Result<std::path::PathBuf, String> {
|
fn agent_store_dir() -> Result<std::path::PathBuf, String> {
|
||||||
if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") {
|
if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") {
|
||||||
return Ok(std::path::PathBuf::from(path));
|
return Ok(std::path::PathBuf::from(path));
|
||||||
@@ -1267,6 +1318,33 @@ fn slugify_agent_name(description: &str) -> String {
|
|||||||
out.trim_matches('-').chars().take(32).collect()
|
out.trim_matches('-').chars().take(32).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
|
||||||
|
let trimmed = subagent_type.map(str::trim).unwrap_or_default();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::from("general-purpose");
|
||||||
|
}
|
||||||
|
|
||||||
|
match canonical_tool_token(trimmed).as_str() {
|
||||||
|
"general" | "generalpurpose" | "generalpurposeagent" => String::from("general-purpose"),
|
||||||
|
"explore" | "explorer" | "exploreagent" => String::from("Explore"),
|
||||||
|
"plan" | "planagent" => String::from("Plan"),
|
||||||
|
"verification" | "verificationagent" | "verify" | "verifier" => {
|
||||||
|
String::from("Verification")
|
||||||
|
}
|
||||||
|
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"),
|
||||||
|
"statusline" | "statuslinesetup" => String::from("statusline-setup"),
|
||||||
|
_ => trimmed.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn iso8601_now() -> String {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> {
|
fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> {
|
||||||
let path = std::path::PathBuf::from(&input.notebook_path);
|
let path = std::path::PathBuf::from(&input.notebook_path);
|
||||||
if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") {
|
if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") {
|
||||||
@@ -1291,38 +1369,35 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
|
|||||||
.ok_or_else(|| String::from("Notebook cells array not found"))?;
|
.ok_or_else(|| String::from("Notebook cells array not found"))?;
|
||||||
|
|
||||||
let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace);
|
let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace);
|
||||||
let target_index = resolve_cell_index(cells, input.cell_id.as_deref(), edit_mode)?;
|
let target_index = match input.cell_id.as_deref() {
|
||||||
let resolved_cell_type = input.cell_type.unwrap_or_else(|| {
|
Some(cell_id) => Some(resolve_cell_index(cells, Some(cell_id), edit_mode)?),
|
||||||
cells
|
None if matches!(
|
||||||
.get(target_index)
|
edit_mode,
|
||||||
.and_then(|cell| cell.get("cell_type"))
|
NotebookEditMode::Replace | NotebookEditMode::Delete
|
||||||
.and_then(serde_json::Value::as_str)
|
) =>
|
||||||
.map(|kind| {
|
{
|
||||||
if kind == "markdown" {
|
Some(resolve_cell_index(cells, None, edit_mode)?)
|
||||||
NotebookCellType::Markdown
|
}
|
||||||
} else {
|
None => None,
|
||||||
NotebookCellType::Code
|
};
|
||||||
}
|
let resolved_cell_type = match edit_mode {
|
||||||
})
|
NotebookEditMode::Delete => None,
|
||||||
.unwrap_or(NotebookCellType::Code)
|
NotebookEditMode::Insert => Some(input.cell_type.unwrap_or(NotebookCellType::Code)),
|
||||||
});
|
NotebookEditMode::Replace => Some(input.cell_type.unwrap_or_else(|| {
|
||||||
|
target_index
|
||||||
|
.and_then(|index| cells.get(index))
|
||||||
|
.and_then(cell_kind)
|
||||||
|
.unwrap_or(NotebookCellType::Code)
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
let new_source = require_notebook_source(input.new_source, edit_mode)?;
|
||||||
|
|
||||||
let cell_id = match edit_mode {
|
let cell_id = match edit_mode {
|
||||||
NotebookEditMode::Insert => {
|
NotebookEditMode::Insert => {
|
||||||
|
let resolved_cell_type = resolved_cell_type.expect("insert cell type");
|
||||||
let new_id = make_cell_id(cells.len());
|
let new_id = make_cell_id(cells.len());
|
||||||
let new_cell = json!({
|
let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
|
||||||
"cell_type": match resolved_cell_type { NotebookCellType::Code => "code", NotebookCellType::Markdown => "markdown" },
|
let insert_at = target_index.map_or(cells.len(), |index| index + 1);
|
||||||
"id": new_id,
|
|
||||||
"metadata": {},
|
|
||||||
"source": source_lines(&input.new_source),
|
|
||||||
"outputs": [],
|
|
||||||
"execution_count": serde_json::Value::Null,
|
|
||||||
});
|
|
||||||
let insert_at = if input.cell_id.is_some() {
|
|
||||||
target_index + 1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
cells.insert(insert_at, new_cell);
|
cells.insert(insert_at, new_cell);
|
||||||
cells
|
cells
|
||||||
.get(insert_at)
|
.get(insert_at)
|
||||||
@@ -1331,21 +1406,38 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
|
|||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
}
|
}
|
||||||
NotebookEditMode::Delete => {
|
NotebookEditMode::Delete => {
|
||||||
let removed = cells.remove(target_index);
|
let removed = cells.remove(target_index.expect("delete target index"));
|
||||||
removed
|
removed
|
||||||
.get("id")
|
.get("id")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
}
|
}
|
||||||
NotebookEditMode::Replace => {
|
NotebookEditMode::Replace => {
|
||||||
|
let resolved_cell_type = resolved_cell_type.expect("replace cell type");
|
||||||
let cell = cells
|
let cell = cells
|
||||||
.get_mut(target_index)
|
.get_mut(target_index.expect("replace target index"))
|
||||||
.ok_or_else(|| String::from("Cell index out of range"))?;
|
.ok_or_else(|| String::from("Cell index out of range"))?;
|
||||||
cell["source"] = serde_json::Value::Array(source_lines(&input.new_source));
|
cell["source"] = serde_json::Value::Array(source_lines(&new_source));
|
||||||
cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
|
cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
|
||||||
NotebookCellType::Code => String::from("code"),
|
NotebookCellType::Code => String::from("code"),
|
||||||
NotebookCellType::Markdown => String::from("markdown"),
|
NotebookCellType::Markdown => String::from("markdown"),
|
||||||
});
|
});
|
||||||
|
match resolved_cell_type {
|
||||||
|
NotebookCellType::Code => {
|
||||||
|
if !cell.get("outputs").is_some_and(serde_json::Value::is_array) {
|
||||||
|
cell["outputs"] = json!([]);
|
||||||
|
}
|
||||||
|
if !cell.get("execution_count").is_some() {
|
||||||
|
cell["execution_count"] = serde_json::Value::Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NotebookCellType::Markdown => {
|
||||||
|
if let Some(object) = cell.as_object_mut() {
|
||||||
|
object.remove("outputs");
|
||||||
|
object.remove("execution_count");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
cell.get("id")
|
cell.get("id")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
@@ -1357,7 +1449,7 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
|
|||||||
std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?;
|
std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?;
|
||||||
|
|
||||||
Ok(NotebookEditOutput {
|
Ok(NotebookEditOutput {
|
||||||
new_source: input.new_source,
|
new_source,
|
||||||
cell_id,
|
cell_id,
|
||||||
cell_type: resolved_cell_type,
|
cell_type: resolved_cell_type,
|
||||||
language,
|
language,
|
||||||
@@ -1369,6 +1461,51 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn require_notebook_source(
|
||||||
|
source: Option<String>,
|
||||||
|
edit_mode: NotebookEditMode,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
match edit_mode {
|
||||||
|
NotebookEditMode::Delete => Ok(source.unwrap_or_default()),
|
||||||
|
NotebookEditMode::Insert | NotebookEditMode::Replace => source
|
||||||
|
.ok_or_else(|| String::from("new_source is required for insert and replace edits")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_notebook_cell(cell_id: &str, cell_type: NotebookCellType, source: &str) -> Value {
|
||||||
|
let mut cell = json!({
|
||||||
|
"cell_type": match cell_type {
|
||||||
|
NotebookCellType::Code => "code",
|
||||||
|
NotebookCellType::Markdown => "markdown",
|
||||||
|
},
|
||||||
|
"id": cell_id,
|
||||||
|
"metadata": {},
|
||||||
|
"source": source_lines(source),
|
||||||
|
});
|
||||||
|
if let Some(object) = cell.as_object_mut() {
|
||||||
|
match cell_type {
|
||||||
|
NotebookCellType::Code => {
|
||||||
|
object.insert(String::from("outputs"), json!([]));
|
||||||
|
object.insert(String::from("execution_count"), Value::Null);
|
||||||
|
}
|
||||||
|
NotebookCellType::Markdown => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cell
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
|
||||||
|
cell.get("cell_type")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(|kind| {
|
||||||
|
if kind == "markdown" {
|
||||||
|
NotebookCellType::Markdown
|
||||||
|
} else {
|
||||||
|
NotebookCellType::Code
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn execute_sleep(input: SleepInput) -> SleepOutput {
|
fn execute_sleep(input: SleepInput) -> SleepOutput {
|
||||||
std::thread::sleep(Duration::from_millis(input.duration_ms));
|
std::thread::sleep(Duration::from_millis(input.duration_ms));
|
||||||
SleepOutput {
|
SleepOutput {
|
||||||
@@ -1551,7 +1688,7 @@ fn resolve_cell_index(
|
|||||||
.position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id))
|
.position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id))
|
||||||
.ok_or_else(|| format!("Cell id not found: {cell_id}"))
|
.ok_or_else(|| format!("Cell id not found: {cell_id}"))
|
||||||
} else {
|
} else {
|
||||||
Ok(0)
|
Ok(cells.len().saturating_sub(1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1787,6 +1924,20 @@ mod tests {
|
|||||||
serde_json::from_str(&selected).expect("valid json");
|
serde_json::from_str(&selected).expect("valid json");
|
||||||
assert_eq!(selected_output["matches"][0], "Agent");
|
assert_eq!(selected_output["matches"][0], "Agent");
|
||||||
assert_eq!(selected_output["matches"][1], "Skill");
|
assert_eq!(selected_output["matches"][1], "Skill");
|
||||||
|
|
||||||
|
let aliased = execute_tool("ToolSearch", &json!({"query": "AgentTool"}))
|
||||||
|
.expect("ToolSearch should support tool aliases");
|
||||||
|
let aliased_output: serde_json::Value = serde_json::from_str(&aliased).expect("valid json");
|
||||||
|
assert_eq!(aliased_output["matches"][0], "Agent");
|
||||||
|
assert_eq!(aliased_output["normalized_query"], "agent");
|
||||||
|
|
||||||
|
let selected_with_alias =
|
||||||
|
execute_tool("ToolSearch", &json!({"query": "select:AgentTool,Skill"}))
|
||||||
|
.expect("ToolSearch alias select should succeed");
|
||||||
|
let selected_with_alias_output: serde_json::Value =
|
||||||
|
serde_json::from_str(&selected_with_alias).expect("valid json");
|
||||||
|
assert_eq!(selected_with_alias_output["matches"][0], "Agent");
|
||||||
|
assert_eq!(selected_with_alias_output["matches"][1], "Skill");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1816,15 +1967,33 @@ mod tests {
|
|||||||
assert_eq!(output["name"], "ship-audit");
|
assert_eq!(output["name"], "ship-audit");
|
||||||
assert_eq!(output["subagentType"], "Explore");
|
assert_eq!(output["subagentType"], "Explore");
|
||||||
assert_eq!(output["status"], "queued");
|
assert_eq!(output["status"], "queued");
|
||||||
|
assert!(output["createdAt"].as_str().is_some());
|
||||||
|
let manifest_file = output["manifestFile"].as_str().expect("manifest file");
|
||||||
let output_file = output["outputFile"].as_str().expect("output file");
|
let output_file = output["outputFile"].as_str().expect("output file");
|
||||||
let contents = std::fs::read_to_string(output_file).expect("agent file exists");
|
let contents = std::fs::read_to_string(output_file).expect("agent file exists");
|
||||||
|
let manifest_contents =
|
||||||
|
std::fs::read_to_string(manifest_file).expect("manifest file exists");
|
||||||
assert!(contents.contains("Audit the branch"));
|
assert!(contents.contains("Audit the branch"));
|
||||||
assert!(contents.contains("Check tests and outstanding work."));
|
assert!(contents.contains("Check tests and outstanding work."));
|
||||||
|
assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
|
||||||
|
|
||||||
|
let normalized = execute_tool(
|
||||||
|
"Agent",
|
||||||
|
&json!({
|
||||||
|
"description": "Verify the branch",
|
||||||
|
"prompt": "Check tests.",
|
||||||
|
"subagent_type": "explorer"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("Agent should normalize built-in aliases");
|
||||||
|
let normalized_output: serde_json::Value =
|
||||||
|
serde_json::from_str(&normalized).expect("valid json");
|
||||||
|
assert_eq!(normalized_output["subagentType"], "Explore");
|
||||||
let _ = std::fs::remove_dir_all(dir);
|
let _ = std::fs::remove_dir_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn notebook_edit_replaces_and_inserts_cells() {
|
fn notebook_edit_replaces_inserts_and_deletes_cells() {
|
||||||
let path = std::env::temp_dir().join(format!(
|
let path = std::env::temp_dir().join(format!(
|
||||||
"clawd-notebook-{}.ipynb",
|
"clawd-notebook-{}.ipynb",
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
@@ -1872,9 +2041,40 @@ mod tests {
|
|||||||
.expect("NotebookEdit insert should succeed");
|
.expect("NotebookEdit insert should succeed");
|
||||||
let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json");
|
let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json");
|
||||||
assert_eq!(inserted_output["cell_type"], "markdown");
|
assert_eq!(inserted_output["cell_type"], "markdown");
|
||||||
let final_notebook = std::fs::read_to_string(&path).expect("read notebook");
|
let appended = execute_tool(
|
||||||
assert!(final_notebook.contains("print(2)"));
|
"NotebookEdit",
|
||||||
assert!(final_notebook.contains("# heading"));
|
&json!({
|
||||||
|
"notebook_path": path.display().to_string(),
|
||||||
|
"new_source": "print(3)\n",
|
||||||
|
"edit_mode": "insert"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("NotebookEdit append should succeed");
|
||||||
|
let appended_output: serde_json::Value = serde_json::from_str(&appended).expect("json");
|
||||||
|
assert_eq!(appended_output["cell_type"], "code");
|
||||||
|
|
||||||
|
let deleted = execute_tool(
|
||||||
|
"NotebookEdit",
|
||||||
|
&json!({
|
||||||
|
"notebook_path": path.display().to_string(),
|
||||||
|
"cell_id": "cell-a",
|
||||||
|
"edit_mode": "delete"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("NotebookEdit delete should succeed without new_source");
|
||||||
|
let deleted_output: serde_json::Value = serde_json::from_str(&deleted).expect("json");
|
||||||
|
assert!(deleted_output["cell_type"].is_null());
|
||||||
|
assert_eq!(deleted_output["new_source"], "");
|
||||||
|
|
||||||
|
let final_notebook: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook"))
|
||||||
|
.expect("valid notebook json");
|
||||||
|
let cells = final_notebook["cells"].as_array().expect("cells array");
|
||||||
|
assert_eq!(cells.len(), 2);
|
||||||
|
assert_eq!(cells[0]["cell_type"], "markdown");
|
||||||
|
assert!(cells[0].get("outputs").is_none());
|
||||||
|
assert_eq!(cells[1]["cell_type"], "code");
|
||||||
|
assert_eq!(cells[1]["source"][0], "print(3)\n");
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user