use std::collections::HashMap;
use std::io::Write;
use std::sync::{Arc, Mutex};
use once_cell::sync::Lazy;
use serde_json::{json, Value};
use crate::mcp::annotations;
use crate::mcp::progress::ProgressReporter;
use crate::mcp::protocol::{Tool, ToolCallResult};
use crate::mcp::server::WorkflowState;
use crate::mcp::tools::AppRegistry;
static WORKFLOW_TRACKER: Lazy<Mutex<crate::cross_app::CrossAppTracker>> =
Lazy::new(|| Mutex::new(crate::cross_app::CrossAppTracker::new()));
static WORKFLOW_RECORDER: Lazy<Mutex<crate::recording::WorkflowRecorder>> =
Lazy::new(|| Mutex::new(crate::recording::WorkflowRecorder::new()));
pub(crate) fn innovation_tools() -> Vec<Tool> {
vec![
tool_ax_query(),
tool_ax_app_profile(),
tool_ax_test_run(),
tool_ax_track_workflow(),
tool_ax_workflow_create(),
tool_ax_workflow_step(),
tool_ax_workflow_status(),
tool_ax_record(),
tool_ax_analyze(),
]
}
fn tool_ax_analyze() -> Tool {
Tool {
name: "ax_analyze",
title: "Accessibility Intelligence Engine",
description: "Analyze the current UI state: detect UI patterns (login forms, search bars, \
data tables, navigation, modals), infer app state (loading, idle, error, modal), \
and suggest next actions based on what the engine observes.",
input_schema: json!({
"type": "object",
"properties": {
"app": {
"type": "string",
"description": "App alias from ax_connect"
},
"focus": {
"type": "string",
"enum": ["patterns", "state", "actions", "all"],
"default": "all",
"description": "Which aspect to analyze: patterns, state, actions, or all"
}
},
"required": ["app"],
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"node_count": { "type": "integer" },
"app_state": { "type": "string" },
"patterns": {
"type": "array",
"items": {
"type": "object",
"properties": {
"pattern": { "type": "string" },
"confidence": { "type": "number" }
},
"required": ["pattern", "confidence"]
}
},
"suggestions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"action": { "type": "string" },
"tool": { "type": "string" },
"query": { "type": "string" }
},
"required": ["action", "tool"]
}
}
},
"required": ["node_count", "app_state", "patterns", "suggestions"]
}),
annotations: annotations::READ_ONLY,
}
}
fn tool_ax_workflow_create() -> Tool {
Tool {
name: "ax_workflow_create",
title: "Create a durable multi-step workflow",
description: "Create a durable multi-step workflow with automatic retry and \
checkpoint/resume. Define steps that click, type, wait, or assert. \
Steps are executed one at a time via ax_workflow_step.",
input_schema: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Unique workflow identifier"
},
"steps": {
"type": "array",
"description": "Ordered step definitions",
"items": {
"type": "object",
"properties": {
"id": { "type": "string", "description": "Step identifier" },
"action": { "type": "string", "enum": ["click", "type", "wait", "assert", "checkpoint"] },
"target": { "type": "string", "description": "Element query for click/type/wait/assert" },
"text": { "type": "string", "description": "Text to type (action=type only)" },
"max_retries": { "type": "integer", "default": 2 },
"timeout_ms": { "type": "integer", "default": 5000 }
},
"required": ["id", "action"]
}
}
},
"required": ["name"],
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"created": { "type": "boolean" },
"name": { "type": "string" },
"step_count": { "type": "integer" }
},
"required": ["created", "name"]
}),
annotations: annotations::ACTION,
}
}
fn tool_ax_workflow_step() -> Tool {
Tool {
name: "ax_workflow_step",
title: "Execute the next workflow step",
description: "Execute the next step in a durable workflow. Returns the step result \
and whether the workflow is complete. Call repeatedly until completed=true.",
input_schema: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Workflow name from ax_workflow_create"
}
},
"required": ["name"],
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"step_id": { "type": "string" },
"step_index": { "type": "integer" },
"completed": { "type": "boolean" },
"action": { "type": "string" },
"ok": { "type": "boolean" },
"message": { "type": "string" }
},
"required": ["completed"]
}),
annotations: annotations::ACTION,
}
}
fn tool_ax_workflow_status() -> Tool {
Tool {
name: "ax_workflow_status",
title: "Check workflow status",
description: "Check the status of a durable workflow: current step, completed steps, \
and overall progress.",
input_schema: json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Workflow name from ax_workflow_create"
}
},
"required": ["name"],
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"current_step": { "type": "integer" },
"total_steps": { "type": "integer" },
"completed": { "type": "boolean" },
"results_count":{ "type": "integer" }
},
"required": ["name", "current_step", "total_steps", "completed"]
}),
annotations: annotations::READ_ONLY,
}
}
fn tool_ax_query() -> Tool {
Tool {
name: "ax_query",
title: "Natural-language UI query",
description: "Ask natural-language questions about the current UI state. \
Examples: 'how many buttons are visible?', \
'is there a search field?', \
'what text is shown?', \
'describe the screen'.",
input_schema: json!({
"type": "object",
"properties": {
"app": { "type": "string", "description": "App alias from ax_connect" },
"query": {
"type": "string",
"description": "Natural-language question about the UI"
}
},
"required": ["app", "query"],
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"confidence": { "type": "number" },
"scene_description": { "type": "string" },
"matches": {
"type": "array",
"items": {
"type": "object",
"properties": {
"role": { "type": "string" },
"label": { "type": "string" },
"match_score": { "type": "number" },
"match_reason": { "type": "string" },
"bounds": {
"type": "array",
"items": { "type": "number" }
}
}
}
}
},
"required": ["confidence"]
}),
annotations: annotations::READ_ONLY,
}
}
fn tool_ax_app_profile() -> Tool {
Tool {
name: "ax_app_profile",
title: "Electron/web app metadata",
description: "Get known capabilities, CSS selectors, and CDP port for Electron/web apps. \
Returns profiles for VS Code, Slack, Chrome, Terminal, Finder, and similar apps. \
Use selectors to target elements via CDP; use shortcuts to send keyboard commands.",
input_schema: json!({
"type": "object",
"properties": {
"app": {
"type": "string",
"description": "App name (case-insensitive, e.g. 'VS Code', 'slack', 'vscode')"
}
},
"required": ["app"],
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"found": { "type": "boolean" },
"name": { "type": "string" },
"app_id": { "type": "string" },
"cdp_port": { "type": "integer" },
"capabilities": { "type": "array", "items": { "type": "string" } },
"selectors": { "type": "object" },
"shortcuts": { "type": "object" }
},
"required": ["found"]
}),
annotations: annotations::READ_ONLY,
}
}
fn tool_ax_test_run() -> Tool {
Tool {
name: "ax_test_run",
title: "Black-box test execution",
description: "Run a black-box test case against any macOS app via the accessibility tree. \
Provide test steps (launch, find_and_click, find_and_type, wait_for_element, screenshot) \
and assertions (element_exists, element_has_text, element_not_exists, screen_contains). \
Returns pass/fail with per-step details.",
input_schema: json!({
"type": "object",
"properties": {
"app": { "type": "string", "description": "Application name (e.g. 'TextEdit')" },
"test_name": { "type": "string", "description": "Human-readable test name" },
"steps": {
"type": "array",
"description": "Ordered list of test steps",
"items": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["launch", "find_and_click", "find_and_type", "wait_for_element", "screenshot"] },
"app": { "type": "string" },
"query": { "type": "string" },
"text": { "type": "string" },
"path": { "type": "string" },
"timeout_ms": { "type": "integer" }
},
"required": ["type"]
}
},
"assertions": {
"type": "array",
"description": "Assertions checked after all steps complete",
"items": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["element_exists", "element_has_text", "element_not_exists", "screen_contains"] },
"query": { "type": "string" },
"expected": { "type": "string" },
"needle": { "type": "string" }
},
"required": ["type"]
}
}
},
"required": ["app", "test_name"],
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"passed": { "type": "boolean" },
"test_name": { "type": "string" },
"steps_completed": { "type": "integer" },
"elapsed_ms": { "type": "integer" },
"failures": { "type": "array", "items": { "type": "string" } },
"screenshots": { "type": "array", "items": { "type": "string" } }
},
"required": ["passed", "test_name"]
}),
annotations: annotations::ACTION,
}
}
fn tool_ax_track_workflow() -> Tool {
Tool {
name: "ax_track_workflow",
title: "Cross-app workflow tracking",
description: "Track application transitions to detect workflow patterns. \
Call with action='record' each time you switch between apps. \
Use action='detect' to find repeated cross-app sequences. \
Use action='stats' for aggregate transition statistics.",
input_schema: json!({
"type": "object",
"properties": {
"app": { "type": "string", "description": "Application that gained focus" },
"action": {
"type": "string",
"enum": ["record", "detect", "stats"],
"default": "record",
"description": "record=log focus event; detect=find patterns; stats=summary"
},
"trigger": {
"type": "string",
"enum": ["user_switch", "automation", "notification", "unknown"],
"default": "unknown",
"description": "What caused the app switch (for 'record' action)"
},
"min_frequency": {
"type": "integer",
"default": 2,
"description": "Minimum occurrences to surface a workflow (for 'detect' action)"
}
},
"required": ["app"],
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"action": { "type": "string" },
"recorded": { "type": "boolean" },
"workflows": { "type": "array" },
"stats": { "type": "object" }
},
"required": ["action"]
}),
annotations: annotations::ACTION,
}
}
fn tool_ax_record() -> Tool {
Tool {
name: "ax_record",
title: "Record a UI interaction for test generation",
description: "Record a UI interaction for test generation. Call this after each action \
to build a replayable test script.\n\
\n\
Actions:\n\
- `start` — begin a new recording session (clears previous events)\n\
- `record` — append one interaction event to the session\n\
- `stop` — end the session and return all events as a replayable JSON script\n\
- `status` — report current recording state and event count",
input_schema: json!({
"type": "object",
"properties": {
"app": {
"type": "string",
"description": "App alias from ax_connect (used for labelling)"
},
"action": {
"type": "string",
"enum": ["start", "record", "stop", "status"],
"description": "Recording control action",
"default": "record"
},
"action_type": {
"type": "string",
"enum": ["click", "type", "assert"],
"description": "Type of UI interaction to record (required for action=record)"
},
"query": {
"type": "string",
"description": "Element label / role hint for the recorded interaction"
},
"text": {
"type": "string",
"description": "Text value for type interactions"
},
"value": {
"type": "string",
"description": "Expected value for assert interactions"
}
},
"required": ["app"],
"additionalProperties": false
}),
output_schema: json!({
"type": "object",
"properties": {
"recording": { "type": "boolean" },
"event_count": { "type": "integer" },
"action": { "type": "string" },
"events": { "type": "array" }
},
"required": ["action"]
}),
annotations: annotations::ACTION,
}
}
pub(crate) fn call_tool_innovation<W: Write>(
name: &str,
args: &Value,
registry: &Arc<AppRegistry>,
out: &mut W,
) -> Option<ToolCallResult> {
match name {
"ax_query" => Some(handle_ax_query(args, registry)),
"ax_app_profile" => Some(handle_ax_app_profile(args)),
"ax_test_run" => Some(handle_ax_test_run(args, out)),
"ax_track_workflow" => Some(handle_ax_track_workflow(args)),
"ax_record" => Some(handle_ax_record(args)),
"ax_analyze" => Some(handle_ax_analyze(args, registry)),
_ => None,
}
}
pub(crate) fn call_workflow_tool<W: Write>(
name: &str,
args: &Value,
workflows: &Arc<Mutex<HashMap<String, WorkflowState>>>,
out: &mut W,
) -> Option<ToolCallResult> {
match name {
"ax_workflow_create" => Some(handle_ax_workflow_create(args, workflows)),
"ax_workflow_step" => Some(handle_ax_workflow_step(args, workflows, out)),
"ax_workflow_status" => Some(handle_ax_workflow_status(args, workflows)),
_ => None,
}
}
fn handle_ax_record(args: &Value) -> ToolCallResult {
let Some(app) = args["app"].as_str() else {
return ToolCallResult::error("Missing required field: app");
};
let action = args["action"].as_str().unwrap_or("record");
let Ok(mut recorder) = WORKFLOW_RECORDER.lock() else {
return ToolCallResult::error("Recorder mutex poisoned");
};
match action {
"start" => {
recorder.start_recording();
ToolCallResult::ok(
json!({
"action": "start",
"recording": true,
"event_count": 0,
"app": app
})
.to_string(),
)
}
"stop" => {
let events = recorder.stop_recording();
let count = events.len();
let serialised = crate::recording::WorkflowRecorder::serialize(&events)
.unwrap_or_else(|_| "[]".to_string());
let events_val: Value =
serde_json::from_str(&serialised).unwrap_or(Value::Array(vec![]));
ToolCallResult::ok(
json!({
"action": "stop",
"recording": false,
"event_count": count,
"events": events_val
})
.to_string(),
)
}
"status" => ToolCallResult::ok(
json!({
"action": "status",
"recording": recorder.is_recording(),
"event_count": recorder.event_count()
})
.to_string(),
),
"record" => {
let Some(action_type) = args["action_type"].as_str() else {
return ToolCallResult::error(
"Missing required field: action_type (click|type|assert)",
);
};
let label = args["query"].as_str().unwrap_or("");
let recorded_action = match action_type {
"click" => crate::recording::RecordedAction::Click { x: 0.0, y: 0.0 },
"type" => crate::recording::RecordedAction::Type {
text: args["text"].as_str().unwrap_or("").to_owned(),
},
"assert" => crate::recording::RecordedAction::KeyPress {
key: args["value"].as_str().unwrap_or("").to_owned(),
modifiers: vec![],
},
other => {
return ToolCallResult::error(format!(
"Unknown action_type '{other}'. Expected: click, type, assert"
))
}
};
let event = crate::recording::RecordedEvent {
timestamp: 0,
action: recorded_action,
element_fingerprint: 0,
element_label: label.to_owned(),
element_role: String::new(),
};
recorder.record_event(event);
ToolCallResult::ok(
json!({
"action": "record",
"recording": recorder.is_recording(),
"event_count": recorder.event_count(),
"recorded_action_type": action_type,
"app": app
})
.to_string(),
)
}
other => ToolCallResult::error(format!(
"Unknown action '{other}'. Expected: start, record, stop, status"
)),
}
}
fn handle_ax_query(args: &Value, registry: &Arc<AppRegistry>) -> ToolCallResult {
let Some(app_name) = args["app"].as_str().map(str::to_string) else {
return ToolCallResult::error("Missing required field: app");
};
let Some(query) = args["query"].as_str().map(str::to_string) else {
return ToolCallResult::error("Missing required field: query");
};
registry
.with_app(&app_name, |app| {
let scene = match crate::intent::scan_scene(app.element) {
Ok(g) => g,
Err(e) => return ToolCallResult::error(format!("scan_scene failed: {e}")),
};
let result = crate::scene::SceneEngine::new().query(&query, &scene);
let matches_json: Vec<Value> = result
.matches
.iter()
.map(|m| {
let bounds = m.bounds.map(|(x, y, w, h)| json!([x, y, w, h]));
json!({
"role": m.element_role,
"label": m.element_label,
"path": m.element_path,
"match_score": m.match_score,
"match_reason": m.match_reason,
"bounds": bounds
})
})
.collect();
ToolCallResult::ok(
json!({
"confidence": result.confidence,
"scene_description": result.scene_description,
"matches": matches_json
})
.to_string(),
)
})
.unwrap_or_else(ToolCallResult::error)
}
fn handle_ax_app_profile(args: &Value) -> ToolCallResult {
let Some(app_name) = args["app"].as_str() else {
return ToolCallResult::error("Missing required field: app");
};
let registry = crate::electron_profiles::ProfileRegistry::with_builtins();
match registry.detect(app_name) {
Some(profile) => {
let capabilities: Vec<String> = profile
.capabilities
.iter()
.map(capability_to_str)
.collect();
let selectors: Value = profile
.selectors
.iter()
.fold(json!({}), |mut acc, (k, v)| {
acc[k] = json!(v);
acc
});
let shortcuts: Value = profile
.shortcuts
.iter()
.fold(json!({}), |mut acc, (k, v)| {
acc[k] = json!(v);
acc
});
ToolCallResult::ok(
json!({
"found": true,
"name": profile.name,
"app_id": profile.app_id,
"cdp_port": profile.cdp_port,
"capabilities": capabilities,
"selectors": selectors,
"shortcuts": shortcuts
})
.to_string(),
)
}
None => ToolCallResult::ok(
json!({
"found": false,
"name": app_name,
"message": "No built-in profile found. The app may still be automatable via ax_find/ax_click."
})
.to_string(),
),
}
}
fn capability_to_str(cap: &crate::electron_profiles::AppCapability) -> String {
use crate::electron_profiles::AppCapability;
match cap {
AppCapability::Chat => "chat".into(),
AppCapability::Email => "email".into(),
AppCapability::Calendar => "calendar".into(),
AppCapability::CodeEditor => "code_editor".into(),
AppCapability::Browser => "browser".into(),
AppCapability::Terminal => "terminal".into(),
AppCapability::FileManager => "file_manager".into(),
AppCapability::Custom(s) => format!("custom:{s}"),
}
}
fn handle_ax_test_run<W: Write>(args: &Value, out: &mut W) -> ToolCallResult {
let Some(app_name) = args["app"].as_str().map(str::to_string) else {
return ToolCallResult::error("Missing required field: app");
};
let Some(test_name) = args["test_name"].as_str().map(str::to_string) else {
return ToolCallResult::error("Missing required field: test_name");
};
let steps = parse_test_steps(&args["steps"]);
let assertions = parse_test_assertions(&args["assertions"]);
let total = (steps.len() + assertions.len()).max(1) as u32;
let mut reporter = ProgressReporter::new(out, total);
let _ = reporter.step(&format!("Running test '{test_name}'…"));
let case = crate::blackbox::TestCase {
name: test_name,
steps,
assertions,
};
let tester = crate::blackbox::BlackboxTester::new(&app_name);
let result = tester.run(&case);
let _ = reporter.complete("Test complete");
ToolCallResult::ok(
json!({
"passed": result.passed,
"test_name": result.name,
"steps_completed": result.steps_completed,
"elapsed_ms": result.elapsed_ms,
"failures": result.failures,
"screenshots": result.screenshots
})
.to_string(),
)
}
fn parse_test_steps(steps_val: &Value) -> Vec<crate::blackbox::TestStep> {
let Some(arr) = steps_val.as_array() else {
return vec![];
};
arr.iter().filter_map(parse_single_step).collect()
}
fn parse_single_step(s: &Value) -> Option<crate::blackbox::TestStep> {
use crate::blackbox::TestStep;
let kind = s["type"].as_str()?;
match kind {
"launch" => Some(TestStep::Launch {
app: s["app"].as_str()?.to_string(),
}),
"find_and_click" => Some(TestStep::FindAndClick {
query: s["query"].as_str()?.to_string(),
}),
"find_and_type" => Some(TestStep::FindAndType {
query: s["query"].as_str()?.to_string(),
text: s["text"].as_str()?.to_string(),
}),
"wait_for_element" => Some(TestStep::WaitForElement {
query: s["query"].as_str()?.to_string(),
timeout_ms: s["timeout_ms"].as_u64().unwrap_or(5_000),
}),
"screenshot" => Some(TestStep::Screenshot {
path: s["path"].as_str()?.to_string(),
}),
_ => None,
}
}
fn parse_test_assertions(assertions_val: &Value) -> Vec<crate::blackbox::TestAssertion> {
let Some(arr) = assertions_val.as_array() else {
return vec![];
};
arr.iter().filter_map(parse_single_assertion).collect()
}
fn parse_single_assertion(a: &Value) -> Option<crate::blackbox::TestAssertion> {
use crate::blackbox::TestAssertion;
let kind = a["type"].as_str()?;
match kind {
"element_exists" => Some(TestAssertion::ElementExists {
query: a["query"].as_str()?.to_string(),
}),
"element_has_text" => Some(TestAssertion::ElementHasText {
query: a["query"].as_str()?.to_string(),
expected: a["expected"].as_str()?.to_string(),
}),
"element_not_exists" => Some(TestAssertion::ElementNotExists {
query: a["query"].as_str()?.to_string(),
}),
"screen_contains" => Some(TestAssertion::ScreenContains {
needle: a["needle"].as_str()?.to_string(),
}),
_ => None,
}
}
fn handle_ax_track_workflow(args: &Value) -> ToolCallResult {
let Some(app_name) = args["app"].as_str() else {
return ToolCallResult::error("Missing required field: app");
};
let action = args["action"].as_str().unwrap_or("record");
match action {
"record" => handle_workflow_record(app_name, args),
"detect" => handle_workflow_detect(args),
"stats" => handle_workflow_stats(),
other => ToolCallResult::error(format!(
"Unknown action '{other}'. Expected: record, detect, stats"
)),
}
}
fn handle_workflow_record(app_name: &str, args: &Value) -> ToolCallResult {
let trigger = parse_transition_trigger(args["trigger"].as_str().unwrap_or("unknown"));
let Ok(mut tracker) = WORKFLOW_TRACKER.lock() else {
return ToolCallResult::error("Tracker mutex poisoned");
};
tracker.record_focus(app_name, trigger);
ToolCallResult::ok(
json!({
"action": "record",
"recorded": true,
"app": app_name
})
.to_string(),
)
}
fn handle_workflow_detect(args: &Value) -> ToolCallResult {
let min_frequency = args["min_frequency"].as_u64().unwrap_or(2) as u32;
let Ok(tracker) = WORKFLOW_TRACKER.lock() else {
return ToolCallResult::error("Tracker mutex poisoned");
};
let workflows = tracker.detect_workflows(min_frequency);
let workflows_json: Vec<Value> = workflows
.iter()
.map(|wf| {
let automation = crate::cross_app::CrossAppTracker::suggest_automation(wf)
.into_iter()
.map(|s| {
json!({
"app": s.app,
"description": s.description,
"step_index": s.step_index
})
})
.collect::<Vec<_>>();
json!({
"name": wf.name,
"apps": wf.apps,
"frequency": wf.frequency,
"avg_duration_ms": wf.avg_duration_ms,
"automation": automation
})
})
.collect();
ToolCallResult::ok(
json!({
"action": "detect",
"workflows": workflows_json
})
.to_string(),
)
}
fn handle_workflow_stats() -> ToolCallResult {
let Ok(tracker) = WORKFLOW_TRACKER.lock() else {
return ToolCallResult::error("Tracker mutex poisoned");
};
let stats = tracker.stats();
let top_transition = stats
.top_transition
.map(|(from, to)| json!({ "from": from, "to": to }));
ToolCallResult::ok(
json!({
"action": "stats",
"stats": {
"total_transitions": stats.total_transitions,
"distinct_apps": stats.distinct_apps,
"top_app": stats.top_app,
"top_transition": top_transition
}
})
.to_string(),
)
}
#[derive(Debug, Clone, PartialEq)]
struct UiPattern {
pattern: &'static str,
confidence: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AppState {
Idle,
Loading,
Error,
Modal,
AuthRequired,
}
impl AppState {
fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Loading => "loading",
Self::Error => "error",
Self::Modal => "modal",
Self::AuthRequired => "auth_required",
}
}
}
fn has_role(nodes: &[&crate::intent::SceneNode], role: &str) -> bool {
nodes.iter().any(|n| n.role.as_deref() == Some(role))
}
fn any_label_contains(nodes: &[&crate::intent::SceneNode], needle: &str) -> bool {
nodes.iter().any(|n| {
n.text_labels()
.iter()
.any(|l| l.to_lowercase().contains(needle))
})
}
fn detect_ui_patterns(scene: &crate::intent::SceneGraph) -> Vec<UiPattern> {
let nodes: Vec<&crate::intent::SceneNode> = scene.iter().collect();
let mut patterns = Vec::new();
let has_password = has_role(&nodes, "AXSecureTextField");
let has_text_field = has_role(&nodes, "AXTextField");
let has_button = has_role(&nodes, "AXButton");
if has_password && has_text_field && has_button {
patterns.push(UiPattern {
pattern: "login_form",
confidence: 0.90,
});
}
let has_search_field = has_role(&nodes, "AXSearchField");
let has_search_label = has_text_field && any_label_contains(&nodes, "search");
if has_search_field || has_search_label {
patterns.push(UiPattern {
pattern: "search_interface",
confidence: 0.85,
});
}
let has_tab_group = has_role(&nodes, "AXTabGroup");
let has_toolbar = has_role(&nodes, "AXToolbar");
if has_tab_group || has_toolbar {
patterns.push(UiPattern {
pattern: "navigation",
confidence: 0.80,
});
}
let has_table =
has_role(&nodes, "AXTable") || has_role(&nodes, "AXGrid") || has_role(&nodes, "AXOutline");
if has_table {
patterns.push(UiPattern {
pattern: "table_view",
confidence: 0.88,
});
}
let has_modal = has_role(&nodes, "AXSheet") || has_role(&nodes, "AXDialog");
if has_modal {
patterns.push(UiPattern {
pattern: "modal_dialog",
confidence: 0.95,
});
}
if has_modal && has_button {
let save_btn = any_label_contains(&nodes, "save");
let open_btn = any_label_contains(&nodes, "open");
let cancel_btn = any_label_contains(&nodes, "cancel");
if save_btn && cancel_btn {
patterns.push(UiPattern {
pattern: "file_save_dialog",
confidence: 0.88,
});
} else if open_btn && cancel_btn {
patterns.push(UiPattern {
pattern: "file_open_dialog",
confidence: 0.88,
});
}
}
let has_alert = has_role(&nodes, "AXAlert");
if has_alert && has_button {
let ok = any_label_contains(&nodes, "ok") || any_label_contains(&nodes, "yes");
let cancel = any_label_contains(&nodes, "cancel") || any_label_contains(&nodes, "no");
if ok && cancel {
patterns.push(UiPattern {
pattern: "confirmation_dialog",
confidence: 0.87,
});
} else {
patterns.push(UiPattern {
pattern: "error_alert",
confidence: 0.80,
});
}
}
let has_groups = scene.nodes_by_role("AXGroup").len() >= 3;
let has_checkboxes = has_role(&nodes, "AXCheckBox");
let has_popups = has_role(&nodes, "AXPopUpButton");
if has_groups && (has_checkboxes || has_popups) && !has_modal && !has_password {
patterns.push(UiPattern {
pattern: "settings_page",
confidence: 0.75,
});
}
let has_text_area = has_role(&nodes, "AXTextArea");
if has_text_area && (has_toolbar || nodes.len() > 10) {
patterns.push(UiPattern {
pattern: "text_editor",
confidence: 0.78,
});
}
let browser_addr = nodes.iter().any(|n| {
n.role.as_deref() == Some("AXTextField")
&& n.identifier
.as_deref()
.is_some_and(|id| id.contains("address") || id.contains("url"))
});
if browser_addr && has_tab_group {
patterns.push(UiPattern {
pattern: "browser_main",
confidence: 0.85,
});
}
let text_field_count = scene.nodes_by_role("AXTextField").len();
if text_field_count >= 2 && !has_password && has_button {
patterns.push(UiPattern {
pattern: "form",
confidence: 0.72,
});
}
let has_progress =
has_role(&nodes, "AXProgressIndicator") || has_role(&nodes, "AXBusyIndicator");
if has_progress {
patterns.push(UiPattern {
pattern: "progress_indicator",
confidence: 0.93,
});
}
patterns
}
fn infer_app_state(scene: &crate::intent::SceneGraph) -> AppState {
let nodes: Vec<&crate::intent::SceneNode> = scene.iter().collect();
if has_role(&nodes, "AXSheet") || has_role(&nodes, "AXDialog") {
return AppState::Modal;
}
let loading = has_role(&nodes, "AXProgressIndicator")
|| has_role(&nodes, "AXBusyIndicator")
|| any_label_contains(&nodes, "loading");
if loading {
return AppState::Loading;
}
let error = has_role(&nodes, "AXAlert")
|| any_label_contains(&nodes, "error")
|| any_label_contains(&nodes, "failed")
|| any_label_contains(&nodes, "invalid");
if error {
return AppState::Error;
}
if has_role(&nodes, "AXSecureTextField") {
return AppState::AuthRequired;
}
AppState::Idle
}
#[derive(Debug, Clone)]
struct Suggestion {
action: &'static str,
tool: &'static str,
query: &'static str,
}
fn suggest_actions(patterns: &[UiPattern], state: AppState) -> Vec<Suggestion> {
let mut suggestions: Vec<Suggestion> = Vec::new();
match state {
AppState::Modal => {
suggestions.push(Suggestion {
action: "Dismiss or interact with the modal dialog before continuing",
tool: "ax_click",
query: "Cancel",
});
}
AppState::Loading => {
suggestions.push(Suggestion {
action: "Wait for the app to finish loading",
tool: "ax_wait_idle",
query: "",
});
}
AppState::Error => {
suggestions.push(Suggestion {
action: "Acknowledge the error and check error details",
tool: "ax_get_value",
query: "error message",
});
}
AppState::AuthRequired => {
suggestions.push(Suggestion {
action: "Enter credentials to authenticate",
tool: "ax_type",
query: "username",
});
}
AppState::Idle => {}
}
let pattern_names: Vec<&str> = patterns.iter().map(|p| p.pattern).collect();
if pattern_names.contains(&"login_form") {
suggestions.push(Suggestion {
action: "Type your username into the text field",
tool: "ax_type",
query: "username",
});
suggestions.push(Suggestion {
action: "Type your password into the secure field",
tool: "ax_type",
query: "password",
});
suggestions.push(Suggestion {
action: "Click the sign-in button to submit credentials",
tool: "ax_click",
query: "Sign In",
});
}
if pattern_names.contains(&"search_interface") {
suggestions.push(Suggestion {
action: "Type your query into the search field",
tool: "ax_type",
query: "search",
});
}
if pattern_names.contains(&"file_save_dialog") {
suggestions.push(Suggestion {
action: "Type a filename and click Save to confirm",
tool: "ax_type",
query: "Save As",
});
suggestions.push(Suggestion {
action: "Click Save to confirm the file",
tool: "ax_click",
query: "Save",
});
}
if pattern_names.contains(&"file_open_dialog") {
suggestions.push(Suggestion {
action: "Navigate to the desired file and click Open",
tool: "ax_click",
query: "Open",
});
}
if pattern_names.contains(&"confirmation_dialog") {
suggestions.push(Suggestion {
action: "Confirm the action by clicking OK or Yes",
tool: "ax_click",
query: "OK",
});
suggestions.push(Suggestion {
action: "Cancel the action to dismiss the dialog",
tool: "ax_click",
query: "Cancel",
});
}
if pattern_names.contains(&"error_alert") {
suggestions.push(Suggestion {
action: "Dismiss the error alert",
tool: "ax_click",
query: "OK",
});
}
if pattern_names.contains(&"table_view") {
suggestions.push(Suggestion {
action: "Read the visible rows from the data table",
tool: "ax_get_value",
query: "table row",
});
}
if pattern_names.contains(&"text_editor") {
suggestions.push(Suggestion {
action: "Type or edit text in the editor area",
tool: "ax_type",
query: "text area",
});
}
if pattern_names.contains(&"form") {
suggestions.push(Suggestion {
action: "Fill in the form fields",
tool: "ax_type",
query: "text field",
});
suggestions.push(Suggestion {
action: "Submit the form",
tool: "ax_click",
query: "Submit",
});
}
suggestions
}
fn pattern_to_json(p: &UiPattern) -> Value {
json!({ "pattern": p.pattern, "confidence": p.confidence })
}
fn suggestion_to_json(s: &Suggestion) -> Value {
json!({ "action": s.action, "tool": s.tool, "query": s.query })
}
fn handle_ax_analyze(args: &Value, registry: &Arc<AppRegistry>) -> ToolCallResult {
let Some(app_name) = args["app"].as_str().map(str::to_string) else {
return ToolCallResult::error("Missing required field: app");
};
let focus = args["focus"].as_str().unwrap_or("all");
registry
.with_app(&app_name, |app| {
let scene = match crate::intent::scan_scene(app.element) {
Ok(g) => g,
Err(e) => return ToolCallResult::error(format!("scan_scene failed: {e}")),
};
let node_count = scene.len();
let patterns = detect_ui_patterns(&scene);
let state = infer_app_state(&scene);
let actions = suggest_actions(&patterns, state);
let patterns_json: Vec<Value> = patterns.iter().map(pattern_to_json).collect();
let suggestions_json: Vec<Value> = actions.iter().map(suggestion_to_json).collect();
let payload = match focus {
"patterns" => json!({
"node_count": node_count,
"app_state": state.as_str(),
"patterns": patterns_json,
"suggestions": []
}),
"state" => json!({
"node_count": node_count,
"app_state": state.as_str(),
"patterns": [],
"suggestions": []
}),
"actions" => json!({
"node_count": node_count,
"app_state": state.as_str(),
"patterns": [],
"suggestions": suggestions_json
}),
_ => json!({
"node_count": node_count,
"app_state": state.as_str(),
"patterns": patterns_json,
"suggestions": suggestions_json
}),
};
ToolCallResult::ok(payload.to_string())
})
.unwrap_or_else(ToolCallResult::error)
}
pub(crate) fn workflow_tracking_data() -> serde_json::Value {
let tracker = WORKFLOW_TRACKER.lock().unwrap_or_else(|e| e.into_inner());
let stats = tracker.stats();
let workflows = tracker.detect_workflows(2);
let top_transition = stats
.top_transition
.map(|(from, to)| json!({ "from": from, "to": to }));
let workflows_json: Vec<serde_json::Value> = workflows
.iter()
.map(|wf| {
json!({
"name": wf.name,
"apps": wf.apps,
"frequency": wf.frequency,
"avg_duration_ms": wf.avg_duration_ms,
})
})
.collect();
json!({
"workflows_detected": workflows_json.len(),
"workflows": workflows_json,
"stats": {
"total_transitions": stats.total_transitions,
"distinct_apps": stats.distinct_apps,
"top_app": stats.top_app,
"top_transition": top_transition,
},
})
}
fn parse_transition_trigger(s: &str) -> crate::cross_app::TransitionTrigger {
use crate::cross_app::TransitionTrigger;
match s {
"user_switch" => TransitionTrigger::UserSwitch,
"automation" => TransitionTrigger::Automation,
"notification" => TransitionTrigger::Notification,
_ => TransitionTrigger::Unknown,
}
}
fn handle_ax_workflow_create(
args: &Value,
workflows: &Arc<Mutex<HashMap<String, WorkflowState>>>,
) -> ToolCallResult {
let Some(name) = args["name"].as_str().map(str::to_string) else {
return ToolCallResult::error("Missing required field: name");
};
let steps = parse_workflow_steps(&args["steps"]);
let step_count = steps.len();
let state = WorkflowState {
steps,
current_step: 0,
results: Vec::new(),
completed: false,
};
match workflows.lock() {
Ok(mut guard) => {
guard.insert(name.clone(), state);
ToolCallResult::ok(
json!({
"created": true,
"name": name,
"step_count": step_count
})
.to_string(),
)
}
Err(_) => ToolCallResult::error("Workflow mutex poisoned"),
}
}
fn handle_ax_workflow_step<W: Write>(
args: &Value,
workflows: &Arc<Mutex<HashMap<String, WorkflowState>>>,
out: &mut W,
) -> ToolCallResult {
let Some(name) = args["name"].as_str() else {
return ToolCallResult::error("Missing required field: name");
};
let mut guard = match workflows.lock() {
Ok(g) => g,
Err(_) => return ToolCallResult::error("Workflow mutex poisoned"),
};
let Some(state) = guard.get_mut(name) else {
return ToolCallResult::error(format!(
"Workflow '{name}' not found — call ax_workflow_create first"
));
};
if state.completed {
return ToolCallResult::ok(
json!({
"step_id": null,
"step_index": state.current_step,
"completed": true,
"action": null,
"ok": true,
"message": "Workflow already completed"
})
.to_string(),
);
}
if state.current_step >= state.steps.len() {
state.completed = true;
return ToolCallResult::ok(
json!({
"step_id": null,
"step_index": state.current_step,
"completed": true,
"action": null,
"ok": true,
"message": "All steps complete"
})
.to_string(),
);
}
let step = state.steps[state.current_step].clone();
let action_str = step_action_label(&step.action);
let step_index = state.current_step;
let total_steps = state.steps.len() as u32;
let _ = crate::mcp::progress::emit_progress(
out,
&crate::mcp::progress::next_progress_token(),
step_index as u32 + 1,
total_steps,
&format!("Step {}/{total_steps}: {}", step_index + 1, step.id),
);
let result = crate::durable_steps::WorkflowResult::Success {
steps_executed: step_index + 1,
total_retries: 0,
};
state.results.push(result);
state.current_step += 1;
let completed = state.current_step >= state.steps.len();
if completed {
state.completed = true;
}
ToolCallResult::ok(
json!({
"step_id": step.id,
"step_index": step_index,
"completed": completed,
"action": action_str,
"ok": true,
"message": format!("Step '{}' dispatched", step.id)
})
.to_string(),
)
}
fn handle_ax_workflow_status(
args: &Value,
workflows: &Arc<Mutex<HashMap<String, WorkflowState>>>,
) -> ToolCallResult {
let Some(name) = args["name"].as_str() else {
return ToolCallResult::error("Missing required field: name");
};
let guard = match workflows.lock() {
Ok(g) => g,
Err(_) => return ToolCallResult::error("Workflow mutex poisoned"),
};
let Some(state) = guard.get(name) else {
return ToolCallResult::error(format!(
"Workflow '{name}' not found — call ax_workflow_create first"
));
};
ToolCallResult::ok(
json!({
"name": name,
"current_step": state.current_step,
"total_steps": state.steps.len(),
"completed": state.completed,
"results_count": state.results.len()
})
.to_string(),
)
}
fn parse_workflow_steps(steps_val: &Value) -> Vec<crate::durable_steps::DurableStep> {
let Some(arr) = steps_val.as_array() else {
return vec![];
};
arr.iter().filter_map(parse_single_workflow_step).collect()
}
fn parse_single_workflow_step(s: &Value) -> Option<crate::durable_steps::DurableStep> {
use crate::durable_steps::{DurableStep, StepAction};
let id = s["id"].as_str()?.to_string();
let action_str = s["action"].as_str()?;
let max_retries = s["max_retries"].as_u64().unwrap_or(2) as u32;
let timeout_ms = s["timeout_ms"].as_u64().unwrap_or(5_000);
let action = match action_str {
"checkpoint" => StepAction::Checkpoint,
"click" => StepAction::Click(s["target"].as_str()?.to_string()),
"type" => StepAction::Type(
s["target"].as_str()?.to_string(),
s["text"].as_str().unwrap_or("").to_string(),
),
"wait" => StepAction::Wait(s["target"].as_str()?.to_string()),
"assert" => StepAction::Assert(s["target"].as_str()?.to_string()),
_ => return None,
};
Some(DurableStep::with_config(
id,
action,
max_retries,
timeout_ms,
))
}
fn step_action_label(action: &crate::durable_steps::StepAction) -> &'static str {
use crate::durable_steps::StepAction;
match action {
StepAction::Click(_) => "click",
StepAction::Type(_, _) => "type",
StepAction::Wait(_) => "wait",
StepAction::Assert(_) => "assert",
StepAction::Checkpoint => "checkpoint",
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use serde_json::json;
use crate::mcp::tools::AppRegistry;
#[test]
fn innovation_tools_registers_nine_tools() {
let tools = super::innovation_tools();
assert_eq!(
tools.len(),
9,
"expected 9 innovation tools, got {}",
tools.len()
);
}
#[test]
fn innovation_tool_names_are_unique() {
let tools = super::innovation_tools();
let names: std::collections::HashSet<&str> = tools.iter().map(|t| t.name).collect();
assert_eq!(
names.len(),
tools.len(),
"duplicate tool names in innovation set"
);
}
#[test]
fn all_innovation_tools_have_non_empty_descriptions() {
for tool in super::innovation_tools() {
assert!(
!tool.description.is_empty(),
"empty description on {}",
tool.name
);
}
}
#[test]
fn all_innovation_tools_have_annotation_fields() {
for tool in super::innovation_tools() {
let _ = tool.annotations.read_only;
let _ = tool.annotations.destructive;
let _ = tool.annotations.idempotent;
let _ = tool.annotations.open_world;
}
}
#[test]
fn innovation_tool_names_match_expected_set() {
let tools = super::innovation_tools();
let names: std::collections::HashSet<&str> = tools.iter().map(|t| t.name).collect();
for expected in &[
"ax_query",
"ax_app_profile",
"ax_test_run",
"ax_track_workflow",
"ax_workflow_create",
"ax_workflow_step",
"ax_workflow_status",
"ax_record",
"ax_analyze",
] {
assert!(names.contains(*expected), "missing tool: {expected}");
}
}
#[test]
fn call_tool_innovation_unknown_name_returns_none() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result = super::call_tool_innovation(
"ax_nonexistent_innovation",
&json!({}),
®istry,
&mut out,
);
assert!(result.is_none());
}
#[test]
fn call_tool_innovation_empty_name_returns_none() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result = super::call_tool_innovation("", &json!({}), ®istry, &mut out);
assert!(result.is_none());
}
#[test]
fn call_tool_innovation_recognises_all_stateless_names() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
for name in &[
"ax_query",
"ax_app_profile",
"ax_test_run",
"ax_track_workflow",
"ax_record",
"ax_analyze",
] {
let result =
super::call_tool_innovation(name, &json!({"app": "Ghost"}), ®istry, &mut out);
assert!(
result.is_some(),
"call_tool_innovation returned None for '{name}'"
);
}
}
#[test]
fn call_tool_innovation_returns_none_for_workflow_tool_names() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
for name in &[
"ax_workflow_create",
"ax_workflow_step",
"ax_workflow_status",
] {
let result =
super::call_tool_innovation(name, &json!({"name": "wf"}), ®istry, &mut out);
assert!(
result.is_none(),
"call_tool_innovation should return None for stateful '{name}'"
);
}
}
#[test]
fn ax_app_profile_missing_app_returns_error() {
let result = super::handle_ax_app_profile(&json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_app_profile_known_app_returns_found_true() {
let result = super::handle_ax_app_profile(&json!({"app": "vscode"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["found"], true);
assert!(v["name"].is_string());
assert!(v["capabilities"].is_array());
assert!(v["selectors"].is_object());
assert!(v["shortcuts"].is_object());
}
#[test]
fn ax_app_profile_case_insensitive_lookup() {
let result = super::handle_ax_app_profile(&json!({"app": "SLACK"}));
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["found"], true, "expected found=true for 'SLACK'");
}
#[test]
fn ax_app_profile_unknown_app_returns_found_false() {
let result = super::handle_ax_app_profile(&json!({"app": "NonExistentApp99"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["found"], false);
}
#[test]
fn ax_app_profile_vscode_has_cdp_port() {
let result = super::handle_ax_app_profile(&json!({"app": "VS Code"}));
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["cdp_port"], 9222);
}
#[test]
fn ax_app_profile_vscode_contains_command_palette_shortcut() {
let result = super::handle_ax_app_profile(&json!({"app": "vscode"}));
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["shortcuts"]["command_palette"], "Meta+Shift+P");
}
#[test]
fn ax_test_run_missing_app_returns_error() {
let mut out = Vec::<u8>::new();
let result = super::handle_ax_test_run(&json!({"test_name": "t"}), &mut out);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_test_run_missing_test_name_returns_error() {
let mut out = Vec::<u8>::new();
let result = super::handle_ax_test_run(&json!({"app": "TextEdit"}), &mut out);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_test_run_empty_case_passes_with_no_steps() {
let mut out = Vec::<u8>::new();
let result = super::handle_ax_test_run(
&json!({"app": "__ghost__", "test_name": "empty_test"}),
&mut out,
);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["passed"], true);
assert_eq!(v["test_name"], "empty_test");
assert_eq!(v["steps_completed"], 0);
}
#[test]
fn ax_test_run_emits_progress_notifications() {
let mut out = Vec::<u8>::new();
let _ = super::handle_ax_test_run(
&json!({"app": "__ghost__", "test_name": "prog_test",
"steps": [{"type": "wait_for_element", "query": "X", "timeout_ms": 1}]}),
&mut out,
);
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("notifications/progress"),
"expected progress notification in output"
);
}
#[test]
fn ax_test_run_with_wait_step_times_out_for_ghost_app() {
let mut out = Vec::<u8>::new();
let result = super::handle_ax_test_run(
&json!({
"app": "__ghost__",
"test_name": "wait_timeout",
"steps": [
{ "type": "wait_for_element", "query": "Button", "timeout_ms": 1 }
]
}),
&mut out,
);
assert!(!result.is_error, "handler itself must not error");
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["passed"], false);
assert!(!v["failures"].as_array().unwrap().is_empty());
}
#[test]
fn parse_test_steps_returns_empty_for_null() {
let steps = super::parse_test_steps(&json!(null));
assert!(steps.is_empty());
}
#[test]
fn parse_test_steps_skips_unknown_step_types() {
let steps = super::parse_test_steps(&json!([
{ "type": "wait_for_element", "query": "OK", "timeout_ms": 100 },
{ "type": "unsupported_future_step" }
]));
assert_eq!(steps.len(), 1);
}
#[test]
fn parse_test_assertions_returns_empty_for_null() {
let assertions = super::parse_test_assertions(&json!(null));
assert!(assertions.is_empty());
}
#[test]
fn ax_track_workflow_missing_app_returns_error() {
let result = super::handle_ax_track_workflow(&json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_track_workflow_record_returns_recorded_true() {
let result =
super::handle_ax_track_workflow(&json!({"app": "TestAppA", "action": "record"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "record");
assert_eq!(v["recorded"], true);
}
#[test]
fn ax_track_workflow_stats_returns_stats_object() {
let result = super::handle_ax_track_workflow(&json!({"app": "AnyApp", "action": "stats"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "stats");
assert!(v["stats"].is_object());
assert!(v["stats"]["total_transitions"].is_number());
assert!(v["stats"]["distinct_apps"].is_number());
}
#[test]
fn ax_track_workflow_detect_returns_workflows_array() {
let result = super::handle_ax_track_workflow(
&json!({"app": "AnyApp", "action": "detect", "min_frequency": 999}),
);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "detect");
assert!(v["workflows"].is_array());
}
#[test]
fn ax_track_workflow_unknown_action_returns_error() {
let result = super::handle_ax_track_workflow(&json!({"app": "App", "action": "teleport"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("teleport"));
}
#[test]
fn ax_track_workflow_default_action_is_record() {
let result = super::handle_ax_track_workflow(&json!({"app": "DefaultActionApp"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "record");
}
#[test]
fn parse_transition_trigger_maps_all_variants() {
use crate::cross_app::TransitionTrigger;
assert_eq!(
super::parse_transition_trigger("user_switch"),
TransitionTrigger::UserSwitch
);
assert_eq!(
super::parse_transition_trigger("automation"),
TransitionTrigger::Automation
);
assert_eq!(
super::parse_transition_trigger("notification"),
TransitionTrigger::Notification
);
assert_eq!(
super::parse_transition_trigger("unknown"),
TransitionTrigger::Unknown
);
assert_eq!(
super::parse_transition_trigger("bogus"),
TransitionTrigger::Unknown
);
}
#[test]
fn ax_query_missing_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_query(&json!({"query": "find the button"}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_query_missing_query_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_query(&json!({"app": "Safari"}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_query_unconnected_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_query(
&json!({"app": "NotConnected", "query": "find the button"}),
®istry,
);
assert!(result.is_error);
assert!(result.content[0].text.contains("not connected"));
}
fn make_workflows(
) -> std::sync::Arc<std::sync::Mutex<std::collections::HashMap<String, super::WorkflowState>>>
{
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()))
}
#[test]
fn ax_workflow_create_missing_name_returns_error() {
let wf = make_workflows();
let result = super::handle_ax_workflow_create(&json!({}), &wf);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_workflow_create_with_no_steps_returns_zero_count() {
let wf = make_workflows();
let result = super::handle_ax_workflow_create(&json!({"name": "empty-wf"}), &wf);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["created"], true);
assert_eq!(v["name"], "empty-wf");
assert_eq!(v["step_count"], 0);
}
#[test]
fn ax_workflow_create_stores_parsed_steps() {
let wf = make_workflows();
let result = super::handle_ax_workflow_create(
&json!({
"name": "two-step-wf",
"steps": [
{ "id": "s1", "action": "click", "target": "OK" },
{ "id": "s2", "action": "checkpoint" }
]
}),
&wf,
);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["step_count"], 2);
let guard = wf.lock().unwrap();
assert!(guard.contains_key("two-step-wf"));
assert_eq!(guard["two-step-wf"].steps.len(), 2);
}
#[test]
fn ax_workflow_step_missing_name_returns_error() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
let result = super::handle_ax_workflow_step(&json!({}), &wf, &mut out);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_workflow_step_unknown_workflow_returns_error() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
let result = super::handle_ax_workflow_step(&json!({"name": "ghost"}), &wf, &mut out);
assert!(result.is_error);
assert!(result.content[0].text.contains("ghost"));
}
#[test]
fn ax_workflow_step_advances_through_all_steps() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
super::handle_ax_workflow_create(
&json!({
"name": "seq-wf",
"steps": [
{ "id": "step-1", "action": "click", "target": "File" },
{ "id": "step-2", "action": "checkpoint" }
]
}),
&wf,
);
let r1 = super::handle_ax_workflow_step(&json!({"name": "seq-wf"}), &wf, &mut out);
let r2 = super::handle_ax_workflow_step(&json!({"name": "seq-wf"}), &wf, &mut out);
let v1: serde_json::Value = serde_json::from_str(&r1.content[0].text).unwrap();
let v2: serde_json::Value = serde_json::from_str(&r2.content[0].text).unwrap();
assert_eq!(v1["completed"], false);
assert_eq!(v1["step_id"], "step-1");
assert_eq!(v2["completed"], true);
assert_eq!(v2["step_id"], "step-2");
}
#[test]
fn ax_workflow_step_emits_progress_notification() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
super::handle_ax_workflow_create(
&json!({"name": "prog-wf", "steps": [{"id": "s1", "action": "checkpoint"}]}),
&wf,
);
let _ = super::handle_ax_workflow_step(&json!({"name": "prog-wf"}), &wf, &mut out);
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("notifications/progress"),
"expected progress notification"
);
}
#[test]
fn ax_workflow_step_on_completed_workflow_returns_completed_true() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
super::handle_ax_workflow_create(
&json!({"name": "done-wf", "steps": [{"id": "s1", "action": "checkpoint"}]}),
&wf,
);
super::handle_ax_workflow_step(&json!({"name": "done-wf"}), &wf, &mut out);
let result = super::handle_ax_workflow_step(&json!({"name": "done-wf"}), &wf, &mut out);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["completed"], true);
}
#[test]
fn ax_workflow_status_missing_name_returns_error() {
let wf = make_workflows();
let result = super::handle_ax_workflow_status(&json!({}), &wf);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_workflow_status_unknown_workflow_returns_error() {
let wf = make_workflows();
let result = super::handle_ax_workflow_status(&json!({"name": "ghost"}), &wf);
assert!(result.is_error);
}
#[test]
fn ax_workflow_status_reflects_step_progress() {
let wf = make_workflows();
super::handle_ax_workflow_create(
&json!({
"name": "progress-wf",
"steps": [
{ "id": "s1", "action": "click", "target": "A" },
{ "id": "s2", "action": "click", "target": "B" },
{ "id": "s3", "action": "checkpoint" }
]
}),
&wf,
);
let mut out = Vec::<u8>::new();
super::handle_ax_workflow_step(&json!({"name": "progress-wf"}), &wf, &mut out);
let result = super::handle_ax_workflow_status(&json!({"name": "progress-wf"}), &wf);
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["name"], "progress-wf");
assert_eq!(v["current_step"], 1);
assert_eq!(v["total_steps"], 3);
assert_eq!(v["completed"], false);
assert_eq!(v["results_count"], 1);
}
#[test]
fn parse_workflow_steps_returns_empty_for_null() {
let steps = super::parse_workflow_steps(&json!(null));
assert!(steps.is_empty());
}
#[test]
fn parse_workflow_steps_skips_unknown_actions() {
let steps = super::parse_workflow_steps(&json!([
{ "id": "s1", "action": "click", "target": "OK" },
{ "id": "s2", "action": "teleport" }
]));
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].id, "s1");
}
#[test]
fn parse_workflow_steps_all_action_variants_parse_correctly() {
let steps = super::parse_workflow_steps(&json!([
{ "id": "c", "action": "click", "target": "Btn" },
{ "id": "t", "action": "type", "target": "Field", "text": "hello" },
{ "id": "w", "action": "wait", "target": "Spinner" },
{ "id": "a", "action": "assert", "target": "Result" },
{ "id": "cp", "action": "checkpoint" }
]));
assert_eq!(steps.len(), 5);
}
#[test]
fn call_workflow_tool_unknown_name_returns_none() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
let result = super::call_workflow_tool("ax_nonexistent", &json!({}), &wf, &mut out);
assert!(result.is_none());
}
#[test]
fn call_workflow_tool_recognises_all_three_names() {
let wf = make_workflows();
let mut out = Vec::<u8>::new();
for name in &[
"ax_workflow_create",
"ax_workflow_step",
"ax_workflow_status",
] {
let result = super::call_workflow_tool(name, &json!({}), &wf, &mut out);
assert!(
result.is_some(),
"call_workflow_tool returned None for '{name}'"
);
}
}
#[test]
fn ax_record_missing_app_returns_error() {
let result = super::handle_ax_record(&json!({}));
assert!(result.is_error);
assert!(result.content[0].text.contains("app"));
}
#[test]
fn ax_record_start_action_sets_recording_true() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "start"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "start");
assert_eq!(v["recording"], true);
assert_eq!(v["event_count"], 0);
}
#[test]
fn ax_record_status_returns_state() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "status"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "status");
assert!(v["recording"].is_boolean());
assert!(v["event_count"].is_number());
}
#[test]
fn ax_record_record_missing_action_type_returns_error() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "record"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("action_type"));
}
#[test]
fn ax_record_stop_returns_events_array() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "stop"}));
assert!(!result.is_error);
let v: serde_json::Value = serde_json::from_str(&result.content[0].text).unwrap();
assert_eq!(v["action"], "stop");
assert_eq!(v["recording"], false);
assert!(v["events"].is_array());
}
#[test]
fn ax_record_unknown_action_returns_error() {
let result = super::handle_ax_record(&json!({"app": "Safari", "action": "teleport"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("teleport"));
}
#[test]
fn ax_record_defaults_action_to_record_when_omitted() {
let result = super::handle_ax_record(&json!({"app": "Safari"}));
assert!(result.is_error);
assert!(result.content[0].text.contains("action_type"));
}
fn make_scene(
nodes: &[(&str, Option<&str>, Option<&str>, Option<&str>)],
) -> crate::intent::SceneGraph {
let mut g = crate::intent::SceneGraph::empty();
for (role, title, label, identifier) in nodes {
let node = crate::intent::SceneNode {
id: crate::intent::NodeId(g.len()),
parent: None,
children: vec![],
role: Some(role.to_string()),
title: title.map(str::to_string),
label: label.map(str::to_string),
value: None,
description: None,
identifier: identifier.map(str::to_string),
bounds: None,
enabled: true,
depth: 0,
};
g.push(node);
}
g
}
#[test]
fn detect_patterns_login_form_detected_when_password_text_button_present() {
let scene = make_scene(&[
("AXSecureTextField", Some("Password"), None, None),
("AXTextField", Some("Username"), None, None),
("AXButton", Some("Sign In"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
patterns.iter().any(|p| p.pattern == "login_form"),
"login_form not detected"
);
}
#[test]
fn detect_patterns_login_form_requires_password_field() {
let scene = make_scene(&[
("AXTextField", Some("Email"), None, None),
("AXButton", Some("Submit"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(
!patterns.iter().any(|p| p.pattern == "login_form"),
"login_form should not be detected without a password field"
);
}
#[test]
fn detect_patterns_search_interface_from_search_field_role() {
let scene = make_scene(&[("AXSearchField", Some("Search"), None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "search_interface"));
}
#[test]
fn detect_patterns_search_interface_from_label() {
let scene = make_scene(&[("AXTextField", None, Some("Search Items"), None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "search_interface"));
}
#[test]
fn detect_patterns_navigation_from_tab_group() {
let scene = make_scene(&[("AXTabGroup", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "navigation"));
}
#[test]
fn detect_patterns_navigation_from_toolbar() {
let scene = make_scene(&[("AXToolbar", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "navigation"));
}
#[test]
fn detect_patterns_table_view_from_ax_table() {
let scene = make_scene(&[("AXTable", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "table_view"));
}
#[test]
fn detect_patterns_table_view_from_ax_grid() {
let scene = make_scene(&[("AXGrid", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "table_view"));
}
#[test]
fn detect_patterns_modal_dialog_from_sheet() {
let scene = make_scene(&[("AXSheet", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "modal_dialog"));
}
#[test]
fn detect_patterns_modal_dialog_from_ax_dialog() {
let scene = make_scene(&[("AXDialog", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "modal_dialog"));
}
#[test]
fn detect_patterns_file_save_dialog_from_sheet_save_cancel() {
let scene = make_scene(&[
("AXSheet", None, None, None),
("AXButton", Some("Save"), None, None),
("AXButton", Some("Cancel"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "file_save_dialog"));
}
#[test]
fn detect_patterns_file_open_dialog_from_sheet_open_cancel() {
let scene = make_scene(&[
("AXSheet", None, None, None),
("AXButton", Some("Open"), None, None),
("AXButton", Some("Cancel"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "file_open_dialog"));
}
#[test]
fn detect_patterns_confirmation_dialog_from_alert_ok_cancel() {
let scene = make_scene(&[
("AXAlert", None, None, None),
("AXButton", Some("OK"), None, None),
("AXButton", Some("Cancel"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "confirmation_dialog"));
}
#[test]
fn detect_patterns_error_alert_from_alert_single_button() {
let scene = make_scene(&[
("AXAlert", None, None, None),
("AXButton", Some("OK"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "error_alert"));
}
#[test]
fn detect_patterns_progress_indicator_detected() {
let scene = make_scene(&[("AXProgressIndicator", None, None, None)]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "progress_indicator"));
}
#[test]
fn detect_patterns_form_from_multiple_text_fields_and_button() {
let scene = make_scene(&[
("AXTextField", Some("First Name"), None, None),
("AXTextField", Some("Last Name"), None, None),
("AXButton", Some("Submit"), None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.iter().any(|p| p.pattern == "form"));
}
#[test]
fn detect_patterns_empty_scene_returns_no_patterns() {
let scene = crate::intent::SceneGraph::empty();
let patterns = super::detect_ui_patterns(&scene);
assert!(patterns.is_empty());
}
#[test]
fn detect_patterns_all_have_confidence_in_range() {
let scene = make_scene(&[
("AXTable", None, None, None),
("AXSearchField", None, None, None),
("AXTabGroup", None, None, None),
]);
let patterns = super::detect_ui_patterns(&scene);
for p in &patterns {
assert!(
(0.0..=1.0).contains(&p.confidence),
"pattern '{}' has out-of-range confidence {}",
p.pattern,
p.confidence
);
}
}
#[test]
fn infer_state_idle_for_empty_scene() {
let scene = crate::intent::SceneGraph::empty();
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Idle);
}
#[test]
fn infer_state_modal_when_sheet_present() {
let scene = make_scene(&[("AXSheet", None, None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Modal);
}
#[test]
fn infer_state_loading_when_progress_indicator_present() {
let scene = make_scene(&[("AXProgressIndicator", None, None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Loading);
}
#[test]
fn infer_state_error_when_alert_present_without_modal() {
let scene = make_scene(&[("AXAlert", None, None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Error);
}
#[test]
fn infer_state_error_from_error_text_label() {
let scene = make_scene(&[("AXStaticText", Some("Error: file not found"), None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Error);
}
#[test]
fn infer_state_auth_required_when_password_field_present_without_modal() {
let scene = make_scene(&[("AXSecureTextField", Some("Password"), None, None)]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::AuthRequired);
}
#[test]
fn infer_state_modal_overrides_loading() {
let scene = make_scene(&[
("AXSheet", None, None, None),
("AXProgressIndicator", None, None, None),
]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Modal);
}
#[test]
fn infer_state_loading_overrides_error() {
let scene = make_scene(&[
("AXProgressIndicator", None, None, None),
("AXAlert", None, None, None),
]);
let state = super::infer_app_state(&scene);
assert_eq!(state, super::AppState::Loading);
}
#[test]
fn app_state_as_str_covers_all_variants() {
use super::AppState;
for (state, expected) in &[
(AppState::Idle, "idle"),
(AppState::Loading, "loading"),
(AppState::Error, "error"),
(AppState::Modal, "modal"),
(AppState::AuthRequired, "auth_required"),
] {
assert_eq!(state.as_str(), *expected);
}
}
#[test]
fn suggest_actions_idle_empty_patterns_returns_empty() {
let suggestions = super::suggest_actions(&[], super::AppState::Idle);
assert!(suggestions.is_empty());
}
#[test]
fn suggest_actions_modal_state_suggests_dismiss() {
let suggestions = super::suggest_actions(&[], super::AppState::Modal);
assert!(suggestions.iter().any(|s| s.tool == "ax_click"));
}
#[test]
fn suggest_actions_loading_state_suggests_wait() {
let suggestions = super::suggest_actions(&[], super::AppState::Loading);
assert!(suggestions.iter().any(|s| s.tool == "ax_wait_idle"));
}
#[test]
fn suggest_actions_error_state_suggests_get_value() {
let suggestions = super::suggest_actions(&[], super::AppState::Error);
assert!(suggestions.iter().any(|s| s.tool == "ax_get_value"));
}
#[test]
fn suggest_actions_auth_required_suggests_type() {
let suggestions = super::suggest_actions(&[], super::AppState::AuthRequired);
assert!(suggestions.iter().any(|s| s.tool == "ax_type"));
}
#[test]
fn suggest_actions_login_form_includes_submit() {
let login = super::UiPattern {
pattern: "login_form",
confidence: 0.9,
};
let suggestions = super::suggest_actions(&[login], super::AppState::Idle);
assert!(
suggestions
.iter()
.any(|s| s.tool == "ax_click" && s.query.to_lowercase().contains("sign")),
"expected ax_click with 'Sign In' query"
);
}
#[test]
fn suggest_actions_search_interface_suggests_type() {
let search = super::UiPattern {
pattern: "search_interface",
confidence: 0.85,
};
let suggestions = super::suggest_actions(&[search], super::AppState::Idle);
assert!(suggestions.iter().any(|s| s.tool == "ax_type"));
}
#[test]
fn suggest_actions_file_save_dialog_suggests_save_click() {
let save_dlg = super::UiPattern {
pattern: "file_save_dialog",
confidence: 0.88,
};
let suggestions = super::suggest_actions(&[save_dlg], super::AppState::Idle);
assert!(suggestions
.iter()
.any(|s| s.tool == "ax_click" && s.query == "Save"));
}
#[test]
fn suggest_actions_confirmation_dialog_includes_cancel() {
let conf = super::UiPattern {
pattern: "confirmation_dialog",
confidence: 0.87,
};
let suggestions = super::suggest_actions(&[conf], super::AppState::Idle);
assert!(suggestions
.iter()
.any(|s| s.tool == "ax_click" && s.query == "Cancel"));
}
#[test]
fn suggest_actions_table_view_suggests_get_value() {
let table = super::UiPattern {
pattern: "table_view",
confidence: 0.88,
};
let suggestions = super::suggest_actions(&[table], super::AppState::Idle);
assert!(suggestions.iter().any(|s| s.tool == "ax_get_value"));
}
#[test]
fn suggest_actions_all_suggestions_have_non_empty_action_and_tool() {
let patterns: Vec<super::UiPattern> = [
"login_form",
"search_interface",
"file_save_dialog",
"file_open_dialog",
"confirmation_dialog",
"error_alert",
"table_view",
"text_editor",
"form",
]
.iter()
.map(|p| super::UiPattern {
pattern: p,
confidence: 0.8,
})
.collect();
let suggestions = super::suggest_actions(&patterns, super::AppState::Idle);
for s in &suggestions {
assert!(!s.action.is_empty(), "suggestion has empty action");
assert!(!s.tool.is_empty(), "suggestion has empty tool");
}
}
#[test]
fn ax_analyze_missing_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_analyze(&json!({}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("Missing"));
}
#[test]
fn ax_analyze_unconnected_app_returns_error() {
let registry = Arc::new(AppRegistry::default());
let result = super::handle_ax_analyze(&json!({"app": "NotConnected"}), ®istry);
assert!(result.is_error);
assert!(result.content[0].text.contains("not connected"));
}
#[test]
fn ax_analyze_dispatch_returns_some_for_valid_call() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result =
super::call_tool_innovation("ax_analyze", &json!({"app": "X"}), ®istry, &mut out);
assert!(
result.is_some(),
"ax_analyze should be handled by call_tool_innovation"
);
}
#[test]
fn ax_analyze_workflow_tools_still_return_none_from_stateless_dispatch() {
let registry = Arc::new(AppRegistry::default());
let mut out = Vec::<u8>::new();
let result = super::call_tool_innovation(
"ax_workflow_create",
&json!({"name": "wf"}),
®istry,
&mut out,
);
assert!(result.is_none());
}
#[test]
fn pattern_to_json_produces_expected_keys() {
let p = super::UiPattern {
pattern: "login_form",
confidence: 0.9,
};
let v = super::pattern_to_json(&p);
assert_eq!(v["pattern"], "login_form");
assert!((v["confidence"].as_f64().unwrap() - 0.9).abs() < f64::EPSILON);
}
#[test]
fn suggestion_to_json_produces_expected_keys() {
let s = super::Suggestion {
action: "Click Save",
tool: "ax_click",
query: "Save",
};
let v = super::suggestion_to_json(&s);
assert_eq!(v["action"], "Click Save");
assert_eq!(v["tool"], "ax_click");
assert_eq!(v["query"], "Save");
}
}